diff --git a/docs/index.html b/docs/index.html index 4ad6781551..9aaac6a701 120000 --- a/docs/index.html +++ b/docs/index.html @@ -1 +1 @@ -2.x/index.html \ No newline at end of file +latest/index.html \ No newline at end of file diff --git a/docs/latest b/docs/latest deleted file mode 120000 index ad98597185..0000000000 --- a/docs/latest +++ /dev/null @@ -1 +0,0 @@ -2.x \ No newline at end of file diff --git a/docs/latest/.buildinfo b/docs/latest/.buildinfo new file mode 100644 index 0000000000..f6b8e25a06 --- /dev/null +++ b/docs/latest/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: d63b3bae12fad402e189a5625ece6110 +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/latest/.nojekyll b/docs/latest/.nojekyll new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/latest/Coding/Changelog.html b/docs/latest/Coding/Changelog.html new file mode 100644 index 0000000000..76f10fd6bf --- /dev/null +++ b/docs/latest/Coding/Changelog.html @@ -0,0 +1,1370 @@ + + + + + + + + + Changelog — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Changelog

+
+

Main branch

+
    +
  • Dependency: Twisted 23.10 (<24) to address upstream CVE alert.

  • +
  • Dependency (potentially Backwards incompatible): Django 4.2 (<4.3). Increases +minimum supported versions of MariaDB, MySQL and PostgreSQL, +see django release nodes

  • +
  • Feature (Backwards incompatible): OptionHandler.set now returns +BaseOption rather than its .value. Instead access .value or .display() +on this return for more control. (Volund)

  • +
  • Feature: (Backwards incompatible): Refactor home page into multiple sub-parts for easier +overriding and composition (johnnyvoruz)

  • +
  • Feature: (Potentially Backwards incompatible): Make build commands +easier to override, with new utility hooks (Volund)

  • +
  • Feature: Allow passing text_kwargs kwarg to EvMore.msg in order to expand +the outputfunc used for every evmore page.

  • +
  • Feature: Allow Discord bot to change user’s nickname and assign +roles for a user on a given server (holl0wstar).

  • +
  • Feature: Make EvenniaAdminSite include custom models better; adds +DJANGO_ADMIN_APP_ORDER and DJANGO_ADMIN_APP_EXCLUDE as modifable +settings.(Volund)

  • +
  • Feature: Handling of the .db._playable_characters helper +methods. Also adds events hooks to modify effects when this list changes (Volund) +avoiding race conditions until server starts (Volund)

  • +
  • Feature: Add $your() and $Your() for actor stance emoting (Volund)

  • +
  • Feature: Add Account.get_character_slots(), +.get_available_character_slots(), .check_available_slots and +at_post_create_character methods to allow better customization of character creation (Volund)

  • +
  • Feature: Refactor/cleanup of Evennia server/portal startup files +into services for easier overriding (Volund)

  • +
  • [Feature][issue3307]: Add support for Attribute-categories when using the monitorhandler +with input funcs to monitor Attribute changes.

  • +
  • Feature: Add Command.cmdset_source, referring to the cmdset each +command was originally pulled from (Volund)

  • +
  • Feature: Add access_type as optional kwarg to lockfuncs (Volund)

  • +
  • Feature: New middleware for checking IP/subnets from requests. New +tools evennia.utils.match_ip and utils.ip_from_request to help. (Volund)

  • +
  • Feature: Refactored almost all default commands to use +Command.msg over the command.caller.msg direct call (more flexible) (Volund)

  • +
  • Feature: Refactor cmdhandler to be more extensible; make cmd merge +a bit more deterministic (Volund)

  • +
  • Feature: Make Fallback AJAX web client more customizable (same as +the websocket client) (Volund)

  • +
  • Fix (Backwards incompatible): Change settings._TEST_ENVIRONMENT to +settings.TEST_ENVIRONMENT to address issues during refactored startup sequence.

  • +
  • Fix: New generate_default_locks() method on typeclasses; +.create and lockhandler.add() will now properly handle emptry strings +(Volund)

  • +
  • Fix: Make sure Global scripts only start in one place,

  • +
  • Fix: Make account-post-login-fail signal fire properly. Add +CUSTOM_SIGNAL for adding one’s own signals (Volund)

  • +
  • Fix: Missing recache step in ObjectSessionHandler (InspectorCaracal)

  • +
  • Fix: Evennia is its own MSSP family now, so we should return that +instead of ‘Custom’ (InspectorCaracal)

  • +
  • Fix: Traceback when creating objects with initial nattributes +(InspectorCaracal)

  • +
  • Fix: Make sure ScriptHandler.add does not fail if passed an +instantiated script. (Volund)

  • +
  • Fix: CmdHelp was using the wrong protocol-key identifier when +routing to the ajax web client.

  • +
  • Fix: Resolve if/elif bug in XYZGrid contrib launch command +(jaborsh)

  • +
  • fix: Made XYZGrid query zcoords in a case-insensitive manner.

  • +
  • Fix: Fix BaseOption.display to always return a string.

  • +
  • Fix: Fix so Portal resets server_restart_mode flag when having +successfully reconnected to the Server after a restart. (InspectorCaracal)

  • +
  • Fix: Fix gendersub contrib to use proper pronoun when referencing +other objects than oneself (InspectorCaracal)

  • +
  • Fix: Fix of monitoring Attributes with categories (scyfris)

  • +
  • Docs & docstrings: Lots of Typo and other fixes (iLPdev, InspectorCaracal, jaborsh, +HouseOfPoe etc)

  • +
  • Beginner tutorial: Cleanup and starting earlier with explaining how to add to +the default cmdsets.

  • +
+
+
+

Evennia 2.3.0

+

Sept 3, 2023

+
    +
  • Feat: EvMenu tooltips for multiple help categories in a node (Seannio).

  • +
  • Feat: Default examine command now also shows an account’s last_login +(michaelfaith84)

  • +
  • Fix: Portal would accidentally start global scripts. (blongden)

  • +
  • Fix: Traceback when printing CounterTrait contrib objects. (InspectorCaracal)

  • +
  • Fix: Typo in evadventure twitch combat’s call of create_combathandler.

  • +
  • Docs: Fix bug in evadventure equipmenthandler blocking creation of npcs. +in-game.

  • +
  • Docs: Plenty of typo fixes (iLPDev, moldikins, others)

  • +
+
+
+

Evennia 2.2.0

+

Aug 6, 2023

+
    +
  • Contrib: Large-language-model (LLM) AI integration; allows NPCs to talk using +responses from an LLM server.

  • +
  • Fix: Make sure at_server_reload is called also on non-repeating Scripts.

  • +
  • Fix: Webclient was not giving a proper error when sending an unknown outputfunc to it.

  • +
  • Fix: Make py command always send strings unless client_raw flag is set.

  • +
  • Fix: Script.start with an integer start_delay caused a traceback.

  • +
  • Fix: Removing “Guest” from the permission-hierarchy setting messed up access.

  • +
  • Docs: Remove doc pages for Travis/TeamCity CI tools, they were both very much +out of date, and Travis is not free for OSS anymore.

  • +
  • Docs: A lot fixes of typos and bugs in tutorials.

  • +
+
+
+

Evennia 2.1.0

+

July 14, 2023

+
    +
  • Fix: The new ExtendedRoom contrib has a bug when dug with no descriptions.

  • +
  • Fix: Clean up get_sides function in evadventure tutorial to return also +the calling combatant with its allies return, to make it easier to reason around.

  • +
  • Feature: Add SSL_CERTIFICATE_ISSUERS setting for customizing Telnet+SSL.

  • +
  • Contrib: Refactored dice.roll contrib function to use safe_eval. Can now +optionally be used as dice.roll("2d10 + 4 > 10"). Old way works too.

  • +
  • Lots of doc updates.

  • +
+
+
+

Evennia 2.0.1

+

June 17, 2023

+
    +
  • Fix: A look-bug in the ExtendedRoom contrib (InspectorCaracal)

  • +
+
+
+

Evennia 2.0.0

+

June 10, 2023

+
    +
  • Possible backwards incompatibility: Updated contrib ExtendedRoom now +supports arbitrary room-states, state-based descriptions, embedded funcparser +tags, details and random messages. While this feature is made to be as +backwards-compatible as possible, so many people depend on this contrib class +that we are updating the major Evennia version to indicate the big changes.

  • +
  • New Contrib: Container typeclass with new commands for storing and retrieving +things inside them (InspectorCaracal)

  • +
  • Feature: Add TagCategoryProperty for setting categories with multiple tags +as properties directly on objects. Complements TagProperty.

  • +
  • Feature: Attribute-support for saving/loading deques with maxlen= set.

  • +
  • Feature: Refactor to provide evennia.SESSION_HANDLER for easier overloading +and less risks of circular import problems (Volund)

  • +
  • Fix: Allow webclient’s goldenlayout UI (default) to understand msg +cls kwarg for customizing the CSS class for every resulting div (friarzen)

  • +
  • Fix: The AttributeHandler.all() now actually accepts category= as +keyword arg, like our docs already claimed it should (Volund)

  • +
  • Fix: TickerHandler store key updating was refactored, fixing an issue with +updating intervals (InspectorCaracal)

  • +
  • Docs: Removed warning about Python3.11 on Windows; upstream Twistd now +supports 3.11 on Windows.

  • +
  • Docs: New Beginner-Tutorial lessons for NPCs, Base-Combat Twitch-Combat and +Turnbased-combat (note that the Beginner tutorial is still WIP).

  • +
  • Stabilize how to make the major update in the docs.

  • +
  • Fix: A lot of other minor bug fixes.

  • +
+
+
+

Evennia 1.3.0

+

Apr 29, 2023

+
    +
  • Feature: Better ANSI color fallbacks (InspectorCaracal).

  • +
  • Feature: Add support for saving deque with maxlen to Attributes (before +maxlen was ignored).

  • +
  • Fix: The username validator did not display errors correctly in web +registration form.

  • +
  • Fix: Components contrib had issues with inherited typeclasses (ChrisLR)

  • +
  • Fix: f-string fix in clothing contrib (aMiss-aWry)

  • +
  • Fix: Have EvenniaTestCase properly flush idmapper cache (bradleymarques)

  • +
  • Tools: More unit tests for scripts (Storsorken)

  • +
  • Docs: Made separate doc pages for Exits, Characters and Rooms. Expanded on how +to change the description of an in-game object with templating.

  • +
  • Docs: A multitude of doc issues and typos fixed.

  • +
+
+
+

Evennia 1.2.1

+

Feb 26, 2023

+
    +
  • Bug fix: Make sure command parser gives precedence to longer cmd-aliases. So +if sending smile at and the cmd smile has alias smile at, the match is +ordered so the result is never interpreted as smile with an argument at.

  • +
  • Bug fix: || (escaped color tags) were parsed too early in help entries, +leading to colors when wanting a | separator

  • +
  • Bug fix: Make sure spawned objects get typeclass_path pointing to the true +location rather than alias (in line with create_object).

  • +
  • Bug fix: Building Menu contrib menu no using Replace over Union mergetype to +avoid clashing with in-game commands while building

  • +
  • Feature: RPSystem contrib sdesc command can now view/delete your sdesc.

  • +
  • Bug fix: Change so script obj = [scriptname|id] is required to manipulate +scripts on objects; script scriptname|id only works on global scripts.

  • +
  • Doc: Add warning about Django-wiki (in wiki tutorial) only supporting +Django <4.0.

  • +
  • Doc: Expanded XYZGrid docstring to clarify MapLink class will not itself +spawn anything, children must define their prototypes explicitly.

  • +
  • Doc: Explained why AttributeProperty.at_get/set will not be called if +accessing the Attribute from the AttributeHandler (bypassing the property)

  • +
  • Bug fix: Evtable options showed spurious empty lines if set without desc

  • +
  • Usage fix: The teleport: and teleport_here: locks where checked in +CmdTeleport, but not actually set on any entities. These locks are now +set with defaults on all objects,characters,rooms and exits.

  • +
+
+
+

Evennia 1.2.0

+

Feb 25, 2023

+
    +
  • Bug fix: TagHandler.get did not consistently cast to string (aMiss-aWry)

  • +
  • Bug fix: Channels hard to manage if given in different case (aMiss-aWry)

  • +
  • Feature: logger.delete_log function for deleting custom logs from inside the +server (aMiss-aWry)

  • +
  • Doc: Nginx setup (InspectorCaracal)

  • +
  • Feature: Add fly/dive commands to XYZGrid contrib to showcase treating its +Z-axis as a full 3D grid. Also fixed minor bug in XYZGrid contrib when using +a Z axis named using an integer rather than a string.

  • +
  • Bug fix: $an() inlinefunc didn’t understand to use ‘an’ words starting with a +capital vowel

  • +
  • Bug fix: Another case of the ‘duplicate Discord bot connections’ bug +(InspectorCaracal)

  • +
  • Fix: Make XYZGrid contrib’s MapParserErrors more succinct

  • +
+
+
+

Evennia 1.1.1

+

Jan 15, 2023

+
    +
  • Bug fix: Better handler malformed alias-regex given to nickhandler. A +regex-relevant character in a channel alias could cause server to not restart.

  • +
  • Feature: Add attr keyword to create_channel. This allows setting +attributes on channels at creation, also from DEFAULT_CHANNELS definitions.

  • +
+
+
+

Evennia 1.1.0

+

Jan 7, 2023

+
    +
  • Stop new registrations with settings.NEW_ACCOUNT_REGISTRATION_ENABLED +(inspectorcaracal)

  • +
  • Bug fixes.

  • +
+
+
+

Evennia 1.0.2

+

Dec 21, 2022

+
    +
  • Bug fix release. Fix more issues with discord bot reconnecting. Some doc +updates.

  • +
+
+
+

Evennia 1.0.1

+

Dec 7, 2022

+
    +
  • Bug fix release. Main issue was reconnect bug for discord bot.

  • +
+
+
+

Evennia 1.0.0

+

2019-2022

+

Changed to using main branch to follow github standard. Old master branch remains +for now but will not be used anymore, so as to not break installs during transition.

+

Also changing to using semantic versioning with this version.

+

Increase requirements: Django 4.1+, Twisted 22.10+ Python 3.10, 3.11. PostgreSQL 11+.

+
    +
  • 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

  • +
  • REST API allows you external access to db objects through HTTP requests (Tehom)

  • +
  • Object.normalize_name and .validate_name added to (by default) enforce latinify +on character name and avoid potential exploits using clever Unicode chars (trhr)

  • +
  • New utils.format_grid for easily displaying long lists of items in a block.

  • +
  • Using lunr search indexing for better help matching and suggestions. Also improve +the main help command’s default listing output.

  • +
  • Added content_types indexing to DefaultObject’s ContentsHandler. (volund)

  • +
  • Made most of the networking classes such as Protocols and the SessionHandlers +replaceable via settings.py for modding enthusiasts. (volund)

  • +
  • The initial_setup.py file can now be substituted in settings.py to customize +initial game database state. (volund)

  • +
  • Added new Traits contrib, converted and expanded from Ainneve project.

  • +
  • Added new requirements_extra.txt file for easily getting all optional dependencies.

  • +
  • Change default multi-match syntax from 1-obj, 2-obj to obj-1, obj-2.

  • +
  • Make object.search support ‘stacks=0’ keyword - if >0, the method will return +N identical matches instead of triggering a multi-match error.

  • +
  • Add tags.has() method for checking if an object has a tag or tags (PR by ChrisLR)

  • +
  • Make IP throttle use Django-based cache system for optional persistence (PR by strikaco)

  • +
  • Renamed Tutorial classes “Weapon” and “WeaponRack” to “TutorialWeapon” and +“TutorialWeaponRack” to prevent collisions with classes in mygame

  • +
  • New crafting contrib, adding a full crafting subsystem (Griatch 2020)

  • +
  • The rplanguage contrib now auto-capitalizes sentences and retains ellipsis (…). This +change means that proper nouns at the start of sentences will not be treated as nouns.

  • +
  • Make MuxCommand lhs/rhslist always be lists, also if empty (used to be the empty string)

  • +
  • Fix typo in UnixCommand contrib, where help was given as --hel.

  • +
  • Latin (la) i18n translation (jamalainm)

  • +
  • Made the evennia dir possible to use without gamedir for purpose of doc generation.

  • +
  • Make Scripts’ timer component independent from script object deletion; can now start/stop +timer without deleting Script. The .persistent flag now only controls if timer survives +reload - Script has to be removed with .delete() like other typeclassed entities.

  • +
  • Add utils.repeat and utils.unrepeat as shortcuts to TickerHandler add/remove, similar +to how utils.delay is a shortcut for TaskHandler add.

  • +
  • Refactor the classic red_button example to use utils.delay/repeat and modern recommended +code style and paradigms instead of relying on Scripts for everything.

  • +
  • Expand CommandTest with ability to check multiple message-receivers; inspired by PR by +user davewiththenicehat. Also add new doc string.

  • +
  • Add central FuncParser as a much more powerful replacement for the old parse_inlinefunc +function.

  • +
  • Attribute/NAttribute got a homogenous representation, using intefaces, both +AttributeHandler and NAttributeHandler has same api now.

  • +
  • Add evennia/utils/verb_conjugation for automatic verb conjugation (English only). This +is useful for implementing actor-stance emoting for sending a string to different targets.

  • +
  • New version of Italian translation (rpolve)

  • +
  • utils.evmenu.ask_yes_no is a helper function that makes it easy to ask a yes/no question +to the user and respond to their input. This complements the existing get_input helper.

  • +
  • Allow sending messages with page/tell without a = if target name contains no spaces.

  • +
  • New FileHelpStorage system allows adding help entries via external files.

  • +
  • sethelp command now warns if shadowing other help-types when creating a new +entry.

  • +
  • Help command now uses view lock to determine if cmd/entry shows in index and +read lock to determine if it can be read. It used to be view in the role +of the latter. Migration swaps these around.

  • +
  • In modules given by settings.PROTOTYPE_MODULES, spawner will now first look for a global +list PROTOTYPE_LIST of dicts before loading all dicts in the module as prototypes.

  • +
  • New Channel-System using the channel command and nicks. Removed the ChannelHandler and the +concept of a dynamically created ChannelCmdSet.

  • +
  • Add Msg.db_receiver_external field to allowe external, string-id message-receivers.

  • +
  • Renamed app.css to website.css for consistency. Removed old prosimii-css files.

  • +
  • Remove mygame/web/static_overrides and -template_overrides, reorganize website/admin/client/api +into a more consistent structure for overriding. Expanded webpage documentation considerably.

  • +
  • REST API list-view was shortened (#2401). New CSS/HTML. Add ReDoc for API autodoc page.

  • +
  • Update and fix dummyrunner with cleaner code and setup.

  • +
  • Made iter_to_str format prettier strings, using Oxford comma.

  • +
  • Added an MXP anchor tag to also support clickable web links.

  • +
  • New tasks command for managing tasks started with utils.delay (PR by davewiththenicehat)

  • +
  • Make help index output clickable for webclient/clients with MXP (PR by davewiththenicehat)

  • +
  • Custom evennia launcher commands (e.g. evennia mycmd foo bar). Add new commands as callables +accepting *args, as settings.EXTRA_LAUNCHER_COMMANDS = {'mycmd': 'path.to.callable', ...}.

  • +
  • New XYZGrid contrib, adding x,y,z grid coordinates with in-game map and +pathfinding. Controlled outside of the game via custom evennia launcher command.

  • +
  • Script.delete has new kwarg stop_task=True, that can be used to avoid +infinite recursion when wanting to set up Script to delete-on-stop.

  • +
  • Command executions now done on copies to make sure yield don’t cause crossovers. Add +Command.retain_instance flag for reusing the same command instance.

  • +
  • The typeclass command will now correctly search the correct database-table for the target +obj (avoids mistakenly assigning an AccountDB-typeclass to a Character etc).

  • +
  • Merged script and scripts commands into one, for both managing global- and +on-object Scripts. Moved CmdScripts and CmdObjects to commands/default/building.py.

  • +
  • Keep GMCP function case if outputfunc starts with capital letter (so cmd_name -> Cmd.Name +but Cmd_nAmE -> Cmd.nAmE). This helps e.g Mudlet’s legacy Client_GUI implementation)

  • +
  • Prototypes now allow setting prototype_parent directly to a prototype-dict. +This makes it easier when dynamically building in-module prototypes.

  • +
  • RPSystem contrib was expanded to support case, so /tall becomes ‘tall man’ +while /Tall becomes ‘Tall man’. One can turn this off if wanting the old style.

  • +
  • Change EvTable fixed-height rebalance algorithm to fill with empty lines at end of +column instead of inserting rows based on cell-size (could be mistaken for a bug).

  • +
  • Split return_appearance hook with helper methods and have it use a template +string in order to make it easier to override.

  • +
  • Add validation question to default account creation.

  • +
  • Add LOCALECHO client option to add server-side echo for clients that does +not support this (useful for getting a complete log).

  • +
  • Make @lazy_property decorator create read/delete-protected properties. This is +because it’s used for handlers, and e.g. self.locks=[] is a common beginner mistake.

  • +
  • Add $pron() inlinefunc for pronoun parsing in actor-stance strings using +msg_contents.

  • +
  • Update defauklt website to show Telnet/SSL/SSH connect info. Added new +SERVER_HOSTNAME setting for use in the server:port stanza.

  • +
  • Changed all at_before/after_* hooks to at_pre/post_* for consistency +across Evennia (the old names still work but are deprecated)

  • +
  • Change settings.COMMAND_DEFAULT_ARG_REGEX default from None to a regex meaning that +a space or / must separate the cmdname and args. This better fits common expectations.

  • +
  • Add confirmation question to ban/unban commands.

  • +
  • Check new teleport and teleport_here lock-types in teleport command to optionally +allow to limit teleportation of an object or to a specific destination.

  • +
  • Add settings.MXP_ENABLED=True and settings.MXP_OUTGOING_ONLY=True as sane defaults, +to avoid known security issues with players entering MXP links.

  • +
  • Add browser name to webclient CLIENT_NAME in session.protocol_flags, e.g. +"Evennia webclient (websocket:firefox)" or "evennia webclient (ajax:chrome)".

  • +
  • TagHandler.add/has(tag=...) kwarg changed to add/has(key=...) for consistency +with other handlers.

  • +
  • Make DefaultScript.delete, DefaultChannel.delete and DefaultAccount.delete return +bool True/False if deletion was successful (like DefaultObject.delete before them)

  • +
  • contrib.custom_gametime days/weeks/months now always starts from 1 (to match +the standard calendar form … there is no month 0 every year after all).

  • +
  • AttributeProperty/NAttributeProperty to allow managing Attributes/NAttributes +on typeclasses in the same way as Django fields.

  • +
  • Give build/system commands a @name to fall back to if the non-@ name is used +by another command (like open and @open. If no duplicate, @ is optional.

  • +
  • Move legacy channel-management commands (ccreate, addcom etc) to a contrib +since their work is now fully handled by the single channel command.

  • +
  • Expand examine command’s code to much more extensible and modular. Show +attribute categories and value types (when not strings).

  • +
  • AttributeHandler.remove(key, return_exception=False, category=None, ...) changed +to .remove(key, category=None, return_exception=False, ...) for consistency.

  • +
  • New command cooldown contrib for making it easier to manage commands using +dynamic cooldowns between uses (owllex)

  • +
  • Restructured contrib/ folder, placing all contribs as separate packages under +subfolders. All imports will need to be updated.

  • +
  • Made MonitorHandler.add/remove support category for monitoring Attributes +with a category (before only key was used, ignoring category entirely).

  • +
  • Move create_* functions into db managers, leaving utils.create only being +wrapper functions (consistent with utils.search). No change of api otherwise.

  • +
  • Add support for $dbref() and $search when assigning an Attribute value +with the set command. This allows assigning real objects from in-game.

  • +
  • Add ability to examine /script and /channel entities with examine command.

  • +
  • Homogenize manager search methods to return querysets and not lists.

  • +
  • Restructure unit tests to always honor default settings; make new parents in +on location for easy use in game dir.

  • +
  • The Lunr search engine used by help excludes common words; the settings-list +LUNR_STOP_WORD_FILTER_EXCEPTIONS can be extended to make sure common names are included.

  • +
  • Add .deserialize() method to _Saver* structures to help completely +decouple structures from database without needing separate import.

  • +
  • Add run_in_main_thread as a helper for those wanting to code server code +from a web view.

  • +
  • Update evennia.utils.logger to use Twisted’s new logging API. No change in Evennia API +except more standard aliases logger.error/info/exception/debug etc can now be used.

  • +
  • Have type/force default to update-mode rather than resetmode and add more verbose +warning when using reset mode.

  • +
  • Attribute storage support defaultdics (Hendher)

  • +
  • Add ObjectParent mixin to default game folder template as an easy, ready-made +way to override features on all ObjectDB-inheriting objects easily. +source location, mimicking behavior of at_pre_move hook - returning False will abort move.

  • +
  • Add TagProperty, AliasProperty and PermissionProperty to assign these +data in a similar way to django fields.

  • +
  • New at_pre_object_receive(obj, source_location) method on Objects. Called on +destination, mimicking behavior of at_pre_move hook - returning False will abort move.

  • +
  • New at_pre_object_leave(obj, destination) method on Objects. Called on

  • +
  • The db pickle-serializer now checks for methods __serialize_dbobjs__ and __deserialize_dbobjs__ +to allow custom packing/unpacking of nested dbobjs, to allow storing in Attribute.

  • +
  • Optimizations to rpsystem contrib performance. Breaking change: .get_sdesc() will +now return None instead of .db.desc if no sdesc is set; fallback in hook (inspectorCaracal)

  • +
  • Reworked text2html parser to avoid problems with stateful color tags (inspectorCaracal)

  • +
  • Simplified EvMenu.options_formatter hook to use EvColumn and f-strings (inspectorcaracal)

  • +
  • Allow # CODE, # HEADER etc as well as #CODE/#HEADER in batchcode +files - this works better with black linting.

  • +
  • Added move_type str kwarg to move_to() calls, optionally identifying the type of +move being done (‘teleport’, ‘disembark’, ‘give’ etc). (volund)

  • +
  • Made RPSystem contrib msg calls pass pose or say as msg-type for use in +e.g. webclient pane filtering where desired. (volund)

  • +
  • Added Account.uses_screenreader(session=None) as a quick shortcut for +finding if a user uses a screenreader (and adjust display accordingly).

  • +
  • Fixed bug in cmdset.remove() where a command could not be deleted by key, +even though doc suggested one could (ChrisLR)

  • +
  • New contrib name_generator for building random real-world based or fantasy-names +based on phonetic rules.

  • +
  • Enable proper serialization of dict subclasses in Attributes (aogier)

  • +
  • object.search fuzzy-matching now uses icontains instead of istartswith +to better match how search works elsewhere (volund)

  • +
  • The .at_traverse hook now receives a exit_obj kwarg, linking back to the +exit triggering the hook (volund)

  • +
  • Contrib buffs for managing temporary and permanent RPG status buffs effects (tegiminis)

  • +
  • New at_server_init() hook called before all other startup hooks for all +startup modes. Used for more generic overriding (volund)

  • +
  • New search lock type used to completely hide an object from being found by +the DefaultObject.search (caller.search) method. (CloudKeeper)

  • +
  • Change setting MULTISESSION_MODE to now only control sessions, not how many +characters can be puppeted simultaneously. New settings now control that.

  • +
  • Add new setting AUTO_CREATE_CHARACTER_WITH_ACCOUNT, a boolean deciding if +the new account should also get a matching character (legacy MUD style).

  • +
  • Add new setting AUTO_PUPPET_ON_LOGIN, boolean deciding if one should +automatically puppet the last/available character on connection (legacy MUD style)

  • +
  • Add new setting MAX_NR_SIMULTANEUS_PUPPETS - how many puppets the account +can run at the same time. Used to limit multi-playing.

  • +
  • Make setting MAX_NR_CHARACTERS interact better with the new settings above.

  • +
  • Allow $search funcparser func to search tags and to accept kwargs for more +powerful searches passed into the regular search functions.

  • +
  • spawner.spawn and linked methods now has a kwarg protfunc_raise_errors +(default True) to disable strict errors on malformed/not-found protfuncs

  • +
  • Improve search performance when having many DB-based prototypes via caching.

  • +
  • Remove the return_parents kwarg of evennia.prototypes.spawner.spawn since it +was inefficient and unused.

  • +
  • Made all id fields BigAutoField for all databases. (owllex)

  • +
  • EvForm refactored. New literals mapping, for literal mappings into the +main template (e.g. for single-character replacements).

  • +
  • EvForm cells kwarg now accepts EvCells with custom formatting options +(mainly for custom align/valign). EvCells now makes use of utils.justify.

  • +
  • utils.justify now supports align="a" (absolute alignments. This keeps +the given left indent but crops/fills to the width. Used in EvCells.

  • +
  • EvTable now supports passing EvColumns as a list directly, (EvTable(table=[colA,colB]))

  • +
  • Add tags= search criterion to DefaultObject.search.

  • +
  • Add AT_EXIT_TRAVERSE signal, firing when an exit is traversed.

  • +
  • Add integration between Evennia and Discord channels (PR by Inspector Cararacal)

  • +
  • Support for using a Godot-powered client with Evennia (PR by ChrisLR)

  • +
  • Added German translation (patch by Zhuraj)

  • +
+
+
+

Evennia 0.9.5

+
+

2019-2020 +Released 2020-11-14. +Transitional release, including new doc system.

+
+

Backported from develop: Python 3.8, 3.9 support. Django 3.2+ support, Twisted 21+ support.

+
    +
  • 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.

  • +
  • Webclient default to a single input pane instead of two. Now defaults to no help-popup.

  • +
  • Webclient fix of prompt display

  • +
  • Webclient multimedia support for relaying images, video and sounds via +.msg(image=URL), .msg(video=URL) +and .msg(audio=URL)

  • +
  • Add Spanish translation (fermuch)

  • +
  • Expand GLOBAL_SCRIPTS container to always start scripts and to include all +global scripts regardless of how they were created.

  • +
  • Change settings to always use lists instead of tuples, to make mutable +settings easier to add to. (#1912)

  • +
  • Make new CHANNEL_MUDINFO setting for specifying the mudinfo channel

  • +
  • Make CHANNEL_CONNECTINFO take full channel definition

  • +
  • Make DEFAULT_CHANNELS list auto-create channels missing at reload

  • +
  • Webclient ANSI->HTML parser updated. Webclient line width changed from 1.6em to 1.1em +to better make ANSI graphics look the same as for third-party clients

  • +
  • AttributeHandler.get(return_list=True) will return [] if there are no +Attributes instead of [None].

  • +
  • Remove pillow requirement (install especially if using imagefield)

  • +
  • Add Simplified Korean translation (aceamro)

  • +
  • Show warning on start -l if settings contains values unsafe for production.

  • +
  • Make code auto-formatted with Black.

  • +
  • Make default set command able to edit nested structures (PR by Aaron McMillan)

  • +
  • Allow running Evennia test suite from core repo with make test.

  • +
  • Return store_key from TickerHandler.add and add store_key as a kwarg to +the TickerHandler.remove method. This makes it easier to manage tickers.

  • +
  • EvMore auto-justify now defaults to False since this works better with all types +of texts (such as tables). New justify bool. Old justify_kwargs remains +but is now only used to pass extra kwargs into the justify function.

  • +
  • EvMore text argument can now also be a list or a queryset. Querysets will be +sliced to only return the required data per page.

  • +
  • Improve performance of find and objects commands on large data sets (strikaco)

  • +
  • New CHANNEL_HANDLER_CLASS setting allows for replacing the ChannelHandler entirely.

  • +
  • Made py interactive mode support regular quit() and more verbose.

  • +
  • Made Account.options.get accept default=None kwarg to mimic other uses of get. Set +the new raise_exception boolean if ranting to raise KeyError on a missing key.

  • +
  • Moved behavior of unmodified Command and MuxCommand .func() to new +.get_command_info() method for easier overloading and access. (Volund)

  • +
  • Removed unused CYCLE_LOGFILES setting. Added SERVER_LOG_DAY_ROTATION +and SERVER_LOG_MAX_SIZE (and equivalent for PORTAL) to control log rotation.

  • +
  • Addded inside_rec lockfunc - if room is locked, the normal inside() lockfunc will +fail e.g. for your inventory objs (since their loc is you), whereas this will pass.

  • +
  • RPSystem contrib’s CmdRecog will now list all recogs if no arg is given. Also multiple +bugfixes.

  • +
  • Remove dummy@example.com as a default account email when unset, a string is no longer +required by Django.

  • +
  • Fixes to spawn, make updating an existing prototype/object work better. Add /raw switch +to spawn command to extract the raw prototype dict for manual editing.

  • +
  • list_to_string is now iter_to_string (but old name still works as legacy alias). It will +now accept any input, including generators and single values.

  • +
  • EvTable should now correctly handle columns with wider asian-characters in them.

  • +
  • Update Twisted requirement to >=2.3.0 to close security vulnerability

  • +
  • Add $random inlinefunc, supports minval,maxval arguments that can be ints and floats.

  • +
  • Add evennia.utils.inlinefuncs.raw(<str>) as a helper to escape inlinefuncs in a string.

  • +
  • Make CmdGet/Drop/Give give proper error if obj.move_to returns False.

  • +
  • Make Object/Room/Exit.create’s account argument optional. If not given, will set perms +to that of the object itself (along with normal Admin/Dev permission).

  • +
  • Make INLINEFUNC_STACK_MAXSIZE default visible in settings_default.py.

  • +
  • Change how ic finds puppets; non-priveleged users will use _playable_characters list as +candidates, Builders+ will use list, local search and only global search if no match found.

  • +
  • Make cmd.at_post_cmd() always run after cmd.func(), even when the latter uses delays +with yield.

  • +
  • EvMore support for db queries and django paginators as well as easier to override for custom +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.

  • +
  • Add Command.client_height() method to match existing .client_width (stricako)

  • +
  • Include more Web-client info in session.protocol_flags.

  • +
  • Fixes in multi-match situations - don’t allow finding/listing multimatches for 3-box when +only two boxes in location.

  • +
  • Fix for TaskHandler with proper deferred returns/ability to cancel etc (PR by davewiththenicehat)

  • +
  • Add PermissionHandler.check method for straight string perm-checks without needing lockstrings.

  • +
  • Add evennia.utils.utils.strip_unsafe_input for removing html/newlines/tags from user input. The +INPUT_CLEANUP_BYPASS_PERMISSIONS is a list of perms that bypass this safety stripping.

  • +
  • Make default set and examine commands aware of Attribute categories.

  • +
+
+
+

Evennia 0.9

+
+

2018-2019 +Released Oct 2019

+
+
+

Distribution

+
    +
  • New requirement: Python 3.7 (py2.7 support removed)

  • +
  • Django 2.1

  • +
  • Twisted 19.2.1

  • +
  • Autobahn websockets (removed old tmwx)

  • +
  • Docker image updated

  • +
+
+
+

Commands

+
    +
  • Remove @-prefix from all default commands (prefixes still work, optional)

  • +
  • Removed default @delaccount command, incorporating as @account/delete instead. Added confirmation +question.

  • +
  • Add new @force command to have another object perform a command.

  • +
  • Add the Portal uptime to the @time command.

  • +
  • Make the @link command first make a local search before a global search.

  • +
  • Have the default Unloggedin-look command look for optional connection_screen() callable in +mygame/server/conf/connection_screen.py. This allows for more flexible welcome screens +that are calculated on the fly.

  • +
  • @py command now defaults to escaping html tags in its output when viewing in the webclient. +Use new /clientraw switch to get old behavior (issue #1369).

  • +
  • Shorter and more informative, dynamic, listing of on-command vars if not +setting func() in child command class.

  • +
  • New Command helper methods

    +
      +
    • .client_width() returns client width of the session running the command.

    • +
    • .styled_table(*args, **kwargs) returns a formatted evtable styled by user’s options

    • +
    • .style_header(*args, **kwargs) creates styled header entry

    • +
    • .style_separator(*args, **kwargs) ” separator

    • +
    • .style_footer(*args, **kwargs) ” footer

    • +
    +
  • +
+
+
+

Web

+
    +
  • Change webclient from old txws version to use more supported/feature-rich Autobahn websocket library

  • +
+
+

Evennia game index

+
    +
  • Made Evennia game index client a part of core - now configured from settings file (old configs +need to be moved)

  • +
  • The evennia connections command starts a wizard that helps you connect your game to the game index.

  • +
  • The game index now accepts games with no public telnet/webclient info (for early prototypes).

  • +
+
+
+

New golden-layout based Webclient UI (@friarzen)

+
    +
  • Features

    +
      +
    • Much slicker behavior and more professional look

    • +
    • Allows tabbing as well as click and drag of panes in any grid position

    • +
    • Renaming tabs, assignments of data tags and output types are simple per-pane menus now

    • +
    • Any number of input panes, with separate histories

    • +
    • Button UI (disabled in JS by default)

    • +
    +
  • +
+
+
+

Web/Django standard initiative (@strikaco)

+
    +
  • Features

    +
      +
    • Adds a series of web-based forms and generic class-based views

      +
        +
      • Accounts

        +
          +
        • Register - Enhances registration; allows optional collection of email address

        • +
        • Form - Adds a generic Django form for creating Accounts from the web

        • +
        +
      • +
      • Characters

        +
          +
        • Create - Authenticated users can create new characters from the website (requires associated form)

        • +
        • Detail - Authenticated and authorized users can view select details about characters

        • +
        • List - Authenticated and authorized users can browse a list of all characters

        • +
        • Manage - Authenticated users can edit or delete owned characters from the web

        • +
        • Form - Adds a generic Django form for creating characters from the web

        • +
        +
      • +
      • Channels

        +
          +
        • Detail - Authorized users can view channel logs from the web

        • +
        • List - Authorized users can browse a list of all channels

        • +
        +
      • +
      • Help Entries

        +
          +
        • Detail - Authorized users can view help entries from the web

        • +
        • List - Authorized users can browse a list of all help entries from the web

        • +
        +
      • +
      +
    • +
    • Navbar changes

      +
        +
      • Characters - Link to character list

      • +
      • Channels - Link to channel list

      • +
      • Help - Link to help entry list

      • +
      • Puppeting

        +
          +
        • Users can puppet their own characters within the context of the website

        • +
        +
      • +
      • Dropdown

        +
          +
        • Link to create characters

        • +
        • Link to manage characters

        • +
        • Link to quick-select puppets

        • +
        • Link to password change workflow

        • +
        +
      • +
      +
    • +
    +
  • +
  • Functions

    +
      +
    • Updates Bootstrap to v4 stable

    • +
    • Enables use of Django Messages framework to communicate with users in browser

    • +
    • Implements webclient/website _shared_login functionality as Django middleware

    • +
    • ‘account’ and ‘puppet’ are added to all request contexts for authenticated users

    • +
    • Adds unit tests for all web views

    • +
    +
  • +
  • Cosmetic

    +
      +
    • Prettifies Django ‘forgot password’ workflow (requires SMTP to actually function)

    • +
    • Prettifies Django ‘change password’ workflow

    • +
    +
  • +
  • Bugfixes

    +
      +
    • Fixes bug on login page where error messages were not being displayed

    • +
    • Remove strvalue field from admin; it made no sense to have here, being an optimization field +for internal use.

    • +
    +
  • +
+
+
+
+

Prototypes

+
    +
  • evennia.prototypes.save_prototype now takes the prototype as a normal +argument (prototype) instead of having to give it as **prototype.

  • +
  • evennia.prototypes.search_prototype has a new kwarg require_single=False that +raises a KeyError exception if query gave 0 or >1 results.

  • +
  • evennia.prototypes.spawner can now spawn by passing a prototype_key

  • +
+
+
+

Typeclasses

+
    +
  • Add new methods on all typeclasses, useful specifically for object handling from the website/admin:

    +
      +
    • web_get_admin_url(): Returns the path to the object detail page in the Admin backend.

    • +
    • web_get_create_url(): Returns the path to the typeclass’ creation page on the website, if implemented.

    • +
    • web_get_absolute_url(): Returns the path to the object’s detail page on the website, if implemented.

    • +
    • web_get_update_url(): Returns the path to the object’s update page on the website, if implemented.

    • +
    • web_get_delete_url(): Returns the path to the object’s delete page on the website, if implemented.

    • +
    +
  • +
  • All typeclasses have new helper class method create, which encompasses useful functionality +that used to be embedded for example in the respective @create or @connect commands.

  • +
  • DefaultAccount now has new class methods implementing many things that used to be in unloggedin +commands (these can now be customized on the class instead):

    +
      +
    • is_banned(): Checks if a given username or IP is banned.

    • +
    • get_username_validators: Return list of validators for username validation (see +settings.AUTH_USERNAME_VALIDATORS)

    • +
    • authenticate: Method to check given username/password.

    • +
    • normalize_username: Normalizes names so (for Unicode environments) users cannot mimic existing usernames by replacing select characters with visually-similar Unicode chars.

    • +
    • validate_username: Mechanism for validating a username based on predefined Django validators.

    • +
    • validate_password: Mechanism for validating a password based on predefined Django validators.

    • +
    • set_password: Apply password to account, using validation checks.

    • +
    +
  • +
  • AttributeHandler.remove and TagHandler.remove can now be used to delete by-category. If neither +key nor category is given, they now work the same as .clear().

  • +
+
+
+

Protocols

+
    +
  • Support for Grapevine MUD-chat network (“channels” supported)

  • +
+
+
+

Server

+
    +
  • Convert ServerConf model to store its values as a Picklefield (same as +Attributes) instead of using a custom solution.

  • +
  • OOB: Add support for MSDP LIST, REPORT, UNREPORT commands (re-mapped to msdp_list, +msdp_report, msdp_unreport, inlinefuncs)

  • +
  • Added evennia.ANSIString to flat API.

  • +
  • Server/Portal log files now cycle to names on the form server_.log_19_03_08_ instead of server.log___19.3.8, retaining +unix file sorting order.

  • +
  • Django signals fire for important events: Puppet/Unpuppet, Object create/rename, Login, +Logout, Login fail Disconnect, Account create/rename

  • +
+
+
+

Settings

+
    +
  • GLOBAL_SCRIPTS - dict defining typeclasses of global scripts to store on the new +evennia.GLOBAL_SCRIPTS container. These will auto-start when Evennia start and will always +exist.

  • +
  • OPTIONS_ACCOUNTS_DEFAULT - option dict with option defaults and Option classes

  • +
  • OPTION_CLASS_MODULES - classes representing an on-Account Option, on special form

  • +
  • VALIDATOR_FUNC_MODULES - (general) text validator functions, for verifying an input +is on a specific form.

  • +
+
+
+

Utils

+
    +
  • evennia launcher now fully handles all django-admin commands, like running tests in parallel.

  • +
  • evennia.utils.create.account now also takes tags and attrs keywords.

  • +
  • evennia.utils.interactive decorator can now allow you to use yield(secs) to pause operation +in any function, not just in Command.func. Likewise, response = yield(question) will work +if the decorated function has an argument or kwarg caller.

  • +
  • Added many more unit tests.

  • +
  • Swap argument order of evennia.set_trace to set_trace(term_size=(140, 40), debugger='auto') +since the size is more likely to be changed on the command line.

  • +
  • utils.to_str(text, session=None) now acts as the old utils.to_unicode (which was removed). +This converts to the str() type (not to a byte-string as in Evennia 0.8), trying different +encodings. This function will also force-convert any object passed to it into a string (so +force_string flag was removed and assumed always set).

  • +
  • utils.to_bytes(text, session=None) replaces the old utils.to_str() functionality and converts +str to bytes.

  • +
  • evennia.MONITOR_HANDLER.all now takes keyword argument obj to only retrieve monitors from that specific +Object (rather than all monitors in the entire handler).

  • +
  • Support adding \f in command doc strings to force where EvMore puts page breaks.

  • +
  • Validation Functions now added with standard API to homogenize user input validation.

  • +
  • Option Classes added to make storing user-options easier and smoother.

  • +
  • evennia.VALIDATOR_CONTAINER and evennia.OPTION_CONTAINER added to load these.

  • +
+
+
+

Contribs

+
    +
  • Evscaperoom - a full puzzle engine for making multiplayer escape rooms in Evennia. Used to make +the entry for the MUD-Coder’s Guild’s 2019 Game Jam with the theme “One Room”, where it ranked #1.

  • +
  • Evennia game-index client no longer a contrib - moved into server core and configured with new +setting GAME_INDEX_ENABLED.

  • +
  • The extended_room contrib saw some backwards-incompatible refactoring:

    +
      +
    • All commands now begin with CmdExtendedRoom. So before it was CmdExtendedLook, now +it’s CmdExtendedRoomLook etc.

    • +
    • The detail command was broken out of the desc command and is now a new, stand-alone command +CmdExtendedRoomDetail. This was done to make things easier to extend and to mimic how the detail +command works in the tutorial-world.

    • +
    • The detail command now also supports deleting details (like the tutorial-world version).

    • +
    • The new ExtendedRoomCmdSet includes all the extended-room commands and is now the recommended way +to install the extended-room contrib.

    • +
    +
  • +
  • Reworked menu_login contrib to use latest EvMenu standards. Now also supports guest logins.

  • +
  • Mail contrib was refactored to have optional Command classes CmdMail for OOC+IC mail (added +to the CharacterCmdSet and CmdMailCharacter for IC-only mailing between chars (added to CharacterCmdSet)

  • +
+
+
+

Translations

+
    +
  • Simplified chinese, courtesy of user MaxAlex.

  • +
+
+
+
+

Evennia 0.8

+
+

2017-2018 +Released Nov 2018

+
+
+

Requirements

+
    +
  • Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0

  • +
  • Add inflect dependency for automatic pluralization of object names.

  • +
+
+
+

Server/Portal

+
    +
  • Removed evennia_runner, completely refactor evennia_launcher.py (the ‘evennia’ program) +with different functionality).

  • +
  • Both Portal/Server are now stand-alone processes (easy to run as daemon)

  • +
  • Made Portal the AMP Server for starting/restarting the Server (the AMP client)

  • +
  • Dynamic logging now happens using evennia -l rather than by interactive mode.

  • +
  • Made AMP secure against erroneous HTTP requests on the wrong port (return error messages).

  • +
  • The evennia istart option will start/switch the Server in foreground (interactive) mode, where it logs +to terminal and can be stopped with Ctrl-C. Using evennia reload, or reloading in-game, will +return Server to normal daemon operation.

  • +
  • For validating passwords, use safe Django password-validation backend instead of custom Evennia one.

  • +
  • Alias evennia restart to mean the same as evennia reload.

  • +
+
+
+

Prototype changes

+
    +
  • New OLC started from olc command for loading/saving/manipulating prototypes in a menu.

  • +
  • Moved evennia/utils/spawner.py into the new evennia/prototypes/ along with all new +functionality around prototypes.

  • +
  • A new form of prototype - database-stored prototypes, editable from in-game, was added. The old, +module-created prototypes remain as read-only prototypes.

  • +
  • All prototypes must have a key prototype_key identifying the prototype in listings. This is +checked to be server-unique. Prototypes created in a module will use the global variable name they +are assigned to if no prototype_key is given.

  • +
  • Prototype field prototype was renamed to prototype_parent to avoid mixing terms.

  • +
  • All prototypes must either have typeclass or prototype_parent defined. If using +prototype_parent, typeclass must be defined somewhere in the inheritance chain. This is a +change from Evennia 0.7 which allowed ‘mixin’ prototypes without typeclass/prototype_key. To +make a mixin now, give it a default typeclass, like evennia.objects.objects.DefaultObject and just +override in the child as needed.

  • +
  • Spawning an object using a prototype will automatically assign a new tag to it, named the same as +the prototype_key and with the category from_prototype.

  • +
  • The spawn command was extended to accept a full prototype on one line.

  • +
  • The spawn command got the /save switch to save the defined prototype and its key

  • +
  • The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes.

  • +
+
+
+

EvMenu

+
    +
  • Added EvMenu.helptext_formatter(helptext) to allow custom formatting of per-node help.

  • +
  • Added evennia.utils.evmenu.list_node decorator for turning an EvMenu node into a multi-page listing.

  • +
  • A goto option callable returning None (rather than the name of the next node) will now rerun the +current node instead of failing.

  • +
  • Better error handling of in-node syntax errors.

  • +
  • Improve dedent of default text/helptext formatter. Right-strip whitespace.

  • +
  • Add debug option when creating menu - this turns off persistence and makes the menudebug +command available for examining the current menu state.

  • +
+
+
+

Webclient

+
    +
  • Webclient now uses a plugin system to inject new components from the html file.

  • +
  • Split-windows - divide input field into any number of horizontal/vertical panes and +assign different types of server messages to them.

  • +
  • Lots of cleanup and bug fixes.

  • +
  • Hot buttons plugin (friarzen) (disabled by default).

  • +
+
+
+

Locks

+
    +
  • New function evennia.locks.lockhandler.check_lockstring. This allows for checking an object +against an arbitrary lockstring without needing the lock to be stored on an object first.

  • +
  • New function evennia.locks.lockhandler.validate_lockstring allows for stand-alone validation +of a lockstring.

  • +
  • New function evennia.locks.lockhandler.get_all_lockfuncs gives a dict {“name”: lockfunc} for +all available lock funcs. This is useful for dynamic listings.

  • +
+
+
+

Utils

+
    +
  • Added new columnize function for easily splitting text into multiple columns. At this point it +is not working too well with ansi-colored text however.

  • +
  • Extend the dedent function with a new baseline_index kwarg. This allows to force all lines to +the indentation given by the given line regardless of if other lines were already a 0 indentation. +This removes a problem with the original textwrap.dedent which will only dedent to the least +indented part of a text.

  • +
  • Added exit_cmd to EvMore pager, to allow for calling a command (e.g. ‘look’) when leaving the pager.

  • +
  • get_all_typeclasses will return dict {"path": typeclass, ...} for all typeclasses available +in the system. This is used by the new @typeclass/list subcommand (useful for builders etc).

  • +
  • evennia.utils.dbserialize.deserialize(obj) is a new helper function to completely disconnect +a mutable recovered from an Attribute from the database. This will convert all nested _Saver* +classes to their plain-Python counterparts.

  • +
+
+
+

General

+
    +
  • Start structuring the CHANGELOG to list features in more detail.

  • +
  • Docker image evennia/evennia:develop is now auto-built, tracking the develop branch.

  • +
  • Inflection and grouping of multiple objects in default room (an box, three boxes)

  • +
  • evennia.set_trace() is now a shortcut for launching pdb/pudb on a line in the Evennia event loop.

  • +
  • Removed the enforcing of MAX_NR_CHARACTERS=1 for MULTISESSION_MODE 0 and 1 by default.

  • +
  • Add evennia.utils.logger.log_sec for logging security-related messages (marked SS in log).

  • +
+
+
+

Contribs

+
    +
  • Auditing (Johnny): Log and filter server input/output for security purposes

  • +
  • Build Menu (vincent-lg): New @edit command to edit object properties in a menu.

  • +
  • Field Fill (Tim Ashley Jenkins): Wraps EvMenu for creating submittable forms.

  • +
  • Health Bar (Tim Ashley Jenkins): Easily create colorful bars/meters.

  • +
  • Tree select (Fluttersprite): Wrap EvMenu to create a common type of menu from a string.

  • +
  • Turnbattle suite (Tim Ashley Jenkins)- the old turnbattle.py was moved into its own +turnbattle/ package and reworked with many different flavors of combat systems:

  • +
  • tb_basic - The basic turnbattle system, with initiative/turn order attack/defense/damage.

  • +
  • tb_equip - Adds weapon and armor, wielding, accuracy modifiers.

  • +
  • tb_items - Extends tb_equip with item use with conditions/status effects.

  • +
  • tb_magic - Extends tb_equip with spellcasting.

  • +
  • tb_range - Adds system for abstract positioning and movement.

  • +
  • The extended_room contrib saw some backwards-incompatible refactoring:

    +
      +
    • All commands now begin with CmdExtendedRoom. So before it was CmdExtendedLook, now +it’s CmdExtendedRoomLook etc.

    • +
    • The detail command was broken out of the desc command and is now a new, stand-alone command +CmdExtendedRoomDetail. This was done to make things easier to extend and to mimic how the detail +command works in the tutorial-world.

    • +
    • The detail command now also supports deleting details (like the tutorial-world version).

    • +
    • The new ExtendedRoomCmdSet includes all the extended-room commands and is now the recommended way +to install the extended-room contrib.

    • +
    +
  • +
  • Updates and some cleanup of existing contribs.

  • +
+
+
+

Internationalization

+
    +
  • Polish translation by user ogotai

  • +
+
+
+
+
+

Overview-Changelogs

+
+

These are changelogs from a time before we used formal version numbers.

+
+
+

Sept 2017:

+

Release of Evennia 0.7; upgrade to Django 1.11, change ‘Player’ to +‘Account’, rework the website template and a slew of other updates. +Info on what changed and how to migrate is found here: +https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ

+
+
+

Feb 2017:

+

New devel branch created, to lead up to Evennia 0.7.

+
+
+

Dec 2016:

+

Lots of bugfixes and considerable uptick in contributors. Unittest coverage +and PEP8 adoption and refactoring.

+
+
+

May 2016:

+

Evennia 0.6 with completely reworked Out-of-band system, making +the message path completely flexible and built around input/outputfuncs. +A completely new webclient, split into the evennia.js library and a +gui library, making it easier to customize.

+
+
+

Feb 2016:

+

Added the new EvMenu and EvMore utilities, updated EvEdit and cleaned up +a lot of the batchcommand functionality. Started work on new Devel branch.

+
+
+

Sept 2015:

+

Evennia 0.5. Merged devel branch, full library format implemented.

+
+
+

Feb 2015:

+

Development currently in devel/ branch. Moved typeclasses to use +django’s proxy functionality. Changed the Evennia folder layout to a +library format with a stand-alone launcher, in preparation for making +an ‘evennia’ pypy package and using versioning. The version we will +merge with will likely be 0.5. There is also work with an expanded +testing structure and the use of threading for saves. We also now +use Travis for automatic build checking.

+
+
+

Sept 2014:

+

Updated to Django 1.7+ which means South dependency was dropped and +minimum Python version upped to 2.7. MULTISESSION_MODE=3 was added +and the web customization system was overhauled using the latest +functionality of django. Otherwise, mostly bug-fixes and +implementation of various smaller feature requests as we got used +to github. Many new users have appeared.

+
+
+

Jan 2014:

+

Moved Evennia project from Google Code to github.com/evennia/evennia.

+
+
+

Nov 2013:

+

Moved the internal webserver into the Server and added support for +out-of-band protocols (MSDP initially). This large development push +also meant fixes and cleanups of the way attributes were handled. +Tags were added, along with proper handlers for permissions, nicks +and aliases.

+
+
+

May 2013:

+

Made players able to control more than one Character at the same +time, through the MULTISESSION_MODE=2 addition. This lead to a lot +of internal changes for the server.

+
+
+

Oct 2012:

+

Changed Evennia from the Modified Artistic 1.0 license to the more +standard and permissive BSD license. Lots of updates and bug fixes as +more people start to use it in new ways. Lots of new caching and +speed-ups.

+
+
+

March 2012:

+

Evennia’s API has changed and simplified slightly in that the +base-modules where removed from game/gamesrc. Instead admins are +encouraged to explicitly create new modules under game/gamesrc/ when +they want to implement their game - gamesrc/ is empty by default +except for the example folders that contain template files to use for +this purpose. We also added the ev.py file, implementing a new, flat +API. Work is ongoing to add support for mud-specific telnet +extensions, notably the MSDP and GMCP out-of-band extensions. On the +community side, evennia’s dev blog was started and linked on planet +Mud-dev aggregator.

+
+
+

Nov 2011:

+

After creating several different proof-of-concept game systems (in +contrib and privately) as well testing lots of things to make sure the +implementation is basically sound, we are declaring Evennia out of +Alpha. This can mean as much or as little as you want, admittedly - +development is still heavy but the issue list is at an all-time low +and the server is slowly stabilizing as people try different things +with it. So Beta it is!

+
+
+

Aug 2011:

+

Split Evennia into two processes: Portal and Server. After a lot of +work trying to get in-memory code-reloading to work, it’s clear this +is not Python’s forte - it’s impossible to catch all exceptions, +especially in asynchronous code like this. Trying to do so results in +hackish, flakey and unstable code. With the Portal-Server split, the +Server can simply be rebooted while players connected to the Portal +remain connected. The two communicates over twisted’s AMP protocol.

+
+
+

May 2011:

+

The new version of Evennia, originally hitting trunk in Aug2010, is +maturing. All commands from the pre-Aug version, including IRC/IMC2 +support works again. An ajax web-client was added earlier in the year, +including moving Evennia to be its own webserver (no more need for +Apache or django-testserver). Contrib-folder added.

+
+
+

Aug 2010:

+

Evennia-griatch-branch is ready for merging with trunk. This marks a +rather big change in the inner workings of the server, such as the +introduction of TypeClasses and Scripts (as compared to the old +ScriptParents and Events) but should hopefully bring everything +together into one consistent package as code development continues.

+
+
+

May 2010:

+

Evennia is currently being heavily revised and cleaned from +the years of gradual piecemeal development. It is thus in a very +‘Alpha’ stage at the moment. This means that old code snippets +will not be backwards compatabile. Changes touch almost all +parts of Evennia’s innards, from the way Objects are handled +to Events, Commands and Permissions.

+
+
+

April 2010:

+

Griatch takes over Maintainership of the Evennia project from +the original creator Greg Taylor.

+
+
+
+

Older

+

Earlier revisions, with previous maintainer, used SVN on Google Code +and have no changelogs.

+

First commit (Evennia’s birthday) was November 20, 2006.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Coding-Overview.html b/docs/latest/Coding/Coding-Overview.html new file mode 100644 index 0000000000..91086d41b7 --- /dev/null +++ b/docs/latest/Coding/Coding-Overview.html @@ -0,0 +1,243 @@ + + + + + + + + + Coding and development help — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Coding and development help

+

This documentation aims to help you set up a sane development environment to +make your game, also if you never coded before.

+

See also the Beginner Tutorial.

+ +
+

Evennia Changelog

+ +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Continuous-Integration-TeamCity.html b/docs/latest/Coding/Continuous-Integration-TeamCity.html new file mode 100644 index 0000000000..adc2463f3f --- /dev/null +++ b/docs/latest/Coding/Continuous-Integration-TeamCity.html @@ -0,0 +1,325 @@ + + + + + + + + + Continuous Integration - TeamCity (linux) — Evennia 2.x documentation + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Continuous Integration - TeamCity (linux)

+

This sets up a TeamCity build integration environment on Linux.

+
+

Prerequisites

+ +

After meeting the preparation steps for your specific environment, log on to your teamcity interface +at http://<your server>:8111/.

+

Create a new project named “Evennia” and in it construct a new template called continuous-integration.

+
+
+

A Quick Overview

+

Templates are fancy objects in TeamCity that allow an administrator to define build steps that are +shared between one or more build projects. Assigning a VCS Root (Source Control) is unnecessary at +this stage, primarily you’ll be worrying about the build steps and your default parameters (both +visible on the tabs to the left.)

+
+
+

Template Setup

+

In this template, you’ll be outlining the steps necessary to build your specific game. (A number of +sample scripts are provided under this section below!) Click Build Steps and prepare your general +flow. For this example, we will be doing a few basic example steps:

+
    +
  • Transforming the Settings.py file - We do this to update ports or other information that make your production +environment unique from your development environment.

  • +
  • Making migrations and migrating the game database.

  • +
  • Publishing the game files.

  • +
  • Reloading the server.

  • +
+

For each step we’ll being use the “Command Line Runner” (a fancy name for a shell script executor).

+

Create a build step with the name: “Transform Configuration” and add the script:

+
#!/bin/bash
+# Replaces the game configuration with one 
+# appropriate for this deployment.
+
+CONFIG="%system.teamcity.build.checkoutDir%/server/conf/settings.py"
+MYCONF="%system.teamcity.build.checkoutDir%/server/conf/my.cnf"
+
+sed -e 's/TELNET_PORTS = [4000]/TELNET_PORTS = [%game.ports%]/g' "$CONFIG" > "$CONFIG".tmp && mv
+"$CONFIG".tmp "$CONFIG"
+sed -e 's/WEBSERVER_PORTS = [(4001, 4002)]/WEBSERVER_PORTS = [%game.webports%]/g' "$CONFIG" >
+"$CONFIG".tmp && mv "$CONFIG".tmp "$CONFIG"
+
+
+
# settings.py MySQL DB configuration
+echo Configuring Game Database...
+echo "" >> "$CONFIG"
+echo "######################################################################" >> "$CONFIG"
+echo "# MySQL Database Configuration" >> "$CONFIG"
+echo "######################################################################" >> "$CONFIG"
+
+echo "DATABASES = {" >> "$CONFIG"
+echo "   'default': {" >> "$CONFIG"
+echo "       'ENGINE': 'django.db.backends.mysql'," >> "$CONFIG"
+echo "       'OPTIONS': {" >> "$CONFIG"
+echo "           'read_default_file': 'server/conf/my.cnf'," >> "$CONFIG"
+echo "       }," >> "$CONFIG"
+echo "   }" >> "$CONFIG"
+echo "}" >> "$CONFIG"
+
+# Create the My.CNF file.
+echo "[client]" >> "$MYCONF"
+echo "database = %mysql.db%" >> "$MYCONF"
+echo "user = %mysql.user%" >> "$MYCONF"
+echo "password = %mysql.pass%" >> "$MYCONF"
+echo "default-character-set = utf8" >> "$MYCONF"
+
+
+

If you look at the parameters side of the page after saving this script, you’ll notice that some new +parameters have been populated for you. This is because we’ve included new teamcity configuration +parameters that are populated when the build itself is ran. When creating projects that inherit this +template, we’ll be able to fill in or override those parameters for project-specific configuration.

+

Go ahead and create another build step called “Make Database Migration” +If you’re using Sqlite3 for your game (default database), it’s prudent to change working directory on this +step to your game dir.

+
#!/bin/bash
+# Update the DB migration
+
+LOGDIR="server/logs"
+
+. %evenv.dir%/bin/activate
+
+# Check that the logs directory exists.
+if [ ! -d "$LOGDIR" ]; then
+  # Control will enter here if $LOGDIR doesn't exist.
+  mkdir "$LOGDIR"
+fi
+
+evennia makemigrations
+
+
+

Create yet another build step, this time named: “Execute Database Migration”: +If you’re using Sqlite3 for your game (default database), it’s prudent to change working directory on this +step to your game dir.

+
#!/bin/bash
+# Apply the database migration.
+    
+LOGDIR="server/logs"
+    
+. %evenv.dir%/bin/activate
+    
+# Check that the logs directory exists.
+if [ ! -d "$LOGDIR" ]; then
+  # Control will enter here if $LOGDIR doesn't exist.
+  mkdir "$LOGDIR"
+fi
+    
+evennia migrate
+
+
+

Our next build step is where we actually publish our build. Up until now, all work on game has been +done in a ‘work’ directory on TeamCity’s build agent. From that directory we will now copy our files +to where our game actually exists on the local server.

+

Create a new build step called “Publish Build”. If you’re using SQlite3 on your game, be sure to order this step ABOVE +the Database Migration steps. The build order will matter!

+
#!/bin/bash
+# Publishes the build to the proper build directory.
+    
+DIRECTORY="<game_dir>"
+    
+if [ ! -d "$DIRECTORY" ]; then
+  # Control will enter here if $DIRECTORY doesn't exist.
+  mkdir "$DIRECTORY"
+fi
+    
+# Copy all the files.
+cp -ruv %teamcity.build.checkoutDir%/* "$DIRECTORY"
+chmod -R 775 "$DIRECTORY"
+ 
+
+
+

Finally the last script will reload our game for us.

+

Create a new script called “Reload Game”: +The working directory on this build step will be: %game.dir%

+
#!/bin/bash
+# Apply the database migration.
+
+LOGDIR="server/logs"
+PIDDIR="server/server.pid"
+
+. %evenv.dir%/bin/activate
+
+# Check that the logs directory exists.
+if [ ! -d "$LOGDIR" ]; then
+  # Control will enter here if $LOGDIR doesn't exist.
+  mkdir "$LOGDIR"
+fi
+
+# Check that the server is running.
+if [ -d "$PIDDIR" ]; then
+  # Control will enter here if the game is running.
+  evennia reload
+fi
+
+
+

Now the template is ready for use! It would be useful this time to revisit the parameters page and +set the evenv parameter to the directory where your virtualenv exists: IE “/srv/mush/evenv”.

+
+

Creating the Project

+

Now it’s time for the last few steps to set up a CI environment.

+
    +
  • Return to the Evennia Project overview/administration page.

  • +
  • Create a new Sub-Project called “Production”. This will be the category that holds our actual game.

  • +
  • Create a new Build Configuration in Production with the name of your MUSH. Base this configuration off of the +continuous-integration template we made earlier.

  • +
  • In the build configuration, enter VCS roots and create a new VCS root that points to the +branch/version control that you are using.

  • +
  • Go to the parameters page and fill in the undefined parameters for your specific configuration.

  • +
  • If you wish for the CI to run every time a commit is made, go to the VCS triggers and add one for +“On Every Commit”.

  • +
+

And you’re done! At this point, you can return to the project overview page and queue a new build +for your game. If everything was set up correctly, the build will complete successfully. Additional +build steps could be added or removed at this point, adding some features like Unit Testing or more!

+
+
+
+ + +
+
+
+ +
+ + + + \ No newline at end of file diff --git a/docs/latest/Coding/Continuous-Integration-Travis.html b/docs/latest/Coding/Continuous-Integration-Travis.html new file mode 100644 index 0000000000..e2948ed276 --- /dev/null +++ b/docs/latest/Coding/Continuous-Integration-Travis.html @@ -0,0 +1,165 @@ + + + + + + + + + Continuous integration with Travis — Evennia 2.x documentation + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Continuous integration with Travis

+

Travis CI is an online service for checking, validating and potentially +deploying code automatically. It can check that every commit is building successfully after every +commit to its Github repository.

+

If your game is open source on Github you may use Travis for free. +See [the Travis docs](https://docs.travis-ci.com/user/getting- started/) for how to get started.

+

After logging in you will get to point Travis to your repository on github. One further thing you +need to set up yourself is a Travis config file named .travis.yml (note the initial period .). +This should be created in the root of your game directory. The idea with this file is that it +describes what Travis needs to import and build in order to create an instance of Evennia from +scratch and then run validation tests on it. Here is an example:

+
language: python
+python:
+  - "3.10"
+install:
+  - git clone https://github.com/evennia/evennia.git
+  - cd evennia
+  - pip install -e .
+  - cd $TRAVIS_BUILD_DIR
+script:
+  - evennia migrate
+  - evennia test --settings settings.py .
+
+
+

This will tell travis how to download Evennia, install it, set up a database and then run +your own test suite (inside the game dir). Use evennia test evennia if you also want to +run the Evennia full test suite.

+

You need to add this file to git (git add .travis.yml) and then commit your changes before Travis +will be able to see it.

+

For properly testing your game you of course also need to write unittests. +The Unit testing doc page gives some ideas on how to set those up for Evennia. +You should be able to refer to that for making tests fitting your game.

+
+ + +
+
+
+ +
+ + + + \ No newline at end of file diff --git a/docs/latest/Coding/Continuous-Integration.html b/docs/latest/Coding/Continuous-Integration.html new file mode 100644 index 0000000000..d53a2ec34b --- /dev/null +++ b/docs/latest/Coding/Continuous-Integration.html @@ -0,0 +1,156 @@ + + + + + + + + + Continuous Integration (CI) — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Continuous Integration (CI)

+

Continuous Integration (CI) is a development practice that requires developers to integrate code into a shared repository. Each check-in is then verified by an automated build, allowing teams to detect problems early. This can be set up to safely deploy data to a production server only after tests have passed, for example.

+

For Evennia, continuous integration allows an automated build process to:

+
    +
  • Pull down a latest build from Source Control.

  • +
  • Run migrations on the backing SQL database.

  • +
  • Automate additional unique tasks for that project.

  • +
  • Run unit tests.

  • +
  • Publish those files to the server directory

  • +
  • Reload the game.

  • +
+
+

Continuous-Integration guides

+

Evennia itself is making heavy use of github actions. This is integrated with Github and is probably the one to go for most people, especially if your code is on Github already. You can see and analyze how Evennia’s actions are running here.

+

There are however a lot of tools and services providing CI functionality. Here is a blog overview (external link).

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Debugging.html b/docs/latest/Coding/Debugging.html new file mode 100644 index 0000000000..2691da7be0 --- /dev/null +++ b/docs/latest/Coding/Debugging.html @@ -0,0 +1,388 @@ + + + + + + + + + Debugging — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Debugging

+

Sometimes, an error is not trivial to resolve. A few simple print statements is not enough to find the cause of the issue. The traceback is not informative or even non-existing.

+

Running a debugger can then be very helpful and save a lot of time. Debugging means running Evennia under control of a special debugger program. This allows you to stop the action at a given point, view the current state and step forward through the program to see how its logic works.

+

Evennia natively supports these debuggers:

+
    +
  • Pdb is a part of the Python distribution and +available out-of-the-box.

  • +
  • PuDB is a third-party debugger that has a slightly more +‘graphical’, curses-based user interface than pdb. It is installed with pip install pudb.

  • +
+
+

Debugging Evennia

+

To run Evennia with the debugger, follow these steps:

+
    +
  1. Find the point in the code where you want to have more insight. Add the following line at that +point.

    +
    from evennia import set_trace;set_trace()
    +
    +
    +
  2. +
  3. (Re-)start Evennia in interactive (foreground) mode with evennia istart. This is important - without this step the debugger will not start correctly - it will start in this interactive terminal.

  4. +
  5. Perform the steps that will trigger the line where you added the set_trace() call. The debugger will start in the terminal from which Evennia was interactively started.

  6. +
+

The evennia.set_trace function takes the following arguments:

+
    evennia.set_trace(debugger='auto', term_size=(140, 40))
+
+
+

Here, debugger is one of pdb, pudb or auto. If auto, use pudb if available, otherwise use pdb. The term_size tuple sets the viewport size for pudb only (it’s ignored by pdb).

+
+
+

A simple example using pdb

+

The debugger is useful in different cases, but to begin with, let’s see it working in a command. +Add the following test command (which has a range of deliberate errors) and also add it to your +default cmdset. Then restart Evennia in interactive mode with evennia istart.

+
# In file commands/command.py
+
+
+class CmdTest(Command):
+
+    """
+    A test command just to test pdb.
+
+    Usage:
+        test
+
+    """
+
+    key = "test"
+
+    def func(self):
+        from evennia import set_trace; set_trace()   # <--- start of debugger
+        obj = self.search(self.args)
+        self.msg("You've found {}.".format(obj.get_display_name()))
+
+
+
+

If you type test in your game, everything will freeze. You won’t get any feedback from the game, and you won’t be able to enter any command (nor anyone else). It’s because the debugger has started in your console, and you will find it here. Below is an example with pdb.

+
...
+> .../mygame/commands/command.py(79)func()
+-> obj = self.search(self.args)
+(Pdb)
+
+
+
+

pdb notes where it has stopped execution and, what line is about to be executed (in our case, obj = self.search(self.args)), and ask what you would like to do.

+
+

Listing surrounding lines of code

+

When you have the pdb prompt (Pdb), you can type in different commands to explore the code. The first one you should know is list (you can type l for short):

+
(Pdb) l
+ 43
+ 44         key = "test"
+ 45
+ 46         def func(self):
+ 47             from evennia import set_trace; set_trace()   # <--- start of debugger
+ 48  ->         obj = self.search(self.args)
+ 49             self.msg("You've found {}.".format(obj.get_display_name()))
+ 50
+ 51     # -------------------------------------------------------------
+ 52     #
+ 53     # The default commands inherit from
+(Pdb)
+
+
+

Okay, this didn’t do anything spectacular, but when you become more confident with pdb and find yourself in lots of different files, you sometimes need to see what’s around in code. Notice that there is a little arrow (->) before the line that is about to be executed.

+

This is important: about to be, not has just been. You need to tell pdb to go on (we’ll soon see how).

+
+
+

Examining variables

+

pdb allows you to examine variables (or really, to run any Python instruction). It is very useful to know the values of variables at a specific line. To see a variable, just type its name (as if you were in the Python interpreter:

+
(Pdb) self
+<commands.command.CmdTest object at 0x045A0990>
+(Pdb) self.args
+u''
+(Pdb) self.caller
+<Character: XXX>
+(Pdb)
+
+
+

If you try to see the variable obj, you’ll get an error:

+
(Pdb) obj
+*** NameError: name 'obj' is not defined
+(Pdb)
+
+
+

That figures, since at this point, we haven’t created the variable yet.

+
+

Examining variable in this way is quite powerful. You can even run Python code and keep on +executing, which can help to check that your fix is actually working when you have identified an +error. If you have variable names that will conflict with pdb commands (like a list +variable), you can prefix your variable with !, to tell pdb that what follows is Python code.

+
+
+
+

Executing the current line

+

It’s time we asked pdb to execute the current line. To do so, use the next command. You can +shorten it by just typing n:

+
(Pdb) n
+AttributeError: "'CmdTest' object has no attribute 'search'"
+> .../mygame/commands/command.py(79)func()
+-> obj = self.search(self.args)
+(Pdb)
+
+
+

Pdb is complaining that you try to call the search method on a command… whereas there’s no search method on commands. The character executing the command is in self.caller, so we might change our line:

+
obj = self.caller.search(self.args)
+
+
+
+
+

Letting the program run

+

pdb is waiting to execute the same instruction… it provoked an error but it’s ready to try again, just in case. We have fixed it in theory, but we need to reload, so we need to enter a command. To tell pdb to terminate and keep on running the program, use the continue (or c) command:

+
(Pdb) c
+...
+
+
+

You see an error being caught, that’s the error we have fixed… or hope to have. Let’s reload the game and try again. You need to run evennia istart again and then run test to get into the command again.

+
> .../mygame/commands/command.py(79)func()
+-> obj = self.caller.search(self.args)
+(Pdb)
+
+
+
+

pdb is about to run the line again.

+
(Pdb) n
+> .../mygame/commands/command.py(80)func()
+-> self.msg("You've found {}.".format(obj.get_display_name()))
+(Pdb)
+
+
+

This time the line ran without error. Let’s see what is in the obj variable:

+
(Pdb) obj
+(Pdb) print obj
+None
+(Pdb)
+
+
+

We have entered the test command without parameter, so no object could be found in the search +(self.args is an empty string).

+

Let’s allow the command to continue and try to use an object name as parameter (although, we should +fix that bug too, it would be better):

+
(Pdb) c
+...
+
+
+

Notice that you’ll have an error in the game this time. Let’s try with a valid parameter. I have another character, barkeep, in this room:

+

test barkeep

+

And again, the command freezes, and we have the debugger opened in the console.

+

Let’s execute this line right away:

+
> .../mygame/commands/command.py(79)func()
+-> obj = self.caller.search(self.args)
+(Pdb) n
+> .../mygame/commands/command.py(80)func()
+-> self.msg("You've found {}.".format(obj.get_display_name()))
+(Pdb) obj
+<Character: barkeep>
+(Pdb)
+
+
+

At least this time we have found the object. Let’s process…

+
(Pdb) n
+TypeError: 'get_display_name() takes exactly 2 arguments (1 given)'
+> .../mygame/commands/command.py(80)func()
+-> self.msg("You've found {}.".format(obj.get_display_name()))
+(Pdb)
+
+
+

As an exercise, fix this error, reload and run the debugger again. Nothing better than some experimenting!

+

Your debugging will often follow the same strategy:

+
    +
  1. Receive an error you don’t understand.

  2. +
  3. Put a breaking point BEFORE the error occurs.

  4. +
  5. Run evennia istart

  6. +
  7. Run the code again and see the debugger open.

  8. +
  9. Run the program line by line, examining variables, checking the logic of instructions.

  10. +
  11. Continue and try again, each step a bit further toward the truth and the working feature.

  12. +
+
+
+
+

Cheat-sheet of pdb/pudb commands

+

PuDB and Pdb share the same commands. The only real difference is how it’s presented. The look +command is not needed much in pudb since it displays the code directly in its user interface.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Pdb/PuDB command

To do what

list (or l)

List the lines around the point of execution (not needed for pudb, it will show

this directly).

print (or p)

Display one or several variables.

!

Run Python code (using a ! is often optional).

continue (or c)

Continue execution and terminate the debugger for this time.

next (or n)

Execute the current line and goes to the next one.

step (or s)

Step inside of a function or method to examine it.

<RETURN>

Repeat the last command (don’t type n repeatedly, just type it once and then press

<RETURN> to repeat it).

+

If you want to learn more about debugging with Pdb, you will find an interesting tutorial on that topic here.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Default-Command-Syntax.html b/docs/latest/Coding/Default-Command-Syntax.html new file mode 100644 index 0000000000..87b1cb9fc4 --- /dev/null +++ b/docs/latest/Coding/Default-Command-Syntax.html @@ -0,0 +1,146 @@ + + + + + + + + + Default Command Syntax — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Default Command Syntax

+

Evennia allows for any command syntax.

+

If you like the way DikuMUDs, LPMuds or MOOs handle things, you could emulate that with Evennia. If you are ambitious you could even design a whole new style, perfectly fitting your own dreams of the ideal game. See the Command documentation for how to do this.

+

We do offer a default however. The default Evennia setup tends to resemble MUX2, and its cousins PennMUSH, TinyMUSH, and RhostMUSH:

+
command[/switches] object [= options]
+
+
+

While the reason for this similarity is partly historical, these codebases offer very mature feature sets for administration and building.

+

Evennia is not a MUX system though. It works very differently in many ways. For example, Evennia +deliberately lacks an online softcode language (a policy explained on our softcode policy page). Evennia also does not shy from using its own syntax when deemed appropriate: the +MUX syntax has grown organically over a long time and is, frankly, rather arcane in places. All in +all the default command syntax should at most be referred to as “MUX-like” or “MUX-inspired”.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Evennia-Code-Style.html b/docs/latest/Coding/Evennia-Code-Style.html new file mode 100644 index 0000000000..c1a317924a --- /dev/null +++ b/docs/latest/Coding/Evennia-Code-Style.html @@ -0,0 +1,401 @@ + + + + + + + + + Evennia Code Style — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia Code Style

+

All code submitted or committed to the Evennia project should aim to follow the +guidelines outlined in Python PEP 8. Keeping the code style uniform +makes it much easier for people to collaborate and read the code.

+

A good way to check if your code follows PEP8 is to use the PEP8 tool +on your sources.

+
+

Main code style specification

+
    +
  • 4-space indentation, NO TABS!

  • +
  • Unix line endings.

  • +
  • 100 character line widths

  • +
  • CamelCase is only used for classes, nothing else.

  • +
  • All non-global variable names and all function names are to be +lowercase, words separated by underscores. Variable names should +always be more than two letters long.

  • +
  • Module-level global variables (only) are to be in CAPITAL letters.

  • +
  • Imports should be done in this order:

    +
      +
    • Python modules (builtins and standard library)

    • +
    • Twisted modules

    • +
    • Django modules

    • +
    • Evennia library modules (evennia)

    • +
    • Evennia contrib modules (evennia.contrib)

    • +
    +
  • +
  • All modules, classes, functions and methods should have doc strings formatted +as outlined below.

  • +
  • All default commands should have a consistent docstring formatted as +outlined below.

  • +
+
+
+

Code Docstrings

+

All modules, classes, functions and methods should have docstrings +formatted with Google style -inspired indents, using +Markdown formatting where needed. Evennia’s api2md +parser will use this to create pretty API documentation.

+
+

Module docstrings

+

Modules should all start with at least a few lines of docstring at +their top describing the contents and purpose of the module.

+

Example of module docstring (top of file):

+
"""
+This module handles the creation of `Objects` that
+are useful in the game ...
+
+"""
+
+
+

Sectioning (# title, ## subtile etc) should not be used in +freeform docstrings - this will confuse the sectioning of the auto +documentation page and the auto-api will create this automatically. +Write just the section name bolded on its own line to mark a section. +Beyond sections markdown should be used as needed to format +the text.

+

Code examples should use multi-line syntax highlighting +to mark multi-line code blocks, using the “python” identifier. Just +indenting code blocks (common in markdown) will not produce the +desired look.

+

When using any code tags (inline or blocks) it’s recommended that you +don’t let the code extend wider than about 70 characters or it will +need to be scrolled horizontally in the wiki (this does not affect any +other text, only code).

+
+
+

Class docstrings

+

The root class docstring should describe the over-arching use of the +class. It should usually not describe the exact call sequence nor list +important methods, this tends to be hard to keep updated as the API +develops. Don’t use section markers (#, ## etc).

+

Example of class docstring:

+
class MyClass(object):
+    """
+    This class describes the creation of `Objects`. It is useful
+    in many situations, such as ...
+
+    """
+
+
+
+
+

Function / method docstrings

+

Example of function or method docstring:

+

+def funcname(a, b, c, d=False, **kwargs):
+    """
+    This is a brief introduction to the function/class/method
+
+    Args:
+        a (str): This is a string argument that we can talk about
+            over multiple lines.
+        b (int or str): Another argument.
+        c (list): A list argument.
+        d (bool, optional): An optional keyword argument.
+
+    Keyword Args:
+        test (list): A test keyword.
+
+    Returns:
+        str: The result of the function.
+
+    Raises:
+        RuntimeException: If there is a critical error,
+            this is raised.
+        IOError: This is only raised if there is a
+            problem with the database.
+
+    Notes:
+        This is an example function. If `d=True`, something
+        amazing will happen.
+
+    """
+
+
+

The syntax is very “loose” but the indentation matters. That is, you +should end the block headers (like Args:) with a line break followed by +an indent. When you need to break a line you should start the next line +with another indent. For consistency with the code we recommend all +indents to be 4 spaces wide (no tabs!).

+

Here are all the supported block headers:

+
    """
+    Args
+        argname (freeform type): Description endind with period.
+    Keyword Args:
+        argname (freeform type): Description.
+    Returns/Yields:
+        type: Description.
+    Raises:
+        Exceptiontype: Description.
+    Notes/Note/Examples/Example:
+        Freeform text.
+    """
+
+
+

Parts marked with “freeform” means that you can in principle put any +text there using any formatting except for sections markers (#, ## +etc). You must also keep indentation to mark which block you are part +of. You should normally use the specified format rather than the +freeform counterpart (this will produce nicer output) but in some +cases the freeform may produce a more compact and readable result +(such as when describing an *args or **kwargs statement in general +terms). The first self argument of class methods should never be +documented.

+

Note that

+
"""
+Args:
+    argname (type, optional): Description.
+"""
+
+
+

and

+
"""
+Keyword Args:
+   sargname (type): Description.
+"""
+
+
+

mean the same thing! Which one is used depends on the function or +method documented, but there are no hard rules; If there is a large +**kwargs block in the function, using the Keyword Args: block may be a +good idea, for a small number of arguments though, just using Args: +and marking keywords as optional will shorten the docstring and make +it easier to read.

+
+
+
+

Default Command Docstrings

+

These represent a special case since Commands in Evennia use their class +docstrings to represent the in-game help entry for that command.

+

All the commands in the default command sets should have their doc-strings +formatted on a similar form. For contribs, this is loosened, but if there is +no particular reason to use a different form, one should aim to use the same +style for contrib-command docstrings as well.

+
      """
+      Short header
+
+      Usage:
+        key[/switches, if any] <mandatory args> [optional] choice1||choice2||choice3
+
+      Switches:
+        switch1    - description
+        switch2    - description
+
+      Examples:
+        Usage example and output
+
+      Longer documentation detailing the command.
+
+      """
+
+
+
    +
  • Two spaces are used for indentation in all default commands.

  • +
  • Square brackets [ ] surround optional, skippable arguments.

  • +
  • Angled brackets < > surround a description of what to write rather than the exact syntax.

  • +
  • Explicit choices are separated by |. To avoid this being parsed as a color code, use || (this +will come out as a single |) or put spaces around the character (“|”) if there’s plenty of room.

  • +
  • The Switches and Examples blocks are optional and based on the Command.

  • +
+

Here is the nick command as an example:

+
      """
+      Define a personal alias/nick
+
+      Usage:
+        nick[/switches] <nickname> = [<string>]
+        alias             ''
+
+      Switches:
+        object   - alias an object
+        account   - alias an account
+        clearall - clear all your aliases
+        list     - show all defined aliases (also "nicks" works)
+
+      Examples:
+        nick hi = say Hello, I'm Sarah!
+        nick/object tom = the tall man
+
+      A 'nick' is a personal shortcut you create for your own use [...]
+
+        """
+
+
+

For commands that require arguments, the policy is for it to return a Usage: +string if the command is entered without any arguments. So for such commands, +the Command body should contain something to the effect of

+
      if not self.args:
+          self.caller.msg("Usage: nick[/switches] <nickname> = [<string>]")
+          return
+
+
+
+
+

Tools for auto-linting

+
+

black

+

Automatic pep8 compliant formatting and linting can be performed using the +black formatter:

+
black --line-length 100
+
+
+
+
+

PyCharm

+

The Python IDE Pycharm can auto-generate empty doc-string stubs. The +default is to use reStructuredText form, however. To change to Evennia’s +Google-style docstrings, follow this guide.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Profiling.html b/docs/latest/Coding/Profiling.html new file mode 100644 index 0000000000..39039049a8 --- /dev/null +++ b/docs/latest/Coding/Profiling.html @@ -0,0 +1,332 @@ + + + + + + + + + Profiling — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Profiling

+
+

Important

+

This is considered an advanced topic. It’s mainly of interest to server developers.

+
+

Sometimes it can be useful to try to determine just how efficient a particular piece of code is, or to figure out if one could speed up things more than they are. There are many ways to test the performance of Python and the running server.

+

Before digging into this section, remember Donald Knuth’s words of wisdom:

+
+

[…]about 97% of the time: Premature optimization is the root of all evil.

+
+

That is, don’t start to try to optimize your code until you have actually identified a need to do so. This means your code must actually be working before you start to consider optimization. Optimization will also often make your code more complex and harder to read. Consider readability and maintainability and you may find that a small gain in speed is just not worth it.

+
+

Simple timer tests

+

Python’s timeit module is very good for testing small things. For example, in +order to test if it is faster to use a for loop or a list comprehension you +could use the following code:

+
    import timeit
+    # Time to do 1000000 for loops
+    timeit.timeit("for i in range(100):\n    a.append(i)", setup="a = []")
+   <<< 10.70982813835144
+    # Time to do 1000000 list comprehensions
+    timeit.timeit("a = [i for i in range(100)]")
+   <<<  5.358283996582031
+
+
+

The setup keyword is used to set up things that should not be included in the time measurement, like a = [] in the first call.

+

By default the timeit function will re-run the given test 1000000 times and returns the total time to do so (so not the average per test). A hint is to not use this default for testing something that includes database writes - for that you may want to use a lower number of repeats (say 100 or 1000) using the number=100 keyword.

+

In the example above, we see that this number of calls, using a list comprehension is about twice as fast as building a list using .append().

+
+
+

Using cProfile

+

Python comes with its own profiler, named cProfile (this is for cPython, no tests have been done with pypy at this point). Due to the way Evennia’s processes are handled, there is no point in using the normal way to start the profiler (python -m cProfile evennia.py). Instead you start the profiler through the launcher:

+
evennia --profiler start
+
+
+

This will start Evennia with the Server component running (in daemon mode) under cProfile. You could instead try --profile with the portal argument to profile the Portal (you would then need to start the Server separately).

+

Please note that while the profiler is running, your process will use a lot more memory than usual. Memory usage is even likely to climb over time. So don’t leave it running perpetually but monitor it carefully (for example using the top command on Linux or the Task Manager’s memory display on Windows).

+

Once you have run the server for a while, you need to stop it so the profiler can give its report. Do not kill the program from your task manager or by sending it a kill signal - this will most likely also mess with the profiler. Instead either use evennia.py stop or (which may be even better), use @shutdown from inside the game.

+

Once the server has fully shut down (this may be a lot slower than usual) you will find that profiler has created a new file mygame/server/logs/server.prof.

+
+

Analyzing the profile

+

The server.prof file is a binary file. There are many ways to analyze and display its contents, all of which has only been tested in Linux (If you are a Windows/Mac user, let us know what works).

+

You can look at the contents of the profile file with Python’s in-built pstats module in the evennia shell (it’s recommended you install ipython with pip install ipython in your virtualenv first, for prettier output):

+
evennia shell
+
+
+

Then in the shell

+
import pstats
+from pstats import SortKey
+
+p = pstats.Stats('server/log/server.prof')
+p.strip_dirs().sort_stats(-1).print_stats()
+
+
+
+

See the Python profiling documentation for more information.

+

You can also visualize the data in various ways.

+
    +
  • Runsnake visualizes the profile to +give a good overview. Install with pip install runsnakerun. Note that this +may require a C compiler and be quite slow to install.

  • +
  • For more detailed listing of usage time, you can use +KCachegrind. To make +KCachegrind work with Python profiles you also need the wrapper script +pyprof2calltree. You can get +pyprof2calltree via pip whereas KCacheGrind is something you need to get +via your package manager or their homepage.

  • +
+

How to analyze and interpret profiling data is not a trivial issue and depends on what you are profiling for. Evennia being an asynchronous server can also confuse profiling. Ask on the mailing list if you need help and be ready to be able to supply your server.prof file for comparison, along with the exact conditions under which it was obtained.

+
+
+
+

The Dummyrunner

+

It is difficult to test “actual” game performance without having players in your game. For this reason Evennia comes with the Dummyrunner system. The Dummyrunner is a stress-testing system: a separate program that logs into your game with simulated players (aka “bots” or “dummies”). Once connected, these dummies will semi-randomly perform various tasks from a list of possible actions. Use Ctrl-C to stop the Dummyrunner.

+
+

Warning

+
You should not run the Dummyrunner on a production database. It
+will spawn many objects and also needs to run with general permissions.
+
+
+

This is the recommended process for using the dummy runner:

+
+
    +
  1. Stop your server completely with evennia stop.

  2. +
  3. At the end of your mygame/server/conf.settings.py file, add the line

    +
     from evennia.server.profiling.settings_mixin import *
    +
    +
    +

    This will override your settings and disable Evennia’s rate limiters and DoS-protections, which would otherwise block mass-connecting clients from one IP. Notably, it will also change to a different (faster) password hasher.

    +
  4. +
  5. (recommended): Build a new database. If you use default Sqlite3 and want to +keep your existing database, just rename mygame/server/evennia.db3 to +mygame/server/evennia.db3_backup and run evennia migrate and evennia start to create a new superuser as usual.

  6. +
  7. (recommended) Log into the game as your superuser. This is just so you +can manually check response. If you kept an old database, you will not +be able to connect with an existing user since the password hasher changed!

  8. +
  9. Start the dummyrunner with 10 dummy users from the terminal with

    +
     evennia --dummyrunner 10
    +
    +
    +

    Use Ctrl-C (or Cmd-C) to stop it.

    +
  10. +
+

If you want to see what the dummies are actually doing you can run with a single dummy:

+
evennia --dummyrunner 1
+
+
+

The inputs/outputs from the dummy will then be printed. By default the runner uses the ‘looker’ profile, which just logs in and sends the ‘look’ command over and over. To change the settings, copy the file evennia/server/profiling/dummyrunner_settings.py to your mygame/server/conf/ directory, then add this line to your settings file to use it in the new location:

+
DUMMYRUNNER_SETTINGS_MODULE = "server/conf/dummyrunner_settings.py"
+
+
+

The dummyrunner settings file is a python code module in its own right - it defines the actions available to the dummies. These are just tuples of command strings (like “look here”) for the dummy to send to the server along with a probability of them happening. The dummyrunner looks for a global variable ACTIONS, a list of tuples, where the first two elements define the commands for logging in/out of the server.

+

Below is a simplified minimal setup (the default settings file adds a lot more functionality and info):

+
# minimal dummyrunner setup file
+
+# Time between each dummyrunner "tick", in seconds. Each dummy will be called
+# with this frequency.
+TIMESTEP = 1
+
+# Chance of a dummy actually performing an action on a given tick. This
+# spreads out usage randomly, like it would be in reality.
+CHANCE_OF_ACTION = 0.5
+
+# Chance of a currently unlogged-in dummy performing its login action every
+# tick. This emulates not all accounts logging in at exactly the same time.
+CHANCE_OF_LOGIN = 0.01
+
+# Which telnet port to connect to. If set to None, uses the first default
+# telnet port of the running server.
+TELNET_PORT = None
+
+# actions
+
+def c_login(client):
+    name = f"Character-{client.gid}"
+    pwd = f"23fwsf23sdfw23wef23"
+    return (
+        f"create {name} {pwd}"
+        f"connect {name} {pwd}"
+    )
+
+def c_logout(client):
+    return ("quit", )
+
+def c_look(client):
+    return ("look here", "look me")
+
+# this is read by dummyrunner.
+ACTIONS = (
+    c_login,
+    c_logout,
+    (1.0, c_look)   # (probability, command-generator)
+)
+
+
+
+

At the bottom of the default file are a few default profiles you can test out by just setting the PROFILE variable to one of the options.

+
+

Dummyrunner hints

+
    +
  • Don’t start with too many dummies. The Dummyrunner taxes the server much more +than ‘real’ users tend to do. Start with 10-100 to begin with.

  • +
  • Stress-testing can be fun, but also consider what a ‘realistic’ number of +users would be for your game.

  • +
  • Note in the dummyrunner output how many commands/s are being sent to the +server by all dummies. This is usually a lot higher than what you’d +realistically expect to see from the same number of users.

  • +
  • The default settings sets up a ‘lag’ measure to measaure the round-about +message time. It updates with an average every 30 seconds. It can be worth to +have this running for a small number of dummies in one terminal before adding +more by starting another dummyrunner in another terminal - the first one will +act as a measure of how lag changes with different loads. Also verify the +lag-times by entering commands manually in-game.

  • +
  • Check the CPU usage of your server using top/htop (linux). In-game, use the +server command.

  • +
  • You can run the server with --profiler start to test it with dummies. Note +that the profiler will itself affect server performance, especially memory +consumption.

  • +
  • Generally, the dummyrunner system makes for a decent test of general +performance; but it is of course hard to actually mimic human user behavior. +For this, actual real-game testing is required.

  • +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Release-Notes-1.0.html b/docs/latest/Coding/Release-Notes-1.0.html new file mode 100644 index 0000000000..0c96357cd4 --- /dev/null +++ b/docs/latest/Coding/Release-Notes-1.0.html @@ -0,0 +1,321 @@ + + + + + + + + + Evennia 1.0 Release Notes — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia 1.0 Release Notes

+

This summarizes the changes. See the Changelog for the full list.

+
    +
  • Main development now on main branch. master branch remains, but will not be updated anymore.

  • +
+
+

Minimum requirements

+
    +
  • Python 3.10 is now required minimum. Ubuntu LTS now installs with 3.10. Evennia 1.0 is also tested with Python 3.11 - this is the recommended version for Linux/Mac. Windows users may want to stay on Python 3.10 unless they are okay with installing a C++ compiler.

  • +
  • Twisted 22.10+

  • +
  • Django 4.1+

  • +
+
+
+

Major new features

+
    +
  • Evennia is now on PyPi and is installable as pip install evennia.

  • +
  • A completely revamped documentation at https://www.evennia.com/docs/latest. The old wiki and readmedocs pages will close.

  • +
  • Evennia 1.0 now has a REST API which allows you access game objects using CRUD operations GET/POST etc. See [The Web-API docs][Web-API] for more information.

  • +
  • Evennia<>Discord Integration between Evennia channels and Discord servers.

  • +
  • Script overhaul: Scripts’ timer component independent from script object deletion; can now start/stop timer without deleting Script. The .persistent flag now only controls if timer survives reload - Script has to be removed with .delete() like other typeclassed entities. This makes Scripts even more useful as general storage entities.

  • +
  • The FuncParser centralizes and vastly improves all in-string function calls, such as say the result is $eval(3 * 7) and say the result the result is 21. The parser completely replaces the old parse_inlinefunc. The new parser can handle both arguments and kwargs and are also used for in-prototype parsing as well as director stance messaging, such as using $You() to represent yourself in a string and having the result come out differently depending on who see you.

  • +
  • Channels New Channel-System using the channel command and nicks. The old ChannelHandler was removed and the customization and operation of channels have been simplified a lot. The old command syntax commands are now available as a contrib.

  • +
  • Help System was refactored.

    +
      +
    • A new type of FileHelp system allows you to add in-game help files as external Python files. This means there are three ways to add help entries in Evennia: 1) Auto-generated from Command’s code. 2) Manually added to the database from the sethelp command in-game and 3) Created as external Python files that Evennia loads and makes available in-game.

    • +
    • We now use lunr search indexing for better help matching and suggestions. Also improve +the main help command’s default listing output.

    • +
    • Help command now uses view lock to determine if cmd/entry shows in index and read lock to determine if it can be read. It used to be view in the role of the latter.

    • +
    • sethelp command now warns if shadowing other help-types when creating a new entry.

    • +
    • Make help index output clickable for webclient/clients with MXP (PR by davewiththenicehat)

    • +
    +
  • +
  • Rework of the Web setup, into a much more consistent structure and update to latest Django. The mygame/web/static_overrides and -template_overrides were removed. The folders are now just mygame/web/static and /templates and handle the automatic copying of data behind the scenes. app.css to website.css for consistency. The old prosimii-css files were removed.

  • +
  • AttributeProperty/TagProperty along with AliasProperty and PermissionProperty to allow managing Attributes, Tags, Aliases and Permissios on typeclasses in the same way as Django fields. This dramatically reduces the need to assign Attributes/Tags in at_create_object hook.

  • +
  • The old MULTISESSION_MODE was divided into smaller settings, for better controlling what happens when a user connects, if a character should be auto-created, and how many characters they can control at the same time. See Connection-Styles for a detailed explanation.

  • +
  • Evennia now supports custom evennia launcher commands (e.g. evennia mycmd foo bar). Add new commands as callables accepting *args, as settings.EXTRA_LAUNCHER_COMMANDS = {'mycmd': 'path.to.callable', ...}.

  • +
+
+
+

Contribs

+

The contrib folder structure was changed from 0.9.5. All contribs are now in sub-folders and organized into categories. All import paths must be updated. See Contribs overview.

+
    +
  • New Traits contrib, converted and expanded from Ainneve project. (whitenoise, Griatch)

  • +
  • New Crafting contrib, adding a full crafting subsystem (Griatch)

  • +
  • New XYZGrid contrib, adding x,y,z grid coordinates with in-game map and pathfinding. Controlled outside of the game via custom evennia launcher command (Griatch)

  • +
  • New Command cooldown contrib contrib for making it easier to manage commands using +dynamic cooldowns between uses (owllex)

  • +
  • New Godot Protocol contrib for connecting to Evennia from a client written in the open-source game engine Godot (ChrisLR).

  • +
  • New name_generator contrib for building random real-world based or fantasy-names based on phonetic rules (InspectorCaracal)

  • +
  • New Buffs contrib for managing temporary and permanent RPG status buffs effects (tegiminis)

  • +
  • The existing RPSystem contrib was refactored and saw a speed boost (InspectorCaracal, other contributors)

  • +
+
+
+

Translations

+
    +
  • New Latin (la) translation (jamalainm)

  • +
  • New German (de) translation (Zhuraj)

  • +
  • Updated Italian translation (rpolve)

  • +
  • Updated Swedish translation

  • +
+
+
+

Utils

+
    +
  • New utils.format_grid for easily displaying long lists of items in a block. This is now used for the default help display.

  • +
  • Add utils.repeat and utils.unrepeat as shortcuts to TickerHandler add/remove, similar +to how utils.delay is a shortcut for TaskHandler add.

  • +
  • Add utils/verb_conjugation for automatic verb conjugation (English only). This +is useful for implementing actor-stance emoting for sending a string to different targets.

  • +
  • utils.evmenu.ask_yes_no is a helper function that makes it easy to ask a yes/no question +to the user and respond to their input. This complements the existing get_input helper.

  • +
  • New tasks command for managing tasks started with utils.delay (PR by davewiththenicehat)

  • +
  • Add .deserialize() method to _Saver* structures to help completely +decouple structures from database without needing separate import.

  • +
  • Add run_in_main_thread as a helper for those wanting to code server code +from a web view.

  • +
  • Update evennia.utils.logger to use Twisted’s new logging API. No change in Evennia API +except more standard aliases logger.error/info/exception/debug etc can now be used.

  • +
  • Made utils.iter_to_str format prettier strings, using Oxford comma.

  • +
  • Move create_* functions into db managers, leaving utils.create only being +wrapper functions (consistent with utils.search). No change of api otherwise.

  • +
+
+
+

Locks

+
    +
  • New search: lock type used to completely hide an object from being found by +the DefaultObject.search (caller.search) method. (CloudKeeper)

  • +
  • New default for holds() lockfunc - changed from default of True to default of False in order to disallow dropping nonsensical things (such as things you don’t hold).

  • +
+
+
+

Hook changes

+
    +
  • Changed all at_before/after_* hooks to at_pre/post_* for consistency +across Evennia (the old names still work but are deprecated)

  • +
  • New at_pre_object_leave(obj, destination) method on Objects.

  • +
  • New at_server_init() hook called before all other startup hooks for all +startup modes. Used for more generic overriding (volund)

  • +
  • New at_pre_object_receive(obj, source_location) method on Objects. Called on +destination, mimicking behavior of at_pre_move hook - returning False will abort move.

  • +
  • Object.normalize_name and .validate_name added to (by default) enforce latinify +on character name and avoid potential exploits using clever Unicode chars (trhr)

  • +
  • Make object.search support ‘stacks=0’ keyword - if >0, the method will return +N identical matches instead of triggering a multi-match error.

  • +
  • Add tags.has() method for checking if an object has a tag or tags (PR by ChrisLR)

  • +
  • Add Msg.db_receiver_external field to allowe external, string-id message-receivers.

  • +
  • Add $pron() and $You() inlinefuncs for pronoun parsing in actor-stance strings using msg_contents.

  • +
+
+
+

Command changes

+
    +
  • Change default multi-match syntax from 1-obj, 2-obj to obj-1, obj-2, which seems to be what most expect.

  • +
  • Split return_appearance hook with helper methods and have it use a template +string in order to make it easier to override.

  • +
  • Command executions now done on copies to make sure yield don’t cause crossovers. Add +Command.retain_instance flag for reusing the same command instance.

  • +
  • Allow sending messages with page/tell without a = if target name contains no spaces.

  • +
  • The typeclass command will now correctly search the correct database-table for the target +obj (avoids mistakenly assigning an AccountDB-typeclass to a Character etc).

  • +
  • Merged script and scripts commands into one, for both managing global- and +on-object Scripts. Moved CmdScripts and CmdObjects to commands/default/building.py.

  • +
  • The channel commands replace all old channel-related commands, such as cset etc

  • +
  • Expand examine command’s code to much more extensible and modular. Show +attribute categories and value types (when not strings).

    +
      +
    • Add ability to examine /script and /channel entities with examine command.

    • +
    +
  • +
  • Add support for $dbref() and $search when assigning an Attribute value +with the set command. This allows assigning real objects from in-game.

  • +
  • Have type/force default to update-mode rather than resetmode and add more verbose +warning when using reset mode.

  • +
+
+
+

Coding improvement highlights

+
    +
  • The db pickle-serializer now checks for methods __serialize_dbobjs__ and __deserialize_dbobjs__ to allow custom packing/unpacking of nested dbobjs, to allow storing in Attribute. See Attributes documentation.

  • +
  • Add ObjectParent mixin to default game folder template as an easy, ready-made +way to override features on all ObjectDB-inheriting objects easily. +source location, mimicking behavior of at_pre_move hook - returning False will abort move.

  • +
  • New Unit test parent classes, for use both in Evenia core and in mygame. Restructured unit tests to always honor default settings.

  • +
+
+
+

Other

+
    +
  • Homogenize manager search methods to always return querysets and not sometimes querysets and sometimes lists.

  • +
  • Attribute/NAttribute got a homogenous representation, using intefaces, both +AttributeHandler and NAttributeHandler has same api now.

  • +
  • Added content_types indexing to DefaultObject’s ContentsHandler. (volund)

  • +
  • Made most of the networking classes such as Protocols and the SessionHandlers +replaceable via settings.py for modding enthusiasts. (volund)

  • +
  • The initial_setup.py file can now be substituted in settings.py to customize +initial game database state. (volund)

  • +
  • Make IP throttle use Django-based cache system for optional persistence (PR by strikaco)

  • +
  • In modules given by settings.PROTOTYPE_MODULES, spawner will now first look for a global +list PROTOTYPE_LIST of dicts before loading all dicts in the module as prototypes. +concept of a dynamically created ChannelCmdSet.

  • +
  • Prototypes now allow setting prototype_parent directly to a prototype-dict. +This makes it easier when dynamically building in-module prototypes.

  • +
  • Make @lazy_property decorator create read/delete-protected properties. This is because it’s used for handlers, and e.g. self.locks=[] is a common beginner mistake.

  • +
  • Change settings.COMMAND_DEFAULT_ARG_REGEX default from None to a regex meaning that +a space or / must separate the cmdname and args. This better fits common expectations.

  • +
  • Add settings.MXP_ENABLED=True and settings.MXP_OUTGOING_ONLY=True as sane defaults, to avoid known security issues with players entering MXP links.

  • +
  • Made MonitorHandler.add/remove support category for monitoring Attributes with a category (before only key was used, ignoring category entirely).

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Setting-up-PyCharm.html b/docs/latest/Coding/Setting-up-PyCharm.html new file mode 100644 index 0000000000..cd4e1f80aa --- /dev/null +++ b/docs/latest/Coding/Setting-up-PyCharm.html @@ -0,0 +1,228 @@ + + + + + + + + + Setting up PyCharm with Evennia — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Setting up PyCharm with Evennia

+

PyCharm is a Python developer’s IDE from Jetbrains available for Windows, Mac and Linux. It is a commercial product but offer free trials, a scaled-down community edition and also generous licenses for OSS projects like Evennia.

+
+

This page was originally tested on Windows (so use Windows-style path examples), but should work the same for all platforms.

+
+

First, install Evennia on your local machine with [[Getting Started]]. If you’re new to PyCharm, loading your project is as easy as selecting the Open option when PyCharm starts, and browsing to your game folder (the one created with evennia --init). We refer to it as mygame here.

+

If you want to be able to examine evennia’s core code or the scripts inside your virtualenv, you’ll +need to add them to your project too:

+
    +
  1. Go to File > Open...

  2. +
  3. Select the folder (i.e. the evennia root)

  4. +
  5. Select “Open in current window” and “Add to currently opened projects”

  6. +
+

It’s a good idea to set up the interpreter this before attempting anything further. The rest of this page assumes your project is already configured in PyCharm.

+
    +
  1. Go to File > Settings... > Project: \<mygame\> > Project Interpreter

  2. +
  3. Click the Gear symbol > Add local

  4. +
  5. Navigate to your evenv/scripts directory, and select Python.exe

  6. +
+

Enjoy seeing all your imports checked properly, setting breakpoints, and live variable watching!

+
+

Debug Evennia from inside PyCharm

+
    +
  1. Launch Evennia in your preferred way (usually from a console/terminal)

  2. +
  3. Open your project in PyCharm

  4. +
  5. In the PyCharm menu, select Run > Attach to Local Process...

  6. +
  7. From the list, pick the twistd process with the server.py parameter (Example: twistd.exe --nodaemon --logfile=\<mygame\>\server\logs\server.log --python=\<evennia repo\>\evennia\server\server.py)

  8. +
+

Of course you can attach to the portal process as well. If you want to debug the Evennia launcher +or runner for some reason (or just learn how they work!), see Run Configuration below.

+
+

NOTE: Whenever you reload Evennia, the old Server process will die and a new one start. So when you restart you have to detach from the old and then reattach to the new process that was created.

+
+
+

To make the process less tedious you can apply a filter in settings to show only the server.py process in the list. To do that navigate to: Settings/Preferences | Build, Execution, Deployment | Python Debugger and then in Attach to process field put in: twistd.exe" --nodaemon. This is an example for windows, I don’t have a working mac/linux box.

+
+

Example process filter configuration

+
+
+

Run Evennia from inside PyCharm

+

This configuration allows you to launch Evennia from inside PyCharm. Besides convenience, it also allows suspending and debugging the evennia_launcher or evennia_runner at points earlier than you could by running them externally and attaching. In fact by the time the server and/or portal are running the launcher will have exited already.

+
    +
  1. Go to Run > Edit Configutations...

  2. +
  3. Click the plus-symbol to add a new configuration and choose Python

  4. +
  5. Add the script: \<yourrepo\>\evenv\Scripts\evennia_launcher.py (substitute your virtualenv if it’s not named evenv)

  6. +
  7. Set script parameters to: start -l (-l enables console logging)

  8. +
  9. Ensure the chosen interpreter is from your virtualenv

  10. +
  11. Set Working directory to your mygame folder (not evenv nor evennia)

  12. +
  13. You can refer to the PyCharm documentation for general info, but you’ll want to set at least a config name (like “MyMUD start” or similar).

  14. +
+

Now set up a “stop” configuration by following the same steps as above, but set your Script parameters to: stop (and name the configuration appropriately).

+

A dropdown box holding your new configurations should appear next to your PyCharm run button. Select MyMUD start and press the debug icon to begin debugging. Depending on how far you let the program run, you may need to run your “MyMUD stop” config to actually stop the server, before you’ll be able start it again.

+
+

Alternative config - utilizing logfiles as source of data

+

This configuration takes a bit different approach as instead of focusing on getting the data back through logfiles. Reason for that is this way you can easily separate data streams, for example you rarely want to follow both server and portal at the same time, and this will allow it. This will also make sure to stop the evennia before starting it, essentially working as reload command (it will also include instructions how to disable that part of functionality). We will start by defining a configuration that will stop evennia. This assumes that upfire is your pycharm project name, and also the game name, hence the upfire/upfire path.

+
    +
  1. Go to Run > Edit Configutations...\

  2. +
  3. Click the plus-symbol to add a new configuration and choose the python interpreter to use (should be project default)

  4. +
  5. Name the configuration as “stop evennia” and fill rest of the fields accordingly to the image: +Stop run configuration

  6. +
  7. Press Apply

  8. +
+

Now we will define the start/reload command that will make sure that evennia is not running already, and then start the server in one go.

+
    +
  1. Go to Run > Edit Configutations...\

  2. +
  3. Click the plus-symbol to add a new configuration and choose the python interpreter to use (should be project default)

  4. +
  5. Name the configuration as “start evennia” and fill rest of the fields accordingly to the image: +Start run configuration

  6. +
  7. Navigate to the Logs tab and add the log files you would like to follow. The picture shows +adding portal.log which will show itself in portal tab when running: +Configuring logs following

  8. +
  9. Skip the following steps if you don’t want the launcher to stop evennia before starting.

  10. +
  11. Head back to Configuration tab and press the + sign at the bottom, under Before launch.... +and select Run another configuration from the submenu that will pop up.

  12. +
  13. Click stop evennia and make sure that it’s added to the list like on the image above.

  14. +
  15. Click Apply and close the run configuration window.

  16. +
+

You are now ready to go, and if you will fire up start evennia configuration you should see +following in the bottom panel: +Example of running alternative configuration +and you can click through the tabs to check appropriate logs, or even the console output as it is +still running in interactive mode.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Soft-Code.html b/docs/latest/Coding/Soft-Code.html new file mode 100644 index 0000000000..eb277b0437 --- /dev/null +++ b/docs/latest/Coding/Soft-Code.html @@ -0,0 +1,194 @@ + + + + + + + + + Soft Code — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Soft Code

+

Softcode is a simple programming language that was created for in-game development on TinyMUD derivatives such as MUX, PennMUSH, TinyMUSH, and RhostMUSH. The idea was that by providing a stripped down, minimalistic language for in-game use, you could allow quick and easy building and game development to happen without builders having to learn the ‘hardcode’ language for those servers (C/C++). There is an added benefit of not having to have to hand out shell access to all developers. Permissions in softcode can be used to alleviate many security problems.

+

Writing and installing softcode is done through a MUD client. Thus it is not a formatted language. Each softcode function is a single line of varying size. Some functions can be a half of a page long or more which is obviously not very readable nor (easily) maintainable over time.

+
+

Examples of Softcode

+

Here is a simple ‘Hello World!’ command:

+
    @set me=HELLO_WORLD.C:$hello:@pemit %#=Hello World!
+
+
+

Pasting this into a MUD client, sending it to a MUX/MUSH server and typing ‘hello’ will theoretically yield ‘Hello World!’, assuming certain flags are not set on your account object.

+

Setting attributes in Softcode is done via @set. Softcode also allows the use of the ampersand (&) symbol. This shorter version looks like this:

+
    &HELLO_WORLD.C me=$hello:@pemit %#=Hello World!
+
+
+

We could also read the text from an attribute which is retrieved when emitting:

+
    &HELLO_VALUE.D me=Hello World
+    &HELLO_WORLD.C me=$hello:@pemit %#=[v(HELLO_VALUE.D)]
+
+
+

The v() function returns the HELLO_VALUE.D attribute on the object that the command resides (me, which is yourself in this case). This should yield the same output as the first example.

+

If you are curious about how MUSH/MUX Softcode works, take a look at some external resources:

+ +
+
+

Problems with Softcode

+

Softcode is excellent at what it was intended for: simple things. It is a great tool for making an interactive object, a room with ambiance, simple global commands, simple economies and coded systems. However, once you start to try to write something like a complex combat system or a higher end economy, you’re likely to find yourself buried under a mountain of functions that span multiple objects across your entire code.

+

Not to mention, softcode is not an inherently fast language. It is not compiled, it is parsed with each calling of a function. While MUX and MUSH parsers have jumped light years ahead of where they once were, they can still stutter under the weight of more complex systems if those are not designed properly.

+

Also, Softcode is not a standardized language. Different servers each have their own slight variations. Code tools and resources are also limited to the documentation from those servers.

+
+
+

Changing Times

+

Now that starting text-based games is easy and an option for even the most technically inarticulate, new projects are a dime a dozen. People are starting new MUDs every day with varying levels of commitment and ability. Because of this shift from fewer, larger, well-staffed games to a bunch of small, one or two developer games, the benefit of softcode fades.

+

Softcode is great in that it allows a mid to large sized staff all work on the same game without stepping on one another’s toes without shell access. However, the rise of modern code collaboration tools (such as private github/gitlab repos) has made it trivial to collaborate on code.

+
+
+

Our Solution

+

Evennia shuns in-game softcode for on-disk Python modules. Python is a popular, mature and professional programming language. Evennia developers have access to the entire library of Python modules out there in the wild - not to mention the vast online help resources available. Python code is not bound to one-line functions on objects; complex systems may be organized neatly into real source code modules, sub-modules, or even broken out into entire Python packages as desired.

+

So what is not included in Evennia is a MUX/MOO-like online player-coding system (aka Softcode). Advanced coding in Evennia is primarily intended to be done outside the game, in full-fledged Python modules (what MUSH would call ‘hardcode’). Advanced building is best handled by extending Evennia’s command system with your own sophisticated building commands.

+

In Evennia you develop your MU like you would any piece of modern software - using your favorite code editor/IDE and online code sharing tools.

+
+
+

Your Solution

+

Adding advanced and flexible building commands to your game is easy and will probably be enough to satisfy most creative builders. However, if you really, really want to offer online coding, there is of course nothing stopping you from adding that to Evennia, no matter our recommendations. You could even re-implement MUX’ softcode in Python should you be very ambitious.

+

In default Evennia, the Funcparser system allows for simple remapping of text on-demand without becomeing a full softcode language. The contribs has several tools and utililities to start from when adding more complex in-game building.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Unit-Testing.html b/docs/latest/Coding/Unit-Testing.html new file mode 100644 index 0000000000..37f89cd5b4 --- /dev/null +++ b/docs/latest/Coding/Unit-Testing.html @@ -0,0 +1,438 @@ + + + + + + + + + Unit Testing — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Unit Testing

+

Unit testing means testing components of a program in isolation from each other to make sure every part works on its own before using it with others. Extensive testing helps avoid new updates causing unexpected side effects as well as alleviates general code rot (a more comprehensive wikipedia article on unit testing can be found here).

+

A typical unit test set calls some function or method with a given input, looks at the result and makes sure that this result looks as expected. Rather than having lots of stand-alone test programs, Evennia makes use of a central test runner. This is a program that gathers all available tests all over the Evennia source code (called test suites) and runs them all in one go. Errors and tracebacks are reported.

+

By default Evennia only tests itself. But you can also add your own tests to your game code and have Evennia run those for you.

+
+

Running the Evennia test suite

+

To run the full Evennia test suite, go to your game folder and issue the command

+
evennia test evennia
+
+
+

This will run all the evennia tests using the default settings. You could also run only a subset of +all tests by specifying a subpackage of the library:

+
evennia test evennia.commands.default
+
+
+

A temporary database will be instantiated to manage the tests. If everything works out you will see +how many tests were run and how long it took. If something went wrong you will get error messages. +If you contribute to Evennia, this is a useful sanity check to see you haven’t introduced an +unexpected bug.

+
+
+

Running custom game-dir unit tests

+

If you have implemented your own tests for your game you can run them from your game dir +with

+
evennia test --settings settings.py .
+
+
+

The period (.) means to run all tests found in the current directory and all subdirectories. You +could also specify, say, typeclasses or world if you wanted to just run tests in those subdirs.

+

An important thing to note is that those tests will all be run using the default Evennia settings. +To run the tests with your own settings file you must use the --settings option:

+
evennia test --settings settings.py .
+
+
+

The --settings option of Evennia takes a file name in the mygame/server/conf folder. It is +normally used to swap settings files for testing and development. In combination with test, it +forces Evennia to use this settings file over the default one.

+

You can also test specific things by giving their path

+
evennia test --settings settings.py world.tests.YourTest
+
+
+
+
+

Writing new unit tests

+

Evennia’s test suite makes use of Django unit test system, which in turn relies on Python’s +unittest module.

+

To make the test runner find the tests, they must be put in a module named test*.py (so test.py, +tests.py etc). Such a test module will be found wherever it is in the package. It can be a good +idea to look at some of Evennia’s tests.py modules to see how they look.

+

Inside the module you need to put a class inheriting (at any distance) from unittest.TestCase. Each +method on that class that starts with test_ will be run separately as a unit test. There +are two special, optional methods setUp and tearDown that will (if you define them) run before +every test. This can be useful for setting up and deleting things.

+

To actually test things, you use special assert... methods on the class. Most common on is +assertEqual, which makes sure a result is what you expect it to be.

+

Here’s an example of the principle. Let’s assume you put this in mygame/world/tests.py +and want to test a function in mygame/world/myfunctions.py

+
    # in a module tests.py somewhere i your game dir
+    import unittest
+
+    from evennia import create_object
+    # the function we want to test
+    from .myfunctions import myfunc
+
+    
+    class TestObj(unittest.TestCase):
+       "This tests a function myfunc."
+
+       def setUp(self):
+           """done before every of the test_ * methods below"""
+           self.obj = create_object("mytestobject")
+           
+       def tearDown(self):
+           """done after every test_* method below """
+           self.obj.delete()
+       
+       def test_return_value(self):
+           """test method. Makes sure return value is as expected."""
+           actual_return = myfunc(self.obj)
+           expected_return = "This is the good object 'mytestobject'."
+           # test
+           self.assertEqual(expected_return, actual_return)
+       def test_alternative_call(self):
+           """test method. Calls with a keyword argument."""
+           actual_return = myfunc(self.obj, bad=True)
+           expected_return = "This is the baaad object 'mytestobject'."
+           # test
+           self.assertEqual(expected_return, actual_return)
+
+
+

To test this, run

+
evennia test --settings settings.py .
+
+
+

to run the entire test module

+
evennia test --settings settings.py world.tests
+
+
+

or a specific class:

+
evennia test --settings settings.py world.tests.TestObj 
+
+
+

You can also run a specific test:

+
evennia test --settings settings.py world.tests.TestObj.test_alternative_call
+
+
+

You might also want to read the Python documentation for the unittest module.

+
+

Using the Evennia testing classes

+

Evennia offers many custom testing classes that helps with testing Evennia features. +They are all found in evennia.utils.test_resources. Note that +these classes implement the setUp and tearDown already, so if you want to add stuff in them +yourself you should remember to use e.g. super().setUp() in your code.

+
+

Classes for testing your game dir

+

These all use whatever setting you pass to them and works well for testing code in your game dir.

+
    +
  • EvenniaTest - this sets up a full object environment for your test. All the created entities +can be accesses as properties on the class:

    +
      +
    • .account - A fake Account named “TestAccount”.

    • +
    • .account2 - Another account named “TestAccount2”

    • +
    • char1 - A Character linked to .account, named Char. +This has ‘Developer’ permissions but is not a superuser.

    • +
    • .char2 - Another character linked to account, named Char2. This has base permissions (player).

    • +
    • .obj1 - A regular Object named “Obj”.

    • +
    • .obj2 - Another object named “Obj2”.

    • +
    • .room1 - A Room named “Room”. Both characters and both +objects are located inside this room. It has a description of “room_desc”.

    • +
    • .room2 - Another room named “Room2”. It is empty and has no set description.

    • +
    • .exit - An exit named “out” that leads from .room1 to .room2.

    • +
    • .script - A Script named “Script”. It’s an inert script +without a timing component.

    • +
    • .session - A fake Session that mimics a player +connecting to the game. It is used by .account1 and has a sessid of 1.

    • +
    +
  • +
  • EvenniaCommandTest - has the same environment like EvenniaTest but also adds a special +.call() method specifically for +testing Evennia Commands. It allows you to compare what the command actually +returns to the player with what you expect. Read the call api doc for more info.

  • +
  • EvenniaTestCase - This is identical to the regular Python TestCase class, it’s +just there for naming symmetry with BaseEvenniaTestCase below.

  • +
+

Here’s an example of using EvenniaTest

+
# in a test module
+
+from evennia.utils.test_resources import EvenniaTest
+
+class TestObject(EvenniaTest):
+    """Remember that the testing class creates char1 and char2 inside room1 ..."""
+    def test_object_search_character(self):
+        """Check that char1 can search for char2 by name"""
+        self.assertEqual(self.char1.search(self.char2.key), self.char2)
+        
+    def test_location_search(self):
+        """Check so that char1 can find the current location by name"""
+        self.assertEqual(self.char1.search(self.char1.location.key), self.char1.location)
+        # ...
+
+
+

This example tests a custom command.

+
    from evennia.commands.default.tests import EvenniaCommandTest
+from commands import command as mycommand
+
+
+class TestSet(EvenniaCommandTest):
+    "tests the look command by simple call, using Char2 as a target"
+
+    def test_mycmd_char(self):
+        self.call(mycommand.CmdMyLook(), "Char2", "Char2(#7)")
+
+    def test_mycmd_room(self):
+        "tests the look command by simple call, with target as room"
+        self.call(mycommand.CmdMyLook(), "Room",
+                  "Room(#1)\nroom_desc\nExits: out(#3)\n"
+                  "You see: Obj(#4), Obj2(#5), Char2(#7)")
+
+
+

When using .call, you don’t need to specify the entire string; you can just give the beginning +of it and if it matches, that’s enough. Use \n to denote line breaks and (this is a special for +the .call helper), || to indicate multiple uses of .msg() in the Command. The .call helper +has a lot of arguments for mimicing different ways of calling a Command, so make sure to +read the API docs for .call().

+
+
+

Classes for testing Evennia core

+

These are used for testing Evennia itself. They provide the same resources as the classes +above but enforce Evennias default settings found in evennia/settings_default.py, ignoring +any settings changes in your game dir.

+
    +
  • BaseEvenniaTest - all the default objects above but with enforced default settings

  • +
  • BaseEvenniaCommandTest - for testing Commands, but with enforced default settings

  • +
  • BaseEvenniaTestCase - no default objects, only enforced default settings

  • +
+

There are also two special ‘mixin’ classes. These are uses in the classes above, but may also +be useful if you want to mix your own testing classes:

+
    +
  • EvenniaTestMixin - A class mixin that creates all test environment objects.

  • +
  • EvenniaCommandMixin - A class mixin that adds the .call() Command-tester helper.

  • +
+

If you want to help out writing unittests for Evennia, take a look at Evennia’s coveralls.io +page. There you see which modules have any form of +test coverage and which does not. All help is appreciated!

+
+
+
+

Unit testing contribs with custom models

+

A special case is if you were to create a contribution to go to the evennia/contrib folder that +uses its own database models. The problem with this is that Evennia (and Django) will +only recognize models in settings.INSTALLED_APPS. If a user wants to use your contrib, they will +be required to add your models to their settings file. But since contribs are optional you cannot +add the model to Evennia’s central settings_default.py file - this would always create your +optional models regardless of if the user wants them. But at the same time a contribution is a part +of the Evennia distribution and its unit tests should be run with all other Evennia tests using +evennia test evennia.

+

The way to do this is to only temporarily add your models to the INSTALLED_APPS directory when the test runs. here is an example of how to do it.

+
+

Note that this solution, derived from this stackexchange answer is currently untested! Please report your findings.

+
+
# a file contrib/mycontrib/tests.py
+
+from django.conf import settings
+import django
+from evennia.utils.test_resources import BaseEvenniaTest
+
+OLD_DEFAULT_SETTINGS = settings.INSTALLED_APPS
+DEFAULT_SETTINGS = dict(
+    INSTALLED_APPS=(
+        'contrib.mycontrib.tests',
+    ),
+    DATABASES={
+        "default": {
+            "ENGINE": "django.db.backends.sqlite3"
+        }
+    },
+    SILENCED_SYSTEM_CHECKS=["1_7.W001"],
+)
+
+
+class TestMyModel(BaseEvenniaTest):
+    def setUp(self):
+        if not settings.configured:
+            settings.configure(**DEFAULT_SETTINGS)
+        django.setup()
+
+        from django.core.management import call_command
+        from django.db.models import loading
+        loading.cache.loaded = False
+        call_command('syncdb', verbosity=0)
+
+    def tearDown(self):
+        settings.configure(**OLD_DEFAULT_SETTINGS)
+        django.setup()
+
+        from django.core.management import call_command
+        from django.db.models import loading
+        loading.cache.loaded = False
+        call_command('syncdb', verbosity=0)
+
+    # test cases below ...
+
+    def test_case(self):
+# test case here
+
+
+
+
+

A note on making the test runner faster

+

If you have custom models with a large number of migrations, creating the test database can take a very long time. If you don’t require migrations to run for your tests, you can disable them with the +django-test-without-migrations package. To install it, simply:

+
$ pip install django-test-without-migrations
+
+
+

Then add it to your INSTALLED_APPS in your server.conf.settings.py:

+
INSTALLED_APPS = (
+    # ...
+    'test_without_migrations',
+)
+
+
+

After doing so, you can then run tests without migrations by adding the --nomigrations argument:

+
evennia test --settings settings.py --nomigrations .
+
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Coding/Version-Control.html b/docs/latest/Coding/Version-Control.html new file mode 100644 index 0000000000..ecb9e455e0 --- /dev/null +++ b/docs/latest/Coding/Version-Control.html @@ -0,0 +1,492 @@ + + + + + + + + + Coding using Version Control — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Coding using Version Control

+

Version control allows you to track changes to your code. You can save ‘snapshots’ of your progress which means you can roll back undo things easily. Version control also allows you to easily back up your code to an online repository such as Github. It also allows you to collaborate with others on the same code without clashing or worry about who changed what.

+ +

Evennia uses the most commonly used version control system, Git . For additional help on using Git, please refer to the Official GitHub documentation.

+
+

Setting up Git

+
    +
  • Fedora Linux

    +
      yum install git-core
    +
    +
    +
  • +
  • Debian Linux (Ubuntu, Linux Mint, etc.)

    +
      apt-get install git
    +
    +
    +
  • +
  • Windows: It is recommended to use Git for Windows.

  • +
  • Mac: Mac platforms offer two methods for installation, one via MacPorts, which you can find out about here, or you can use the Git OSX Installer.

  • +
+
+

You can find expanded instructions for installation here.

+
+ +

To avoid a common issue later, you will need to set a couple of settings; first you will need to tell Git your username, followed by your e-mail address, so that when you commit code later you will be properly credited.

+
    +
  1. Set the default name for git to use when you commit:

    +
     git config --global user.name "Your Name Here"
    +
    +
    +
  2. +
  3. Set the default email for git to use when you commit:

    +
     git config --global user.email "your_email@example.com"
    +
    +
    +
  4. +
+
+

To get a running start with Git, here’s a good YouTube talk about it. It’s a bit long but it will help you understand the underlying ideas behind GIT (which in turn makes it a lot more intuitive to use).

+
+
+
+

Common Git commands

+ +

Git can be controlled via a GUI. But it’s often easier to use the base terminal/console commands, since it makes it clear if something goes wrong.

+

All these actions need to be done from inside the git repository .

+

Git may seem daunting at first. But when working with git, you’ll be using the same 2-3 commands 99% of the time. And you can make git aliases to have them be even easier to remember.

+
+

git init

+

This initializes a folder/directory on your drive as a ‘git repository’

+
git init .
+
+
+

The . means to apply to the current directory. If you are inside mygame, this makes your game dir into a git repository. That’s all there is to it, really. You only need to do this once.

+
+
+

git add

+
git add <file> 
+
+
+

This tells Git to start to track the file under version control. You need to do this when you create a new file. You can also add all files in your current directory:

+
git add . 
+
+
+

Or

+
git add *
+
+
+

All files in the current directory are now tracked by Git. You only need to do this once for every file you want to track.

+
+
+

git commit

+
git commit -a -m "This is the initial commit"
+
+
+

This commits your changes. It stores a snapshot of all (-a) your code at the current time, adding a message -m so you know what you did. Later you can check out your code the way it was at a given time. The message is mandatory and you will thank yourself later if write clear and descriptive log messages. If you don’t add -m, a text editor opens for you to write the message instead.

+

The git commit is something you’ll be using all the time, so it can be useful to make a git alias for it:

+
git config --global alias.cma 'commit -a -m'
+
+
+

After you’ve run this, you can commit much simpler, like this:

+
git cma "This is the initial commit"
+
+
+

Much easier to remember!

+
+
+

git status, git diff and git log

+
git status -s 
+
+
+

This gives a short (-s) of the files that changes since your last git commit.

+
git diff --word-diff`
+
+
+

This shows exactly what changed in each file since you last made a git commit. The --word-diff option means it will mark if a single word changed on a line.

+
git log
+
+
+

This shows the log of all commits done. Each log will show you who made the change, the commit-message and a unique hash (like ba214f12ab12e123...) that uniquely describes that commit.

+

You can make the log command more succinct with some more options:

+
ls=log --pretty=format:%C(green)%h\ %C(yellow)[%ad]%Cred%d\ %Creset%s%Cblue\ [%an] --decorate --date=relative
+
+
+

This adds coloration and another fancy effects (use git help log to see what they mean).

+

Let’s add aliases:

+
git config --global alias.st 'status -s'
+git config --global alias.df 'diff --word-diff'
+git config --global alias.ls 'log --pretty=format:%C(green)%h\ %C(yellow)[%ad]%Cred%d\ %Creset%s%Cblue\ [%an] --decorate --date=relative'
+
+
+

You can now use the much shorter

+
git st    # short status
+git dif   # diff with word-marking
+git ls    # log with pretty formatting
+
+
+

for these useful functions.

+
+
+

git branch, checkout and merge

+

Git allows you to work with branches. These are separate development paths your code may take, completely separate from each other. You can later merge the code from a branch back into another branch. Evennia’s main and develop branches are examples of this.

+
git branch -b branchaname 
+
+
+

This creates a new branch, exactly identical to the branch you were on. It also moves you to that branch.

+
git branch -D branchname 
+
+
+

Deletes a branch.

+
git branch 
+
+
+

Shows all your branches, marking which one you are currently on.

+
git checkout branchname 
+
+
+

This checks out another branch. As long as you are in a branch all git commits will commit the code to that branch only.

+
git checkout .
+
+
+

This checks out your current branch and has the effect of throwing away all your changes since your last commit. This is like undoing what you did since the last save point.

+
git checkout b2342bc21c124
+
+
+

This checks out a particular commit, identified by the hash you find with git log. This open a ‘temporary branch’ where the code is as it was when you made this commit. As an example, you can use this to check where a bug was introduced. Check out an existing branch to go back to your normal timeline, or use git branch -b newbranch to break this code off into a new branch you can continue working from.

+
git merge branchname
+
+
+

This merges the code from branchname into the branch you are currently in. Doing so may lead to merge conflicts if the same code changed in different ways in the two branches. See how to resolve merge conflicts in git for more help.

+
+
+

git glone, git push and git pull

+

All of these other commands have dealt with code only sitting in your local repository-folder. These commands instead allows you to exchange code with a remote repository - usually one that is online (like on github).

+
+

How you actually set up a remote repository is described in the next section.

+
+
git clone repository/path
+
+
+

This copies the remote repository to your current location. If you used the Git installation instructions to install Evennia, this is what you used to get your local copy of the Evennia repository.

+
git pull
+
+
+

Once you cloned or otherwise set up a remote repository, using git pull will re-sync the remote with what you have locally. If what you download clashes with local changes, git will force you to git commit your changes before you can continue with git pull.

+
git push 
+
+
+

This uploads your local changes of your current branch to the same-named branch on the remote repository. To be able to do this you must have write-permissions to the remote repository.

+
+
+

Other git commands

+

There are many other git commands. Read up on them online:

+
git reflog 
+
+
+

Shows hashes of individual git actions. This allows you to go back in the git event history itself.

+
git reset 
+
+
+

Force reset a branch to an earlier commit. This could throw away some history, so be careful.

+
git grep -n -I -i <query>
+
+
+

Quickly search for a phrase/text in all files tracked by git. Very useful to quickly find where things are. Set up an alias git gr with

+
git config --global alias.gr 'grep -n -I -i'
+
+
+
+
+
+

Putting your game dir under version control

+

This makes use of the git commands listed in the previous section.

+ +
cd mygame 
+git init . 
+git add *
+git commit -a -m "Initial commit"
+
+
+

Your game-dir is now tracked by git.

+

You will notice that some files are not covered by your git version control, notably your secret-settings file (mygame/server/conf/secret_settings.py) and your sqlite3 database file mygame/server/evennia.db3. This is intentional and controlled from the file mygame/.gitignore.

+
+

Warning

+

You should never put your sqlite3 database file into git by removing its entry +in .gitignore. GIT is for backing up your code, not your database. That way +lies madness and a good chance you’ll confuse yourself. Make one mistake or local change and after a few commits and reverts you will have lost track of what is in your database or not. If you want to backup your SQlite3 database, do so by simply copying the database file to a safe location.

+
+
+

Pushing your code online

+

So far your code is only located on your private machine. A good idea is to back it up online. The easiest way to do this is to git push it to your own remote repository on GitHub. So for this you need a (free) Github account.

+

If you don’t want your code to be publicly visible, Github also allows you set up a private repository, only visible to you.

+

Create a new, empty repository on Github. Github explains how here . Don’t allow it to add a README, license etc, that will just clash with what we upload later.

+ +

Make sure you are in your local game dir (previously initialized as a git repo).

+
git remote add origin <github URL>
+
+
+

This tells Git that there is a remote repository at <github URL>. See the github docs as to which URL to use. Verify that the remote works with git remote -v

+

Now we push to the remote (labeled ‘origin’ which is the default):

+
git push
+
+
+

Depending on how you set up your authentication with github, you may be asked to enter your github username and password. If you set up SSH authentication, this command will just work.

+

You use git push to upload your local changes so the remote repository is in sync with your local one. If you edited a file online using the Github editor (or a collaborator pushed code), you use git pull to sync in the other direction.

+
+
+
+

Contributing to Evennia

+

If you want to help contributing to Evennia you must do so by forking - making your own remote copy of the Evennia repository on Github. So for this, you need a (free) Github account. Doing so is a completely separate process from putting your game dir under version control (which you should also do!).

+

At the top right of the evennia github page, click the “Fork” button:

+

fork button

+

This will create a new online fork Evennia under your github account.

+

The fork only exists online as of yet. In a terminal, cd to the folder you wish to develop in. This folder should not be your game dir, nor the place you cloned Evennia into if you used the Git installation.

+

From this directory run the following command:

+
git clone https://github.com/yourusername/evennia.git evennia
+
+
+

This will download your fork to your computer. It creates a new folder evennia/ at your current location. If you installed Evennia using the Git installation, this folder will be identical in content to the evennia folder you cloned during that installation. The difference is that this repo is connected to your remote fork and not to the ‘original’ upstream Evennia.

+

When we cloned our fork, git automatically set up a ‘remote repository’ labeled origin pointing to it. So if we do git pull and git push, we’ll push to our fork.

+

We now want to add a second remote repository linked to the original Evennia repo. We will label this remote repository upstream:

+
cd evennia
+git remote add upstream https://github.com/evennia/evennia.git
+
+
+

If you also want to access Evennia’s develop branch (the bleeding edge development) do the following:

+
git fetch upstream develop
+git checkout develop
+
+
+

Use

+
git checkout main
+git checkout develop
+
+
+

to switch between the branches.

+

To pull the latest from upstream Evennia, just checkout the branch you want and do

+
git pull upstream
+
+
+ +
+

Fixing an Evennia bug or feature

+

This should be done in your fork of Evennia. You should always do this in a separate git branch based off the Evennia branch you want to improve.

+
git checkout main (or develop)
+git branch -b myfixbranch
+
+
+

Now fix whatever needs fixing. Abide by the Evennia code style. You can git commit commit your changes along the way as normal.

+

Upstream Evennia is not standing still, so you want to make sure that your work is up-to-date with upstream changes. Make sure to first commit your myfixbranch changes, then

+
git checkout main (or develop)
+git pull upstream 
+git checkout myfixbranch
+git merge main (or develop)
+
+
+

Up to this point your myfixbranch branch only exists on your local computer. No +one else can see it.

+
git push
+
+
+

This will automatically create a matching myfixbranch in your forked version of Evennia and push to it. On github you will be able to see appear it in the branches dropdown. You can keep pushing to your remote myfixbranch as much as you like.

+

Once you feel you have something to share, you need to create a pull request (PR): +This is a formal request for upstream Evennia to adopt and pull your code into the main repository.

+
    +
  1. Click New pull request

  2. +
  3. Choose compare across forks

  4. +
  5. Select your fork from dropdown list of head repository repos. Pick the right branch to compare.

  6. +
  7. On the Evennia side (to the left) make sure to pick the right base branch: If you want to contribute a change to the develop branch, you must pick develop as the base.

  8. +
  9. Then click Create pull request and fill in as much information as you can in the form.

  10. +
  11. Optional: Once you saved your PR, you can go into your code (on github) and add some per-line comments; this can help reviewers by explaining complex code or decisions you made.

  12. +
+

Now you just need to wait for your code to be reviewed. Expect to get feedback and be asked to make changes, add more documentation etc. Getting as PR merged can take a few iterations.

+ +
+
+
+

Troubleshooting

+
+

Getting 403: Forbidden access

+

Some users have experienced this on git push to their remote repository. They are not asked for username/password (and don’t have a ssh key set up).

+

Some users have reported that the workaround is to create a file .netrc under your home directory and add your github credentials there:

+
machine github.com
+login <my_github_username>
+password <my_github_password>
+
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Accounts.html b/docs/latest/Components/Accounts.html new file mode 100644 index 0000000000..c612aec0fb --- /dev/null +++ b/docs/latest/Components/Accounts.html @@ -0,0 +1,221 @@ + + + + + + + + + Accounts — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Accounts

+
┌──────┐ │   ┌───────┐    ┌───────┐   ┌──────┐
+│Client├─┼──►│Session├───►│Account├──►│Object│  
+└──────┘ │   └───────┘    └───────┘   └──────┘
+                              ^
+
+
+

An Account represents a unique game account - one player playing the game. Whereas a player can potentially connect to the game from several Clients/Sessions, they will normally have only one Account.

+

The Account object has no in-game representation. In order to actually get on the game the Account must puppet an Object (normally a Character).

+

Exactly how many Sessions can interact with an Account and its Puppets at once is determined by +Evennia’s MULTISESSION_MODE

+

Apart from storing login information and other account-specific data, the Account object is what is chatting on Evennia’s default Channels. It is also a good place to store Permissions to be consistent between different in-game characters. It can also hold player-level configuration options.

+

The Account object has its own default CmdSet, the AccountCmdSet. The commands in this set are available to the player no matter which character they are puppeting. Most notably the default game’s exit, who and chat-channel commands are in the Account cmdset.

+
> ooc 
+
+
+

The default ooc command causes you to leave your current puppet and go into OOC mode. In this mode you have no location and have only the Account-cmdset available. It acts a staging area for switching characters (if your game supports that) as well as a safety fallback if your character gets accidentally deleted.

+
> ic 
+
+
+

This re-puppets the latest character.

+

Note that the Account object can have, and often does have, a different set of Permissions from the Character they control. Normally you should put your permissions on the Account level - this will overrule permissions set on the Character level. For the permissions of the Character to come into play the default quell command can be used. This allows for exploring the game using a different permission set (but you can’t escalate your permissions this way - for hierarchical permissions like Builder, Admin etc, the lower of the permissions on the Character/Account will always be used).

+
+

Working with Accounts

+

You will usually not want more than one Account typeclass for all new accounts.

+

An Evennia Account is, per definition, a Python class that includes evennia.DefaultAccount among its parents. In mygame/typeclasses/accounts.py there is an empty class ready for you to modify. Evennia defaults to using this (it inherits directly from DefaultAccount).

+

Here’s an example of modifying the default Account class in code:

+
    # in mygame/typeclasses/accounts.py
+
+    from evennia import DefaultAccount
+
+    class Account(DefaultAccount): 
+        # [...]
+    	def at_account_creation(self): 
+        	"this is called only once, when account is first created"
+    	    self.db.real_name = None      # this is set later 
+    	    self.db.real_address = None   #       "
+    	    self.db.config_1 = True       # default config 
+    	    self.db.config_2 = False      #       "
+    	    self.db.config_3 = 1          #       "
+    
+    	    # ... whatever else our game needs to know 
+
+
+

Reload the server with reload.

+

… However, if you use examine *self (the asterisk makes you examine your Account object rather than your Character), you won’t see your new Attributes yet. This is because at_account_creation is only called the very first time the Account is called and your Account object already exists (any new Accounts that connect will see them though). To update yourself you need to make sure to re-fire the hook on all the Accounts you have already created. Here is an example of how to do this using py:

+

py [account.at_account_creation() for account in evennia.managers.accounts.all()]

+

You should now see the Attributes on yourself.

+
+

If you wanted Evennia to default to a completely different Account class located elsewhere, you > must point Evennia to it. Add BASE_ACCOUNT_TYPECLASS to your settings file, and give the python path to your custom class as its value. By default this points to typeclasses.accounts.Account, the empty template we used above.

+
+
+

Properties on Accounts

+

Beyond those properties assigned to all typeclassed objects (see Typeclasses), the Account also has the following custom properties:

+
    +
  • user - a unique link to a User Django object, representing the logged-in user.

  • +
  • obj - an alias for character.

  • +
  • name - an alias for user.username

  • +
  • sessions - an instance of ObjectSessionHandler managing all connected Sessions (physical connections) this object listens to (Note: In older versions of Evennia, this was a list). The so-called session-id (used in many places) is found as a property sessid on each Session instance.

  • +
  • is_superuser (bool: True/False) - if this account is a superuser.

  • +
+

Special handlers:

+
    +
  • cmdset - This holds all the current Commands of this Account. By default these are +the commands found in the cmdset defined by settings.CMDSET_ACCOUNT.

  • +
  • nicks - This stores and handles Nicks, in the same way as nicks it works on Objects. For Accounts, nicks are primarily used to store custom aliases for Channels.

  • +
+

Selection of special methods (see evennia.DefaultAccount for details):

+
    +
  • get_puppet - get a currently puppeted object connected to the Account and a given session id, if any.

  • +
  • puppet_object - connect a session to a puppetable Object.

  • +
  • unpuppet_object - disconnect a session from a puppetable Object.

  • +
  • msg - send text to the Account

  • +
  • execute_cmd - runs a command as if this Account did it.

  • +
  • search - search for Accounts.

  • +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Attributes.html b/docs/latest/Components/Attributes.html new file mode 100644 index 0000000000..0c628a246d --- /dev/null +++ b/docs/latest/Components/Attributes.html @@ -0,0 +1,622 @@ + + + + + + + + + Attributes — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Attributes

+
+
In-game
+
> set obj/myattr = "test"
+
+
+
+
+
In-code, using the .db wrapper
+
obj.db.foo = [1, 2, 3, "bar"]
+value = obj.db.foo
+
+
+
+
+
In-code, using the .attributes handler
+
obj.attributes.add("myattr", 1234, category="bar")
+value = attributes.get("myattr", category="bar")
+
+
+
+
+
In-code, using AttributeProperty at class level
+
from evennia import DefaultObject
+from evennia import AttributeProperty
+
+class MyObject(DefaultObject):
+    foo = AttributeProperty(default=[1, 2, 3, "bar"])
+    myattr = AttributeProperty(100, category='bar')
+
+
+
+

Attributes allow you to to store arbitrary data on objects and make sure the data survives a server reboot. An Attribute can store pretty much any Python data structure and data type, like numbers, strings, lists, dicts etc. You can also store (references to) database objects like characters and rooms.

+
+

Working with Attributes

+

Attributes are usually handled in code. All Typeclassed entities (Accounts, Objects, Scripts and Channels) can (and usually do) have Attributes associated with them. There are three ways to manage Attributes, all of which can be mixed.

+
+

Using .db

+

The simplest way to get/set Attributes is to use the .db shortcut. This allows for setting and getting Attributes that lack a category (having category None)

+
import evennia
+
+obj = evennia.create_object(key="Foo")
+
+obj.db.foo1 = 1234
+obj.db.foo2 = [1, 2, 3, 4]
+obj.db.weapon = "sword"
+obj.db.self_reference = obj   # stores a reference to the obj
+
+# (let's assume a rose exists in-game)
+rose = evennia.search_object(key="rose")[0]  # returns a list, grab 0th element
+rose.db.has_thorns = True
+
+# retrieving
+val1 = obj.db.foo1
+val2 = obj.db.foo2
+weap = obj.db.weapon
+myself = obj.db.self_reference  # retrieve reference from db, get object back
+
+is_ouch = rose.db.has_thorns
+
+# this will return None, not AttributeError!
+not_found = obj.db.jiwjpowiwwerw
+
+# returns all Attributes on the object
+obj.db.all
+
+# delete an Attribute
+del obj.db.foo2
+
+
+

Trying to access a non-existing Attribute will never lead to an AttributeError. Instead you will get None back. The special .db.all will return a list of all Attributes on the object. You can replace this with your own Attribute all if you want, it will replace the default all functionality until you delete it again.

+
+
+

Using .attributes

+

If you want to group your Attribute in a category, or don’t know the name of the Attribute beforehand, you can make use of the AttributeHandler, available as .attributes on all typeclassed entities. With no extra keywords, this is identical to using the .db shortcut (.db is actually using the AttributeHandler internally):

+
is_ouch = rose.attributes.get("has_thorns")
+
+obj.attributes.add("helmet", "Knight's helmet")
+helmet = obj.attributes.get("helmet")
+
+# you can give space-separated Attribute-names (can't do that with .db)
+obj.attributes.add("my game log", "long text about ...")
+
+
+

By using a category you can separate same-named Attributes on the same object to help organization.

+
# store (let's say we have gold_necklace and ringmail_armor from before)
+obj.attributes.add("neck", gold_necklace, category="clothing")
+obj.attributes.add("neck", ringmail_armor, category="armor")
+
+# retrieve later - we'll get back gold_necklace and ringmail_armor
+neck_clothing = obj.attributes.get("neck", category="clothing")
+neck_armor = obj.attributes.get("neck", category="armor")
+
+
+

If you don’t specify a category, the Attribute’s category will be None and can thus also be found via .db. None is considered a category of its own, so you won’t find None-category Attributes mixed with Attributes having categories.

+

Here are the methods of the AttributeHandler. See the AttributeHandler API for more details.

+
    +
  • has(...) - this checks if the object has an Attribute with this key. This is equivalent to doing obj.db.attrname except you can also check for a specific `category.

  • +
  • get(...) - this retrieves the given Attribute. You can also provide a default value to return if the Attribute is not defined (instead of None). By supplying an accessing_object to the call one can also make sure to check permissions before modifying anything. The raise_exception kwarg allows you to raise an AttributeError instead of returning None when you access a non-existing Attribute. The strattr kwarg tells the system to store the Attribute as a raw string rather than to pickle it. While an optimization this should usually not be used unless the Attribute is used for some particular, limited purpose.

  • +
  • add(...) - this adds a new Attribute to the object. An optional lockstring can be supplied here to restrict future access and also the call itself may be checked against locks.

  • +
  • remove(...) - Remove the given Attribute. This can optionally be made to check for permission before performing the deletion. - clear(...) - removes all Attributes from object.

  • +
  • all(category=None) - returns all Attributes (of the given category) attached to this object.

  • +
+

Examples:

+
try:
+  # raise error if Attribute foo does not exist
+  val = obj.attributes.get("foo", raise_exception=True):
+except AttributeError:
+   # ...
+
+# return default value if foo2 doesn't exist
+val2 = obj.attributes.get("foo2", default=[1, 2, 3, "bar"])
+
+# delete foo if it exists (will silently fail if unset, unless
+# raise_exception is set)
+obj.attributes.remove("foo")
+
+# view all clothes on obj
+all_clothes = obj.attributes.all(category="clothes")
+
+
+
+
+

Using AttributeProperty

+

The third way to set up an Attribute is to use an AttributeProperty. This is done on the class level of your typeclass and allows you to treat Attributes a bit like Django database Fields. Unlike using .db and .attributes, an AttributeProperty can’t be created on the fly, you must assign it in the class code.

+
# mygame/typeclasses/characters.py
+
+from evennia import DefaultCharacter
+from evennia.typeclasses.attributes import AttributeProperty
+
+class Character(DefaultCharacter):
+
+    strength = AttributeProperty(10, category='stat')
+    constitution = AttributeProperty(11, category='stat')
+    agility = AttributeProperty(12, category='stat')
+    magic = AttributeProperty(13, category='stat')
+
+    sleepy = AttributeProperty(False, autocreate=False)
+    poisoned = AttributeProperty(False, autocreate=False)
+
+    def at_object_creation(self):
+      # ...
+
+
+

When a new instance of the class is created, new Attributes will be created with the value and category given.

+

With AttributeProperty’s set up like this, one can access the underlying Attribute like a regular property on the created object:

+
char = create_object(Character)
+
+char.strength   # returns 10
+char.agility = 15  # assign a new value (category remains 'stat')
+
+char.db.magic  # returns None (wrong category)
+char.attributes.get("agility", category="stat")  # returns 15
+
+char.db.sleepy # returns None because autocreate=False (see below)
+
+
+
+
+

Warning

+

Be careful to not assign AttributeProperty’s to names of properties and methods already existing on the class, like ‘key’ or ‘at_object_creation’. That could lead to very confusing errors.

+
+

The autocreate=False (default is True) used for sleepy and poisoned is worth a closer explanation. When False, no Attribute will be auto-created for these AttributProperties unless they are explicitly set.

+

The advantage of not creating an Attribute is that the default value given to AttributeProperty is returned with no database access unless you change it. This also means that if you want to change the default later, all entities previously create will inherit the new default.

+

The drawback is that without a database precense you can’t find the Attribute via .db and .attributes.get (or by querying for it in other ways in the database):

+
char.sleepy   # returns False, no db access
+
+char.db.sleepy   # returns None - no Attribute exists
+char.attributes.get("sleepy")  # returns None too
+
+char.sleepy = True  # now an Attribute is created
+char.db.sleepy   # now returns True!
+char.attributes.get("sleepy")  # now returns True
+
+char.sleepy  # now returns True, involves db access
+
+
+

You can e.g. del char.strength to set the value back to the default (the value defined in the AttributeProperty).

+

See the AttributeProperty API for more details on how to create it with special options, like giving access-restrictions.

+
+

Warning

+

While the AttributeProperty uses the AttributeHandler (.attributes) under the hood, the reverse is not true. The AttributeProperty has helper methods, like at_get and at_set. These will only be called if you access the Attribute using the property.

+

That is, if you do obj.yourattribute = 1, the AttributeProperty.at_set will be called. But while doing obj.db.yourattribute = 1, will lead to the same Attribute being saved, this is ‘bypassing’ the AttributeProperty and using the AttributeHandler directly. So in this case the AttributeProperty.at_set will not be called. If you added some special functionality in at_get this may be confusing.

+

To avoid confusion, you should aim to be consistent in how you access your Attributes - if you use a AttributeProperty to define it, use that also to access and modify the Attribute later.

+
+
+
+

Properties of Attributes

+

An Attribute object is stored in the database. It has the following properties:

+
    +
  • key - the name of the Attribute. When doing e.g. obj.db.attrname = value, this property is set to attrname.

  • +
  • value - this is the value of the Attribute. This value can be anything which can be pickled - objects, lists, numbers or what have you (see this section for more info). In the example +obj.db.attrname = value, the value is stored here.

  • +
  • category - this is an optional property that is set to None for most Attributes. Setting this allows to use Attributes for different functionality. This is usually not needed unless you want to use Attributes for very different functionality (Nicks is an example of using +Attributes in this way). To modify this property you need to use the Attribute Handler

  • +
  • strvalue - this is a separate value field that only accepts strings. This severely limits the data possible to store, but allows for easier database lookups. This property is usually not used except when re-using Attributes for some other purpose (Nicks use it). It is only accessible via the Attribute Handler.

  • +
+

There are also two special properties:

+
    +
  • attrtype - this is used internally by Evennia to separate Nicks, from Attributes (Nicks use Attributes behind the scenes).

  • +
  • model - this is a natural-key describing the model this Attribute is attached to. This is on the form appname.modelclass, like objects.objectdb. It is used by the Attribute and NickHandler to quickly sort matches in the database. Neither this nor attrtype should normally need to be modified.

  • +
+

Non-database attributes are not stored in the database and have no equivalence to category nor strvalue, attrtype or model.

+
+
+

Managing Attributes in-game

+

Attributes are mainly used by code. But one can also allow the builder to use Attributes to ‘turn knobs’ in-game. For example a builder could want to manually tweak the “level” Attribute of an enemy NPC to lower its difficuly.

+

When setting Attributes this way, you are severely limited in what can be stored - this is because giving players (even builders) the ability to store arbitrary Python would be a severe security problem.

+

In game you can set an Attribute like this:

+
set myobj/foo = "bar"
+
+
+

To view, do

+
set myobj/foo
+
+
+

or see them together with all object-info with

+
examine myobj
+
+
+

The first set-example will store a new Attribute foo on the object myobj and give it the value “bar”. You can store numbers, booleans, strings, tuples, lists and dicts this way. But if you store a list/tuple/dict they must be proper Python structures and may only contain strings +or numbers. If you try to insert an unsupported structure, the input will be converted to a +string.

+
set myobj/mybool = True
+set myobj/mybool = True
+set myobj/mytuple = (1, 2, 3, "foo")
+set myobj/mylist = ["foo", "bar", 2]
+set myobj/mydict = {"a": 1, "b": 2, 3: 4}
+set mypobj/mystring = [1, 2, foo]   # foo is invalid Python (no quotes)
+
+
+

For the last line you’ll get a warning and the value instead will be saved as a string "[1, 2, foo]".

+
+
+

Locking and checking Attributes

+

While the set command is limited to builders, individual Attributes are usually not locked down. You may want to lock certain sensitive Attributes, in particular for games where you allow player building. You can add such limitations by adding a lock string to your Attribute. A NAttribute have no locks.

+

The relevant lock types are

+
    +
  • attrread - limits who may read the value of the Attribute

  • +
  • attredit - limits who may set/change this Attribute

  • +
+

You must use the AttributeHandler to assign the lockstring to the Attribute:

+
lockstring = "attread:all();attredit:perm(Admins)"
+obj.attributes.add("myattr", "bar", lockstring=lockstring)"
+
+
+

If you already have an Attribute and want to add a lock in-place you can do so by having the AttributeHandler return the Attribute object itself (rather than its value) and then assign the lock to it directly:

+
     lockstring = "attread:all();attredit:perm(Admins)"
+     obj.attributes.get("myattr", return_obj=True).locks.add(lockstring)
+
+
+

Note the return_obj keyword which makes sure to return the Attribute object so its LockHandler could be accessed.

+

A lock is no good if nothing checks it – and by default Evennia does not check locks on Attributes. To check the lockstring you provided, make sure you include accessing_obj and set default_access=False as you make a get call.

+
    # in some command code where we want to limit
+    # setting of a given attribute name on an object
+    attr = obj.attributes.get(attrname,
+                              return_obj=True,
+                              accessing_obj=caller,
+                              default=None,
+                              default_access=False)
+    if not attr:
+        caller.msg("You cannot edit that Attribute!")
+        return
+    # edit the Attribute here
+
+
+

The same keywords are available to use with obj.attributes.set() and obj.attributes.remove(), those will check for the attredit lock type.

+
+
+
+

What types of data can I save in an Attribute?

+

The database doesn’t know anything about Python objects, so Evennia must serialize Attribute values into a string representation before storing it to the database. This is done using the pickle module of Python.

+
+

The only exception is if you use the strattr keyword of the AttributeHandler to save to the strvalue field of the Attribute. In that case you can only save strings and those will not be pickled).

+
+
+

Storing single objects

+

With a single object, we mean anything that is not iterable, like numbers, strings or custom class instances without the __iter__ method.

+
    +
  • You can generally store any non-iterable Python entity that can be pickled.

  • +
  • Single database objects/typeclasses can be stored, despite them normally not being possible to pickle. Evennia will convert them to an internal representation using theihr classname, database-id and creation-date with a microsecond precision. When retrieving, the object instance will be re-fetched from the database using this information.

  • +
  • If you ‘hide’ a db-obj as a property on a custom class, Evennia will not be able to find it to serialize it. For that you need to help it out (see below).

  • +
+
+
Valid assignments
+
# Examples of valid single-value  attribute data:
+obj.db.test1 = 23
+obj.db.test1 = False
+# a database object (will be stored as an internal representation)
+obj.db.test2 = myobj
+
+
+
+

As mentioned, Evennia will not be able to automatically serialize db-objects ‘hidden’ in arbitrary properties on an object. This will lead to an error when saving the Attribute.

+
+
Invalid, ‘hidden’ dbobject
+
# example of storing an invalid, "hidden" dbobject in Attribute
+class Container:
+    def __init__(self, mydbobj):
+        # no way for Evennia to know this is a database object!
+        self.mydbobj = mydbobj
+
+# let's assume myobj is a db-object
+container = Container(myobj)
+obj.db.mydata = container  # will raise error!
+
+
+
+

By adding two methods __serialize_dbobjs__ and __deserialize_dbobjs__ to the object you want to save, you can pre-serialize and post-deserialize all ‘hidden’ objects before Evennia’s main serializer gets to work. Inside these methods, use Evennia’s evennia.utils.dbserialize.dbserialize and dbunserialize functions to safely serialize the db-objects you want to store.

+
+
Fixing an invalid ‘hidden’ dbobj for storing in Attribute
+
from evennia.utils import dbserialize  # important
+
+class Container:
+    def __init__(self, mydbobj):
+        # A 'hidden' db-object
+        self.mydbobj = mydbobj
+
+    def __serialize_dbobjs__(self):
+        """This is called before serialization and allows
+        us to custom-handle those 'hidden' dbobjs"""
+        self.mydbobj = dbserialize.dbserialize(self.mydbobj
+
+    def __deserialize_dbobjs__(self):
+        """This is called after deserialization and allows you to
+        restore the 'hidden' dbobjs you serialized before"""
+        if isinstance(self.mydbobj, bytes):
+            # make sure to check if it's bytes before trying dbunserialize
+            self.mydbobj = dbserialize.dbunserialize(self.mydbobj)
+
+# let's assume myobj is a db-object
+container = Container(myobj)
+obj.db.mydata = container  # will now work fine!
+
+
+
+
+

Note the extra check in __deserialize_dbobjs__ to make sure the thing you are deserializing is a bytes object. This is needed because the Attribute’s cache reruns deserializations in some situations when the data was already once deserialized. If you see errors in the log saying Could not unpickle data for storage: ..., the reason is likely that you forgot to add this check.

+
+
+
+

Storing multiple objects

+

This means storing objects in a collection of some kind and are examples of iterables, pickle-able entities you can loop over in a for-loop. Attribute-saving supports the following iterables:

+
    +
  • Tuples, like (1,2,"test", <dbobj>).

  • +
  • Lists, like [1,2,"test", <dbobj>].

  • +
  • Dicts, like {1:2, "test":<dbobj>].

  • +
  • Sets, like {1,2,"test",<dbobj>}.

  • +
  • collections.OrderedDict, +like OrderedDict((1,2), ("test", <dbobj>)).

  • +
  • collections.Deque, like deque((1,2,"test",<dbobj>)).

  • +
  • collections.DefaultDict like defaultdict(list).

  • +
  • Nestings of any combinations of the above, like lists in dicts or an OrderedDict of tuples, each containing dicts, etc.

  • +
  • All other iterables (i.e. entities with the __iter__ method) will be converted to a list. Since you can use any combination of the above iterables, this is generally not much of a limitation.

  • +
+

Any entity listed in the Single object section above can be stored in the iterable.

+
+

As mentioned in the previous section, database entities (aka typeclasses) are not possible to pickle. So when storing an iterable, Evennia must recursively traverse the iterable and all its nested sub-iterables in order to find eventual database objects to convert. This is a very fast process but for efficiency you may want to avoid too deeply nested structures if you can.

+
+
# examples of valid iterables to store
+obj.db.test3 = [obj1, 45, obj2, 67]
+# a dictionary
+obj.db.test4 = {'str':34, 'dex':56, 'agi':22, 'int':77}
+# a mixed dictionary/list
+obj.db.test5 = {'members': [obj1,obj2,obj3], 'enemies':[obj4,obj5]}
+# a tuple with a list in it
+obj.db.test6 = (1, 3, 4, 8, ["test", "test2"], 9)
+# a set
+obj.db.test7 = set([1, 2, 3, 4, 5])
+# in-situ manipulation
+obj.db.test8 = [1, 2, {"test":1}]
+obj.db.test8[0] = 4
+obj.db.test8[2]["test"] = 5
+# test8 is now [4,2,{"test":5}]
+
+
+

Note that if make some advanced iterable object, and store an db-object on it in a way such that it is not returned by iterating over it, you have created a ‘hidden’ db-object. See the previous section for how to tell Evennia how to serialize such hidden objects safely.

+
+
+

Retrieving Mutable objects

+

A side effect of the way Evennia stores Attributes is that mutable iterables (iterables that can be modified in-place after they were created, which is everything except tuples) are handled by custom objects called _SaverList, _SaverDict etc. These _Saver... classes behave just like the normal variant except that they are aware of the database and saves to it whenever new data gets assigned to them. This is what allows you to do things like self.db.mylist[7] = val and be sure that the new version of list is saved. Without this you would have to load the list into a temporary variable, change it and then re-assign it to the Attribute in order for it to save.

+

There is however an important thing to remember. If you retrieve your mutable iterable into another variable, e.g. mylist2 = obj.db.mylist, your new variable (mylist2) will still be a _SaverList. This means it will continue to save itself to the database whenever it is updated!

+
obj.db.mylist = [1, 2, 3, 4]
+mylist = obj.db.mylist
+
+mylist[3] = 5  # this will also update database
+
+print(mylist)  # this is now [1, 2, 3, 5]
+print(obj.db.mylist)  # now also [1, 2, 3, 5]
+
+
+

When you extract your mutable Attribute data into a variable like mylist, think of it as getting a snapshot of the variable. If you update the snapshot, it will save to the database, but this change will not propagate to any other snapshots you may have done previously.

+
obj.db.mylist = [1, 2, 3, 4]
+mylist1 = obj.db.mylist
+mylist2 = obj.db.mylist
+mylist1[3] = 5
+
+print(mylist1)  # this is now [1, 2, 3, 5]
+print(obj.db.mylist)  # also updated to [1, 2, 3, 5]
+
+print(mylist2)  # still [1, 2, 3, 4]  !
+
+
+
+ +

To avoid confusion with mutable Attributes, only work with one variable (snapshot) at a time and save back the results as needed.

+

You can also choose to “disconnect” the Attribute entirely from the +database with the help of the .deserialize() method:

+
obj.db.mylist = [1, 2, 3, 4, {1: 2}]
+mylist = obj.db.mylist.deserialize()
+
+
+

The result of this operation will be a structure only consisting of normal Python mutables (list instead of _SaverList, dict instead of _SaverDict and so on). If you update it, you need to explicitly save it back to the Attribute for it to save.

+
+
+
+

In-memory Attributes (NAttributes)

+

NAttributes (short of Non-database Attributes) mimic Attributes in most things except they +are non-persistent - they will not survive a server reload.

+
    +
  • Instead of .db use .ndb.

  • +
  • Instead of .attributes use .nattributes

  • +
  • Instead of AttributeProperty, use NAttributeProperty.

  • +
+
    rose.ndb.has_thorns = True
+    is_ouch = rose.ndb.has_thorns
+
+    rose.nattributes.add("has_thorns", True)
+    is_ouch = rose.nattributes.get("has_thorns")
+
+
+

Differences between Attributes and NAttributes:

+
    +
  • NAttributes are always wiped on a server reload.

  • +
  • They only exist in memory and never involve the database at all, making them faster to +access and edit than Attributes.

  • +
  • NAttributes can store any Python structure (and database object) without limit. However, if you were to delete a database object you previously stored in an NAttribute, the NAttribute will not know about this and may give you a python object without a matching database entry. In comparison, an Attribute always checks this). If this is a concern, use an Attribute or check that the object’s .pk property is not None before saving it.

  • +
  • They can not be set with the standard set command (but they are visible with examine)

  • +
+

There are some important reasons we recommend using ndb to store temporary data rather than the simple alternative of just storing a variable directly on an object: +

+
    +
  • NAttributes are tracked by Evennia and will not be purged in various cache-cleanup operations the server may do. So using them guarantees that they’ll remain available at least as long as the server lives.

  • +
  • It’s a consistent style - .db/.attributes and .ndb/.nattributes makes for clean-looking code where it’s clear how long-lived (or not) your data is to be.

  • +
+
+

Persistent vs non-persistent

+

So persistent data means that your data will survive a server reboot, whereas with +non-persistent data it will not …

+

… So why would you ever want to use non-persistent data? The answer is, you don’t have to. Most of +the time you really want to save as much as you possibly can. Non-persistent data is potentially +useful in a few situations though.

+
    +
  • You are worried about database performance. Since Evennia caches Attributes very aggressively, this is not an issue unless you are reading and writing to your Attribute very often (like many times per second). Reading from an already cached Attribute is as fast as reading any Python property. But even then this is not likely something to worry about: Apart from Evennia’s own caching, modern database systems themselves also cache data very efficiently for speed. Our default database even runs completely in RAM if possible, alleviating much of the need to write to disk during heavy loads.

  • +
  • A more valid reason for using non-persistent data is if you want to lose your state when logging off. Maybe you are storing throw-away data that are re-initialized at server startup. Maybe you are implementing some caching of your own. Or maybe you are testing a buggy Script that does potentially harmful stuff to your character object. With non-persistent storage you can be sure that whatever is messed up, it’s nothing a server reboot can’t clear up.

  • +
  • NAttributes have no restrictions at all on what they can store, since they don’t need to worry about being saved to the database - they work very well for temporary storage.

  • +
  • You want to implement a fully or partly non-persistent world. Who are we to argue with your grand vision!

  • +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Batch-Code-Processor.html b/docs/latest/Components/Batch-Code-Processor.html new file mode 100644 index 0000000000..92671e4a54 --- /dev/null +++ b/docs/latest/Components/Batch-Code-Processor.html @@ -0,0 +1,306 @@ + + + + + + + + + Batch Code Processor — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Batch Code Processor

+

For an introduction and motivation to using batch processors, see here. This page describes the Batch-code processor. The Batch-command one is covered [here](Batch-Command- Processor).

+

The batch-code processor is a superuser-only function, invoked by

+
 > batchcode path.to.batchcodefile
+
+
+

Where path.to.batchcodefile is the path to a batch-code file. Such a file should have a name ending in “.py” (but you shouldn’t include that in the path). The path is given like a python path relative to a folder you define to hold your batch files, set by BATCH_IMPORT_PATH in your settings. Default folder is (assuming your game is called “mygame”) mygame/world/. So if you want to run the example batch file in mygame/world/batch_code.py, you could simply use

+
 > batchcode batch_code
+
+
+

This will try to run through the entire batch file in one go. For more gradual, interactive control you can use the /interactive switch. The switch /debug will put the processor in debug mode. Read below for more info.

+
+

The batch file

+

A batch-code file is a normal Python file. The difference is that since the batch processor loads and executes the file rather than importing it, you can reliably update the file, then call it again, over and over and see your changes without needing to reload the server. This makes for easy testing. In the batch-code file you have also access to the following global variables:

+
    +
  • caller - This is a reference to the object running the batchprocessor.

  • +
  • DEBUG - This is a boolean that lets you determine if this file is currently being run in debug-mode or not. See below how this can be useful.

  • +
+

Running a plain Python file through the processor will just execute the file from beginning to end. If you want to get more control over the execution you can use the processor’s interactive mode. This runs certain code blocks on their own, rerunning only that part until you are happy with it. In order to do this you need to add special markers to your file to divide it up into smaller chunks. These take the form of comments, so the file remains valid Python.

+
    +
  • #CODE as the first on a line marks the start of a code block. It will last until the beginning of another marker or the end of the file. Code blocks contain functional python code. Each #CODE block will be run in complete isolation from other parts of the file, so make sure it’s self- contained.

  • +
  • #HEADER as the first on a line marks the start of a header block. It lasts until the next marker or the end of the file. This is intended to hold imports and variables you will need for all other blocks .All python code defined in a header block will always be inserted at the top of every #CODE blocks in the file. You may have more than one #HEADER block, but that is equivalent to having one big one. Note that you can’t exchange data between code blocks, so editing a header- variable in one code block won’t affect that variable in any other code block!

  • +
  • #INSERT path.to.file will insert another batchcode (Python) file at that position. - A # that is not starting a #HEADER, #CODE or #INSERT instruction is considered a comment.

  • +
  • Inside a block, normal Python syntax rules apply. For the sake of indentation, each block acts as a separate python module.

  • +
+

Below is a version of the example file found in evennia/contrib/tutorial_examples/.

+
    #
+    # This is an example batch-code build file for Evennia. 
+    #
+    
+    #HEADER
+    
+    # This will be included in all other #CODE blocks
+    
+    from evennia import create_object, search_object
+    from evennia.contrib.tutorial_examples import red_button
+    from typeclasses.objects import Object
+    
+    limbo = search_object('Limbo')[0]
+    
+    
+    #CODE 
+ 
+    red_button = create_object(red_button.RedButton, key="Red button", 
+                               location=limbo, aliases=["button"])
+    
+    # caller points to the one running the script
+    caller.msg("A red button was created.")
+    
+    # importing more code from another batch-code file
+    #INSERT batch_code_insert
+    
+    #CODE
+    
+    table = create_object(Object, key="Blue Table", location=limbo)
+    chair = create_object(Object, key="Blue Chair", location=limbo)
+    
+    string = f"A {table} and {chair} were created."
+    if DEBUG:
+        table.delete()
+        chair.delete()
+        string += " Since debug was active, they were deleted again." 
+    caller.msg(string)
+
+
+

This uses Evennia’s Python API to create three objects in sequence.

+
+
+

Debug mode

+

Try to run the example script with

+
 > batchcode/debug tutorial_examples.example_batch_code
+
+
+

The batch script will run to the end and tell you it completed. You will also get messages that the button and the two pieces of furniture were created. Look around and you should see the button there. But you won’t see any chair nor a table! This is because we ran this with the /debug switch, which is directly visible as DEBUG==True inside the script. In the above example we handled this state by deleting the chair and table again.

+

The debug mode is intended to be used when you test out a batchscript. Maybe you are looking for bugs in your code or try to see if things behave as they should. Running the script over and over would then create an ever-growing stack of chairs and tables, all with the same name. You would have to go back and painstakingly delete them later.

+
+
+

Interactive mode

+

Interactive mode works very similar to the [batch-command processor counterpart](Batch-Command- Processor). It allows you more step-wise control over how the batch file is executed. This is useful for debugging or for picking and choosing only particular blocks to run. Use batchcode with the /interactive flag to enter interactive mode.

+
 > batchcode/interactive tutorial_examples.batch_code
+
+
+

You should see the following:

+
01/02: red_button = create_object(red_button.RedButton, [...]         (hh for help) 
+
+
+

This shows that you are on the first #CODE block, the first of only two commands in this batch file. Observe that the block has not actually been executed at this point!

+

To take a look at the full code snippet you are about to run, use ll (a batch-processor version of look).

+
    from evennia.utils import create, search
+    from evennia.contrib.tutorial_examples import red_button
+    from typeclasses.objects import Object
+    
+    limbo = search.objects(caller, 'Limbo', global_search=True)[0]
+
+    red_button = create.create_object(red_button.RedButton, key="Red button", 
+                                      location=limbo, aliases=["button"])
+    
+    # caller points to the one running the script
+    caller.msg("A red button was created.")
+
+
+

Compare with the example code given earlier. Notice how the content of #HEADER has been pasted at the top of the #CODE block. Use pp to actually execute this block (this will create the button +and give you a message). Use nn (next) to go to the next command. Use hh for a list of commands.

+

If there are tracebacks, fix them in the batch file, then use rr to reload the file. You will still be at the same code block and can rerun it easily with pp as needed. This makes for a simple debug cycle. It also allows you to rerun individual troublesome blocks - as mentioned, in a large batch file this can be very useful (don’t forget the /debug mode either).

+

Use nn and bb (next and back) to step through the file; e.g. nn 12 will jump 12 steps forward (without processing any blocks in between). All normal commands of Evennia should work too while working in interactive mode.

+
+
+

Limitations and Caveats

+

The batch-code processor is by far the most flexible way to build a world in Evennia. There are however some caveats you need to keep in mind.

+
+

Safety

+

Or rather the lack of it. There is a reason only superusers are allowed to run the batch-code +processor by default. The code-processor runs without any Evennia security checks and allows +full access to Python. If an untrusted party could run the code-processor they could execute +arbitrary python code on your machine, which is potentially a very dangerous thing. If you want to +allow other users to access the batch-code processor you should make sure to run Evennia as a +separate and very limited-access user on your machine (i.e. in a ‘jail’). By comparison, the batch- +command processor is much safer since the user running it is still ‘inside’ the game and can’t +really do anything outside what the game commands allow them to.

+
+
+

No communication between code blocks

+

Global variables won’t work in code batch files, each block is executed as stand-alone environments. #HEADER blocks are literally pasted on top of each #CODE block so updating some header-variable in your block will not make that change available in another block. Whereas a python execution limitation, allowing this would also lead to very hard-to-debug code when using the interactive mode - this would be a classical example of “spaghetti code”.

+

The main practical issue with this is when building e.g. a room in one code block and later want to connect that room with a room you built in the current block. There are two ways to do this:

+
    +
  • Perform a database search for the name of the room you created (since you cannot know in advance which dbref it got assigned). The problem is that a name may not be unique (you may have a lot of “A dark forest” rooms). There is an easy way to handle this though - use Tags or Aliases. You can assign any number of tags and/or aliases to any object. Make sure that one of those tags or aliases is unique to the room (like “room56”) and you will henceforth be able to always uniquely search and find it later.

  • +
  • Use the caller global property as an inter-block storage. For example, you could have a dictionary of room references in an ndb:

    +
    #HEADER 
    +if caller.ndb.all_rooms is None:
    +    caller.ndb.all_rooms = {}
    +
    +#CODE 
    +# create and store the castle
    +castle = create_object("rooms.Room", key="Castle")
    +caller.ndb.all_rooms["castle"] = castle
    +
    +#CODE 
    +# in another node we want to access the castle
    +castle = caller.ndb.all_rooms.get("castle")
    +
    +
    +
  • +
+

Note how we check in #HEADER if caller.ndb.all_rooms doesn’t already exist before creating the dict. Remember that #HEADER is copied in front of every #CODE block. Without that if statement +we’d be wiping the dict every block!

+
+
+

Don’t treat a batchcode file like any Python file

+

Despite being a valid Python file, a batchcode file should only be run by the batchcode processor. You should not do things like define Typeclasses or Commands in them, or import them into other code. Importing a module in Python will execute base level of the module, which in the case of your average batchcode file could mean creating a lot of new objects every time.

+
+
+

Don’t let code rely on the batch-file’s real file path

+

When you import things into your batchcode file, don’t use relative imports but always import with paths starting from the root of your game directory or evennia library. Code that relies on the batch file’s “actual” location will fail. Batch code files are read as text and the strings executed. When the code runs it has no knowledge of what file those strings where once a part of.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Batch-Command-Processor.html b/docs/latest/Components/Batch-Command-Processor.html new file mode 100644 index 0000000000..85a07dab49 --- /dev/null +++ b/docs/latest/Components/Batch-Command-Processor.html @@ -0,0 +1,267 @@ + + + + + + + + + Batch Command Processor — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Batch Command Processor

+

For an introduction and motivation to using batch processors, see here. This +page describes the Batch-command processor. The Batch-code one is covered [here](Batch-Code- +Processor).

+

The batch-command processor is a superuser-only function, invoked by

+
 > batchcommand path.to.batchcmdfile
+
+
+

Where path.to.batchcmdfile is the path to a batch-command file with the “.ev” file ending. +This path is given like a python path relative to a folder you define to hold your batch files, set +with BATCH_IMPORT_PATH in your settings. Default folder is (assuming your game is in the mygame +folder) mygame/world. So if you want to run the example batch file in +mygame/world/batch_cmds.ev, you could use

+
 > batchcommand batch_cmds
+
+
+

A batch-command file contains a list of Evennia in-game commands separated by comments. The +processor will run the batch file from beginning to end. Note that it will not stop if commands in +it fail (there is no universal way for the processor to know what a failure looks like for all +different commands). So keep a close watch on the output, or use Interactive mode (see below) to +run the file in a more controlled, gradual manner.

+
+

The batch file

+

The batch file is a simple plain-text file containing Evennia commands. Just like you would write +them in-game, except you have more freedom with line breaks.

+

Here are the rules of syntax of an *.ev file. You’ll find it’s really, really simple:

+
    +
  • All lines having the # (hash)-symbol as the first one on the line are considered comments. All non-comment lines are treated as a command and/or their arguments.

  • +
  • Comment lines have an actual function – they mark the end of the previous command definition. So never put two commands directly after one another in the file - separate them with a comment, or the second of the two will be considered an argument to the first one. Besides, using plenty of comments is good practice anyway.

  • +
  • A line that starts with the word #INSERT is a comment line but also signifies a special instruction. The syntax is #INSERT <path.batchfile> and tries to import a given batch-cmd file into this one. The inserted batch file (file ending .ev) will run normally from the point of the #INSERT instruction.

  • +
  • Extra whitespace in a command definition is ignored. - A completely empty line translates in to a line break in texts. Two empty lines thus means a new paragraph (this is obviously only relevant for commands accepting such formatting, such as the @desc command).

  • +
  • The very last command in the file is not required to end with a comment.

  • +
  • You cannot nest another batchcommand statement into your batch file. If you want to link many batch-files together, use the #INSERT batch instruction instead. You also cannot launch the batchcode command from your batch file, the two batch processors are not compatible.

  • +
+

Below is a version of the example file found in evennia/contrib/tutorial_examples/batch_cmds.ev.

+
    #
+    # This is an example batch build file for Evennia. 
+    #
+    
+    # This creates a red button
+    @create button:tutorial_examples.red_button.RedButton
+    # (This comment ends input for @create)
+    # Next command. Let's create something. 
+    @set button/desc = 
+      This is a large red button. Now and then 
+      it flashes in an evil, yet strangely tantalizing way. 
+    
+      A big sign sits next to it. It says:
+
+    
+    -----------
+    
+     Press me! 
+    
+    -----------
+
+    
+      ... It really begs to be pressed! You 
+    know you want to! 
+    
+    # This inserts the commands from another batch-cmd file named
+    # batch_insert_file.ev.
+    #INSERT examples.batch_insert_file
+    
+      
+    # (This ends the @set command). Note that single line breaks 
+    # and extra whitespace in the argument are ignored. Empty lines 
+    # translate into line breaks in the output.
+    # Now let's place the button where it belongs (let's say limbo #2 is 
+    # the evil lair in our example)
+    @teleport #2
+    # (This comments ends the @teleport command.) 
+    # Now we drop it so others can see it. 
+    # The very last command in the file needs not be ended with #.
+    drop button
+
+
+

To test this, run @batchcommand on the file:

+
> batchcommand contrib.tutorial_examples.batch_cmds
+
+
+

A button will be created, described and dropped in Limbo. All commands will be executed by the user calling the command.

+
+

Note that if you interact with the button, you might find that its description changes, loosing your custom-set description above. This is just the way this particular object works.

+
+
+
+

Interactive mode

+

Interactive mode allows you to more step-wise control over how the batch file is executed. This is useful for debugging and also if you have a large batch file and is only updating a small part of it – running the entire file again would be a waste of time (and in the case of create-ing objects you would to end up with multiple copies of same-named objects, for example). Use batchcommand with the /interactive flag to enter interactive mode.

+
 > @batchcommand/interactive tutorial_examples.batch_cmds
+
+
+

You will see this:

+
01/04: @create button:tutorial_examples.red_button.RedButton  (hh for help) 
+
+
+

This shows that you are on the @create command, the first out of only four commands in this batch file. Observe that the command @create has not been actually processed at this point!

+

To take a look at the full command you are about to run, use ll (a batch-processor version of +look). Use pp to actually process the current command (this will actually @create the button) – and make sure it worked as planned. Use nn (next) to go to the next command. Use hh for a list of commands.

+

If there are errors, fix them in the batch file, then use rr to reload the file. You will still be at the same command and can rerun it easily with pp as needed. This makes for a simple debug cycle. It also allows you to rerun individual troublesome commands - as mentioned, in a large batch file this can be very useful. Do note that in many cases, commands depend on the previous ones (e.g. if create in the example above had failed, the following commands would have had nothing to operate on).

+

Use nn and bb (next and back) to step through the file; e.g. nn 12 will jump 12 steps forward (without processing any command in between). All normal commands of Evennia should work too while working in interactive mode.

+
+
+

Limitations and Caveats

+

The batch-command processor is great for automating smaller builds or for testing new commands and objects repeatedly without having to write so much. There are several caveats you have to be aware of when using the batch-command processor for building larger, complex worlds though.

+

The main issue is that when you run a batch-command script you (you, as in your superuser +character) are actually moving around in the game creating and building rooms in sequence, just as if you had been entering those commands manually, one by one. You have to take this into account when creating the file, so that you can ‘walk’ (or teleport) to the right places in order.

+

This also means there are several pitfalls when designing and adding certain types of objects. Here are some examples:

+
    +
  • Rooms that change your Command Set: Imagine that you build a ‘dark’ room, which severely limits the cmdsets of those entering it (maybe you have to find the light switch to proceed). In your batch script you would create this room, then teleport to it - and promptly be shifted into the dark state where none of your normal build commands work …

  • +
  • Auto-teleportation: Rooms that automatically teleport those that enter them to another place (like a trap room, for example). You would be teleported away too.

  • +
  • Mobiles: If you add aggressive mobs, they might attack you, drawing you into combat. If they have AI they might even follow you around when building - or they might move away from you before you’ve had time to finish describing and equipping them!

  • +
+

The solution to all these is to plan ahead. Make sure that superusers are never affected by whatever effects are in play. Add an on/off switch to objects and make sure it’s always set to off upon creation. It’s all doable, one just needs to keep it in mind.

+
+
+

Editor highlighting for .ev files

+
    +
  • GNU Emacs users might find it interesting to use emacs’ evennia mode. This is an Emacs major mode found in evennia/utils/evennia-mode.el. It offers correct syntax highlighting and indentation with <tab> when editing .ev files in Emacs. See the header of that file for installation instructions.

  • +
  • VIM users can use amfl’s vim-evennia mode instead, see its readme for install instructions.

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Batch-Processors.html b/docs/latest/Components/Batch-Processors.html new file mode 100644 index 0000000000..3a5cc36e68 --- /dev/null +++ b/docs/latest/Components/Batch-Processors.html @@ -0,0 +1,188 @@ + + + + + + + + + Batch Processors — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Batch Processors

+ +

Building a game world is a lot of work, especially when starting out. Rooms should be created, descriptions have to be written, objects must be detailed and placed in their proper places. In many +traditional MUD setups you had to do all this online, line by line, over a telnet session.

+

Evennia already moves away from much of this by shifting the main coding work to external Python modules. But also building would be helped if one could do some or all of it externally. Enter Evennia’s batch processors (there are two of them). The processors allows you, as a game admin, to build your game completely offline in normal text files (batch files) that the processors understands. Then, when you are ready, you use the processors to read it all into Evennia (and into the database) in one go.

+

You can of course still build completely online should you want to - this is certainly the easiest way to go when learning and for small build projects. But for major building work, the advantages of using the batch-processors are many:

+
    +
  • It’s hard to compete with the comfort of a modern desktop text editor; Compared to a traditional MUD line input, you can get much better overview and many more features. Also, accidentally pressing Return won’t immediately commit things to the database.

  • +
  • You might run external spell checkers on your batch files. In the case of one of the batch- processors (the one that deals with Python code), you could also run external debuggers and code analyzers on your file to catch problems before feeding it to Evennia.

  • +
  • The batch files (as long as you keep them) are records of your work. They make a natural starting point for quickly re-building your world should you ever decide to start over.

  • +
  • If you are an Evennia developer, using a batch file is a fast way to setup a test-game after having reset the database.

  • +
  • The batch files might come in useful should you ever decide to distribute all or part of your world to others.

  • +
+

There are two batch processors, the Batch-command processor and the Batch-code processor. The +first one is the simpler of the two. It doesn’t require any programming knowledge - you basically +just list in-game commands in a text file. The code-processor on the other hand is much more +powerful but also more complex - it lets you use Evennia’s API to code your world in full-fledged +Python code.

+
+

A note on File Encodings

+

As mentioned, both the processors take text files as input and then proceed to process them. As long as you stick to the standard ASCII character set (which means the normal English characters, basically) you should not have to worry much about this section.

+

Many languages however use characters outside the simple ASCII table. Common examples are various apostrophes and umlauts but also completely different symbols like those of the greek or cyrillic alphabets.

+

First, we should make it clear that Evennia itself handles international characters just fine. It (and Django) uses unicode strings internally.

+

The problem is that when reading a text file like the batchfile, we need to know how to decode the byte-data stored therein to universal unicode. That means we need an encoding (a mapping) for how the file stores its data. There are many, many byte-encodings used around the world, with opaque names such as Latin-1, ISO-8859-3 or ARMSCII-8 to pick just a few examples. Problem is that it’s practially impossible to determine which encoding was used to save a file just by looking at it (it’s just a bunch of bytes!). You have to know.

+

With this little introduction it should be clear that Evennia can’t guess but has to assume an encoding when trying to load a batchfile. The text editor and Evennia must speak the same “language” so to speak. Evennia will by default first try the international UTF-8 encoding, but you can have Evennia try any sequence of different encodings by customizing the ENCODINGS list in your settings file. Evennia will use the first encoding in the list that do not raise any errors. Only if none work will the server give up and return an error message.

+

You can often change the text editor encoding (this depends on your editor though), otherwise you need to add the editor’s encoding to Evennia’s ENCODINGS list. If you are unsure, write a test file with lots of non-ASCII letters in the editor of your choice, then import to make sure it works as it should.

+

More help with encodings can be found in the entry Text Encodings and also in the Wikipedia article here.

+

A footnote for the batch-code processor: Just because Evennia can parse your file and your +fancy special characters, doesn’t mean that Python allows their use. Python syntax only allows international characters inside strings. In all other source code only ASCII set characters are +allowed.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Channels.html b/docs/latest/Components/Channels.html new file mode 100644 index 0000000000..840c15e4ea --- /dev/null +++ b/docs/latest/Components/Channels.html @@ -0,0 +1,523 @@ + + + + + + + + + Channels — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Channels

+

In a multiplayer game, players often need other means of in-game communication +than moving to the same room and use say or emote.

+

Channels allows Evennia’s to act as a fancy chat program. When a player is +connected to a channel, sending a message to it will automatically distribute +it to every other subscriber.

+

Channels can be used both for chats between Accounts and between +Objects (usually Characters). Chats could be both OOC +(out-of-character) or IC (in-charcter) in nature. Some examples:

+
    +
  • A support channel for contacting staff (OOC)

  • +
  • A general chat for discussing anything and foster community (OOC)

  • +
  • Admin channel for private staff discussions (OOC)

  • +
  • Private guild channels for planning and organization (IC/OOC depending on game)

  • +
  • Cyberpunk-style retro chat rooms (IC)

  • +
  • In-game radio channels (IC)

  • +
  • Group telepathy (IC)

  • +
  • Walkie-talkies (IC)

  • +
+
+

Changed in version 1.0: Channel system changed to use a central ‘channel’ command and nicks instead of +auto-generated channel-commands and -cmdset. ChannelHandler was removed.

+
+
+

Working with channels

+
+

Viewing and joining channels

+

In the default command set, channels are all handled via the mighty channel command, channel (or chan). By default, this command will assume all entities dealing with channels are Accounts.

+

Viewing channels

+
channel       - shows your subscriptions
+channel/all   - shows all subs available to you
+channel/who   - shows who subscribes to this channel
+
+
+

To join/unsub a channel do

+
channel/sub channelname
+channel/unsub channelname
+
+
+

If you temporarily don’t want to hear the channel for a while (without actually +unsubscribing), you can mute it:

+
channel/mute channelname
+channel/unmute channelname
+
+
+
+
+

Talk on channels

+

To speak on a channel, do

+
channel public Hello world!
+
+
+

If the channel-name has spaces in it, you need to use a ‘=’:

+
channel rest room = Hello world!
+
+
+

Now, this is more to type than we’d like, so when you join a channel, the +system automatically sets up an personal alias so you can do this instead:

+
public Hello world
+
+
+
+

Warning

+

This shortcut will not work if the channel-name has spaces in it. +So channels with long names should make sure to provide a one-word alias as +well.

+
+

Any user can make up their own channel aliases:

+
channel/alias public = foo;bar
+
+
+

You can now just do

+
foo Hello world!
+bar Hello again!
+
+
+

And even remove the default one if they don’t want to use it

+
channel/unalias public
+public Hello    (gives a command-not-found error now)
+
+
+

But you can also use your alias with the channel command:

+
channel foo Hello world!
+
+
+
+

What happens when aliasing is that a nick is created that maps your +alias + argument onto calling the channel command. So when you enter foo hello, +what the server sees is actually channel foo = hello. The system is also +clever enough to know that whenever you search for channels, your channel-nicks +should also be considered so as to convert your input to an existing channel name.

+
+

You can check if you missed channel conversations by viewing the channel’s +scrollback with

+
channel/history public
+
+
+

This retrieves the last 20 lines of text (also from a time when you were +offline). You can step further back by specifying how many lines back to start:

+
channel/history public = 30
+
+
+

This again retrieve 20 lines, but starting 30 lines back (so you’ll get lines +30-50 counting backwards).

+
+
+

Channel administration

+

Evennia can create certain channels when it starts. Channels can also +be created on-the-fly in-game.

+
+

Default channels from settings

+

You can specify ‘default’ channels you want to auto-create from the Evennia +settings. New accounts will automatically be subscribed to such ‘default’ channels if +they have the right permissions. This is a list of one dict per channel (example is the default public channel):

+
# in mygame/server/conf/settings.py
+DEFAULT_CHANNELS = [ 
+	{
+         "key": "Public",
+         "aliases": ("pub",),
+         "desc": "Public discussion",
+         "locks": "control:perm(Admin);listen:all();send:all()",
+     },
+]
+
+
+

Each dict is fed as **channeldict into the create_channel function, and thus supports all the same keywords.

+

Evennia also has two system-related channels:

+
    +
  • CHANNEL_MUDINFO is a dict describing the “MudInfo” channel. This is assumed to exist and is a place for Evennia to echo important server information. The idea is that server admins and staff can subscribe to this channel to stay in the loop.

  • +
  • CHANNEL_CONECTINFO is not defined by default. It will receive connect/disconnect-messages and could be visible also for regular players. If not given, connection-info will just be logged quietly.

  • +
+
+
+

Managing channels in-game

+

To create/destroy a new channel on the fly you can do

+
channel/create channelname;alias;alias = description
+channel/destroy channelname
+
+
+

Aliases are optional but can be good for obvious shortcuts everyone may want to +use. The description is used in channel-listings. You will automatically join a +channel you created and will be controlling it. You can also use channel/desc to +change the description on a channel you own later.

+

If you control a channel you can also kick people off it:

+
channel/boot mychannel = annoyinguser123 : stop spamming!
+
+
+

The last part is an optional reason to send to the user before they are booted. +You can give a comma-separated list of channels to kick the same user from all +those channels at once. The user will be unsubbed from the channel and all +their aliases will be wiped. But they can still rejoin if they like.

+
channel/ban mychannel = annoyinguser123
+channel/ban      - view bans
+channel/unban mychannel = annoyinguser123
+
+
+

Banning adds the user to the channels blacklist. This means they will not be +able to rejoin if you boot them. You will need to run channel/boot to +actually kick them out.

+

See the Channel command api docs (and in-game help) for more details.

+

Admin-level users can also modify channel’s locks:

+
channel/lock buildchannel = listen:all();send:perm(Builders)
+
+
+

Channels use three lock-types by default:

+
    +
  • listen - who may listen to the channel. Users without this access will not +even be able to join the channel and it will not appear in listings for them.

  • +
  • send - who may send to the channel.

  • +
  • control - this is assigned to you automatically when you create the channel. With +control over the channel you can edit it, boot users and do other management tasks.

  • +
+
+
+

Restricting channel administration

+

By default everyone can use the channel command (evennia.commands.default.comms.CmdChannel) to create channels and will then control the channels they created (to boot/ban people etc). If you as a developer does not want regular players to do this (perhaps you want only staff to be able to spawn new channels), you can override the channel command and change its locks property.

+

The default help command has the following locks property:

+
    locks = "cmd:not perm(channel_banned); admin:all(); manage:all(); changelocks: perm(Admin)"
+
+
+

This is a regular lockstring.

+
    +
  • cmd: pperm(channel_banned) - The cmd locktype is the standard one used for all Commands. +an accessing object failing this will not even know that the command exists. The pperm() lockfunc +checks an on-account [Permission](Building Permissions) ‘channel_banned’ - and the not means +that if they have that ‘permission’ they are cut off from using the channel command. You usually +don’t need to change this lock.

  • +
  • admin:all() - this is a lock checked in the channel command itself. It controls access to the +/boot, /ban and /unban switches (by default letting everyone use them).

  • +
  • manage:all() - this controls access to the /create, /destroy, /desc switches.

  • +
  • changelocks: perm(Admin) - this controls access to the /lock and /unlock switches. By +default this is something only [Admins](Building Permissions) can change.

  • +
+
+

Note - while admin:all() and manage:all() will let everyone use these switches, users +will still only be able to admin or destroy channels they actually control!

+
+

If you only want (say) Builders and higher to be able to create and admin +channels you could override the help command and change the lockstring to:

+
  # in for example mygame/commands/commands.py
+
+  from evennia import default_cmds
+
+  class MyCustomChannelCmd(default_cmds.CmdChannel):
+      locks = "cmd: not pperm(channel_banned);admin:perm(Builder);manage:perm(Builder);changelocks:perm(Admin)"
+
+
+
+

Add this custom command to your default cmdset and regular users will now get an +access-denied error when trying to use use these switches.

+
+
+
+
+

Using channels in code

+

For most common changes, the default channel, the recipient hooks and possibly +overriding the channel command will get you very far. But you can also tweak +channels themselves.

+
+

Allowing Characters to use Channels

+

The default channel command (evennia.commands.default.comms.CmdChannel) sits in the Account command set. It is set up such that it will always operate on Accounts, even if you were to add it to the CharacterCmdSet.

+

It’s a one-line change to make this command accept non-account callers. But for convenience we provide a version for Characters/Objects. Just import evennia.commands.default.comms.CmdObjectChannel and inherit from that instead.

+
+
+

Customizing channel output and behavior

+

When distributing a message, the channel will call a series of hooks on itself +and (more importantly) on each recipient. So you can customize things a lot by +just modifying hooks on your normal Object/Account typeclasses.

+

Internally, the message is sent with +channel.msg(message, senders=sender, bypass_mute=False, **kwargs), where +bypass_mute=True means the message ignores muting (good for alerts or if you +delete the channel etc) and **kwargs are any extra info you may want to pass +to the hooks. The senders (it’s always only one in the default implementation +but could in principle be multiple) and bypass_mute are part of the kwargs +below:

+
    +
  1. channel.at_pre_msg(message, **kwargs)

  2. +
  3. For each recipient:

    +
      +
    • message = recipient.at_pre_channel_msg(message, channel, **kwargs) - +allows for the message to be tweaked per-receiver (for example coloring it depending +on the users’ preferences). If this method returns False/None, that +recipient is skipped.

    • +
    • recipient.channel_msg(message, channel, **kwargs) - actually sends to recipient.

    • +
    • recipient.at_post_channel_msg(message, channel, **kwargs) - any post-receive effects.

    • +
    +
  4. +
  5. channel.at_post_channel_msg(message, **kwargs)

  6. +
+

Note that Accounts and Objects both have their have separate sets of hooks. +So make sure you modify the set actually used by your subscribers (or both). +Default channels all use Account subscribers.

+
+
+

Channel class

+

Channels are Typeclassed entities. This means they are persistent in the database, can have attributes and Tags and can be easily extended.

+

To change which channel typeclass Evennia uses for default commands, change settings.BASE_CHANNEL_TYPECLASS. The base command class is evennia.comms.comms.DefaultChannel. There is an empty child class in mygame/typeclasses/channels.py, same as for other typelass-bases.

+

In code you create a new channel with evennia.create_channel or +Channel.create:

+
  from evennia import create_channel, search_object
+  from typeclasses.channels import Channel
+
+  channel = create_channel("my channel", aliases=["mychan"], locks=..., typeclass=...)
+  # alternative
+  channel = Channel.create("my channel", aliases=["mychan"], locks=...)
+
+  # connect to it
+  me = search_object(key="Foo")[0]
+  channel.connect(me)
+
+  # send to it (this will trigger the channel_msg hooks described earlier)
+  channel.msg("Hello world!", senders=me)
+
+  # view subscriptions (the SubscriptionHandler handles all subs under the hood)
+  channel.subscriptions.has(me)    # check we subbed
+  channel.subscriptions.all()      # get all subs
+  channel.subscriptions.online()   # get only subs currently online
+  channel.subscriptions.clear()    # unsub all
+
+  # leave channel
+  channel.disconnect(me)
+
+  # permanently delete channel (will unsub everyone)
+  channel.delete()
+
+
+
+

The Channel’s .connect method will accept both Account and Object subscribers +and will handle them transparently.

+

The channel has many more hooks, both hooks shared with all typeclasses as well as special ones related to muting/banning etc. See the channel class for +details.

+
+
+

Channel logging

+
+

Changed in version 0.7: Channels changed from using Msg to TmpMsg and optional log files.

+
+
+

Changed in version 1.0: Channels stopped supporting Msg and TmpMsg, using only log files.

+
+

The channel messages are not stored in the database. A channel is instead always logged to a regular text log-file mygame/server/logs/channel_<channelname>.log. This is where channels/history channelname gets its data from. A channel’s log will rotate when it grows too big, which thus also automatically limits the max amount of history a user can view with +/history.

+

The log file name is set on the channel class as the log_file property. This +is a string that takes the formatting token {channelname} to be replaced with +the (lower-case) name of the channel. By default the log is written to in the +channel’s at_post_channel_msg method.

+
+
+

Properties on Channels

+

Channels have all the standard properties of a Typeclassed entity (key, +aliases, attributes, tags, locks etc). This is not an exhaustive list; +see the Channel api docs for details.

+
    +
  • send_to_online_only - this class boolean defaults to True and is a +sensible optimization since people offline people will not see the message anyway.

  • +
  • log_file - this is a string that determines the name of the channel log file. Default +is "channel_{channelname}.log". The log file will appear in settings.LOG_DIR (usually +mygame/server/logs/). You should usually not change this.

  • +
  • channel_prefix_string - this property is a string to easily change how +the channel is prefixed. It takes the channelname format key. Default is "[{channelname}] " +and produces output like [public] ....

  • +
  • subscriptions - this is the SubscriptionHandler, which +has methods has, add, remove, all, clear and also online (to get +only actually online channel-members).

  • +
  • wholist, mutelist, banlist are properties that return a list of subscribers, +as well as who are currently muted or banned.

  • +
  • channel_msg_nick_pattern - this is a regex pattern for performing the in-place nick +replacement (detect that channelalias <msg means that you want to send a message to a channel). +This pattern accepts an {alias} formatting marker. Don’t mess with this unless you really +want to change how channels work.

  • +
  • channel_msg_nick_replacement - this is a string on the [nick replacement

  • +
  • form](./Nicks.md). It accepts the {channelname} formatting tag. This is strongly tied to the +channel command and is by default channel {channelname} = $1.

  • +
+

Notable Channel hooks:

+
    +
  • at_pre_channel_msg(message, **kwargs) - called before sending a message, to +modify it. Not used by default.

  • +
  • msg(message, senders=..., bypass_mute=False, **kwargs) - send the message onto +the channel. The **kwargs are passed on into the other call hooks (also on the recipient).

  • +
  • at_post_channel_msg(message, **kwargs) - by default this is used to store the message +to the log file.

  • +
  • channel_prefix(message) - this is called to allow the channel to prefix. This is called +by the object/account when they build the message, so if wanting something else one can +also just remove that call.

  • +
  • every channel message. By default it just returns channel_prefix_string.

  • +
  • has_connection(subscriber) - shortcut to check if an entity subscribes to +this channel.

  • +
  • mute/unmute(subscriber) - this mutes the channel for this user.

  • +
  • ban/unban(subscriber) - adds/remove user from banlist.

  • +
  • connect/disconnect(subscriber) - adds/removes a subscriber.

  • +
  • add_user_channel_alias(user, alias, **kwargs) - sets up a user-nick for this channel. This is +what maps e.g. alias <msg> to channel channelname = <msg>.

  • +
  • remove_user_channel_alias(user, alias, **kwargs) - remove an alias. Note that this is +a class-method that will happily remove found channel-aliases from the user linked to any +channel, not only from the channel the method is called on.

  • +
  • pre_join_channel(subscriber) - if this returns False, connection will be refused.

  • +
  • post_join_channel(subscriber) - by default this sets up a users’ channel-nicks/aliases.

  • +
  • pre_leave_channel(subscriber) - if this returns False, the user is not allowed to leave.

  • +
  • post_leave_channel(subscriber) - this will clean up any channel aliases/nicks of the user.

  • +
  • delete the standard typeclass-delete mechanism will also automatically un-subscribe all +subscribers (and thus wipe all their aliases).

  • +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Characters.html b/docs/latest/Components/Characters.html new file mode 100644 index 0000000000..4d6376d0e0 --- /dev/null +++ b/docs/latest/Components/Characters.html @@ -0,0 +1,157 @@ + + + + + + + + + Characters — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Characters

+

**Inheritance Tree:

+
┌─────────────┐
+│DefaultObject│
+└─────▲───────┘
+      │
+┌─────┴──────────┐
+│DefaultCharacter│
+└─────▲──────────┘
+      │           ┌────────────┐
+      │ ┌─────────►ObjectParent│
+      │ │         └────────────┘
+  ┌───┴─┴───┐
+  │Character│
+  └─────────┘
+
+
+

Characters is an in-game Object commonly used to represent the player’s in-game avatar. The empty Character class is found in mygame/typeclasses/characters.py. It inherits from DefaultCharacter and the (by default empty) ObjectParent class (used if wanting to add share properties between all in-game Objects).

+

When a new Account logs in to Evennia for the first time, a new Character object is created and the Account will be set to puppet it. By default this first Character will get the same name as the Account (but Evennia supports alternative connection-styles if so desired).

+

A Character object will usually have a Default Commandset set on itself at creation, or the account will not be able to issue any in-game commands!

+

If you want to change the default character created by the default commands, you can change it in settings:

+
BASE_CHARACTER_TYPECLASS = "typeclasses.characters.Character"
+
+
+

This deafult points at the empty class in mygame/typeclasses/characters.py , ready for you to modify as you please.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Coding-Utils.html b/docs/latest/Components/Coding-Utils.html new file mode 100644 index 0000000000..94b33fc8e4 --- /dev/null +++ b/docs/latest/Components/Coding-Utils.html @@ -0,0 +1,365 @@ + + + + + + + + + Coding Utils — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Coding Utils

+

Evennia comes with many utilities to help with common coding tasks. Most are accessible directly +from the flat API, otherwise you can find them in the evennia/utils/ folder.

+
+

This is just a small selection of the tools in evennia/utils. It’s worth to browse the directory and in particular the content of evennia/utils/utils.py directly to find more useful stuff.

+
+
+

Searching

+

A common thing to do is to search for objects. There it’s easiest to use the search method defined +on all objects. This will search for objects in the same location and inside the self object:

+
     obj = self.search(objname)
+
+
+

The most common time one needs to do this is inside a command body. obj = self.caller.search(objname) will search inside the caller’s (typically, the character that typed the command) .contents (their “inventory”) and .location (their “room”).

+

Give the keyword global_search=True to extend search to encompass entire database. Aliases will also be matched by this search. You will find multiple examples of this functionality in the default command set.

+

If you need to search for objects in a code module you can use the functions in +evennia.utils.search. You can access these as shortcuts evennia.search_*.

+
     from evennia import search_object
+     obj = search_object(objname)
+
+
+ +

Note that these latter methods will always return a list of results, even if the list has one or zero entries.

+
+
+

Create

+

Apart from the in-game build commands (@create etc), you can also build all of Evennia’s game entities directly in code (for example when defining new create commands).

+
   import evennia
+
+   myobj = evennia.create_objects("game.gamesrc.objects.myobj.MyObj", key="MyObj")
+
+
+ +

Each of these create-functions have a host of arguments to further customize the created entity. See evennia/utils/create.py for more information.

+
+
+

Logging

+

Normally you can use Python print statements to see output to the terminal/log. The print +statement should only be used for debugging though. For producion output, use the logger which will create proper logs either to terminal or to file.

+
     from evennia import logger
+     #
+     logger.log_err("This is an Error!")
+     logger.log_warn("This is a Warning!")
+     logger.log_info("This is normal information")
+     logger.log_dep("This feature is deprecated")
+
+
+

There is a special log-message type, log_trace() that is intended to be called from inside a traceback - this can be very useful for relaying the traceback message back to log without having it +kill the server.

+
     try:
+       # [some code that may fail...]
+     except Exception:
+       logger.log_trace("This text will show beneath the traceback itself.")
+
+
+

The log_file logger, finally, is a very useful logger for outputting arbitrary log messages. This is a heavily optimized asynchronous log mechanism using threads to avoid overhead. You should be able to use it for very heavy custom logging without fearing disk-write delays.

+
 logger.log_file(message, filename="mylog.log")
+
+
+

If not an absolute path is given, the log file will appear in the mygame/server/logs/ directory. If the file already exists, it will be appended to. Timestamps on the same format as the normal Evennia logs will be automatically added to each entry. If a filename is not specified, output will be written to a file game/logs/game.log.

+

See also the Debugging documentation for help with finding elusive bugs.

+
+
+

Time Utilities

+
+

Game time

+

Evennia tracks the current server time. You can access this time via the evennia.gametime shortcut:

+
from evennia import gametime
+
+# all the functions below return times in seconds).
+
+# total running time of the server
+runtime = gametime.runtime()
+# time since latest hard reboot (not including reloads)
+uptime = gametime.uptime()
+# server epoch (its start time)
+server_epoch = gametime.server_epoch()
+
+# in-game epoch (this can be set by `settings.TIME_GAME_EPOCH`.
+# If not, the server epoch is used.
+game_epoch = gametime.game_epoch()
+# in-game time passed since time started running
+gametime = gametime.gametime()
+# in-game time plus game epoch (i.e. the current in-game
+# time stamp)
+gametime = gametime.gametime(absolute=True)
+# reset the game time (back to game epoch)
+gametime.reset_gametime()
+
+
+
+

The setting TIME_FACTOR determines how fast/slow in-game time runs compared to the real world. The setting TIME_GAME_EPOCH sets the starting game epoch (in seconds). The functions from the gametime module all return their times in seconds. You can convert this to whatever units of time you desire for your game. You can use the @time command to view the server time info. +You can also schedule things to happen at specific in-game times using the gametime.schedule function:

+
import evennia
+
+def church_clock:
+    limbo = evennia.search_object(key="Limbo")
+    limbo.msg_contents("The church clock chimes two.")
+
+gametime.schedule(church_clock, hour=2)
+
+
+
+
+

utils.time_format()

+

This function takes a number of seconds as input (e.g. from the gametime module above) and converts it to a nice text output in days, hours etc. It’s useful when you want to show how old something is. It converts to four different styles of output using the style keyword:

+
    +
  • style 0 - 5d:45m:12s (standard colon output)

  • +
  • style 1 - 5d (shows only the longest time unit)

  • +
  • style 2 - 5 days, 45 minutes (full format, ignores seconds)

  • +
  • style 3 - 5 days, 45 minutes, 12 seconds (full format, with seconds)

  • +
+
+
+

utils.delay()

+

This allows for making a delayed call.

+
from evennia import utils
+
+def _callback(obj, text):
+    obj.msg(text)
+
+# wait 10 seconds before sending "Echo!" to obj (which we assume is defined)
+utils.delay(10, _callback, obj, "Echo!", persistent=False)
+
+# code here will run immediately, not waiting for the delay to fire!
+
+
+
+

See The Asynchronous process for more information.

+
+
+
+

Finding Classes

+
+

utils.inherits_from()

+

This useful function takes two arguments - an object to check and a parent. It returns True if object inherits from parent at any distance (as opposed to Python’s in-built is_instance() that +will only catch immediate dependence). This function also accepts as input any combination of +classes, instances or python-paths-to-classes.

+

Note that Python code should usually work with duck typing. But in Evennia’s case it can sometimes be useful to check if an object inherits from a given Typeclass as a way of identification. Say for example that we have a typeclass Animal. This has a subclass Felines which in turn has a subclass HouseCat. Maybe there are a bunch of other animal types too, like horses and dogs. Using inherits_from will allow you to check for all animals in one go:

+
     from evennia import utils
+     if (utils.inherits_from(obj, "typeclasses.objects.animals.Animal"):
+        obj.msg("The bouncer stops you in the door. He says: 'No talking animals allowed.'")
+
+
+
+
+
+

Text utilities

+

In a text game, you are naturally doing a lot of work shuffling text back and forth. Here is a non- +complete selection of text utilities found in evennia/utils/utils.py (shortcut evennia.utils). +If nothing else it can be good to look here before starting to develop a solution of your own.

+
+

utils.fill()

+

This flood-fills a text to a given width (shuffles the words to make each line evenly wide). It also indents as needed.

+
     outtxt = fill(intxt, width=78, indent=4)
+
+
+
+
+

utils.crop()

+

This function will crop a very long line, adding a suffix to show the line actually continues. This +can be useful in listings when showing multiple lines would mess up things.

+
     intxt = "This is a long text that we want to crop."
+     outtxt = crop(intxt, width=19, suffix="[...]")
+     # outtxt is now "This is a long text[...]"
+
+
+
+
+

utils.dedent()

+

This solves what may at first glance appear to be a trivial problem with text - removing indentations. It is used to shift entire paragraphs to the left, without disturbing any further formatting they may have. A common case for this is when using Python triple-quoted strings in code - they will retain whichever indentation they have in the code, and to make easily-readable source code one usually don’t want to shift the string to the left edge.

+
    #python code is entered at a given indentation
+          intxt = """
+          This is an example text that will end
+          up with a lot of whitespace on the left.
+                    It also has indentations of
+                    its own."""
+          outtxt = dedent(intxt)
+          # outtxt will now retain all internal indentation
+          # but be shifted all the way to the left.
+
+
+

Normally you do the dedent in the display code (this is for example how the help system homogenizes +help entries).

+
+
+

to_str() and to_bytes()

+

Evennia supplies two utility functions for converting text to the correct encodings. to_str() and to_bytes(). Unless you are adding a custom protocol and need to send byte-data over the wire, to_str is the only one you’ll need.

+

The difference from Python’s in-built str() and bytes() operators are that the Evennia ones makes use of the ENCODINGS setting and will try very hard to never raise a traceback but instead echo errors through logging. See here for more info.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Command-Sets.html b/docs/latest/Components/Command-Sets.html new file mode 100644 index 0000000000..a7d64ce454 --- /dev/null +++ b/docs/latest/Components/Command-Sets.html @@ -0,0 +1,512 @@ + + + + + + + + + Command Sets — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Command Sets

+

Command Sets are intimately linked with Commands and you should be familiar with +Commands before reading this page. The two pages were split for ease of reading.

+

A Command Set (often referred to as a CmdSet or cmdset) is the basic unit for storing one or more +Commands. A given Command can go into any number of different command sets. Storing Command +classes in a command set is the way to make commands available to use in your game.

+

When storing a CmdSet on an object, you will make the commands in that command set available to the +object. An example is the default command set stored on new Characters. This command set contains +all the useful commands, from look and inventory to @dig and @reload +(permissions then limit which players may use them, but that’s a separate +topic).

+

When an account enters a command, cmdsets from the Account, Character, its location, and elsewhere +are pulled together into a merge stack. This stack is merged together in a specific order to +create a single “merged” cmdset, representing the pool of commands available at that very moment.

+

An example would be a Window object that has a cmdset with two commands in it: look through window and open window. The command set would be visible to players in the room with the window, +allowing them to use those commands only there. You could imagine all sorts of clever uses of this, +like a Television object which had multiple commands for looking at it, switching channels and so +on. The tutorial world included with Evennia showcases a dark room that replaces certain critical +commands with its own versions because the Character cannot see.

+

If you want a quick start into defining your first commands and using them with command sets, you +can head over to the Adding Command Tutorial which steps through things +without the explanations.

+
+

Defining Command Sets

+

A CmdSet is, as most things in Evennia, defined as a Python class inheriting from the correct parent +(evennia.CmdSet, which is a shortcut to evennia.commands.cmdset.CmdSet). The CmdSet class only +needs to define one method, called at_cmdset_creation(). All other class parameters are optional, +but are used for more advanced set manipulation and coding (see the [merge rules](Command- +Sets#merge-rules) section).

+
# file mygame/commands/mycmdset.py
+
+from evennia import CmdSet
+
+# this is a theoretical custom module with commands we
+# created previously: mygame/commands/mycommands.py
+from commands import mycommands
+
+class MyCmdSet(CmdSet):
+    def at_cmdset_creation(self):
+        """
+        The only thing this method should need
+        to do is to add commands to the set.
+        """
+        self.add(mycommands.MyCommand1())
+        self.add(mycommands.MyCommand2())
+        self.add(mycommands.MyCommand3())
+
+
+

The CmdSet’s add() method can also take another CmdSet as input. In this case all the commands +from that CmdSet will be appended to this one as if you added them line by line:

+
    def at_cmdset_creation():
+        ...
+        self.add(AdditionalCmdSet) # adds all command from this set
+        ...
+
+
+

If you added your command to an existing cmdset (like to the default cmdset), that set is already +loaded into memory. You need to make the server aware of the code changes:

+
@reload
+
+
+

You should now be able to use the command.

+

If you created a new, fresh cmdset, this must be added to an object in order to make the commands +within available. A simple way to temporarily test a cmdset on yourself is use the @py command to +execute a python snippet:

+
@py self.cmdset.add('commands.mycmdset.MyCmdSet')
+
+
+

This will stay with you until you @reset or @shutdown the server, or you run

+
@py self.cmdset.delete('commands.mycmdset.MyCmdSet')
+
+
+

In the example above, a specific Cmdset class is removed. Calling delete without arguments will +remove the latest added cmdset.

+
+

Note: Command sets added using cmdset.add are, by default, not persistent in the database.

+
+

If you want the cmdset to survive a reload, you can do:

+
@py self.cmdset.add(commands.mycmdset.MyCmdSet, persistent=True)
+
+
+

Or you could add the cmdset as the default cmdset:

+
@py self.cmdset.add_default(commands.mycmdset.MyCmdSet)
+
+
+

An object can only have one “default” cmdset (but can also have none). This is meant as a safe fall- +back even if all other cmdsets fail or are removed. It is always persistent and will not be affected +by cmdset.delete(). To remove a default cmdset you must explicitly call cmdset.remove_default().

+

Command sets are often added to an object in its at_object_creation method. For more examples of +adding commands, read the Step by step tutorial. Generally you can +customize which command sets are added to your objects by using self.cmdset.add() or +self.cmdset.add_default().

+
+

Important: Commands are identified uniquely by key or alias (see Commands). If any +overlap exists, two commands are considered identical. Adding a Command to a command set that +already has an identical command will replace the previous command. This is very important. You +must take this behavior into account when attempting to overload any default Evennia commands with +your own. Otherwise, you may accidentally “hide” your own command in your command set when adding a +new one that has a matching alias.

+
+
+

Properties on Command Sets

+

There are several extra flags that you can set on CmdSets in order to modify how they work. All are +optional and will be set to defaults otherwise. Since many of these relate to merging cmdsets, +you might want to read the [Adding and Merging Command Sets](./Command-Sets.md#adding-and-merging- +command-sets) section for some of these to make sense.

+
    +
  • key (string) - an identifier for the cmdset. This is optional, but should be unique. It is used +for display in lists, but also to identify special merging behaviours using the key_mergetype +dictionary below.

  • +
  • mergetype (string) - allows for one of the following string values: “Union”, “Intersect”, +“Replace”, or “Remove”.

  • +
  • priority (int) - This defines the merge order of the merge stack - cmdsets will merge in rising +order of priority with the highest priority set merging last. During a merger, the commands from the +set with the higher priority will have precedence (just what happens depends on the merge +type). If priority is identical, the order in the +merge stack determines preference. The priority value must be greater or equal to -100. Most in- +game sets should usually have priorities between 0 and 100. Evennia default sets have priorities +as follows (these can be changed if you want a different distribution):

    +
      +
    • EmptySet: -101 (should be lower than all other sets)

    • +
    • SessionCmdSet: -20

    • +
    • AccountCmdSet: -10

    • +
    • CharacterCmdSet: 0

    • +
    • ExitCmdSet: 101 (generally should always be available)

    • +
    • ChannelCmdSet: 101 (should usually always be available) - since exits never accept +arguments, there is no collision between exits named the same as a channel even though the commands +“collide”.

    • +
    +
  • +
  • key_mergetype (dict) - a dict of key:mergetype pairs. This allows this cmdset to merge +differently with certain named cmdsets. If the cmdset to merge with has a key matching an entry in +key_mergetype, it will not be merged according to the setting in mergetype but according to the +mode in this dict. Please note that this is more complex than it may seem due to the merge +order of command sets. Please review that section +before using key_mergetype.

  • +
  • duplicates (bool/None default None) - this determines what happens when merging same-priority +cmdsets containing same-key commands together. Thedupicate option will only apply when merging +the cmdset with this option onto one other cmdset with the same priority. The resulting cmdset will +not retain this duplicate setting.

    +
      +
    • None (default): No duplicates are allowed and the cmdset being merged “onto” the old one +will take precedence. The result will be unique commands. However, the system will assume this +value to be True for cmdsets on Objects, to avoid dangerous clashes. This is usually the safe bet.

    • +
    • False: Like None except the system will not auto-assume any value for cmdsets defined on +Objects.

    • +
    • True: Same-named, same-prio commands will merge into the same cmdset. This will lead to a +multimatch error (the user will get a list of possibilities in order to specify which command they +meant). This is is useful e.g. for on-object cmdsets (example: There is a red button and a green button in the room. Both have a press button command, in cmdsets with the same priority. This +flag makes sure that just writing press button will force the Player to define just which object’s +command was intended).

    • +
    +
  • +
  • no_objs this is a flag for the cmdhandler that builds the set of commands available at every +moment. It tells the handler not to include cmdsets from objects around the account (nor from rooms +or inventory) when building the merged set. Exit commands will still be included. This option can +have three values:

    +
      +
    • None (default): Passthrough of any value set explicitly earlier in the merge stack. If never +set explicitly, this acts as False.

    • +
    • True/False: Explicitly turn on/off. If two sets with explicit no_objs are merged, +priority determines what is used.

    • +
    +
  • +
  • no_exits - this is a flag for the cmdhandler that builds the set of commands available at every +moment. It tells the handler not to include cmdsets from exits. This flag can have three values:

    +
      +
    • None (default): Passthrough of any value set explicitly earlier in the merge stack. If +never set explicitly, this acts as False.

    • +
    • True/False: Explicitly turn on/off. If two sets with explicit no_exits are merged, +priority determines what is used.

    • +
    +
  • +
  • no_channels (bool) - this is a flag for the cmdhandler that builds the set of commands available +at every moment. It tells the handler not to include cmdsets from available in-game channels. This +flag can have three values:

    +
      +
    • None (default): Passthrough of any value set explicitly earlier in the merge stack. If +never set explicitly, this acts as False.

    • +
    • True/False: Explicitly turn on/off. If two sets with explicit no_channels are merged, +priority determines what is used.

    • +
    +
  • +
+
+
+
+

Command Sets Searched

+

When a user issues a command, it is matched against the [merged](./Command-Sets.md#adding-and-merging- +command-sets) command sets available to the player at the moment. Which those are may change at any +time (such as when the player walks into the room with the Window object described earlier).

+

The currently valid command sets are collected from the following sources:

+
    +
  • The cmdsets stored on the currently active Session. Default is the empty +SessionCmdSet with merge priority -20.

  • +
  • The cmdsets defined on the Account. Default is the AccountCmdSet with merge priority +-10.

  • +
  • All cmdsets on the Character/Object (assuming the Account is currently puppeting such a +Character/Object). Merge priority 0.

  • +
  • The cmdsets of all objects carried by the puppeted Character (checks the call lock). Will not be +included if no_objs option is active in the merge stack.

  • +
  • The cmdsets of the Character’s current location (checks the call lock). Will not be included if +no_objs option is active in the merge stack.

  • +
  • The cmdsets of objects in the current location (checks the call lock). Will not be included if +no_objs option is active in the merge stack.

  • +
  • The cmdsets of Exits in the location. Merge priority +101. Will not be included if no_exits +or no_objs option is active in the merge stack.

  • +
  • The channel cmdset containing commands for posting to all channels the account +or character is currently connected to. Merge priority +101. Will not be included if no_channels +option is active in the merge stack.

  • +
+

Note that an object does not have to share its commands with its surroundings. A Character’s +cmdsets should not be shared for example, or all other Characters would get multi-match errors just +by being in the same room. The ability of an object to share its cmdsets is managed by its call +lock. For example, Character objects defaults to call:false() so that any +cmdsets on them can only be accessed by themselves, not by other objects around them. Another +example might be to lock an object with call:inside() to only make their commands available to +objects inside them, or cmd:holds() to make their commands available only if they are held.

+
+
+

Adding and Merging Command Sets

+

Note: This is an advanced topic. It’s very useful to know about, but you might want to skip it if +this is your first time learning about commands.

+

CmdSets have the special ability that they can be merged together into new sets. Which of the +ingoing commands end up in the merged set is defined by the merge rule and the relative +priorities of the two sets. Removing the latest added set will restore things back to the way it +was before the addition.

+

CmdSets are non-destructively stored in a stack inside the cmdset handler on the object. This stack +is parsed to create the “combined” cmdset active at the moment. CmdSets from other sources are also +included in the merger such as those on objects in the same room (like buttons to press) or those +introduced by state changes (such as when entering a menu). The cmdsets are all ordered after +priority and then merged together in reverse order. That is, the higher priority will be merged +“onto” lower-prio ones. By defining a cmdset with a merge-priority between that of two other sets, +you will make sure it will be merged in between them. +The very first cmdset in this stack is called the Default cmdset and is protected from accidental +deletion. Running obj.cmdset.delete() will never delete the default set. Instead one should add +new cmdsets on top of the default to “hide” it, as described below. Use the special +obj.cmdset.delete_default() only if you really know what you are doing.

+

CmdSet merging is an advanced feature useful for implementing powerful game effects. Imagine for +example a player entering a dark room. You don’t want the player to be able to find everything in +the room at a glance - maybe you even want them to have a hard time to find stuff in their backpack! +You can then define a different CmdSet with commands that override the normal ones. While they are +in the dark room, maybe the look and inv commands now just tell the player they cannot see +anything! Another example would be to offer special combat commands only when the player is in +combat. Or when being on a boat. Or when having taken the super power-up. All this can be done on +the fly by merging command sets.

+
+

Merge Rules

+

Basic rule is that command sets are merged in reverse priority order. That is, lower-prio sets are +merged first and higher prio sets are merged “on top” of them. Think of it like a layered cake with +the highest priority on top.

+

To further understand how sets merge, we need to define some examples. Let’s call the first command +set A and the second B. We assume B is the command set already active on our object and +we will merge A onto B. In code terms this would be done by object.cdmset.add(A). +Remember, B is already active on object from before.

+

We let the A set have higher priority than B. A priority is simply an integer number. As +seen in the list above, Evennia’s default cmdsets have priorities in the range -101 to 120. You +are usually safe to use a priority of 0 or 1 for most game effects.

+

In our examples, both sets contain a number of commands which we’ll identify by numbers, like A1, A2 for set A and B1, B2, B3, B4 for B. So for that example both sets contain commands +with the same keys (or aliases) “1” and “2” (this could for example be “look” and “get” in the real +game), whereas commands 3 and 4 are unique to B. To describe a merge between these sets, we +would write A1,A2 + B1,B2,B3,B4 = ? where ? is a list of commands that depend on which merge +type A has, and which relative priorities the two sets have. By convention, we read this +statement as “New command set A is merged onto the old command set B to form ?”.

+

Below are the available merge types and how they work. Names are partly borrowed from Set +theory.

+
    +
  • Union (default) - The two cmdsets are merged so that as many commands as possible from each +cmdset ends up in the merged cmdset. Same-key commands are merged by priority.

    +
       # Union
    +   A1,A2 + B1,B2,B3,B4 = A1,A2,B3,B4
    +
    +
    +
  • +
  • Intersect - Only commands found in both cmdsets (i.e. which have the same keys) end up in +the merged cmdset, with the higher-priority cmdset replacing the lower one’s commands.

    +
       # Intersect
    +   A1,A3,A5 + B1,B2,B4,B5 = A1,A5
    +
    +
    +
  • +
  • Replace - The commands of the higher-prio cmdset completely replaces the lower-priority +cmdset’s commands, regardless of if same-key commands exist or not.

    +
       # Replace
    +   A1,A3 + B1,B2,B4,B5 = A1,A3
    +
    +
    +
  • +
  • Remove - The high-priority command sets removes same-key commands from the lower-priority +cmdset. They are not replaced with anything, so this is a sort of filter that prunes the low-prio +set using the high-prio one as a template.

    +
       # Remove
    +   A1,A3 + B1,B2,B3,B4,B5 = B2,B4,B5
    +
    +
    +
  • +
+

Besides priority and mergetype, a command-set also takes a few other variables to control how +they merge:

+
    +
  • duplicates (bool) - determines what happens when two sets of equal priority merge. Default is +that the new set in the merger (i.e. A above) automatically takes 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 player receiving a multiple-match error higher up the road, but can be good for +things like cmdsets on non-player objects in a room, to allow the system to warn that more than one +‘ball’ in the room has the same ‘kick’ command defined on it and offer a chance to select which +ball to kick … Allowing duplicates only makes sense for Union and Intersect, the setting is +ignored for the other mergetypes.

  • +
  • key_mergetypes (dict) - allows the cmdset to define a unique mergetype for particular cmdsets, +identified by their cmdset key. Format is {CmdSetkey:mergetype}. Example: +{'Myevilcmdset','Replace'} which would make sure for this set to always use ‘Replace’ on the +cmdset with the key Myevilcmdset only, no matter what the main mergetype is set to.

  • +
+
+

Warning: The key_mergetypes dictionary can only work on the cmdset we merge onto. When using +key_mergetypes it is thus important to consider the merge priorities - you must make sure that you +pick a priority between the cmdset you want to detect and the next higher one, if any. That is, if +we define a cmdset with a high priority and set it to affect a cmdset that is far down in the merge +stack, we would not “see” that set when it’s time for us to merge. Example: Merge stack is +A(prio=-10), B(prio=-5), C(prio=0), D(prio=5). We now merge a cmdset E(prio=10) onto this stack, +with a key_mergetype={"B":"Replace"}. But priorities dictate that we won’t be merged onto B, we +will be merged onto E (which is a merger of the lower-prio sets at this point). Since we are merging +onto E and not B, our key_mergetype directive won’t trigger. To make sure it works we must make +sure we merge onto B. Setting E’s priority to, say, -4 will make sure to merge it onto B and affect +it appropriately.

+
+

More advanced cmdset example:

+
from commands import mycommands
+
+class MyCmdSet(CmdSet):
+
+    key = "MyCmdSet"
+    priority = 4
+    mergetype = "Replace"
+    key_mergetypes = {'MyOtherCmdSet':'Union'}
+
+    def at_cmdset_creation(self):
+        """
+        The only thing this method should need
+        to do is to add commands to the set.
+        """
+        self.add(mycommands.MyCommand1())
+        self.add(mycommands.MyCommand2())
+        self.add(mycommands.MyCommand3())
+
+
+
+
+

Assorted Notes

+

It is very important to remember that two commands are compared both by their key properties +and by their aliases properties. If either keys or one of their aliases match, the two commands +are considered the same. So consider these two Commands:

+
    +
  • A Command with key “kick” and alias “fight”

  • +
  • A Command with key “punch” also with an alias “fight”

  • +
+

During the cmdset merging (which happens all the time since also things like channel commands and +exits are merged in), these two commands will be considered identical since they share alias. It +means only one of them will remain after the merger. Each will also be compared with all other +commands having any combination of the keys and/or aliases “kick”, “punch” or “fight”.

+

… So avoid duplicate aliases, it will only cause confusion.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Commands.html b/docs/latest/Components/Commands.html new file mode 100644 index 0000000000..5006f05776 --- /dev/null +++ b/docs/latest/Components/Commands.html @@ -0,0 +1,612 @@ + + + + + + + + + Commands — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Commands

+

Commands are intimately linked to Command Sets and you need to read that page too to +be familiar with how the command system works. The two pages were split for easy reading.

+

The basic way for users to communicate with the game is through Commands. These can be commands directly related to the game world such as look, get, drop and so on, or administrative commands such as examine or dig.

+

The default commands coming with Evennia are ‘MUX-like’ in that they use @ for admin commands, support things like switches, syntax with the ‘=’ symbol etc, but there is nothing that prevents you from implementing a completely different command scheme for your game. You can find the default commands in evennia/commands/default. You should not edit these directly - they will be updated by the Evennia team as new features are added. Rather you should look to them for inspiration and inherit your own designs from them.

+

There are two components to having a command running - the Command class and the Command Set (command sets were split into a separate wiki page for ease of reading).

+
    +
  1. A Command is a python class containing all the functioning code for what a command does - for example, a get command would contain code for picking up objects.

  2. +
  3. A Command Set (often referred to as a CmdSet or cmdset) is like a container for one or more Commands. A given Command can go into any number of different command sets. Only by putting the command set on a character object you will make all the commands therein available to use by that character. You can also store command sets on normal objects if you want users to be able to use the object in various ways. Consider a “Tree” object with a cmdset defining the commands climb and chop down. Or a “Clock” with a cmdset containing the single command check time.

  4. +
+

This page goes into full detail about how to use Commands. To fully use them you must also read the page detailing Command Sets. There is also a step-by-step Adding Command Tutorial that will get you started quickly without the extra explanations.

+
+

Defining Commands

+

All commands are implemented as normal Python classes inheriting from the base class Command +(evennia.Command). You will find that this base class is very “bare”. The default commands of +Evennia actually inherit from a child of Command called MuxCommand - this is the class that +knows all the mux-like syntax like /switches, splitting by “=” etc. Below we’ll avoid mux- +specifics and use the base Command class directly.

+
    # basic Command definition
+    from evennia import Command
+
+    class MyCmd(Command):
+       """
+       This is the help-text for the command
+       """
+       key = "mycommand"
+       def parse(self):
+           # parsing the command line here
+       def func(self):
+           # executing the command here
+
+
+

Here is a minimalistic command with no custom parsing:

+
    from evennia import Command
+
+    class CmdEcho(Command):
+        key = "echo"
+
+        def func(self):
+            # echo the caller's input back to the caller
+            self.caller.msg(f"Echo: {self.args}")
+
+
+
+

You define a new command by assigning a few class-global properties on your inherited class and +overloading one or two hook functions. The full gritty mechanic behind how commands work are found +towards the end of this page; for now you only need to know that the command handler creates an +instance of this class and uses that instance whenever you use this command - it also dynamically +assigns the new command instance a few useful properties that you can assume to always be available.

+
+

Who is calling the command?

+

In Evennia there are three types of objects that may call the command. It is important to be aware +of this since this will also assign appropriate caller, session, sessid and account +properties on the command body at runtime. Most often the calling type is Session.

+
    +
  • A Session. This is by far the most common case when a user is entering a command in +their client.

    +
      +
    • caller - this is set to the puppeted Object if such an object exists. If no +puppet is found, caller is set equal to account. Only if an Account is not found either (such as +before being logged in) will this be set to the Session object itself.

    • +
    • session - a reference to the Session object itself.

    • +
    • sessid - sessid.id, a unique integer identifier of the session.

    • +
    • account - the Account object connected to this Session. None if not logged in.

    • +
    +
  • +
  • An Account. This only happens if account.execute_cmd() was used. No Session +information can be obtained in this case.

    +
      +
    • caller - this is set to the puppeted Object if such an object can be determined (without +Session info this can only be determined in MULTISESSION_MODE=0 or 1). If no puppet is found, +this is equal to account.

    • +
    • session - None*

    • +
    • sessid - None*

    • +
    • account - Set to the Account object.

    • +
    +
  • +
  • An Object. This only happens if object.execute_cmd() was used (for example by an +NPC).

    +
      +
    • caller - This is set to the calling Object in question.

    • +
    • session - None*

    • +
    • sessid - None*

    • +
    • account - None

    • +
    +
  • +
+
+

*): There is a way to make the Session available also inside tests run directly on Accounts and Objects, and that is to pass it to execute_cmd like so: account.execute_cmd("...", session=<Session>). Doing so will make the .session and .sessid properties available in the command.

+
+
+
+

Properties assigned to the command instance at run-time

+

Let’s say account Bob with a character BigGuy enters the command look at sword. After the system having successfully identified this as the “look” command and determined that BigGuy really has access to a command named look, it chugs the look command class out of storage and either loads an existing Command instance from cache or creates one. After some more checks it then assigns it the following properties:

+
    +
  • caller - The character BigGuy, in this example. This is a reference to the object executing the command. The value of this depends on what type of object is calling the command; see the previous section.

  • +
  • session - the Session Bob uses to connect to the game and control BigGuy (see also previous section).

  • +
  • sessid - the unique id of self.session, for quick lookup.

  • +
  • account - the Account Bob (see previous section).

  • +
  • cmdstring - the matched key for the command. This would be look in our example.

  • +
  • args - this is the rest of the string, except the command name. So if the string entered was look at sword, args would be ” at sword”. Note the space kept - Evennia would correctly interpret lookat sword too. This is useful for things like /switches that should not use space. In the MuxCommand class used for default commands, this space is stripped. Also see the arg_regex property if you want to enforce a space to make lookat sword give a command-not-found error.

  • +
  • obj - the game Object on which this command is defined. This need not be the caller, but since look is a common (default) command, this is probably defined directly on BigGuy - so obj will point to BigGuy. Otherwise obj could be an Account or any interactive object with commands defined on it, like in the example of the “check time” command defined on a “Clock” object. - cmdset - this is a reference to the merged CmdSet (see below) from which this command was +matched. This variable is rarely used, it’s main use is for the auto-help system (Advanced note: the merged cmdset need NOT be the same as BigGuy.cmdset. The merged set can be a combination of the cmdsets from other objects in the room, for example).

  • +
  • raw_string - this is the raw input coming from the user, without stripping any surrounding +whitespace. The only thing that is stripped is the ending newline marker.

  • +
+
+

Other useful utility methods:

+
    +
  • .get_help(caller, cmdset) - Get the help entry for this command. By default the arguments are not used, but they could be used to implement alternate help-display systems.

  • +
  • .client_width() - Shortcut for getting the client’s screen-width. Note that not all clients will +truthfully report this value - that case the settings.DEFAULT_SCREEN_WIDTH will be returned. - .styled_table(*args, **kwargs) - This returns an [EvTable](module- evennia.utils.evtable) styled based on the session calling this command. The args/kwargs are the same as for EvTable, except styling defaults are set.

  • +
  • .styled_header, _footer, separator - These will produce styled decorations for display to the user. They are useful for creating listings and forms with colors adjustable per-user.

  • +
+
+
+
+

Defining your own command classes

+

Beyond the properties Evennia always assigns to the command at run-time (listed above), your job is to define the following class properties:

+
    +
  • key (string) - the identifier for the command, like look. This should (ideally) be unique. A key can consist of more than one word, like “press button” or “pull left lever”. Note that both key and aliases below determine the identity of a command. So two commands are considered if either matches. This is important for merging cmdsets described below.

  • +
  • aliases (optional list) - a list of alternate names for the command (["glance", "see", "l"]). Same name rules as for key applies.

  • +
  • locks (string) - a lock definition, usually on the form cmd:<lockfuncs>. Locks is a rather big topic, so until you learn more about locks, stick to giving the lockstring "cmd:all()" to make the command available to everyone (if you don’t provide a lock string, this will be assigned for you).

  • +
  • help_category (optional string) - setting this helps to structure the auto-help into categories. If none is set, this will be set to General.

  • +
  • save_for_next (optional boolean). This defaults to False. If True, a copy of this command object (along with any changes you have done to it) will be stored by the system and can be accessed by the next command by retrieving self.caller.ndb.last_cmd. The next run command will either clear or replace the storage.

  • +
  • arg_regex (optional raw string): Used to force the parser to limit itself and tell it when the command-name ends and arguments begin (such as requiring this to be a space or a /switch). This is done with a regular expression. See the arg_regex section for the details.

  • +
  • auto_help (optional boolean). Defaults to True. This allows for turning off the auto-help system on a per-command basis. This could be useful if you either want to write your help entries manually or hide the existence of a command from help’s generated list.

  • +
  • is_exit (bool) - this marks the command as being used for an in-game exit. This is, by default, set by all Exit objects and you should not need to set it manually unless you make your own Exit system. It is used for optimization and allows the cmdhandler to easily disregard this command when the cmdset has its no_exits flag set.

  • +
  • is_channel (bool)- this marks the command as being used for an in-game channel. This is, by default, set by all Channel objects and you should not need to set it manually unless you make your own Channel system. is used for optimization and allows the cmdhandler to easily disregard this command when its cmdset has its no_channels flag set.

  • +
  • msg_all_sessions (bool): This affects the behavior of the Command.msg method. If unset (default), calling self.msg(text) from the Command will always only send text to the Session that actually triggered this Command. If set however, self.msg(text) will send to all Sessions relevant to the object this Command sits on. Just which Sessions receives the text depends on the object and the server’s MULTISESSION_MODE.

  • +
+

You should also implement at least two methods, parse() and func() (You could also implement +perm(), but that’s not needed unless you want to fundamentally change how access checks work).

+
    +
  • at_pre_cmd() is called very first on the command. If this function returns anything that evaluates to True the command execution is aborted at this point.

  • +
  • parse() is intended to parse the arguments (self.args) of the function. You can do this in any way you like, then store the result(s) in variable(s) on the command object itself (i.e. on self). To take an example, the default mux-like system uses this method to detect “command switches” and store them as a list in self.switches. Since the parsing is usually quite similar inside a command scheme you should make parse() as generic as possible and then inherit from it rather than re- implementing it over and over. In this way, the default MuxCommand class implements a parse() for all child commands to use.

  • +
  • func() is called right after parse() and should make use of the pre-parsed input to actually do whatever the command is supposed to do. This is the main body of the command. The return value from this method will be returned from the execution as a Twisted Deferred.

  • +
  • at_post_cmd() is called after func() to handle eventual cleanup.

  • +
+

Finally, you should always make an informative doc string (__doc__) at the top of your class. This string is dynamically read by the Help System to create the help entry for this command. You should decide on a way to format your help and stick to that.

+

Below is how you define a simple alternative “smile” command:

+
from evennia import Command
+
+class CmdSmile(Command):
+    """
+    A smile command
+
+    Usage:
+      smile [at] [<someone>]
+      grin [at] [<someone>]
+
+    Smiles to someone in your vicinity or to the room
+    in general.
+
+    (This initial string (the __doc__ string)
+    is also used to auto-generate the help
+    for this command)
+    """
+
+    key = "smile"
+    aliases = ["smile at", "grin", "grin at"]
+    locks = "cmd:all()"
+    help_category = "General"
+
+    def parse(self):
+        "Very trivial parser"
+        self.target = self.args.strip()
+
+    def func(self):
+        "This actually does things"
+        caller = self.caller
+
+        if not self.target or self.target == "here":
+            string = f"{caller.key} smiles"
+        else:
+            target = caller.search(self.target)
+            if not target:
+                return
+            string = f"{caller.key} smiles at {target.key}"
+
+        caller.location.msg_contents(string)
+
+
+
+

The power of having commands as classes and to separate parse() and func() lies in the ability to inherit functionality without having to parse every command individually. For example, as mentioned the default commands all inherit from MuxCommand. MuxCommand implements its own version of parse() that understands all the specifics of MUX-like commands. Almost none of the default commands thus need to implement parse() at all, but can assume the incoming string is already split up and parsed in suitable ways by its parent.

+

Before you can actually use the command in your game, you must now store it within a command set. See the Command Sets page.

+
+
+

Command prefixes

+

Historically, many MU* servers used to use prefix, such as @ or & to signify that a command is used for administration or requires staff privileges. The problem with this is that newcomers to MU often find such extra symbols confusing. Evennia allows commands that can be accessed both with- or without such a prefix.

+
CMD_IGNORE_PREFIXES = "@&/+`
+
+
+

This is a setting consisting of a string of characters. Each is a prefix that will be considered a skippable prefix - if the command is still unique in its cmdset when skipping the prefix.

+

So if you wanted to write @look instead of look you can do so - the @ will be ignored. But If we added an actual @look command (with a key or alias @look) then we would need to use the @ to separate between the two.

+

This is also used in the default commands. For example, @open is a building command that allows you to create new exits to link two rooms together. Its key is set to @open, including the @ (no alias is set). By default you can use both @open and open for this command. But “open” is a pretty common word and let’s say a developer adds a new open command for opening a door. Now @open and open are two different commands and the @ must be used to separate them.

+
+

The help command will prefer to show all command names without prefix if +possible. Only if there is a collision, will the prefix be shown in the help system.

+
+
+
+

arg_regex

+

The command parser is very general and does not require a space to end your command name. This means that the alias : to emote can be used like :smiles without modification. It also means getstone will get you the stone (unless there is a command specifically named getstone, then that will be used). If you want to tell the parser to require a certain separator between the command name and its arguments (so that get stone works but getstone gives you a ‘command not found’ error) you can do so with the arg_regex property.

+

The arg_regex is a raw regular expression string. The regex will be compiled by the system at runtime. This allows you to customize how the part immediately following the command name (or alias) must look in order for the parser to match for this command. Some examples:

+
    +
  • commandname argument (arg_regex = r"\s.+"): This forces the parser to require the command name to be followed by one or more spaces. Whatever is entered after the space will be treated as an argument. However, if you’d forget the space (like a command having no arguments), this would not match commandname.

  • +
  • commandname or commandname argument (arg_regex = r"\s.+|$"): This makes both look and look me work but lookme will not.

  • +
  • commandname/switches arguments (arg_regex = r"(?:^(?:\s+|\/).*$)|^$". If you are using Evennia’s MuxCommand Command parent, you may wish to use this since it will allow /switches to work as well as having or not having a space.

  • +
+

The arg_regex allows you to customize the behavior of your commands. You can put it in the parent class of your command to customize all children of your Commands. However, you can also change the base default behavior for all Commands by modifying settings.COMMAND_DEFAULT_ARG_REGEX.

+
+
+
+

Exiting a command

+

Normally you just use return in one of your Command class’ hook methods to exit that method. That will however still fire the other hook methods of the Command in sequence. That’s usually what you want but sometimes it may be useful to just abort the command, for example if you find some unacceptable input in your parse method. To exit the command this way you can raise evennia.InterruptCommand:

+
from evennia import InterruptCommand
+
+class MyCommand(Command):
+
+   # ...
+
+   def parse(self):
+       # ...
+       # if this fires, `func()` and `at_post_cmd` will not
+       # be called at all
+       raise InterruptCommand()
+
+
+
+
+
+

Pauses in commands

+

Sometimes you want to pause the execution of your command for a little while before continuing - maybe you want to simulate a heavy swing taking some time to finish, maybe you want the echo of your voice to return to you with an ever-longer delay. Since Evennia is running asynchronously, you cannot use time.sleep() in your commands (or anywhere, really). If you do, the entire game will +be frozen for everyone! So don’t do that. Fortunately, Evennia offers a really quick syntax for +making pauses in commands.

+

In your func() method, you can use the yield keyword. This is a Python keyword that will freeze +the current execution of your command and wait for more before processing.

+
+

Note that you cannot just drop yield into any code and expect it to pause. Evennia will only pause for you if you yield inside the Command’s func() method. Don’t expect it to work anywhere else.

+
+

Here’s an example of a command using a small pause of five seconds between messages:

+
from evennia import Command
+
+class CmdWait(Command):
+    """
+    A dummy command to show how to wait
+
+    Usage:
+      wait
+
+    """
+
+    key = "wait"
+    locks = "cmd:all()"
+    help_category = "General"
+
+    def func(self):
+        """Command execution."""
+        self.msg("Beginner-Tutorial to wait ...")
+        yield 5
+        self.msg("... This shows after 5 seconds. Waiting ...")
+        yield 2
+        self.msg("... And now another 2 seconds have passed.")
+
+
+

The important line is the yield 5 and yield 2 lines. It will tell Evennia to pause execution here and not continue until the number of seconds given has passed.

+

There are two things to remember when using yield in your Command’s func method:

+
    +
  1. The paused state produced by the yield is not saved anywhere. So if the server reloads in the middle of your command pausing, it will not resume when the server comes back up - the remainder of the command will never fire. So be careful that you are not freezing the character or account in a way that will not be cleared on reload.

  2. +
  3. If you use yield you may not also use return <values> in your func method. You’ll get an error explaining this. This is due to how Python generators work. You can however use a “naked” return just fine. Usually there is no need for func to return a value, but if you ever do need to mix yield with a final return value in the same func, look at twisted.internet.defer.returnValue.

  4. +
+
+
+

Asking for user input

+

The yield keyword can also be used to ask for user input. Again you can’t use Python’s input in your command, for it would freeze Evennia for everyone while waiting for that user to input their text. Inside a Command’s func method, the following syntax can also be used:

+
answer = yield("Your question")
+
+
+

Here’s a very simple example:

+
class CmdConfirm(Command):
+
+    """
+    A dummy command to show confirmation.
+
+    Usage:
+        confirm
+
+    """
+
+    key = "confirm"
+
+    def func(self):
+        answer = yield("Are you sure you want to go on?")
+        if answer.strip().lower() in ("yes", "y"):
+            self.msg("Yes!")
+        else:
+            self.msg("No!")
+
+
+

This time, when the user enters the ‘confirm’ command, she will be asked if she wants to go on. Entering ‘yes’ or “y” (regardless of case) will give the first reply, otherwise the second reply will show.

+
+

Note again that the yield keyword does not store state. If the game reloads while waiting for the user to answer, the user will have to start over. It is not a good idea to use yield for important or complex choices, a persistent EvMenu might be more appropriate in this case.

+
+
+
+

System commands

+

Note: This is an advanced topic. Skip it if this is your first time learning about commands.

+

There are several command-situations that are exceptional in the eyes of the server. What happens if the account enters an empty string? What if the ‘command’ given is infact the name of a channel the user wants to send a message to? Or if there are multiple command possibilities?

+

Such ‘special cases’ are handled by what’s called system commands. A system command is defined in the same way as other commands, except that their name (key) must be set to one reserved by the engine (the names are defined at the top of evennia/commands/cmdhandler.py). You can find (unused) implementations of the system commands in evennia/commands/default/system_commands.py. Since these are not (by default) included in any CmdSet they are not actually used, they are just there for show. When the special situation occurs, Evennia will look through all valid CmdSets for your custom system command. Only after that will it resort to its own, hard-coded implementation.

+

Here are the exceptional situations that triggers system commands. You can find the command keys they use as properties on evennia.syscmdkeys:

+
    +
  • No input (syscmdkeys.CMD_NOINPUT) - the account just pressed return without any input. Default is to do nothing, but it can be useful to do something here for certain implementations such as line editors that interpret non-commands as text input (an empty line in the editing buffer).

  • +
  • Command not found (syscmdkeys.CMD_NOMATCH) - No matching command was found. Default is to display the “Huh?” error message.

  • +
  • Several matching commands where found (syscmdkeys.CMD_MULTIMATCH) - Default is to show a list of matches.

  • +
  • User is not allowed to execute the command (syscmdkeys.CMD_NOPERM) - Default is to display the “Huh?” error message.

  • +
  • Channel (syscmdkeys.CMD_CHANNEL) - This is a Channel name of a channel you are subscribing to - Default is to relay the command’s argument to that channel. Such commands are created by the Comm system on the fly depending on your subscriptions.

  • +
  • New session connection (syscmdkeys.CMD_LOGINSTART). This command name should be put in the settings.CMDSET_UNLOGGEDIN. Whenever a new connection is established, this command is always called on the server (default is to show the login screen).

  • +
+

Below is an example of redefining what happens when the account doesn’t provide any input (e.g. just presses return). Of course the new system command must be added to a cmdset as well before it will work.

+
    from evennia import syscmdkeys, Command
+
+    class MyNoInputCommand(Command):
+        "Usage: Just press return, I dare you"
+        key = syscmdkeys.CMD_NOINPUT
+        def func(self):
+            self.caller.msg("Don't just press return like that, talk to me!")
+
+
+
+
+

Dynamic Commands

+

Note: This is an advanced topic.

+

Normally Commands are created as fixed classes and used without modification. There are however situations when the exact key, alias or other properties is not possible (or impractical) to pre- code.

+

To create a command with a dynamic call signature, first define the command body normally in a class (set your key, aliases to default values), then use the following call (assuming the command class you created is named MyCommand):

+
     cmd = MyCommand(key="newname",
+                     aliases=["test", "test2"],
+                     locks="cmd:all()",
+                     ...)
+
+
+

All keyword arguments you give to the Command constructor will be stored as a property on the command object. This will overload existing properties defined on the parent class.

+

Normally you would define your class and only overload things like key and aliases at run-time. But you could in principle also send method objects (like func) as keyword arguments in order to make your command completely customized at run-time.

+
+

Dynamic commands - Exits

+

Exits are examples of the use of a Dynamic Command.

+

The functionality of Exit objects in Evennia is not hard-coded in the engine. Instead Exits are normal typeclassed objects that auto-create a CmdSet on themselves when they load. This cmdset has a single dynamically created Command with the same properties (key, aliases and locks) as the Exit object itself. When entering the name of the exit, this dynamic exit-command is triggered and (after access checks) moves the Character to the exit’s destination.

+

Whereas you could customize the Exit object and its command to achieve completely different behaviour, you will usually be fine just using the appropriate traverse_* hooks on the Exit object. But if you are interested in really changing how things work under the hood, check out evennia/objects/objects.py for how the Exit typeclass is set up.

+
+
+
+

Command instances are re-used

+

Note: This is an advanced topic that can be skipped when first learning about Commands.

+

A Command class sitting on an object is instantiated once and then re-used. So if you run a command from object1 over and over you are in fact running the same command instance over and over (if you run the same command but sitting on object2 however, it will be a different instance). This is usually not something you’ll notice, since every time the Command-instance is used, all the relevant properties on it will be overwritten. But armed with this knowledge you can implement some of the more exotic command mechanism out there, like the command having a ‘memory’ of what you last entered so that you can back-reference the previous arguments etc.

+
+

Note: On a server reload, all Commands are rebuilt and memory is flushed.

+
+

To show this in practice, consider this command:

+
class CmdTestID(Command):
+    key = "testid"
+
+    def func(self):
+
+        if not hasattr(self, "xval"):
+            self.xval = 0
+        self.xval += 1
+
+        self.caller.msg(f"Command memory ID: {id(self)} (xval={self.xval})")
+
+
+
+

Adding this to the default character cmdset gives a result like this in-game:

+
> testid
+Command memory ID: 140313967648552 (xval=1)
+> testid
+Command memory ID: 140313967648552 (xval=2)
+> testid
+Command memory ID: 140313967648552 (xval=3)
+
+
+

Note how the in-memory address of the testid command never changes, but xval keeps ticking up.

+
+
+

Create a command on the fly

+

This is also an advanced topic.

+

Commands can also be created and added to a cmdset on the fly. Creating a class instance with a keyword argument, will assign that keyword argument as a property on this paricular command:

+
class MyCmdSet(CmdSet):
+
+    def at_cmdset_creation(self):
+
+        self.add(MyCommand(myvar=1, foo="test")
+
+
+
+

This will start the MyCommand with myvar and foo set as properties (accessable as self.myvar and self.foo). How they are used is up to the Command. Remember however the discussion from the previous section - since the Command instance is re-used, those properties will remain on the command as long as this cmdset and the object it sits is in memory (i.e. until the next reload). Unless myvar and foo are somehow reset when the command runs, they can be modified and that change will be remembered for subsequent uses of the command.

+
+
+

How commands actually work

+

Note: This is an advanced topic mainly of interest to server developers.

+

Any time the user sends text to Evennia, the server tries to figure out if the text entered +corresponds to a known command. This is how the command handler sequence looks for a logged-in user:

+
    +
  1. A user enters a string of text and presses enter.

  2. +
  3. The user’s Session determines the text is not some protocol-specific control sequence or OOB command, but sends it on to the command handler.

  4. +
  5. Evennia’s command handler analyzes the Session and grabs eventual references to Account and eventual puppeted Characters (these will be stored on the command object later). The caller property is set appropriately.

  6. +
  7. If input is an empty string, resend command as CMD_NOINPUT. If no such command is found in cmdset, ignore.

  8. +
  9. If command.key matches settings.IDLE_COMMAND, update timers but don’t do anything more.

  10. +
  11. The command handler gathers the CmdSets available to caller at this time:

    +
      +
    • The caller’s own currently active CmdSet.

    • +
    • CmdSets defined on the current account, if caller is a puppeted object.

    • +
    • CmdSets defined on the Session itself.

    • +
    • The active CmdSets of eventual objects in the same location (if any). This includes commands on Exits.

    • +
    • Sets of dynamically created System commands representing available Communications

    • +
    +
  12. +
  13. All CmdSets of the same priority are merged together in groups. Grouping avoids order- dependent issues of merging multiple same-prio sets onto lower ones.

  14. +
  15. All the grouped CmdSets are merged in reverse priority into one combined CmdSet according to each set’s merge rules.

  16. +
  17. Evennia’s command parser takes the merged cmdset and matches each of its commands (using its key and aliases) against the beginning of the string entered by caller. This produces a set of candidates.

  18. +
  19. The cmd parser next rates the matches by how many characters they have and how many percent matches the respective known command. Only if candidates cannot be separated will it return multiple matches.

    +
      +
    • If multiple matches were returned, resend as CMD_MULTIMATCH. If no such command is found in cmdset, return hard-coded list of matches.

    • +
    • If no match was found, resend as CMD_NOMATCH. If no such command is found in cmdset, give hard-coded error message.

    • +
    +
  20. +
  21. If a single command was found by the parser, the correct command object is plucked out of storage. This usually doesn’t mean a re-initialization.

  22. +
  23. It is checked that the caller actually has access to the command by validating the lockstring of the command. If not, it is not considered as a suitable match and CMD_NOMATCH is triggered.

  24. +
  25. If the new command is tagged as a channel-command, resend as CMD_CHANNEL. If no such command is found in cmdset, use hard-coded implementation.

  26. +
  27. Assign several useful variables to the command instance (see previous sections).

  28. +
  29. Call at_pre_command() on the command instance.

  30. +
  31. Call parse() on the command instance. This is fed the remainder of the string, after the name of the command. It’s intended to pre-parse the string into a form useful for the func() method.

  32. +
  33. Call func() on the command instance. This is the functional body of the command, actually doing useful things.

  34. +
  35. Call at_post_command() on the command instance.

  36. +
+
+
+

Assorted notes

+

The return value of Command.func() is a Twisted deferred. +Evennia does not use this return value at all by default. If you do, you must +thus do so asynchronously, using callbacks.

+
     # in command class func()
+     def callback(ret, caller):
+        caller.msg(f"Returned is {ret}")
+     deferred = self.execute_command("longrunning")
+     deferred.addCallback(callback, self.caller)
+
+
+

This is probably not relevant to any but the most advanced/exotic designs (one might use it to create a “nested” command structure for example).

+

The save_for_next class variable can be used to implement state-persistent commands. For example it can make a command operate on “it”, where it is determined by what the previous command operated on.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Components-Overview.html b/docs/latest/Components/Components-Overview.html new file mode 100644 index 0000000000..b81939764b --- /dev/null +++ b/docs/latest/Components/Components-Overview.html @@ -0,0 +1,374 @@ + + + + + + + + + Core Components — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Core Components

+

These are the ‘building blocks’ out of which Evennia is built. This documentation is complementary to, and often goes deeper than, the doc-strings of each component in the API.

+
+

Base components

+

These are base pieces used to make an Evennia game. Most are long-lived and are persisted in the database.

+ +
+
+

Commands

+

Evennia’s Command system handle everything sent to the server by the user.

+ +
+
+

Utils and tools

+

Evennia provides a library of code resources to help the creation of a game.

+ +
+
+

Web components

+

Evennia is also its own webserver, with a website and in-browser webclient you can expand on.

+ +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Default-Commands.html b/docs/latest/Components/Default-Commands.html new file mode 100644 index 0000000000..ebbbed74b2 --- /dev/null +++ b/docs/latest/Components/Default-Commands.html @@ -0,0 +1,229 @@ + + + + + + + + + Default Commands — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Default Commands

+

The full set of default Evennia commands currently contains 89 commands in 9 source +files. Our policy for adding default commands is outlined here. The +Commands documentation explains how Commands work as well as how to make new or customize +existing ones.

+
+

Note that this page is auto-generated. Report problems to the issue tracker.

+
+
+

Note

+

Some game-states add their own Commands which are not listed here. Examples include editing a text +with EvEditor, flipping pages in EvMore or using the +Batch-Processor’s interactive mode.

+
+ +
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/EvEditor.html b/docs/latest/Components/EvEditor.html new file mode 100644 index 0000000000..1bdb3f03d3 --- /dev/null +++ b/docs/latest/Components/EvEditor.html @@ -0,0 +1,311 @@ + + + + + + + + + EvEditor — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

EvEditor

+

Evennia offers a powerful in-game line editor in evennia.utils.eveditor.EvEditor. This editor, +mimicking the well-known VI line editor. It offers line-by-line editing, undo/redo, line deletes, +search/replace, fill, dedent and more.

+
+

Launching the editor

+

The editor is created as follows:

+
from evennia.utils.eveditor import EvEditor
+
+EvEditor(caller,
+         loadfunc=None, savefunc=None, quitfunc=None,
+         key="")
+
+
+
    +
  • caller (Object or Account): The user of the editor.

  • +
  • loadfunc (callable, optional): This is a function called when the editor is first started. It +is called with caller as its only argument. The return value from this function is used as the +starting text in the editor buffer.

  • +
  • savefunc (callable, optional): This is called when the user saves their buffer in the editor is +called with two arguments, caller and buffer, where buffer is the current buffer.

  • +
  • quitfunc (callable, optional): This is called when the user quits the editor. If given, all +cleanup and exit messages to the user must be handled by this function.

  • +
  • key (str, optional): This text will be displayed as an identifier and reminder while editing. +It has no other mechanical function.

  • +
  • persistent (default False): if set to True, the editor will survive a reboot.

  • +
+
+
+

Working with EvEditor

+

This is an example command for setting a specific Attribute using the editor.

+
from evennia import Command
+from evennia.utils import eveditor
+
+class CmdSetTestAttr(Command):
+    """
+    Set the "test" Attribute using
+    the line editor.
+
+    Usage:
+       settestattr
+
+    """
+    key = "settestattr"
+    def func(self):
+        "Set up the callbacks and launch the editor"
+        def load(caller):
+            "get the current value"
+            return caller.attributes.get("test")
+        def save(caller, buffer):
+            "save the buffer"
+            caller.attributes.add("test", buffer)
+        def quit(caller):
+            "Since we define it, we must handle messages"
+            caller.msg("Editor exited")
+        key = f"{self.caller}/test"
+        # launch the editor
+        eveditor.EvEditor(self.caller,
+                          loadfunc=load, savefunc=save, quitfunc=quit,
+                          key=key)
+
+
+
+

Persistent editor

+

If you set the persistent keyword to True when creating the editor, it will remain open even +when reloading the game. In order to be persistent, an editor needs to have its callback functions +(loadfunc, savefunc and quitfunc) as top-level functions defined in the module. Since these +functions will be stored, Python will need to find them.

+
from evennia import Command
+from evennia.utils import eveditor
+
+def load(caller):
+    "get the current value"
+    return caller.attributes.get("test")
+
+def save(caller, buffer):
+    "save the buffer"
+    caller.attributes.add("test", buffer)
+
+def quit(caller):
+    "Since we define it, we must handle messages"
+    caller.msg("Editor exited")
+
+class CmdSetTestAttr(Command):
+    """
+    Set the "test" Attribute using
+    the line editor.
+
+    Usage:
+       settestattr
+
+    """
+    key = "settestattr"
+    def func(self):
+        "Set up the callbacks and launch the editor"
+        key = f"{self.caller}/test"
+        # launch the editor
+        eveditor.EvEditor(self.caller,
+                          loadfunc=load, savefunc=save, quitfunc=quit,
+                          key=key, persistent=True)
+
+
+
+
+

Line editor usage

+

The editor mimics the VIM editor as best as possible. The below is an excerpt of the return from +the in-editor help command (:h).

+
 <txt>  - any non-command is appended to the end of the buffer.
+ :  <l> - view buffer or only line <l>
+ :: <l> - view buffer without line numbers or other parsing
+ :::    - print a ':' as the only character on the line...
+ :h     - this help.
+
+ :w     - save the buffer (don't quit)
+ :wq    - save buffer and quit
+ :q     - quit (will be asked to save if buffer was changed)
+ :q!    - quit without saving, no questions asked
+
+ :u     - (undo) step backwards in undo history
+ :uu    - (redo) step forward in undo history
+ :UU    - reset all changes back to initial state
+
+ :dd <l>     - delete line <n>
+ :dw <l> <w> - delete word or regex <w> in entire buffer or on line <l>
+ :DD         - clear buffer
+
+ :y  <l>        - yank (copy) line <l> to the copy buffer
+ :x  <l>        - cut line <l> and store it in the copy buffer
+ :p  <l>        - put (paste) previously copied line directly after <l>
+ :i  <l> <txt>  - insert new text <txt> at line <l>. Old line will move down
+ :r  <l> <txt>  - replace line <l> with text <txt>
+ :I  <l> <txt>  - insert text at the beginning of line <l>
+ :A  <l> <txt>  - append text after the end of line <l>
+
+ :s <l> <w> <txt> - search/replace word or regex <w> in buffer or on line <l>
+
+ :f <l>    - flood-fill entire buffer or line <l>
+ :fi <l>   - indent entire buffer or line <l>
+ :fd <l>   - de-indent entire buffer or line <l>
+
+ :echo - turn echoing of the input on/off (helpful for some clients)
+
+    Legend:
+    <l> - line numbers, or range lstart:lend, e.g. '3:7'.
+    <w> - one word or several enclosed in quotes.
+    <txt> - longer string, usually not needed to be enclosed in quotes.
+
+
+
+
+

The EvEditor to edit code

+

The EvEditor is also used to edit some Python code in Evennia. The py command supports an /edit switch that will open the EvEditor in code mode. This mode isn’t significantly different from the standard one, except it handles automatic indentation of blocks and a few options to control this behavior.

+
    +
  • :< to remove a level of indentation for the future lines.

  • +
  • :+ to add a level of indentation for the future lines.

  • +
  • := to disable automatic indentation altogether.

  • +
+

Automatic indentation is there to make code editing more simple. Python needs correct indentation, not as an aesthetic addition, but as a requirement to determine beginning and ending of blocks. The EvEditor will try to guess the next level of indentation. If you type a block “if”, for instance, the EvEditor will propose you an additional level of indentation at the next line. This feature cannot be perfect, however, and sometimes, you will have to use the above options to handle indentation.

+

:= can be used to turn automatic indentation off completely. This can be very useful when trying +to paste several lines of code that are already correctly indented, for instance.

+

To see the EvEditor in code mode, you can use the @py/edit command. Type in your code (on one or several lines). You can then use the :w option (save without quitting) and the code you have +typed will be executed. The :! will do the same thing. Executing code while not closing the +editor can be useful if you want to test the code you have typed but add new lines after your test.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/EvForm.html b/docs/latest/Components/EvForm.html new file mode 100644 index 0000000000..3550e3007c --- /dev/null +++ b/docs/latest/Components/EvForm.html @@ -0,0 +1,134 @@ + + + + + + + + + EvForm — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/EvMenu.html b/docs/latest/Components/EvMenu.html new file mode 100644 index 0000000000..c1bc3d7e45 --- /dev/null +++ b/docs/latest/Components/EvMenu.html @@ -0,0 +1,1334 @@ + + + + + + + + + EvMenu — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

EvMenu

+
Is your answer yes or no?
+_________________________________________
+[Y]es! - Answer yes.
+[N]o! - Answer no.
+[A]bort - Answer neither, and abort.
+
+> Y
+You chose yes!
+
+Thanks for your answer. Goodbye!
+
+
+

EvMenu is used for generate branching multi-choice menus. Each menu ‘node’ can +accepts specific options as input or free-form input. Depending what the player +chooses, they are forwarded to different nodes in the menu.

+

The EvMenu utility class is located in evennia/utils/evmenu.py. +It allows for easily adding interactive menus to the game; for example to implement Character creation, building commands or similar. Below is an example of offering NPC conversation choices:

+

This is how the example menu at the top of this page will look in code:

+
from evennia.utils import evmenu
+
+def _handle_answer(caller, raw_input, **kwargs):
+    answer = kwargs.get("answer")
+    caller.msg(f"You chose {answer}!")
+    return "end"  # name of next node
+
+def node_question(caller, raw_input, **kwargs):
+    text = "Is your answer yes or no?"
+    options = (
+        {"key": ("[Y]es!", "yes", "y"),
+         "desc": Answer yes.",
+         "goto": _handle_answer, {"answer": "yes"}},
+        {"key": ("[N]o!", "no", "n"),
+         "desc": "Answer no.",
+         "goto": _handle_answer, {"answer": "no"}},
+        {"key": ("[A]bort", "abort", "a"),
+         "desc": "Answer neither, and abort.",
+         "goto": "end"}
+    )
+    return text, options
+
+def node_end(caller, raw_input, **kwargs):
+    text "Thanks for your answer. Goodbye!"
+    return text, None  # empty options ends the menu
+
+evmenu.EvMenu(caller, {"start": node_question, "end": node_end})
+
+
+
+

Note the call to EvMenu at the end; this immediately creates the menu for the +caller. It also assigns the two node-functions to menu node-names start and +end, which is what the menu then uses to reference the nodes.

+

Each node of the menu is a function that returns the text and a list of dicts +describing the choices you can make on that node.

+

Each option details what it should show (key/desc) as well as which node to go +to (goto) next. The “goto” should be the name of the next node to go (if None, +the same node will be rerun again).

+

Above, the Abort option gives the “end” node name just as a string whereas the +yes/no options instead uses the callable _handle_answer but pass different +arguments to it. _handle_answer then returns the name of the next node (this +allows you to perform actions when making a choice before you move on to the +next node the menu). Note that _handle_answer is not a node in the menu, +it’s just a helper function.

+

When choosing ‘yes’ (or ‘no’) what happens here is that _handle_answer gets +called and echoes your choice before directing to the “end” node, which exits +the menu (since it doesn’t return any options).

+

You can also write menus using the EvMenu templating language. This +allows you to use a text string to generate simpler menus with less boiler +plate. Let’s create exactly the same menu using the templating language:

+
from evennia.utils import evmenu
+
+def _handle_answer(caller, raw_input, **kwargs):
+    answer = kwargs.get("answer")
+    caller.msg(f"You chose {answer}!")
+    return "end"  # name of next node
+
+menu_template = """
+
+## node start
+
+Is your answer yes or no?
+
+## options
+
+[Y]es!;yes;y: Answer yes. -> handle_answer(answer=yes)
+[N]o!;no;n: Answer no. -> handle_answer(answer=no)
+[A]bort;abort;a: Answer neither, and abort. -> end
+
+## node end
+
+Thanks for your answer. Goodbye!
+
+"""
+
+evmenu.template2menu(caller, menu_template, {"handle_answer": _handle_answer})
+
+
+
+

As seen, the _handle_answer is the same, but the menu structure is +described in the menu_template string. The template2menu helper +uses the template-string and a mapping of callables (we must add +_handle_answer here) to build a full EvMenu for us.

+

Here’s another menu example, where we can choose how to interact with an NPC:

+
The guard looks at you suspiciously.
+"No one is supposed to be in here ..."
+he says, a hand on his weapon.
+_______________________________________________
+ 1. Try to bribe him [Cha + 10 gold]
+ 2. Convince him you work here [Int]
+ 3. Appeal to his vanity [Cha]
+ 4. Try to knock him out [Luck + Dex]
+ 5. Try to run away [Dex]
+
+
+

+def _skill_check(caller, raw_string, **kwargs):
+    skills = kwargs.get("skills", [])
+    gold = kwargs.get("gold", 0)
+
+    # perform skill check here, decide if check passed or not
+    # then decide which node-name to return based on
+    # the result ...
+
+    return next_node_name
+
+def node_guard(caller, raw_string, **kwarg):
+    text = (
+        'The guard looks at you suspiciously.\n'
+        '"No one is supposed to be in here ..."\n'
+        'he says, a hand on his weapon.'
+    options = (
+        {"desc": "Try to bribe on [Cha + 10 gold]",
+         "goto": (_skill_check, {"skills": ["Cha"], "gold": 10})},
+        {"desc": "Convince him you work here [Int].",
+         "goto": (_skill_check, {"skills": ["Int"]})},
+        {"desc": "Appeal to his vanity [Cha]",
+         "goto": (_skill_check, {"skills": ["Cha"]})},
+        {"desc": "Try to knock him out [Luck + Dex]",
+         "goto": (_skill_check, {"skills"" ["Luck", "Dex"]})},
+        {"desc": "Try to run away [Dex]",
+         "goto": (_skill_check, {"skills": ["Dex"]})}
+    return text, options
+    )
+
+# EvMenu called below, with all the nodes ...
+
+
+
+

Note that by skipping the key of the options, we instead get an +(auto-generated) list of numbered options to choose from.

+

Here the _skill_check helper will check (roll your stats, exactly what this +means depends on your game) to decide if your approach succeeded. It may then +choose to point you to nodes that continue the conversation or maybe dump you +into combat!

+
+

Launching the menu

+

Initializing the menu is done using a call to the evennia.utils.evmenu.EvMenu class. This is the most common way to do so - from inside a Command:

+
# in, for example gamedir/commands/command.py
+
+from evennia.utils.evmenu import EvMenu
+
+class CmdTestMenu(Command):
+
+    key = "testcommand"
+
+    def func(self):
+
+	EvMenu(self.caller, "world.mymenu")
+
+
+
+

When running this command, the menu will start using the menu nodes loaded from +mygame/world/mymenu.py. See next section on how to define menu nodes.

+

The EvMenu has the following optional callsign:

+
EvMenu(caller, menu_data,
+       startnode="start",
+       cmdset_mergetype="Replace", cmdset_priority=1,
+       auto_quit=True, auto_look=True, auto_help=True,
+       cmd_on_exit="look",
+       persistent=False,
+       startnode_input="",
+       session=None,
+       debug=False,
+       **kwargs)
+
+
+
+
    +
  • caller (Object or Account): is a reference to the object using the menu. This object will get a new CmdSet assigned to it, for handling the menu.

  • +
  • menu_data (str, module or dict): is a module or python path to a module where the global-level functions will each be considered to be a menu node. Their names in the module will be the names by which they are referred to in the module. Importantly, function names starting with an underscore _ will be ignored by the loader. Alternatively, this can be a direct mapping +{"nodename":function, ...}.

  • +
  • startnode (str): is the name of the menu-node to start the menu at. Changing this means that you can jump into a menu tree at different positions depending on circumstance and thus possibly re-use menu entries.

  • +
  • cmdset_mergetype (str): This is usually one of “Replace” or “Union” (see [CmdSets](Command- Sets). The first means that the menu is exclusive - the user has no access to any other commands while in the menu. The Union mergetype means the menu co-exists with previous commands (and may overload them, so be careful as to what to name your menu entries in this case).

  • +
  • cmdset_priority (int): The priority with which to merge in the menu cmdset. This allows for advanced usage.

  • +
  • auto_quit, auto_look, auto_help (bool): If either of these are True, the menu automatically makes a quit, look or help command available to the user. The main reason why you’d want to turn this off is if you want to use the aliases “q”, “l” or “h” for something in your menu. The auto_help also activates the ability to have arbitrary “tool tips” in your menu node (see below), At least quit is highly recommend - if False, the menu must itself supply an “exit node” (a node without any options), or the user will be stuck in the menu until the server reloads (or eternally if the menu is persistent)!

  • +
  • cmd_on_exit (str): This command string will be executed right after the menu has closed down. From experience, it’s useful to trigger a “look” command to make sure the user is aware of the change of state; but any command can be used. If set to None, no command will be triggered after exiting the menu.

  • +
  • persistent (bool) - if True, the menu will survive a reload (so the user will not be kicked +out by the reload - make sure they can exit on their own!)

  • +
  • startnode_input (str or (str, dict) tuple): Pass an input text or a input text + kwargs to the +start node as if it was entered on a fictional previous node. This can be very useful in order to +start a menu differently depending on the Command’s arguments in which it was initialized.

  • +
  • session (Session): Useful when calling the menu from an Account in +MULTISESSION_MODE higher than 2, to make sure only the right Session sees the menu output.

  • +
  • debug (bool): If set, the menudebug command will be made available in the menu. Use it to +list the current state of the menu and use menudebug <variable> to inspect a specific state +variable from the list.

  • +
  • All other keyword arguments will be available as initial data for the nodes. They will be available in all nodes as properties on caller.ndb._evmenu (see below). These will also survive a reload if the menu is persistent.

  • +
+

You don’t need to store the EvMenu instance anywhere - the very act of initializing it will store it +as caller.ndb._evmenu on the caller. This object will be deleted automatically when the menu +is exited and you can also use it to store your own temporary variables for access throughout the +menu. Temporary variables you store on a persistent _evmenu as it runs will +not survive a @reload, only those you set as part of the original EvMenu call.

+
+
+

The Menu nodes

+

The EvMenu nodes consist of functions on one of these forms.

+
def menunodename1(caller):
+    # code
+    return text, options
+
+def menunodename2(caller, raw_string):
+    # code
+    return text, options
+
+def menunodename3(caller, raw_string, **kwargs):
+    # code
+    return text, options
+
+
+
+
+

While all of the above forms are okay, it’s recommended to stick to the third and last form since it gives the most flexibility. The previous forms are mainly there for backwards compatibility with existing menus from a time when EvMenu was less able and may become deprecated at some time in the future.

+
+
+

Input arguments to the node

+
    +
  • caller (Object or Account): The object using the menu - usually a Character but could also be a Session or Account depending on where the menu is used.

  • +
  • raw_string (str): If this is given, it will be set to the exact text the user entered on the +previous node (that is, the command entered to get to this node). On the starting-node of the menu, this will be an empty string, unless startnode_input was set.

  • +
  • kwargs (dict): These extra keyword arguments are extra optional arguments passed to the node when the user makes a choice on the previous node. This may include things like status flags and details about which exact option was chosen (which can be impossible to determine from +raw_string alone). Just what is passed in kwargs is up to you when you create the previous node.

  • +
+
+
+

Return values from the node

+

Each node function must return two variables, text and options.

+
+

text

+

The text variable is either a string or a tuple. This is the simplest form:

+
text = "Node text"
+
+
+

This is what will be displayed as text in the menu node when entering it. You can modify this dynamically in the node if you want. Returning a None node text text is allowed - this leads to a node with no text and only options.

+
text = ("Node text", "help text to show with h|elp")
+
+
+

In this form, we also add an optional help text. If auto_help=True when initializing the EvMenu, the user will be able to use h or help to see this text when viewing this node. If the user were to provide a custom option overriding h or help, that will be shown instead.

+

If auto_help=True and no help text is provided, using h|elp will give a generic error message.

+
text = ("Node text", {"help topic 1": "Help 1", 
+                      ("help topic 2", "alias1", ...): "Help 2", ...})
+
+
+

This is ‘tooltip’ or ‘multi-help category’ mode. This also requires auto_help=True when initializing the EvMenu. By providing a dict as the second element of the text tuple, the user will be able to help about any of these topics. Use a tuple as key to add multiple aliases to the same help entry. This allows the user to get more detailed help text without leaving the given node.

+

Note that in ‘tooltip’ mode, the normal h|elp command won’t work. The h|elp entry must be added manually in the dict. As an example, this would reproduce the normal help functionality:

+
text = ("Node text", {("help", "h"): "Help entry...", ...})
+
+
+
+
+

options

+

The options list describe all the choices available to the user when viewing this node. If options is returned as None, it means that this node is an Exit node - any text is displayed and then the menu immediately exits, running the exit_cmd if given.

+

Otherwise, options should be a list (or tuple) of dictionaries, one for each option. If only one option is available, a single dictionary can also be returned. This is how it could look:

+
def node_test(caller, raw_string, **kwargs):
+
+    text = "A goblin attacks you!"
+
+    options = (
+	{"key": ("Attack", "a", "att"),
+         "desc": "Strike the enemy with all your might",
+         "goto": "node_attack"},
+	{"key": ("Defend", "d", "def"),
+         "desc": "Hold back and defend yourself",
+         "goto": (_defend, {"str": 10, "enemyname": "Goblin"})})
+
+    return text, options
+
+
+
+

This will produce a menu node looking like this:

+
A goblin attacks you!
+________________________________
+
+Attack: Strike the enemy with all your might
+Defend: Hold back and defend yourself
+
+
+
+
+
option-key ‘key’
+

The option’s key is what the user should enter in order to choose that option. If given as a tuple, the first string of that tuple will be what is shown on-screen while the rest are aliases for picking that option. In the above example, the user could enter “Attack” (or “attack”, it’s not case-sensitive), “a” or “att” in order to attack the goblin. Aliasing is useful for adding custom coloring to the choice. The first element of the aliasing tuple should then be the colored version, followed by a version without color - since otherwise the user would have to enter the color codes to select that choice.

+

Note that the key is optional. If no key is given, it will instead automatically be replaced +with a running number starting from 1. If removing the key part of each option, the resulting +menu node would look like this instead:

+
A goblin attacks you!
+________________________________
+
+1: Strike the enemy with all your might
+2: Hold back and defend yourself
+
+
+
+

Whether you want to use a key or rely on numbers is mostly a matter of style and the type of menu.

+

EvMenu accepts one important special key given only as "_default". This key is used when a user enters something that does not match any other fixed keys. It is particularly useful for getting user input:

+
def node_readuser(caller, raw_string, **kwargs):
+    text = "Please enter your name"
+
+    options = {"key": "_default",
+               "goto": "node_parse_input"}
+
+    return text, options
+
+
+
+

A "_default" option does not show up in the menu, so the above will just be a node saying +"Please enter your name". The name they entered will appear as raw_string in the next node.

+
+
+
+

option-key ‘desc’

+

This simply contains the description as to what happens when selecting the menu option. For "_default" options or if the key is already long or descriptive, it is not strictly needed. But usually it’s better to keep the key short and put more detail in desc.

+
+
+

option-key ‘goto’

+

This is the operational part of the option and fires only when the user chooses said option. Here are three ways to write it

+

+def _action_two(caller, raw_string, **kwargs):
+    # do things ...
+    return "calculated_node_to_go_to"
+
+def _action_three(caller, raw_string, **kwargs):
+    # do things ...
+    return "node_four", {"mode": 4}
+
+def node_select(caller, raw_string, **kwargs):
+
+    text = ("select one",
+            "help - they all do different things ...")
+
+    options = ({"desc": "Option one",
+		            "goto": "node_one"},
+	             {"desc": "Option two",
+		            "goto": _action_two},
+	             {"desc": "Option three",
+		            "goto": (_action_three, {"key": 1, "key2": 2})}
+              )
+
+    return text, options
+
+
+
+

As seen above, goto could just be pointing to a single nodename string - the name of the node to go to. When given like this, EvMenu will look for a node named like this and call its associated function as

+
    nodename(caller, raw_string, **kwargs)
+
+
+

Here, raw_string is always the input the user entered to make that choice and kwargs are the same as those kwargs that already entered the current node (they are passed on).

+

Alternatively the goto could point to a “goto-callable”. Such callables are usually defined in the same module as the menu nodes and given names starting with _ (to avoid being parsed as nodes themselves). These callables will be called the same as a node function - callable(caller, raw_string, **kwargs), where raw_string is what the user entered on this node and **kwargs is forwarded from the node’s own input.

+

The goto option key could also point to a tuple (callable, kwargs) - this allows for customizing the kwargs passed into the goto-callable, for example you could use the same callable but change the kwargs passed into it depending on which option was actually chosen.

+

The “goto callable” must either return a string "nodename" or a tuple ("nodename", mykwargs). This will lead to the next node being called as either nodename(caller, raw_string, **kwargs) or nodename(caller, raw_string, **mykwargs) - so this allows changing (or replacing) the options going into the next node depending on what option was chosen.

+

There is one important case - if the goto-callable returns None for a nodename, the current node will run again, possibly with different kwargs. This makes it very easy to re-use a node over and over, for example allowing different options to update some text form being passed and manipulated for every iteration.

+
+
+
+

Temporary storage

+

When the menu starts, the EvMenu instance is stored on the caller as caller.ndb._evmenu. Through this object you can in principle reach the menu’s internal state if you know what you are doing. This is also a good place to store temporary, more global variables that may be cumbersome to keep passing from node to node via the **kwargs. The _evmnenu will be deleted automatically when the menu closes, meaning you don’t need to worry about cleaning anything up.

+

If you want permanent state storage, it’s instead better to use an Attribute on caller. Remember that this will remain after the menu closes though, so you need to handle any needed cleanup yourself.

+
+
+

Customizing Menu formatting

+

The EvMenu display of nodes, options etc are controlled by a series of formatting methods on the EvMenu class. To customize these, simply create a new child class of EvMenu and override as needed. Here is an example:

+
from evennia.utils.evmenu import EvMenu
+
+class MyEvMenu(EvMenu):
+
+    def nodetext_formatter(self, nodetext):
+        """
+        Format the node text itself.
+
+        Args:
+            nodetext (str): The full node text (the text describing the node).
+
+        Returns:
+            nodetext (str): The formatted node text.
+
+        """
+
+    def helptext_formatter(self, helptext):
+        """
+        Format the node's help text
+
+        Args:
+            helptext (str): The unformatted help text for the node.
+
+        Returns:
+            helptext (str): The formatted help text.
+
+        """
+
+    def options_formatter(self, optionlist):
+        """
+        Formats the option block.
+
+        Args:
+            optionlist (list): List of (key, description) tuples for every
+                option related to this node.
+            caller (Object, Account or None, optional): The caller of the node.
+
+        Returns:
+            options (str): The formatted option display.
+
+        """
+
+    def node_formatter(self, nodetext, optionstext):
+        """
+        Formats the entirety of the node.
+
+        Args:
+            nodetext (str): The node text as returned by `self.nodetext_formatter`.
+            optionstext (str): The options display as returned by `self.options_formatter`.
+            caller (Object, Account or None, optional): The caller of the node.
+
+        Returns:
+            node (str): The formatted node to display.
+
+        """
+
+
+
+

See evennia/utils/evmenu.py for the details of their default implementations.

+
+
+
+

EvMenu templating language

+

In evmenu.py are two helper functions parse_menu_template and template2menu that is used to parse a menu template string into an EvMenu:

+
evmenu.template2menu(caller, menu_template, goto_callables)
+
+
+

One can also do it in two steps, by generate a menutree and using that to call +EvMenu normally:

+
menutree = evmenu.parse_menu_template(caller, menu_template, goto_callables)
+EvMenu(caller, menutree)
+
+
+

With this latter solution, one could mix and match normally created menu nodes +with those generated by the template engine.

+

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:

+
menu_template = """
+
+## node node1
+
+Text for node
+
+## options
+
+key1: desc1 -> node2
+key2: desc2 -> node3
+key3: desc3 -> node4
+"""
+
+
+

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.

+

Except for defining the node/options, # act as comments - everything following +will be ignored by the template parser.

+
+

Template Options

+

The option syntax is

+
<key>: [desc ->] nodename or function-call
+
+
+

The ‘desc’ part is optional, and if that is not given, the -> can be skipped +too:

+
key: nodename
+
+
+

The key can both be strings and numbers. Separate the aliases with ;.

+
key: node1
+1: node2
+key;k: node3
+foobar;foo;bar;f;b: node4
+
+
+

Starting the key with the special letter > indicates that what follows is a +glob/regex matcher.

+
>: node1          - matches empty input
+> foo*: node1     - everything starting with foo
+> *foo: node3     - everything ending with foo
+> [0-9]+?: node4  - regex (all numbers)
+> *: node5        - catches everything else (put as last option)
+
+
+

Here’s how to call a goto-function from an option:

+
key: desc -> myfunc(foo=bar)
+
+
+

For this to work template2menu or parse_menu_template must be given a dict +that includes {"myfunc": _actual_myfunc_callable}. All callables to be +available in the template must be mapped this way. Goto callables act like +normal EvMenu goto-callables and should have a callsign of +_actual_myfunc_callable(caller, raw_string, **kwargs) and return the next node +(passing dynamic kwargs into the next node does not work with the template

+
    +
  • use the full EvMenu if you want advanced dynamic data passing).

  • +
+

Only no or named keywords are allowed in these callables. So

+
myfunc()         # OK
+myfunc(foo=bar)  # OK
+myfunc(foo)      # error!
+
+
+

This is because these properties are passed as **kwargs into the goto callable.

+
+
+

Templating example

+
from random import random
+from evennia.utils import evmenu
+
+def _gamble(caller, raw_string, **kwargs):
+
+    caller.msg("You roll the dice ...")
+    if random() < 0.5:
+        return "loose"
+    else:
+        return "win"
+
+template_string = """
+
+## node start
+
+Death patiently holds out a set of bone dice to you.
+
+"ROLL"
+
+he says.
+
+## options
+
+1: Roll the dice -> gamble()
+2: Try to talk yourself out of rolling -> start
+
+## node win
+
+The dice clatter over the stones.
+
+"LOOKS LIKE YOU WIN THIS TIME"
+
+says Death.
+
+# (this ends the menu since there are no options)
+
+## node loose
+
+The dice clatter over the stones.
+
+"YOUR LUCK RAN OUT"
+
+says Death.
+
+"YOU ARE COMING WITH ME."
+
+# (this ends the menu, but what happens next - who knows!)
+
+"""
+
+# map the in-template callable-name to real python code
+goto_callables = {"gamble": _gamble}
+# this starts the evmenu for the caller
+evmenu.template2menu(caller, template_string, goto_callables)
+
+
+
+
+
+
+

Asking for one-line input

+

This describes two ways for asking for simple questions from the user. Using Python’s input +will not work in Evennia. input will block the entire server for everyone until that one +player has entered their text, which is not what you want.

+
+

The yield way

+

In the func method of your Commands (only) you can use Python’s built-in yield command to +request input in a similar way to input. It looks like this:

+
result = yield("Please enter your answer:")
+
+
+

This will send “Please enter your answer” to the Command’s self.caller and then pause at that +point. All other players at the server will be unaffected. Once caller enteres a reply, the code +execution will continue and you can do stuff with the result. Here is an example:

+
from evennia import Command
+class CmdTestInput(Command):
+    key = "test"
+    def func(self):
+        result = yield("Please enter something:")
+        self.caller.msg(f"You entered {result}.")
+        result2 = yield("Now enter something else:")
+        self.caller.msg(f"You now entered {result2}.")
+
+
+

Using yield is simple and intuitive, but it will only access input from self.caller and you +cannot abort or time out the pause until the player has responded. Under the hood, it is actually +just a wrapper calling get_input described in the following section.

+
+

Important Note: In Python you cannot mix yield and return <value> in the same method. It has +to do with yield turning the method into a +generator. A return without an argument works, you +can just not do return <value>. This is usually not something you need to do in func() anyway, +but worth keeping in mind.

+
+
+
+

The get_input way

+

The evmenu module offers a helper function named get_input. This is wrapped by the yield +statement which is often easier and more intuitive to use. But get_input offers more flexibility +and power if you need it. While in the same module as EvMenu, get_input is technically unrelated +to it. The get_input allows you to ask and receive simple one-line input from the user without +launching the full power of a menu to do so. To use, call get_input like this:

+
get_input(caller, prompt, callback)
+
+
+

Here caller is the entity that should receive the prompt for input given as prompt. The +callback is a callable function(caller, prompt, user_input) that you define to handle the answer +from the user. When run, the caller will see prompt appear on their screens and any text they +enter will be sent into the callback for whatever processing you want.

+

Below is a fully explained callback and example call:

+
from evennia import Command
+from evennia.utils.evmenu import get_input
+
+def callback(caller, prompt, user_input):
+    """
+    This is a callback you define yourself.
+
+    Args:
+        caller (Account or Object): The one being asked
+          for input
+        prompt (str): A copy of the current prompt
+        user_input (str): The input from the account.
+
+    Returns:
+        repeat (bool): If not set or False, exit the
+          input prompt and clean up. If returning anything
+          True, stay in the prompt, which means this callback
+          will be called again with the next user input.
+    """
+    caller.msg(f"When asked '{prompt}', you answered '{user_input}'.")
+
+get_input(caller, "Write something! ", callback)
+
+
+

This will show as

+
Write something!
+> Hello
+When asked 'Write something!', you answered 'Hello'.
+
+
+
+

Normally, the get_input function quits after any input, but as seen in the example docs, you could +return True from the callback to repeat the prompt until you pass whatever check you want.

+
+

Note: You cannot link consecutive questions by putting a new get_input call inside the +callback If you want that you should use an EvMenu instead (see the Repeating the same +node example above). Otherwise you can either peek at the +implementation of get_input and implement your own mechanism (it’s just using cmdset nesting) or +you can look at this extension suggested on the mailing +list.

+
+
+

Example: Yes/No prompt

+

Below is an example of a Yes/No prompt using the get_input function:

+
def yesno(caller, prompt, result):
+    if result.lower() in ("y", "yes", "n", "no"):
+        # do stuff to handle the yes/no answer
+        # ...
+        # if we return None/False the prompt state
+        # will quit after this
+    else:
+        # the answer is not on the right yes/no form
+        caller.msg("Please answer Yes or No. \n{prompt}")
+@        # returning True will make sure the prompt state is not exited
+        return True
+
+# ask the question
+get_input(caller, "Is Evennia great (Yes/No)?", yesno)
+
+
+
+
+
+
+

The @list_node decorator

+

The evennia.utils.evmenu.list_node is an advanced decorator for use with EvMenu node functions. +It is used to quickly create menus for manipulating large numbers of items.

+
text here
+______________________________________________
+
+1. option1     7. option7      13. option13
+2. option2     8. option8      14. option14
+3. option3     9. option9      [p]revius page
+4. option4    10. option10      page 2
+5. option5    11. option11     [n]ext page
+6. option6    12. option12
+
+
+
+

The menu will automatically create an multi-page option listing that one can flip through. One can +inpect each entry and then select them with prev/next. This is how it is used:

+
from evennia.utils.evmenu import list_node
+
+
+...
+
+_options(caller):
+    return ['option1', 'option2', ... 'option100']
+
+_select(caller, menuchoice, available_choices):
+    # analyze choice
+    return "next_node"
+
+@list_node(options, select=_select, pagesize=10)
+def node_mylist(caller, raw_string, **kwargs):
+    ...
+
+    return text, options
+
+
+
+

The options argument to list_node is either a list, a generator or a callable returning a list +of strings for each option that should be displayed in the node.

+

The select is a callable in the example above but could also be the name of a menu node. If a +callable, the menuchoice argument holds the selection done and available_choices holds all the +options available. The callable should return the menu to go to depending on the selection (or +None to rerun the same node). If the name of a menu node, the selection will be passed as +selection kwarg to that node.

+

The decorated node itself should return text to display in the node. It must return at least an +empty dictionary for its options. It returning options, those will supplement the options +auto-created by the list_node decorator.

+
+
+

Example Menus

+

Here is a diagram to help visualize the flow of data from node to node, including goto-callables in-between:

+
        ┌─
+        │  def nodeA(caller, raw_string, **kwargs):
+        │      text = "Choose how to operate on 2 and 3."
+        │      options = (
+        │          {
+        │              "key": "A",
+        │              "desc": "Multiply 2 with 3",
+        │              "goto": (_callback, {"type": "mult", "a": 2, "b": 3})
+        │          },                      ───────────────────┬────────────
+        │          {                                          │
+        │              "key": "B",                            └───────────────┐
+        │              "desc": "Add 2 and 3",                                 │
+  Node A│              "goto": (_callback, {"type": "add", "a": 2, "b": 3})   │
+        │          },                      ─────────────────┬─────────────    │
+        │          {                                        │                 │
+        │              "key": "C",                          │                 │
+        │              "desc": "Show the value 5",          │                 │
+        │              "goto": ("node_B", {"c": 5})         │                 │
+        │          }                      ───────┐          │                 │
+        │      )                                 └──────────┼─────────────────┼───┐
+        │      return text, options                         │                 │   │
+        └─                                       ┌──────────┘                 │   │
+                                                 │                            │   │
+                                                 │ ┌──────────────────────────┘   │
+        ┌─                                       ▼ ▼                              │
+        │  def _callback(caller, raw_string, **kwargs):                           │
+        │      if kwargs["type"] == "mult":                                       │
+        │          return "node_B", {"c": kwargs["a"] * kwargs["b"]}              │
+Goto-   │                           ───────────────┬────────────────              │
+callable│                                          │                              │
+        │                                          └───────────────────┐          │
+        │                                                              │          │
+        │      elif kwargs["type"] == "add":                           │          │
+        │          return "node_B", {"c": kwargs["a"] + kwargs["b"]}   │          │
+        └─                          ────────┬───────────────────────   │          │
+                                            │                          │          │
+                                            │ ┌────────────────────────┼──────────┘
+                                            │ │                        │
+                                            │ │ ┌──────────────────────┘
+        ┌─                                  ▼ ▼ ▼
+        │  def nodeB(caller, raw_string, **kwargs):
+  Node B│      text = "Result of operation: " + kwargs["c"]
+        │      return text, {}
+        └─
+
+        ┌─
+   Menu │  EvMenu(caller, {"node_A": nodeA, "node_B": nodeB}, startnode="node_A")
+   Start│
+        └─
+
+
+

Above we create a very simple/stupid menu (in the EvMenu call at the end) where we map the node identifier "node_A" to the Python function nodeA and "node_B" to the function nodeB.

+

We start the menu in "node_A" where we get three options A, B and C. Options A and B will route via a a goto-callable _callback that either multiples or adds the numbers 2 and 3 together before continuing to "node_B". Option C routes directly to "node_B", passing the number 5.

+

In every step, we pass a dict which becomes the ingoing **kwargs in the next step. If we didn’t pass anything (it’s optional), the next step’s **kwargs would just be empty.

+

More examples:

+ +
+

Example: Simple branching menu

+

Below is an example of a simple branching menu node leading to different other nodes depending on choice:

+
# in mygame/world/mychargen.py
+
+def define_character(caller):
+    text = \
+    """
+    What aspect of your character do you want
+    to change next?
+    """
+    options = ({"desc": "Change the name",
+                "goto": "set_name"},
+               {"desc": "Change the description",
+                "goto": "set_description"})
+    return text, options
+
+EvMenu(caller, "world.mychargen", startnode="define_character")
+
+
+
+

This will result in the following node display:

+
What aspect of your character do you want
+to change next?
+_________________________
+1: Change the name
+2: Change the description
+
+
+

Note that since we didn’t specify the “name” key, EvMenu will let the user enter numbers instead. In +the following examples we will not include the EvMenu call but just show nodes running inside the +menu. Also, since EvMenu also takes a dictionary to describe the menu, we could have called it +like this instead in the example:

+
EvMenu(caller, {"define_character": define_character}, startnode="define_character")
+
+
+
+
+
+

Example: Dynamic goto

+

+def _is_in_mage_guild(caller, raw_string, **kwargs):
+    if caller.tags.get('mage', category="guild_member"):
+        return "mage_guild_welcome"
+    else:
+        return "mage_guild_blocked"
+
+def enter_guild:
+    text = 'You say to the mage guard:'
+    options ({'desc': 'I need to get in there.',
+              'goto': _is_in_mage_guild},
+             {'desc': 'Never mind',
+              'goto': 'end_conversation'})
+    return text, options
+
+
+

This simple callable goto will analyse what happens depending on who the caller is. The +enter_guild node will give you a choice of what to say to the guard. If you try to enter, you will +end up in different nodes depending on (in this example) if you have the right Tag set on +yourself or not. Note that since we don’t include any ‘key’s in the option dictionary, you will just +get to pick between numbers.

+
+
+

Example: Set caller properties

+

Here is an example of passing arguments into the goto callable and use that to influence +which node it should go to next:

+

+def _set_attribute(caller, raw_string, **kwargs):
+    "Get which attribute to modify and set it"
+
+    attrname, value = kwargs.get("attr", (None, None))
+    next_node = kwargs.get("next_node")
+
+    caller.attributes.add(attrname, attrvalue)
+
+    return next_node
+
+
+def node_background(caller):
+    text = \
+    f"""
+    {caller.key} experienced a traumatic event
+    in their childhood. What was it?
+    """
+
+    options = ({"key": "death",
+                "desc": "A violent death in the family",
+                "goto": (_set_attribute, {"attr": ("experienced_violence", True),
+					  "next_node": "node_violent_background"})},
+               {"key": "betrayal",
+                "desc": "The betrayal of a trusted grown-up",
+                "goto": (_set_attribute, {"attr": ("experienced_betrayal", True),
+					  "next_node": "node_betrayal_background"})})
+    return text, options
+
+
+

This will give the following output:

+
Kovash the magnificent experienced a traumatic event
+in their childhood. What was it?
+____________________________________________________
+death: A violent death in the family
+betrayal: The betrayal of a trusted grown-up
+
+
+
+

Note above how we use the _set_attribute helper function to set the attribute depending on the +User’s choice. In thie case the helper function doesn’t know anything about what node called it - we +even tell it which nodename it should return, so the choices leads to different paths in the menu. +We could also imagine the helper function analyzing what other choices

+
+
+

Example: Get arbitrary input

+

An example of the menu asking the user for input - any input.

+

+def _set_name(caller, raw_string, **kwargs):
+
+    inp = raw_string.strip()
+
+    prev_entry = kwargs.get("prev_entry")
+
+    if not inp:
+        # a blank input either means OK or Abort
+        if prev_entry:
+            caller.key = prev_entry
+            caller.msg(f"Set name to {prev_entry}.")
+            return "node_background"
+        else:
+	    caller.msg("Aborted.")
+	    return "node_exit"
+    else:
+        # re-run old node, but pass in the name given
+        return None, {"prev_entry": inp}
+
+
+def enter_name(caller, raw_string, **kwargs):
+
+    # check if we already entered a name before
+    prev_entry = kwargs.get("prev_entry")
+
+    if prev_entry:
+	text = "Current name: {}.\nEnter another name or <return> to accept."
+    else:
+	text = "Enter your character's name or <return> to abort."
+
+    options = {"key": "_default",
+               "goto": (_set_name, {"prev_entry": prev_entry})}
+
+    return text, options
+
+
+
+

This will display as

+
Enter your character's name or <return> to abort.
+
+> Gandalf
+
+Current name: Gandalf
+Enter another name or <return> to accept.
+
+>
+
+Set name to Gandalf.
+
+
+
+

Here we re-use the same node twice for reading the input data from the user. Whatever we enter will +be caught by the _default option and passed into the helper function. We also pass along whatever +name we have entered before. This allows us to react correctly on an “empty” input - continue to the +node named "node_background" if we accept the input or go to an exit node if we presses Return +without entering anything. By returning None from the helper function we automatically re-run the +previous node, but updating its ingoing kwargs to tell it to display a different text.

+
+
+

Example: Storing data between nodes

+

A convenient way to store data is to store it on the caller.ndb._evmenu which you can reach from +every node. The advantage of doing this is that the _evmenu NAttribute will be deleted +automatically when you exit the menu.

+

+def _set_name(caller, raw_string, **kwargs):
+
+    caller.ndb._evmenu.charactersheet = {}
+    caller.ndb._evmenu.charactersheet['name'] = raw_string
+    caller.msg(f"You set your name to {raw_string}")
+    return "background"
+
+def node_set_name(caller):
+    text = 'Enter your name:'
+    options = {'key': '_default',
+               'goto': _set_name}
+
+    return text, options
+
+...
+
+
+def node_view_sheet(caller):
+    text = f"Character sheet:\n {self.ndb._evmenu.charactersheet}"
+
+    options = ({"key": "Accept",
+                "goto": "finish_chargen"},
+	       {"key": "Decline",
+                "goto": "start_over"})
+
+    return text, options
+
+
+
+

Instead of passing the character sheet along from node to node through the kwargs we instead +set it up temporarily on caller.ndb._evmenu.charactersheet. This makes it easy to reach from +all nodes. At the end we look at it and, if we accept the character the menu will likely save the +result to permanent storage and exit.

+
+

One point to remember though is that storage on caller.ndb._evmenu is not persistent across +@reloads. If you are using a persistent menu (using EvMenu(..., persistent=True) you should +use +caller.db to store in-menu data like this as well. You must then yourself make sure to clean it +when the user exits the menu.

+
+
+
+

Example: Repeating the same node

+

Sometimes you want to make a chain of menu nodes one after another, but you don’t want the user to be able to continue to the next node until you have verified that what they input in the previous node is ok. A common example is a login menu:

+

+def _check_username(caller, raw_string, **kwargs):
+    # we assume lookup_username() exists
+    if not lookup_username(raw_string):
+	# re-run current node by returning `None`
+	caller.msg("|rUsername not found. Try again.")
+	return None
+    else:
+	# username ok - continue to next node
+	return "node_password"
+
+
+def node_username(caller):
+    text = "Please enter your user name."
+    options = {"key": "_default",
+               "goto": _check_username}
+    return text, options
+
+
+def _check_password(caller, raw_string, **kwargs):
+
+    nattempts = kwargs.get("nattempts", 0)
+    if nattempts > 3:
+	caller.msg("Too many failed attempts. Logging out")
+	return "node_abort"
+    elif not validate_password(raw_string):
+        caller.msg("Password error. Try again.")
+	return None, {"nattempts", nattempts + 1}
+    else:
+	# password accepted
+	return "node_login"
+
+def node_password(caller, raw_string, **kwargs):
+    text = "Enter your password."
+    options = {"key": "_default",
+	       "goto": _check_password}
+    return text, options
+
+
+
+

This will display something like

+
---------------------------
+Please enter your username.
+---------------------------
+
+> Fo
+
+------------------------------
+Username not found. Try again.
+______________________________
+abort: (back to start)
+------------------------------
+
+> Foo
+
+---------------------------
+Please enter your password.
+---------------------------
+
+> Bar
+
+--------------------------
+Password error. Try again.
+--------------------------
+
+
+

And so on.

+

Here the goto-callables will return to the previous node if there is an error. In the case of +password attempts, this will tick up the nattempts argument that will get passed on from iteration +to iteration until too many attempts have been made.

+
+
+

Defining nodes in a dictionary

+

You can also define your nodes directly in a dictionary to feed into the EvMenu creator.

+
def mynode(caller):
+   # a normal menu node function
+   return text, options
+
+menu_data = {"node1": mynode,
+             "node2": lambda caller: (
+                      "This is the node text",
+                     ({"key": "lambda node 1",
+                       "desc": "go to node 1 (mynode)",
+                       "goto": "node1"},
+                      {"key": "lambda node 2",
+                       "desc": "go to thirdnode",
+                       "goto": "node3"})),
+             "node3": lambda caller, raw_string: (
+                       # ... etc ) }
+
+# start menu, assuming 'caller' is available from earlier
+EvMenu(caller, menu_data, startnode="node1")
+
+
+
+

The keys of the dictionary become the node identifiers. You can use any callable on the right form +to describe each node. If you use Python lambda expressions you can make nodes really on the fly. +If you do, the lambda expression must accept one or two arguments and always return a tuple with two +elements (the text of the node and its options), same as any menu node function.

+

Creating menus like this is one way to present a menu that changes with the circumstances - you +could for example remove or add nodes before launching the menu depending on some criteria. The +drawback is that a lambda expression is much more +limited than a full +function - for example you can’t use other Python keywords like if inside the body of the +lambda.

+

Unless you are dealing with a relatively simple dynamic menu, defining menus with lambda’s is +probably more work than it’s worth: You can create dynamic menus by instead making each node +function more clever. See the NPC shop tutorial for an example of this.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/EvMore.html b/docs/latest/Components/EvMore.html new file mode 100644 index 0000000000..8457212992 --- /dev/null +++ b/docs/latest/Components/EvMore.html @@ -0,0 +1,161 @@ + + + + + + + + + EvMore — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

EvMore

+

When sending a very long text to a user client, it might scroll beyond of the height of the client +window. The evennia.utils.evmore.EvMore class gives the user the in-game ability to only view one +page of text at a time. It is usually used via its access function, evmore.msg.

+

The name comes from the famous unix pager utility more which performs just this function.

+

To use the pager, just pass the long text through it:

+
from evennia.utils import evmore
+
+evmore.msg(receiver, long_text)
+
+
+

Where receiver is an Object or a Account. If the text is longer than the +client’s screen height (as determined by the NAWS handshake or by settings.CLIENT_DEFAULT_HEIGHT) +the pager will show up, something like this:

+
+

[…] +aute irure dolor in reprehenderit in voluptate velit +esse cillum dolore eu fugiat nulla pariatur. Excepteur +sint occaecat cupidatat non proident, sunt in culpa qui +officia deserunt mollit anim id est laborum.

+
+
+

(more [1/6] return|back|top|end|abort)

+
+

where the user will be able to hit the return key to move to the next page, or use the suggested +commands to jump to previous pages, to the top or bottom of the document as well as abort the +paging.

+

The pager takes several more keyword arguments for controlling the message output. See the +evmore-API for more info.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/EvTable.html b/docs/latest/Components/EvTable.html new file mode 100644 index 0000000000..993af6249b --- /dev/null +++ b/docs/latest/Components/EvTable.html @@ -0,0 +1,134 @@ + + + + + + + + + EvTable — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Exits.html b/docs/latest/Components/Exits.html new file mode 100644 index 0000000000..eeae21b094 --- /dev/null +++ b/docs/latest/Components/Exits.html @@ -0,0 +1,201 @@ + + + + + + + + + Exits — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Exits

+

Inheritance Tree:

+
┌─────────────┐
+│DefaultObject│
+└─────▲───────┘
+      │
+┌─────┴─────┐
+│DefaultExit│
+└─────▲─────┘
+      │       ┌────────────┐
+      │ ┌─────►ObjectParent│
+      │ │     └────────────┘
+    ┌─┴─┴┐
+    │Exit│
+    └────┘
+
+
+

Exits are in-game Objects connecting other objects (usually Rooms) together.

+
+

Note that Exits are one-way objects, so in order for two Rooms to be linked bi-directionally, there will need to be two exits.

+
+

An object named north or in might be exits, as well as door, portal or jump out the window.

+

An exit has two things that separate them from other objects.

+
    +
  1. Their .destination property is set and points to a valid target location. This fact makes it easy and fast to locate exits in the database.

  2. +
  3. Exits define a special Transit Command on themselves when they are created. This command is named the same as the exit object and will, when called, handle the practicalities of moving the character to the Exits’s .destination - this allows you to just enter the name of the exit on its own to move around, just as you would expect.

  4. +
+

The default exit functionality is all defined on the DefaultExit typeclass. You could in principle completely change how exits work in your game by overriding this - it’s not recommended though, unless you really know what you are doing).

+

Exits are locked using an access_type called traverse and also make use of a few hook methods for giving feedback if the traversal fails. See evennia.DefaultExit for more info.

+

Exits are normally overridden on a case-by-case basis, but if you want to change the default exit created by rooms like dig, tunnel or open you can change it in settings:

+
BASE_EXIT_TYPECLASS = "typeclasses.exits.Exit"
+
+
+

In mygame/typeclasses/exits.py there is an empty Exit class for you to modify.

+
+

Exit details

+

The process of traversing an exit is as follows:

+
    +
  1. The traversing obj sends a command that matches the Exit-command name on the Exit object. The cmdhandler detects this and triggers the command defined on the Exit. Traversal always involves the “source” (the current location) and the destination (this is stored on the Exit object).

  2. +
  3. The Exit command checks the traverse lock on the Exit object

  4. +
  5. The Exit command triggers at_traverse(obj, destination) on the Exit object.

  6. +
  7. In at_traverse, object.move_to(destination) is triggered. This triggers the following hooks, in order:

    +
      +
    1. obj.at_pre_move(destination) - if this returns False, move is aborted.

    2. +
    3. origin.at_pre_leave(obj, destination)

    4. +
    5. obj.announce_move_from(destination)

    6. +
    7. Move is performed by changing obj.location from source location to destination.

    8. +
    9. obj.announce_move_to(source)

    10. +
    11. destination.at_object_receive(obj, source)

    12. +
    13. obj.at_post_move(source)

    14. +
    +
  8. +
  9. On the Exit object, at_post_traverse(obj, source) is triggered.

  10. +
+

If the move fails for whatever reason, the Exit will look for an Attribute err_traverse on itself and display this as an error message. If this is not found, the Exit will instead call at_failed_traverse(obj) on itself.

+
+
+

Creating Exits in code

+

For an example of how to create Exits programatically please see this guide.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/FuncParser.html b/docs/latest/Components/FuncParser.html new file mode 100644 index 0000000000..f6cc8cfed1 --- /dev/null +++ b/docs/latest/Components/FuncParser.html @@ -0,0 +1,511 @@ + + + + + + + + + FuncParser inline text parsing — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

FuncParser inline text parsing

+

The FuncParser extracts and executes ‘inline functions’ embedded in a string on the form $funcname(args, kwargs), executes the matching ‘inline function’ and replaces the call with the return from the call.

+

To test it, let’s tell Evennia to apply the Funcparser on every outgoing message. This is disabled by default (not everyone needs this functionality). To activate, add to your settings file:

+
FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED = True
+
+
+

After a reload, you can try this in-game

+
> say I got $randint(1,5) gold!
+You say "I got 3 gold!"
+
+
+

To escape the inlinefunc (e.g. to explain to someone how it works, use $$)

+

While randint may look and work just like random.randint from the standard Python library, it is not. Instead it’s a inlinefunc named randint made available to Evennia (which in turn uses the standard library function). For security reasons, only functions explicitly assigned to be used as inlinefuncs are viable.

+

You can apply the FuncParser manually. The parser is initialized with the inlinefunc(s) it’s supposed to recognize in that string. Below is an example of a parser only understanding a single $pow inlinefunc:

+
from evennia.utils.funcparser import FuncParser
+
+def _power_callable(*args, **kwargs):
+    """This will be callable as $pow(number, power=<num>) in string"""
+    pow = int(kwargs.get('power', 2))
+    return float(args[0]) ** pow
+
+# create a parser and tell it that '$pow' means using _power_callable
+parser = FuncParser({"pow": _power_callable})
+
+
+
+

Next, just pass a string into the parser, containing $func(...) markers:

+
parser.parse("We have that 4 x 4 x 4 is $pow(4, power=3).")
+"We have that 4 x 4 x 4 is 64."
+
+
+

Normally the return is always converted to a string but you can also get the actual data type from the call:

+
parser.parse_to_any("$pow(4)")
+16
+
+
+

You don’t have to define all your inline functions from scratch. In evennia.utils.funcparser you’ll find ready-made dicts of inline-funcs you can import and plug into your parsers. See default funcparser callables below for the defails.

+
+

Working with FuncParser

+

The FuncParser can be applied to any string. Out of the box it’s applied in a few situations:

+
    +
  • Outgoing messages. All messages sent from the server is processed through FuncParser and every callable is provided the Session of the object receiving the message. This potentially allows a message to be modified on the fly to look different for different recipients.

  • +
  • Prototype values. A Prototype dict’s values are run through the parser such that every callable gets a reference to the rest of the prototype. In the Prototype ORM, this would allow builders to safely call functions to set non-string values to prototype values, get random values, reference +other fields of the prototype, and more.

  • +
  • Actor-stance in messages to others. In the Object.msg_contents method, the outgoing string is parsed for special $You() and $conj() callables to decide if a given recipient +should see “You” or the character’s name.

  • +
+
+

Important

+

The inline-function parser is not intended as a ‘softcode’ programming language. It does not have things like loops and conditionals, for example. While you could in principle extend it to do very advanced things and allow builders a lot of power, all-out coding is something Evennia expects you to do in a proper text editor, outside of the game, not from inside it.

+
+

You can apply inline function parsing to any string. The +FuncParser is imported as evennia.utils.funcparser.

+
from evennia.utils import funcparser
+
+parser = FuncParser(callables, **default_kwargs)
+parsed_string = parser.parse(input_string, raise_errors=False,
+                              escape=False, strip=False,
+                              return_str=True, **reserved_kwargs)
+
+# callables can also be passed as paths to modules
+parser = FuncParser(["game.myfuncparser_callables", "game.more_funcparser_callables"])
+
+
+

Here, callables points to a collection of normal Python functions (see next section) for you to make +available to the parser as you parse strings with it. It can either be

+
    +
  • A dict of {"functionname": callable, ...}. This allows you do pick and choose exactly which callables +to include and how they should be named. Do you want a callable to be available under more than one name? +Just add it multiple times to the dict, with a different key.

  • +
  • A module or (more commonly) a python-path to a module. This module can define a dict +FUNCPARSER_CALLABLES = {"funcname": callable, ...} - this will be imported and used like the dict above. +If no such variable is defined, every top-level function in the module (whose name doesn’t start with +an underscore _) will be considered a suitable callable. The name of the function will be the $funcname +by which it can be called.

  • +
  • A list of modules/paths. This allows you to pull in modules from many sources for your parsing.

  • +
  • The **default kwargs are optional kwargs that will be passed to all +callables every time this parser is used - unless the user overrides it explicitly in +their call. This is great for providing sensible standards that the user can +tweak as needed.

  • +
+

FuncParser.parse takes further arguments, and can vary for every string parsed.

+
    +
  • raise_errors - By default, any errors from a callable will be quietly ignored and the result +will be that the failing function call will show verbatim. If raise_errors is set, +then parsing will stop and whatever exception happened will be raised. It’d be up to you to handle +this properly.

  • +
  • escape - Returns a string where every $func(...) has been escaped as \$func().

  • +
  • strip - Remove all $func(...) calls from string (as if each returned '').

  • +
  • return_str - When True (default), parser always returns a string. If False, it may return +the return value of a single function call in the string. This is the same as using the .parse_to_any +method.

  • +
  • The **reserved_keywords are always passed to every callable in the string. +They override any **defaults given when instantiating the parser and cannot +be overridden by the user - if they enter the same kwarg it will be ignored. +This is great for providing the current session, settings etc.

  • +
  • The funcparser and raise_errors +are always added as reserved keywords - the first is a +back-reference to the FuncParser instance and the second +is the raise_errors boolean given to FuncParser.parse.

  • +
+

Here’s an example of using the default/reserved keywords:

+
def _test(*args, **kwargs):
+    # do stuff
+    return something
+
+parser = funcparser.FuncParser({"test": _test}, mydefault=2)
+result = parser.parse("$test(foo, bar=4)", myreserved=[1, 2, 3])
+
+
+

Here the callable will be called as

+
_test('foo', bar='4', mydefault=2, myreserved=[1, 2, 3],
+      funcparser=<FuncParser>, raise_errors=False)
+
+
+

The mydefault=2 kwarg could be overwritten if we made the call as $test(mydefault=...) but myreserved=[1, 2, 3] will always be sent as-is and will override a call $test(myreserved=...). +The funcparser/raise_errors kwargs are also always included as reserved kwargs.

+
+
+

Defining custom callables

+

All callables made available to the parser must have the following signature:

+
def funcname(*args, **kwargs):
+    # ...
+    return something
+
+
+
+

The *args and **kwargs must always be included. If you are unsure how *args and **kwargs work in Python, read about them here.

+
+

The input from the innermost $funcname(...) call in your callable will always be a str. Here’s +an example of an $toint function; it converts numbers to integers.

+
"There's a $toint(22.0)% chance of survival."
+
+
+

What will enter the $toint callable (as args[0]) is the string "22.0". The function is responsible for converting this to a number so that we can convert it to an integer. We must also properly handle invalid inputs (like non-numbers).

+

If you want to mark an error, raise evennia.utils.funcparser.ParsingError. This stops the entire parsing of the string and may or may not raise the exception depending on what you set raise_errors to when you created the parser.

+

However, if you nest functions, the return of the innermost function may be something other than +a string. Let’s introduce the $eval function, which evaluates simple expressions using +Python’s literal_eval and/or simple_eval. It returns whatever data type it +evaluates to.

+
"There's a $toint($eval(10 * 2.2))% chance of survival."
+
+
+

Since the $eval is the innermost call, it will get a string as input - the string "10 * 2.2". +It evaluates this and returns the float 22.0. This time the outermost $toint will be called with +this float instead of with a string.

+
+

It’s important to safely validate your inputs since users may end up nesting your callables in any order. See the next section for useful tools to help with this.

+
+

In these examples, the result will be embedded in the larger string, so the result of the entire parsing will be a string:

+
  parser.parse(above_string)
+  "There's a 22% chance of survival."
+
+
+

However, if you use the parse_to_any (or parse(..., return_str=False)) and don’t add any extra string around the outermost function call, you’ll get the return type of the outermost callable back:

+
parser.parse_to_any("$toint($eval(10 * 2.2)")
+22
+parser.parse_to_any("the number $toint($eval(10 * 2.2).")
+"the number 22"
+parser.parse_to_any("$toint($eval(10 * 2.2)%")
+"22%"
+
+
+
+

Escaping special character

+

When entering funcparser callables in strings, it looks like a regular +function call inside a string:

+
"This is a $myfunc(arg1, arg2, kwarg=foo)."
+
+
+

Commas (,) and equal-signs (=) are considered to separate the arguments and +kwargs. In the same way, the right parenthesis ()) closes the argument list. +Sometimes you want to include commas in the argument without it breaking the +argument list.

+
"The $format(forest's smallest meadow, with dandelions) is to the west."
+
+
+

You can escape in various ways.

+
    +
  • Prepending special characters like , and = with the escape character \

  • +
+
"The $format(forest's smallest meadow\, with dandelions) is to the west."
+
+
+
    +
  • Wrapping your strings in double quotes. Unlike in raw Python, you +can’t escape with single quotes ' since these could also be apostrophes (like +forest's above). The result will be a verbatim string that contains +everything but the outermost double quotes.

  • +
+
'The $format("forest's smallest meadow, with dandelions") is to the west.'
+
+
+
    +
  • If you want verbatim double-quotes to appear in your string, you can escape +them with \" in turn.

  • +
+
'The $format("forest's smallest meadow, with \"dandelions\"') is to the west.'
+
+
+
+
+

Safe convertion of inputs

+

Since you don’t know in which order users may use your callables, they should +always check the types of its inputs and convert to the type the callable needs. +Note also that when converting from strings, there are limits what inputs you +can support. This is because FunctionParser strings can be used by +non-developer players/builders and some things (such as complex +classes/callables etc) are just not safe/possible to convert from string +representation.

+

In evennia.utils.utils is a helper called safe_convert_to_types. This function automates the conversion of simple data types in a safe way:

+
from evennia.utils.utils import safe_convert_to_types
+
+def _process_callable(*args, **kwargs):
+    """
+    $process(expression, local, extra1=34, extra2=foo)
+
+    """
+    args, kwargs = safe_convert_to_type(
+      (('py', str), {'extra1': int, 'extra2': str}),
+      *args, **kwargs)
+
+    # args/kwargs should be correct types now
+
+
+
+

In other words, in the callable $process(expression, local, extra1=.., extra2=...), the first argument will be handled by the ‘py’ converter (described below), the second will passed through regular Python str, kwargs will be handled by int and str respectively. You can supply your own converter function as long as it takes one argument and returns the converted result.

+
args, kwargs = safe_convert_to_type(
+        (tuple_of_arg_converters, dict_of_kwarg_converters), *args, **kwargs)
+
+
+

The special converter "py" will try to convert a string argument to a Python structure with the help of the following tools (which you may also find useful to experiment with on your own):

+
    +
  • ast.literal_eval is an in-built Python function. It only supports strings, bytes, numbers, tuples, lists, dicts, sets, booleans and None. That’s it - no arithmetic or modifications of data is allowed. This is good for converting individual values and lists/dicts from the input line to real Python objects.

  • +
  • simpleeval is a third-party tool included with Evennia. This allows for evaluation of simple (and thus safe) expressions. One can operate on numbers and strings with +-/* as well as do simple comparisons like 4 > 3 and more. It does not accept more complex containers like lists/dicts etc, so this and literal_eval are complementary to each other.

  • +
+
+

Warning

+

It may be tempting to run use Python’s in-built eval() or exec() functions as converters since these are able to convert any valid Python source code to Python. NEVER DO THIS unless you really, really know that ONLY developers will ever modify the string going into the callable. The parser is intended for untrusted users (if you were trusted you’d have access to Python already). Letting untrusted users pass strings to eval/exec is a MAJOR security risk. It allows the caller to run arbitrary Python code on your server. This is the path to maliciously deleted hard drives. Just don’t do it and sleep better at night.

+
+
+
+
+

Default funcparser callables

+

These are some example callables you can import and add your parser. They are divided into global-level dicts in evennia.utils.funcparser. Just import the dict(s) and merge/add one or more to them when you create your FuncParser instance to have those callables be available.

+
+

evennia.utils.funcparser.FUNCPARSER_CALLABLES

+

These are the ‘base’ callables.

+
    +
  • $eval(expression) (code) - this uses literal_eval and simple_eval (see previous section) attemt to convert a string expression to a python object. This handles e.g. lists of literals [1, 2, 3] and simple expressions like "1 + 2".

  • +
  • $toint(number) (code) - always converts an output to an integer, if possible.

  • +
  • $add/sub/mult/div(obj1, obj2) (code) - +this adds/subtracts/multiplies and divides to elements together. While simple addition could be done with $eval, this could for example be used also to add two lists together, which is not possible with eval; for example $add($eval([1,2,3]), $eval([4,5,6])) -> [1, 2, 3, 4, 5, 6].

  • +
  • $round(float, significant) (code) - rounds an input float into the number of provided significant digits. For example $round(3.54343, 3) -> 3.543.

  • +
  • $random([start, [end]]) (code) - this works like the Python random() function, but will randomize to an integer value if both start/end are +integers. Without argument, will return a float between 0 and 1.

  • +
  • $randint([start, [end]]) (code) - works like the randint() python function and always returns an integer.

  • +
  • $choice(list) (code) - the input will automatically be parsed the same way as $eval and is expected to be an iterable. A random element of this list will be returned.

  • +
  • $pad(text[, width, align, fillchar]) (code) - this will pad content. $pad("Hello", 30, c, -) will lead to a text centered in a 30-wide block surrounded by - characters.

  • +
  • $crop(text, width=78, suffix='[...]') (code) - this will crop a text longer than the width, by default ending it with a [...]-suffix that also fits within the width. If no width is given, the client width or settings.DEFAULT_CLIENT_WIDTH will be used.

  • +
  • $space(num) (code) - this will insert num spaces.

  • +
  • $just(string, width=40, align=c, indent=2) (code) - justifies the text to a given width, aligning it left/right/center or ‘f’ for full (spread text across width).

  • +
  • $ljust - shortcut to justify-left. Takes all other kwarg of $just.

  • +
  • $rjust - shortcut to right justify.

  • +
  • $cjust - shortcut to center justify.

  • +
  • $clr(startcolor, text[, endcolor]) (code) - color text. The color is given with one or two characters without the preceeding |. If no endcolor is given, the string will go back to neutral, so $clr(r, Hello) is equivalent to |rHello|n.

  • +
+
+
+

evennia.utils.funcparser.SEARCHING_CALLABLES

+

These are callables that requires access-checks in order to search for objects. So they require some extra reserved kwargs to be passed when running the parser:

+

+parser.parse_to_any(string, caller=<object or account>, access="control", ...)
+
+
+
+

The caller is required, it’s the the object to do the access-check for. The access kwarg is the +lock type to check, default being "control".

+
    +
  • $search(query,type=account|script,return_list=False) (code) - this will look up and try to match an object by key or alias. Use the type kwarg to search for account or script instead. By default this will return nothing if there are more than one match; if return_list is True a list of 0, 1 or more matches will be returned instead.

  • +
  • $obj(query), $dbref(query) - legacy aliases for $search.

  • +
  • $objlist(query) - legacy alias for $search, always returning a list.

  • +
+
+
+

evennia.utils.funcparser.ACTOR_STANCE_CALLABLES

+

These are used to implement actor-stance emoting. They are used by the DefaultObject.msg_contents method by default. You can read a lot more about this on the page +Change messages per receiver.

+

On the parser side, all these inline functions require extra kwargs be passed into the parser (done by msg_contents by default):

+
parser.parse(string, caller=<obj>, receiver=<obj>, mapping={'key': <obj>, ...})
+
+
+

Here the caller is the one sending the message and receiver the one to see it. The mapping contains references to other objects accessible via these callables.

+
    +
  • $you([key]) (code) - +if no key is given, this represents the caller, otherwise an object from mapping +will be used. As this message is sent to different recipients, the receiver will change and this will +be replaced either with the string you (if you and the receiver is the same entity) or with the +result of you_obj.get_display_name(looker=receiver). This allows for a single string to echo differently +depending on who sees it, and also to reference other people in the same way.

  • +
  • $You([key]) - same as $you but always capitalized.

  • +
  • $conj(verb) (code) - conjugates a verb +between 2nd person presens to 3rd person presence depending on who +sees the string. For example "$You() $conj(smiles)". will show as “You smile.” and “Tom smiles.” depending +on who sees it. This makes use of the tools in evennia.utils.verb_conjugation +to do this, and only works for English verbs.

  • +
  • $pron(pronoun [,options]) (code) - Dynamically +map pronouns (like his, herself, you, its etc) between 1st/2nd person to 3rd person.

  • +
+
+
+

evennia.prototypes.protfuncs

+

This is used by the Prototype system and allows for adding references inside the prototype. The funcparsing will happen before the spawn.

+

Available inlinefuncs to prototypes:

+
    +
  • All FUNCPARSER_CALLABLES and SEARCHING_CALLABLES

  • +
  • $protkey(key) - returns the value of another key within the same prototype. Note that the system will try to convert this to a ‘real’ value (like turning the string “3” into the integer 3), for security reasons, not all embedded values can be converted this way. Note however that you can do nested calls with inlinefuncs, including adding your own converters.

  • +
+
+
+

Example

+

Here’s an example of including the default callables together with two custom ones.

+
from evennia.utils import funcparser
+from evennia.utils import gametime
+
+def _dashline(*args, **kwargs):
+    if args:
+        return f"\n-------- {args[0]} --------"
+    return ''
+
+def _uptime(*args, **kwargs):
+    return gametime.uptime()
+
+callables = {
+    "dashline": _dashline,
+    "uptime": _uptime,
+    **funcparser.FUNCPARSER_CALLABLES,
+    **funcparser.ACTOR_STANCE_CALLABLES,
+    **funcparser.SEARCHING_CALLABLES
+}
+
+parser = funcparser.FuncParser(callables)
+
+string = "This is the current uptime:$dashline($toint($uptime()) seconds)"
+result = parser.parse(string)
+
+
+
+

Above we define two callables _dashline and _uptime and map them to names "dashline" and "uptime", +which is what we then can call as $header and $uptime in the string. We also have access to +all the defaults (like $toint()).

+

The parsed result of the above would be something like this:

+
This is the current uptime:
+------- 343 seconds -------
+
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Help-System.html b/docs/latest/Components/Help-System.html new file mode 100644 index 0000000000..dcea7d04e5 --- /dev/null +++ b/docs/latest/Components/Help-System.html @@ -0,0 +1,446 @@ + + + + + + + + + Help System — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Help System

+
> help theatre
+
+
+
------------------------------------------------------------------------------
+Help for The theatre (aliases: the hub, curtains)
+
+The theatre is at the centre of the city, both literally and figuratively ...
+(A lot more text about it follows ...)
+
+Subtopics:
+  theatre/lore
+  theatre/layout
+  theatre/dramatis personae
+------------------------------------------------------------------------------
+
+
+
> help evennia
+
+
+
------------------------------------------------------------------------------
+No help found
+
+There is no help topic matching 'evennia'.
+... But matches where found within the help texts of the suggestions below.
+
+Suggestions:
+  grapevine2chan, about, irc2chan
+-----------------------------------------------------------------------------
+
+
+

Evennia has an extensive help system covering both command-help and regular free-form help documentation. It supports subtopics and if failing to find a match it will provide suggestsions, first from alternative topics and then by finding mentions of the search term in help entries.

+

The help system is accessed in-game by use of the help command:

+
help <topic>
+
+
+

Sub-topics are accessed as help <topic>/<subtopic>/....

+
+

Working with three types of help entries

+

There are three ways to generate help entries:

+
    +
  • In the database

  • +
  • As Python modules

  • +
  • From Command doc strings

  • +
+
+

Database-stored help entries

+

Creating a new help entry from in-game is done with

+
sethelp <topic>[;aliases] [,category] [,lockstring] = <text>
+
+
+

For example

+
sethelp The Gods;pantheon, Lore = In the beginning all was dark ...
+
+
+

This will create a new help entry in the database. Use the /edit switch to open the EvEditor for more convenient in-game writing (but note that devs can also create help entries outside the game using their regular code editor, see below).

+

The HelpEntry stores database help. It is not a Typeclassed entity and can’t be extended using the typeclass mechanism.

+

Here’s how to create a database-help entry in code:

+
from evennia import create_help_entry
+entry = create_help_entry("emote",
+                "Emoting is important because ...",
+                category="Roleplaying", locks="view:all()")
+
+
+
+
+

File-stored help entries

+
+

New in version 1.0.

+
+

File-help entries are created by the game development team outside of the game. The help entries are defined in normal Python modules (.py file ending) containing a dict to represent each entry. They require a server reload before any changes apply.

+
    +
  • Evennia will look through all modules given by +settings.FILE_HELP_ENTRY_MODULES. This should be a list of python-paths for +Evennia to import.

  • +
  • If this module contains a top-level variable HELP_ENTRY_DICTS, this will be +imported and must be a list of help-entry dicts.

  • +
  • If no HELP_ENTRY_DICTS list is found, every top-level variable in the +module that is a dict will be read as a help entry. The variable-names will +be ignored in this case.

  • +
+

If you add multiple modules to be read, same-keyed help entries added later in +the list will override coming before.

+

Each entry dict must define keys to match that needed by all help entries. +Here’s an example of a help module:

+

+# in a module pointed to by settings.FILE_HELP_ENTRY_MODULES
+
+HELP_ENTRY_DICTS = [
+  {
+    "key": "The Gods",   # case-insensitive, can be searched by 'gods' too
+    "aliases": ['pantheon', 'religion']
+    "category": "Lore",
+    "locks": "read:all()",  # optional
+    "text": '''
+        The gods formed the world ...
+
+        # Subtopics
+
+        ## Pantheon
+
+        The pantheon consists of 40 gods that ...
+
+        ### God of love
+
+        The most prominent god is ...
+
+        ### God of war
+
+        Also known as 'the angry god', this god is known to ...
+
+    '''
+  },
+  {
+    "key": "The mortals",
+
+  }
+]
+
+
+
+

The help entry text will be dedented and will retain paragraphs. You should try +to keep your strings a reasonable width (it will look better). Just reload the +server and the file-based help entries will be available to view.

+
+
+

Command-help entries

+

The __docstring__ of Command classes are automatically extracted into a help entry. You set help_category directly on the class.

+
from evennia import Command
+
+class MyCommand(Command): 
+    """ 
+    This command is great! 
+
+    Usage: 
+      mycommand [argument]
+
+    When this command is called, great things happen. If you 
+    pass an argument, even GREATER things HAPPEN!
+
+    """
+
+    key = "mycommand"
+
+    locks: "cmd:all();read:all()"   # default 
+    help_category = "General"       # default
+    auto_help = True                # default 
+
+    # ...
+
+
+

When you update your code, the command’s help will follow. The idea is that the command docs are easier to maintain and keep up-to-date if the developer can change them at the same time as they do the code.

+
+
+

Locking help entries

+

The default help command gather all available commands and help entries +together so they can be searched or listed. By setting locks on the command/help +entry one can limit who can read help about it.

+
    +
  • Commands failing the normal cmd-lock will be removed before even getting +to the help command. In this case the other two lock types below are ignored.

  • +
  • The view access type determines if the command/help entry should be visible in +the main help index. If not given, it is assumed everyone can view.

  • +
  • The read access type determines if the command/help entry can be actually read. +If a read lock is given and view is not, the read-lock is assumed to +apply to view-access as well (so if you can’t read the help entry it will +also not show up in the index). If read-lock is not given, it’s assume +everyone can read the help entry.

  • +
+

For Commands you set the help-related locks the same way you would any lock:

+
class MyCommand(Command):
+    """
+    <docstring for command>
+    """
+    key = "mycommand"
+    # everyone can use the command, builders can view it in the help index
+    # but only devs can actually read the help (a weird setup for sure!)
+    locks = "cmd:all();view:perm(Builders);read:perm(Developers)
+
+
+
+

Db-help entries and File-Help entries work the same way (except the cmd-type +lock is not used. A file-help example:

+
help_entry = {
+    # ...
+    locks = "read:perm(Developer)",
+    # ...
+}
+
+
+
+
+

Changed in version 1.0: Changed the old ‘view’ lock to control the help-index inclusion and added +the new ‘read’ lock-type to control access to the entry itself.

+
+
+
+

Customizing the look of the help system

+

This is done almost exclusively by overriding the help command evennia.commands.default.help.CmdHelp.

+

Since the available commands may vary from moment to moment, help is responsible for collating the three sources of help-entries (commands/db/file) together and search through them on the fly. It also does all the formatting of the output.

+

To make it easier to tweak the look, the parts of the code that changes the visual presentation and entity searching has been broken out into separate methods on the command class. Override these in your version of help to change the display or tweak as you please. See the api link above for details.

+
+
+
+

Subtopics

+
+

New in version 1.0.

+
+

Rather than making a very long help entry, the text may also be broken up into subtopics. A list of the next level of subtopics are shown below the main help text and allows the user to read more about some particular detail that wouldn’t fit in the main text.

+

Subtopics use a markup slightly similar to markdown headings. The top level heading must be named # subtopics (non case-sensitive) and the following headers must be sub-headings to this (so ## subtopic name etc). All headings are non-case sensitive (the help command will format them). The topics can be nested at most to a depth of 5 (which is probably too many levels already). The parser uses fuzzy matching to find the subtopic, so one does not have to type it all out exactly.

+

Below is an example of a text with sub topics.

+
The theatre is the heart of the city, here you can find ...
+(This is the main help text, what you get with `help theatre`)
+
+# subtopics
+
+## lore
+
+The theatre holds many mysterious things...
+(`help theatre/lore`)
+
+### the grand opening
+
+The grand opening is the name for a mysterious event where ghosts appeared ...
+(`this is a subsub-topic to lore, accessible as `help theatre/lore/grand` or
+any other partial match).
+
+### the Phantom
+
+Deep under the theatre, rumors has it a monster hides ...
+(another subsubtopic, accessible as `help theatre/lore/phantom`)
+
+## layout
+
+The theatre is a two-story building situated at ...
+(`help theatre/layout`)
+
+## dramatis personae
+
+There are many interesting people prowling the halls of the theatre ...
+(`help theatre/dramatis` or `help theathre/drama` or `help theatre/personae` would work)
+
+### Primadonna Ada
+
+Everyone knows the primadonna! She is ...
+(A subtopic under dramatis personae, accessible as `help theatre/drama/ada` etc)
+
+### The gatekeeper
+
+He always keeps an eye on the door and ...
+(`help theatre/drama/gate`)
+
+
+
+
+
+

Technical notes

+
+

Help-entry clashes

+

Should you have clashing help-entries (of the same name) between the three types of available entries, the priority is

+
Command-auto-help > Db-help > File-help
+
+
+

The sethelp command (which only deals with creating db-based help entries) will warn you if a new help entry might shadow/be shadowed by a same/similar-named command or file-based help entry.

+
+
+

The Help Entry container

+

All help entries (no matter the source) are parsed into an object with the following properties:

+
    +
  • key - This is the main topic-name. For Commands, this is literally the command’s key.

  • +
  • aliases - Alternate names for the help entry. This can be useful if the main name is hard to remember.

  • +
  • help_category - The general grouping of the entry. This is optional. If not given it will use the default category given by settings.COMMAND_DEFAULT_HELP_CATEGORY for Commands and +settings.DEFAULT_HELP_CATEGORY for file+db help entries.

  • +
  • locks - Lock string (for commands) or LockHandler (all help entries). This defines who may read this entry. See the next section.

  • +
  • tags - This is not used by default, but could be used to further organize help entries.

  • +
  • text - The actual help entry text. This will be dedented and stripped of extra space at beginning and end.

  • +
+
+
+

Help pagination

+

A text that scrolls off the screen will automatically be paginated by the EvMore pager (you can control this with settings.HELP_MORE_ENABLED=False). If you use EvMore and want to control exactly where the pager should break the page, mark the break with the control character \f.

+
+
+

Search engine

+

Since it needs to search so different types of data, the help system has to collect all possibilities in memory before searching through the entire set. It uses the Lunr search engine to search through the main bulk of help entries. Lunr is a mature engine used for web-pages and produces much more sensible results than previous solutions.

+

Once the main entry has been found, subtopics are then searched with simple ==, startswith and in matching (there are so relatively few of them at that point).

+
+

Changed in version 1.0: Replaced the old bag-of-words algorithm with lunr package.

+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Inputfuncs.html b/docs/latest/Components/Inputfuncs.html new file mode 100644 index 0000000000..a930d3d638 --- /dev/null +++ b/docs/latest/Components/Inputfuncs.html @@ -0,0 +1,331 @@ + + + + + + + + + Inputfuncs — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Inputfuncs

+
          Internet│
+            ┌─────┐ │                                   ┌────────┐
+┌──────┐    │Text │ │  ┌────────────┐    ┌─────────┐    │Command │
+│Client├────┤JSON ├─┼──►commandtuple├────►Inputfunc├────►DB query│
+└──────┘    │etc  │ │  └────────────┘    └─────────┘    │etc     │
+            └─────┘ │                                   └────────┘
+                    │Evennia
+
+
+
+

The Inputfunc is the last fixed step on the Ingoing message path. The available Inputfuncs are looked up and called using commandtuple structures sent from the client. The job of the Inputfunc is to perform whatever action is requested, by firing a Command, performing a database query or whatever is needed.

+

Given a commandtuple on the form

+
(commandname, (args), {kwargs})
+
+
+

Evennia will try to find and call an Inputfunc on the form

+
def commandname(session, *args, **kwargs):
+    # ...
+
+
+
+

Or, if no match was found, it will call an inputfunc named “default” on this form

+
def default(session, cmdname, *args, **kwargs):
+    # cmdname is the name of the mismatched inputcommand
+
+
+
+

The default inputfuncs are found in evennia/server/inputfuncs.py.

+
+

Adding your own inputfuncs

+
    +
  1. Add a function on the above form to mygame/server/conf/inputfuncs.py. Your function must be in the global, outermost scope of that module and not start with an underscore (_) to be recognized as an inputfunc. i

  2. +
  3. reload the server.

  4. +
+

To overload a default inputfunc (see below), just add a function with the same name. You can also extend the settings-list INPUT_FUNC_MODULES.

+
INPUT_FUNC_MODULES += ["path.to.my.inputfunc.module"]
+
+
+

All global-level functions with a name not starting with _ in these module(s) will be used by Evennia as an inputfunc. The list is imported from left to right, so latter imported functions will replace earlier ones.

+
+
+

Default inputfuncs

+

Evennia defines a few default inputfuncs to handle the common cases. These are defined in +evennia/server/inputfuncs.py.

+
+

text

+
    +
  • Input: ("text", (textstring,), {})

  • +
  • Output: Depends on Command triggered

  • +
+

This is the most common of inputs, and the only one supported by every traditional mud. The argument is usually what the user sent from their command line. Since all text input from the user +like this is considered a Command, this inputfunc will do things like nick-replacement and then pass on the input to the central Commandhandler.

+
+
+

echo

+
    +
  • Input: ("echo", (args), {})

  • +
  • Output: ("text", ("Echo returns: %s" % args), {})

  • +
+

This is a test input, which just echoes the argument back to the session as text. Can be used for testing custom client input.

+
+
+

default

+

The default function, as mentioned above, absorbs all non-recognized inputcommands. The default one will just log an error.

+
+
+

client_options

+
    +
  • Input: ("client_options, (), {key:value, ...})

  • +
  • Output:

  • +
  • normal: None

  • +
  • get: ("client_options", (), {key:value, ...})

  • +
+

This is a direct command for setting protocol options. These are settable with the @option +command, but this offers a client-side way to set them. Not all connection protocols makes use of +all flags, but here are the possible keywords:

+
    +
  • get (bool): If this is true, ignore all other kwargs and immediately return the current settings +as an outputcommand ("client_options", (), {key=value, ...})-

  • +
  • client (str): A client identifier, like “mushclient”.

  • +
  • version (str): A client version

  • +
  • ansi (bool): Supports ansi colors

  • +
  • xterm256 (bool): Supports xterm256 colors or not

  • +
  • mxp (bool): Supports MXP or not

  • +
  • utf-8 (bool): Supports UTF-8 or not

  • +
  • screenreader (bool): Screen-reader mode on/off

  • +
  • mccp (bool): MCCP compression on/off

  • +
  • screenheight (int): Screen height in lines

  • +
  • screenwidth (int): Screen width in characters

  • +
  • inputdebug (bool): Debug input functions

  • +
  • nomarkup (bool): Strip all text tags

  • +
  • raw (bool): Leave text tags unparsed

  • +
+
+

Note that there are two GMCP aliases to this inputfunc - hello and supports_set, which means it will be accessed via the GMCP Hello and Supports.Set instructions assumed by some clients.

+
+
+
+

get_client_options

+
    +
  • Input: ("get_client_options, (), {key:value, ...})

  • +
  • Output: ("client_options, (), {key:value, ...})

  • +
+

This is a convenience wrapper that retrieves the current options by sending “get” to client_options above.

+
+
+

get_inputfuncs

+
    +
  • Input: ("get_inputfuncs", (), {})

  • +
  • Output: ("get_inputfuncs", (), {funcname:docstring, ...})

  • +
+

Returns an outputcommand on the form ("get_inputfuncs", (), {funcname:docstring, ...}) - a list of all the available inputfunctions along with their docstrings.

+
+
+

login

+
+

Note: this is currently experimental and not very well tested.

+
+
    +
  • Input: ("login", (username, password), {})

  • +
  • Output: Depends on login hooks

  • +
+

This performs the inputfunc version of a login operation on the current Session. It’s meant to be used by custom client setups.

+
+
+

get_value

+

Input: ("get_value", (name, ), {}) +Output: ("get_value", (value, ), {})

+

Retrieves a value from the Character or Account currently controlled by this Session. Takes one argument, This will only accept particular white-listed names, you’ll need to overload the function to expand. By default the following values can be retrieved:

+
    +
  • “name” or “key”: The key of the Account or puppeted Character.

  • +
  • “location”: Name of the current location, or “None”.

  • +
  • “servername”: Name of the Evennia server connected to.

  • +
+
+
+

repeat

+
    +
  • Input: ("repeat", (), {"callback":funcname,  "interval": secs, "stop": False})

  • +
  • Output: Depends on the repeated function. Will return ("text", (repeatlist),{} with a list of +accepted names if given an unfamiliar callback name.

  • +
+

This will tell evennia to repeatedly call a named function at a given interval. Behind the scenes this will set up a Ticker. Only previously acceptable functions are possible to repeat-call in this way, you’ll need to overload this inputfunc to add the ones you want to offer. By default only two example functions are allowed, “test1” and “test2”, which will just echo a text back at the given interval. Stop the repeat by sending "stop": True (note that you must include both the callback name and interval for Evennia to know what to stop).

+
+
+

unrepeat

+
    +
  • Input: ("unrepeat", (), ("callback":funcname,                            "interval": secs)

  • +
  • Output: None

  • +
+

This is a convenience wrapper for sending “stop” to the repeat inputfunc.

+
+
+

monitor

+
    +
  • Input: ("monitor", (), ("name":field_or_argname, stop=False)

  • +
  • Output (on change): ("monitor", (), {"name":name, "value":value})

  • +
+

This sets up on-object monitoring of Attributes or database fields. Whenever the field or Attribute changes in any way, the outputcommand will be sent. This is using the MonitorHandler behind the scenes. Pass the “stop” key to stop monitoring. Note that you must supply the name also when stopping to let the system know which monitor should be cancelled.

+

Only fields/attributes in a whitelist are allowed to be used, you have to overload this function to add more. By default the following fields/attributes can be monitored:

+
    +
  • “name”: The current character name

  • +
  • “location”: The current location

  • +
  • “desc”: The description Argument

  • +
+
+
+

unmonitor

+
    +
  • Input: ("unmonitor", (), {"name":name})

  • +
  • Output: None

  • +
+

A convenience wrapper that sends “stop” to the monitor function.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Locks.html b/docs/latest/Components/Locks.html new file mode 100644 index 0000000000..313dbaf7ef --- /dev/null +++ b/docs/latest/Components/Locks.html @@ -0,0 +1,430 @@ + + + + + + + + + Locks — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Locks

+

For most games it is a good idea to restrict what people can do. In Evennia such restrictions are applied and checked by something called locks. All Evennia entities (Commands, Objects, Scripts, Accounts, Help System, messages and channels) are accessed through locks.

+

A lock can be thought of as an “access rule” restricting a particular use of an Evennia entity. +Whenever another entity wants that kind of access the lock will analyze that entity in different ways to determine if access should be granted or not. Evennia implements a “lockdown” philosophy - all entities are inaccessible unless you explicitly define a lock that allows some or full access.

+

Let’s take an example: An object has a lock on itself that restricts how people may “delete” that object. Apart from knowing that it restricts deletion, the lock also knows that only players with the specific ID of, say, 34 are allowed to delete it. So whenever a player tries to run delete on the object, the delete command makes sure to check if this player is really allowed to do so. It calls the lock, which in turn checks if the player’s id is 34. Only then will it allow delete to go on with its job.

+
+

Working with locks

+

The in-game command for setting locks on objects is lock:

+
 > lock obj = <lockstring>
+
+
+

The <lockstring> is a string of a certain form that defines the behaviour of the lock. We will go into more detail on how <lockstring> should look in the next section.

+

Code-wise, Evennia handles locks through what is usually called locks on all relevant entities. This is a handler that allows you to add, delete and check locks.

+
     myobj.locks.add(<lockstring>)
+
+
+

One can call locks.check() to perform a lock check, but to hide the underlying implementation all objects also have a convenience function called access. This should preferably be used. In the example below, accessing_obj is the object requesting the ‘delete’ access whereas obj is the object that might get deleted. This is how it would look (and does look) from inside the delete command:

+
     if not obj.access(accessing_obj, 'delete'):
+         accessing_obj.msg("Sorry, you may not delete that.")
+         return
+
+
+
+

Defining locks

+

Defining a lock (i.e. an access restriction) in Evennia is done by adding simple strings of lock +definitions to the object’s locks property using obj.locks.add().

+

Here are some examples of lock strings (not including the quotes):

+
     delete:id(34)   # only allow obj #34 to delete
+     edit:all()      # let everyone edit
+     # only those who are not "very_weak" or are Admins may pick this up
+     get: not attr(very_weak) or perm(Admin)
+
+
+

Formally, a lockstring has the following syntax:

+
     access_type: [NOT] lockfunc1([arg1,..]) [AND|OR] [NOT] lockfunc2([arg1,...]) [...]
+
+
+

where [] marks optional parts. AND, OR and NOT are not case sensitive and excess spaces are ignored. lockfunc1, lockfunc2 etc are special lock functions available to the lock system.

+

So, a lockstring consists of the type of restriction (the access_type), a colon (:) and then an expression involving function calls that determine what is needed to pass the lock. Each function returns either True or False. AND, OR and NOT work as they do normally in Python. If the total result is True, the lock is passed.

+

You can create several lock types one after the other by separating them with a semicolon (;) in the lockstring. The string below yields the same result as the previous example:

+
delete:id(34);edit:all();get: not attr(very_weak) or perm(Admin)
+
+
+
+
+

Valid access_types

+

An access_type, the first part of a lockstring, defines what kind of capability a lock controls, such as “delete” or “edit”. You may in principle name your access_type anything as long as it is unique for the particular object. The name of the access types is not case-sensitive.

+

If you want to make sure the lock is used however, you should pick access_type names that you (or the default command set) actually checks for, as in the example of delete above that uses the ‘delete’ access_type.

+

Below are the access_types checked by the default commandset.

+
    +
  • Commands

    +
      +
    • cmd - this defines who may call this command at all.

    • +
    +
  • +
  • Objects:

    +
      +
    • control - who is the “owner” of the object. Can set locks, delete it etc. Defaults to the creator of the object.

    • +
    • call - who may call Object-commands stored on this Object except for the Object itself. By default, Objects share their Commands with anyone in the same location (e.g. so you can ‘press’ a Button object in the room). For Characters and Mobs (who likely only use those Commands for themselves and don’t want to share them) this should usually be turned off completely, using something like call:false().

    • +
    • examine - who may examine this object’s properties.

    • +
    • delete - who may delete the object.

    • +
    • edit - who may edit properties and attributes of the object.

    • +
    • view - if the look command will display/list this object in descriptions and if you will be able to see its description. Note that if you target it specifically by name, the system will still find it, just not be able to look at it. See search lock to completely hide the item.

    • +
    • search - this controls if the object can be found with the DefaultObject.search method (usually referred to with caller.search in Commands). This is how to create entirely ‘undetectable’ in-game objects. If not setting this lock explicitly, all objects are assumed searchable.

    • +
    • get- who may pick up the object and carry it around.

    • +
    • puppet - who may “become” this object and control it as their “character”.

    • +
    • attrcreate - who may create new attributes on the object (default True)

    • +
    +
  • +
  • Characters:

    +
      +
    • Same as for Objects

    • +
    +
  • +
  • Exits:

    +
      +
    • Same as for Objects

    • +
    • traverse - who may pass the exit.

    • +
    +
  • +
  • Accounts:

    +
      +
    • examine - who may examine the account’s properties.

    • +
    • delete - who may delete the account.

    • +
    • edit - who may edit the account’s attributes and properties.

    • +
    • msg - who may send messages to the account.

    • +
    • boot - who may boot the account.

    • +
    +
  • +
  • Attributes: (only checked by obj.secure_attr)

    +
      +
    • attrread - see/access attribute

    • +
    • attredit - change/delete attribute

    • +
    +
  • +
  • Channels:

    +
      +
    • control - who is administrating the channel. This means the ability to delete the channel, boot listeners etc.

    • +
    • send - who may send to the channel.

    • +
    • listen - who may subscribe and listen to the channel.

    • +
    +
  • +
  • HelpEntry:

    +
      +
    • examine - who may view this help entry (usually everyone)

    • +
    • edit - who may edit this help entry.

    • +
    +
  • +
+

So to take an example, whenever an exit is to be traversed, a lock of the type traverse will be checked. Defining a suitable lock type for an exit object would thus involve a lockstring traverse: <lock functions>.

+
+
+

Custom access_types

+

As stated above, the access_type part of the lock is simply the ‘name’ or ‘type’ of the lock. The text is an arbitrary string that must be unique for an object. If adding a lock with the same access_type as one that already exists on the object, the new one override the old one.

+

For example, if you wanted to create a bulletin board system and wanted to restrict who can either read a board or post to a board. You could then define locks such as:

+
     obj.locks.add("read:perm(Player);post:perm(Admin)")
+
+
+

This will create a ‘read’ access type for Characters having the Player permission or above and a ‘post’ access type for those with Admin permissions or above (see below how the perm() lock function works). When it comes time to test these permissions, simply check like this (in this example, the obj may be a board on the bulletin board system and accessing_obj is the player trying to read the board):

+
     if not obj.access(accessing_obj, 'read'):
+         accessing_obj.msg("Sorry, you may not read that.")
+         return
+
+
+
+
+

Lock functions

+

A lock function is a normal Python function put in a place Evennia looks for such functions. The modules Evennia looks at is the list settings.LOCK_FUNC_MODULES. All functions in any of those modules will automatically be considered a valid lock function. The default ones are found in evennia/locks/lockfuncs.py and you can start adding your own in mygame/server/conf/lockfuncs.py. You can append the setting to add more module paths. To replace a default lock function, just add your own with the same name.

+

This is the basic definition of a lock function:

+
def lockfunc_name(accessing_obj, accessed_obj, *args, **kwargs):
+    return True # or False
+
+
+

The accessing object is the object wanting to get access. The accessed object is the object being accessed (the object with the lock). The function always return a boolean determining if the lock is passed or not.

+

The *args will become the tuple of arguments given to the lockfunc. So for a lockstring "edit:id(3)" (a lockfunc named id), *args in the lockfunc would be (3,) .

+

The **kwargs dict has one default keyword always provided by Evennia, the access_type, which is a string with the access type being checked for. For the lockstring "edit:id(3)", access_type" would be "edit". This is unused by default Evennia.

+

Any arguments explicitly given in the lock definition will appear as extra arguments.

+
# A simple example lock function. Called with e.g. `id(34)`. This is
+# defined in, say mygame/server/conf/lockfuncs.py
+
+def id(accessing_obj, accessed_obj, *args, **kwargs):
+    if args:
+        wanted_id = args[0]
+        return accessing_obj.id == wanted_id
+    return False
+
+
+

The above could for example be used in a lock function like this:

+
    # we have `obj` and `owner_object` from before
+    obj.locks.add(f"edit: id({owner_object.id})")
+
+
+

We could check if the “edit” lock is passed with something like this:

+
    # as part of a Command's func() method, for example
+    if not obj.access(caller, "edit"):
+        caller.msg("You don't have access to edit this!")
+        return
+
+
+

In this example, everyone except the caller with the right id will get the error.

+
+

(Using the * and ** syntax causes Python to magically put all extra arguments into a list args and all keyword arguments into a dictionary kwargs respectively. If you are unfamiliar with how *args and **kwargs work, see the Python manuals).

+
+

Some useful default lockfuncs (see src/locks/lockfuncs.py for more):

+
    +
  • true()/all() - give access to everyone

  • +
  • false()/none()/superuser() - give access to none. Superusers bypass the check entirely and are thus the only ones who will pass this check.

  • +
  • perm(perm) - this tries to match a given permission property, on an Account firsthand, on a Character second. See below.

  • +
  • perm_above(perm) - like perm but requires a “higher” permission level than the one given.

  • +
  • id(num)/dbref(num) - checks so the access_object has a certain dbref/id.

  • +
  • attr(attrname) - checks if a certain Attribute exists on accessing_object.

  • +
  • attr(attrname, value) - checks so an attribute exists on accessing_object and has the given value.

  • +
  • attr_gt(attrname, value) - checks so accessing_object has a value larger (>) than the given value.

  • +
  • attr_ge, attr_lt, attr_le, attr_ne - corresponding for >=, <, <= and !=.

  • +
  • holds(objid) - checks so the accessing objects contains an object of given name or dbref.

  • +
  • inside() - checks so the accessing object is inside the accessed object (the inverse of holds()).

  • +
  • pperm(perm), pid(num)/pdbref(num) - same as perm, id/dbref but always looks for permissions and dbrefs of Accounts, not on Characters.

  • +
  • serversetting(settingname, value) - Only returns True if Evennia has a given setting or a setting set to a given value.

  • +
+
+
+

Checking simple strings

+

Sometimes you don’t really need to look up a certain lock, you just want to check a lockstring. A common use is inside Commands, in order to check if a user has a certain permission. The lockhandler has a method check_lockstring(accessing_obj, lockstring, bypass_superuser=False) that allows this.

+
     # inside command definition
+     if not self.caller.locks.check_lockstring(self.caller, "dummy:perm(Admin)"):
+         self.caller.msg("You must be an Admin or higher to do this!")
+         return
+
+
+

Note here that the access_type can be left to a dummy value since this method does not actually do a Lock lookup.

+
+
+

Default locks

+

Evennia sets up a few basic locks on all new objects and accounts (if we didn’t, noone would have any access to anything from the start). This is all defined in the root Typeclasses of the respective entity, in the hook method basetype_setup() (which you usually don’t want to edit unless you want to change how basic stuff like rooms and exits store their internal variables). This is called once, before at_object_creation, so just put them in the latter method on your child object to change the default. Also creation commands like create changes the locks of objects you create - for example it sets the control lock_type so as to allow you, its creator, to control and delete the object.

+
+
+
+

More Lock definition examples

+
examine: attr(eyesight, excellent) or perm(Builders)
+
+
+

You are only allowed to do examine on this object if you have ‘excellent’ eyesight (that is, has an Attribute eyesight with the value excellent defined on yourself) or if you have the “Builders” permission string assigned to you.

+
open: holds('the green key') or perm(Builder)
+
+
+

This could be called by the open command on a “door” object. The check is passed if you are a Builder or has the right key in your inventory.

+
cmd: perm(Builders)
+
+
+

Evennia’s command handler looks for a lock of type cmd to determine if a user is allowed to even call upon a particular command or not. When you define a command, this is the kind of lock you must set. See the default command set for lots of examples. If a character/account don’t pass the cmd lock type the command will not even appear in their help list.

+
cmd: not perm(no_tell)
+
+
+

“Permissions” can also be used to block users or implement highly specific bans. The above example would be be added as a lock string to the tell command. This will allow everyone not having the “permission” no_tell to use the tell command. You could easily give an account the “permission” no_tell to disable their use of this particular command henceforth.

+
    dbref = caller.id
+    lockstring = "control:id(%s);examine:perm(Builders);delete:id(%s) or perm(Admin);get:all()" %
+(dbref, dbref)
+    new_obj.locks.add(lockstring)
+
+
+

This is how the create command sets up new objects. In sequence, this permission string sets the owner of this object be the creator (the one running create). Builders may examine the object whereas only Admins and the creator may delete it. Everyone can pick it up.

+
+

A complete example of setting locks on an object

+

Assume we have two objects - one is ourselves (not superuser) and the other is an Object +called box.

+
 > create/drop box
+ > desc box = "This is a very big and heavy box."
+
+
+

We want to limit which objects can pick up this heavy box. Let’s say that to do that we require the would-be lifter to to have an attribute strength on themselves, with a value greater than 50. We assign it to ourselves to begin with.

+
 > set self/strength = 45
+
+
+

Ok, so for testing we made ourselves strong, but not strong enough. Now we need to look at what happens when someone tries to pick up the the box - they use the get command (in the default set). This is defined in evennia/commands/default/general.py. In its code we find this snippet:

+
    if not obj.access(caller, 'get'):
+        if obj.db.get_err_msg:
+            caller.msg(obj.db.get_err_msg)
+        else:
+            caller.msg("You can't get that.")
+        return
+
+
+

So the get command looks for a lock with the type get (not so surprising). It also looks for an Attribute on the checked object called get_err_msg in order to return a customized error message. Sounds good! Let’s start by setting that on the box:

+
 > set box/get_err_msg = You are not strong enough to lift this box.
+
+
+

Next we need to craft a Lock of type get on our box. We want it to only be passed if the accessing object has the attribute strength of the right value. For this we would need to create a lock function that checks if attributes have a value greater than a given value. Luckily there is already such a one included in Evennia (see evennia/locks/lockfuncs.py), called attr_gt.

+

So the lock string will look like this: get:attr_gt(strength, 50). We put this on the box now:

+
 lock box = get:attr_gt(strength, 50)
+
+
+

Try to get the object and you should get the message that we are not strong enough. Increase your strength above 50 however and you’ll pick it up no problem. Done! A very heavy box!

+

If you wanted to set this up in python code, it would look something like this:

+

+ from evennia import create_object
+
+    # create, then set the lock
+    box = create_object(None, key="box")
+    box.locks.add("get:attr_gt(strength, 50)")
+
+    # or we can assign locks in one go right away
+    box = create_object(None, key="box", locks="get:attr_gt(strength, 50)")
+
+    # set the attributes
+    box.db.desc = "This is a very big and heavy box."
+    box.db.get_err_msg = "You are not strong enough to lift this box."
+
+    # one heavy box, ready to withstand all but the strongest...
+
+
+
+
+
+

On Django’s permission system

+

Django also implements a comprehensive permission/security system of its own. The reason we don’t use that is because it is app-centric (app in the Django sense). Its permission strings are of the form appname.permstring and it automatically adds three of them for each database model in the app - for the app evennia/object this would be for example ‘object.create’, ‘object.admin’ and ‘object.edit’. This makes a lot of sense for a web application, not so much for a MUD, especially when we try to hide away as much of the underlying architecture as possible.

+

The django permissions are not completely gone however. We use it for validating passwords during login. It is also used exclusively for managing Evennia’s web-based admin site, which is a graphical front-end for the database of Evennia. You edit and assign such permissions directly from the web interface. It’s stand-alone from the permissions described above.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/MonitorHandler.html b/docs/latest/Components/MonitorHandler.html new file mode 100644 index 0000000000..0d917da39d --- /dev/null +++ b/docs/latest/Components/MonitorHandler.html @@ -0,0 +1,208 @@ + + + + + + + + + MonitorHandler — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

MonitorHandler

+

The MonitorHandler is a system for watching changes in properties or Attributes on objects. A +monitor can be thought of as a sort of trigger that responds to change.

+

The main use for the MonitorHandler is to report changes to the client; for example the client +Session may ask Evennia to monitor the value of the Character’s health attribute and report +whenever it changes. This way the client could for example update its health bar graphic as needed.

+
+

Using the MonitorHandler

+

The MontorHandler is accessed from the singleton evennia.MONITOR_HANDLER. The code for the handler +is in evennia.scripts.monitorhandler.

+

Here’s how to add a new monitor:

+
from evennia import MONITOR_HANDLER
+
+MONITOR_HANDLER.add(obj, fieldname, callback,
+                    idstring="", persistent=False, **kwargs)
+
+
+
+
    +
  • obj (Typeclassed entity) - the object to monitor. Since this must be +typeclassed, it means you can’t monitor changes on Sessions with the monitorhandler, for +example.

  • +
  • fieldname (str) - the name of a field or Attribute on obj. If you want to +monitor a database field you must specify its full name, including the starting db_ (like +db_key, db_location etc). Any names not starting with db_ are instead assumed to be the names +of Attributes. This difference matters, since the MonitorHandler will automatically know to watch +the db_value field of the Attribute.

  • +
  • callback(callable) - This will be called as callback(fieldname=fieldname, obj=obj, **kwargs) +when the field updates.

  • +
  • idstring (str) - this is used to separate multiple monitors on the same object and fieldname. +This is required in order to properly identify and remove the monitor later. It’s also used for +saving it.

  • +
  • persistent (bool) - if True, the monitor will survive a server reboot.

  • +
+

Example:

+
from evennia import MONITOR_HANDLER as monitorhandler
+
+def _monitor_callback(fieldname="", obj=None, **kwargs):    
+    # reporting callback that works both
+    # for db-fields and Attributes
+    if fieldname.startswith("db_"):
+        new_value = getattr(obj, fieldname)
+    else: # an attribute    
+        new_value = obj.attributes.get(fieldname)
+    obj.msg(f"{obj.key}.{fieldname} changed to '{new_value}'.")
+
+# (we could add _some_other_monitor_callback here too)
+
+# monitor Attribute (assume we have obj from before)
+monitorhandler.add(obj, "desc", _monitor_callback)  
+
+# monitor same db-field with two different callbacks (must separate by id_string)
+monitorhandler.add(obj, "db_key", _monitor_callback, id_string="foo")  
+monitorhandler.add(obj, "db_key", _some_other_monitor_callback, id_string="bar")
+
+
+
+

A monitor is uniquely identified by the combination of the object instance it is monitoring, the +name of the field/attribute to monitor on that object and its idstring (obj + fieldname + +idstring). The idstring will be the empty string unless given explicitly.

+

So to “un-monitor” the above you need to supply enough information for the system to uniquely find +the monitor to remove:

+
monitorhandler.remove(obj, "desc")
+monitorhandler.remove(obj, "db_key", idstring="foo")
+monitorhandler.remove(obj, "db_key", idstring="bar")
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Msg.html b/docs/latest/Components/Msg.html new file mode 100644 index 0000000000..193aacd107 --- /dev/null +++ b/docs/latest/Components/Msg.html @@ -0,0 +1,217 @@ + + + + + + + + + Msg — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Msg

+

The Msg object represents a database-saved piece of communication. Think of it as a discrete piece of email - it contains a message, some metadata and will always have a sender and one or more recipients.

+

Once created, a Msg is normally not changed. It is persitently saved in the database. This allows for comprehensive logging of communications. Here are some good uses for Msg objects:

+
    +
  • page/tells (the page command is how Evennia uses them out of the box)

  • +
  • messages in a bulletin board

  • +
  • game-wide email stored in ‘mailboxes’.

  • +
+
+

Important

+

A Msg does not have any in-game representation. So if you want to use them +to represent in-game mail/letters, the physical letters would never be +visible in a room (possible to steal, spy on etc) unless you make your +spy-system access the Msgs directly (or go to the trouble of spawning an +actual in-game letter-object based on the Msg)

+
+
+

Changed in version 1.0: Channels dropped Msg-support. Now only used in page command by default.

+
+
+

Working with Msg

+

The Msg is intended to be used exclusively in code, to build other game systems. It is not a Typeclassed entity, which means it cannot (easily) be overridden. It doesn’t support Attributes (but it does support Tags). It tries to be lean and small since a new one is created for every message. +You create a new message with evennia.create_message:

+
    from evennia import create_message
+    message = create_message(senders, message, receivers,
+                             locks=..., tags=..., header=...)
+
+
+

You can search for Msg objects in various ways:

+
  from evennia import search_message, Msg
+
+  # args are optional. Only a single sender/receiver should be passed
+  messages = search_message(sender=..., receiver=..., freetext=..., dbref=...)
+
+  # get all messages for a given sender/receiver
+  messages = Msg.objects.get_msg_by_sender(sender)
+  messages = Msg.objects.get_msg_by_receiver(recipient)
+
+
+
+
+

Properties on Msg

+
    +
  • senders - there must always be at least one sender. This is a set of

  • +
  • Account, Object, Script +or str in any combination (but usually a message only targets one type). +Using a str for a sender indicates it’s an ‘external’ sender and +and can be used to point to a sender that is not a typeclassed entity. This is not used by default +and what this would be depends on the system (it could be a unique id or a +python-path, for example). While most systems expect a single sender, it’s +possible to have any number of them.

  • +
  • receivers - these are the ones to see the Msg. These are again any combination of +Account, Object or Script or str (an ‘external’ receiver). +It’s in principle possible to have zero receivers but most usages of Msg expects one or more.

  • +
  • header - this is an optional text field that can contain meta-information about the message. For +an email-like system it would be the subject line. This can be independently searched, making +this a powerful place for quickly finding messages.

  • +
  • message - the actual text being sent.

  • +
  • date_sent - this is auto-set to the time the Msg was created (and thus presumably sent).

  • +
  • locks - the Evennia lock handler. Use with locks.add() etc and check locks with msg.access() +like for all other lockable entities. This can be used to limit access to the contents +of the Msg. The default lock-type to check is 'read'.

  • +
  • hide_from - this is an optional list of Accounts or Objects that +will not see this Msg. This relationship is available mainly for optimization +reasons since it allows quick filtering of messages not intended for a given +target.

  • +
+
+
+
+

TempMsg

+

evennia.comms.models.TempMsg is an object that implements the same API as the regular Msg, but which has no database component (and thus cannot be searched). It’s meant to plugged into systems expecting a Msg but where you just want to process the message without saving it.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Nicks.html b/docs/latest/Components/Nicks.html new file mode 100644 index 0000000000..da87186e1b --- /dev/null +++ b/docs/latest/Components/Nicks.html @@ -0,0 +1,252 @@ + + + + + + + + + Nicks — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Nicks

+

Nicks, short for Nicknames is a system allowing an object (usually a Account) to +assign custom replacement names for other game entities.

+

Nicks are not to be confused with Aliases. Setting an Alias on a game entity actually changes an +inherent attribute on that entity, and everyone in the game will be able to use that alias to +address the entity thereafter. A Nick on the other hand, is used to map a different way you +alone can refer to that entity. Nicks are also commonly used to replace your input text which means +you can create your own aliases to default commands.

+

Default Evennia use Nicks in three flavours that determine when Evennia actually tries to do the +substitution.

+
    +
  • inputline - replacement is attempted whenever you write anything on the command line. This is the +default.

  • +
  • objects - replacement is only attempted when referring to an object

  • +
  • accounts - replacement is only attempted when referring an account

  • +
+

Here’s how to use it in the default command set (using the nick command):

+
 nick ls = look
+
+
+

This is a good one for unix/linux users who are accustomed to using the ls command in their daily +life. It is equivalent to nick/inputline ls = look.

+
 nick/object mycar2 = The red sports car 
+
+
+

With this example, substitutions will only be done specifically for commands expecting an object +reference, such as

+
 look mycar2 
+
+
+

becomes equivalent to “look The red sports car”.

+
 nick/accounts tom = Thomas Johnsson
+
+
+

This is useful for commands searching for accounts explicitly:

+
 @find *tom 
+
+
+

One can use nicks to speed up input. Below we add ourselves a quicker way to build red buttons. In +the future just writing rb will be enough to execute that whole long string.

+
 nick rb = @create button:examples.red_button.RedButton
+
+
+

Nicks could also be used as the start for building a “recog” system suitable for an RP mud.

+
 nick/account Arnold = The mysterious hooded man
+
+
+

The nick replacer also supports unix-style templating:

+
 nick build $1 $2 = @create/drop $1;$2
+
+
+

This will catch space separated arguments and store them in the the tags $1 and $2, to be +inserted in the replacement string. This example allows you to do build box crate and have Evennia +see @create/drop box;crate. You may use any $ numbers between 1 and 99, but the markers must +match between the nick pattern and the replacement.

+
+

If you want to catch “the rest” of a command argument, make sure to put a $ tag with no spaces +to the right of it - it will then receive everything up until the end of the line.

+
+

You can also use shell-type wildcards:

+
    +
  • * - matches everything.

  • +
  • ? - matches a single character.

  • +
  • [seq] - matches everything in the sequence, e.g. [xyz] will match both x, y and z

  • +
  • [!seq] - matches everything not in the sequence. e.g. [!xyz] will match all but x,y z.

  • +
+
+

Coding with nicks

+

Nicks are stored as the Nick database model and are referred from the normal Evennia +object through the nicks property - this is known as the NickHandler. The NickHandler +offers effective error checking, searches and conversion.

+
    # A command/channel nick:
+      obj.nicks.add("greetjack", "tell Jack = Hello pal!")
+    
+    # An object nick:  
+      obj.nicks.add("rose", "The red flower", nick_type="object")
+    
+    # An account nick:
+      obj.nicks.add("tom", "Tommy Hill", nick_type="account")
+    
+    # My own custom nick type (handled by my own game code somehow):
+      obj.nicks.add("hood", "The hooded man", nick_type="my_identsystem")
+    
+    # get back the translated nick:
+     full_name = obj.nicks.get("rose", nick_type="object")
+    
+    # delete a previous set nick
+      object.nicks.remove("rose", nick_type="object")
+
+
+

In a command definition you can reach the nick handler through self.caller.nicks. See the nick +command in evennia/commands/default/general.py for more examples.

+

As a last note, The Evennia channel alias systems are using nicks with the +nick_type="channel" in order to allow users to create their own custom aliases to channels.

+
+
+

Advanced note

+

Internally, nicks are Attributes saved with the db_attrype set to “nick” (normal +Attributes has this set to None).

+

The nick stores the replacement data in the Attribute.db_value field as a tuple with four fields +(regex_nick, template_string, raw_nick, raw_template). Here regex_nick is the converted regex +representation of the raw_nick and the template-string is a version of the raw_template +prepared for efficient replacement of any $- type markers. The raw_nick and raw_template are +basically the unchanged strings you enter to the nick command (with unparsed $ etc).

+

If you need to access the tuple for some reason, here’s how:

+
tuple = obj.nicks.get("nickname", return_tuple=True)
+# or, alternatively
+tuple = obj.nicks.get("nickname", return_obj=True).value
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Objects.html b/docs/latest/Components/Objects.html new file mode 100644 index 0000000000..dafddbc493 --- /dev/null +++ b/docs/latest/Components/Objects.html @@ -0,0 +1,299 @@ + + + + + + + + + Objects — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Objects

+

Message-path:

+
┌──────┐ │   ┌───────┐    ┌───────┐   ┌──────┐
+│Client├─┼──►│Session├───►│Account├──►│Object│
+└──────┘ │   └───────┘    └───────┘   └──────┘
+                                         ^
+
+
+

All in-game objects in Evennia, be it characters, chairs, monsters, rooms or hand grenades are jointly referred to as an Evennia Object. An Object is generally something you can look and interact with in the game world. When a message travels from the client, the Object-level is the last stop.

+

Objects form the core of Evennia and is probably what you’ll spend most time working with. Objects are Typeclassed entities.

+

An Evennia Object is, by definition, a Python class that includes evennia.objects.objects.DefaultObject among its parents. Evennia defines several subclasses of DefaultObject:

+
    +
  • Object - the base in-game entity. Found in mygame/typeclasses/objects.py. Inherits directly from DefaultObject.

  • +
  • Characters - the normal in-game Character, controlled by a player. Found in mygame/typeclasses/characters.py. Inherits from DefaultCharacter, which is turn a child of DefaultObject.

  • +
  • Rooms - a location in the game world. Found in mygame/typeclasses/rooms.py. Inherits from DefaultRoom, which is in turn a child of DefaultObject).

  • +
  • Exits - represents a one-way connection to another location. Found in mygame/typeclasses/exits.py (inherits from DefaultExit, which is in turn a child of DefaultObject).

  • +
+
+

Object

+

Inheritance Tree:

+
┌─────────────┐
+│DefaultObject│
+└──────▲──────┘
+       │       ┌────────────┐
+       │ ┌─────►ObjectParent│
+       │ │     └────────────┘
+     ┌─┴─┴──┐
+     │Object│
+     └──────┘
+
+
+
+

For an explanation of ObjectParent, see next section.

+
+

The Object class is meant to be used as the basis for creating things that are neither characters, rooms or exits - anything from weapons and armour, equipment and houses can be represented by extending the Object class. Depending on your game, this also goes for NPCs and monsters (in some games you may want to treat NPCs as just an un-puppeted Character instead).

+

You should not use Objects for game systems. Don’t use an ‘invisible’ Object for tracking weather, combat, economy or guild memberships - that’s what Scripts are for.

+
+
+

ObjectParent - Adding common functionality

+

Object, as well as Character, Room and Exit classes all additionally inherit from mygame.typeclasses.objects.ObjectParent.

+

ObjectParent is an empty ‘mixin’ class. You can add stuff to this class that you want all in-game entities to have.

+

Here is an example:

+
# in mygame/typeclasses/objects.py
+# ... 
+
+from evennia.objects.objects import DefaultObject 
+
+class ObjectParent:
+    def at_pre_get(self, getter, **kwargs):
+       # make all entities by default un-pickable
+      return False
+
+
+

Now all of Object, Exit. Room and Character default to not being able to be picked up using the get command.

+
+
+

Working with children of DefaultObject

+

This functionality is shared by all sub-classes of DefaultObject. You can easily add your own in-game behavior by either modifying one of the typeclasses in your game dir or by inheriting further from them.

+

You can put your new typeclass directly in the relevant module, or you could organize your code in some other way. Here we assume we make a new module mygame/typeclasses/flowers.py:

+
    # mygame/typeclasses/flowers.py
+
+    from typeclasses.objects import Object
+
+    class Rose(Object):
+        """
+        This creates a simple rose object        
+        """    
+        def at_object_creation(self):
+            "this is called only once, when object is first created"
+            # add a persistent attribute 'desc' 
+            # to object (silly example).
+            self.db.desc = "This is a pretty rose with thorns."     
+
+
+

Now you just need to point to the class Rose with the create command to make a new rose:

+
 create/drop MyRose:flowers.Rose
+
+
+

What the create command actually does is to use the evennia.create_object function. You can do the same thing yourself in code:

+
    from evennia import create_object
+    new_rose = create_object("typeclasses.flowers.Rose", key="MyRose")
+
+
+

(The create command will auto-append the most likely path to your typeclass, if you enter the call manually you have to give the full path to the class. The create.create_object function is powerful and should be used for all coded object creating (so this is what you use when defining your own building commands).

+

This particular Rose class doesn’t really do much, all it does it make sure the attribute desc(which is what the look command looks for) is pre-set, which is pretty pointless since you will usually want to change this at build time (using the desc command or using the Spawner).

+
+

Properties and functions on Objects

+

Beyond the properties assigned to all typeclassed objects (see that page for a list +of those), the Object also has the following custom properties:

+
    +
  • aliases - a handler that allows you to add and remove aliases from this object. Use aliases.add() to add a new alias and aliases.remove() to remove one.

  • +
  • location - a reference to the object currently containing this object.

  • +
  • home is a backup location. The main motivation is to have a safe place to move the object to if its location is destroyed. All objects should usually have a home location for safety.

  • +
  • destination - this holds a reference to another object this object links to in some way. Its main use is for Exits, it’s otherwise usually unset.

  • +
  • nicks - as opposed to aliases, a Nick holds a convenient nickname replacement for a real name, word or sequence, only valid for this object. This mainly makes sense if the Object is used as a game character - it can then store briefer shorts, example so as to quickly reference game commands or other characters. Use nicks.add(alias, realname) to add a new one.

  • +
  • account - this holds a reference to a connected Account controlling this object (if any). Note that this is set also if the controlling account is not currently online - to test if an account is online, use the has_account property instead.

  • +
  • sessions - if account field is set and the account is online, this is a list of all active sessions (server connections) to contact them through (it may be more than one if multiple connections are allowed in settings).

  • +
  • has_account - a shorthand for checking if an online account is currently connected to this object.

  • +
  • contents - this returns a list referencing all objects ‘inside’ this object (i,e. which has this object set as their location).

  • +
  • exits - this returns all objects inside this object that are Exits, that is, has the destination property set.

  • +
  • appearance_template - this helps formatting the look of the Object when someone looks at it (see next section).l

  • +
  • cmdset - this is a handler that stores all command sets defined on the object (if any).

  • +
  • scripts - this is a handler that manages Scripts attached to the object (if any).

  • +
+

The Object also has a host of useful utility functions. See the function headers in src/objects/objects.py for their arguments and more details.

+
    +
  • msg() - this function is used to send messages from the server to an account connected to this object.

  • +
  • msg_contents() - calls msg on all objects inside this object.

  • +
  • search() - this is a convenient shorthand to search for a specific object, at a given location or globally. It’s mainly useful when defining commands (in which case the object executing the command is named caller and one can do caller.search() to find objects in the room to operate on).

  • +
  • execute_cmd() - Lets the object execute the given string as if it was given on the command line.

  • +
  • move_to - perform a full move of this object to a new location. This is the main move method and will call all relevant hooks, do all checks etc.

  • +
  • clear_exits() - will delete all Exits to and from this object.

  • +
  • clear_contents() - this will not delete anything, but rather move all contents (except Exits) to their designated Home locations.

  • +
  • delete() - deletes this object, first calling clear_exits() and clear_contents().

  • +
  • return_appearance is the main hook letting the object visually describe itself.

  • +
+

The Object Typeclass defines many more hook methods beyond at_object_creation. Evennia calls these hooks at various points. When implementing your custom objects, you will inherit from the base parent and overload these hooks with your own custom code. See evennia.objects.objects for an updated list of all the available hooks or the API for DefaultObject here.

+
+
+
+

Changing an Object’s appearance

+

When you type look <obj>, this is the sequence of events that happen:

+
    +
  1. The command checks if the caller of the command (the ‘looker’) passes the view lock of the target obj. If not, they will not find anything to look at (this is how you make objects invisible).

  2. +
  3. The look command calls caller.at_look(obj) - that is, the at_look hook on the ‘looker’ (the caller of the command) is called to perform the look on the target object. The command will echo whatever this hook returns.

  4. +
  5. caller.at_look calls and returns the outcome of obj.return_apperance(looker, **kwargs). Here looker is the caller of the command. In other words, we ask the obj to descibe itself to looker.

  6. +
  7. obj.return_appearance makes use of its .appearance_template property and calls a slew of helper-hooks to populate this template. This is how the template looks by default:

    +
         ```python
    +     appearance_template = """
    +     {header}
    +     |c{name}|n
    +     {desc}
    +     {exits}{characters}{things}
    +     {footer}
    +     """```
    +
    +
    +
  8. +
  9. Each field of the template is populated by a matching helper method (and their default returns):

    +
      +
    • name -> obj.get_display_name(looker, **kwargs) - returns obj.name.

    • +
    • desc -> obj.get_display_desc(looker, **kwargs) - returns obj.db.desc.

    • +
    • header -> obj.get_display_header(looker, **kwargs) - empty by default.

    • +
    • footer -> obj.get_display_footer(looker, **kwargs) - empty by default.

    • +
    • exits -> obj.get_display_exits(looker, **kwargs) - a list of DefaultExit-inheriting objects found inside this object (usually only present if obj is a Room).

    • +
    • characters -> obj.get_display_characters(looker, **kwargs) - a list of DefaultCharacter-inheriting entities inside this object.

    • +
    • things -> obj.get_display_things(looker, **kwargs) - a list of all other Objects inside obj.

    • +
    +
  10. +
  11. obj.format_appearance(string, looker, **kwargs) is the last step the populated template string goes through. This can be used for final adjustments, such as stripping whitespace. The return from this method is what the user will see.

  12. +
+

As each of these hooks (and the template itself) can be overridden in your child class, you can customize your look extensively. You can also have objects look different depending on who is looking at them. The extra **kwargs are not used by default, but are there to allow you to pass extra data into the system if you need it (like light conditions etc.)

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Permissions.html b/docs/latest/Components/Permissions.html new file mode 100644 index 0000000000..925c67aee0 --- /dev/null +++ b/docs/latest/Components/Permissions.html @@ -0,0 +1,312 @@ + + + + + + + + + Permissions — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Permissions

+

A permission is simply a text string stored in the handler permissions on Objects and Accounts. Think of it as a specialized sort of Tag - one specifically dedicated to access checking. They are thus often tightly coupled to Locks. Permission strings are not case-sensitive, so “Builder” is the same as “builder” etc.

+

Permissions are used as a convenient way to structure access levels and hierarchies. It is set by the perm command and checked by the PermissionHandler.check method as well as by the specially the perm() and pperm() lock functions.

+

All new accounts are given a default set of permissions defined by settings.PERMISSION_ACCOUNT_DEFAULT.

+
+

The super user

+

There are strictly speaking two types of users in Evennia, the super user and everyone else. The +superuser is the first user you create, object #1. This is the all-powerful server-owner account. +Technically the superuser not only has access to everything, it bypasses the permission checks +entirely.

+

This makes the superuser impossible to lock out, but makes it unsuitable to actually play- +test the game’s locks and restrictions with (see quell below). Usually there is no need to have +but one superuser.

+
+
+

Working with Permissions

+

In-game, you use the perm command to add and remove permissions

+
 > perm/account Tommy = Builders
+ > perm/account/del Tommy = Builders
+
+
+

Note the use of the /account switch. It means you assign the permission to the Accounts Tommy instead of any Character that also happens to be named “Tommy”. If you don’t want to use /account, you can also prefix the name with * to indicate an Account is sought:

+
> perm *Tommy = Builders
+
+
+

There can be reasons for putting permissions on Objects (especially NPCS), but for granting powers to players, you should usually put the permission on the Account - this guarantees that they are kept, regardless of which Character they are currently puppeting.

+

This is especially important to remember when assigning permissions from the hierarchy tree (see below), as an Account’s permissions will overrule that of its character. So to be sure to avoid confusion you should generally put hierarchy permissions on the Account, not on their Characters/puppets.

+

If you do want to start using the permissions on your puppet, you use quell

+
> quell 
+> unquell   
+
+
+

This drops to the permissions on the puppeted object, and then back to your Account-permissions again. Quelling is useful if you want to try something “as” someone else. It’s also useful for superusers since this makes them susceptible to locks (so they can test things).

+

In code, you add/remove Permissions via the PermissionHandler, which sits on all +typeclassed entities as the property .permissions:

+
    account.permissions.add("Builders")
+    account.permissions.add("cool_guy")
+    obj.permissions.add("Blacksmith")
+    obj.permissions.remove("Blacksmith")
+
+
+
+

The permission hierarchy

+

Selected permission strings can be organized in a permission hierarchy by editing the tuple +settings.PERMISSION_HIERARCHY. Evennia’s default permission hierarchy is as follows +(in increasing order of power):

+
 Guest            # temporary account, only used if GUEST_ENABLED=True (lowest)
+ Player           # can chat and send tells (default level)
+ Helper           # can edit help files
+ Builder          # can edit the world
+ Admin            # can administrate accounts
+ Developer        # like superuser but affected by locks (highest)
+
+
+

(Besides being case-insensitive, hierarchical permissions also understand the plural form, so you could use Developers and Developer interchangeably).

+

When checking a hierarchical permission (using one of the methods to follow), you will pass checks for your level and below. That is, if you have the “Admin” hierarchical permission, you will also pass checks asking for “Builder”, “Helper” and so on.

+

By contrast, if you check for a non-hierarchical permission, like “Blacksmith” you must have exactly that permission to pass.

+
+
+

Checking permissions

+

It’s important to note that you check for the permission of a puppeted Object (like a Character), the check will always first use the permissions of any Account connected to that Object before checking for permissions on the Object. In the case of hierarchical permissions (Admins, Builders etc), the Account permission will always be used (this stops an Account from escalating their permission by puppeting a high-level Character). If the permission looked for is not in the hierarchy, an exact match is required, first on the Account and if not found there (or if no Account is connected), then on the Object itself.

+
+
+

Checking with obj.permissions.check()

+

The simplest way to check if an entity has a permission is to check its PermissionHandler, stored as .permissions on all typeclassed entities.

+
if obj.permissions.check("Builder"):
+    # allow builder to do stuff
+
+if obj.permissions.check("Blacksmith", "Warrior"):
+    # do stuff for blacksmiths OR warriors
+
+if obj.permissions.check("Blacksmith", "Warrior", require_all=True):
+    # only for those that are both blacksmiths AND warriors
+
+
+

Using the .check method is the way to go, it will take hierarchical +permissions into account, check accounts/sessions etc.

+
+

Warning

+
Don't confuse `.permissions.check()` with `.permissions.has()`. The .has()
+method checks if a string is defined specifically on that PermissionHandler.
+It will not consider permission-hierarchy, puppeting etc. `.has` can be useful
+if you are manipulating permissions, but use `.check` for access checking.
+
+
+
+
+
+

Lock funcs

+

While the PermissionHandler offers a simple way to check perms, Lock +strings offers a mini-language for describing how something is accessed. +The perm() lock function is the main tool for using Permissions in locks.

+

Let’s say we have a red_key object. We also have red chests that we want to +unlock with this key.

+
perm red_key = unlocks_red_chests
+
+
+

This gives the red_key object the permission “unlocks_red_chests”. Next we +lock our red chests:

+
lock red chest = unlock:perm(unlocks_red_chests)
+
+
+

When trying to unlock the red chest with this key, the chest Typeclass could +then take the key and do an access check:

+
# in some typeclass file where chest is defined
+
+class TreasureChest(Object):
+
+  # ...
+
+  def open_chest(self, who, tried_key):
+
+      if not chest.access(who, tried_key, "unlock"):
+          who.msg("The key does not fit!")
+          return
+      else:
+          who.msg("The key fits! The chest opens.")
+          # ...
+
+
+
+

There are several variations to the default perm lockfunc:

+
    +
  • perm_above - requires a hierarchical permission higher than the one +provided. Example: "edit: perm_above(Player)"

  • +
  • pperm - looks only for permissions on Accounts, never at any puppeted +objects (regardless of hierarchical perm or not).

  • +
  • pperm_above - like perm_above, but for Accounts only.

  • +
+
+
+

Some examples

+

Adding permissions and checking with locks

+
    account.permissions.add("Builder")
+    account.permissions.add("cool_guy")
+    account.locks.add("enter:perm_above(Player) and perm(cool_guy)")
+    account.access(obj1, "enter") # this returns True!
+
+
+

An example of a puppet with a connected account:

+
    account.permissions.add("Player")
+    puppet.permissions.add("Builders")
+    puppet.permissions.add("cool_guy")
+    obj2.locks.add("enter:perm_above(Accounts) and perm(cool_guy)")
+
+    obj2.access(puppet, "enter") # this returns False, since puppet permission
+                                 # is lower than Account's perm, and perm takes
+                                 # precedence.
+
+
+
+
+
+

Quelling

+

The quell command can be used to enforce the perm() lockfunc to ignore +permissions on the Account and instead use the permissions on the Character +only. This can be used e.g. by staff to test out things with a lower permission +level. Return to the normal operation with unquell. Note that quelling will +use the smallest of any hierarchical permission on the Account or Character, so +one cannot escalate one’s Account permission by quelling to a high-permission +Character. Also the superuser can quell their powers this way, making them +affectable by locks.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Portal-And-Server.html b/docs/latest/Components/Portal-And-Server.html new file mode 100644 index 0000000000..c65a55e983 --- /dev/null +++ b/docs/latest/Components/Portal-And-Server.html @@ -0,0 +1,154 @@ + + + + + + + + + Portal And Server — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Portal And Server

+
Internet│  ┌──────────┐ ┌─┐           ┌─┐ ┌─────────┐
+        │  │Portal    │ │S│   ┌───┐   │S│ │Server   │
+    P   │  │          │ │e│   │AMP│   │e│ │         │
+    l ──┼──┤ Telnet   ├─┤s├───┤   ├───┤s├─┤         │
+    a   │  │ Webclient│ │s│   │   │   │s│ │ Game    │
+    y ──┼──┤ SSH      ├─┤i├───┤   ├───┤i├─┤ Database│
+    e   │  │ ...      │ │o│   │   │   │o│ │         │
+    r ──┼──┤          ├─┤n├───┤   ├───┤n├─┤         │
+    s   │  │          │ │s│   └───┘   │s│ │         │
+        │  └──────────┘ └─┘           └─┘ └─────────┘
+        │Evennia
+
+
+

The Portal and Server consitutes the two main halves of Evennia.

+

These are two separate twistd processes and can be controlled from inside the game or from the command line as described in the Running-Evennia doc.

+
    +
  • The Portal knows everything about internet protocols (telnet, websockets etc), but knows very little about the game.

  • +
  • The Server knows everything about the game. It knows that a player has connected but now how they connected.

  • +
+

The effect of this is that you can fully reload the Server and have players still connected to the game. One the server comes back up, it will re-connect to the Portal and re-sync all players as if nothing happened.

+

The Portal and Server are intended to always run on the same machine. They are glued together via an AMP (Asynchronous Messaging Protocol) connection. This allows the two programs to communicate seamlessly.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Prototypes.html b/docs/latest/Components/Prototypes.html new file mode 100644 index 0000000000..2641f7c6e1 --- /dev/null +++ b/docs/latest/Components/Prototypes.html @@ -0,0 +1,422 @@ + + + + + + + + + Spawner and Prototypes — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Spawner and Prototypes

+
> spawn goblin
+
+Spawned Goblin Grunt(#45)
+
+
+

The spawner is a system for defining and creating individual objects from a base template called a prototype. It is only designed for use with in-game Objects, not any other type of entity.

+

The normal way to create a custom object in Evennia is to make a Typeclass. If you haven’t read up on Typeclasses yet, think of them as normal Python classes that save to the database behind the scenes. Say you wanted to create a “Goblin” enemy. A common way to do this would be to first create a Mobile typeclass that holds everything common to mobiles in the game, like generic AI, combat code and various movement methods. A Goblin subclass is then made to inherit from Mobile. The Goblin class adds stuff unique to goblins, like group-based AI (because goblins are smarter in a group), the ability to panic, dig for gold etc.

+

But now it’s time to actually start to create some goblins and put them in the world. What if we wanted those goblins to not all look the same? Maybe we want grey-skinned and green-skinned goblins or some goblins that can cast spells or which wield different weapons? We could make subclasses of Goblin, like GreySkinnedGoblin and GoblinWieldingClub. But that seems a bit excessive (and a lot of Python code for every little thing). Using classes can also become impractical when wanting to combine them - what if we want a grey-skinned goblin shaman wielding a spear - setting up a web of classes inheriting each other with multiple inheritance can be tricky.

+

This is what the prototype is for. It is a Python dictionary that describes these per-instance changes to an object. The prototype also has the advantage of allowing an in-game builder to customize an object without access to the Python backend. Evennia also allows for saving and searching prototypes so other builders can find and use (and tweak) them later. Having a library of interesting prototypes is a good reasource for builders. The OLC system allows for creating, saving, loading and manipulating prototypes using a menu system.

+

The spawner takes a prototype and uses it to create (spawn) new, custom objects.

+
+

Working with Prototypes

+
+

Using the OLC

+

Enter the olc command or spawn/olc to enter the prototype wizard. This is a menu system for creating, loading, saving and manipulating prototypes. It’s intended to be used by in-game builders and will give a better understanding of prototypes in general. Use help on each node of the menu for more information. Below are further details about how prototypes work and how they are used.

+
+
+

The prototype

+

The prototype dictionary can either be created for you by the OLC (see above), be written manually in a Python module (and then referenced by the spawn command/OLC), or created on-the-fly and manually loaded into the spawner function or spawn command.

+

The dictionary defines all possible database-properties of an Object. It has a fixed set of allowed keys. When preparing to store the prototype in the database (or when using the OLC), some of these keys are mandatory. When just passing a one-time prototype-dict to the spawner the system is more lenient and will use defaults for keys not explicitly provided.

+

In dictionary form, a prototype can look something like this:

+
{
+   "prototype_key": "house"
+   "key": "Large house"
+   "typeclass": "typeclasses.rooms.house.House"
+ }
+
+
+

If you wanted to load it into the spawner in-game you could just put all on one line:

+
spawn {"prototype_key="house", "key": "Large house", ...}
+
+
+
+

Note that the prototype dict as given on the command line must be a valid Python structure - so you need to put quotes around strings etc. For security reasons, a dict inserted from-in game cannot have any other advanced Python functionality, such as executable code, lambda etc. If builders are supposed to be able to use such features, you need to offer them through [$protfuncs](Spawner-and- Prototypes#protfuncs), embedded runnable functions that you have full control to check and vet before running.

+
+
+
+

Prototype keys

+

All keys starting with prototype_ are for book keeping.

+
    +
  • prototype_key - the ‘name’ of the prototype, used for referencing the prototype +when spawning and inheritance. If defining a prototype in a module and this +not set, it will be auto-set to the name of the prototype’s variable in the module.

  • +
  • prototype_parent - If given, this should be the prototype_key of another prototype stored in the system or available in a module. This makes this prototype inherit the keys from the +parent and only override what is needed. Give a tuple (parent1, parent2, ...) for multiple left-right inheritance. If this is not given, a typeclass should usually be defined (below).

  • +
  • prototype_desc - this is optional and used when listing the prototype in in-game listings.

  • +
  • protototype_tags - this is optional and allows for tagging the prototype in order to find it +easier later.

  • +
  • prototype_locks - two lock types are supported: edit and spawn. The first lock restricts the copying and editing of the prototype when loaded through the OLC. The second determines who may use the prototype to create new objects.

  • +
+

The remaining keys determine actual aspects of the objects to spawn from this prototype:

+
    +
  • key - the main object identifier. Defaults to “Spawned Object X”, where X is a random integer.

  • +
  • typeclass - A full python-path (from your gamedir) to the typeclass you want to use. If not set, the prototype_parent should be defined, with typeclass defined somewhere in the parent chain. When creating a one-time prototype dict just for spawning, one could omit this - settings.BASE_OBJECT_TYPECLASS will be used instead.

  • +
  • location - this should be a #dbref.

  • +
  • home - a valid #dbref. Defaults to location or settings.DEFAULT_HOME if location does not exist.

  • +
  • destination - a valid #dbref. Only used by exits.

  • +
  • permissions - list of permission strings, like ["Accounts", "may_use_red_door"]

  • +
  • locks - a lock-string like "edit:all();control:perm(Builder)"

  • +
  • aliases - list of strings for use as aliases

  • +
  • tags - list Tags. These are given as tuples (tag, category, data).

  • +
  • attrs - list of Attributes. These are given as tuples (attrname, value, category, lockstring)

  • +
  • Any other keywords are interpreted as non-category Attributes and their values. This is convenient for simple Attributes - use attrs for full control of Attributes.

  • +
+
+

More on prototype inheritance

+
    +
  • A prototype can inherit by defining a prototype_parent pointing to the name (prototype_key of another prototype). If a list of prototype_keys, this will be stepped through from left to right, giving priority to the first in the list over those appearing later. That is, if your inheritance is prototype_parent = ('A', 'B,' 'C'), and all parents contain colliding keys, then the one from A will apply.

  • +
  • The prototype keys that start with prototype_* are all unique to each prototype. They are never inherited from parent to child.

  • +
  • The prototype fields 'attr': [(key, value, category, lockstring),...] and 'tags': [(key, category, data), ...] are inherited in a complementary fashion. That means that only colliding key+category matches will be replaced, not the entire list. Remember that the category None is also considered a valid category!

  • +
  • Adding an Attribute as a simple key:value will under the hood be translated into an Attribute tuple (key, value, None, '') and may replace an Attribute in the parent if it the same key and a None category.

  • +
  • All other keys (permissions, destination, aliases etc) are completely replaced by the child’s value if given. For the parent’s value to be retained, the child must not define these keys at all.

  • +
+
+
+
+

Prototype values

+

The prototype supports values of several different types.

+

It can be a hard-coded value:

+
    {"key": "An ugly goblin", ...}
+
+
+
+

It can also be a callable. This callable is called without arguments whenever the prototype is used to spawn a new object:

+
    {"key": _get_a_random_goblin_name, ...}
+
+
+
+

By use of Python lambda one can wrap the callable so as to make immediate settings in the prototype:

+
    {"key": lambda: random.choice(("Urfgar", "Rick the smelly", "Blargh the foul", ...)), ...}
+
+
+
+
+

Protfuncs

+

Finally, the value can be a prototype function (Protfunc). These look like simple function calls that you embed in strings and that has a $ in front, like

+
    {"key": "$choice(Urfgar, Rick the smelly, Blargh the foul)",
+     "attrs": {"desc": "This is a large $red(and very red) demon. "
+                       "He has $randint(2,5) skulls in a chain around his neck."}
+
+
+
+

If you want to escape a protfunc and have it appear verbatim, use $$funcname().

+
+

At spawn time, the place of the protfunc will be replaced with the result of that protfunc being called (this is always a string). A protfunc is a FuncParser function run every time the prototype is used to spawn a new object. See the FuncParse for a lot more information.

+

Here is how a protfunc is defined (same as other funcparser functions).

+
# this is a silly example, you can just color the text red with |r directly!
+def red(*args, **kwargs):
+   """
+   Usage: $red(<text>)
+   Returns the same text you entered, but red.
+   """
+   if not args or len(args) > 1:
+      raise ValueError("Must have one argument, the text to color red!")
+   return f"|r{args[0]}|n"
+
+
+
+

Note that we must make sure to validate input and raise ValueError on failure.

+
+

The parser will always include the following reserved kwargs:

+
    +
  • session - the current Session performing the spawning.

  • +
  • prototype - The Prototype-dict this function is a part of. This is intended to be used read-only. Be careful to modify a mutable structure like this from inside the function - you can cause really hard-to-find bugs this way.

  • +
  • current_key - The current key of the prototype dict under which this protfunc is executed.

  • +
+

To make this protfunc available to builders in-game, add it to a new module and add the path to that module to settings.PROT_FUNC_MODULES:

+
# in mygame/server/conf/settings.py
+
+PROT_FUNC_MODULES += ["world.myprotfuncs"]
+
+
+
+

All global callables in your added module will be considered a new protfunc. To avoid this (e.g. to have helper functions that are not protfuncs on their own), name your function something starting with _.

+

The default protfuncs available out of the box are defined in evennia/prototypes/profuncs.py. To override the ones available, just add the same-named function in your own protfunc module.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Protfunc

Description

$random()

Returns random value in range [0, 1)

$randint(start, end)

Returns random value in range [start, end]

$left_justify(<text>)

Left-justify text

$right_justify(<text>)

Right-justify text to screen width

$center_justify(<text>)

Center-justify text to screen width

$full_justify(<text>)

Spread text across screen width by adding spaces

$protkey(<name>)

Returns value of another key in this prototype (self-reference)

$add(<value1>, <value2>)

Returns value1 + value2. Can also be lists, dicts etc

$sub(<value1>, <value2>)

Returns value1 - value2

$mult(<value1>, <value2>)

Returns value1 * value2

$div(<value1>, <value2>)

Returns value2 / value1

$toint(<value>)

Returns value converted to integer (or value if not possible)

$eval(<code>)

Returns result of literal-eval of code string. Only simple python expressions.

$obj(<query>)

Returns object #dbref searched globally by key, tag or #dbref. Error if more than one found.

$objlist(<query>)

Like $obj, except always returns a list of zero, one or more results.

$dbref(dbref)

Returns argument if it is formed as a #dbref (e.g. #1234), otherwise error.

+

For developers with access to Python, using protfuncs in prototypes is generally not useful. Passing real Python functions is a lot more powerful and flexible. Their main use is to allow in-game builders to do limited coding/scripting for their prototypes without giving them direct access to raw Python.

+
+
+
+
+

Database prototypes

+

Stored as Scripts in the database. These are sometimes referred to as database- prototypes This is the only way for in-game builders to modify and add prototypes. They have the advantage of being easily modifiable and sharable between builders but you need to work with them using in-game tools.

+
+
+

Module-based prototypes

+

These prototypes are defined as dictionaries assigned to global variables in one of the modules defined in settings.PROTOTYPE_MODULES. They can only be modified from outside the game so they are are necessarily “read-only” from in-game and cannot be modified (but copies of them could be made into database-prototypes). These were the only prototypes available before Evennia 0.8. Module based prototypes can be useful in order for developers to provide read-only “starting” or “base” prototypes to build from or if they just prefer to work offline in an external code editor.

+

By default mygame/world/prototypes.py is set up for you to add your own prototypes. All global +dicts in this module will be considered by Evennia to be a prototype. You could also tell Evennia +to look for prototypes in more modules if you want:

+
# in mygame/server/conf.py
+
+PROTOTYPE_MODULES = += ["world.myownprototypes", "combat.prototypes"]
+
+
+
+

Here is an example of a prototype defined in a module:

+
```python
+# in a module Evennia looks at for prototypes,
+# (like mygame/world/prototypes.py)
+
+ORC_SHAMAN = {"key":"Orc shaman",
+	  "typeclass": "typeclasses.monsters.Orc",
+	  "weapon": "wooden staff",
+	  "health": 20}
+```
+
+
+
+

Note that in the example above, "ORC_SHAMAN" will become the prototype_key of this prototype. It’s the only case when prototype_key can be skipped in a prototype. However, if prototype_keywas given explicitly, that would take precedence. This is a legacy behavior and it’s recommended > that you always add prototype_key to be consistent.

+
+
+
+

Spawning

+

The spawner can be used from inside the game through the Builder-only @spawn command. Assuming the “goblin” typeclass is available to the system (either as a database-prototype or read from module), you can spawn a new goblin with

+
spawn goblin
+
+
+

You can also specify the prototype directly as a valid Python dictionary:

+
spawn {"prototype_key": "shaman", \
+    "key":"Orc shaman", \
+        "prototype_parent": "goblin", \
+        "weapon": "wooden staff", \
+        "health": 20}
+
+
+
+

Note: The spawn command is more lenient about the prototype dictionary than shown here. So you can for example skip the prototype_key if you are just testing a throw-away prototype. A random hash will be used to please the validation. You could also skip prototype_parent/typeclass - then the typeclass given by settings.BASE_OBJECT_TYPECLASS will be used.

+
+
+

Using evennia.prototypes.spawner()

+

In code you access the spawner mechanism directly via the call

+
    new_objects = evennia.prototypes.spawner.spawn(*prototypes)
+
+
+

All arguments are prototype dictionaries. The function will return a +matching list of created objects. Example:

+
    obj1, obj2 = evennia.prototypes.spawner.spawn({"key": "Obj1", "desc": "A test"},
+                                                  {"key": "Obj2", "desc": "Another test"})
+
+
+
+

Hint: Same as when using spawn, when spawning from a one-time prototype dict like this, you can skip otherwise required keys, like prototype_key or typeclass/prototype_parent. Defaults will be used.

+
+

Note that no location will be set automatically when using evennia.prototypes.spawner.spawn(), you have to specify location explicitly in the prototype dict. If the prototypes you supply are using prototype_parent keywords, the spawner will read prototypes from modules in settings.PROTOTYPE_MODULES as well as those saved to the database to determine the body of available parents. The spawn command takes many optional keywords, you can find its definition in the api docs

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Rooms.html b/docs/latest/Components/Rooms.html new file mode 100644 index 0000000000..b3e61b3995 --- /dev/null +++ b/docs/latest/Components/Rooms.html @@ -0,0 +1,157 @@ + + + + + + + + + Rooms — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Rooms

+

Inheritance Tree:

+
┌─────────────┐
+│DefaultObject│
+└─────▲───────┘
+      │
+┌─────┴─────┐
+│DefaultRoom│
+└─────▲─────┘
+      │       ┌────────────┐
+      │ ┌─────►ObjectParent│
+      │ │     └────────────┘
+    ┌─┴─┴┐
+    │Room│
+    └────┘
+
+
+

Rooms are in-game Objects representing the root containers of all other objects.

+

The only thing technically separating a room from any other object is that they have no location of their own and that default commands like dig creates objects of this class - so if you want to expand your rooms with more functionality, just inherit from evennia.DefaultRoom.

+

To change the default room created by dig, tunnel and other default commands, change it in settings:

+
BASE_ROOM_TYPECLASS = "typeclases.rooms.Room"
+
+
+

The empty class in mygame/typeclasses/rooms.py is a good place to start!

+

While the default Room is very simple, there are several Evennia contribs customizing and extending rooms with more functionality.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Scripts.html b/docs/latest/Components/Scripts.html new file mode 100644 index 0000000000..9bc5c8f2a6 --- /dev/null +++ b/docs/latest/Components/Scripts.html @@ -0,0 +1,494 @@ + + + + + + + + + Scripts — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Scripts

+

Script API reference

+

Scripts are the out-of-character siblings to the in-character Objects. Scripts are so flexible that the name “Script” is a bit limiting in itself - but we had to pick something to name them. Other possible names (depending on what you’d use them for) would be OOBObjects, StorageContainers or TimerObjects.

+

If you ever consider creating an Object with a None-location just to store some game data, you should really be using a Script instead.

+
    +
  • Scripts are full Typeclassed entities - they have Attributes and can be modified in the same way. But they have no in-game existence, so no location or command-execution like Objects and no connection to a particular player/session like Accounts. This means they are perfectly suitable for acting as database-storage backends for game systems: Storing the current state of the economy, who is involved in the current fight, tracking an ongoing barter and so on. They are great as persistent system handlers.

  • +
  • Scripts have an optional timer component. This means that you can set up the script to tick the at_repeat hook on the Script at a certain interval. The timer can be controlled independently of the rest of the script as needed. This component is optional and complementary to other timing functions in Evennia, like evennia.utils.delay and evennia.utils.repeat.

  • +
  • Scripts can attach to Objects and Accounts via e.g. obj.scripts.add/remove. In the script you can then access the object/account as self.obj or self.account. This can be used to dynamically extend other typeclasses but also to use the timer component to affect the parent object in various ways. For historical reasons, a Script not attached to an object is referred to as a Global Script.

  • +
+
+

Changed in version 1.0: In previous Evennia versions, stopping the Script’s timer also meant deleting the Script object. +Starting with this version, the timer can be start/stopped separately and .delete() must be called +on the Script explicitly to delete it.

+
+
+

Working with Scripts

+

There are two main commands controlling scripts in the default cmdset:

+

The addscript command is used for attaching scripts to existing objects:

+
> addscript obj = bodyfunctions.BodyFunctions
+
+
+

The scripts command is used to view all scripts and perform operations on them:

+
> scripts
+> scripts/stop bodyfunctions.BodyFunctions
+> scripts/start #244
+> scripts/pause #11
+> scripts/delete #566
+
+
+
+

Changed in version 1.0: The addscript command used to be only script which was easy to confuse with scripts.

+
+
+

Code examples

+

Here are some examples of working with Scripts in-code (more details to follow in later +sections).

+

Create a new script:

+
new_script = evennia.create_script(key="myscript", typeclass=...)
+
+
+

Create script with timer component:

+
# (note that this will call `timed_script.at_repeat` which is empty by default)
+timed_script = evennia.create_script(key="Timed script",
+                                     interval=34,  # seconds <=0 means off
+                                     start_delay=True,  # wait interval before first call
+                                     autostart=True)  # start timer (else needing .start() )
+
+# manipulate the script's timer
+timed_script.stop()
+timed_script.start()
+timed_script.pause()
+timed_script.unpause()
+
+
+

Attach script to another object:

+
myobj.scripts.add(new_script)
+myobj.scripts.add(evennia.DefaultScript)
+all_scripts_on_obj = myobj.scripts.all()
+
+
+

Search/find scripts in various ways:

+
# regular search (this is always a list, also if there is only one match)
+list_of_myscripts = evennia.search_script("myscript")
+
+# search through Evennia's GLOBAL_SCRIPTS container (based on
+# script's key only)
+from evennia import GLOBAL_SCRIPTS
+
+myscript = GLOBAL_SCRIPTS.myscript
+GLOBAL_SCRIPTS.get("Timed script").db.foo = "bar"
+
+
+

Delete the Script (this will also stop its timer):

+
new_script.delete()
+timed_script.delete()
+
+
+
+
+

Defining new Scripts

+

A Script is defined as a class and is created in the same way as other +typeclassed entities. The parent class is evennia.DefaultScript.

+
+

Simple storage script

+

In mygame/typeclasses/scripts.py is an empty Script class already set up. You +can use this as a base for your own scripts.

+
# in mygame/typeclasses/scripts.py
+
+from evennia import DefaultScript
+
+class Script(DefaultScript):
+    # stuff common for all your scripts goes here
+
+class MyScript(Script):
+    def at_script_creation(self):
+        """Called once, when script is first created"""
+        self.key = "myscript"
+        self.db.foo = "bar"
+
+
+
+

Once created, this simple Script could act as a global storage:

+
evennia.create_script('typeclasses.scripts.MyScript')
+
+# from somewhere else
+
+myscript = evennia.search_script("myscript").first()
+bar = myscript.db.foo
+myscript.db.something_else = 1000
+
+
+
+

Note that if you give keyword arguments to create_script you can override the values +you set in your at_script_creation:

+

+evennia.create_script('typeclasses.scripts.MyScript', key="another name",
+                      attributes=[("foo", "bar-alternative")])
+
+
+
+
+

See the create_script and search_script API documentation for more options on creating and finding Scripts.

+
+
+

Timed Script

+

There are several properties one can set on the Script to control its timer component.

+
# in mygame/typeclasses/scripts.py
+
+class TimerScript(Script):
+
+    def at_script_creation(self):
+        self.key = "myscript"
+        self.desc = "An example script"
+        self.interval = 60  # 1 min repeat
+
+    def at_repeat(self):
+        # do stuff every minute
+
+
+
+

This example will call at_repeat every minute. The create_script function has an autostart=True keyword +set by default - this means the script’s timer component will be started automatically. Otherwise +.start() must be called separately.

+

Supported properties are:

+
    +
  • key (str): The name of the script. This makes it easier to search for it later. If it’s a script +attached to another object one can also get all scripts off that object and get the script that way.

  • +
  • desc (str): Note - not .db.desc! This is a database field on the Script shown in script listings +to help identifying what does what.

  • +
  • interval (int): The amount of time (in seconds) between every ‘tick’ of the timer. Note that +it’s generally bad practice to use sub-second timers for anything in a text-game - the player will +not be able to appreciate the precision (and if you print it, it will just spam the screen). For +calculations you can pretty much always do them on-demand, or at a much slower interval without the player being the wiser.

  • +
  • start_delay (bool): If timer should start right away or wait interval seconds first.

  • +
  • repeats (int): If >0, the timer will only run this many times before stopping. Otherwise the +number of repeats are infinite. If set to 1, the Script mimics a delay action.

  • +
  • persistent (bool): This defaults to True and means the timer will survive a server reload/reboot. +If not, a reload will have the timer come back in a stopped state. Setting this to False will not +delete the Script object itself (use .delete() for this).

  • +
+

The timer component is controlled with methods on the Script class:

+
    +
  • .at_repeat() - this method is called every interval seconds while the timer is +active.

  • +
  • .is_valid() - this method is called by the timer just before at_repeat(). If it returns False +the timer is immediately stopped.

  • +
  • .start() - start/update the timer. If keyword arguments are given, they can be used to +change interval, start_delay etc on the fly. This calls the .at_start() hook. +This is also called after a server reload assuming the timer was not previously stopped.

  • +
  • .update() - legacy alias for .start.

  • +
  • .stop() - stops and resets the timer. This calls the .at_stop() hook.

  • +
  • .pause() - pauses the timer where it is, storing its current position. This calls +the .at_pause(manual_pause=True) hook. This is also called on a server reload/reboot, +at which time the manual_pause will be False.

  • +
  • .unpause() - unpause a previously paused script. This will call the at_start hook.

  • +
  • .time_until_next_repeat() - get the time until next time the timer fires.

  • +
  • .remaining_repeats() - get the number of repeats remaining, or None if repeats are infinite.

  • +
  • .reset_callcount() - this resets the repeat counter to start over from 0. Only useful if repeats>0.

  • +
  • .force_repeat() - this prematurely forces at_repeat to be called right away. Doing so will reset the countdown so that next call will again happen after interval seconds.

  • +
+
+
+
+

Script timers vs delay/repeat

+

If the only goal is to get a repeat/delay effect, the evennia.utils.delay and evennia.utils.repeat functions should generally be considered first. A Script is a lot ‘heavier’ to create/delete on the fly. In fact, for making a single delayed call (script.repeats==1), the utils.delay call is probably always the better choice.

+

For repeating tasks, the utils.repeat is optimized for quick repeating of a large number of objects. It uses the TickerHandler under the hood. Its subscription-based model makes it very efficient to start/stop the repeating action for an object. The side effect is however that all objects set to tick at a given interval will all do so at the same time. This may or may not look strange in-game depending on the situation. By contrast the Script uses its own ticker that will operate independently from the tickers of all other Scripts.

+

It’s also worth noting that once the script object has already been created, starting/stopping/pausing/unpausing the timer has very little overhead. The pause/unpause and update methods of the script also offers a bit more fine-control than using utils.delays/repeat.

+
+
+

Script attached to another object

+

Scripts can be attached to an Account or (more commonly) an Object. +If so, the ‘parent object’ will be available to the script as either .obj or .account.

+
    # mygame/typeclasses/scripts.py
+    # Script class is defined at the top of this module
+
+    import random
+
+    class Weather(Script):
+        """
+        A timer script that displays weather info. Meant to
+        be attached to a room.
+
+        """
+        def at_script_creation(self):
+            self.key = "weather_script"
+            self.desc = "Gives random weather messages."
+            self.interval = 60 * 5  # every 5 minutes
+
+        def at_repeat(self):
+            "called every self.interval seconds."
+            rand = random.random()
+            if rand < 0.5:
+                weather = "A faint breeze is felt."
+            elif rand < 0.7:
+                weather = "Clouds sweep across the sky."
+            else:
+                weather = "There is a light drizzle of rain."
+            # send this message to everyone inside the object this
+            # script is attached to (likely a room)
+            self.obj.msg_contents(weather)
+
+
+

If attached to a room, this Script will randomly report some weather +to everyone in the room every 5 minutes.

+
    myroom.scripts.add(scripts.Weather)
+
+
+
+

Note that typeclasses in your game dir is added to the setting TYPECLASS_PATHS. +Therefore we don’t need to give the full path (typeclasses.scripts.Weather +but only scripts.Weather above.

+
+

You can also attach the script as part of creating it:

+
    create_script('typeclasses.weather.Weather', obj=myroom)
+
+
+
+
+

Other Script methods

+

A Script has all the properties of a typeclassed object, such as db and ndb(see +Typeclasses). Setting key is useful in order to manage scripts (delete them by name +etc). These are usually set up in the Script’s typeclass, but can also be assigned on the fly as +keyword arguments to evennia.create_script.

+
    +
  • at_script_creation() - this is only called once - when the script is first created.

  • +
  • at_server_reload() - this is called whenever the server is warm-rebooted (e.g. with the reload command). It’s a good place to save non-persistent data you might want to survive a reload.

  • +
  • at_server_shutdown() - this is called when a system reset or systems shutdown is invoked.

  • +
  • at_server_start() - this is called when the server comes back (from reload/shutdown/reboot). It can be usuful for initializations and caching of non-persistent data when starting up a script’s functionality.

  • +
  • at_repeat()

  • +
  • at_start()

  • +
  • at_pause()

  • +
  • at_stop()

  • +
  • delete() - same as for other typeclassed entities, this will delete the Script. Of note is that +it will also stop the timer (if it runs), leading to the at_stop hook being called.

  • +
+

In addition, Scripts support Attributes, Tags and Locks etc like other Typeclassed entities.

+

See also the methods involved in controlling a Timed Script above.

+
+
+

Dealing with Script Errors

+

Errors inside a timed, executing script can sometimes be rather terse or point to parts of the execution mechanism that is hard to interpret. One way to make it easier to debug scripts is to import Evennia’s native logger and wrap your functions in a try/catch block. Evennia’s logger can show you where the traceback occurred in your script.

+

+from evennia.utils import logger
+
+class Weather(Script):
+
+    # [...]
+
+    def at_repeat(self):
+
+        try:
+            # [...]
+        except Exception:
+            logger.log_trace()
+
+
+
+
+
+

Using GLOBAL_SCRIPTS

+

A Script not attached to another entity is commonly referred to as a Global script since it’t available +to access from anywhere. This means they need to be searched for in order to be used.

+

Evennia supplies a convenient “container” evennia.GLOBAL_SCRIPTS to help organize your global +scripts. All you need is the Script’s key.

+
from evennia import GLOBAL_SCRIPTS
+
+# access as a property on the container, named the same as the key
+my_script = GLOBAL_SCRIPTS.my_script
+# needed if there are spaces in name or name determined on the fly
+another_script = GLOBAL_SCRIPTS.get("another script")
+# get all global scripts (this returns a Django Queryset)
+all_scripts = GLOBAL_SCRIPTS.all()
+# you can operate directly on the script
+GLOBAL_SCRIPTS.weather.db.current_weather = "Cloudy"
+
+
+
+
+

Warning

+

Note that global scripts appear as properties on GLOBAL_SCRIPTS based on their key. If you were to create two global scripts with the same key (even with different typeclasses), the GLOBAL_SCRIPTS container will only return one of them (which one depends on order in the database). Best is to organize your scripts so that this does not happen. Otherwise, use evennia.search_script to get exactly the script you want.

+
+

There are two ways to make a script appear as a property on GLOBAL_SCRIPTS:

+
    +
  1. Manually create a new global script with a key using create_script.

  2. +
  3. Define the script’s properties in the GLOBAL_SCRIPTS settings variable. This tells Evennia +that it should check if a script with that key exists and if not, create it for you. +This is very useful for scripts that must always exist and/or should be auto-created +when your server restarts. If you use this method, you must make sure all +script keys are globally unique.

  4. +
+

Here’s how to tell Evennia to manage the script in settings:

+
# in mygame/server/conf/settings.py
+
+GLOBAL_SCRIPTS = {
+    "my_script": {
+        "typeclass": "typeclasses.scripts.Weather",
+        "repeats": -1,
+        "interval": 50,
+        "desc": "Weather script"
+    },
+    "storagescript": {}
+}
+
+
+

Above we add two scripts with keys myscript and storagescriptrespectively. The following dict can be empty - the settings.BASE_SCRIPT_TYPECLASS will then be used. Under the hood, the provided dict (along with the key) will be passed into create_script automatically, so all the same keyword arguments as for create_script are supported here.

+
+

Warning

+

Before setting up Evennia to manage your script like this, make sure that your Script typeclass does not have any critical errors (test it separately). If there are, you’ll see errors in your log and your Script will temporarily fall back to being a DefaultScript type.

+
+

Moreover, a script defined this way is guaranteed to exist when you try to access it:

+
from evennia import GLOBAL_SCRIPTS
+# Delete the script
+GLOBAL_SCRIPTS.storagescript.delete()
+# running the `scripts` command now will show no storagescript
+# but below it's automatically recreated again!
+storage = GLOBAL_SCRIPTS.storagescript
+
+
+

That is, if the script is deleted, next time you get it from GLOBAL_SCRIPTS, Evennia will use the +information in settings to recreate it for you on the fly.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Sessions.html b/docs/latest/Components/Sessions.html new file mode 100644 index 0000000000..e422a14d01 --- /dev/null +++ b/docs/latest/Components/Sessions.html @@ -0,0 +1,259 @@ + + + + + + + + + Sessions — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Sessions

+
┌──────┐ │   ┌───────┐    ┌───────┐   ┌──────┐
+│Client├─┼──►│Session├───►│Account├──►│Object│
+└──────┘ │   └───────┘    └───────┘   └──────┘
+                 ^
+
+
+

An Evennia Session represents one single established connection to the server. Depending on the +Evennia session, it is possible for a person to connect multiple times, for example using different +clients in multiple windows. Each such connection is represented by a session object.

+

A session object has its own cmdset, usually the “unloggedin” cmdset. This is what is used to show the login screen and to handle commands to create a new account (or Account in evennia lingo) read initial help and to log into the game with an existing account. A session object can either be “logged in” or not. Logged in means that the user has authenticated. When this happens the session is associated with an Account object (which is what holds account-centric stuff). The account can then in turn puppet any number of objects/characters.

+

A Session is not persistent - it is not a Typeclass and has no connection to the database. The Session will go away when a user disconnects and you will lose any custom data on it if the server reloads. The .db handler on Sessions is there to present a uniform API (so you can assume .db exists even if you don’t know if you receive an Object or a Session), but this is just an alias to .ndb. So don’t store any data on Sessions that you can’t afford to lose in a reload.

+
+

Working with Sessions

+
+

Properties on Sessions

+

Here are some important properties available on (Server-)Sessions

+
    +
  • sessid - The unique session-id. This is an integer starting from 1.

  • +
  • address - The connected client’s address. Different protocols give different information here.

  • +
  • logged_in - True if the user authenticated to this session.

  • +
  • account - The Account this Session is attached to. If not logged in yet, this is None.

  • +
  • puppet - The Character/Object currently puppeted by this Account/Session combo. If not logged in or in OOC mode, this is None.

  • +
  • ndb - The Non-persistent Attribute handler.

  • +
  • db - As noted above, Sessions don’t have regular Attributes. This is an alias to ndb.

  • +
  • cmdset - The Session’s CmdSetHandler

  • +
+

Session statistics are mainly used internally by Evennia.

+
    +
  • conn_time - How long this Session has been connected

  • +
  • cmd_last - Last active time stamp. This will be reset by sending idle keepalives.

  • +
  • cmd_last_visible - last active time stamp. This ignores idle keepalives and representes the +last time this session was truly visibly active.

  • +
  • cmd_total - Total number of Commands passed through this Session.

  • +
+
+
+

Returning data to the session

+

When you use msg() to return data to a user, the object on which you call the msg() matters. The +MULTISESSION_MODE also matters, especially if greater than 1.

+

For example, if you use account.msg("hello") there is no way for evennia to know which session it +should send the greeting to. In this case it will send it to all sessions. If you want a specific +session you need to supply its session to the msg call (account.msg("hello", session=mysession)).

+

On the other hand, if you call the msg() message on a puppeted object, like +character.msg("hello"), the character already knows the session that controls it - it will +cleverly auto-add this for you (you can specify a different session if you specifically want to send +stuff to another session).

+

Finally, there is a wrapper for msg() on all command classes: command.msg(). This will +transparently detect which session was triggering the command (if any) and redirects to that session +(this is most often what you want). If you are having trouble redirecting to a given session, +command.msg() is often the safest bet.

+

You can get the session in two main ways:

+
    +
  • Accounts and Objects (including Characters) have a sessions property. +This is a handler that tracks all Sessions attached to or puppeting them. Use e.g. +accounts.sessions.get() to get a list of Sessions attached to that entity.

  • +
  • A Command instance has a session property that always points back to the Session that triggered +it (it’s always a single one). It will be None if no session is involved, like when a mob or +script triggers the Command.

  • +
+
+
+

Customizing the Session object

+

When would one want to customize the Session object? Consider for example a character creation system: You might decide to keep this on the out-of-character level. This would mean that you create the character at the end of some sort of menu choice. The actual char-create cmdset would then normally be put on the account. This works fine as long as you are MULTISESSION_MODE below 2. For higher modes, replacing the Account cmdset will affect all your connected sessions, also those not involved in character creation. In this case you want to instead put the char-create cmdset on the Session level - then all other sessions will keep working normally despite you creating a new character in one of them.

+

By default, the session object gets the commands.default_cmdsets.UnloggedinCmdSet when the user first connects. Once the session is authenticated it has no default sets. To add a “logged-in” cmdset to the Session, give the path to the cmdset class with settings.CMDSET_SESSION. This set +will then henceforth always be present as soon as the account logs in.

+

To customize further you can completely override the Session with your own subclass. To replace the default Session class, change settings.SERVER_SESSION_CLASS to point to your custom class. This is a dangerous practice and errors can easily make your game unplayable. Make sure to take heed of the original and make your changes carefully.

+
+
+
+

Portal and Server Sessions

+

Note: This is considered an advanced topic. You don’t need to know this on a first read-through.

+

Evennia is split into two parts, the Portal and the Server. Each side tracks its own Sessions, syncing them to each other.

+

The “Session” we normally refer to is actually the ServerSession. Its counter-part on the Portal +side is the PortalSession. Whereas the server sessions deal with game states, the portal session +deals with details of the connection-protocol itself. The two are also acting as backups of critical +data such as when the server reboots.

+

New Account connections are listened for and handled by the Portal using the [protocols](Portal-And- Server) it understands (such as telnet, ssh, webclient etc). When a new connection is established, a PortalSession is created on the Portal side. This session object looks different depending on which protocol is used to connect, but all still have a minimum set of attributes that are generic to all sessions.

+

These common properties are piped from the Portal, through the AMP connection, to the Server, which is now informed a new connection has been established. On the Server side, a ServerSession object is created to represent this. There is only one type of ServerSession; It looks the same regardless of how the Account connects.

+

From now on, there is a one-to-one match between the ServerSession on one side of the AMP +connection and the PortalSession on the other. Data arriving to the Portal Session is sent on to +its mirror Server session and vice versa.

+

During certain situations, the portal- and server-side sessions are +“synced” with each other:

+
    +
  • The Player closes their client, killing the Portal Session. The Portal syncs with the Server to +make sure the corresponding Server Session is also deleted.

  • +
  • The Player quits from inside the game, killing the Server Session. The Server then syncs with the +Portal to make sure to close the Portal connection cleanly.

  • +
  • The Server is rebooted/reset/shutdown - The Server Sessions are copied over (“saved”) to the +Portal side. When the Server comes back up, this data is returned by the Portal so the two are again +in sync. This way an Account’s login status and other connection-critical things can survive a +server reboot (assuming the Portal is not stopped at the same time, obviously).

  • +
+
+

Sessionhandlers

+

Both the Portal and Server each have a sessionhandler to manage the connections. These handlers +are global entities contain all methods for relaying data across the AMP bridge. All types of +Sessions hold a reference to their respective Sessionhandler (the property is called +sessionhandler) so they can relay data. See protocols for more info on building new protocols.

+

To get all Sessions in the game (i.e. all currently connected clients), you access the server-side Session handler, which you get by

+
from evennia.server.sessionhandler import SESSION_HANDLER
+
+
+
+

Note: The SESSION_HANDLER singleton has an older alias SESSIONS that is commonly seen in various places as well.

+
+

See the sessionhandler.py module for details on the capabilities of the ServerSessionHandler.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Signals.html b/docs/latest/Components/Signals.html new file mode 100644 index 0000000000..63b0d34e39 --- /dev/null +++ b/docs/latest/Components/Signals.html @@ -0,0 +1,254 @@ + + + + + + + + + Signals — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Signals

+

This is feature available from evennia 0.9 and onward.

+

There are multiple ways for you to plug in your own functionality into Evennia. +The most common way to do so is through hooks - methods on typeclasses that +gets called at particular events. Hooks are great when you want a game entity +to behave a certain way when something happens to it. Signals complements +hooks for cases when you want to easily attach new functionality without +overriding things on the typeclass.

+

When certain events happen in Evennia, a Signal is fired. The idea is that +you can “attach” any number of event-handlers to these signals. You can attach +any number of handlers and they’ll all fire whenever any entity triggers the +signal.

+

Evennia uses the Django Signal system.

+
+

Working with Signals

+

First you create your handler

+

+def myhandler(sender, **kwargs):
+  # do stuff
+
+
+
+

The **kwargs is mandatory. Then you attach it to the signal of your choice:

+
from evennia.server import signals
+
+signals.SIGNAL_OBJECT_POST_CREATE.connect(myhandler)
+
+
+
+

This particular signal fires after (post) an Account has connected to the game. +When that happens, myhandler will fire with the sender being the Account that just connected.

+

If you want to respond only to the effects of a specific entity you can do so +like this:

+
from evennia import search_account
+from evennia import signals
+
+account = search_account("foo")[0]
+signals.SIGNAL_ACCOUNT_POST_CONNECT.connect(myhandler, account)
+
+
+
+

Available signals

+

All signals (including some django-specific defaults) are available in the module +evennia.server.signals +(with a shortcut evennia.signals). Signals are named by the sender type. So SIGNAL_ACCOUNT_* +returns +Account instances as senders, SIGNAL_OBJECT_* returns Objects etc. Extra keywords (kwargs) +should +be extracted from the **kwargs dict in the signal handler.

+
    +
  • SIGNAL_ACCOUNT_POST_CREATE - this is triggered at the very end of Account.create(). Note that +calling evennia.create.create_account (which is called internally by Account.create) will +not +trigger this signal. This is because using Account.create() is expected to be the most commonly +used way for users to themselves create accounts during login. It passes and extra kwarg ip with +the client IP of the connecting account.

  • +
  • SIGNAL_ACCOUNT_POST_LOGIN - this will always fire when the account has authenticated. Sends +extra kwarg session with the new Session object involved.

  • +
  • SIGNAL_ACCCOUNT_POST_FIRST_LOGIN - this fires just before SIGNAL_ACCOUNT_POST_LOGIN but only +if +this is the first connection done (that is, if there are no previous sessions connected). Also +passes the session along as a kwarg.

  • +
  • SIGNAL_ACCOUNT_POST_LOGIN_FAIL - sent when someone tried to log into an account by failed. +Passes +the session as an extra kwarg.

  • +
  • SIGNAL_ACCOUNT_POST_LOGOUT - always fires when an account logs off, no matter if other sessions +remain or not. Passes the disconnecting session along as a kwarg.

  • +
  • SIGNAL_ACCOUNT_POST_LAST_LOGOUT - fires before SIGNAL_ACCOUNT_POST_LOGOUT, but only if this is +the last Session to disconnect for that account. Passes the session as a kwarg.

  • +
  • SIGNAL_OBJECT_POST_PUPPET - fires when an account puppets this object. Extra kwargs session +and account represent the puppeting entities. +SIGNAL_OBJECT_POST_UNPUPPET - fires when the sending object is unpuppeted. Extra kwargs are +session and account.

  • +
  • SIGNAL_ACCOUNT_POST_RENAME - triggered by the setting of Account.username. Passes extra +kwargs old_name, new_name.

  • +
  • SIGNAL_TYPED_OBJECT_POST_RENAME - triggered when any Typeclassed entity’s key is changed. +Extra +kwargs passed are old_key and new_key.

  • +
  • SIGNAL_SCRIPT_POST_CREATE - fires when a script is first created, after any hooks.

  • +
  • SIGNAL_CHANNEL_POST_CREATE - fires when a Channel is first created, after any hooks.

  • +
  • SIGNAL_HELPENTRY_POST_CREATE - fires when a help entry is first created.

  • +
  • SIGNAL_EXIT_TRAVERSED - fires when an exit is traversed, just after at_traverse hook. The sender is the exit itself, traverser= keyword hold the one traversing the exit.

  • +
+

The evennia.signals module also gives you conveneient access to the default Django signals (these +use a +different naming convention).

+
    +
  • pre_save - fired when any database entitiy’s .save method fires, before any saving has +happened.

  • +
  • post_save - fires after saving a database entity.

  • +
  • pre_delete - fires just before a database entity is deleted.

  • +
  • post_delete - fires after a database entity was deleted.

  • +
  • pre_init - fires before a typeclass’ __init__ method (which in turn +happens before the at_init hook fires).

  • +
  • post_init - triggers at the end of __init__ (still before the at_init hook).

  • +
+

These are highly specialized Django signals that are unlikely to be useful to most users. But +they are included here for completeness.

+
    +
  • m2m_changed - fires after a Many-to-Many field (like db_attributes) changes.

  • +
  • pre_migrate - fires before database migration starts with evennia migrate.

  • +
  • post_migrate - fires after database migration finished.

  • +
  • request_started - sent when HTTP request begins.

  • +
  • request_finished - sent when HTTP request ends.

  • +
  • settings_changed - sent when changing settings due to @override_settings +decorator (only relevant for unit testing)

  • +
  • template_rendered - sent when test system renders http template (only useful for unit tests).

  • +
  • connection_creation - sent when making initial connection to database.

  • +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Tags.html b/docs/latest/Components/Tags.html new file mode 100644 index 0000000000..d833247f14 --- /dev/null +++ b/docs/latest/Components/Tags.html @@ -0,0 +1,348 @@ + + + + + + + + + Tags — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Tags

+
+
In game
+
> tag obj = tagname
+
+
+
+
+
In code, using .tags (TagHandler)
+
obj.tags.add("mytag", category="foo")
+obj.tags.get("mytag", category="foo")
+
+
+
+
+
In code, using TagProperty or TagCategoryProperty
+
from evennia import DefaultObject
+from evennia import TagProperty, TagCategoryProperty
+
+class Sword(DefaultObject): 
+    # name of property is the tagkey, category as argument
+    can_be_wielded = TagProperty(category='combat')
+    has_sharp_edge = TagProperty(category='combat')
+
+    # name of property is the category, tag-keys are arguments
+    damage_type = TagCategoryProperty("piercing", "slashing")
+    crafting_element = TagCategoryProperty("blade", "hilt", "pommel") 
+        
+
+
+
+

In-game, tags are controlled tag command:

+
 > tag Chair = furniture
+ > tag Chair = furniture
+ > tag Table = furniture
+ 
+ > tag/search furniture 
+ Chair, Sofa, Table
+
+
+

Tags are short text lables one can ‘hang’ on objects in order to organize, group and quickly find out their properties. An Evennia entity can be tagged by any number of tags. They are more efficient than Attributes since on the database-side, Tags are shared between all objects with that particular tag. A tag does not carry a value in itself; it either sits on the entity

+

You manage Tags using the TagHandler (.tags) on typeclassed entities. You can also assign Tags on the class level through the TagProperty (one tag, one category per line) or the TagCategoryProperty (one category, multiple tags per line). Both of these use the TagHandler under the hood, they are just convenient ways to add tags already when you define your class.

+

Above, the tags inform us that the Sword is both sharp and can be wielded. If that’s all they do, they could just be a normal Python flag. When tags become important is if there are a lot of objects with different combinations of tags. Maybe you have a magical spell that dulls all sharp-edged objects in the castle - whether sword, dagger, spear or kitchen knife! You can then just grab all objects with the has_sharp_edge tag. +Another example would be a weather script affecting all rooms tagged as outdoors or finding all characters tagged with belongs_to_fighter_guild.

+

In Evennia, Tags are technically also used to implement Aliases (alternative names for objects) and Permissions (simple strings for Locks to check for).

+
+

Working with Tags

+
+

Searching for tags

+

The common way to use tags (once they have been set) is find all objects tagged with a particular tag combination:

+
objs = evennia.search_tag(key=("foo", "bar"), category='mycategory')
+
+
+

As shown above, you can also have tags without a category (category of None).

+
     import evennia
+     
+     # all methods return Querysets
+
+     # search for objects 
+     objs = evennia.search_tag("furniture")
+     objs2 = evennia.search_tag("furniture", category="luxurious")
+     dungeon = evennia.search_tag("dungeon#01")
+     forest_rooms = evennia.search_tag(category="forest") 
+     forest_meadows = evennia.search_tag("meadow", category="forest")
+     magic_meadows = evennia.search_tag("meadow", category="magical")
+
+     # search for scripts
+     weather = evennia.search_tag_script("weather")
+     climates = evennia.search_tag_script(category="climate")
+
+     # search for accounts
+     accounts = evennia.search_tag_account("guestaccount")          
+
+
+
+

Note that searching for just “furniture” will only return the objects tagged with the “furniture” tag that has a category of None. We must explicitly give the category to get the “luxurious” furniture.

+
+

Using any of the search_tag variants will all return Django Querysets, including if you only have one match. You can treat querysets as lists and iterate over them, or continue building search queries with them.

+

Remember when searching that not setting a category means setting it to None - this does not mean that category is undefined, rather None is considered the default, unnamed category.

+
import evennia 
+
+myobj1.tags.add("foo")  # implies category=None
+myobj2.tags.add("foo", category="bar")
+
+# this returns a queryset with *only* myobj1 
+objs = evennia.search_tag("foo")
+
+# these return a queryset with *only* myobj2
+objs = evennia.search_tag("foo", category="bar")
+# or
+objs = evennia.search_tag(category="bar")
+
+
+

There is also an in-game command that deals with assigning and using (Object-) tags:

+
 tag/search furniture
+
+
+
+
+

TagHandler

+

This is the main way to work with tags when you have the entry already. This handler sits on all typeclassed entities as .tags and you use .tags.add(), .tags.remove() and .tags.has() to manage Tags on the object. See the api docs for more useful methods.

+

The TagHandler can be found on any of the base typeclassed objects, namely Objects, Accounts, Scripts and Channels (as well as their children). Here are some examples of use:

+
     mychair.tags.add("furniture")
+     mychair.tags.add("furniture", category="luxurious")
+     myroom.tags.add("dungeon#01")
+     myscript.tags.add("weather", category="climate")
+     myaccount.tags.add("guestaccount")
+
+     mychair.tags.all()  # returns a list of Tags
+     mychair.tags.remove("furniture") 
+     mychair.tags.clear()    
+
+
+

Adding a new tag will either create a new Tag or re-use an already existing one. Note that there are two “furniture” tags, one with a None category, and one with the “luxurious” category.

+

When using remove, the Tag is not deleted but are just disconnected from the tagged object. This makes for very quick operations. The clear method removes (disconnects) all Tags from the object.

+
+
+

TagProperty

+

This is used as a property when you create a new class:

+
from evennia import TagProperty 
+from typeclasses import Object 
+
+class MyClass(Object):
+    mytag = TagProperty(tagcategory)
+
+
+

This will create a Tag named mytag and category tagcategory in the database. You’ll be able to find it by obj.mytag but more useful you can find it with the normal Tag searching methods in the database.

+

Note that if you were to delete this tag with obj.tags.remove("mytag", "tagcategory"), that tag will be re-added to the object next time this property is accessed!

+
+
+

TagCategoryProperty

+

This is the inverse of TagProperty:

+
from evennia import TagCategoryProperty 
+from typeclasses import Object 
+
+class MyClass(Object): 
+    tagcategory = TagCategroyProperty(tagkey1, tagkey2)
+
+
+

The above example means you’ll have two tags (tagkey1 and tagkey2), each with the tagcategory category, assigned to this object.

+

Note that similarly to how it works for TagProperty, if you were to delete these tags from the object with the TagHandler (obj.tags.remove("tagkey1", "tagcategory"), then these tags will be re-added automatically next time the property is accessed.

+

The reverse is however not true: If you were to add a new tag of the same category to the object, via the TagHandler, then this property will include that in the list of returned tags.

+

If you want to ‘re-sync’ the tags in the property with that in the database, you can use the del operation on it - next time the property is accessed, it will then only show the default keys you specify in it. Here’s how it works:

+
>>> obj.tagcategory 
+["tagkey1", "tagkey2"]
+
+# remove one of the default tags outside the property
+>>> obj.tags.remove("tagkey1", "tagcategory")
+>>> obj.tagcategory 
+["tagkey1", "tagkey2"]   # missing tag is auto-created! 
+
+# add a new tag from outside the property 
+>>> obj.tags.add("tagkey3", "tagcategory")
+>>> obj.tagcategory 
+["tagkey1", "tagkey2", "tagkey3"]  # includes the new tag! 
+
+# sync property with datbase 
+>>> del obj.tagcategory 
+>>> obj.tagcategory 
+["tagkey1", "tagkey2"]   # property/database now in sync 
+
+
+
+
+
+

Properties of Tags (and Aliases and Permissions)

+

Tags are unique. This means that there is only ever one Tag object with a given key and category.

+
+

Important

+

Not specifying a category (default) gives the tag a category of None, which is also considered a unique key + category combination. You cannot use TagCategoryProperty to set Tags with None categories, since the property name may not be None. Use the TagHandler (or TagProperty) for this.

+
+

When Tags are assigned to game entities, these entities are actually sharing the same Tag. This means that Tags are not suitable for storing information about a single object - use an +Attribute for this instead. Tags are a lot more limited than Attributes but this also +makes them very quick to lookup in the database - this is the whole point.

+

Tags have the following properties, stored in the database:

+
    +
  • key - the name of the Tag. This is the main property to search for when looking up a Tag.

  • +
  • category - this category allows for retrieving only specific subsets of tags used for different purposes. You could have one category of tags for “zones”, another for “outdoor locations”, for example. If not given, the category will be None, which is also considered a separate, default, category.

  • +
  • data - this is an optional text field with information about the tag. Remember that Tags are shared between entities, so this field cannot hold any object-specific information. Usually it would be used to hold info about the group of entities the Tag is tagging - possibly used for contextual help like a tool tip. It is not used by default.

  • +
+

There are also two special properties. These should usually not need to be changed or set, it is used internally by Evennia to implement various other uses it makes of the Tag object:

+
    +
  • model - this holds a natural-key description of the model object that this tag deals with, on the form application.modelclass, for example objects.objectdb. It used by the TagHandler of each entity type for correctly storing the data behind the scenes.

  • +
  • tagtype - this is a “top-level category” of sorts for the inbuilt children of Tags, namely Aliases and Permissions. The Taghandlers using this special field are especially intended to free up the category property for any use you desire.

  • +
+
+
+

Aliases and Permissions

+

Aliases and Permissions are implemented using normal TagHandlers that simply save Tags with a +different tagtype. These handlers are named aliases and permissions on all Objects. They are +used in the same way as Tags above:

+
    boy.aliases.add("rascal")
+    boy.permissions.add("Builders")
+    boy.permissions.remove("Builders")
+
+    all_aliases = boy.aliases.all()
+
+
+

and so on. Similarly to how tag works in-game, there is also the perm command for assigning permissions and @alias command for aliases.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/TickerHandler.html b/docs/latest/Components/TickerHandler.html new file mode 100644 index 0000000000..b4b941d946 --- /dev/null +++ b/docs/latest/Components/TickerHandler.html @@ -0,0 +1,218 @@ + + + + + + + + + TickerHandler — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

TickerHandler

+

One way to implement a dynamic MUD is by using “tickers”, also known as “heartbeats”. A ticker is a timer that fires (“ticks”) at a given interval. The tick triggers updates in various game systems.

+

Tickers are very common or even unavoidable in other mud code bases. Certain code bases are even hard-coded to rely on the concept of the global ‘tick’. Evennia has no such notion - the decision to use tickers is very much up to the need of your game and which requirements you have. The “ticker recipe” is just one way of cranking the wheels.

+

The most fine-grained way to manage the flow of time is to use utils.delay (using the TaskHandler). Another is to use the time-repeat capability of Scripts. These tools operate on individual objects.

+

Many types of operations (weather being the classic example) are however done on multiple objects in the same way at regular intervals, and for this, it’s inefficient to set up separate delays/scripts for every such object.

+

The way to do this is to use a ticker with a “subscription model” - let objects sign up to be +triggered at the same interval, unsubscribing when the updating is no longer desired. This means that the time-keeping mechanism is only set up once for all objects, making subscribing/unsubscribing faster.

+

Evennia offers an optimized implementation of the subscription model - the TickerHandler. This is a singleton global handler reachable from evennia.TICKER_HANDLER. You can assign any callable (a function or, more commonly, a method on a database object) to this handler. The TickerHandler will then call this callable at an interval you specify, and with the arguments you supply when adding it. This continues until the callable un-subscribes from the ticker. The handler survives a reboot and is highly optimized in resource usage.

+
+

Usage

+

Here is an example of importing TICKER_HANDLER and using it:

+
    # we assume that obj has a hook "at_tick" defined on itself
+    from evennia import TICKER_HANDLER as tickerhandler    
+
+    tickerhandler.add(20, obj.at_tick)
+
+
+

That’s it - from now on, obj.at_tick() will be called every 20 seconds.

+
+

Important

+

Everything you supply to TickerHandler.add will need to be pickled at some point to be saved into the database - also if you use persistent=False. Most of the time the handler will correctly store things like database objects, but the same restrictions as for Attributes apply to what the TickerHandler may store.

+
+

You can also import a function and tick that:

+
    from evennia import TICKER_HANDLER as tickerhandler
+    from mymodule import myfunc
+
+    tickerhandler.add(30, myfunc)
+
+
+

Removing (stopping) the ticker works as expected:

+
    tickerhandler.remove(20, obj.at_tick)
+    tickerhandler.remove(30, myfunc) 
+
+
+

Note that you have to also supply interval to identify which subscription to remove. This is because the TickerHandler maintains a pool of tickers and a given callable can subscribe to be ticked at any number of different intervals.

+

The full definition of the tickerhandler.add method is

+
    tickerhandler.add(interval, callback, 
+                      idstring="", persistent=True, *args, **kwargs)
+
+
+

Here *args and **kwargs will be passed to callback every interval seconds. If persistent +is False, this subscription will be wiped by a server shutdown (it will still survive a normal reload).

+

Tickers are identified and stored by making a key of the callable itself, the ticker-interval, the persistent flag and the idstring (the latter being an empty string when not given explicitly).

+

Since the arguments are not included in the ticker’s identification, the idstring must be used to have a specific callback triggered multiple times on the same interval but with different arguments:

+
    tickerhandler.add(10, obj.update, "ticker1", True, 1, 2, 3)
+    tickerhandler.add(10, obj.update, "ticker2", True, 4, 5)
+
+
+
+

Note that, when we want to send arguments to our callback within a ticker handler, we need to specify idstring and persistent before, unless we call our arguments as keywords, which would often be more readable:

+
+
    tickerhandler.add(10, obj.update, caller=self, value=118)
+
+
+

If you add a ticker with exactly the same combination of callback, interval and idstring, it will +overload the existing ticker. This identification is also crucial for later removing (stopping) the subscription:

+
    tickerhandler.remove(10, obj.update, idstring="ticker1")
+    tickerhandler.remove(10, obj.update, idstring="ticker2")
+
+
+

The callable can be on any form as long as it accepts the arguments you give to send to it in TickerHandler.add.

+

When testing, you can stop all tickers in the entire game with tickerhandler.clear(). You can also view the currently subscribed objects with tickerhandler.all().

+

See the Weather Tutorial for an example of using the TickerHandler.

+
+

When not to use TickerHandler

+

Using the TickerHandler may sound very useful but it is important to consider when not to use it. Even if you are used to habitually relying on tickers for everything in other code bases, stop and think about what you really need it for. This is the main point:

+
+

You should never use a ticker to catch changes.

+
+

Think about it - you might have to run the ticker every second to react to the change fast enough. Most likely nothing will have changed at a given moment. So you are doing pointless calls (since skipping the call gives the same result as doing it). Making sure nothing’s changed might even be computationally expensive depending on the complexity of your system. Not to mention that you might need to run the check on every object in the database. Every second. Just to maintain status quo …

+

Rather than checking over and over on the off-chance that something changed, consider a more proactive approach. Could you implement your rarely changing system to itself report when its status changes? It’s almost always much cheaper/efficient if you can do things “on demand”. Evennia itself uses hook methods for this very reason.

+

So, if you consider a ticker that will fire very often but which you expect to have no effect 99% of the time, consider handling things things some other way. A self-reporting on-demand solution is usually cheaper also for fast-updating properties. Also remember that some things may not need to be updated until someone actually is examining or using them - any interim changes happening up to that moment are pointless waste of computing time.

+

The main reason for needing a ticker is when you want things to happen to multiple objects at the same time without input from something else.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Typeclasses.html b/docs/latest/Components/Typeclasses.html new file mode 100644 index 0000000000..d7c5354cb9 --- /dev/null +++ b/docs/latest/Components/Typeclasses.html @@ -0,0 +1,428 @@ + + + + + + + + + Typeclasses — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Typeclasses

+

Typeclasses form the core of Evennia’s data storage. It allows Evennia to represent any number of different game entities as Python classes, without having to modify the database schema for every new type.

+

In Evennia the most important game entities, Accounts, Objects, Scripts and Channels are all Python classes inheriting, at varying distance, from evennia.typeclasses.models.TypedObject. In the documentation we refer to these objects as being “typeclassed” or even “being a typeclass”.

+

This is how the inheritance looks for the typeclasses in Evennia:

+
                                  ┌───────────┐
+                                  │TypedObject│
+                                  └─────▲─────┘
+               ┌───────────────┬────────┴──────┬────────────────┐
+          ┌────┴────┐     ┌────┴───┐      ┌────┴────┐      ┌────┴───┐
+1:        │AccountDB│     │ScriptDB│      │ChannelDB│      │ObjectDB│
+          └────▲────┘     └────▲───┘      └────▲────┘      └────▲───┘
+       ┌───────┴──────┐ ┌──────┴──────┐ ┌──────┴───────┐ ┌──────┴──────┐
+2:     │DefaultAccount│ │DefaultScript│ │DefaultChannel│ │DefaultObject│
+       └───────▲──────┘ └──────▲──────┘ └──────▲───────┘ └──────▲──────┘
+               │               │               │                │  Evennia
+       ────────┼───────────────┼───────────────┼────────────────┼─────────
+               │               │               │                │  Gamedir
+           ┌───┴───┐       ┌───┴──┐        ┌───┴───┐   ┌──────┐ │
+3:         │Account│       │Script│        │Channel│   │Object├─┤
+           └───────┘       └──────┘        └───────┘   └──────┘ │
+                                                    ┌─────────┐ │
+                                                    │Character├─┤
+                                                    └─────────┘ │
+                                                         ┌────┐ │
+                                                         │Room├─┤
+                                                         └────┘ │
+                                                         ┌────┐ │
+                                                         │Exit├─┘
+                                                         └────┘
+
+
+
    +
  • Level 1 above is the “database model” level. This describes the database tables and fields (this is technically a Django model).

  • +
  • Level 2 is where we find Evennia’s default implementations of the various game entities, on top of the database. These classes define all the hook methods that Evennia calls in various situations. DefaultObject is a little special since it’s the parent for DefaultCharacter, DefaultRoom and DefaultExit. They are all grouped under level 2 because they all represents defaults to build from.

  • +
  • Level 3, finally, holds empty template classes created in your game directory. This is the level you are meant to modify and tweak as you please, overloading the defaults as befits your game. The templates inherit directly from their defaults, so Object inherits from DefaultObject and Room inherits from DefaultRoom.

  • +
+
+

This diagram doesn’t include the ObjectParent mixin for Object, Character, Room and Exit. This establishes a common parent for those classes, for shared properties. See Objects for more details.

+
+

The typeclass/list command will provide a list of all typeclasses known to Evennia. This can be useful for getting a feel for what is available. Note however that if you add a new module with a class in it but do not import that module from anywhere, the typeclass/list will not find it. To make it known to Evennia you must import that module from somewhere.

+
+

Difference between typeclasses and classes

+

All Evennia classes inheriting from class in the table above share one important feature and two +important limitations. This is why we don’t simply call them “classes” but “typeclasses”.

+
    +
  1. A typeclass can save itself to the database. This means that some properties (actually not that many) on the class actually represents database fields and can only hold very specific data types.

  2. +
  3. Due to its connection to the database, the typeclass’ name must be unique across the entire server namespace. That is, there must never be two same-named classes defined anywhere. So the below code would give an error (since DefaultObject is now globally found both in this module and in the default library):

    +
    from evennia import DefaultObject as BaseObject
    +class DefaultObject(BaseObject):
    +     pass
    +
    +
    +
  4. +
  5. A typeclass’ __init__ method should normally not be overloaded. This has mostly to do with the fact that the __init__ method is not called in a predictable way. Instead Evennia suggest you use the at_*_creation hooks (like at_object_creation for Objects) for setting things the very first time the typeclass is saved to the database or the at_init hook which is called every time the object is cached to memory. If you know what you are doing and want to use __init__, it must both accept arbitrary keyword arguments and use super to call its parent:

    +
    def __init__(self, **kwargs):
    +    # my content
    +    super().__init__(**kwargs)
    +    # my content
    +
    +
    +
  6. +
+

Apart from this, a typeclass works like any normal Python class and you can +treat it as such.

+
+
+

Working with typeclasses

+
+

Creating a new typeclass

+

It’s easy to work with Typeclasses. Either you use an existing typeclass or you create a new Python class inheriting from an existing typeclass. Here is an example of creating a new type of Object:

+
    from evennia import DefaultObject
+
+    class Furniture(DefaultObject):
+        # this defines what 'furniture' is, like
+        # storing who sits on it or something.
+        pass
+
+
+
+

You can now create a new Furniture object in two ways. First (and usually not the most +convenient) way is to create an instance of the class and then save it manually to the database:

+
chair = Furniture(db_key="Chair")
+chair.save()
+
+
+
+

To use this you must give the database field names as keywords to the call. Which are available +depends on the entity you are creating, but all start with db_* in Evennia. This is a method you +may be familiar with if you know Django from before.

+

It is recommended that you instead use the create_* functions to create typeclassed entities:

+
from evennia import create_object
+
+chair = create_object(Furniture, key="Chair")
+# or (if your typeclass is in a module furniture.py)
+chair = create_object("furniture.Furniture", key="Chair")
+
+
+

The create_object (create_account, create_script etc) takes the typeclass as its first +argument; this can both be the actual class or the python path to the typeclass as found under your +game directory. So if your Furniture typeclass sits in mygame/typeclasses/furniture.py, you +could point to it as typeclasses.furniture.Furniture. Since Evennia will itself look in +mygame/typeclasses, you can shorten this even further to just furniture.Furniture. The create- +functions take a lot of extra keywords allowing you to set things like Attributes and +Tags all in one go. These keywords don’t use the db_* prefix. This will also automatically +save the new instance to the database, so you don’t need to call save() explicitly.

+

An example of a database field is db_key. This stores the “name” of the entity you are modifying +and can thus only hold a string. This is one way of making sure to update the db_key:

+
chair.db_key = "Table"
+chair.save()
+
+print(chair.db_key)
+<<< Table
+
+
+

That is, we change the chair object to have the db_key “Table”, then save this to the database. +However, you almost never do things this way; Evennia defines property wrappers for all the database +fields. These are named the same as the field, but without the db_ part:

+
chair.key = "Table"
+
+print(chair.key)
+<<< Table
+
+
+
+

The key wrapper is not only shorter to write, it will make sure to save the field for you, and +does so more efficiently by levering sql update mechanics under the hood. So whereas it is good to +be aware that the field is named db_key you should use key as much as you can.

+

Each typeclass entity has some unique fields relevant to that type. But all also share the +following fields (the wrapper name without db_ is given):

+
    +
  • key (str): The main identifier for the entity, like “Rose”, “myscript” or “Paul”. name is an +alias.

  • +
  • date_created (datetime): Time stamp when this object was created.

  • +
  • typeclass_path (str): A python path pointing to the location of this (type)class

  • +
+

There is one special field that doesn’t use the db_ prefix (it’s defined by Django):

+
    +
  • id (int): the database id (database ref) of the object. This is an ever-increasing, unique +integer. It can also be accessed as dbid (database ID) or pk (primary key). The dbref property +returns the string form “#id”.

  • +
+

The typeclassed entity has several common handlers:

+
    +
  • tags - the TagHandler that handles tagging. Use tags.add() , tags.get() etc.

  • +
  • locks - the LockHandler that manages access restrictions. Use locks.add(), +locks.get() etc.

  • +
  • attributes - the AttributeHandler that manages Attributes on the object. Use +attributes.add() +etc.

  • +
  • db (DataBase) - a shortcut property to the AttributeHandler; allowing obj.db.attrname = value

  • +
  • nattributes - the Non-persistent AttributeHandler for attributes not saved in the +database.

  • +
  • ndb (NotDataBase) - a shortcut property to the Non-peristent AttributeHandler. Allows +obj.ndb.attrname = value

  • +
+

Each of the typeclassed entities then extend this list with their own properties. Go to the +respective pages for Objects, Scripts, Accounts and +Channels for more info. It’s also recommended that you explore the available +entities using Evennia’s flat API to explore which properties and methods they have +available.

+
+
+

Overloading hooks

+

The way to customize typeclasses is usually to overload hook methods on them. Hooks are methods that Evennia call in various situations. An example is the at_object_creation hook on Objects, which is only called once, the very first time this object is saved to the database. Other examples are the at_login hook of Accounts and the at_repeat hook of Scripts.

+
+
+

Querying for typeclasses

+

Most of the time you search for objects in the database by using convenience methods like the +caller.search() of Commands or the search functions like evennia.search_objects.

+

You can however also query for them directly using Django’s query +language. This makes use of a database +manager that sits on all typeclasses, named objects. This manager holds methods that allow +database searches against that particular type of object (this is the way Django normally works +too). When using Django queries, you need to use the full field names (like db_key) to search:

+
matches = Furniture.objects.get(db_key="Chair")
+
+
+
+

It is important that this will only find objects inheriting directly from Furniture in your +database. If there was a subclass of Furniture named Sitables you would not find any chairs +derived from Sitables with this query (this is not a Django feature but special to Evennia). To +find objects from subclasses Evennia instead makes the get_family and filter_family query +methods available:

+
# search for all furnitures and subclasses of furnitures
+# whose names starts with "Chair"
+matches = Furniture.objects.filter_family(db_key__startswith="Chair")
+
+
+
+

To make sure to search, say, all Scripts regardless of typeclass, you need to query from the +database model itself. So for Objects, this would be ObjectDB in the diagram above. Here’s an +example for Scripts:

+
from evennia import ScriptDB
+matches = ScriptDB.objects.filter(db_key__contains="Combat")
+
+
+

When querying from the database model parent you don’t need to use filter_family or get_family - +you will always query all children on the database model.

+
+
+

Updating existing typeclass instances

+

If you already have created instances of Typeclasses, you can modify the Python code at any time - +due to how Python inheritance works your changes will automatically be applied to all children once you have reloaded the server.

+

However, database-saved data, like db_* fields, Attributes, Tags etc, are +not themselves embedded into the class and will not be updated automatically. This you need to +manage yourself, by searching for all relevant objects and updating or adding the data:

+
# add a worth Attribute to all existing Furniture
+for obj in Furniture.objects.all():
+    # this will loop over all Furniture instances
+    obj.db.worth = 100
+
+
+

A common use case is putting all Attributes in the at_*_creation hook of the entity, such as +at_object_creation for Objects. This is called every time an object is created - and only then. +This is usually what you want but it does mean already existing objects won’t get updated if you +change the contents of at_object_creation later. You can fix this in a similar way as above +(manually setting each Attribute) or with something like this:

+
# Re-run at_object_creation only on those objects not having the new Attribute
+for obj in Furniture.objects.all():
+    if not obj.db.worth:
+        obj.at_object_creation()
+
+
+

The above examples can be run in the command prompt created by evennia shell. You could also run +it all in-game using @py. That however requires you to put the code (including imports) as one +single line using ; and list +comprehensions, like this (ignore the +line break, that’s only for readability in the wiki):

+
py from typeclasses.furniture import Furniture;
+[obj.at_object_creation() for obj in Furniture.objects.all() if not obj.db.worth]
+
+
+

It is recommended that you plan your game properly before starting to build, to avoid having to +retroactively update objects more than necessary.

+
+
+

Swap typeclass

+

If you want to swap an already existing typeclass, there are two ways to do so: From in-game and via code. From inside the game you can use the default @typeclass command:

+
typeclass objname = path.to.new.typeclass
+
+
+

There are two important switches to this command:

+
    +
  • /reset - This will purge all existing Attributes on the object and re-run the creation hook (like at_object_creation for Objects). This assures you get an object which is purely of this new class.

  • +
  • /force - This is required if you are changing the class to be the same class the object already has - it’s a safety check to avoid user errors. This is usually used together with /reset to re-run the creation hook on an existing class.

  • +
+

In code you instead use the swap_typeclass method which you can find on all typeclassed entities:

+
obj_to_change.swap_typeclass(new_typeclass_path, clean_attributes=False,
+                   run_start_hooks="all", no_default=True, clean_cmdsets=False)
+
+
+

The arguments to this method are described in the API docs here.

+
+
+
+

How typeclasses actually work

+

This is considered an advanced section.

+

Technically, typeclasses are Django proxy models. The only database +models that are “real” in the typeclass system (that is, are represented by actual tables in the database) are AccountDB, ObjectDB, ScriptDB and ChannelDB (there are also Attributes and Tags but they are not typeclasses themselves). All the subclasses of them are “proxies”, extending them with Python code without actually modifying the database layout.

+

Evennia modifies Django’s proxy model in various ways to allow them to work without any boiler plate (for example you don’t need to set the Django “proxy” property in the model Meta subclass, Evennia handles this for you using metaclasses). Evennia also makes sure you can query subclasses as well as patches django to allow multiple inheritance from the same base class.

+
+

Caveats

+

Evennia uses the idmapper to cache its typeclasses (Django proxy models) in memory. The idmapper allows things like on-object handlers and properties to be stored on typeclass instances and to not get lost as long as the server is running (they will only be cleared on a Server reload). Django does not work like this by default; by default every time you search for an object in the database you’ll get a different instance of that object back and anything you stored on it that was not in the database would be lost. The bottom line is that Evennia’s Typeclass instances subside in memory a lot longer than vanilla Django model instance do.

+

There is one caveat to consider with this, and that relates to [making your own models](New- +Models): Foreign relationships to typeclasses are cached by Django and that means that if you were to change an object in a foreign relationship via some other means than via that relationship, the object seeing the relationship may not reliably update but will still see its old cached version. Due to typeclasses staying so long in memory, stale caches of such relationships could be more +visible than common in Django. See the closed issue #1098 and its comments for examples and solutions.

+
+
+
+

Will I run out of dbrefs?

+

Evennia does not re-use its #dbrefs. This means new objects get an ever-increasing #dbref, also if you delete older objects. There are technical and safety reasons for this. But you may wonder if this means you have to worry about a big game ‘running out’ of dbref integers eventually.

+

The answer is simply no.

+

For example, the max dbref value for the default sqlite3 database is 2**64. If you created 10 000 new objects every second of every minute of every day of the year it would take about 60 million years for you to run out of dbref numbers. That’s a database of 140 TeraBytes, just to store the dbrefs, no other data.

+

If you are still using Evennia at that point and have this concern, get back to us and we can discuss adding dbref reuse then.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Web-API.html b/docs/latest/Components/Web-API.html new file mode 100644 index 0000000000..b3c722038f --- /dev/null +++ b/docs/latest/Components/Web-API.html @@ -0,0 +1,240 @@ + + + + + + + + + Evennia REST API — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia REST API

+

Evennia makes its database accessible via a REST API found on +http://localhost:4001/api if running locally with default setup. The API allows you to retrieve, edit and create resources from outside the game, for example with your own custom client or game editor. While you can view and learn about the api in the web browser, it is really +meant to be accessed in code, by other programs.

+

The API is using Django Rest Framework. This automates the process +of setting up views (Python code) to process the result of web requests. +The process of retrieving data is similar to that explained on the +Webserver page, except the views will here return JSON +data for the resource you want. You can also send such JSON data +in order to update the database from the outside.

+
+

Usage

+

To activate the API, add this to your settings file.

+
REST_API_ENABLED = True
+
+
+

The main controlling setting is REST_FRAMEWORK, which is a dict. The keys +DEFAULT_LIST_PERMISSION and DEFAULT_CREATE_PERMISSIONS control who may +view and create new objects via the api respectively. By default, users with +‘Builder’-level permission or higher may access both actions.

+

While the api is meant to be expanded upon, Evennia supplies several operations +out of the box. If you click the Autodoc button in the upper right of the /api +website you’ll get a fancy graphical presentation of the available endpoints.

+

Here is an example of calling the api in Python using the standard requests library.

+
>>> import requests
+>>> response = requests.get("https://www.mygame.com/api", auth=("MyUsername", "password123"))
+>>> response.json()
+{'accounts': 'http://www.mygame.com/api/accounts/',
+ 'objects': 'http://www.mygame.com/api/objects/',
+'characters': 'http://www.mygame.comg/api/characters/',
+'exits': 'http://www.mygame.com/api/exits/',
+'rooms': 'http://www.mygame.com/api/rooms/',
+'scripts': 'http://www.mygame.com/api/scripts/'
+'helpentries': 'http://www.mygame.com/api/helpentries/' }
+
+
+

To list a specific type of object:

+
>>> response = requests.get("https://www.mygame.com/api/objects",
+                            auth=("Myusername", "password123"))
+>>> response.json()
+{
+"count": 125,
+"next": "https://www.mygame.com/api/objects/?limit=25&offset=25",
+"previous": null,
+"results" : [{"db_key": "A rusty longsword", "id": 57, "db_location": 213, ...}]}
+
+
+

In the above example, it now displays the objects inside the “results” array, while it has a “count” value for the number of total objects, and “next” and “previous” links for the next and previous page, if any. This is called pagination, and the link displays “limit” and “offset” as query parameters that can be added to the url to control the output.

+

Other query parameters can be defined as filters which allow you to further narrow the results. For example, to only get accounts with developer permissions:

+
>>> response = requests.get("https://www.mygame.com/api/accounts/?permission=developer",
+                            auth=("MyUserName", "password123"))
+>>> response.json()
+{
+"count": 1,
+"results": [{"username": "bob",...}]
+}
+
+
+

Now suppose that you want to use the API to create an Object:

+
>>> data = {"db_key": "A shiny sword"}
+>>> response = requests.post("https://www.mygame.com/api/objects",
+                             data=data, auth=("Anotherusername", "mypassword"))
+>>> response.json()
+{"db_key": "A shiny sword", "id": 214, "db_location": None, ...}
+
+
+

Here we made a HTTP POST request to the /api/objects endpoint with the db_key we wanted. We got back info for the newly created object. You can now make another request with PUT (replace everything) or PATCH (replace only what you provide). By providing the id to the endpoint (/api/objects/214), we make sure to update the right sword:

+
>>> data = {"db_key": "An even SHINIER sword", "db_location": 50}
+>>> response = requests.put("https://www.mygame.com/api/objects/214",
+                            data=data, auth=("Anotherusername", "mypassword"))
+>>> response.json()
+{"db_key": "An even SHINIER sword", "id": 214, "db_location": 50, ...}
+
+
+

In most cases, you won’t be making API requests to the backend with Python, +but with Javascript from some frontend application. +There are many Javascript libraries which are meant to make this process +easier for requests from the frontend, such as AXIOS, or using +the native Fetch.

+
+
+

Customizing the API

+

Overall, reading up on Django Rest Framework ViewSets and +other parts of their documentation is required for expanding and +customizing the API.

+

Check out the Website page for help on how to override code, templates +and static files.

+
    +
  • API templates (for the web-display) is located in evennia/web/api/templates/rest_framework/ (it must +be named such to allow override of the original REST framework templates).

  • +
  • Static files is in evennia/web/api/static/rest_framework/

  • +
  • The api code is located in evennia/web/api/ - the url.py file here is responsible for +collecting all view-classes.

  • +
+

Contrary to other web components, there is no pre-made urls.py set up for +mygame/web/api/. This is because the registration of models with the api is +strongly integrated with the REST api functionality. Easiest is probably to +copy over evennia/web/api/urls.py and modify it in place.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Web-Admin.html b/docs/latest/Components/Web-Admin.html new file mode 100644 index 0000000000..0017617cd8 --- /dev/null +++ b/docs/latest/Components/Web-Admin.html @@ -0,0 +1,300 @@ + + + + + + + + + The Web Admin — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

The Web Admin

+

The Evennia Web admin is a customized Django admin site +used for manipulating the game database using a graphical interface. You +have to be logged into the site to use it. It then appears as an Admin link +the top of your website. You can also go to http://localhost:4001/admin when +running locally.

+

Almost all actions done in the admin can also be done in-game by use of Admin- +or Builder-commands.

+
+

Usage

+

The admin is pretty self-explanatory - you can see lists of each object type, +create new instances of each type and also add new Attributes/tags them. The +admin frontpage will give a summary of all relevant entities and how they are +used.

+

There are a few use cases that requires some additional explanation though.

+
+

Adding objects to Attributes

+

The value field of an Attribute is pickled into a special form. This is usually not +something you need to worry about (the admin will pickle/unpickle) the value +for you), except if you want to store a database-object in an attribute. Such +objects are actually stored as a tuple with object-unique data.

+
    +
  1. Find the object you want to add to the Attribute. At the bottom of the first section +you’ll find the field Serialized string. This string shows a Python tuple like

    +
    ('__packed_dbobj__', ('objects', 'objectdb'), '2021:05:15-08:59:30:624660', 358)
    +
    +
    +

    Mark and copy this tuple-string to your clipboard exactly as it stands (parentheses and all).

    +
  2. +
  3. Go to the entity that should have the new Attribute and create the Attribute. In its value +field, paste the tuple-string you copied before. Save!

  4. +
  5. If you want to store multiple objects in, say, a list, you can do so by literally +typing a python list [tuple, tuple, tuple, ...] where you paste in the serialized +tuple-strings with commas. At some point it’s probably easier to do this in code though …

  6. +
+
+
+

Linking Accounts and Characters

+

In MULTISESSION_MODE 0 or 1, each connection can have one Account and one +Character, usually with the same name. Normally this is done by the user +creating a new account and logging in - a matching Character will then be +created for them. You can however also do so manually in the admin:

+
    +
  1. First create the complete Account in the admin.

  2. +
  3. Next, create the Object (usually of Character typeclass) and name it the same +as the Account. It also needs a command-set. The default CharacterCmdset is a good bet.

  4. +
  5. In the Puppeting Account field, select the Account.

  6. +
  7. Make sure to save everything.

  8. +
  9. Click the Link to Account button (this will only work if you saved first). This will +add the needed locks and Attributes to the Account to allow them to immediately +connect to the Character when they next log in. This will (where possible):

    +
      +
    • Set account.db._last_puppet to the Character.

    • +
    • Add Character to account.db._playabel_characters list.

    • +
    • Add/extend the puppet: lock on the Character to include puppet:pid(<Character.id>)

    • +
    +
  10. +
+
+
+

Building with the Admin

+

It’s possible (if probably not very practical at scale) to build and describe +rooms in the Admin.

+
    +
  1. Create an Object of a Room-typeclass with a suitable room-name.

  2. +
  3. Set an Attribute ‘desc’ on the room - the value of this Attribute is the +room’s description.

  4. +
  5. Add Tags of type ‘alias’ to add room-aliases (no type for regular tags)

  6. +
+

Exits:

+
    +
  1. Exits are Objects of an Exit typeclass, so create one.

  2. +
  3. The exit has Location of the room you just created.

  4. +
  5. Set Destination set to where the exit leads to.

  6. +
  7. Set a ‘desc’ Attribute, this is shown if someone looks at the exit.

  8. +
  9. Tags of type ‘alias’ are alternative names users can use to go through +this exit.

  10. +
+
+
+
+

Grant others access to the admin

+

The access to the admin is controlled by the Staff status flag on the +Account. Without this flag set, even superusers will not even see the admin +link on the web page. The staff-status has no in-game equivalence.

+

Only Superusers can change the Superuser status flag, and grant new +permissions to accounts. The superuser is the only permission level that is +also relevant in-game. User Permissions and Groups found on the Account +admin page only affects the admin - they have no connection to the in-game +Permissions (Player, Builder, Admin etc).

+

For a staffer with Staff status to be able to actually do anything, the +superuser must grant at least some permissions for them on their Account. This +can also be good in order to limit mistakes. It can be a good idea to not allow +the Can delete Account permission, for example.

+
+

Important

+

If you grant staff-status and permissions to an Account and they still cannot +access the admin’s content, try reloading the server.

+
+
+

Warning

+
If a staff member has access to the in-game ``py`` command, they can just as
+well have their admin ``Superuser status`` set too. The reason is that ``py``
+grants them all the power they need to set the ``is_superuser`` flag on their
+account manually. There is a reason access to the ``py`` command must be
+considered carefully ...
+
+
+
+
+
+

Customizing the web admin

+

Customizing the admin is a big topic and something beyond the scope of this +documentation. See the official Django docs for +the details. This is just a brief summary.

+

See the Website page for an overview of the components going into +generating a web page. The Django admin uses the same principle except that +Django provides a lot of tools to automate the admin-generation for us.

+

Admin templates are found in evennia/web/templates/admin/ but you’ll find +this is relatively empty. This is because most of the templates are just +inherited directly from their original location in the Django package +(django/contrib/admin/templates/). So if you wanted to override one you’d have +to copy it from there into your mygame/templates/admin/ folder. Same is true +for CSS files.

+

The admin site’s backend code (the views) is found in evennia/web/admin/. It +is organized into admin-classes, like ObjectAdmin, AccountAdmin etc. +These automatically use the underlying database models to generate useful views +for us without us havint go code the forms etc ourselves.

+

The top level AdminSite (the admin configuration referenced in django docs) +is found in evennia/web/utils/adminsite.py.

+
+

Change the title of the admin

+

By default the admin’s title is Evennia web admin. To change this, add the +following to your mygame/web/urls.py:

+
# in mygame/web/urls.py
+
+# ...
+
+from django.conf.admin import site
+
+#...
+
+site.site_header = "My great game admin"
+
+
+
+
+

Reload the server and the admin’s title header will have changed.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Web-Bootstrap-Framework.html b/docs/latest/Components/Web-Bootstrap-Framework.html new file mode 100644 index 0000000000..60fc9884d6 --- /dev/null +++ b/docs/latest/Components/Web-Bootstrap-Framework.html @@ -0,0 +1,316 @@ + + + + + + + + + Bootstrap frontend framework — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Bootstrap frontend framework

+

Evennia’s default web page uses a framework called Bootstrap. This framework is in use across the internet - you’ll probably start to recognize its influence once you learn some of the common design patterns. This switch is great for web developers, perhaps like yourself, because instead of wondering about setting up different grid systems or what custom class another designer used, we have a base, a bootstrap, to work from. Bootstrap is responsive by default, and comes with some default styles that Evennia has lightly overrode to keep some of the same colors and styles you’re used to from the previous design.

+

e, a brief overview of Bootstrap follows. For more in-depth info, please +read the documentation.

+
+

Grid system

+

Other than the basic styling Bootstrap includes, it also includes a built in layout and grid system.

+
+

The container

+

The first part of the grid system is the container.

+

The container is meant to hold all your page content. Bootstrap provides two types: fixed-width and +full-width. Fixed-width containers take up a certain max-width of the page - they’re useful for limiting the width on Desktop or Tablet platforms, instead of making the content span the width of the page.

+
<div class="container">
+    <!--- Your content here -->
+</div>
+
+
+

Full width containers take up the maximum width available to them - they’ll span across a wide- +screen desktop or a smaller screen phone, edge-to-edge.

+
<div class="container-fluid">
+    <!--- This content will span the whole page -->
+</div>
+
+
+
+
+

The grid

+

The second part of the layout system is the grid.

+

This is the bread-and-butter of the layout of Bootstrap - it allows you to change the size of elements depending on the size of the screen, without writing any media queries. We’ll briefly go over it - to learn more, please read the docs or look at the source code for Evennia’s home page in your browser.

+
+

Important! Grid elements should be in a .container or .container-fluid. This will center the +contents of your site.

+
+

Bootstrap’s grid system allows you to create rows and columns by applying classes based on breakpoints. The default breakpoints are extra small, small, medium, large, and extra-large. If you’d like to know more about these breakpoints, please take a look at the documentation for +them.

+

To use the grid system, first create a container for your content, then add your rows and columns like so:

+
<div class="container">
+    <div class="row">
+        <div class="col">
+           1 of 3
+        </div>
+        <div class="col">
+           2 of 3
+        </div>
+        <div class="col">
+           3 of 3
+        </div>
+    </div>
+</div>
+
+
+

This layout would create three equal-width columns.

+

To specify your sizes - for instance, Evennia’s default site has three columns on desktop and +tablet, but reflows to single-column on smaller screens. Try it out!

+
<div class="container">
+    <div class="row">
+        <div class="col col-md-6 col-lg-3">
+            1 of 4
+        </div>
+        <div class="col col-md-6 col-lg-3">
+            2 of 4
+        </div>
+        <div class="col col-md-6 col-lg-3">
+            3 of 4
+        </div>
+        <div class="col col-md-6 col-lg-3">
+            4 of 4
+        </div>
+    </div>
+</div>
+
+
+

This layout would be 4 columns on large screens, 2 columns on medium screens, and 1 column on +anything smaller.

+

To learn more about Bootstrap’s grid, please take a look at the +docs +I

+
+
+
+

General Styling elements

+

Bootstrap provides base styles for your site. These can be customized through CSS, but the default +styles are intended to provide a consistent, clean look for sites.

+
+

Color

+

Most elements can be styled with default colors. Take a look at the documentation to learn more about these colors

+
    +
  • suffice to say, adding a class of text-* or bg-*, for instance, text-primary, sets the text color +or background color.

  • +
+
+
+

Borders

+

Simply adding a class of ‘border’ to an element adds a border to the element. For more in-depth +info, please read the documentation on borders..

+
<span class="border border-dark"></span>
+
+
+

You can also easily round corners just by adding a class.

+
<img src="..." class="rounded" />
+
+
+
+
+

Spacing

+

Bootstrap provides classes to easily add responsive margin and padding. Most of the time, you might like to add margins or padding through CSS itself - however these classes are used in the default Evennia site. Take a look at the docs to +learn more.

+
+
+

Buttons

+

Buttons in Bootstrap are very easy to use - button styling can be added to <button>, <a>, and <input> elements.

+
<a class="btn btn-primary" href="#" role="button">I'm a Button</a>
+<button class="btn btn-primary" type="submit">Me too!</button>
+<input class="btn btn-primary" type="button" value="Button">
+<input class="btn btn-primary" type="submit" value="Also a Button">
+<input class="btn btn-primary" type="reset" value="Button as Well">
+
+
+
+
+

Cards

+

Cards provide a container for other elements +that stands out from the rest of the page. The “Accounts”, “Recently Connected”, and “Database +Stats” on the default webpage are all in cards. Cards provide quite a bit of formatting options - +the following is a simple example, but read the documentation or look at the site’s source for more.

+
<div class="card">
+  <div class="card-body">
+    <h4 class="card-title">Card title</h4>
+    <h6 class="card-subtitle mb-2 text-muted">Card subtitle</h6>
+    <p class="card-text">Fancy, isn't it?</p>
+    <a href="#" class="card-link">Card link</a>
+  </div>
+</div>
+
+
+
+
+

Jumbotron

+

Jumbotrons are useful for featuring an +image or tagline for your game. They can flow with the rest of your content or take up the full +width of the page - Evennia’s base site uses the former.

+
<div class="jumbotron jumbotron-fluid">
+  <div class="container">
+    <h1 class="display-3">Full Width Jumbotron</h1>
+    <p class="lead">Look at the source of the default Evennia page for a regular Jumbotron</p>
+  </div>
+</div>
+
+
+
+
+

Forms

+

Forms are highly customizable with Bootstrap. +For a more in-depth look at how to use forms and their styles in your own Evennia site, please read +over the web character gen tutorial.

+
+
+
+

Further reading

+

Bootstrap also provides a huge amount of utilities, as well as styling and content elements. To learn more about them, please [read the Bootstrap docs](https://getbootstrap.com/docs/4.0/getting- started/introduction/) or read one of our other web tutorials.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Webclient.html b/docs/latest/Components/Webclient.html new file mode 100644 index 0000000000..82549ce4c6 --- /dev/null +++ b/docs/latest/Components/Webclient.html @@ -0,0 +1,430 @@ + + + + + + + + + Web Client — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Web Client

+

Evennia comes with a MUD client accessible from a normal web browser. During development you can try +it at http://localhost:4001/webclient. The client consists of several parts, all under +evennia/web:

+

templates/webclient/webclient.html and templates/webclient/base.html are the very simplistic +django html templates describing the webclient layout.

+

static/webclient/js/evennia.js is the main evennia javascript library. This handles all +communication between Evennia and the client over websockets and via AJAX/COMET if the browser can’t +handle websockets. It will make the Evennia object available to the javascript namespace, which +offers methods for sending and receiving data to/from the server transparently. This is intended to +be used also if swapping out the gui front end.

+

static/webclient/js/webclient_gui.js is the default plugin manager. It adds the plugins and +plugin_manager objects to the javascript namespace, coordinates the GUI operations between the +various plugins, and uses the Evennia object library for all in/out.

+

static/webclient/js/plugins provides a default set of plugins that implement a “telnet-like” +interface, and a couple of example plugins to show how you could implement new plugin features.

+

static/webclient/css/webclient.css is the CSS file for the client; it also defines things like how +to display ANSI/Xterm256 colors etc.

+

The server-side webclient protocols are found in evennia/server/portal/webclient.py and +webclient_ajax.py for the two types of connections. You can’t (and should not need to) modify +these.

+
+

Customizing the web client

+

Like was the case for the website, you override the webclient from your game directory. You need to +add/modify a file in the matching directory locations within your project’s mygame/web/ directories. +These directories are NOT directly used by the web server when the game is running, the +server copies everything web related in the Evennia folder over to mygame/server/.static/ and then +copies in all of your mygame/web/ files. This can cause some cases were you edit a file, but it doesn’t +seem to make any difference in the servers behavior. Before doing anything else, try shutting +down the game and running evennia collectstatic from the command line then start it back up, clear +your browser cache, and see if your edit shows up.

+

Example: To change the list of in-use plugins, you need to override base.html by copying +evennia/web/templates/webclient/base.html to +mygame/web/templates/webclient/base.html and editing it to add your new plugin.

+
+
+

Evennia Web Client API (from evennia.js)

+
    +
  • Evennia.init( opts )

  • +
  • Evennia.connect()

  • +
  • Evennia.isConnected()

  • +
  • Evennia.msg( cmdname, args, kwargs, callback )

  • +
  • Evennia.emit( cmdname, args, kwargs )

  • +
  • log()

  • +
+
+
+

Plugin Manager API (from webclient_gui.js)

+
    +
  • options Object, Stores key/value ‘state’ that can be used by plugins to coordinate behavior.

  • +
  • plugins Object, key/value list of the all the loaded plugins.

  • +
  • plugin_handler Object

    +
      +
    • plugin_handler.add("name", plugin)

    • +
    • plugin_handler.onSend(string)

    • +
    +
  • +
+
+
+

Plugin callbacks API

+
    +
  • init() – The only required callback

  • +
  • boolean onKeydown(event) This plugin listens for Keydown events

  • +
  • onBeforeUnload() This plugin does something special just before the webclient page/tab is +closed.

  • +
  • onLoggedIn(args, kwargs) This plugin does something when the webclient first logs in.

  • +
  • onGotOptions(args, kwargs) This plugin does something with options sent from the server.

  • +
  • boolean onText(args, kwargs) This plugin does something with messages sent from the server.

  • +
  • boolean onPrompt(args, kwargs) This plugin does something when the server sends a prompt.

  • +
  • boolean onUnknownCmd(cmdname, args, kwargs) This plugin does something with “unknown commands”.

  • +
  • onConnectionClose(args, kwargs) This plugin does something when the webclient disconnects from +the server.

  • +
  • newstring onSend(string) This plugin examines/alters text that other plugins generate. Use +with caution

  • +
+

The order of the plugins defined in base.html is important. All the callbacks for each plugin +will be executed in that order. Functions marked “boolean” above must return true/false. Returning +true will short-circuit the execution, so no other plugins lower in the base.html list will have +their callback for this event called. This enables things like the up/down arrow keys for the +history.js plugin to always occur before the default_in.js plugin adds that key to the current input +buffer.

+
+

Example/Default Plugins (plugins/*.js)

+
    +
  • clienthelp.js Defines onOptionsUI from the options2 plugin. This is a mostly empty plugin to +add some “How To” information for your game.

  • +
  • default_in.js Defines onKeydown. <enter> key or mouse clicking the arrow will send the currently typed text.

  • +
  • default_out.js Defines onText, onPrompt, and onUnknownCmd. Generates HTML output for the user.

  • +
  • default_unload.js Defines onBeforeUnload. Prompts the user to confirm that they meant to +leave/close the game.

  • +
  • font.js Defines onOptionsUI. The plugin adds the ability to select your font and font size.

  • +
  • goldenlayout_default_config.js Not actually a plugin, defines a global variable that +goldenlayout uses to determine its window layout, known tag routing, etc.

  • +
  • goldenlayout.js Defines onKeydown, onText and custom functions. A very powerful “tabbed” window manager for drag-n-drop windows, text routing and more.

  • +
  • history.js Defines onKeydown and onSend. Creates a history of past sent commands, and uses arrow keys to peruse.

  • +
  • hotbuttons.js Defines onGotOptions. A Disabled-by-default plugin that defines a button bar with +user-assignable commands.

  • +
  • html.js A basic plugin to allow the client to handle “raw html” messages from the server, this +allows the server to send native HTML messages like >div style=‘s’<styled text>/div<

  • +
  • iframe.js Defines onOptionsUI. A goldenlayout-only plugin to create a restricted browsing sub- +window for a side-by-side web/text interface, mostly an example of how to build new HTML +“components” for goldenlayout.

  • +
  • message_routing.js Defines onOptionsUI, onText, onKeydown. This goldenlayout-only plugin +implements regex matching to allow users to “tag” arbitrary text that matches, so that it gets +routed to proper windows. Similar to “Spawn” functions for other clients.

  • +
  • multimedia.js An basic plugin to allow the client to handle “image” “audio” and “video” messages from the server and display them as inline HTML.

  • +
  • notifications.js Defines onText. Generates browser notification events for each new message +while the tab is hidden.

  • +
  • 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.

  • +
  • text2html.js Provides a new message handler type: text2html, similar to the multimedia and html plugins. This plugin provides a way to offload rendering the regular pipe-styled ASCII messages to the client. This allows the server to do less work, while also allowing the client a place to customize this conversion process. To use this plugin you will need to override the current commands in Evennia, changing any place where a raw text output message is generated and turn it into a text2html message. For example: target.msg("my text") becomes: target.msg(text2html=("my text")) (even better, use a webclient pane routing tag: target.msg(text2html=("my text", {"type": "sometag"}))) text2html messages should format and behave identically to the server-side generated text2html() output.

  • +
+
+
+

A side note on html messages vs text2html messages

+

So…lets say you have a desire to make your webclient output more like standard webpages… +For telnet clients, you could collect a bunch of text lines together, with ASCII formatted borders, etc. Then send the results to be rendered client-side via the text2html plugin.

+

But for webclients, you could format a message directly with the html plugin to render the whole thing as an HTML table, like so:

+
    # Server Side Python Code:
+
+    if target.is_webclient():
+        # This can be styled however you like using CSS, just add the CSS file to web/static/webclient/css/...
+        table = [
+                 "<table>",
+                  "<tr><td>1</td><td>2</td><td>3</td></tr>",
+                  "<tr><td>4</td><td>5</td><td>6</td></tr>",
+                 "</table>"
+               ]
+        target.msg( html=( "".join(table), {"type": "mytag"}) )
+    else:
+        # This will use the client to render this as "plain, simple" ASCII text, the same
+        #   as if it was rendered server-side via the Portal's text2html() functions
+        table = [ 
+                "#############",
+                "# 1 # 2 # 3 #",
+                "#############",
+                "# 4 # 5 # 6 #",
+                "#############"
+               ]
+        target.msg( html2html=( "\n".join(table), {"type": "mytag"}) )
+
+
+
+
+
+

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. +The Goldenlayout plugin framework can help with this.

+
+

GoldenLayout

+

GoldenLayout is a web framework that allows web developers and their users to create their own +tabbed/windowed layouts. Windows/tabs can be click-and-dragged from location to location by +clicking on their titlebar and dragging until the “frame lines” appear. Dragging a window onto +another window’s titlebar will create a tabbed “Stack”. The Evennia goldenlayout plugin defines 3 +basic types of window: The Main window, input windows and non-main text output windows. The Main +window and the first input window are unique in that they can’t be “closed”.

+

The most basic customization is to provide your users with a default layout other than just one Main +output and the one starting input window. This is done by modifying your server’s +goldenlayout_default_config.js.

+

Start by creating a new +mygame/web/static/webclient/js/plugins/goldenlayout_default_config.js file, and adding +the following JSON variable:

+
var goldenlayout_config = {
+    content: [{
+        type: 'column',
+        content: [{
+            type: 'row',
+            content: [{
+                type: 'column',
+                content: [{
+                    type: 'component',
+                    componentName: 'Main',
+                    isClosable: false,
+                    tooltip: 'Main - drag to desired position.',
+                    componentState: {
+                        cssClass: 'content',
+                        types: 'untagged',
+                        updateMethod: 'newlines',
+                    },
+                }, {
+                    type: 'component',
+                    componentName: 'input',
+                    id: 'inputComponent',
+                    height: 10,
+                    tooltip: 'Input - The last input in the layout is always the default.',
+                }, {
+                    type: 'component',
+                    componentName: 'input',
+                    id: 'inputComponent',
+                    height: 10,
+                    isClosable: false,
+                    tooltip: 'Input - The last input in the layout is always the default.',
+                }]
+            },{
+                type: 'column',
+                content: [{
+                    type: 'component',
+                    componentName: 'evennia',
+                    componentId: 'evennia',
+                    title: 'example',
+                    height: 60,
+                    isClosable: false,
+                    componentState: {
+                        types: 'some-tag-here',
+                        updateMethod: 'newlines',
+                    },
+                }, {
+                    type: 'component',
+                    componentName: 'evennia',
+                    componentId: 'evennia',
+                    title: 'sheet',
+                    isClosable: false,
+                    componentState: {
+                        types: 'sheet',
+                        updateMethod: 'replace',
+                    },
+                }],
+            }],
+        }]
+    }]
+};
+
+
+

This is a bit ugly, but hopefully, from the indentation, you can see that it creates a side-by-side +(2-column) interface with 3 windows down the left side (The Main and 2 inputs) and a pair of windows +on the right side for extra outputs. Any text tagged with “some-tag-here” will flow to the bottom +of the “example” window, and any text tagged “sheet” will replace the text already in the “sheet” +window.

+

Note: GoldenLayout gets VERY confused and will break if you create two windows with the “Main” +componentName.

+

Now, let’s say you want to display text on each window using different CSS. This is where new +goldenlayout “components” come in. Each component is like a blueprint that gets stamped out when +you create a new instance of that component, once it is defined, it won’t be easily altered. You +will need to define a new component, preferably in a new plugin file, and then add that into your +page (either dynamically to the DOM via javascript, or by including the new plugin file into the +base.html).

+

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/myplugin.js" %} language="javascript"
+type="text/javascript"></script>
+
+
+

Remember, plugins are load-order dependent, so make sure the new <script> tag comes before the goldenlayout.js.

+

Next, create a new plugin file mygame/web/static/webclient/js/plugins/myplugin.js and +edit it.

+
let myplugin = (function () {
+    //
+    //
+    var postInit = function() {
+        var myLayout = window.plugins['goldenlayout'].getGL();
+
+        // register our component and replace the default messagewindow
+        myLayout.registerComponent( 'mycomponent', function (container, componentState) {
+            let mycssdiv = $('<div>').addClass('myCSS');
+            mycssdiv.attr('types', 'mytag');
+            mycssdiv.attr('update_method', 'newlines');
+            mycssdiv.appendTo( container.getElement() );
+        });
+
+        console.log("MyPlugin Initialized.");
+    }
+
+    return {
+        init: function () {},
+        postInit: postInit,
+    }
+})();
+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.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Webserver.html b/docs/latest/Components/Webserver.html new file mode 100644 index 0000000000..7d7b910ad5 --- /dev/null +++ b/docs/latest/Components/Webserver.html @@ -0,0 +1,213 @@ + + + + + + + + + Webserver — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Webserver

+

When Evennia starts it also spins up its own Twisted-based web server. The +webserver is responsible for serving the html pages of the game’s website. It +can also serve static resources like images and music.

+

The webclient runs as part of the Server process of +Evennia. This means that it can directly access cached objects modified +in-game, and there is no risk of working with objects that are temporarily +out-of-sync in the database.

+

The webserver runs on Twisted and is meant to be used in a production +environment. It leverages the Django web framework and provides:

+
    +
  • A Game Website - this is what you see when you go to +localhost:4001. The look of the website is meant to be customized to your +game. Users logged into the website will be auto-logged into the game if they +do so with the webclient since they share the same login credentials (there +is no way to safely do auto-login with telnet clients).

  • +
  • The Web Admin is based on the Django web admin and allows you to +edit the game database in a graphical interface.

  • +
  • The Webclient page is served by the webserver, but the actual +game communication (sending/receiving data) is done by the javascript client +on the page opening a websocket connection directly to Evennia’s Portal.

  • +
  • The Evennia REST-API allows for accessing the database from outside the game +(only if `REST_API_ENABLED=True).

  • +
+
+

Basic Webserver data flow

+
    +
  1. A user enters an url in their browser (or clicks a button). This leads to +the browser sending a HTTP request to the server containing an url-path +(like for https://localhost:4001/, the part of the url we need to consider +/). Other possibilities would be /admin/, /login/, /channels/ etc.

  2. +
  3. evennia (through Django) will make use of the regular expressions registered +in the urls.py file. This acts as a rerouter to views, which are +regular Python functions or callable classes able to process the incoming +request (think of these as similar to the right Evennia Command being +selected to handle your input - views are like Commands in this sense). In +the case of / we reroute to a view handling the main index-page of the +website.

  4. +
  5. The view code will prepare all the data needed by the web page. For the default +index page, this means gather the game statistics so you can see how many +are currently connected to the game etc.

  6. +
  7. The view will next fetch a template. A template is a HTML-document with special +‘placeholder’ tags (written as {{...}} or {% ... %} usually). These +placeholders allow the view to inject dynamic content into the HTML and make +the page customized to the current situation. For the index page, it means +injecting the current player-count in the right places of the html page. This +is called ‘rendering’ the template. The result is a complete HTML page.

  8. +
  9. (The view can also pull in a form to customize user-input in a similar way.)

  10. +
  11. The finished HTML page is packed into a HTTP response and returned to the +web browser, which can now display the page!

  12. +
+
+

A note on the webclient

+

The web browser can also execute code directly without talking to the Server. +This code must be written/loaded into the web page and is written using the +Javascript programming language (there is no way around this, it is what web +browsers understand). Executing Javascript is something the web browser does, +it operates independently from Evennia. Small snippets of javascript can be +used on a page to have buttons react, make small animations etc that doesn’t +require the server.

+

In the case of the Webclient, Evennia will load the Webclient page +as above, but the page then initiates Javascript code (a lot of it) responsible +for actually displaying the client GUI, allows you to resize windows etc.

+

After it starts, the webclient ‘calls home’ and spins up a +websocket link to the Evennia Portal - this +is how all data is then exchanged. So after the initial loading of the +webclient page, the above sequence doesn’t happen again until close the tab and +come back or you reload it manually in your browser.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Components/Website.html b/docs/latest/Components/Website.html new file mode 100644 index 0000000000..9b94d025c6 --- /dev/null +++ b/docs/latest/Components/Website.html @@ -0,0 +1,458 @@ + + + + + + + + + Game website — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Game website

+

When Evennia starts it will also start a Webserver as part of the Server process. This uses Django to present a simple but functional default game website. With the default setup, open your browser to localhost:4001 or 127.0.0.1:4001 to see it.

+

The website allows existing players to log in using an account-name and password they previously used to register with the game. If a user logs in with the Webclient they will also log into the website and vice-versa. So if you are logged into the website, opening the webclient will automatically log you into the game as that account.

+

The default website shows a “Welcome!” page with a few links to useful resources. It also shows some statistics about how many players are currently connected.

+

In the top menu you can find

+
    +
  • Home - Get back to front page.

  • +
  • Documentation - A link to the latest stable Evennia documentation.

  • +
  • Characters - This is a demo of connecting in-game characters to the website. +It will display a list of all entities of the +_typeclasses.characters.Character` typeclass and allow you to view their +description with an optional image. The list is only available to logged-in +users.

  • +
  • Channels - This is a demo of connecting in-game chats to the website. It will +show a list of all channels available to you and allow you to view the latest +discussions. Most channels require logging in, but the Public channel can +also be viewed by non-loggedin users.

  • +
  • Help - This ties the in-game Help system to the website. All +database-based help entries that are publicly available or accessible to your +account can be read. This is a good way to present a body of help for people +to read outside of the game.

  • +
  • Play Online - This opens the Webclient in the browser.

  • +
  • Admin The [Web admin](Web admin) will only show if you are logged in.

  • +
  • Log in/out - Allows you to authenticate using the same credentials you use +in the game.

  • +
  • Register - Allows you to register a new account. This is the same as +creating a new account upon first logging into the game).

  • +
+
+

Modifying the default Website

+

You can modify and override all aspects of the web site from your game dir. You’ll mostly be doing so in your settings file (mygame/server/conf/settings.py and in the gamedir’s web/folder (mygame/web/ if your game folder is mygame/).

+
+

When testing your modifications, it’s a good idea to add DEBUG = True to +your settings file. This will give you nice informative tracebacks directly +in your browser instead of generic 404 or 500 error pages. Just remember that +DEBUG mode leaks memory (for retaining debug info) and is not safe to use +for a production game!

+
+

As explained on the Webserver page, the process for getting a web page is

+
    +
  1. Web browser sends HTTP request to server with an URL

  2. +
  3. urls.py uses regex to match that URL to a view (a Python function or callable class).

  4. +
  5. The correct Python view is loaded and executes.

  6. +
  7. The view pulls in a template, a HTML document with placeholder markers in it, +and fills those in as needed (it may also use a form to customize user-input in the same way). +A HTML page may also in turn point to static resources (usually CSS, sometimes images etc).

  8. +
  9. The rendered HTML page is returned to the browser as a HTTP response. If +the HTML page requires static resources are requested, the browser will +fetch those separately before displaying it to the user.

  10. +
+

If you look at the evennia/web/ directory you’ll find the following structure (leaving out stuff not relevant to the website):

+
  evennia/web/
+    ...
+    static/
+        website/
+            css/
+               (css style files)
+            images/
+               (images to show)
+
+    templates/
+        website/
+          (html files)
+
+    website/
+      urls.py
+      views/
+        (python files related to website)
+
+    urls.py
+
+
+
+

The top-level web/urls.py file ‘includes’ the web/website/urls.py file - that way all the website-related url-handling is kept in the same place.

+

This is the layout of the mygame/web/ folder relevant for the website:

+
  mygame/web/
+    ...
+    static/
+      website/
+        css/
+        images/
+
+    templates/
+      website/
+
+      website/
+        urls.py
+        views/
+
+    urls.py
+
+
+
+
+

Changed in version 1.0: Game folders created with older versions of Evennia will lack most of this +convenient mygame/web/ layout. If you use a game dir from an older version, +you should copy over the missing evennia/game_template/web/ folders from +there, as well as the main urls.py file.

+
+

As you can see, the mygame/web/ folder is a copy of the evennia/web/ folder structure except the mygame folders are mostly empty.

+

For static- and template-files, Evennia will first look in mygame/static and mygame/templates before going to the default locations in evennia/web/. So override these resources, you just need to put a file with the same name in the right spot under mygame/web/ (and then reload the server). Easiest is often to copy the original over and modify it.

+

Overridden views (Python modules) also need an additional tweak to the website/urls.py file - you must make sure to repoint the url to the new version rather than it using the original.

+
+
+

Examples of commom web changes

+
+

Important

+

Django is a very mature web-design framework. There are endless +internet-tutorials, courses and books available to explain how to use Django. +So these examples only serve as a first primer to get you started.

+
+
+

Change Title and blurb

+

The website’s title and blurb are simply changed by tweaking +settings.SERVERNAME and settings.GAME_SLOGAN. Your settings file is in +mygame/server/conf/settings.py, just set/add

+
SERVERNAME = "My Awesome Game"
+GAME_SLOGAN = "The best game in the world"
+
+
+
+ +
+

Change front page HTML

+

The front page of the website is usually referred to as the ‘index’ in HTML +parlance.

+

The frontpage template is found in evennia/web/templates/website/index.html. +Just copy this to the equivalent place in mygame/web/. Modify it there and +reload the server to see your changes.

+

Django templates has a few special features that separate them from normal HTML +documents - they contain a special templating language marked with {% ... %} and +{{ ... }}.

+

Some important things to know:

+
    +
  • {% extends "base.html" %} - This is equivalent to a Python from othermodule import * statement, but for templates. It allows a given template to use everything from the imported (extended) template, but also to override anything it wants to change. This makes it easy to keep all pages looking the same and avoids a lot of boiler plate.

  • +
  • {% block blockname %}...{% endblock %} - Blocks are inheritable, named pieces of code that are modified in one place and then used elsewhere. This works a bit in reverse to normal inheritance, because it’s commonly in such a way that base.html defines an empty block, let’s say contents: {% block contents %}{% endblock %} but makes sure to put that in the right place, say in the main body, next to the sidebar etc. Then each page does {% extends "base.html %"} and makes their own {% block contents} <actual content> {% endblock %}. Their contents block will now override the empty one in base.html and appear in the right place in the document, without the extending template having to specifying everything else +around it!

  • +
  • {{ ... }} are ‘slots’ usually embedded inside HTML tags or content. They reference a context (basically a dict) that the Python view makes available to it. Keys on the context are accessed with dot-notation, so if you provide a context {"stats": {"hp": 10, "mp": 5}} to your template, you could access that as {{ stats.hp }} to display 10 at that location to display 10 at that location.

  • +
+

This allows for template inheritance (making it easier to make all pages look the same without rewriting the same thing over and over)

+

There’s a lot more information to be found in the Django template language documentation.

+
+
+

Change webpage colors and styling

+

You can tweak the CSS of the entire website. If you investigate the evennia/web/templates/website/base.html file you’ll see that we use the Bootstrap 4 toolkit.

+

Much structural HTML functionality is actually coming from bootstrap, so you will often be able to just add bootstrap CSS classes to elements in the HTML file to get various effects like text-centering or similar.

+

The website’s custom CSS is found in evennia/web/static/website/css/website.css but we also look for a (currently empty) custom.css in the same location. You can override either, but it may be easier to revert your changes if you only add things to custom.css.

+

Copy the CSS file you want to modify to the corresponding location in mygame/web. Modify it and reload the server to see your changes.

+

You can also apply static files without reloading, but running this in the terminal:

+
evennia collectstatic --no-input
+
+
+

(this is run automatically when reloading the server).

+
+

Note that before you see new CSS files applied you may need to refresh your +browser without cache (Ctrl-F5 in Firefox, for example).

+
+

As an example, add/copy custom.css to mygame/web/static/website/css/ and add the following:

+
.navbar {
+  background-color: #7a3d54;
+}
+
+.footer {
+  background-color: #7a3d54;
+}
+
+
+

Reload and your website now has a red theme!

+
+

Hint: Learn to use your web browser’s Developer tools. +These allow you to tweak CSS ‘live’ to find a look you like and copy it into +the .css file only when you want to make the changes permanent.

+
+
+
+

Change front page functionality

+

The logic is all in the view. To find where the index-page view is found, we look in evennia/web/website/urls.py. Here we find the following line:

+
# in evennia/web/website/urls.py
+
+  ...
+  # website front page
+  path("", index.EvenniaIndexView.as_view(), name="index"),
+  ...
+
+
+
+

The first "" is the empty url - root - what you get if you just enter localhost:4001/ +with no extra path. As expected, this leads to the index page. By looking at the imports +we find the view is in in evennia/web/website/views/index.py.

+

Copy this file to the corresponding location in mygame/web. Then tweak your mygame/web/website/urls.py file to point to the new file:

+
# in mygame/web/website/urls.py
+
+# ...
+
+from web.website.views import index
+
+urlpatterns = [
+    path("", index.EvenniaIndexView.as_view(), name="index")
+
+]
+# ...
+
+
+
+

So we just import index from the new location and point to it. After a reload the front page will now redirect to use your copy rather than the original.

+

The frontpage view is a class EvenniaIndexView. This is a Django class-based view. It’s a little less visible what happens in a class-based view than in a function (since the class implements a lot of functionality as methods), but it’s powerful and much easier to extend/modify.

+

The class property template_name sets the location of the template used under the templates/ folder. So website/index.html points to web/templates/website/index.html (as we already explored above.

+

The get_context_data is a convenient method for providing the context for the template. In the index-page’s case we want the game stats (number of recent players etc). These are then made available to use in {{ ... }} slots in the template as described in the previous section.

+
+
+

Change other website pages

+

The other sub pages are handled in the same way - copy the template or static resource to the right place, or copy the view and repoint your website/urls.py to your copy. Just remember to reload.

+
+
+
+

Adding a new web page

+
+

Using Flat Pages

+

The absolutely simplest way to add a new web page is to use the Flat Pages app available in the Web Admin. The page will appear with the same styling as the rest of the site.

+

For the Flat pages module to work you must first set up a Site (or domain) to use. You only need to this once.

+
    +
  • Go to the Web admin and select Sites. If your game is at mygreatgame.com, that’s the domain you need to add. For local experimentation, add the domain localhost:4001. Note the id of the domain (look at the url when you click on the new domain, if it’s for example http://localhost:4001/admin/sites/site/2/change/, then the id is 2).

  • +
  • Now add the line SITE_ID = <id> to your settings file.

  • +
+

Next you create new pages easily.

+
    +
  • Go the Flat Pages web admin and choose to add a new flat page.

  • +
  • Set the url. If you want the page to appear as e.g. localhost:4001/test/, then +add /test/ here. You need to add both leading and trailing slashes.

  • +
  • Set Title to the name of the page.

  • +
  • The Content is the HTML content of the body of the page. Go wild!

  • +
  • Finally pick the Site you made before, and save.

  • +
  • (in the advanced section you can make it so that you have to login to see the page etc).

  • +
+

You can now go to localhost:4001/test/ and see your new page!

+
+
+

Add Custom new page

+

The Flat Pages page doesn’t allow for (much) dynamic content and customization. For this you need to add the needed components yourself.

+

Let’s see how to make a /test/ page from scratch.

+
    +
  • Add a new test.html file under mygame/web/templates/website/. Easiest is to base this off an existing file. Make sure to {% extend base.html %} if you want to get the same styling as the rest of your site.

  • +
  • Add a new view testview.py under mygame/web/website/views/ (don’t name it test.py or +Django/Evennia will think it contains unit tests). Add a view there to process your page. This is a minimal view to start from (read much more in the Django docs):

    +
    # mygame/web/website/views/testview.py
    +
    +from django.views.generic import TemplateView
    +
    +class MyTestView(TemplateView):
    +    template_name = "website/test.html"
    +
    +
    +
    +
    +
  • +
  • Finally, point to your view from the mygame/web/website/urls.py:

    +
    # in mygame/web/website/urls.py
    +
    +# ...
    +from web.website.views import testview
    +
    +urlpatterns = [
    +    # ...
    +    # we can skip the initial / here
    +    path("test/", testview.MyTestView.as_view())
    +]
    +
    +
    +
    +
  • +
  • Reload the server and your new page is available. You can now continue to add +all sorts of advanced dynamic content through your view and template!

  • +
+
+
+
+

User forms

+

All the pages created so far deal with presenting information to the user. +It’s also possible for the user to input data on the page through forms. An +example would be a page of fields and sliders you fill in to create a +character, with a big ‘Submit’ button at the bottom.

+

Firstly, this must be represented in HTML. The <form> ... </form> is a +standard HTML element you need to add to your template. It also has some other +requirements, such as <input> and often Javascript components as well (but +usually Django will help with this). If you are unfamiliar with how HTML forms +work, read about them here.

+

The basic gist of it is that when you click to ‘submit’ the form, a POST HTML +request will be sent to the server containing the data the user entered. It’s +now up to the server to make sure the data makes sense (validation) and then +process the input somehow (like creating a new character).

+

On the backend side, we need to specify the logic for validating and processing +the form data. This is done by the Form Django class. +This specifies fields on itself that define how to validate that piece of data.

+

The form is then linked into the view-class by adding form_class = MyFormClass to +the view (next to template_name).

+

There are several example forms in evennia/web/website/forms.py. It’s also a good +idea to read Building a form in Django on the Django website - it covers all you need.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Async-Process.html b/docs/latest/Concepts/Async-Process.html new file mode 100644 index 0000000000..28e52ce55e --- /dev/null +++ b/docs/latest/Concepts/Async-Process.html @@ -0,0 +1,308 @@ + + + + + + + + + Async Process — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Async Process

+
+

Important

+

This is considered an advanced topic.

+
+
+

Synchronous versus Asynchronous

+ +

Most program code operates synchronously. This means that each statement in your code gets processed and finishes before the next can begin. This makes for easy-to-understand code. It is also a requirement in many cases - a subsequent piece of code often depend on something calculated or defined in a previous statement.

+

Consider this piece of code in a traditional Python program:

+
    print("before call ...")
+    long_running_function()
+    print("after call ...")
+
+
+

When run, this will print "before call ...", after which the long_running_function gets to work +for however long time. Only once that is done, the system prints "after call ...". Easy and logical to follow. Most of Evennia work in this way and often it’s important that commands get +executed in the same strict order they were coded.

+

Evennia, via Twisted, is a single-process multi-user server. In simple terms this means that it swiftly switches between dealing with player input so quickly that each player feels like they do things at the same time. This is a clever illusion however: If one user, say, runs a command containing that long_running_function, all other players are effectively forced to wait until it finishes.

+

Now, it should be said that on a modern computer system this is rarely an issue. Very few commands run so long that other users notice it. And as mentioned, most of the time you want to enforce all commands to occur in strict sequence.

+
+
+

utils.delay

+ +

The delay function is a much simpler sibling to run_async. It is in fact just a way to delay the execution of a command until a future time.

+
     from evennia.utils import delay
+
+     # [...]
+     # e.g. inside a Command, where `self.caller` is available
+     def callback(obj):
+        obj.msg("Returning!")
+     delay(10, callback, self.caller)
+
+
+

This will delay the execution of the callback for 10 seconds. Provide persistent=True to make the delay survive a server reload. While waiting, you can input commands normally.

+

You can also try the following snippet just see how it works:

+
py from evennia.utils import delay; delay(10, lambda who: who.msg("Test!"), self)
+
+
+

Wait 10 seconds and ‘Test!’ should be echoed back to you.

+
+
+

@utils.interactive decorator

+

The @interactive [decorator](https://realpython.com/primer-on-python- decorators/) makes any function or method possible to ‘pause’ and/or await player input in an interactive way.

+
    from evennia.utils import interactive
+
+    @interactive
+    def myfunc(caller):
+        
+      while True:
+          caller.msg("Getting ready to wait ...")
+          yield(5)
+          caller.msg("Now 5 seconds have passed.")
+
+          response = yield("Do you want to wait another 5 secs?")  
+
+          if response.lower() not in ("yes", "y"):
+              break 
+
+
+

The @interactive decorator gives the function the ability to pause. The use of yield(seconds) will do just that - it will asynchronously pause for the number of seconds given before continuing. This is technically equivalent to using call_async with a callback that continues after 5 secs. But the code with @interactive is a little easier to follow.

+

Within the @interactive function, the response = yield("question") question allows you to ask the user for input. You can then process the input, just like you would if you used the Python input function.

+

All of this makes the @interactive decorator very useful. But it comes with a few caveats.

+
    +
  • The decorated function/method/callable must have an argument named exactly caller. Evennia will look for an argument with this name and treat it as the source of input.

  • +
  • Decorating a function this way turns it turns it into a Python generator. This means

    +
      +
    • You can’t use return <value> from a generator (just an empty return works). To return a value from a function/method you have decorated with @interactive, you must instead use a special Twisted function twisted.internet.defer.returnValue. Evennia also makes this function conveniently available from evennia.utils:

    • +
    +
    from evennia.utils import interactive, returnValue
    +
    +@interactive
    +def myfunc():
    +
    +    # ... 
    +    result = 10
    +
    +    # this must be used instead of `return result`
    +    returnValue(result)
    +
    +
    +
  • +
+
+
+

utils.run_async

+
+

Warning

+

Unless you have a very clear purpose in mind, you are unlikely to get an expected result from run_async. Notably, it will still run your long-running function in the same thread as the rest of the server. So while it does run async, a very heavy and CPU-heavy operation will still block the server. So don’t consider this as a way to offload heavy operations without affecting the rest of the server.

+
+

When you don’t care in which order the command actually completes, you can run it asynchronously. This makes use of the run_async() function in src/utils/utils.py:

+
    run_async(function, *args, **kwargs)
+
+
+

Where function will be called asynchronously with *args and **kwargs. Example:

+
    from evennia import utils
+    print("before call ...")
+    utils.run_async(long_running_function)
+    print("after call ...")
+
+
+

Now, when running this you will find that the program will not wait around for long_running_function to finish. In fact you will see "before call ..." and "after call ..." printed out right away. The long-running function will run in the background and you (and other users) can go on as normal.

+

A complication with using asynchronous calls is what to do with the result from that call. What if +long_running_function returns a value that you need? It makes no real sense to put any lines of +code after the call to try to deal with the result from long_running_function above - as we saw +the "after call ..." got printed long before long_running_function was finished, making that +line quite pointless for processing any data from the function. Instead one has to use callbacks.

+

utils.run_async takes reserved kwargs that won’t be passed into the long-running function:

+
    +
  • at_return(r) (the callback) is called when the asynchronous function (long_running_function +above) finishes successfully. The argument r will then be the return value of that function (or +None).

    +
        def at_return(r):
    +        print(r)
    +
    +
    +
  • +
  • at_return_kwargs - an optional dictionary that will be fed as keyword arguments to the at_return callback.

  • +
  • at_err(e) (the errback) is called if the asynchronous function fails and raises an exception. +This exception is passed to the errback wrapped in a Failure object e. If you do not supply an +errback of your own, Evennia will automatically add one that silently writes errors to the evennia +log. An example of an errback is found below:

  • +
+
        def at_err(e):
+            print("There was an error:", str(e))
+
+
+
    +
  • at_err_kwargs - an optional dictionary that will be fed as keyword arguments to the at_err +errback.

  • +
+

An example of making an asynchronous call from inside a Command definition:

+
    from evennia import utils, Command
+
+    class CmdAsync(Command):
+
+       key = "asynccommand"
+    
+       def func(self):     
+           
+           def long_running_function():  
+               #[... lots of time-consuming code  ...]
+               return final_value
+           
+           def at_return_function(r):
+               self.caller.msg(f"The final value is {r}")
+    
+           def at_err_function(e):
+               self.caller.msg(f"There was an error: {e}")
+
+           # do the async call, setting all callbacks
+           utils.run_async(long_running_function, at_return=at_return_function,
+at_err=at_err_function)
+
+
+

That’s it - from here on we can forget about long_running_function and go on with what else need to be done. Whenever it finishes, the at_return_function function will be called and the final value will pop up for us to see. If not we will see an error message.

+
+

Technically, run_async is just a very thin and simplified wrapper around a Twisted Deferred object; the wrapper sets up a default errback also if none is supplied. If you know what you are doing there is nothing stopping you from bypassing the utility function, building a more sophisticated callback chain after your own liking.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Banning.html b/docs/latest/Concepts/Banning.html new file mode 100644 index 0000000000..8558626ef9 --- /dev/null +++ b/docs/latest/Concepts/Banning.html @@ -0,0 +1,259 @@ + + + + + + + + + Banning — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Banning

+

Whether due to abuse, blatant breaking of your rules, or some other reason, you will eventually find +no other recourse but to kick out a particularly troublesome player. The default command set has +admin tools to handle this, primarily ban, unban, and boot.

+

Say we have a troublesome player “YouSuck” - this is a person that refuses common courtesy - an abusive and spammy account that is clearly created by some bored internet hooligan only to cause grief. You have tried to be nice. Now you just want this troll gone.

+
+

Creating a ban

+
+

Name ban

+

The easiest recourse is to block the account YouSuck from ever connecting again.

+
 ban YouSuck
+
+
+

This will lock the name YouSuck (as well as ‘yousuck’ and any other capitalization combination), and next time they try to log in with this name the server will not let them!

+

You can also give a reason so you remember later why this was a good thing (the banned account will never see this)

+
 ban YouSuck:This is just a troll.
+
+
+

If you are sure this is just a spam account, you might even consider deleting the player account outright:

+
 accounts/delete YouSuck
+
+
+

Generally, banning the name is the easier and safer way to stop the use of an account – if you +change your mind you can always remove the block later whereas a deletion is permanent.

+
+
+

IP ban

+

Just because you block YouSuck’s name might not mean the trolling human behind that account gives up. They can just create a new account YouSuckMore and be back at it. One way to make things harder for them is to tell the server to not allow connections from their particular IP address.

+

First, when the offending account is online, check which IP address they use. This you can do with +the who command, which will show you something like this:

+
 Account Name     On for     Idle     Room     Cmds     Host          
+ YouSuckMore      01:12      2m       22       212      237.333.0.223 
+
+
+

The “Host” bit is the IP address from which the account is connecting. Use this to define the ban instead of the name:

+
 ban 237.333.0.223
+
+
+

This will stop YouSuckMore connecting from their computer. Note however that IP address might change easily - either due to how the player’s Internet Service Provider operates or by the user simply changing computers. You can make a more general ban by putting asterisks * as wildcards for the groups of three digits in the address. So if you figure out that !YouSuckMore mainly connects from 237.333.0.223, 237.333.0.225, and 237.333.0.256 (only changes in their subnet), it might be an idea to put down a ban like this to include any number in that subnet:

+
 ban 237.333.0.*
+
+
+

You should combine the IP ban with a name-ban too of course, so the account YouSuckMore is truly locked regardless of where they connect from.

+

Be careful with too general IP bans however (more asterisks above). If you are unlucky you could be blocking out innocent players who just happen to connect from the same subnet as the offender.

+
+
+

Lifting a ban

+

Use the unban (or ban) command without any arguments and you will see a list of all currently active bans:

+
Active bans
+id   name/ip       date                      reason 
+1    yousuck       Fri Jan 3 23:00:22 2020   This is just a Troll.
+2    237.333.0.*   Fri Jan 3 23:01:03 2020   YouSuck's IP.
+
+
+

Use the id from this list to find out which ban to lift.

+
 unban 2
+  
+Cleared ban 2: 237.333.0.*
+
+
+
+
+
+

Booting

+

YouSuck is not really noticing all this banning yet though - and won’t until having logged out and trying to log back in again. Let’s help the troll along.

+
 boot YouSuck
+
+
+

Good riddance. You can give a reason for booting too (to be echoed to the player before getting kicked out).

+
 boot YouSuck:Go troll somewhere else.
+
+
+
+
+

Summary of abuse-handling tools

+

Below are other useful commands for dealing with annoying players.

+
    +
  • who – (as admin) Find the IP of a account. Note that one account can be connected to from +multiple IPs depending on what you allow in your settings.

  • +
  • examine/account thomas – Get all details about an account. You can also use *thomas to get +the account. If not given, you will get the Object thomas if it exists in the same location, which +is not what you want in this case.

  • +
  • boot thomas – Boot all sessions of the given account name.

  • +
  • boot 23 – Boot one specific client session/IP by its unique id.

  • +
  • ban – List all bans (listed with ids)

  • +
  • ban thomas – Ban the user with the given account name

  • +
  • ban/ip 134.233.2.111 – Ban by IP

  • +
  • ban/ip 134.233.2.* – Widen IP ban

  • +
  • ban/ip 134.233.*.* – Even wider IP ban

  • +
  • unban 34 – Remove ban with id #34

  • +
  • cboot mychannel = thomas – Boot a subscriber from a channel you control

  • +
  • clock mychannel = control:perm(Admin);listen:all();send:all() – Fine control of access to your channel using lock definitions.

  • +
+

Locking a specific command (like page) is accomplished like so:

+
    +
  1. Examine the source of the command. The default page command class has the lock string “cmd:not pperm(page_banned)”. This means that unless the player has the ‘permission’ “page_banned” they can use this command. You can assign any lock string to allow finer customization in your commands. You might look for the value of an Attribute or Tag, your current location etc.

  2. +
  3. perm/account thomas = page_banned – Give the account the ‘permission’ which causes (in this case) the lock to fail.

  4. +
+
    +
  • perm/del/account thomas = page_banned – Remove the given permission

  • +
  • tel thomas = jail – Teleport a player to a specified location or #dbref

  • +
  • type thomas = FlowerPot – Turn an annoying player into a flower pot (assuming you have a FlowerPot typeclass ready)

  • +
  • userpassword thomas = fooBarFoo – Change a user’s password

  • +
  • accounts/delete thomas – Delete a player account (not recommended, use ban instead)

  • +
  • server – Show server statistics, such as CPU load, memory usage, and how many objects are cached

  • +
  • time – Gives server uptime, runtime, etc

  • +
  • reload – Reloads the server without disconnecting anyone

  • +
  • reset – Restarts the server, kicking all connections

  • +
  • shutdown – Stops the server cold without it auto-starting again

  • +
  • py – Executes raw Python code, allows for direct inspection of the database and account objects on the fly. For advanced users.

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Change-Message-Per-Receiver.html b/docs/latest/Concepts/Change-Message-Per-Receiver.html new file mode 100644 index 0000000000..26a4d5c1d2 --- /dev/null +++ b/docs/latest/Concepts/Change-Message-Per-Receiver.html @@ -0,0 +1,464 @@ + + + + + + + + + Messages varying per receiver — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Messages varying per receiver

+

Sending messages to everyong in a location is handled by the msg_contents method on +all Objects. It’s most commonly called on rooms.

+
room.msg_contents("Anna walks into the room.")
+
+
+

You can also embed references in the string:

+

+room.msg_contents("{anna} walks into the room.",
+                  from_obj=caller,
+                  mapping={'anna': anna_object})
+
+
+

Use exclude=object_or_list_of_object to skip sending the message one or more targets.

+

The advantage of this is that anna_object.get_display_name(looker) will be called for every onlooker; this allows the {anna} stanza to be different depending on who sees the strings. How this is to work depends on the stance of your game.

+

The stance indicates how your game echoes its messages to the player. Knowing how you want to +handle the stance is important for a text game. There are two main stances that are usually considered, Actor stance and Director stance.

+ + + + + + + + + + + + + + + + + +

Stance

You see

Others in the same location see

Actor stance

You pick up the stone

Anna picks up the stone

Director stance

Anna picks up the stone

Anna picks up the stone

+

It’s not unheard of to mix the two stances - with commands from the game being told in Actor stance while Director stance is used for complex emoting and roleplaying. One should usually try to be consistent however.

+
+

Director Stance

+

While not as common as Actor stance, director stance has the advantage of simplicity, particularly +in roleplaying MUDs where longer roleplaying emotes are used. It is also a pretty simple stance to +implement technically since everyone sees the same text, regardless of viewpoint.

+

Here’s an example of a flavorful text to show the room:

+
Tom picks up the gun, whistling to himself.
+
+
+

Everyone will see this string, both Tom and others. Here’s how to send it to everyone in +the room.

+
text = "Tom picks up the gun, whistling to himself."
+room.msg_contents(text)
+
+
+

One may want to expand on it by making the name Tom be seen differently by different people, +but the English grammar of the sentence does not change. Not only is this pretty easy to do +technically, it’s also easy to write for the player.

+
+
+

Actor Stance

+

This means that the game addresses “you” when it does things. In actor stance, whenever you perform an action, you should get a different message than those observing you doing that action.

+
Tom picks up the gun, whistling to himself.
+
+
+

This is what others should see. The player themselves should see this:

+
You pick up the gun, whistling to yourself.
+
+
+

Not only do you need to map “Tom” to “You” above, there are also grammatical differences - “Tom walks” vs “You walk” and “himself” vs “yourself”. This is a lot more complex to handle. For a developer making simple “You/Tom pick/picks up the stone” messages, you could in principle hand-craft the strings from every view point, but there’s a better way.

+

The msg_contents method helps by parsing the ingoing string with a FuncParser functions with some very specific $inline-functions. The inline funcs basically provides you with a mini-language for building one string that will change appropriately depending on who sees it.

+
text = "$You() $conj(pick) up the gun, whistling to $pron(yourself)."
+room.msg_contents(text, from_obj=caller, mapping={"gun": gun_object})
+
+
+

These are the inline-functions available:

+
    +
  • $You()/$you() - this is a reference to ‘you’ in the text. It will be replaced with “You/you” for +the one sending the text and with the return from caller.get_display_name(looker) for everyone else.

  • +
  • $conj(verb) - this will conjugate the given verb depending on who sees the string (like pick +to picks). Enter the root form of the verb.

  • +
  • $pron(pronoun[,options]) - A pronoun is a word you want to use instead of a proper noun, like +him, herself, its, me, I, their and so on. The options is a space- or comma-separated +set of options to help the system map your pronoun from 1st/2nd person to 3rd person and vice versa. See next section.

  • +
+
+

More on $pron()

+

The $pron() inline func maps between 1st/2nd person (I/you) to 3rd person (he/she etc). In short, +it translates between this table …

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Subject Pronoun

Object Pronoun

Possessive Adjective

Possessive Pronoun

Reflexive Pronoun

1st person

I

me

my

mine

myself

1st person plural

we

us

our

ours

ourselves

2nd person

you

you

your

yours

yourself

2nd person plural

you

you

your

yours

yourselves

+

… to this table (in both directions):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Subject Pronoun

Object Pronoun

Possessive Adjective

Possessive Pronoun

Reflexive Pronoun

3rd person male

he

him

his

his

himself

3rd person female

she

her

her

hers

herself

3rd person neutral

it

it

its

theirs*

itself

3rd person plural

they

them

their

theirs

themselves

+

Some mappings are easy. For example, if you write $pron(yourselves) then the 3rd-person form is always themselves. But because English grammar is the way it is, not all mappings are 1:1. For example, if you write $pron(you), Evennia will not know which 3rd-persion equivalent this should map to - you need to provide more info to help out. This can either be provided as a second space-separated option to $pron or the system will try to figure it out on its own.

+
    +
  • pronoun_type - this is one of the columns in the table and can be set as a $pron option.

    +
      +
    • subject pronoun (aliases subject or sp)

    • +
    • object pronoun (aliases object or op)

    • +
    • possessive adjective (aliases adjective or pa)

    • +
    • possessive pronoun (aliases pronoun or pp).

    • +
    +

    (There is no need to specify reflexive pronouns since they +are all uniquely mapped 1:1). Speciying the pronoun-type is mainly needed when using you, +since the same ‘you’ is used to represent all sorts of things in English grammar. +If not specified and the mapping is not clear, a ‘subject pronoun’ (he/she/it/they) is assumed.

    +
  • +
  • gender - set in $pron option as

    +
      +
    • male, or m

    • +
    • female' or f

    • +
    • neutral, or n

    • +
    • plural, or p (yes plural is considered a ‘gender’ for this purpose).

    • +
    +

    If not set as an option the system will +look for a callable or property .gender on the current from_obj. A callable will be called +with no arguments and is expected to return a string ‘male/female/neutral/plural’. If none +is found, a neutral gender is assumed.

    +
  • +
  • viewpoint- set in $pron option as

    +
      +
    • 1st person (aliases 1st or 1)

    • +
    • 2nd person (aliases 2nd or 2)

    • +
    +

    This is only needed if you want to have 1st person perspective - if +not, 2nd person is assumed wherever the viewpoint is unclear.

    +
  • +
+

$pron() examples:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Input

you see

others see

note

$pron(I, male)

I

he

$pron(I, f)

I

she

$pron(my)

my

its

figures out it’s an possessive adjective, assumes neutral

$pron(you)

you

it

assumes neutral subject pronoun

$pron(you, f)

you

she

female specified, assumes subject pronoun

$pron(you,op f)

you

her

$pron(you,op p)

you

them

$pron(you, f op)

you

her

specified female and objective pronoun

$pron(yourself)

yourself

itself

$pron(its)

your

its

$Pron(its)

Your

Its

Using $Pron always capitalizes

$pron(her)

you

her

3rd person -> 2nd person

$pron(her, 1)

I

her

3rd person -> 1st person

$pron(its, 1st)

my

its

3rd person -> 1st person

+

Note the three last examples - instead of specifying the 2nd person form you can also specify the 3rd-person and do a ‘reverse’ lookup - you will still see the proper 1st/2nd text. So writing $pron(her) instead of $pron(you, op f) gives the same result.

+

The $pron inlinefunc api is found here

+
+
+
+

Referencing other objects

+

There is one more inlinefunc understood by msg_contents. This can be used natively to spruce up +your strings (for both director- and actor stance):

+
    +
  • $Obj(name)/$obj(name) references another entity, which must be supplied +in the mapping keyword argument to msg_contents. The object’s .get_display_name(looker) will be +called and inserted instead. This is essentially the same as using the {anna} marker we used +in the first example at the top of this page, but using $Obj/$obj allows you to easily +control capitalization.

  • +
+

This is used like so:

+
# director stance
+text = "Tom picks up the $obj(gun), whistling to himself"
+
+# actor stance
+text = "$You() $conj(pick) up the $obj(gun), whistling to $pron(yourself)"
+
+room.msg_contents(text, from_obj=caller, mapping={"gun": gun_object})
+
+
+

Depending on your game, Tom may now see himself picking up A rusty old gun, whereas an onlooker with a high gun smith skill may instead see him picking up A rare-make Smith & Wesson model 686 in poor condition" ...

+
+
+

Recog systems and roleplaying

+

The $funcparser inline functions are very powerful for the game developer, but they may +be a bit too much to write for the regular player.

+

The rpsystem contrib implements a full dynamic emote/pose and recognition system with short-descriptions and disguises. It uses director stance with a custom markup language, like /me /gun and /tall man to refer to players and objects in the location. It can be worth checking out for inspiration.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Clickable-Links.html b/docs/latest/Concepts/Clickable-Links.html new file mode 100644 index 0000000000..d7fd048fc3 --- /dev/null +++ b/docs/latest/Concepts/Clickable-Links.html @@ -0,0 +1,195 @@ + + + + + + + + + Clickable links — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ + + + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Colors.html b/docs/latest/Concepts/Colors.html new file mode 100644 index 0000000000..25f013a461 --- /dev/null +++ b/docs/latest/Concepts/Colors.html @@ -0,0 +1,404 @@ + + + + + + + + + Colors — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Colors

+
+

Note that the Documentation does not display colour the way it would look on the screen.

+
+

Color can be a very useful tool for your game. It can be used to increase readability and make your +game more appealing visually.

+

Remember however that, with the exception of the webclient, you generally don’t control the client +used to connect to the game. There is, for example, one special tag meaning “yellow”. But exactly +which hue of yellow is actually displayed on the user’s screen depends on the settings of their +particular mud client. They could even swap the colours around or turn them off altogether if so +desired. Some clients don’t even support color - text games are also played with special reading +equipment by people who are blind or have otherwise diminished eyesight.

+

So a good rule of thumb is to use colour to enhance your game but don’t rely on it to display +critical information. If you are coding the game, you can add functionality to let users disable +colours as they please, as described here.

+

Evennia supports two color standards:

+
    +
  • ANSI - 16 foreground colors + 8 background colors. Widely supported.

  • +
  • Xterm256 - 128 RGB colors, 32 greyscales. Not always supported in old clients.

  • +
+

To see which colours your client support, use the default color command. This will list all +available colours for ANSI and Xterm256 along with the codes you use for them. The +central ansi/xterm256 parser is located in evennia/utils/ansi.py.

+
+

ANSI colours

+

Evennia supports the ANSI standard for text. This is by far the most supported MUD-color standard, available in all but the most ancient mud clients.

+

To colour your text you put special tags in it. Evennia will parse these and convert them to the +correct markup for the client used. If the user’s client/console/display supports ANSI colour, they +will see the text in the specified colour, otherwise the tags will be stripped (uncolored text).

+

For the webclient, Evennia will translate the codes to CSS tags.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Tag

Effect

|n

end all color formatting, including background colors.

|r

bright red foreground color

|g

bright green foreground color

|y

bright yellow foreground color

|b

bright blue foreground color

|m

bright magentaforeground color

|c

bright cyan foreground color

|w

bright white foreground color

|x

bright black (dark grey) foreground color

|R

normal red foreground color

|G

normal green foreground color

|Y

normal yellow foreground color

|B

normal blue foreground color

|M

normal magentaforeground color

|C

normal cyan foreground color

|W

normal white (light grey) foreground color

|X

normal black foreground color

|[#

background colours, e.g. |[c for bright cyan background and |[C a normal cyan background.

|!#

foreground color that inherits brightness from previous tags. Always uppcase, like |!R

|h

make any following foreground ANSI colors bright (no effect on Xterm colors). Use with |!#. Technically, |h|G == |g.

|H

negates the effects of |h, return foreground to normal (no effect on Xterm colors)

|/

line break. Use instead of Python \n when adding strings from in-game.

|-

tab character when adding strings in-game. Can vay per client, so usually better with spaces.

|_

a space. Only needed to avoid auto-cropping at the end of a in-game input

|*

invert the current text/background colours, like a marker. See note below.

+

Here is an example of the tags in action:

+
 |rThis text is bright red.|n This is normal text.
+ |RThis is a dark red text.|n This is normal text.
+ |[rThis text has red background.|n This is normal text.
+ |b|[yThis is bright blue text on yellow background.|n This is normal text.
+
+
+

Note: The ANSI standard does not actually support bright backgrounds like |[r - the standard +only supports “normal” intensity backgrounds. To get around this Evennia implements these as Xterm256 colours behind the scenes. If the client does not support +Xterm256 the ANSI colors will be used instead and there will be no visible difference between using upper- and lower-case background tags.

+

If you want to display an ANSI marker as output text (without having any effect), you need to escape it by preceding its | with another |:

+
say The ||r ANSI marker changes text color to bright red.
+
+
+

This will output the raw |r without any color change. This can also be necessary if you are doing +ansi art that uses | with a letter directly following it.

+

Use the command

+
color ansi
+
+
+

to get a list of all supported ANSI colours and the tags used to produce them.

+

A few additional ANSI codes are supported:

+
+

Caveats of |*

+

The |* tag (inverse video) is an old ANSI standard and should usually not be used for more than to +mark short snippets of text. If combined with other tags it comes with a series of potentially +confusing behaviors:

+
    +
  • The |* tag will only work once in a row:, ie: after using it once it won’t have an effect again +until you declare another tag. This is an example:

    +
    Normal text, |*reversed text|*, still reversed text.
    +
    +
    +

    that is, it will not reverse to normal at the second |*. You need to reset it manually:

    +
    Normal text, |*reversed text|n, normal again.
    +
    +
    +
  • +
  • The |* tag does not take “bright” colors into account:

    +
    |RNormal red, |hnow brightened. |*BG is normal red.
    +
    +
    +
  • +
+

So |* only considers the ‘true’ foreground color, ignoring any highlighting. Think of the bright +state (|h) as something like like <strong> in HTML: it modifies the appearance of a normal +foreground color to match its bright counterpart, without changing its normal color.

+
    +
  • Finally, after a |*, if the previous background was set to a dark color (via |[), |!#) will +actually change the background color instead of the foreground:

    +
    |*reversed text |!R now BG is red.
    +
    +
    +
  • +
+

For a detailed explanation of these caveats, see the [Understanding Color Tags](Understanding-Color- +Tags) tutorial. But most of the time you might be better off to simply avoid |* and mark your text +manually instead.

+
+
+
+

Xterm256 Colours

+ +

The Xterm256 standard is a colour scheme that supports 256 colours for text and/or background. It can be combined freely with ANSI colors (above), but some ANSI tags don’t affect Xterm256 tags.

+

While this offers many more possibilities than traditional ANSI colours, be wary that too many text +colors will be confusing to the eye. Also, not all clients support Xterm256 - these will instead see +the closest equivalent ANSI color. You can mix Xterm256 tags with ANSI tags as you please.

+ + + + + + + + + + + + + + + + + + + + +

Tag

Effect

|###

foreground RGB (red/green/blue), each from 0 to 5.

|[###

background RGB

|=#

a-z foreground greyscale, where a is black and z is white.

|[=#

a-z background greyscale

+

Some examples:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Tag

Effect

|500

bright red

|050

bright green

|005

bright blue

|520

red + a little green = orange

|555

pure white foreground

|230

olive green foreground

|[300

text with a dark red background

|005|[054

dark blue text on a bright cyan background

|=a

greyscale foreground, equal to black

|=m

greyscale foreground, midway between white and black.

|=z

greyscale foreground, equal to white

|[=m

greyscale background

+

Xterm256 don’t use bright/normal intensity like ANSI does; intensity is just varied by increasing/decreasing all RGB values by the same amount.

+

If you have a client that supports Xterm256, you can use

+
color xterm256
+
+
+

to get a table of all the 256 colours and the codes that produce them. If the table looks broken up +into a few blocks of colors, it means Xterm256 is not supported and ANSI are used as a replacement. You can use the options command to see if xterm256 is active for you. This depends on if your client told Evennia what it supports - if not, and you know what your client supports, you may have to activate some features manually.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Concepts-Overview.html b/docs/latest/Concepts/Concepts-Overview.html new file mode 100644 index 0000000000..3b4556eb90 --- /dev/null +++ b/docs/latest/Concepts/Concepts-Overview.html @@ -0,0 +1,244 @@ + + + + + + + + + Core Concepts — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Connection-Styles.html b/docs/latest/Concepts/Connection-Styles.html new file mode 100644 index 0000000000..e991ff2e73 --- /dev/null +++ b/docs/latest/Concepts/Connection-Styles.html @@ -0,0 +1,271 @@ + + + + + + + + + Character connection styles — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Character connection styles

+
> login Foobar password123
+
+
+

Evennia supports multiple ways for players to connect to the game. This allows Evennia to mimic the behavior of various other servers, or open things up for a custom solution.

+
+

Changing the login screen

+

This is done by modifying mygame/server/conf/connection_screens.py and reloading. If you don’t like the default login, there are two contribs to check out as inspiration.

+
    +
  • Email login - require email during install, use email for login.

  • +
  • Menu login - login using several prompts, asking to enter username and password in sequence.

  • +
+
+
+

Customizing the login command

+

When a player connects to the game, it runs the CMD_LOGINSTART system command. By default, this is the CmdUnconnectedLook. This shows the welcome screen. The other commands in the UnloggedinCmdSet are what defines the login experience. So if you want to customise it, you just need to replace/remove those commands.

+ +
# in mygame/commands/mylogin_commands.py
+
+from evennia import syscmdkeys, default_cmds, Command
+
+
+class MyUnloggedinLook(Command):
+
+    # this will now be the first command called when connecting
+    key = syscmdkeys.CMD_LOGINSTART 
+
+    def func(self):
+        # ... 
+
+
+

Next, add this to the right place in the UnloggedinCmdSet:

+
# in mygame/commands/default_cmdsets.py
+
+from commands.mylogin_commands import MyUnloggedinLook
+# ... 
+
+class UnloggedinCmdSet(default_cmds.UnloggedinCmdSet):
+    # ... 
+    def at_cmdset_creation(self):
+        super().at_cmdset_creation
+        self.add(MyUnloggedinLook())
+
+
+

reload and your alternate command will be used. Examine the default commands and you’ll be able to change everything about the login.

+
+
+

Multisession mode and multi-playing

+

The number of sessions possible to connect to a given account at the same time and how it works is given by the MULTISESSION_MODE setting:

+
    +
  • MULTISESSION_MODE=0: One session per account. When connecting with a new session the old one is disconnected. This is the default mode and emulates many classic mud code bases.

    +
    ┌──────┐ │   ┌───────┐    ┌───────┐   ┌─────────┐
    +│Client├─┼──►│Session├───►│Account├──►│Character│
    +└──────┘ │   └───────┘    └───────┘   └─────────┘
    +
    +
    +
  • +
  • MULTISESSION_MODE=1: Many sessions per account, input/output from/to each session is treated the same. For the player this means they can connect to the game from multiple clients and see the same output in all of them. The result of a command given in one client (that is, through one Session) will be returned to all connected Sessions/clients with no distinction.

    +
             │
    +┌──────┐ │   ┌───────┐
    +│Client├─┼──►│Session├──┐
    +└──────┘ │   └───────┘  └──►┌───────┐   ┌─────────┐
    +         │                  │Account├──►│Character│
    +┌──────┐ │   ┌───────┐  ┌──►└───────┘   └─────────┘
    +│Client├─┼──►│Session├──┘
    +└──────┘ │   └───────┘
    +         │
    +
    +
    +
  • +
  • MULTISESSION_MODE=2: Many sessions per account, one character per session. In this mode, puppeting an Object/Character will link the puppet back only to the particular Session doing the puppeting. That is, input from that Session will make use of the CmdSet of that Object/Character and outgoing messages (such as the result of a look) will be passed back only to that puppeting Session. If another Session tries to puppet the same Character, the old Session will automatically un-puppet it. From the player’s perspective, this will mean that they can open separate game clients and play a different Character in each using one game account.

    +
             │                 ┌───────┐
    +┌──────┐ │   ┌───────┐     │Account│    ┌─────────┐
    +│Client├─┼──►│Session├──┐  │       │  ┌►│Character│
    +└──────┘ │   └───────┘  └──┼───────┼──┘ └─────────┘
    +         │                 │       │
    +┌──────┐ │   ┌───────┐  ┌──┼───────┼──┐ ┌─────────┐
    +│Client├─┼──►│Session├──┘  │       │  └►│Character│
    +└──────┘ │   └───────┘     │       │    └─────────┘
    +         │                 └───────┘
    +
    +
    +
  • +
  • MULTISESSION_MODE=3: Many sessions per account and character. This is the full multi-puppeting mode, where multiple sessions may not only connect to the player account but multiple sessions may also puppet a single character at the same time. From the user’s perspective it means one can open multiple client windows, some for controlling different Characters and some that share a Character’s input/output like in mode 1. This mode otherwise works the same as mode 2.

    +
             │                 ┌───────┐
    +┌──────┐ │   ┌───────┐     │Account│    ┌─────────┐
    +│Client├─┼──►│Session├──┐  │       │  ┌►│Character│
    +└──────┘ │   └───────┘  └──┼───────┼──┘ └─────────┘
    +         │                 │       │
    +┌──────┐ │   ┌───────┐  ┌──┼───────┼──┐
    +│Client├─┼──►│Session├──┘  │       │  └►┌─────────┐
    +└──────┘ │   └───────┘     │       │    │Character│
    +         │                 │       │  ┌►└─────────┘
    +┌──────┐ │   ┌───────┐  ┌──┼───────┼──┘             ▼
    +│Client├─┼──►│Session├──┘  │       │
    +└──────┘ │   └───────┘     └───────┘
    +         │
    +
    +
    +
  • +
+
+

Note that even if multiple Sessions puppet one Character, there is only ever one instance of that Character.

+
+

Mode 0 is the default and mimics how many legacy codebases work, especially in the DIKU world. The equivalence of higher modes are often ‘hacked’ into existing servers to allow for players to have multiple characters.

+
MAX_NR_SIMULTANEOUS_PUPPETS = 1
+
+
+

This setting limits how many different puppets your Account can puppet simultaneously. This is used to limit true multiplaying. A value higher than one makes no sense unless MULTISESSION_MODE is also set >1. Set to None for no limit.

+
+
+

Character creation and auto-puppeting

+

When a player first creates an account, Evennia will auto-create a Character puppet of the same name. When the player logs in, they will auto-puppet this Character. This default hides the Account-Character separation from the player and puts them immediately in the game. This default behavior is similar to how it works in many legacy MU servers.

+

To control this behavior, you need to tweak the settings. These are the defaults:

+
AUTO_CREATE_CHARACTER_WITH_ACCOUNT = True
+AUTO_PUPPET_ON_LOGIN = True 
+MAX_NR_CHARACTERS = 1
+
+
+

There is a default charcreate command. This heeds the MAX_NR_CHARACTERS; and if you make your own character-creation command, you should do the same. It needs to be at least 1. Set to None for no limit. See the Beginner Tutorial for ideas on how to make a more advanced character generation system.

+ +

If you choose to not auto-create a character, you will need to provide a character-generation, and there will be no (initial) Character to puppet. In both of these settings, you will initially end up in ooc mode after you login. This is a good place to put a character generation screen/menu (you can e.g. replace the CmdOOCLook to trigger something other than the normal ooc-look).

+

Once you created a Character, if your auto-puppet is set, you will automatically puppet your latest-puppeted Character whenever you login. If not set, you will always start OOC (and should be able to select which Character to puppet).

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Guests.html b/docs/latest/Concepts/Guests.html new file mode 100644 index 0000000000..41161f2e88 --- /dev/null +++ b/docs/latest/Concepts/Guests.html @@ -0,0 +1,147 @@ + + + + + + + + + Guest Logins — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Guest Logins

+

Evennia supports guest logins out of the box. A guest login is an anonymous, low-access account and can be useful if you want users to have a chance to try out your game without committing to creating a real account.

+

Guest accounts are turned off by default. To activate, add this to your game/settings.py file:

+
GUEST_ENABLED = True
+
+
+

Henceforth users can use connect guest (in the default command set) to login with a guest account. You may need to change your Connection Screen to inform them of this possibility. Guest accounts work differently from normal accounts - they are automatically deleted whenever the user logs off or the server resets (but not during a reload). They are literally re- usable throw-away accounts.

+

You can add a few more variables to your settings.py file to customize your guests:

+
    +
  • BASE_GUEST_TYPECLASS - the python-path to the default typeclass for guests. Defaults to "typeclasses.accounts.Guest".

  • +
  • PERMISSION_GUEST_DEFAULT - permission level for guest accounts. Defaults to "Guest", which is the lowest permission level in the hierarchy (below Player).

  • +
  • GUEST_START_LOCATION - the #dbref to the starting location newly logged-in guests should appear at. Defaults to "#2 (Limbo).

  • +
  • GUEST_HOME - guest home locations. Defaults to Limbo as well.

  • +
  • GUEST_LIST - this is a list holding the possible guest names to use when entering the game. The length of this list also sets how many guests may log in at the same time. By default this is a list of nine names from "Guest1" to "Guest9".

  • +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Inline-Functions.html b/docs/latest/Concepts/Inline-Functions.html new file mode 100644 index 0000000000..9c88a2f71a --- /dev/null +++ b/docs/latest/Concepts/Inline-Functions.html @@ -0,0 +1,156 @@ + + + + + + + + + Inline functions — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Inline functions

+ +

Inline functions, also known as funcparser functions are embedded strings on the form

+
$funcname(args, kwargs)
+
+
+

For example

+
> say the answer is $eval(24 * 12)!
+You say, "the answer is 288!"
+
+
+

General processing of outgoing strings is disabled by default. To activate inline-function parsing of outgoing strings, add this to your settings file:

+
FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED=True    
+
+
+

Inline functions are provided by the FuncParser. It is enabled in a few other situations:

+
    +
  • Processing of Prototypes; these ‘prototypefuncs’ allow for prototypes whose values change dynamically upon spawning. For example, you would set {key: '$choice(["Bo", "Anne", "Tom"])' and spawn a random-named character every time.

  • +
  • Processing of strings to the msg_contents method. This allows for sending different messages depending on who will see them.

  • +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Internationalization.html b/docs/latest/Concepts/Internationalization.html new file mode 100644 index 0000000000..a34749f364 --- /dev/null +++ b/docs/latest/Concepts/Internationalization.html @@ -0,0 +1,323 @@ + + + + + + + + + Internationalization — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Internationalization

+

Internationalization (often abbreviated i18n since there are 18 characters +between the first “i” and the last “n” in that word) allows Evennia’s core +server to return texts in other languages than English - without anyone having +to edit the source code.

+

Language-translations are done by volunteers, so support can vary a lot +depending on when a given language was last updated. Below are all languages +(besides English) with some level of support. Generally, any language not +updated after Sept 2022 will be missing some translations.

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Language Code

Language

Last updated

de

German

Dec 2022

es

Spanish

Aug 2019

fr

French

Dec 2022

it

Italian

Oct 2022

ko

Korean (simplified)

Sep 2019

la

Latin

Feb 2021

pl

Polish

Feb 2019

pt

Portugese

Oct 2022

ru

Russian

Apr 2020

sv

Swedish

Sep 2022

zh

Chinese (simplified)

May 2019

+

Language translations are found in the evennia/locale +folder. Read below if you want to help improve an existing translation of +contribute a new one.

+
+

Changing server language

+

Change language by adding the following to your mygame/server/conf/settings.py +file:

+
    USE_I18N = True
+    LANGUAGE_CODE = 'en'
+
+
+
+

Here 'en' (the default English) should be changed to the abbreviation for one +of the supported languages found in locale/ (and in the list above). Restart +the server to activate i18n.

+
+

Important

+

Even for a ‘fully translated’ language you will still see English text +in many places when you start Evennia. This is because we expect you (the +developer) to know English (you are reading this manual after all). So we +translate hard-coded strings that the end player may see - things you +can’t easily change from your mygame/ folder. Outputs from Commands and +Typeclasses are generally not translated, nor are console/log outputs.

+
+ +
+
+

Translating Evennia

+

Translations are found in the core evennia/ library, under +evennia/evennia/locale/. You must make sure to have cloned this repository +from Evennia’s github before you can proceed.

+

If you cannot find your language in evennia/evennia/locale/ it’s because no one +has translated it yet. Alternatively you might have the language but find the +translation bad … You are welcome to help improve the situation!

+

To start a new translation you need to first have cloned the Evennia repository +with GIT and activated a python virtualenv as described on the +Setup Quickstart page.

+

Go to evennia/evennia/ - that is, not your game dir, but inside the evennia/ +repo itself. If you see the locale/ folder you are in the right place. Make +sure your virtualenv is active so the evennia command is available. Then run

+
 evennia makemessages --locale <language-code>
+
+
+

where <language-code> is the two-letter locale code +for the language you want to translate, like ‘sv’ for Swedish or ‘es’ for +Spanish. After a moment it will tell you the language has been processed. For +instance:

+
 evennia makemessages --locale sv
+
+
+

If you started a new language, a new folder for that language will have emerged +in the locale/ folder. Otherwise the system will just have updated the +existing translation with eventual new strings found in the server. Running this +command will not overwrite any existing strings so you can run it as much as you +want.

+

Next head to locale/<language-code>/LC_MESSAGES and edit the **.po file you +find there. You can edit this with a normal text editor but it is easiest if +you use a special po-file editor from the web (search the web for “po editor” +for many free alternatives), for example:

+
- [gtranslator](https://wiki.gnome.org/Apps/Gtranslator)
+- [poeditor](https://poeditor.com/)
+
+
+

The concept of translating is simple, it’s just a matter of taking the english +strings you find in the **.po file and add your language’s translation best +you can. Once you are done, run

+
`evennia compilemessages`
+
+
+

This will compile all languages. Check your language and also check back to your +.po file in case the process updated it - you may need to fill in some missing +header fields and should usually note who did the translation.

+

When you are done, make sure that everyone can benefit from your translation! +Make a PR against Evennia with the updated **.po file. Less ideally (if git is +not your thing) you can also attach it to a new post in our forums.

+
+

Hints on translation

+

Many of the translation strings use { ... } placeholders. This is because they +are to be used in .format() python operations. While you can change the +order of these if it makes more sense in your language, you must not +translate the variables in these formatting tags - Python will look for them!

+
Original: "|G{key} connected|n"
+Swedish:  "|G{key} anslöt|n"
+
+
+

You must also retain line breaks at the start and end of a message, if any +(your po-editor should stop you if you don’t). Try to also end with the same +sentence delimiter (if that makes sense in your language).

+
Original: "\n(Unsuccessfull tried '{path}')."
+Swedish: "\nMisslyckades med att nå '{path}')."
+
+
+

Finally, try to get a feel for who a string is for. If a special technical term +is used it may be more confusing than helpful to translate it, even if it’s +outside of a {...} tag. A mix of English and your language may be clearer +than you forcing some ad-hoc translation for a term everyone usually reads in +English anyway.

+
Original: "\nError loading cmdset: No cmdset class '{classname}' in '{path}'.
+           \n(Traceback was logged {timestamp})"
+Swedish:  "Fel medan cmdset laddades: Ingen cmdset-klass med namn '{classname}' i {path}.
+           \n(Traceback loggades {timestamp})"
+
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Messagepath.html b/docs/latest/Concepts/Messagepath.html new file mode 100644 index 0000000000..e0540c0ccb --- /dev/null +++ b/docs/latest/Concepts/Messagepath.html @@ -0,0 +1,326 @@ + + + + + + + + + The Message path — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

The Message path

+
> look
+
+A Meadow 
+
+This is a beautiful meadow. It is full of flowers.
+
+You see: a flower
+Exits: north, east
+
+
+

When you send a command like look into Evennia - what actually happens? How does that look string end up being handled by the CmdLook class? What happens when we use e.g. caller.msg() to send the message back?

+

Understanding this flow of data - the message path is important in order to understand how Evennia works.

+
+

Ingoing message path

+
            Internet│
+            ┌─────┐ │                                   ┌────────┐
+┌──────┐    │Text │ │  ┌────────────┐    ┌─────────┐    │Command │
+│Client├────┤JSON ├─┼──►commandtuple├────►Inputfunc├────►DB query│
+└──────┘    │etc  │ │  └────────────┘    └─────────┘    │etc     │
+            └─────┘ │                                   └────────┘
+                    │Evennia
+
+
+
+

Incoming command tuples

+

Ingoing data from the client (coming in as raw strings or serialized JSON) is converted by Evennia to a commandtuple. Thesa are the same regardless of what client or connection was used. A commandtuple is a simple tuple with three elements:

+
(commandname, (args), {kwargs})
+
+
+

For the look-command (and anything else written by the player), the text commandtuple is generated:

+
("text", ("look",), {})
+
+
+
+
+

Inputfuncs

+

On the Evennia server side, a list of inputfucs are registered. You can add your own by extending settings.INPUT_FUNC_MODULES.

+
inputfunc_commandname(session, *args, **kwargs)
+
+
+

Here the session represents the unique client connection this is coming from (that is, it’s identifying just who is sending this input).

+

One such inputfunc is named text. For sending a look, it will be called as

+ +
text(session, *("look",), **{})  
+
+
+

What an inputfunc does with this depends. For an Out-of-band instruction, it could fetch the health of a player or tick down some counter.

+ +

For the text inputfunc the Evennia CommandHandler is invoked and the argument is parsed further in order to figure which command was intended.

+

In the example of look, the CmdLook command-class will be invoked. This will retrieve the description of the current location.

+
+
+
+

Outgoing message path

+
            Internet│
+            ┌─────┐ │
+┌──────┐    │Text │ │  ┌──────────┐    ┌────────────┐   ┌─────┐
+│Client◄────┤JSON ├─┼──┤outputfunc◄────┤commandtuple◄───┤msg()│
+└──────┘    │etc  │ │  └──────────┘    └────────────┘   └─────┘
+            └─────┘ │
+                    │Evennia
+
+
+
+

msg to outgoing commandtuple

+

When the inputfunc has finished whatever it is supposed to, the server may or may not decide to return a result (Some types of inputcommands may not expect or require a response at all). The server also often sends outgoing messages without any prior matching ingoing data.

+

Whenever data needs to be sent “out” of Evennia, we must generalize it into a (now outgoing) commandtuple (commandname, (args), {kwargs}). This we do with the msg() method. For convenience, this methods is available on every major entity, such as Object.msg() and Account.msg(). They all link back to Session.msg().

+
msg(text=None, from_obj=None, session=None, options=None, **kwargs)
+
+
+

text is so common that it is given as the default:

+
msg("A meadow\n\nThis is a beautiful meadow...")
+
+
+

This is converted to a commandtuple looking like this:

+
("text", ("A meadow\n\nThis is a beutiful meadow...",) {})
+
+
+

The msg() method allows you to define the commandtuple directly, for whatever outgoing instruction you want to find:

+
msg(current_status=(("healthy", "charged"), {"hp": 12, "mp": 20}))
+
+
+

This will be converted to a commandtuple looking like this:

+
("current_status", ("healthy", "charged"), {"hp": 12, "mp": 20})
+
+
+
+
+

outputfuncs

+ +

Since msg() is aware of which Session to send to, the outgoing commandtuple is always end up pointed at the right client.

+

Each supported Evennia Protocol (Telnet, SSH, Webclient etc) has their own outputfunc, which converts the generic commandtuple into a form that particular protocol understands, such as telnet instructions or JSON.

+

For telnet (no SSL), the look will return over the wire as plain text:

+
A meadow\n\nThis is a beautiful meadow...
+
+
+

When sending to the webclient, the commandtuple is converted as serialized JSON, like this:

+
'["look", ["A meadow\\n\\nThis is a beautiful meadow..."], {}]'
+
+
+

This is then sent to the client over the wire. It’s then up to the client to interpret and handle the data properly.

+
+
+
+

Components along the path

+
+

Ingoing

+
                ┌──────┐                ┌─────────────────────────┐
+                │Client│                │                         │
+                └──┬───┘                │  ┌────────────────────┐ │
+                   │             ┌──────┼─►│ServerSessionHandler│ │
+┌──────────────────┼──────┐      │      │  └───┬────────────────┘ │
+│ Portal           │      │      │      │      │                  │
+│        ┌─────────▼───┐  │    ┌─┴─┐    │  ┌───▼─────────┐        │
+│        │PortalSession│  │    │AMP│    │  │ServerSession│        │
+│        └─────────┬───┘  │    └─┬─┘    │  └───┬─────────┘        │
+│                  │      │      │      │      │                  │
+│ ┌────────────────▼───┐  │      │      │  ┌───▼─────┐            │
+│ │PortalSessionHandler├──┼──────┘      │  │Inputfunc│            │
+│ └────────────────────┘  │             │  └─────────┘            │
+│                         │             │                  Server │
+└─────────────────────────┘             └─────────────────────────┘
+
+
+
    +
  1. Client - sends handshake or commands over the wire. This is received by the Evennia Portal.

  2. +
  3. PortalSession represents one client connection. It understands the communiation protocol used. It converts the protocol-specific input to a generic commandtuple structure (cmdname, (args), {kwargs}).

  4. +
  5. PortalSessionHandler handles all connections. It pickles the commandtuple together with the session-id.

  6. +
  7. Pickled data is sent across the AMP (Asynchronous Message Protocol) connection to the Server part of Evennia.

  8. +
  9. ServerSessionHandler unpickles the commandtuple and matches the session-id to a matching SessionSession.

  10. +
  11. ServerSession represents the session-connection on the Server side. It looks through its registry of Inputfuncs to find a match.

  12. +
  13. The appropriate Inputfunc is called with the args/kwargs included in the commandtuple. Depending on Inputfunc, this could have different effects. For the text inputfunc, it fires the CommandHandler.

  14. +
+
+
+

Outgoing

+
                ┌──────┐                ┌─────────────────────────┐
+                │Client│                │                         │
+                └──▲───┘                │  ┌────────────────────┐ │
+                   │             ┌──────┼──┤ServerSessionHandler│ │
+┌──────────────────┼──────┐      │      │  └───▲────────────────┘ │
+│ Portal           │      │      │      │      │                  │
+│        ┌─────────┴───┐  │    ┌─┴─┐    │  ┌───┴─────────┐        │
+│        │PortalSession│  │    │AMP│    │  │ServerSession│        │
+│        └─────────▲───┘  │    └─┬─┘    │  └───▲─────────┘        │
+│                  │      │      │      │      │                  │
+│ ┌────────────────┴───┐  │      │      │  ┌───┴──────┐           │
+│ │PortalSessionHandler◄──┼──────┘      │  │msg() call│           │
+│ └────────────────────┘  │             │  └──────────┘           │
+│                         │             │                  Server │
+└─────────────────────────┘             └─────────────────────────┘
+
+
+
    +
  1. The msg() method is called

  2. +
  3. ServerSession and in particular ServerSession.msg() is the central point through which all msg() calls are routed in order to send data to that Session.

  4. +
  5. ServerSessionHandler converts the msg input to a proper commandtuple structure (cmdname, (args), {kwargs}). It pickles the commandtuple together with the session-id.

  6. +
  7. Pickled data is sent across across the AMP (Asynchronous Message Protocol) connection to the Portal part of Evennia.

  8. +
  9. PortalSessionHandler unpickles the commandtuple and matches its session id to a matching PortalSession.

  10. +
  11. The PortalSession is now responsible for converting the generic commandtuple to the communication protocol used by that particular connection.

  12. +
  13. The Client receives the data and can act on it.

  14. +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Models.html b/docs/latest/Concepts/Models.html new file mode 100644 index 0000000000..3172cf42cd --- /dev/null +++ b/docs/latest/Concepts/Models.html @@ -0,0 +1,344 @@ + + + + + + + + + New Models — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

New Models

+

Note: This is considered an advanced topic.

+

Evennia offers many convenient ways to store object data, such as via Attributes or Scripts. This is sufficient for most use cases. But if you aim to build a large stand-alone system, trying to squeeze your storage requirements into those may be more complex than you bargain for. Examples may be to store guild data for guild members to be able to change, tracking the flow of money across a game-wide economic system or implement other custom game systems that requires the storage of custom data in a quickly accessible way.

+

Whereas Tags or Scripts can handle many situations, sometimes things may be easier to handle by adding your own database model.

+
+

Overview of database tables

+

SQL-type databases (which is what Evennia supports) are basically highly optimized systems for +retrieving text stored in tables. A table may look like this

+
     id | db_key    | db_typeclass_path          | db_permissions  ...
+    ------------------------------------------------------------------
+     1  |  Griatch  | evennia.DefaultCharacter   | Developers       ...
+     2  |  Rock     | evennia.DefaultObject      | None            ...
+
+
+

Each line is considerably longer in your database. Each column is referred to as a “field” and every row is a separate object. You can check this out for yourself. If you use the default sqlite3 database, go to your game folder and run

+
 evennia dbshell
+
+
+

You will drop into the database shell. While there, try:

+
 sqlite> .help       # view help
+
+ sqlite> .tables     # view all tables
+
+ # show the table field names for objects_objectdb
+ sqlite> .schema objects_objectdb
+
+ # show the first row from the objects_objectdb table
+ sqlite> select * from objects_objectdb limit 1;
+
+ sqlite> .exit
+
+
+

Evennia uses Django, which abstracts away the database SQL manipulation and allows you to search and manipulate your database entirely in Python. Each database table is in Django represented by a class commonly called a model since it describes the look of the table. In Evennia, Objects, Scripts, Channels etc are examples of Django models that we then extend and build on.

+
+
+

Adding a new database table

+

Here is how you add your own database table/models:

+
    +
  1. In Django lingo, we will create a new “application” - a subsystem under the main Evennia program. For this example we’ll call it “myapp”. Run the following (you need to have a working Evennia running before you do this, so make sure you have run the steps in [Setup Quickstart](Getting- Started) first):

    +
     evennia startapp myapp
    + mv myapp world  (linux)
    + move myapp world   (windows)
    +
    +
    +
  2. +
  3. A new folder myapp is created. “myapp” will also be the name (the “app label”) from now on. We move it into the world/ subfolder here, but you could keep it in the root of your mygame if that makes more sense. 1. The myapp folder contains a few empty default files. What we are interested in for now is models.py. In models.py you define your model(s). Each model will be a table in the database. See the next section and don’t continue until you have added the models you want.

  4. +
  5. You now need to tell Evennia that the models of your app should be a part of your database scheme. Add this line to your mygame/server/conf/settings.pyfile (make sure to use the path where you put myapp and don’t forget the comma at the end of the tuple):

    +
    INSTALLED_APPS = INSTALLED_APPS + ("world.myapp", )
    +
    +
    +
  6. +
  7. From mygame/, run

    +
     evennia makemigrations myapp
    + evennia migrate myapp
    +
    +
    +
  8. +
+

This will add your new database table to the database. If you have put your game under version control (if not, you should), don’t forget to git add myapp/* to add all items +to version control.

+
+
+

Defining your models

+

A Django model is the Python representation of a database table. It can be handled like any other Python class. It defines fields on itself, objects of a special type. These become the “columns” of the database table. Finally, you create new instances of the model to add new rows to the database.

+

We won’t describe all aspects of Django models here, for that we refer to the vast Django documentation on the subject. Here is a (very) brief example:

+
from django.db import models
+
+class MyDataStore(models.Model):
+    "A simple model for storing some data"
+    db_key = models.CharField(max_length=80, db_index=True)
+    db_category = models.CharField(max_length=80, null=True, blank=True)
+    db_text = models.TextField(null=True, blank=True)
+    # we need this one if we want to be
+    # able to store this in an Evennia Attribute!
+    db_date_created = models.DateTimeField('date created', editable=False,
+                                            auto_now_add=True, db_index=True)
+
+
+

We create four fields: two character fields of limited length and one text field which has no +maximum length. Finally we create a field containing the current time of us creating this object.

+
+

The db_date_created field, with exactly this name, is required if you want to be able to store instances of your custom model in an Evennia Attribute. It will automatically be set upon creation and can after that not be changed. Having this field will allow you to do e.g. obj.db.myinstance = mydatastore. If you know you’ll never store your model instances in Attributes the db_date_created field is optional.

+
+

You don’t have to start field names with db_, this is an Evennia convention. It’s nevertheless recommended that you do use db_, partly for clarity and consistency with Evennia (if you ever want to share your code) and partly for the case of you later deciding to use Evennia’s +SharedMemoryModel parent down the line.

+

The field keyword db_index creates a database index for this field, which allows quicker lookups, so it’s recommended to put it on fields you know you’ll often use in queries. The null=True and blank=True keywords means that these fields may be left empty or set to the empty string without the database complaining. There are many other field types and keywords to define them, see django docs for more info.

+

Similar to using django-admin you are able to do evennia inspectdb to get an automated listing of model information for an existing database. As is the case with any model generating tool you should only use this as a starting +point for your models.

+
+
+

Referencing existing models and typeclasses

+

You may want to use ForeignKey or ManyToManyField to relate your new model to existing ones.

+

To do this we need to specify the app-path for the root object type we want to store as a string (we must use a string rather than the class directly or you’ll run into problems with models not having been initialized yet).

+
    +
  • "objects.ObjectDB" for all Objects (like exits, rooms, characters etc)

  • +
  • "accounts.AccountDB" for Accounts.

  • +
  • "scripts.ScriptDB" for Scripts.

  • +
  • "comms.ChannelDB" for Channels.

  • +
  • "comms.Msg" for Msg objects.

  • +
  • "help.HelpEntry" for Help Entries.

  • +
+

Here’s an example:

+
from django.db import models
+
+class MySpecial(models.Model):
+    db_character = models.ForeignKey("objects.ObjectDB")
+    db_items = models.ManyToManyField("objects.ObjectDB")
+    db_account = modeles.ForeignKey("accounts.AccountDB")
+
+
+

It may seem counter-intuitive, but this will work correctly:

+
myspecial.db_character = my_character  # a Character instance
+my_character = myspecial.db_character  # still a Character
+
+
+

This works because when the .db_character field is loaded into Python, the entity itself knows that it’s supposed to be a Character and loads itself to that form.

+

The drawback of this is that the database won’t enforce the type of object you store in the relation. This is the price we pay for many of the other advantages of the Typeclass system.

+

While the db_character field fail if you try to store an Account, it will gladly accept any instance of a typeclass that inherits from ObjectDB, such as rooms, exits or other non-character Objects. It’s up to you to validate that what you store is what you expect it to be.

+
+
+

Creating a new model instance

+

To create a new row in your table, you instantiate the model and then call its save() method:

+
     from evennia.myapp import MyDataStore
+
+     new_datastore = MyDataStore(db_key="LargeSword",
+                                 db_category="weapons",
+                                 db_text="This is a huge weapon!")
+     # this is required to actually create the row in the database!
+     new_datastore.save()
+
+
+
+

Note that the db_date_created field of the model is not specified. Its flag at_now_add=True makes sure to set it to the current date when the object is created (it can also not be changed further after creation).

+

When you update an existing object with some new field value, remember that you have to save the object afterwards, otherwise the database will not update:

+
    my_datastore.db_key = "Larger Sword"
+    my_datastore.save()
+
+
+

Evennia’s normal models don’t need to explicitly save, since they are based on SharedMemoryModel rather than the raw django model. This is covered in the next section.

+
+
+

Using the SharedMemoryModel parent

+

Evennia doesn’t base most of its models on the raw django.db.models.Model but on the Evennia base model evennia.utils.idmapper.models.SharedMemoryModel. There are two main reasons for this:

+
    +
  1. Ease of updating fields without having to explicitly call save()

  2. +
  3. On-object memory persistence and database caching

  4. +
+

The first (and least important) point means that as long as you named your fields db_*, Evennia will automatically create field wrappers for them. This happens in the model’s Metaclass so there is no speed penalty for this. The name of the wrapper will be the same name as the field, minus the db_ prefix. So the db_key field will have a wrapper property named key. You can then do:

+
    my_datastore.key = "Larger Sword"
+
+
+

and don’t have to explicitly call save() afterwards. The saving also happens in a more efficient way under the hood, updating only the field rather than the entire model using django optimizations. Note that if you were to manually add the property or method key to your model, this will be used instead of the automatic wrapper and allows you to fully customize access as needed.

+

To explain the second and more important point, consider the following example using the default Django model parent:

+
    shield = MyDataStore.objects.get(db_key="SmallShield")
+    shield.cracked = True # where cracked is not a database field
+
+
+

And then in another function you do

+
    shield = MyDataStore.objects.get(db_key="SmallShield")
+    print(shield.cracked)  # error!
+
+
+

The outcome of that last print statement is undefined! It could maybe randomly work but most likely you will get an AttributeError for not finding the cracked property. The reason is that cracked doesn’t represent an actual field in the database. It was just added at run-time and thus Django don’t care about it. When you retrieve your shield-match later there is no guarantee you will get back the same Python instance of the model where you defined cracked, even if you search for the same database object.

+

Evennia relies heavily on on-model handlers and other dynamically created properties. So rather than using the vanilla Django models, Evennia uses SharedMemoryModel, which levies something called idmapper. The idmapper caches model instances so that we will always get the same instance back after the first lookup of a given object. Using idmapper, the above example would work fine and you could retrieve your cracked property at any time - until you rebooted when all non-persistent data goes.

+

Using the idmapper is both more intuitive and more efficient per object; it leads to a lot less +reading from disk. The drawback is that this system tends to be more memory hungry overall. So if you know that you’ll never need to add new properties to running instances or know that you will create new objects all the time yet rarely access them again (like for a log system), you are probably better off making “plain” Django models rather than using SharedMemoryModel and its idmapper.

+

To use the idmapper and the field-wrapper functionality you just have to have your model classes inherit from evennia.utils.idmapper.models.SharedMemoryModel instead of from the default django.db.models.Model:

+
from evennia.utils.idmapper.models import SharedMemoryModel
+
+class MyDataStore(SharedMemoryModel):
+    # the rest is the same as before, but db_* is important; these will
+    # later be settable as .key, .category, .text ...
+    db_key = models.CharField(max_length=80, db_index=True)
+    db_category = models.CharField(max_length=80, null=True, blank=True)
+    db_text = models.TextField(null=True, blank=True)
+    db_date_created = models.DateTimeField('date created', editable=False,
+                                            auto_now_add=True, db_index=True)
+
+
+
+
+

Searching for your models

+

To search your new custom database table you need to use its database manager to build a query. Note that even if you use SharedMemoryModel as described in the previous section, you have to use the actual field names in the query, not the wrapper name (so db_key and not just key).

+
     from world.myapp import MyDataStore
+
+     # get all datastore objects exactly matching a given key
+     matches = MyDataStore.objects.filter(db_key="Larger Sword")
+     # get all datastore objects with a key containing "sword"
+     # and having the category "weapons" (both ignoring upper/lower case)
+     matches2 = MyDataStore.objects.filter(db_key__icontains="sword",
+                                           db_category__iequals="weapons")
+     # show the matching data (e.g. inside a command)
+     for match in matches2:
+        self.caller.msg(match.db_text)
+
+
+

See the Beginner Tutorial lesson on Django querying for a lot more information about querying the database.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/OOB.html b/docs/latest/Concepts/OOB.html new file mode 100644 index 0000000000..c712019ecf --- /dev/null +++ b/docs/latest/Concepts/OOB.html @@ -0,0 +1,344 @@ + + + + + + + + + Out-of-Band messaging — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Out-of-Band messaging

+

OOB, or Out-Of-Band, means sending data between Evennia and the user’s client without the user +prompting it or necessarily being aware that it’s being passed. Common uses would be to update +client health-bars, handle client button-presses or to display certain tagged text in a different +window pane.

+

If you haven’t, you should be familiar with the Messagepath, which describes how a message enters and leaves Evennia and how along the way, all messages are converted to a generic format called a commandtuple:

+
(commandname, (args), {kwargs})
+
+
+
+

Sending and receiving an OOB message

+

Sending is simple. You just use the normal msg method of the object whose session you want to send to.

+
    caller.msg(commandname=((args, ...), {key:value, ...}))
+
+
+

The keyword becomes the command-name part of the commandtuple and the value its args and kwargs parts. You can also send multiple messages of different commandnames at the same time.

+

A special case is the text call. It’s so common that it’s the default of the msg method. So these are equivalent:

+
    caller.msg("Hello")
+    caller.msg(text="Hello")
+
+
+

You don’t have to specify the full commandtuple definition. So for example, if your particular command only needs kwargs, you can skip the (args) part. Like in the text case you can skip +writing the tuple if there is only one arg … and so on - the input is pretty flexible. If there +are no args at all you need to give the empty tuple msg(cmdname=(,) (giving None would mean a +single argument None).

+
+

Which command-names can I send?

+

This depends on the client and protocol. If you use the Evennia webclient, you can modify it to have it support whatever command-names you like.

+

Many third-party MUD clients support a range of OOB protocols listed below. If a client does not support a particular OOB instruction/command, Evennia will just send the text command to them and quietly drop all other OOB instructions.

+
+

Note that a given message may go to multiple clients with different capabilities. So unless you turn off telnet completely and only rely on the webclient, you should never rely on non-text OOB messages always reaching all targets.

+
+
+
+

Which command-names can I receive

+

This is decided by which Inputfuncs you define. You can extend Evennia’s default as you like, but adding your own functions in a module pointed to by settings.INPUT_FUNC_MODULES.

+
+
+
+

Supported OOB protocols

+

Evennia supports clients using one of the following protocols:

+
+

Telnet

+

By default telnet (and telnet+SSL) supports only the plain text outputcommand. Evennia detects if the Client supports one of two MUD-specific OOB extensions to the standard telnet protocol - GMCP or MSDP. Evennia supports both simultaneously and will switch to the protocol the client uses. If the client supports both, GMCP will be used.

+
+

Note that for Telnet, text has a special status as the “in-band” operation. So the text outputcommand sends the text argument directly over the wire, without going through the OOB translations described below.

+
+
+

Telnet + GMCP

+

GMCP, the Generic Mud Communication Protocol sends data on the form cmdname + JSONdata. Here the cmdname is expected to be on the form “Package.Subpackage”. There could also be additional Sub-sub packages etc. The names of these ‘packages’ and ‘subpackages’ are not that well standardized beyond what individual MUDs or companies have chosen to go with over the years. You can decide on your own package names, but here are what others are using:

+ +

Evennia will translate underscores to . and capitalize to fit the specification. So the outputcommand foo_bar will become a GMCP command-name Foo.Bar. A GMCP command “Foo.Bar” will be come foo_bar. To send a GMCP command that turns into an Evennia inputcommand without an underscore, use the Core package. So Core.Cmdname becomes just cmdname in Evennia and vice versa.

+

On the wire, the commandtuple

+
("cmdname", ("arg",), {}) 
+
+
+

will be sent over the wire as this GMCP telnet instruction

+
IAC SB GMCP "cmdname" "arg" IAC SE
+
+
+

where all the capitalized words are telnet character constants specified in ]evennia/server/portal/telnet_oob. These are parsed/added by the protocol and we don’t include these in the listings below.

+ + + + + + + + + + + + + + + + + + + + + + + +

commandtuple

GMCP-Command

(cmd_name, (), {})

Cmd.Name

(cmd_name, (arg,), {})

Cmd.Name arg

(cmd_na_me, (args,...),{})

Cmd.Na.Me [arg, arg...]

(cmd_name, (), {kwargs})

Cmd.Name {kwargs}

(cmdname, (arg,), {kwargs})

Core.Cmdname [[args],{kwargs}]

+

Since Evennia already supplies default Inputfuncs that don’t match the names expected by the most common GMCP implementations we have a few hard-coded mappings for those:

+ + + + + + + + + + + + + + + + + + + + + + + + + + +

GMCP command name

commandtuple command name

"Core.Hello"

"client_options"

"Core.Supports.Get"

"client_options"

"Core.Commands.Get"

"get_inputfuncs"

"Char.Value.Get"

"get_value"

"Char.Repeat.Update"

"repeat"

"Char.Monitor.Update"

"monitor"

+
+
+

Telnet + MSDP

+

MSDP, the Mud Server Data Protocol, is a competing standard to GMCP. The MSDP protocol page specifies a range of “recommended” available MSDP command names. Evennia does not support those - since MSDP doesn’t specify a special format for its command names (like GMCP does) the client can and should just call the internal Evennia inputfunc by its actual name.

+

MSDP uses Telnet character constants to package various structured data over the wire. MSDP supports strings, arrays (lists) and tables (dicts). These are used to define the cmdname, args and kwargs needed. When sending MSDP for ("cmdname", ("arg",), {}) the resulting MSDP instruction will look like this:

+
IAC SB MSDP VAR cmdname VAL arg IAC SE
+
+
+

The various available MSDP constants like VAR (variable), VAL (value), ARRAYOPEN/ARRAYCLOSE +and TABLEOPEN/TABLECLOSE are specified in evennia/server/portal/telnet_oob.

+ + + + + + + + + + + + + + + + + + + + + + + +

commandtuple

MSDP instruction

(cmdname, (), {})

VAR cmdname VAL

(cmdname, (arg,), {})

VAR cmdname VAL arg

(cmdname, (arg,...),{})

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

+

Observe that VAR ... VAL always identifies cmdnames, so if there are multiple arrays/dicts tagged with the same cmdname they will be appended to the args, kwargs of that inputfunc. Vice-versa, a +different VAR ... VAL (outside a table) will come out as a second, different command input.

+
+
+
+

SSH

+

SSH only supports the text input/outputcommand.

+
+
+

Web client

+

Our web client uses pure JSON structures for all its communication, including text. This maps directly to the Evennia internal output/inputcommand, including eventual empty args/kwargs.

+ + + + + + + + + + + + + + + + + + + + + + + +

commandtuple

Evennia Webclient JSON

(cmdname, (), {})

["cmdname", [], {}]

(cmdname, (arg,), {})

["cmdname", [arg], {}]

(cmdname, (arg,...),{})

["cmdname", [arg, ...], {})

(cmdname, (), {kwargs})

["cmdname", [], {kwargs})

(cmdname, (arg,...), {kwargs})

["cmdname", [arg, ...], {kwargs})

+

Since JSON is native to Javascript, this becomes very easy for the webclient to handle.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Protocols.html b/docs/latest/Concepts/Protocols.html new file mode 100644 index 0000000000..3a87527a43 --- /dev/null +++ b/docs/latest/Concepts/Protocols.html @@ -0,0 +1,326 @@ + + + + + + + + + Protocols — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Protocols

+
            Internet│ Protocol
+            ┌─────┐ │ | 
+┌──────┐    │Text │ │  ┌──────────┐    ┌────────────┐   ┌─────┐
+│Client◄────┤JSON ├─┼──┤outputfunc◄────┤commandtuple◄───┤msg()│
+└──────┘    │etc  │ │  └──────────┘    └────────────┘   └─────┘
+            └─────┘ │
+                    │Evennia
+
+
+

The Protocol describes how Evennia sends and receives data over the wire to the client. Each connection-type (telnet, ssh, webclient etc) has its own protocol. Some protocols may also have variations (such plain-text Telnet vs Telnet SSL).

+

See the Message Path for the bigger picture of how data flows through Evennia.

+

In Evennia, the PortalSession represents the client connection. The session is told to use a particular protocol. When sending data out, the session must provide an “Outputfunc” to convert the generic commandtuple to a form the protocol understands. For ingoing data, the server must also provide suitable Inputfuncs to handle the instructions sent to the server.

+
+

Adding a new Protocol

+

Evennia has a plugin-system that add the protocol as a new “service” to the application.

+

To add a new service of your own (for example your own custom client protocol) to the Portal or Server, expand mygame/server/conf/server_services_plugins and portal_services_plugins.

+

To expand where Evennia looks for plugins, use the following settings:

+
    # add to the Server
+    SERVER_SERVICES_PLUGIN_MODULES.append('server.conf.my_server_plugins')
+    # or, if you want to add to the Portal
+    PORTAL_SERVICES_PLUGIN_MODULES.append('server.conf.my_portal_plugins')
+
+
+
+

When adding a new client connection you’ll most likely only need to add new things to the Portal-plugin files.

+
+

The plugin module must contain a function start_plugin_services(app), where the app arguments refers to the Portal/Server application itself. This is called by the Server or Portal when it starts up. It must contatin all startup code needed.

+

Example:

+
    # mygame/server/conf/portal_services_plugins.py
+
+    # here the new Portal Twisted protocol is defined
+    class MyOwnFactory( ... ):
+       # [...]
+
+    # some configs
+    MYPROC_ENABLED = True # convenient off-flag to avoid having to edit settings all the time
+    MY_PORT = 6666
+
+    def start_plugin_services(portal):
+        "This is called by the Portal during startup"
+         if not MYPROC_ENABLED:
+             return
+         # output to list this with the other services at startup
+         print(f"  myproc: {MY_PORT}")
+
+         # some setup (simple example)
+         factory = MyOwnFactory()
+         my_service = internet.TCPServer(MY_PORT, factory)
+         # all Evennia services must be uniquely named
+         my_service.setName("MyService")
+         # add to the main portal application
+         portal.services.addService(my_service)
+
+
+

Once the module is defined and targeted in settings, just reload the server and your new +protocol/services should start with the others.

+
+

Writing your own Protocol

+
+

Important

+

This is considered an advanced topic.

+
+

Writing a stable communication protocol from scratch is not something we’ll cover here, it’s no trivial task. The good news is that Twisted offers implementations of many common protocols, ready for adapting.

+

Writing a protocol implementation in Twisted usually involves creating a class inheriting from an already existing Twisted protocol class and from evennia.server.session.Session (multiple +inheritance), then overloading the methods that particular protocol uses to link them to the +Evennia-specific inputs.

+

Here’s a example to show the concept:

+
# In module that we'll later add to the system through PORTAL_SERVICE_PLUGIN_MODULES
+
+# pseudo code 
+from twisted.something import TwistedClient
+# this class is used both for Portal- and Server Sessions
+from evennia.server.session import Session 
+
+from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS
+
+class MyCustomClient(TwistedClient, Session): 
+
+    def __init__(self, *args, **kwargs): 
+        super().__init__(*args, **kwargs)
+        self.sessionhandler = PORTAL_SESSIONS
+
+    # these are methods we must know that TwistedClient uses for 
+    # communication. Name and arguments could vary for different Twisted protocols
+    def onOpen(self, *args, **kwargs):
+        # let's say this is called when the client first connects
+
+        # we need to init the session and connect to the sessionhandler. The .factory
+        # is available through the Twisted parents
+
+        client_address = self.getClientAddress()  # get client address somehow
+
+        self.init_session("mycustom_protocol", client_address, self.factory.sessionhandler)
+        self.sessionhandler.connect(self)
+
+    def onClose(self, reason, *args, **kwargs):
+        # called when the client connection is dropped
+        # link to the Evennia equivalent
+        self.disconnect(reason)
+
+    def onMessage(self, indata, *args, **kwargs): 
+        # called with incoming data
+        # convert as needed here        
+        self.data_in(data=indata) 
+
+    def sendMessage(self, outdata, *args, **kwargs):
+        # called to send data out
+        # modify if needed        
+        super().sendMessage(self, outdata, *args, **kwargs)
+
+     # these are Evennia methods. They must all exist and look exactly like this
+     # The above twisted-methods call them and vice-versa. This connects the protocol
+     # the Evennia internals.  
+     
+     def disconnect(self, reason=None): 
+         """
+         Called when connection closes. 
+         This can also be called directly by Evennia when manually closing the connection.
+         Do any cleanups here.
+         """
+         self.sessionhandler.disconnect(self)
+
+     def at_login(self): 
+         """
+         Called when this session authenticates by the server (if applicable)
+         """    
+
+     def data_in(self, **kwargs):
+         """
+         Data going into the server should go through this method. It 
+         should pass data into `sessionhandler.data_in`. THis will be called
+         by the sessionhandler with the data it gets from the approrpriate 
+         send_* method found later in this protocol. 
+         """
+         self.sessionhandler.data_in(self, text=kwargs['data'])
+
+     def data_out(self, **kwargs):
+         """
+         Data going out from the server should go through this method. It should
+         hand off to the protocol's send method, whatever it's called.
+         """
+         # we assume we have a 'text' outputfunc
+         self.onMessage(kwargs['text'])
+
+     # 'outputfuncs' are defined as `send_<outputfunc_name>`. From in-code, they are called 
+     # with `msg(outfunc_name=<data>)`. 
+
+     def send_text(self, txt, *args, **kwargs): 
+         """
+         Send text, used with e.g. `session.msg(text="foo")`
+         """
+         # we make use of the 
+         self.data_out(text=txt)
+
+     def send_default(self, cmdname, *args, **kwargs): 
+         """
+         Handles all outputfuncs without an explicit `send_*` method to handle them.
+         """
+         self.data_out(**{cmdname: str(args)})
+
+
+
+

The principle here is that the Twisted-specific methods are overridden to redirect inputs/outputs to +the Evennia-specific methods.

+
+
+

Sending data out

+

To send data out through this protocol, you’d need to get its Session and then you could e.g.

+
    session.msg(text="foo")
+
+
+

The message will pass through the system such that the sessionhandler will dig out the session and check if it has a send_text method (it has). It will then pass the “foo” into that method, which +in our case means sending “foo” across the network.

+
+
+

Receiving data

+

Just because the protocol is there, does not mean Evennia knows what to do with it. An Inputfunc must exist to receive it. In the case of the text input exemplified above, Evennia alredy handles this input - it will parse it as a Command name followed by its inputs. So handle that you need to simply add a cmdset with commands on your receiving Session (and/or the Object/Character it is puppeting). If not you may need to add your own Inputfunc (see the Inputfunc page for how to do this.

+

These might not be as clear-cut in all protocols, but the principle is there. These four basic components - however they are accessed - links to the Portal Session, which is the actual common interface between the different low-level protocols and Evennia.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Tags-Parsed-By-Evennia.html b/docs/latest/Concepts/Tags-Parsed-By-Evennia.html new file mode 100644 index 0000000000..2b2bd850c3 --- /dev/null +++ b/docs/latest/Concepts/Tags-Parsed-By-Evennia.html @@ -0,0 +1,169 @@ + + + + + + + + + In-text tags parsed by Evennia — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

In-text tags parsed by Evennia

+ +

Evennia will parse various special tags and markers embedded in text and convert it dynamically depending on if the data is going in or out of the server.

+
    +
  • Colors - Using |r, |n etc can be used to mark parts of text with a color. The color will +become ANSI/XTerm256 color tags for Telnet connections and CSS information for the webclient.

    +
    > say Hello, I'm wearing my |rred hat|n today. 
    +
    +
    +
  • +
  • Clickable links - This allows you to provide a text the user can click to execute an +in-game command. This is on the form |lc command |lt text |le. Clickable links are generally only parsed in the outgoing direction, since if users could provde them, they could be a potential security problem. To activate, MXP_ENABLED=True must be added to settings (disabled by default).

    +
    py self.msg("This is a |c look |ltclickable 'look' link|le")
    +
    +
    +
  • +
  • FuncParser callables - These are full-fledged function calls on the form $funcname(args, kwargs) that lead to calls to Python functions. The parser can be run with different available callables in different circumstances. The parser is run on all outgoing messages if settings.FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED=True (disabled by default).

    +
    > say The answer is $eval(40 + 2)! 
    +
    +
    +
  • +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Text-Encodings.html b/docs/latest/Concepts/Text-Encodings.html new file mode 100644 index 0000000000..a97071f32e --- /dev/null +++ b/docs/latest/Concepts/Text-Encodings.html @@ -0,0 +1,198 @@ + + + + + + + + + Text Encodings — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Text Encodings

+

Evennia is a text-based game server. This makes it important to understand how +it actually deals with data in the form of text.

+

Text byte encodings describe how a string of text is actually stored in the +computer - that is, the particular sequence of bytes used to represent the +letters of your particular alphabet. A common encoding used in English-speaking +languages is the ASCII encoding. This describes the letters in the English +alphabet (Aa-Zz) as well as a bunch of special characters. For describing other +character sets (such as that of other languages with other letters than +English), sets with names such as Latin-1, ISO-8859-3 and ARMSCII-8 +are used. There are hundreds of different byte encodings in use around the +world.

+

A string of letters in a byte encoding is represented with the bytes type. +In contrast to the byte encoding is the unicode representation. In Python +this is the str type. The unicode is an internationally agreed-upon table +describing essentially all available letters you could ever want to print. +Everything from English to Chinese alphabets and all in between. So what +Evennia (as well as Python and Django) does is to store everything in Unicode +internally, but then converts the data to one of the encodings whenever +outputting data to the user.

+

An easy memory aid is that bytes are what are sent over the network wire. At +all other times, str (unicode) is used. This means that we must convert +between the two at the points where we send/receive network data.

+

The problem is that when receiving a string of bytes over the network it’s +impossible for Evennia to guess which encoding was used - it’s just a bunch of +bytes! Evennia must know the encoding in order to convert back and from the +correct unicode representation.

+
+

How to customize encodings

+

As long as you stick to the standard ASCII character set (which means the +normal English characters, basically) you should not have to worry much +about this section.

+

If you want to build your game in another language however, or expect your +users to want to use special characters not in ASCII, you need to consider +which encodings you want to support.

+

As mentioned, there are many, many byte-encodings used around the world. It +should be clear at this point that Evennia can’t guess but has to assume or +somehow be told which encoding you want to use to communicate with the server. +Basically the encoding used by your client must be the same encoding used by +the server. This can be customized in two complementary ways.

+
    +
  1. Point users to the default @encoding command or the @options command. +This allows them to themselves set which encoding they (and their client of +choice) uses. Whereas data will remain stored as unicode strings internally in +Evennia, all data received from and sent to this particular player will be +converted to the given format before transmitting.

  2. +
  3. As a back-up, in case the user-set encoding translation is erroneous or +fails in some other way, Evennia will fall back to trying with the names +defined in the settings variable ENCODINGS. This is a list of encoding +names Evennia will try, in order, before giving up and giving an encoding +error message.

  4. +
+

Note that having to try several different encodings every input/output adds +unneccesary overhead. Try to guess the most common encodings you players will +use and make sure these are tried first. The International UTF-8 encoding is +what Evennia assumes by default (and also what Python/Django use normally). See +the Wikipedia article here for more help.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Concepts/Zones.html b/docs/latest/Concepts/Zones.html new file mode 100644 index 0000000000..57b5b31deb --- /dev/null +++ b/docs/latest/Concepts/Zones.html @@ -0,0 +1,179 @@ + + + + + + + + + Zones — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Zones

+

Evennia recommends using Tags to create zones and other groupings.

+

Say you create a room named Meadow in your nice big forest MUD. That’s all nice and dandy, but +what if you, in the other end of that forest want another Meadow? As a game creator, this can +cause all sorts of confusion. For example, teleporting to Meadow will now give you a warning that +there are two Meadow s and you have to select which one. It’s no problem to do that, you just +choose for example to go to 2-meadow, but unless you examine them you couldn’t be sure which of +the two sat in the magical part of the forest and which didn’t.

+

Another issue is if you want to group rooms in geographic regions. Let’s say the “normal” part of +the forest should have separate weather patterns from the magical part. Or maybe a magical +disturbance echoes through all magical-forest rooms. It would then be convenient to be able to +simply find all rooms that are “magical” so you could send messages to them.

+
+

Zones in Evennia

+

Zones try to separate rooms by global location. In our example we would separate the forest into two parts - the magical and the non-magical part. Each have a Meadow and rooms belonging to each part should be easy to retrieve.

+

Many MUD codebases hardcode zones as part of the engine and database. Evennia does no such +distinction.

+

All objects in Evennia can hold any number of Tags. Tags are short labels that you attach to objects. They make it very easy to retrieve groups of objects. An object can have any number of different tags. So let’s attach the relevant tag to our forest:

+
     forestobj.tags.add("magicalforest", category="zone")
+
+
+

You could add this manually, or automatically during creation somehow (you’d need to modify your +dig command for this, most likely). You can also use the default tag command during building:

+
 tag forestobj = magicalforest : zone
+
+
+

Henceforth you can then easily retrieve only objects with a given tag:

+
     import evennia
+     rooms = evennia.search_tag("magicalforest", category="zone")
+
+
+
+
+

Using typeclasses and inheritance for zoning

+

The tagging or aliasing systems above don’t instill any sort of functional difference between a magical forest room and a normal one - they are just arbitrary ways to mark objects for quick retrieval later. Any functional differences must be expressed using Typeclasses.

+

Of course, an alternative way to implement zones themselves is to have all rooms/objects in a zone inherit from a given typeclass parent - and then limit your searches to objects inheriting from that given parent. The effect would be similar but you’d need to expand the search functionality to +properly search the inheritance tree.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-AWSStorage.html b/docs/latest/Contribs/Contrib-AWSStorage.html new file mode 100644 index 0000000000..77ae8eb765 --- /dev/null +++ b/docs/latest/Contribs/Contrib-AWSStorage.html @@ -0,0 +1,365 @@ + + + + + + + + + AWSstorage system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

AWSstorage system

+

Contrib by The Right Honourable Reverend (trhr), 2020

+

This plugin migrates the Web-based portion of Evennia, namely images, +javascript, and other items located inside staticfiles into Amazon AWS (S3) +cloud hosting. Great for those serving media with the game.

+

Files hosted on S3 are “in the cloud,” and while your personal +server may be sufficient for serving multimedia to a minimal number of users, +the perfect use case for this plugin would be:

+
    +
  • Servers supporting heavy web-based traffic (webclient, etc) …

  • +
  • With a sizable number of users …

  • +
  • Where the users are globally distributed …

  • +
  • Where multimedia files are served to users as a part of gameplay

  • +
+

Bottom line - if you’re sending an image to a player every time they traverse a +map, the bandwidth reduction of using this will be substantial. If not, probably +skip this contrib.

+
+

On costs

+

Note that storing and serving files via S3 is not technically free outside of +Amazon’s “free tier” offering, which you may or may not be eligible for; +setting up a vanilla evennia server with this contrib currently requires 1.5MB +of storage space on S3, making the current total cost of running this plugin +~$0.0005 per year. If you have substantial media assets and intend to serve +them to many users, caveat emptor on a total cost of ownership - check AWS’s +pricing structure.

+
+
+

Technical details

+

This is a drop-in replacement that operates deeper than all of Evennia’s code, +so your existing code does not need to change at all to support it.

+

For example, when Evennia (or Django), tries to save a file permanently (say, an +image uploaded by a user), the save (or load) communication follows the path:

+
Evennia -> Django
+Django -> Storage backend
+Storage backend -> file storage location (e.g. hard drive)
+
+
+

django docs

+

This plugin, when enabled, overrides the default storage backend, +which defaults to saving files at mygame/website/, instead, +sending the files to S3 via the storage backend defined herein.

+

There is no way (or need) to directly access or use the functions here with +other contributions or custom code. Simply work how you would normally, Django +will handle the rest.

+
+
+

Installation

+
+

Set up AWS account

+

If you don’t have an AWS S3 account, you should create one at +https://aws.amazon.com/ - documentation for AWS S3 is available at: +https://docs.aws.amazon.com/AmazonS3/latest/gsg/GetStartedWithS3.html

+

Credentials required within the app are AWS IAM Access Key and Secret Keys, +which can be generated/found in the AWS Console.

+

The following example IAM Control Policy Permissions can be added to +the IAM service inside AWS. Documentation for this can be found here: +https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html

+

Note that this is only required if you want to tightly secure the roles +that this plugin has access to.

+
{
+    "Version": "2012-10-17",
+    "Statement": [
+        {
+            "Sid": "evennia",
+            "Effect": "Allow",
+            "Action": [
+                "s3:PutObject",
+                "s3:GetObjectAcl",
+                "s3:GetObject",
+                "s3:ListBucket",
+                "s3:DeleteObject",
+                "s3:PutObjectAcl"
+            ],
+            "Resource": [
+                "arn:aws:s3:::YOUR_BUCKET_NAME/*",
+                "arn:aws:s3:::YOUR_BUCKET_NAME"
+            ]
+        }
+    ],
+    [
+      {
+         "Sid":"evennia",
+         "Effect":"Allow",
+         "Action":[
+            "s3:CreateBucket",
+         ],
+         "Resource":[
+            "arn:aws:s3:::*"
+         ]
+       }
+    ]
+}
+
+
+

Advanced Users: The second IAM statement, CreateBucket, is only needed +for initial installation. You can remove it later, or you can +create the bucket and set the ACL yourself before you continue.

+
+
+
+

Dependencies

+

This package requires the dependency “boto3 >= 1.4.4”, the official +AWS python package. To install, it’s easiest to just install Evennia’s +extra requirements;

+
pip install evennia[extra]
+
+
+

If you installed Evennia with git, you can also

+
    +
  • cd to the root of the Evennia repository.

  • +
  • pip install --upgrade -e .[extra]

  • +
+
+
+

Configure Evennia

+

Customize the variables defined below in secret_settings.py. No further +configuration is needed. Note the three lines that you need to set to your +actual values.

+
# START OF SECRET_SETTINGS.PY COPY/PASTE >>>
+
+AWS_ACCESS_KEY_ID = 'THIS_IS_PROVIDED_BY_AMAZON'
+AWS_SECRET_ACCESS_KEY = 'THIS_IS_PROVIDED_BY_AMAZON'
+AWS_STORAGE_BUCKET_NAME = 'mygame-evennia' # CHANGE ME! I suggest yourgamename-evennia
+
+# The settings below need to go in secret_settings,py as well, but will
+# not need customization unless you want to do something particularly fancy.
+
+AWS_S3_REGION_NAME = 'us-east-1' # N. Virginia
+AWS_S3_OBJECT_PARAMETERS = { 'Expires': 'Thu, 31 Dec 2099 20:00:00 GMT',
+                            'CacheControl': 'max-age=94608000', }
+AWS_DEFAULT_ACL = 'public-read'
+AWS_S3_CUSTOM_DOMAIN = '%s.s3.amazonaws.com' % settings.AWS_BUCKET_NAME
+AWS_AUTO_CREATE_BUCKET = True
+STATICFILES_STORAGE = 'evennia.contrib.base_systems.awsstorage.aws-s3-cdn.S3Boto3Storage'
+
+# <<< END OF SECRET_SETTINGS.PY COPY/PASTE
+
+
+

You may also store these keys as environment variables of the same name. +For advanced configuration, refer to the docs for django-storages.

+

After copying the above, run evennia reboot.

+
+
+

Check that it works

+

Confirm that web assets are being served from S3 by visiting your website, then +checking the source of any image (for instance, the logo). It should read +https://your-bucket-name.s3.amazonaws.com/path/to/file. If so, the system +works and you shouldn’t need to do anything else.

+
+
+

Uninstallation

+

If you haven’t made changes to your static files (uploaded images, etc), +you can simply remove the lines you added to secret_settings.py. If you +have made changes and want to uninstall at a later date, you can export +your files from your S3 bucket and put them in /static/ in the evennia +directory.

+
+
+

License

+

Draws heavily from code provided by django-storages, for which these contributors +are authors:

+

Marty Alchin (S3) +David Larlet (S3) +Arne Brodowski (S3) +Sebastian Serrano (S3) +Andrew McClain (MogileFS) +Rafal Jonca (FTP) +Chris McCormick (S3 with Boto) +Ivanov E. (Database) +Ariel Núñez (packaging) +Wim Leers (SymlinkOrCopy + patches) +Michael Elsdörfer (Overwrite + PEP8 compatibility) +Christian Klein (CouchDB) +Rich Leland (Mosso Cloud Files) +Jason Christa (patches) +Adam Nelson (patches) +Erik CW (S3 encryption) +Axel Gembe (Hash path) +Waldemar Kornewald (MongoDB) +Russell Keith-Magee (Apache LibCloud patches) +Jannis Leidel (S3 and GS with Boto) +Andrei Coman (Azure) +Chris Streeter (S3 with Boto) +Josh Schneier (Fork maintainer, Bugfixes, Py3K) +Anthony Monthe (Dropbox) +EunPyo (Andrew) Hong (Azure) +Michael Barrientos (S3 with Boto3) +piglei (patches) +Matt Braymer-Hayes (S3 with Boto3) +Eirik Martiniussen Sylliaas (Google Cloud Storage native support) +Jody McIntyre (Google Cloud Storage native support) +Stanislav Kaledin (Bug fixes in SFTPStorage) +Filip Vavera (Google Cloud MIME types support) +Max Malysh (Dropbox large file support) +Scott White (Google Cloud updates) +Alex Watt (Google Cloud Storage patch) +Jumpei Yoshimura (S3 docs) +Jon Dufresne +Rodrigo Gadea (Dropbox fixes) +Martey Dodoo +Chris Rink +Shaung Cheng (S3 docs) +Andrew Perry (Bug fixes in SFTPStorage)

+

The repurposed code from django-storages is released under BSD 3-Clause, +same as Evennia, so for detailed licensing, refer to the Evennia license.

+
+
+

Versioning

+

This is confirmed to work for Django 2 and Django 3.

+
+

This document page is generated from evennia/contrib/base_systems/awsstorage/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Auditing.html b/docs/latest/Contribs/Contrib-Auditing.html new file mode 100644 index 0000000000..e16a04c1a3 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Auditing.html @@ -0,0 +1,210 @@ + + + + + + + + + Input/Output Auditing — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Input/Output Auditing

+

Contribution by Johnny, 2017

+

Utility that taps and intercepts all data sent to/from clients and the +server and passes it to a callback of your choosing. This is intended for +quality assurance, post-incident investigations and debugging.

+

Note that this should be used with care since it can obviously be abused. All +data is recorded in cleartext. Please be ethical, and if you are unwilling to +properly deal with the implications of recording user passwords or private +communications, please do not enable this module.

+

Some checks have been implemented to protect the privacy of users.

+

Files included in this module:

+
outputs.py - Example callback methods. This module ships with examples of
+        callbacks that send data as JSON to a file in your game/server/logs
+        dir or to your native Linux syslog daemon. You can of course write
+        your own to do other things like post them to Kafka topics.
+
+server.py - Extends the Evennia ServerSession object to pipe data to the
+        callback upon receipt.
+
+tests.py - Unit tests that check to make sure commands with sensitive
+        arguments are having their PII scrubbed.
+
+
+
+

Installation/Configuration:

+

Deployment is completed by configuring a few settings in server.conf. This line +is required:

+
SERVER_SESSION_CLASS = 'evennia.contrib.utils.auditing.server.AuditedServerSession'
+
+
+

This tells Evennia to use this ServerSession instead of its own. Below are the +other possible options along with the default value that will be used if unset.

+
# Where to send logs? Define the path to a module containing your callback
+# function. It should take a single dict argument as input
+AUDIT_CALLBACK = 'evennia.contrib.utils.auditing.outputs.to_file'
+
+# Log user input? Be ethical about this; it will log all private and
+# public communications between players and/or admins (default: False).
+AUDIT_IN = False
+
+# Log server output? This will result in logging of ALL system
+# messages and ALL broadcasts to connected players, so on a busy game any
+# broadcast to all users will yield a single event for every connected user!
+AUDIT_OUT = False
+
+# The default output is a dict. Do you want to allow key:value pairs with
+# null/blank values? If you're just writing to disk, disabling this saves
+# some disk space, but whether you *want* sparse values or not is more of a
+# consideration if you're shipping logs to a NoSQL/schemaless database.
+# (default: False)
+AUDIT_ALLOW_SPARSE = False
+
+# If you write custom commands that handle sensitive data like passwords,
+# you must write a regular expression to remove that before writing to log.
+# AUDIT_MASKS is a list of dictionaries that define the names of commands
+# and the regexes needed to scrub them.
+# The system already has defaults to filter out sensitive login/creation
+# commands in the default command set. Your list of AUDIT_MASKS will be appended
+# to those defaults.
+#
+# In the regex, the sensitive data itself must be captured in a named group with a
+# label of 'secret' (see the Python docs on the `re` module for more info). For
+# example: `{'authentication': r"^@auth\s+(?P<secret>[\w]+)"}`
+AUDIT_MASKS = []
+
+
+
+

This document page is generated from evennia/contrib/utils/auditing/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Barter.html b/docs/latest/Contribs/Contrib-Barter.html new file mode 100644 index 0000000000..c8def410c6 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Barter.html @@ -0,0 +1,249 @@ + + + + + + + + + Barter system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Barter system

+

Contribution by Griatch, 2012

+

This implements a full barter system - a way for players to safely +trade items between each other in code rather than simple give/get +commands. This increases both safety (at no time will one player have +both goods and payment in-hand) and speed, since agreed goods will +be moved automatically). By just replacing one side with coin objects, +(or a mix of coins and goods), this also works fine for regular money +transactions.

+
+

Installation

+

Just import the CmdsetTrade command into (for example) the default +cmdset. This will make the trade (or barter) command available +in-game.

+
# in mygame/commands/default_cmdsets.py
+
+from evennia.contrib.game_systems import barter  # <---
+
+# ...
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at cmdset_creation(self):
+        # ...
+        self.add(barter.CmdsetTrade)  # <---
+
+
+
+
+
+

Usage

+

In this module, a “barter” is generally referred to as a “trade”.

+

Below is an example of a barter sequence. A and B are the parties. +The A> and B> are their inputs.

+
    +
  1. opening a trade

    +

    A> trade B: Hi, I have a nice extra sword. You wanna trade?

    +

    B sees: +A says: “Hi, I have a nice extra sword. You wanna trade?” +A wants to trade with you. Enter ‘trade A ’ to accept.

    +

    B> trade A: Hm, I could use a good sword …

    +

    A sees: +B says: “Hm, I could use a good sword … +B accepts the trade. Use ‘trade help’ for aid.

    +

    B sees: +You are now trading with A. Use ‘trade help’ for aid.

    +
  2. +
  3. negotiating

    +

    A> offer sword: This is a nice sword. I would need some rations in trade.

    +

    B sees: A says: “This is a nice sword. I would need some rations in trade.” +[A offers Sword of might.]

    +

    B> evaluate sword +B sees: +<Sword’s description and possibly stats>

    +

    B> offer ration: This is a prime ration.

    +

    A sees: +B says: “This is a prime ration.” +[B offers iron ration]

    +

    A> say Hey, this is a nice sword, I need something more for it.

    +

    B sees: +A says: “Hey this is a nice sword, I need something more for it.”

    +

    B> offer sword,apple: Alright. I will also include a magic apple. That’s my last offer.

    +

    A sees: +B says: “Alright, I will also include a magic apple. That’s my last offer.” +[B offers iron ration and magic apple]

    +

    A> accept: You are killing me here, but alright.

    +

    B sees: A says: “You are killing me here, but alright.” +[A accepts your offer. You must now also accept.]

    +

    B> accept: Good, nice making business with you. +You accept the deal. Deal is made and goods changed hands.

    +

    A sees: B says: “Good, nice making business with you.” +B accepts the deal. Deal is made and goods changed hands.

    +
  4. +
+

At this point the trading system is exited and the negotiated items +are automatically exchanged between the parties. In this example B was +the only one changing their offer, but also A could have changed their +offer until the two parties found something they could agree on. The +emotes are optional but useful for RP-heavy worlds.

+
+
+

Technical info

+

The trade is implemented by use of a TradeHandler. This object is a +common place for storing the current status of negotiations. It is +created on the object initiating the trade, and also stored on the +other party once that party agrees to trade. The trade request times +out after a certain time - this is handled by a Script. Once trade +starts, the CmdsetTrade cmdset is initiated on both parties along with +the commands relevant for the trading.

+
+
+

Ideas for NPC bartering

+

This module is primarily intended for trade between two players. But +it can also in principle be used for a player negotiating with an +AI-controlled NPC. If the NPC uses normal commands they can use it +directly – but more efficient is to have the NPC object send its +replies directly through the tradehandler to the player. One may want +to add some functionality to the decline command, so players can +decline specific objects in the NPC offer (decline ) and allow +the AI to maybe offer something else and make it into a proper +barter. Along with an AI that “needs” things or has some sort of +personality in the trading, this can make bartering with NPCs at least +moderately more interesting than just plain ‘buy’.

+
+

This document page is generated from evennia/contrib/game_systems/barter/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Batchprocessor.html b/docs/latest/Contribs/Contrib-Batchprocessor.html new file mode 100644 index 0000000000..c417ecff02 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Batchprocessor.html @@ -0,0 +1,175 @@ + + + + + + + + + Batch processor examples — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Batch processor examples

+

Contibution by Griatch, 2012

+

Simple examples for the batch-processor. The batch processor is used for generating +in-game content from one or more static files. Files can be stored with version +control and then ‘applied’ to the game to create content.

+

There are two batch processor types:

+
    +
  • Batch-cmd processor: A list of #-separated Evennia commands being executed +in sequence, such as create, dig, north etc. When running a script +of this type (filename ending with .ev), the caller of the script will be +the one performing the script’s actions.

  • +
  • Batch-code processor: A full Python script (filename ending with .py that +executes Evennia api calls to build, such as evennia.create_object or +evennia.search_object etc. It can be divided up into comment-separated +chunks so one can execute only parts of the script at a time (in this way it’s +a little different than a normal Python file).

  • +
+
+

Usage

+

To test the two example batch files, you need Developer or superuser +permissions, be logged into the game and run of

+
> batchcommand/interactive tutorials.batchprocessor.example_batch_cmds
+> batchcode/interactive tutorials.batchprocessor.example_batch_code
+
+
+

The /interactive drops you in interactive mode so you can follow along what +the scripts do. Skip it to build it all at once.

+

Both commands produce the same results - they create a red-button object, +a table and a chair. If you run either with the /debug switch, the objects will +be deleted afterwards (for quick tests of syntax that you don’t want to spam new +objects, for example).

+
+

This document page is generated from evennia/contrib/tutorials/batchprocessor/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Bodyfunctions.html b/docs/latest/Contribs/Contrib-Bodyfunctions.html new file mode 100644 index 0000000000..79c90ec870 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Bodyfunctions.html @@ -0,0 +1,157 @@ + + + + + + + + + Script example — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Script example

+

Contribution by Griatch, 2012

+

Example script for testing. This adds a simple timer that has your +character make small verbal observations at irregular intervals.

+

To test, use (in game)

+
> script me = contrib.tutorials.bodyfunctions.BodyFunctions
+
+
+
+

Notes

+

Use scripts me to see the script running on you. Note that even though +the timer ticks down to 0, you will not see an echo every tick (it’s +random if an echo is given on a tick or not).

+
+

This document page is generated from evennia/contrib/tutorials/bodyfunctions/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Buffs.html b/docs/latest/Contribs/Contrib-Buffs.html new file mode 100644 index 0000000000..691f8e0864 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Buffs.html @@ -0,0 +1,594 @@ + + + + + + + + + Buffs — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Buffs

+

Contribution by Tegiminis 2022

+

A buff is a timed object, attached to a game entity. It is capable of modifying values, triggering code, or both. +It is a common design pattern in RPGs, particularly action games.

+

Features:

+
    +
  • BuffHandler: A buff handler to apply to your objects.

  • +
  • BaseBuff: A buff class to extend from to create your own buffs.

  • +
  • BuffableProperty: A sample property class to show how to automatically check modifiers.

  • +
  • CmdBuff: A command which applies buffs.

  • +
  • samplebuffs.py: Some sample buffs to learn from.

  • +
+
+

Quick Start

+

Assign the handler to a property on the object, like so.

+
@lazy_property
+def buffs(self) -> BuffHandler:
+    return BuffHandler(self)
+
+
+

You may then call the handler to add or manipulate buffs like so: object.buffs. See Using the Handler.

+
+

Customization

+

If you want to customize the handler, you can feed the constructor two arguments:

+
    +
  • dbkey: The string you wish to use as the attribute key for the buff database. Defaults to “buffs”. This allows you to keep separate buff pools - for example, “buffs” and “perks”.

  • +
  • autopause: If you want this handler to automatically pause playtime buffs when its owning object is unpuppeted.

  • +
+
+

Note: If you enable autopausing, you MUST initialize the property in your owning object’s +at_init hook. Otherwise, a hot reload can cause playtime buffs to not update properly +on puppet/unpuppet. You have been warned!

+
+

Let’s say you want another handler for an object, perks, which has a separate database and +respects playtime buffs. You’d assign this new property as so:

+
class BuffableObject(Object):
+    @lazy_property
+    def perks(self) -> BuffHandler:
+        return BuffHandler(self, dbkey='perks', autopause=True)
+
+    def at_init(self):
+        self.perks
+
+
+
+
+
+

Using the Handler

+

Here’s how to make use of your new handler.

+
+

Apply a Buff

+

Call the handler’s add method. This requires a class reference, and also contains a number of +optional arguments to customize the buff’s duration, stacks, and so on. You can also store any arbitrary value +in the buff’s cache by passing a dictionary through the to_cache optional argument. This will not overwrite the normal +values on the cache.

+
self.buffs.add(StrengthBuff)                            # A single stack of StrengthBuff with normal duration
+self.buffs.add(DexBuff, stacks=3, duration=60)          # Three stacks of DexBuff, with a duration of 60 seconds
+self.buffs.add(ReflectBuff, to_cache={'reflect': 0.5})  # A single stack of ReflectBuff, with an extra cache value
+
+
+

Two important attributes on the buff are checked when the buff is applied: refresh and unique.

+
    +
  • refresh (default: True) determines if a buff’s timer is refreshed when it is reapplied.

  • +
  • unique (default: True) determines if this buff is unique; that is, only one of it exists on the object.

  • +
+

The combination of these two booleans creates one of three kinds of keys:

+
    +
  • Unique is True, Refresh is True/False: The buff’s default key.

  • +
  • Unique is False, Refresh is True: The default key mixed with the applier’s dbref. This makes the buff “unique-per-player”, so you can refresh through reapplication.

  • +
  • Unique is False, Refresh is False: The default key mixed with a randomized number.

  • +
+
+
+

Get Buffs

+

The handler has several getter methods which return instanced buffs. You won’t need to use these for basic functionality, but if you want to manipulate +buffs after application, they are very useful. The handler’s check/trigger methods utilize some of these getters, while others are just for developer convenience.

+

get(key) is the most basic getter. It returns a single buff instance, or None if the buff doesn’t exist on the handler. It is also the only getter +that returns a single buff instance, rather than a dictionary.

+
+

Note: The handler method has(buff) allows you to check if a matching key (if a string) or buff class (if a class) is present on the handler cache, without actually instantiating the buff. You should use this method for basic “is this buff present?” checks.

+
+

Group getters, listed below, return a dictionary of values in the format {buffkey: instance}. If you want to iterate over all of these buffs, +you should do so via the dict.values() method.

+
    +
  • get_all() returns all buffs on this handler. You can also use the handler.all property.

  • +
  • get_by_type(BuffClass) returns buffs of the specified type.

  • +
  • get_by_stat(stat) returns buffs with a Mod object of the specified stat string in their mods list.

  • +
  • get_by_trigger(string) returns buffs with the specified string in their triggers list.

  • +
  • get_by_source(Object) returns buffs applied by the specified source object.

  • +
  • get_by_cachevalue(key, value) returns buffs with the matching key: value pair in their cache. value is optional.

  • +
+

All group getters besides get_all() can “slice” an existing dictionary through the optional to_filter argument.

+
dict1 = handler.get_by_type(Burned)                     # This finds all "Burned" buffs on the handler
+dict2 = handler.get_by_source(self, to_filter=dict1)    # This filters dict1 to find buffs with the matching source
+
+
+
+

Note: Most of these getters also have an associated handler property. For example, handler.effects returns all buffs that can be triggered, which +is then iterated over by the get_by_trigger method.

+
+
+
+

Remove Buffs

+

There are also a number of remover methods. Generally speaking, these follow the same format as the getters.

+
    +
  • remove(key) removes the buff with the specified key.

  • +
  • clear() removes all buffs.

  • +
  • remove_by_type(BuffClass) removes buffs of the specified type.

  • +
  • remove_by_stat(stat) removes buffs with a Mod object of the specified stat string in their mods list.

  • +
  • remove_by_trigger(string) removes buffs with the specified string in their triggers list.

  • +
  • remove_by_source(Object) removes buffs applied by the specified source

  • +
  • remove_by_cachevalue(key, value) removes buffs with the matching key: value pair in their cache. value is optional.

  • +
+

You can also remove a buff by calling the instance’s remove helper method. You can do this on the dictionaries returned by the +getters listed above.

+
to_remove = handler.get_by_trigger(trigger)     # Finds all buffs with the specified trigger
+for buff in to_remove.values():                 # Removes all buffs in the to_remove dictionary via helper methods
+    buff.remove()   
+
+
+
+
+

Check Modifiers

+

Call the handler check(value, stat) method when you want to see the modified value. +This will return the value, modified by any relevant buffs on the handler’s owner (identified by +the stat string).

+

For example, let’s say you want to modify how much damage you take. That might look something like this:

+
# The method we call to damage ourselves
+def take_damage(self, source, damage):
+    _damage = self.buffs.check(damage, 'taken_damage')
+    self.db.health -= _damage
+
+
+

This method calls the at_pre_check and at_post_check methods at the relevant points in the process. You can use to this make +buffs that are reactive to being checked; for example, removing themselves, altering their values, or interacting with the game state.

+
+

Note: You can also trigger relevant buffs at the same time as you check them by ensuring the optional argument trigger is True in the check method.

+
+

Modifiers are calculated additively - that is, all modifiers of the same type are added together before being applied. They are then +applied through the following formula.

+
(base + total_add) / max(1, 1.0 + total_div) * max(0, 1.0 + total_mult)
+
+
+
+

Multiplicative Buffs (Advanced)

+

Multiply/divide modifiers in this buff system are additive by default. This means that two +50% modifiers will equal a +100% modifier. But what if you want to apply mods multiplicatively?

+

First, you should carefully consider if you truly want multiplicative modifiers. Here’s some things to consider.

+
    +
  • They are unintuitive to the average user, as two +50% damage buffs equal +125% instead of +100%.

  • +
  • They lead to “power explosion”, where stacking buffs in the right way can turn characters into unstoppable forces

  • +
+

Doing purely-additive multipliers allows you to better control the balance of your game. Conversely, doing multiplicative multipliers enables very fun build-crafting where smart usage of buffs and skills can turn you into a one-shot powerhouse. Each has its place.

+

The best design practice for multiplicative buffs is to divide your multipliers into “tiers”, where each tier is applied separately. You can easily do this with multiple check calls.

+
damage = damage
+damage = handler.check(damage, 'damage')
+damage = handler.check(damage, 'empower')
+damage = handler.check(damage, 'radiant')
+damage = handler.check(damage, 'overpower')
+
+
+
+
+

Buff Strength Priority (Advanced)

+

Sometimes you only want to apply the strongest modifier to a stat. This is supported by the optional strongest bool arg in the handler’s check method

+
def take_damage(self, source, damage):
+    _damage = self.buffs.check(damage, 'taken_damage', strongest=True)
+    self.db.health -= _damage
+
+
+
+
+
+

Trigger Buffs

+

Call the handler’s trigger(string) method when you want an event call. This will call the at_trigger hook method on all buffs with the relevant trigger string.

+

For example, let’s say you want to trigger a buff to “detonate” when you hit your target with an attack. +You’d write a buff that might look like this:

+
class Detonate(BaseBuff):
+    ...
+    triggers = ['take_damage']
+    def at_trigger(self, trigger, *args, **kwargs)
+        self.owner.take_damage(100)
+        self.remove()
+
+
+

And then call handler.trigger('take_damage') in the method you use to take damage.

+
+

Note You could also do this through mods and at_post_check if you like, depending on how to want to add the damage.

+
+
+
+

Ticking

+

Ticking buffs are slightly special. They are similar to trigger buffs in that they run code, but instead of +doing so on an event trigger, they do so on a periodic tick. A common use case for a buff like this is a poison, +or a heal over time.

+
class Poison(BaseBuff):
+    ...
+    tickrate = 5
+    def at_tick(self, initial=True, *args, **kwargs):
+        _dmg = self.dmg * self.stacks
+        if not initial:
+            self.owner.location.msg_contents(
+                "Poison courses through {actor}'s body, dealing {damage} damage.".format(
+                    actor=self.owner.named, damage=_dmg
+                )
+            )
+
+
+

To make a buff ticking, ensure the tickrate is 1 or higher, and it has code in its at_tick +method. Once you add it to the handler, it starts ticking!

+
+

Note: Ticking buffs always tick on initial application, when initial is True. If you don’t want your hook to fire at that time, +make sure to check the value of initial in your at_tick method.

+
+
+
+

Context

+

Every important handler method optionally accepts a context dictionary.

+

Context is an important concept for this handler. Every method which checks, triggers, or ticks a buff passes this +dictionary (default: empty) to the buff hook methods as keyword arguments (**kwargs). It is used for nothing else. This allows you to make those +methods “event-aware” by storing relevant data in the dictionary you feed to the method.

+

For example, let’s say you want a “thorns” buff which damages enemies that attack you. Let’s take our take_damage method +and add a context to the mix.

+
def take_damage(attacker, damage):
+    context = {'attacker': attacker, 'damage': damage}
+    _damage = self.buffs.check(damage, 'taken_damage', context=context)
+    self.buffs.trigger('taken_damage', context=context)
+    self.db.health -= _damage
+
+
+

Now we use the values that context passes to the buff kwargs to customize our logic.

+
class ThornsBuff(BaseBuff):
+    ...
+    triggers = ['taken_damage']
+    # This is the hook method on our thorns buff
+    def at_trigger(self, trigger, attacker=None, damage=0, **kwargs):
+        if not attacker: 
+            return
+        attacker.db.health -= damage * 0.2
+
+
+

Apply the buff, take damage, and watch the thorns buff do its work!

+
+
+

Viewing

+

There are two helper methods on the handler that allow you to get useful buff information back.

+
    +
  • view: Returns a dictionary of tuples in the format {buffkey: (buff.name, buff.flavor)}. Finds all buffs by default, but optionally accepts a dictionary of buffs to filter as well. Useful for basic buff readouts.

  • +
  • view_modifiers(stat): Returns a nested dictionary of information on modifiers that affect the specified stat. The first layer is the modifier type (add/mult/div) and the second layer is the value type (total/strongest). Does not return the buffs that cause these modifiers, just the modifiers themselves (akin to using handler.check but without actually modifying a value). Useful for stat sheets.

  • +
+

You can also create your own custom viewing methods through the various handler getters, which will always return the entire buff object.

+
+
+
+

Creating New Buffs

+

Creating a new buff is very easy: extend BaseBuff into a new class, and fill in all the relevant buff details. +However, there are a lot of individual moving parts to a buff. Here’s a step-through of the important stuff.

+
+

Basics

+

Regardless of any other functionality, all buffs have the following class attributes:

+
    +
  • They have customizable key, name, and flavor strings.

  • +
  • They have a duration (float), and automatically clean-up at the end. Use -1 for infinite duration, and 0 to clean-up immediately. (default: -1)

  • +
  • They have a tickrate (float), and automatically tick if it is greater than 1 (default: 0)

  • +
  • They can stack, if maxstacks (int) is not equal to 1. If it’s 0, the buff stacks forever. (default: 1)

  • +
  • They can be unique (bool), which determines if they have a unique namespace or not. (default: True)

  • +
  • They can refresh (bool), which resets the duration when stacked or reapplied. (default: True)

  • +
  • They can be playtime (bool) buffs, where duration only counts down during active play. (default: False)

  • +
+

Buffs also have a few useful properties:

+
    +
  • owner: The object this buff is attached to

  • +
  • ticknum: How many ticks the buff has gone through

  • +
  • timeleft: How much time is remaining on the buff

  • +
  • ticking/stacking: If this buff ticks/stacks (checks tickrate and maxstacks)

  • +
+
+

Buff Cache (Advanced)

+

Buffs always store some useful mutable information about themselves in the cache (what is stored on the owning object’s database attribute). A buff’s cache corresponds to {buffkey: buffcache}, where buffcache is a dictionary containing at least the information below:

+
    +
  • ref (class): The buff class path we use to construct the buff.

  • +
  • start (float): The timestamp of when the buff was applied.

  • +
  • source (Object): If specified; this allows you to track who or what applied the buff.

  • +
  • prevtick (float): The timestamp of the previous tick.

  • +
  • duration (float): The cached duration. This can vary from the class duration, depending on if the duration has been modified (paused, extended, shortened, etc).

  • +
  • tickrate (float): The buff’s tick rate. Cannot go below 0. Altering the tickrate on an applied buff will not cause it to start ticking if it wasn’t ticking before. (pause and unpause to start/stop ticking on existing buffs)

  • +
  • stacks (int): How many stacks they have.

  • +
  • paused (bool): Paused buffs do not clean up, modify values, tick, or fire any hook methods.

  • +
+

Sometimes you will want to dynamically update a buff’s cache at runtime, such as changing a tickrate in a hook method, or altering a buff’s duration. +You can do so by using the interface buff.cachekey. As long as the attribute name matches a key in the cache dictionary, it will update the stored +cache with the new value.

+

If there is no matching key, it will do nothing. If you wish to add a new key to the cache, you must use the buff.update_cache(dict) method, +which will properly update the cache (including adding new keys) using the dictionary provided.

+
+

Example: You want to increase a buff’s duration by 30 seconds. You use buff.duration += 30. This new duration is now reflected on both the instance and the cache.

+
+

The buff cache can also store arbitrary information. To do so, pass a dictionary through the handler add method (handler.add(BuffClass, to_cache=dict)), +set the cache dictionary attribute on your buff class, or use the aforementioned buff.update_cache(dict) method.

+
+

Example: You store damage as a value in the buff cache and use it for your poison buff. You want to increase it over time, so you use buff.damage += 1 in the tick method.

+
+
+
+
+

Modifiers

+

Mods are stored in the mods list attribute. Buffs which have one or more Mod objects in them can modify stats. You can use the handler method to check all +mods of a specific stat string and apply their modifications to the value; however, you are encouraged to use check in a getter/setter, for easy access.

+

Mod objects consist of only four values, assigned by the constructor in this order:

+
    +
  • stat: The stat you want to modify. When check is called, this string is used to find all the mods that are to be collected.

  • +
  • mod: The modifier. Defaults are add (addition/subtraction), mult (multiply), and div (divide). Modifiers are calculated additively (see _calculate_mods for more)

  • +
  • value: How much value the modifier gives regardless of stacks

  • +
  • perstack: How much value the modifier grants per stack, INCLUDING the first. (default: 0)

  • +
+

The most basic way to add a Mod to a buff is to do so in the buff class definition, like this:

+
class DamageBuff(BaseBuff):
+    mods = [Mod('damage', 'add', 10)]
+
+
+

No mods applied to the value are permanent in any way. All calculations are done at runtime, and the mod values are never stored +anywhere except on the buff in question. In other words: you don’t need to track the origin of particular stat mods, and you will +never permanently change a stat modified by a buff. To remove the modification, simply remove the buff from the object.

+
+

Note: You can add your own modifier types by overloading the _calculate_mods method, which contains the basic modifier application logic.

+
+
+

Generating Mods (Advanced)

+

An advanced way to do mods is to generate them when the buff is initialized. This lets you create mods on the fly that are reactive to the game state.

+
class GeneratedStatBuff(BaseBuff):
+    ...
+    def at_init(self, *args, **kwargs) -> None:
+        # Finds our "modgen" cache value, and generates a mod from it
+        modgen = list(self.cache.get("modgen"))
+        if modgen:
+            self.mods = [Mod(*modgen)]
+
+
+
+
+
+

Triggers

+

Buffs which have one or more strings in the triggers attribute can be triggered by events.

+

When the handler’s trigger method is called, it searches all buffs on the handler for any with a matchingtrigger, +then calls their at_trigger hooks. Buffs can have multiple triggers, and you can tell which trigger was used by +the trigger argument in the hook.

+
class AmplifyBuff(BaseBuff):
+    triggers = ['damage', 'heal'] 
+
+    def at_trigger(self, trigger, **kwargs):
+        if trigger == 'damage': print('Damage trigger called!')
+        if trigger == 'heal': print('Heal trigger called!')
+
+
+
+
+

Ticking

+

A buff which ticks isn’t much different than one which triggers. You’re still executing arbitrary hooks on +the buff class. To tick, the buff must have a tickrate of 1 or higher.

+
class Poison(BaseBuff):
+    ...
+    # this buff will tick 6 times between application and cleanup.
+    duration = 30
+    tickrate = 5
+    def at_tick(self, initial, **kwargs):
+        self.owner.take_damage(10)
+
+
+
+

Note: The buff always ticks once when applied. For this first tick only, initial will be True in the at_tick hook method. initial will be False on subsequent ticks.

+
+

Ticks utilize a persistent delay, so they should be pickleable. As long as you are not adding new properties to your buff class, this shouldn’t be a concern. +If you are adding new properties, try to ensure they do not end up with a circular code path to their object or handler, as this will cause pickling errors.

+
+
+

Extras

+

Buffs have a grab-bag of extra functionality to let you add complexity to your designs.

+
+

Conditionals

+

You can restrict whether or not the buff will check, trigger, or tick through defining the conditional hook. As long +as it returns a “truthy” value, the buff will apply itself. This is useful for making buffs dependent on game state - for +example, if you want a buff that makes the player take more damage when they are on fire:

+
class FireSick(BaseBuff):
+    ...
+    def conditional(self, *args, **kwargs):
+        if self.owner.buffs.has(FireBuff): 
+            return True
+        return False
+
+
+

Conditionals for check/trigger are checked when the buffs are gathered by the handler methods for the respective operations. Tick +conditionals are checked each tick.

+
+
+

Helper Methods

+

Buff instances have a number of helper methods.

+
    +
  • remove/dispel: Allows you to remove or dispel the buff. Calls at_remove/at_dispel, depending on optional arguments.

  • +
  • pause/unpause: Pauses and unpauses the buff. Calls at_pause/at_unpause.

  • +
  • reset: Resets the buff’s start to the current time; same as “refreshing” it.

  • +
  • alter_cache: Updates the buff’s cache with the {key:value} pairs in the provided dictionary. Can overwrite default values, so be careful!

  • +
+
+
+

Playtime Duration

+

If your handler has autopause enabled, any buffs with truthy playtime value will automatically pause +and unpause when the object the handler is attached to is puppetted or unpuppetted. This even works with ticking buffs, +although if you have less than 1 second of tick duration remaining, it will round up to 1s.

+
+

Note: If you want more control over this process, you can comment out the signal subscriptions on the handler and move the autopause logic +to your object’s at_pre/post_puppet/unpuppet hooks.

+
+
+

This document page is generated from evennia/contrib/rpg/buffs/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Building-Menu.html b/docs/latest/Contribs/Contrib-Building-Menu.html new file mode 100644 index 0000000000..b957df4f5d --- /dev/null +++ b/docs/latest/Contribs/Contrib-Building-Menu.html @@ -0,0 +1,1443 @@ + + + + + + + + + Building menu — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Building menu

+

Contrib by vincent-lg, 2018

+

Building menus are in-game menus, not unlike EvMenu though using a +different approach. Building menus have been specifically designed to edit +information as a builder. Creating a building menu in a command allows +builders quick-editing of a given object, like a room. If you follow the +steps to add the contrib, you will have access to an edit command +that will edit any default object, offering to change its key and description.

+
+

Install

+
    +
  1. Import the GenericBuildingCmd class from this contrib in your +mygame/commands/default_cmdset.py file:

    +
    from evennia.contrib.base_systems.building_menu import GenericBuildingCmd
    +
    +
    +
  2. +
  3. Below, add the command in the CharacterCmdSet:

    +
    # ... These lines should exist in the file
    +class CharacterCmdSet(default_cmds.CharacterCmdSet):
    +    key = "DefaultCharacter"
    +
    +    def at_cmdset_creation(self):
    +        super().at_cmdset_creation()
    +        # ... add the line below
    +        self.add(GenericBuildingCmd())
    +
    +
    +
  4. +
+
+
+

Basic Usage

+

The edit command will allow you to edit any object. You will need to +specify the object name or ID as an argument. For instance: edit here +will edit the current room. However, building menus can perform much more +than this very simple example, read on for more details.

+

Building menus can be set to edit about anything. Here is an example of +output you could obtain when editing the room:

+
 Editing the room: Limbo(#2)
+
+ [T]itle: the limbo room
+ [D]escription
+    This is the limbo room.  You can easily change this default description,
+    either by using the |y@desc/edit|n command, or simply by entering this
+    menu (enter |yd|n).
+ [E]xits:
+     north to A parking(#4)
+ [Q]uit this menu
+
+
+

From there, you can open the title choice by pressing t. You can then +change the room title by simply entering text, and go back to the +main menu entering @ (all this is customizable). Press q to quit this menu.

+

The first thing to do is to create a new module and place a class +inheriting from BuildingMenu in it.

+
from evennia.contrib.base_systems.building_menu import BuildingMenu
+
+class RoomBuildingMenu(BuildingMenu):
+    # ...
+
+
+
+

Next, override the init method (not __init__!). You can add +choices (like the title, description, and exits choices as seen above) by using +the add_choice method.

+
class RoomBuildingMenu(BuildingMenu):
+    def init(self, room):
+        self.add_choice("title", "t", attr="key")
+
+
+
+

That will create the first choice, the title choice. If one opens your menu +and enter t, she will be in the title choice. She can change the title +(it will write in the room’s key attribute) and then go back to the +main menu using @.

+

add_choice has a lot of arguments and offers a great deal of +flexibility. The most useful ones is probably the usage of callbacks, +as you can set almost any argument in add_choice to be a callback, a +function that you have defined above in your module. This function will be +called when the menu element is triggered.

+

Notice that in order to edit a description, the best method to call isn’t +add_choice, but add_choice_edit. This is a convenient shortcut +which is available to quickly open an EvEditor when entering this choice +and going back to the menu when the editor closes.

+
class RoomBuildingMenu(BuildingMenu):
+    def init(self, room):
+        self.add_choice("title", "t", attr="key")
+        self.add_choice_edit("description", key="d", attr="db.desc")
+
+
+
+

When you wish to create a building menu, you just need to import your +class, create it specifying your intended caller and object to edit, +then call open:

+
from <wherever> import RoomBuildingMenu
+
+class CmdEdit(Command):
+
+    key = "redit"
+
+    def func(self):
+        menu = RoomBuildingMenu(self.caller, self.caller.location)
+        menu.open()
+
+
+
+
+
+

A simple menu example

+

Before diving in, there are some things to point out:

+
    +
  • Building menus work on an object. This object will be edited by manipulations in the menu. So +you can create a menu to add/edit a room, an exit, a character and so on.

  • +
  • Building menus are arranged in layers of choices. A choice gives access to an option or to a sub- +menu. Choices are linked to commands (usually very short). For instance, in the example shown +below, to edit the room key, after opening the building menu, you can type k. That will lead you +to the key choice where you can enter a new key for the room. Then you can enter @ to leave this +choice and go back to the entire menu. (All of this can be changed).

  • +
  • To open the menu, you will need something like a command. This contrib offers a basic command for +demonstration, but we will override it in this example, using the same code with more flexibility.

  • +
+

So let’s add a very basic example to begin with.

+
+

A generic editing command

+

Let’s begin by adding a new command. You could add or edit the following file (there’s no trick +here, feel free to organize the code differently):

+
# file: commands/building.py
+from evennia.contrib.building_menu import BuildingMenu
+from commands.command import Command
+
+class EditCmd(Command):
+
+    """
+    Editing command.
+
+    Usage:
+      @edit [object]
+
+    Open a building menu to edit the specified object.  This menu allows to
+    specific information about this object.
+
+    Examples:
+      @edit here
+      @edit self
+      @edit #142
+
+    """
+
+    key = "@edit"
+    locks = "cmd:id(1) or perm(Builders)"
+    help_category = "Building"
+
+    def func(self):
+        if not self.args.strip():
+            self.msg("|rYou should provide an argument to this function: the object to edit.|n")
+            return
+
+        obj = self.caller.search(self.args.strip(), global_search=True)
+        if not obj:
+            return
+
+        if obj.typename == "Room":
+            Menu = RoomBuildingMenu
+        else:
+            obj_name = obj.get_display_name(self.caller)
+            self.msg(f"|rThe object {obj_name} cannot be edited.|n")
+            return
+
+        menu = Menu(self.caller, obj)
+        menu.open()
+
+
+

This command is rather simple in itself:

+
    +
  1. It has a key @edit and a lock to only allow builders to use it.

  2. +
  3. In its func method, it begins by checking the arguments, returning an error if no argument is +specified.

  4. +
  5. It then searches for the given argument. We search globally. The search method used in this +way will return the found object or None. It will also send the error message to the caller if +necessary.

  6. +
  7. Assuming we have found an object, we check the object typename. This will be used later when +we want to display several building menus. For the time being, we only handle Room. If the +caller specified something else, we’ll display an error.

  8. +
  9. Assuming this object is a Room, we have defined a Menu object containing the class of our +building menu. We build this class (creating an instance), giving it the caller and the object to +edit.

  10. +
  11. We then open the building menu, using the open method.

  12. +
+

The end might sound a bit surprising at first glance. But the process is still very simple: we +create an instance of our building menu and call its open method. Nothing more.

+
+

Where is our building menu?

+
+

If you go ahead and add this command and test it, you’ll get an error. We haven’t defined +RoomBuildingMenu yet.

+

To add this command, edit commands/default_cmdsets.py. Import our command, adding an import line +at the top of the file:

+
"""
+...
+"""
+
+from evennia import default_cmds
+
+# The following line is to be added
+from commands.building import EditCmd
+
+
+

And in the class below (CharacterCmdSet), add the last line of this code:

+
class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    """
+    The `CharacterCmdSet` contains general in-game commands like `look`,
+    `get`, etc available on in-game Character objects. It is merged with
+    the `AccountCmdSet` when an Account puppets a Character.
+    """
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+        """
+        Populates the cmdset
+        """
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones.
+        #
+        self.add(EditCmd())
+
+
+
+
+

Our first menu

+

So far, we can’t use our building menu. Our @edit command will throw an error. We have to define +the RoomBuildingMenu class. Open the commands/building.py file and add to the end of the file:

+
# ... at the end of commands/building.py
+# Our building menu
+
+class RoomBuildingMenu(BuildingMenu):
+
+    """
+    Building menu to edit a room.
+
+    For the time being, we have only one choice: key, to edit the room key.
+
+    """
+
+    def init(self, room):
+        self.add_choice("key", "k", attr="key")
+
+
+

Save these changes, reload your game. You can now use the @edit command. Here’s what we get +(notice that the commands we enter into the game are prefixed with > , though this prefix will +probably not appear in your MUD client):

+
> look
+Limbo(#2)
+Welcome to your new Evennia-based game! Visit https://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 @batchcommand tutorial_world.build.
+
+> @edit here
+Building menu: Limbo
+
+ [K]ey: Limbo
+ [Q]uit the menu
+
+> q
+Closing the building menu.
+
+> @edit here
+Building menu: Limbo
+
+ [K]ey: Limbo
+ [Q]uit the menu
+
+> k
+-------------------------------------------------------------------------------
+key for Limbo(#2)
+
+You can change this value simply by entering it.
+
+Use @ to go back to the main menu.
+
+Current value: Limbo
+
+> A beautiful meadow
+-------------------------------------------------------------------------------
+
+key for A beautiful meadow(#2)
+
+You can change this value simply by entering it.
+
+Use @ to go back to the main menu.
+
+Current value: A beautiful meadow
+
+> @
+Building menu: A beautiful meadow
+
+ [K]ey: A beautiful meadow
+ [Q]uit the menu
+
+> q
+
+Closing the building menu.
+
+> look
+A beautiful meadow(#2)
+Welcome to your new Evennia-based game! Visit https://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 @batchcommand tutorial_world.build.
+
+
+

Before diving into the code, let’s examine what we have:

+
    +
  • When we use the @edit here command, a building menu for this room appears.

  • +
  • This menu has two choices:

    +
      +
    • Enter k to edit the room key. You will go into a choice where you can simply type the key +room key (the way we have done here). You can use @ to go back to the menu.

    • +
    • You can use q to quit the menu.

    • +
    +
  • +
+

We then check, with the look command, that the menu has modified this room key. So by adding a +class, with a method and a single line of code within, we’ve added a menu with two choices.

+
+
+

Code explanation

+

Let’s examine our code again:

+
class RoomBuildingMenu(BuildingMenu):
+
+    """
+    Building menu to edit a room.
+
+    For the time being, we have only one choice: key, to edit the room key.
+
+    """
+
+    def init(self, room):
+        self.add_choice("key", "k", attr="key")
+
+
+
    +
  • We first create a class inheriting from BuildingMenu. This is usually the case when we want to +create a building menu with this contrib.

  • +
  • In this class, we override the init method, which is called when the menu opens.

  • +
  • In this init method, we call add_choice. This takes several arguments, but we’ve defined only +three here:

    +
      +
    • The choice name. This is mandatory and will be used by the building menu to know how to +display this choice.

    • +
    • The command key to access this choice. We’ve given a simple "k". Menu commands usually are +pretty short (that’s part of the reason building menus are appreciated by builders). You can also +specify additional aliases, but we’ll see that later.

    • +
    • We’ve added a keyword argument, attr. This tells the building menu that when we are in this +choice, the text we enter goes into this attribute name. It’s called attr, but it could be a room +attribute or a typeclass persistent or non-persistent attribute (we’ll see other examples as well).

    • +
    +
  • +
+
+

We’ve added the menu choice for key here, why is another menu choice defined for quit?

+
+

Our building menu creates a choice at the end of our choice list if it’s a top-level menu (sub-menus +don’t have this feature). You can, however, override it to provide a different “quit” message or to +perform some actions.

+

I encourage you to play with this code. As simple as it is, it offers some functionalities already.

+
+
+
+

Customizing building menus

+

This somewhat long section explains how to customize building menus. There are different ways +depending on what you would like to achieve. We’ll go from specific to more advanced here.

+
+

Generic choices

+

In the previous example, we’ve used add_choice. This is one of three methods you can use to add +choices. The other two are to handle more generic actions:

+
    +
  • add_choice_edit: this is called to add a choice which points to the EvEditor. It is used to +edit a description in most cases, although you could edit other things. We’ll see an example +shortly. add_choice_edit uses most of the add_choice keyword arguments we’ll see, but usually +we specify only two (sometimes three):

    +
      +
    • The choice title as usual.

    • +
    • The choice key (command key) as usual.

    • +
    • Optionally, the attribute of the object to edit, with the attr keyword argument. By +default, attr contains db.desc. It means that this persistent data attribute will be edited by +the EvEditor. You can change that to whatever you want though.

    • +
    +
  • +
  • add_choice_quit: this allows to add a choice to quit the editor. Most advisable! If you don’t +do it, the building menu will do it automatically, except if you really tell it not to. Again, you +can specify the title and key of this menu. You can also call a function when this menu closes.

  • +
+

So here’s a more complete example (you can replace your RoomBuildingMenu class in +commands/building.py to see it):

+
class RoomBuildingMenu(BuildingMenu):
+
+    """
+    Building menu to edit a room.
+    """
+
+    def init(self, room):
+        self.add_choice("key", "k", attr="key")
+        self.add_choice_edit("description", "d")
+        self.add_choice_quit("quit this editor", "q")
+
+
+

So far, our building menu class is still thin… and yet we already have some interesting feature. +See for yourself the following MUD client output (again, the commands are prefixed with > to +distinguish them):

+
> @reload
+
+> @edit here
+Building menu: A beautiful meadow
+
+ [K]ey: A beautiful meadow
+ [D]escription:
+   Welcome to your new Evennia-based game! Visit https://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 @batchcommand tutorial_world.build.
+ [Q]uit this editor
+
+> d
+
+----------Line Editor [editor]----------------------------------------------------
+01| Welcome to your new |wEvennia|n-based game! Visit https://www.evennia.com if you need
+02| help, want to contribute, report issues or just join the community.
+03| As Account #1 you can create a demo/tutorial area with |w@batchcommand tutorial_world.build|n.
+
+> :DD
+
+----------[l:03 w:034 c:0247]------------(:h for help)----------------------------
+Cleared 3 lines from buffer.
+
+> This is a beautiful meadow. But so beautiful I can't describe it.
+
+01| This is a beautiful meadow. But so beautiful I can't describe it.
+
+> :wq
+Building menu: A beautiful meadow
+
+ [K]ey: A beautiful meadow
+ [D]escription:
+   This is a beautiful meadow.  But so beautiful I can't describe it.
+ [Q]uit this editor
+
+> q
+Closing the building menu.
+
+> look
+A beautiful meadow(#2)
+This is a beautiful meadow.  But so beautiful I can't describe it.
+
+
+

So by using the d shortcut in our building menu, an EvEditor opens. You can use the EvEditor +commands (like we did here, :DD to remove all, :wq to save and quit). When you quit the editor, +the description is saved (here, in room.db.desc) and you go back to the building menu.

+

Notice that the choice to quit has changed too, which is due to our adding add_choice_quit. In +most cases, you will probably not use this method, since the quit menu is added automatically.

+
+
+

add_choice options

+

add_choice and the two methods add_choice_edit and add_choice_quit take a lot of optional +arguments to make customization easier. Some of these options might not apply to add_choice_edit +or add_choice_quit however.

+

Below are the options of add_choice, specify them as arguments:

+
    +
  • The first positional, mandatory argument is the choice title, as we have seen. This will +influence how the choice appears in the menu.

  • +
  • The second positional, mandatory argument is the command key to access to this menu. It is best +to use keyword arguments for the other arguments.

  • +
  • The aliases keyword argument can contain a list of aliases that can be used to access to this +menu. For instance: add_choice(..., aliases=['t'])

  • +
  • The attr keyword argument contains the attribute to edit when this choice is selected. It’s a +string, it has to be the name, from the object (specified in the menu constructor) to reach this +attribute. For instance, a attr of "key" will try to find obj.key to read and write the +attribute. You can specify more complex attribute names, for instance, attr="db.desc" to set the +desc persistent attribute, or attr="ndb.something" so use a non-persistent data attribute on the +object.

  • +
  • The text keyword argument is used to change the text that will be displayed when the menu choice +is selected. Menu choices provide a default text that you can change. Since this is a long text, +it’s useful to use multi-line strings (see an example below).

  • +
  • The glance keyword argument is used to specify how to display the current information while in +the menu, when the choice hasn’t been opened. If you examine the previous examples, you will see +that the current (key or db.desc) was shown in the menu, next to the command key. This is +useful for seeing at a glance the current value (hence the name). Again, menu choices will provide +a default glance if you don’t specify one.

  • +
  • The on_enter keyword argument allows to add a callback to use when the menu choice is opened. +This is more advanced, but sometimes useful.

  • +
  • The on_nomatch keyword argument is called when, once in the menu, the caller enters some text +that doesn’t match any command (including the @ command). By default, this will edit the +specified attr.

  • +
  • The on_leave keyword argument allows to specify a callback used when the caller leaves the menu +choice. This can be useful for cleanup as well.

  • +
+

These are a lot of possibilities, and most of the time you won’t need them all. Here is a short +example using some of these arguments (again, replace the RoomBuildingMenu class in +commands/building.py with the following code to see it working):

+
class RoomBuildingMenu(BuildingMenu):
+
+    """
+    Building menu to edit a room.
+
+    For the time being, we have only one choice: key, to edit the room key.
+
+    """
+
+    def init(self, room):
+        self.add_choice("title", key="t", attr="key", glance="{obj.key}", text="""
+                -------------------------------------------------------------------------------
+                Editing the title of {{obj.key}}(#{{obj.id}})
+
+                You can change the title simply by entering it.
+                Use |y{back}|n to go back to the main menu.
+
+                Current title: |c{{obj.key}}|n
+        """.format(back="|n or |y".join(self.keys_go_back)))
+        self.add_choice_edit("description", "d")
+
+
+

Reload your game and see it in action:

+
> @edit here
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+   This is a beautiful meadow.  But so beautiful I can't describe it.
+ [Q]uit the menu
+
+> t
+-------------------------------------------------------------------------------
+
+Editing the title of A beautiful meadow(#2)
+
+You can change the title simply by entering it.
+Use @ to go back to the main menu.
+
+Current title: A beautiful meadow
+
+> @
+
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+   This is a beautiful meadow.  But so beautiful I can't describe it.
+ [Q]uit the menu
+
+> q
+Closing the building menu.
+
+
+

The most surprising part is no doubt the text. We use the multi-line syntax (with """). +Excessive spaces will be removed from the left for each line automatically. We specify some +information between braces… sometimes using double braces. What might be a bit odd:

+
    +
  • {back} is a direct format argument we’ll use (see the .format specifiers).

  • +
  • {{obj...}} refers to the object being edited. We use two braces, because .format will remove them.

  • +
+

In glance, we also use {obj.key} to indicate we want to show the room’s key.

+
+
+

Everything can be a function

+

The keyword arguments of add_choice are often strings (type str). But each of these arguments +can also be a function. This allows for a lot of customization, since we define the callbacks that +will be executed to achieve such and such an operation.

+

To demonstrate, we will try to add a new feature. Our building menu for rooms isn’t that bad, but +it would be great to be able to edit exits too. So we can add a new menu choice below +description… but how to actually edit exits? Exits are not just an attribute to set: exits are +objects (of type Exit by default) which stands between two rooms (object of type Room). So how +can we show that?

+

First let’s add a couple of exits in limbo, so we have something to work with:

+
@tunnel n
+@tunnel s
+
+
+

This should create two new rooms, exits leading to them from limbo and back to limbo.

+
> look
+A beautiful meadow(#2)
+This is a beautiful meadow.  But so beautiful I can't describe it.
+Exits: north(#4) and south(#7)
+
+
+

We can access room exits with the exits property:

+
> @py here.exits
+[<Exit: north>, <Exit: south>]
+
+
+

So what we need is to display this list in our building menu… and to allow to edit it would be +great. Perhaps even add new exits?

+

First of all, let’s write a function to display the glance on existing exits. Here’s the code, +it’s explained below:

+
class RoomBuildingMenu(BuildingMenu):
+
+    """
+    Building menu to edit a room.
+
+    """
+
+    def init(self, room):
+        self.add_choice("title", key="t", attr="key", glance="{obj.key}", text="""
+                -------------------------------------------------------------------------------
+                Editing the title of {{obj.key}}(#{{obj.id}})
+
+                You can change the title simply by entering it.
+                Use |y{back}|n to go back to the main menu.
+
+                Current title: |c{{obj.key}}|n
+        """.format(back="|n or |y".join(self.keys_go_back)))
+        self.add_choice_edit("description", "d")
+        self.add_choice("exits", "e", glance=glance_exits, attr="exits")
+
+
+# Menu functions
+def glance_exits(room):
+    """Show the room exits."""
+    if room.exits:
+        glance = ""
+        for exit in room.exits:
+            glance += f"\n  |y{exit.key}|n"
+
+        return glance
+
+    return "\n  |gNo exit yet|n"
+
+
+

When the building menu opens, it displays each choice to the caller. A choice is displayed with its +title (rendered a bit nicely to show the key as well) and the glance. In the case of the exits +choice, the glance is a function, so the building menu calls this function giving it the object +being edited (the room here). The function should return the text to see.

+
> @edit here
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+   This is a beautiful meadow.  But so beautiful I can't describe it.
+ [E]xits:
+  north
+  south
+ [Q]uit the menu
+
+> q
+Closing the editor.
+
+
+
+

How do I know the parameters of the function to give?

+
+

The function you give can accept a lot of different parameters. This allows for a flexible approach +but might seem complicated at first. Basically, your function can accept any parameter, and the +building menu will send only the parameter based on their names. If your function defines an +argument named caller for instance (like def func(caller): ), then the building menu knows that +the first argument should contain the caller of the building menu. Here are the arguments, you +don’t have to specify them (if you do, they need to have the same name):

+
    +
  • menu: if your function defines an argument named menu, it will contain the building menu +itself.

  • +
  • choice: if your function defines an argument named choice, it will contain the Choice object +representing this menu choice.

  • +
  • string: if your function defines an argument named string, it will contain the user input to +reach this menu choice. This is not very useful, except on nomatch callbacks which we’ll see +later.

  • +
  • obj: if your function defines an argument named obj, it will contain the building menu edited +object.

  • +
  • caller: if your function defines an argument named caller, it will contain the caller of the +building menu.

  • +
  • Anything else: any other argument will contain the object being edited by the building menu.

  • +
+

So in our case:

+
def glance_exits(room):
+
+
+

The only argument we need is room. It’s not present in the list of possible arguments, so the +editing object of the building menu (the room, here) is given.

+
+

Why is it useful to get the menu or choice object?

+
+

Most of the time, you will not need these arguments. In very rare cases, you will use them to get +specific data (like the default attribute that was set). This tutorial will not elaborate on these +possibilities. Just know that they exist.

+

We should also define a text callback, so that we can enter our menu to see the room exits. We’ll +see how to edit them in the next section but this is a good opportunity to show a more complete +callback. To see it in action, as usual, replace the class and functions in commands/building.py:

+
# Our building menu
+
+class RoomBuildingMenu(BuildingMenu):
+
+    """
+    Building menu to edit a room.
+
+    """
+
+    def init(self, room):
+        self.add_choice("title", key="t", attr="key", glance="{obj.key}", text="""
+                -------------------------------------------------------------------------------
+                Editing the title of {{obj.key}}(#{{obj.id}})
+
+                You can change the title simply by entering it.
+                Use |y{back}|n to go back to the main menu.
+
+                Current title: |c{{obj.key}}|n
+        """.format(back="|n or |y".join(self.keys_go_back)))
+        self.add_choice_edit("description", "d")
+        self.add_choice("exits", "e", glance=glance_exits, attr="exits", text=text_exits)
+
+
+# Menu functions
+def glance_exits(room):
+    """Show the room exits."""
+    if room.exits:
+        glance = ""
+        for exit in room.exits:
+            glance += f"\n  |y{exit.key}|n"
+
+        return glance
+
+    return "\n  |gNo exit yet|n"
+
+def text_exits(caller, room):
+    """Show the room exits in the choice itself."""
+    text = "-" * 79
+    text += "\n\nRoom exits:"
+    text += "\n Use |y@c|n to create a new exit."
+    text += "\n\nExisting exits:"
+    if room.exits:
+        for exit in room.exits:
+            text += f"\n  |y@e {exit.key}|n"
+            if exit.aliases.all():
+                text += " (|y{aliases}|n)".format(aliases="|n, |y".join(
+                    alias for alias in exit.aliases.all()
+                ))
+            if exit.destination:
+                text += f" toward {exit.get_display_name(caller)}"
+    else:
+        text += "\n\n |gNo exit has yet been defined.|n"
+
+    return text
+
+
+

Look at the second callback in particular. It takes an additional argument, the caller (remember, +the argument names are important, their order is not relevant). This is useful for displaying +destination of exits accurately. Here is a demonstration of this menu:

+
> @edit here
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+   This is a beautiful meadow.  But so beautiful I can't describe it.
+ [E]xits:
+  north
+  south
+ [Q]uit the menu
+
+> e
+-------------------------------------------------------------------------------
+
+Room exits:
+ Use @c to create a new exit.
+
+Existing exits:
+  @e north (n) toward north(#4)
+  @e south (s) toward south(#7)
+
+> @
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+   This is a beautiful meadow.  But so beautiful I can't describe it.
+ [E]xits:
+  north
+  south
+ [Q]uit the menu
+
+> q
+Closing the building menu.
+
+
+

Using callbacks allows a great flexibility. We’ll now see how to handle sub-menus.

+
+ +
+
+

Full sub-menu as separate classes

+

The best way to handle individual exits is to create two separate classes:

+
    +
  • One for the room menu.

  • +
  • One for the individual exit menu.

  • +
+

The first one will have to redirect on the second. This might be more intuitive and flexible, +depending on what you want to achieve. So let’s build two menus:

+
# Still in commands/building.py, replace the menu class and functions by...
+# Our building menus
+
+class RoomBuildingMenu(BuildingMenu):
+
+    """
+    Building menu to edit a room.
+    """
+
+    def init(self, room):
+        self.add_choice("title", key="t", attr="key", glance="{obj.key}", text="""
+                -------------------------------------------------------------------------------
+                Editing the title of {{obj.key}}(#{{obj.id}})
+
+                You can change the title simply by entering it.
+                Use |y{back}|n to go back to the main menu.
+
+                Current title: |c{{obj.key}}|n
+        """.format(back="|n or |y".join(self.keys_go_back)))
+        self.add_choice_edit("description", "d")
+        self.add_choice("exits", "e", glance=glance_exits, text=text_exits,
+on_nomatch=nomatch_exits)
+
+
+# Menu functions
+def glance_exits(room):
+    """Show the room exits."""
+    if room.exits:
+        glance = ""
+        for exit in room.exits:
+            glance += f"\n  |y{exit.key}|n"
+
+        return glance
+
+    return "\n  |gNo exit yet|n"
+
+def text_exits(caller, room):
+    """Show the room exits in the choice itself."""
+    text = "-" * 79
+    text += "\n\nRoom exits:"
+    text += "\n Use |y@c|n to create a new exit."
+    text += "\n\nExisting exits:"
+    if room.exits:
+        for exit in room.exits:
+            text += f"\n  |y@e {exit.key}|n"
+            if exit.aliases.all():
+                text += " (|y{aliases}|n)".format(aliases="|n, |y".join(
+                    alias for alias in exit.aliases.all()
+                ))
+            if exit.destination:
+                text += f" toward {exit.get_display_name(caller)}"
+    else:
+        text += "\n\n |gNo exit has yet been defined.|n"
+
+    return text
+
+def nomatch_exits(menu, caller, room, string):
+    """
+    The user typed something in the list of exits.  Maybe an exit name?
+    """
+    string = string[3:]
+    exit = caller.search(string, candidates=room.exits)
+    if exit is None:
+        return
+
+    # Open a sub-menu, using nested keys
+    caller.msg(f"Editing: {exit.key}")
+    menu.open_submenu("commands.building.ExitBuildingMenu", exit, parent_keys=["e"])
+    return False
+
+class ExitBuildingMenu(BuildingMenu):
+
+    """
+    Building menu to edit an exit.
+
+    """
+
+    def init(self, exit):
+        self.add_choice("key", key="k", attr="key", glance="{obj.key}")
+        self.add_choice_edit("description", "d")
+
+
+

The code might be much easier to read. But before detailing it, let’s see how it behaves in the +game:

+
> @edit here
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+   This is a beautiful meadow.  But so beautiful I can't describe it.
+ [E]xits:
+  door
+  south
+ [Q]uit the menu
+
+> e
+-------------------------------------------------------------------------------
+
+Room exits:
+ Use @c to create a new exit.
+
+Existing exits:
+  @e door (n) toward door(#4)
+  @e south (s) toward south(#7)
+
+Editing: door
+
+> @e door
+Building menu: door
+
+ [K]ey: door
+ [D]escription:
+   None
+
+> k
+-------------------------------------------------------------------------------
+key for door(#4)
+
+You can change this value simply by entering it.
+
+Use @ to go back to the main menu.
+
+Current value: door
+
+> north
+
+-------------------------------------------------------------------------------
+key for north(#4)
+
+You can change this value simply by entering it.
+
+Use @ to go back to the main menu.
+
+Current value: north
+
+> @
+Building menu: north
+
+ [K]ey: north
+ [D]escription:
+   None
+
+> d
+----------Line Editor [editor]----------------------------------------------------
+01| None
+----------[l:01 w:001 c:0004]------------(:h for help)----------------------------
+
+> :DD
+Cleared 1 lines from buffer.
+
+> This is the northern exit. Cool huh?
+01| This is the northern exit. Cool huh?
+
+> :wq
+Building menu: north
+ [K]ey: north
+ [D]escription:
+   This is the northern exit.  Cool huh?
+
+> @
+-------------------------------------------------------------------------------
+Room exits:
+ Use @c to create a new exit.
+
+Existing exits:
+  @e north (n) toward north(#4)
+  @e south (s) toward south(#7)
+
+> @
+Building menu: A beautiful meadow
+
+ [T]itle: A beautiful meadow
+ [D]escription:
+   This is a beautiful meadow.  But so beautiful I can't describe it.
+ [E]xits:
+  north
+  south
+ [Q]uit the menu
+
+> q
+Closing the building menu.
+
+> look
+A beautiful meadow(#2)
+This is a beautiful meadow.  But so beautiful I can't describe it.
+Exits: north(#4) and south(#7)
+> @py here.exits[0]
+>>> here.exits[0]
+north
+> @py here.exits[0].db.desc
+>>> here.exits[0].db.desc
+This is the northern exit.  Cool huh?
+
+
+

Very simply, we created two menus and bridged them together. This needs much less callbacks. There +is only one line in the nomatch_exits to add:

+
    menu.open_submenu("commands.building.ExitBuildingMenu", exit, parent_keys=["e"])
+
+
+

We have to call open_submenu on the menu object (which opens, as its name implies, a sub menu) +with three arguments:

+
    +
  • The path of the menu class to create. It’s the Python class leading to the menu (notice the +dots).

  • +
  • The object that will be edited by the menu. Here, it’s our exit, so we give it to the sub-menu.

  • +
  • The keys of the parent to open when the sub-menu closes. Basically, when we’re in the root of the +sub-menu and press @, we’ll open the parent menu, with the parent keys. So we specify ["e"], +since the parent menus is the “exits” choice.

  • +
+

And that’s it. The new class will be automatically created. As you can see, we have to create a +on_nomatch callback to open the sub-menu, but once opened, it automatically close whenever needed.

+
+

Generic menu options

+

There are some options that can be set on any menu class. These options allow for greater +customization. They are class attributes (see the example below), so just set them in the class +body:

+
    +
  • keys_go_back (default to ["@"]): the keys to use to go back in the menu hierarchy, from choice +to root menu, from sub-menu to parent-menu. By default, only a @ is used. You can change this +key for one menu or all of them. You can define multiple return commands if you want.

  • +
  • sep_keys (default "."): this is the separator for nested keys. There is no real need to +redefine it except if you really need the dot as a key, and need nested keys in your menu.

  • +
  • joker_key (default to "*"): used for nested keys to indicate “any key”. Again, you shouldn’t +need to change it unless you want to be able to use the @*@ in a command key, and also need nested +keys in your menu.

  • +
  • min_shortcut (default to 1): although we didn’t see it here, one can create a menu choice +without giving it a key. If so, the menu system will try to “guess” the key. This option allows to +change the minimum length of any key for security reasons.

  • +
+

To set one of them just do so in your menu class(es):

+
class RoomBuildingMenu(BuildingMenu):
+    keys_go_back = ["/"]
+    min_shortcut = 2
+
+
+
+
+
+

Conclusion

+

Building menus mean to save you time and create a rich yet simple interface. But they can be +complicated to learn and require reading the source code to find out how to do such and such a +thing. This documentation, however long, is an attempt at describing this system, but chances are +you’ll still have questions about it after reading it, especially if you try to push this system to +a great extent. Do not hesitate to read the documentation of this contrib, it’s meant to be +exhaustive but user-friendly.

+
+

This document page is generated from evennia/contrib/base_systems/building_menu/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Character-Creator.html b/docs/latest/Contribs/Contrib-Character-Creator.html new file mode 100644 index 0000000000..25cf43444f --- /dev/null +++ b/docs/latest/Contribs/Contrib-Character-Creator.html @@ -0,0 +1,263 @@ + + + + + + + + + Character Creator — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Character Creator

+

Contribution by InspectorCaracal, 2022

+

Commands for managing and initiating an in-game character-creation menu.

+
+

Installation

+

In your game folder commands/default_cmdsets.py, import and add +ContribCmdCharCreate to your AccountCmdSet.

+

Example:

+
from evennia.contrib.rpg.character_creator.character_creator import ContribCmdCharCreate
+
+class AccountCmdSet(default_cmds.AccountCmdSet):
+
+    def at_cmdset_creation(self):
+        super().at_cmdset_creation()
+        self.add(ContribCmdCharCreate)
+
+
+

In your game folder typeclasses/accounts.py, import and inherit from ContribChargenAccount +on your Account class.

+

(Alternatively, you can copy the at_look method directly into your own class.)

+
+

Example:

+
from evennia.contrib.rpg.character_creator.character_creator import ContribChargenAccount
+
+class Account(ContribChargenAccount):
+    # your Account class code
+
+
+

In your settings file server/conf/settings.py, add the following settings:

+
AUTO_CREATE_CHARACTER_WITH_ACCOUNT = False
+AUTO_PUPPET_ON_LOGIN = False
+
+
+

(If you want to allow players to create more than one character, you can +customize that with the setting MAX_NR_CHARACTERS.)

+

By default, the new charcreate command will reference the example menu +provided by the contrib, so you can test it out before building your own menu. +You can reference +the example menu here for +ideas on how to build your own.

+

Once you have your own menu, just add it to your settings to use it. e.g. if your menu is in +mygame/word/chargen_menu.py, you’d add the following to your settings file:

+
CHARGEN_MENU = "world.chargen_menu"
+
+
+
+
+
+

Usage

+
+

The EvMenu

+

In order to use the contrib, you will need to create your own chargen EvMenu. +The included example_menu.py gives a number of useful menu node techniques +with basic attribute examples for you to reference. It can be run as-is as a +tutorial for yourself/your devs, or used as base for your own menu.

+

The example menu includes code, tips, and instructions for the following types +of decision nodes:

+
+

Informational Pages

+

A small set of nodes that let you page through information on different choices before committing to one.

+
+
+

Option Categories

+

A pair of nodes which let you divide an arbitrary number of options into separate categories.

+

The base node has a list of categories as the options, and the child node displays the actual character choices.

+
+
+

Multiple Choice

+

Allows players to select and deselect options from the list in order to choose more than one.

+
+
+

Starting Objects

+

Allows players to choose from a selection of starting objects, which are then created on chargen completion.

+
+
+

Choosing a Name

+

The contrib assumes the player will choose their name during character creation, +so the necessary code for doing so is of course included!

+
+
+
+

charcreate command

+

The contrib overrides the character creation command - charcreate - to use a +character creator menu, as well as supporting exiting/resuming the process. In +addition, unlike the core command, it’s designed for the character name to be +chosen later on via the menu, so it won’t parse any arguments passed to it.

+
+
+

Changes to Account.at_look

+

The contrib version works mostly the same as core evennia, but adds an +additional check to recognize an in-progress character. If you’ve modified your +own at_look hook, it’s an easy addition to make: just add this section to the +playable character list loop.

+
    for char in characters:
+        # contrib code starts here
+        if char.db.chargen_step:
+            # currently in-progress character; don't display placeholder names
+            result.append("\n - |Yin progress|n (|wcharcreate|n to continue)")
+            continue
+        # the rest of your code continues here
+
+
+
+

This document page is generated from evennia/contrib/rpg/character_creator/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Clothing.html b/docs/latest/Contribs/Contrib-Clothing.html new file mode 100644 index 0000000000..79f937d3c3 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Clothing.html @@ -0,0 +1,275 @@ + + + + + + + + + Clothing — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Clothing

+

Contribution by Tim Ashley Jenkins, 2017

+

Provides a typeclass and commands for wearable clothing. These +look of these clothes are appended to the character’s description when worn.

+

Clothing items, when worn, are added to the character’s description +in a list. For example, if wearing the following clothing items:

+
a thin and delicate necklace
+a pair of regular ol' shoes
+one nice hat
+a very pretty dress
+
+
+

Would result in this added description:

+
Tim is wearing one nice hat, a thin and delicate necklace,
+a very pretty dress and a pair of regular ol' shoes.
+
+
+
+

Installation

+

To install, import this module and have your default character +inherit from ClothedCharacter in your game’s characters.py file:

+

+from evennia.contrib.game_systems.clothing import ClothedCharacter
+
+class Character(ClothedCharacter):
+
+
+
+

And then add ClothedCharacterCmdSet in your character set in +mygame/commands/default_cmdsets.py:

+

+from evennia.contrib.game_systems.clothing import ClothedCharacterCmdSet # <--
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+     # ...
+     at_cmdset_creation(self):
+
+         super().at_cmdset_creation()
+         # ...
+         self.add(ClothedCharacterCmdSet)    # <--
+
+
+
+
+
+

Usage

+

Once installed, you can use the default builder commands to create clothes +with which to test the system:

+
create a pretty shirt : evennia.contrib.game_systems.clothing.ContribClothing
+set shirt/clothing_type = 'top'
+wear shirt
+
+
+

A character’s description may look like this:

+
Superuser(#1)
+This is User #1.
+
+Superuser is wearing one nice hat, a thin and delicate necklace,
+a very pretty dress and a pair of regular ol' shoes.
+
+
+

Characters can also specify the style of wear for their clothing - I.E. +to wear a scarf ‘tied into a tight knot around the neck’ or ‘draped +loosely across the shoulders’ - to add an easy avenue of customization. +For example, after entering:

+
wear scarf draped loosely across the shoulders
+
+
+

The garment appears like so in the description:

+
Superuser(#1)
+This is User #1.
+
+Superuser is wearing a fanciful-looking scarf draped loosely
+across the shoulders.
+
+
+

Items of clothing can be used to cover other items, and many options +are provided to define your own clothing types and their limits and +behaviors. For example, to have undergarments automatically covered +by outerwear, or to put a limit on the number of each type of item +that can be worn. The system as-is is fairly freeform - you +can cover any garment with almost any other, for example - but it +can easily be made more restrictive, and can even be tied into a +system for armor or other equipment.

+
+
+

Configuration

+

The contrib has several optional configurations which you can define in your settings.py +Here are the settings and their default values.

+
# Maximum character length of 'wear style' strings, or None for unlimited.
+CLOTHING_WEARSTYLE_MAXLENGTH = 50
+
+# The order in which clothing types appear on the description.
+# Untyped clothing or clothing with a type not in this list goes last.
+CLOTHING_TYPE_ORDERED = [
+        "hat",
+        "jewelry",
+        "top",
+        "undershirt",
+        "gloves",
+        "fullbody",
+        "bottom",
+        "underpants",
+        "socks",
+        "shoes",
+        "accessory",
+    ]
+
+# The maximum number of clothing items that can be worn, or None for unlimited.
+CLOTHING_OVERALL_LIMIT = 20
+
+# The maximum number for specific clothing types that can be worn.
+# If the clothing item has no type or is not specified here, the only maximum is the overall limit.
+CLOTHING_TYPE_LIMIT = {"hat": 1, "gloves": 1, "socks": 1, "shoes": 1}
+
+# What types of clothes will automatically cover what other types of clothes when worn.
+# Note that clothing only gets auto-covered if it's already being worn. It's perfectly possible
+# to have your underpants showing if you put them on after your pants!
+CLOTHING_TYPE_AUTOCOVER = {
+        "top": ["undershirt"],
+        "bottom": ["underpants"],
+        "fullbody": ["undershirt", "underpants"],
+        "shoes": ["socks"],
+    }
+
+# Any types of clothes that can't be used to cover other clothes at all.
+CLOTHING_TYPE_CANT_COVER_WITH = ["jewelry"]
+
+
+
+

This document page is generated from evennia/contrib/game_systems/clothing/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Color-Markups.html b/docs/latest/Contribs/Contrib-Color-Markups.html new file mode 100644 index 0000000000..8a411b1255 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Color-Markups.html @@ -0,0 +1,196 @@ + + + + + + + + + Additional Color markups — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Additional Color markups

+

Contrib by Griatch, 2017

+

Additional color markup styles for Evennia (extending or replacing the default +|r, |234). Adds support for MUSH-style (%cr, %c123) and/or legacy-Evennia +({r, {123).

+
+

Installation

+

Import the desired style variables from this module into +mygame/server/conf/settings.py and add them to the settings variables below. +Each are specified as a list, and multiple such lists can be added to each +variable to support multiple formats. Note that list order affects which regexes +are applied first. You must restart both Portal and Server for color tags to +update.

+

Assign to the following settings variables (see below for example):

+
COLOR_ANSI_EXTRA_MAP - a mapping between regexes and ANSI colors
+COLOR_XTERM256_EXTRA_FG - regex for defining XTERM256 foreground colors
+COLOR_XTERM256_EXTRA_BG - regex for defining XTERM256 background colors
+COLOR_XTERM256_EXTRA_GFG - regex for defining XTERM256 grayscale foreground colors
+COLOR_XTERM256_EXTRA_GBG - regex for defining XTERM256 grayscale background colors
+COLOR_ANSI_BRIGHT_BG_EXTRA_MAP = ANSI does not support bright backgrounds; we fake
+this by mapping ANSI markup to matching bright XTERM256 backgrounds
+
+COLOR_NO_DEFAULT - Set True/False. If False (default), extend the default
+markup, otherwise replace it completely.
+
+
+
+
+

Example

+

To add the {- “curly-bracket” style, add the following to your settings file, +then reboot both Server and Portal:

+
from evennia.contrib.base_systems import color_markups
+COLOR_ANSI_EXTRA_MAP = color_markups.CURLY_COLOR_ANSI_EXTRA_MAP
+COLOR_XTERM256_EXTRA_FG = color_markups.CURLY_COLOR_XTERM256_EXTRA_FG
+COLOR_XTERM256_EXTRA_BG = color_markups.CURLY_COLOR_XTERM256_EXTRA_BG
+COLOR_XTERM256_EXTRA_GFG = color_markups.CURLY_COLOR_XTERM256_EXTRA_GFG
+COLOR_XTERM256_EXTRA_GBG = color_markups.CURLY_COLOR_XTERM256_EXTRA_GBG
+COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP = color_markups.CURLY_COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP
+
+
+

To add the %c- “mux/mush” style, add the following to your settings file, then +reboot both Server and Portal:

+
from evennia.contrib.base_systems import color_markups
+COLOR_ANSI_EXTRA_MAP = color_markups.MUX_COLOR_ANSI_EXTRA_MAP
+COLOR_XTERM256_EXTRA_FG = color_markups.MUX_COLOR_XTERM256_EXTRA_FG
+COLOR_XTERM256_EXTRA_BG = color_markups.MUX_COLOR_XTERM256_EXTRA_BG
+COLOR_XTERM256_EXTRA_GFG = color_markups.MUX_COLOR_XTERM256_EXTRA_GFG
+COLOR_XTERM256_EXTRA_GBG = color_markups.MUX_COLOR_XTERM256_EXTRA_GBG
+COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP = color_markups.MUX_COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP
+
+
+
+

This document page is generated from evennia/contrib/base_systems/color_markups/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Components.html b/docs/latest/Contribs/Contrib-Components.html new file mode 100644 index 0000000000..05ef4bd2f2 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Components.html @@ -0,0 +1,331 @@ + + + + + + + + + Components — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Components

+

Contrib by ChrisLR, 2021

+

Expand typeclasses using a components/composition approach.

+
+

The Components Contrib

+

This contrib introduces Components and Composition to Evennia. +Each ‘Component’ class represents a feature that will be ‘enabled’ on a typeclass instance. +You can register these components on an entire typeclass or a single object at runtime. +It supports both persisted attributes and in-memory attributes by using Evennia’s AttributeHandler.

+
+
+

Pros

+
    +
  • You can reuse a feature across multiple typeclasses without inheritance

  • +
  • You can cleanly organize each feature into a self-contained class.

  • +
  • You can check if your object supports a feature without checking its instance.

  • +
+
+
+

Cons

+
    +
  • It introduces additional complexity.

  • +
  • A host typeclass instance is required.

  • +
+
+
+

How to install

+

To enable component support for a typeclass, +import and inherit the ComponentHolderMixin, similar to this

+
from evennia.contrib.base_systems.components import ComponentHolderMixin
+class Character(ComponentHolderMixin, DefaultCharacter):
+# ...
+
+
+

Components need to inherit the Component class directly and require a name.

+
from evennia.contrib.base_systems.components import Component
+
+class Health(Component):
+    name = "health"
+
+
+

Components may define DBFields or NDBFields at the class level. +DBField will store its values in the host’s DB with a prefixed key. +NDBField will store its values in the host’s NDB and will not persist. +The key used will be ‘component_name::field_name’. +They use AttributeProperty under the hood.

+

Example:

+
from evennia.contrib.base_systems.components import Component, DBField
+
+class Health(Component):
+    health = DBField(default=1)
+
+
+

Note that default is optional and will default to None.

+

Adding a component to a host will also a similarly named tag with ‘components’ as category. +A Component named health will appear as key=”health, category=“components”. +This allows you to retrieve objects with specific components by searching with the tag.

+

It is also possible to add Component Tags the same way, using TagField. +TagField accepts a default value and can be used to store a single or multiple tags. +Default values are automatically added when the component is added. +Component Tags are cleared from the host if the component is removed.

+

Example:

+
from evennia.contrib.base_systems.components import Component, TagField
+
+class Health(Component):
+    resistances = TagField()
+    vulnerability = TagField(default="fire", enforce_single=True)
+
+
+

The ‘resistances’ field in this example can be set to multiple times and it will keep the added tags. +The ‘vulnerability’ field in this example will override the previous tag with the new one.

+

Each typeclass using the ComponentHolderMixin can declare its components +in the class via the ComponentProperty. +These are components that will always be present in a typeclass. +You can also pass kwargs to override the default values +Example

+
from evennia.contrib.base_systems.components import ComponentHolderMixin
+class Character(ComponentHolderMixin, DefaultCharacter):
+    health = ComponentProperty("health", hp=10, max_hp=50)
+
+
+

You can then use character.components.health to access it. +The shorter form character.cmp.health also exists. +character.health would also be accessible but only for typeclasses that have +this component defined on the class.

+

Alternatively you can add those components at runtime. +You will have to access those via the component handler. +Example

+
character = self
+vampirism = components.Vampirism.create(character)
+character.components.add(vampirism)
+
+...
+
+vampirism_from_elsewhere = character.components.get("vampirism")
+
+
+

Keep in mind that all components must be imported to be visible in the listing. +As such, I recommend regrouping them in a package. +You can then import all your components in that package’s init

+

Because of how Evennia import typeclasses and the behavior of python imports +I recommend placing the components package inside the typeclass package. +In other words, create a folder named components inside your typeclass folder. +Then, inside the ‘typeclasses/init.py’ file add the import to the folder, like

+
from typeclasses import components
+
+
+

This ensures that the components package will be imported when the typeclasses are imported. +You will also need to import each components inside the package’s own ‘typeclasses/components/init.py’ file. +You only need to import each module/file from there but importing the right class is a good practice.

+
from typeclasses.components.health import Health
+
+
+
from typeclasses.components import health
+
+
+

Both of the above examples will work.

+
+
+

Full Example

+
from evennia.contrib.base_systems import components
+
+
+# This is the Component class
+class Health(components.Component):
+    name = "health"
+
+    # Stores the current and max values as Attributes on the host, defaulting to 100
+    current = components.DBField(default=100)
+    max = components.DBField(default=100)
+
+    def damage(self, value):
+        if self.current <= 0:
+            return
+
+        self.current -= value
+        if self.current > 0:
+            return
+
+        self.current = 0
+        self.on_death()
+
+    def heal(self, value):
+        hp = self.current
+        hp += value
+        if hp >= self.max_hp:
+            hp = self.max_hp
+
+        self.current = hp
+
+    @property
+    def is_dead(self):
+        return self.current <= 0
+
+    def on_death(self):
+        # Behavior is defined on the typeclass
+        self.host.on_death()
+
+
+# This is how the Character inherits the mixin and registers the component 'health'
+class Character(ComponentHolderMixin, DefaultCharacter):
+    health = ComponentProperty("health")
+
+
+# This is an example of a command that checks for the component
+class Attack(Command):
+    key = "attack"
+    aliases = ('melee', 'hit')
+
+    def at_pre_cmd(self):
+        caller = self.caller
+        targets = self.caller.search(args, quiet=True)
+        valid_target = None
+        for target in targets:
+            # Attempt to retrieve the component, None is obtained if it does not exist.
+            if target.components.health:
+                valid_target = target
+
+        if not valid_target:
+            caller.msg("You can't attack that!")
+            return True
+
+
+
+

This document page is generated from evennia/contrib/base_systems/components/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Containers.html b/docs/latest/Contribs/Contrib-Containers.html new file mode 100644 index 0000000000..2f5dd455db --- /dev/null +++ b/docs/latest/Contribs/Contrib-Containers.html @@ -0,0 +1,200 @@ + + + + + + + + + Containers — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Containers

+

Contribution by InspectorCaracal (2023)

+

Adds the ability to put objects into other container objects by providing a container typeclass and extending certain base commands.

+
+

Installation

+

To install, import and add the ContainerCmdSet to CharacterCmdSet in your default_cmdsets.py file:

+
from evennia.contrib.game_systems.containers import ContainerCmdSet
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    
+    def at_cmdset_creation(self):
+        # ...
+        self.add(ContainerCmdSet)
+
+
+

This will replace the default look and get commands with the container-friendly versions provided by the contrib as well as add a new put command.

+
+
+

Usage

+

The contrib includes a ContribContainer typeclass which has all of the set-up necessary to be used as a container. To use, all you need to do is create an object in-game with that typeclass - it will automatically inherit anything you implemented in your base Object typeclass as well.

+
create bag:game_systems.containers.ContribContainer
+
+
+

The contrib’s ContribContainer comes with a capacity limit of a maximum number of items it can hold. This can be changed per individual object.

+

In code:

+
obj.capacity = 5
+
+
+

In game:

+
set box/capacity = 5
+
+
+

You can also make any other objects usable as containers by setting the get_from lock type on it.

+
lock mysterious box = get_from:true()
+
+
+
+
+

Extending

+

The ContribContainer class is intended to be usable as-is, but you can also inherit from it for your own container classes to extend its functionality. Aside from having the container lock pre-set on object creation, it comes with three main additions:

+
+

capacity property

+

ContribContainer.capacity is an AttributeProperty - meaning you can access it in code with obj.capacity and also set it in game with set obj/capacity = 5 - which represents the capacity of the container as an integer. You can override this with a more complex representation of capacity on your own container classes.

+
+
+

at_pre_get_from and at_pre_put_in methods

+

These two methods on ContribContainer are called as extra checks when attempting to either get an object from, or put an object in, a container. The contrib’s ContribContainer.at_pre_get_from doesn’t do any additional validation by default, while ContribContainer.at_pre_put_in does a simple capacity check.

+

You can override these methods on your own child class to do any additional capacity or access checks.

+
+

This document page is generated from evennia/contrib/game_systems/containers/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Cooldowns.html b/docs/latest/Contribs/Contrib-Cooldowns.html new file mode 100644 index 0000000000..7546957cac --- /dev/null +++ b/docs/latest/Contribs/Contrib-Cooldowns.html @@ -0,0 +1,193 @@ + + + + + + + + + Cooldowns — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Cooldowns

+

Contribution by owllex, 2021

+

Cooldowns are used to model rate-limited actions, like how often a +character can perform a given action; until a certain time has passed their +command can not be used again. This contrib provides a simple cooldown +handler that can be attached to any typeclass. A cooldown is a lightweight persistent +asynchronous timer that you can query to see if a certain time has yet passed.

+

Cooldowns are completely asynchronous and must be queried to know their +state. They do not fire callbacks, so are not a good fit for use cases +where something needs to happen on a specific schedule (use delay or +a TickerHandler for that instead).

+

See also the evennia howto for more information +about the concept.

+
+

Installation

+

To use, simply add the following property to the typeclass definition of any +object type that you want to support cooldowns. It will expose a new cooldowns +property that persists data to the object’s attribute storage. You can set this +on your base Object typeclass to enable cooldown tracking on every kind of +object, or just put it on your Character typeclass.

+

By default the CooldownHandler will use the cooldowns property, but you can +customize this if desired by passing a different value for the db_attribute +parameter.

+
from evennia.contrib.game_systems.cooldowns import CooldownHandler
+from evennia.utils.utils import lazy_property
+
+@lazy_property
+def cooldowns(self):
+    return CooldownHandler(self, db_attribute="cooldowns")
+
+
+
+
+

Example

+

Assuming you’ve installed cooldowns on your Character typeclasses, you can use a +cooldown to limit how often you can perform a command. The following code +snippet will limit the use of a Power Attack command to once every 10 seconds +per character.

+
class PowerAttack(Command):
+    def func(self):
+        if self.caller.cooldowns.ready("power attack"):
+            self.do_power_attack()
+            self.caller.cooldowns.add("power attack", 10)
+        else:
+            self.caller.msg("That's not ready yet!")
+
+
+
+
+

This document page is generated from evennia/contrib/game_systems/cooldowns/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Crafting.html b/docs/latest/Contribs/Contrib-Crafting.html new file mode 100644 index 0000000000..a9e6427271 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Crafting.html @@ -0,0 +1,419 @@ + + + + + + + + + Crafting system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Crafting system

+

Contribution by Griatch 2020

+

This implements a full crafting system. The principle is that of a ‘recipe’, +where you combine items (tagged as ingredients) create something new. The recipe can also +require certain (non-consumed) tools. An example would be to use the ‘bread recipe’ to +combine ‘flour’, ‘water’ and ‘yeast’ with an ‘oven’ to bake a ‘loaf of bread’.

+

The recipe process can be understood like this:

+
ingredient(s) + tool(s) + recipe -> object(s)
+
+
+

Here, ‘ingredients’ are consumed by the crafting process, whereas ‘tools’ are +necessary for the process but will not be destroyed by it.

+

The included craft command works like this:

+
craft <recipe> [from <ingredient>,...] [using <tool>, ...]
+
+
+
+

Examples

+

Using the craft command:

+
craft toy car from plank, wooden wheels, nails using saw, hammer
+
+
+

A recipe does not have to use tools or even multiple ingredients:

+
snow + snowball_recipe -> snowball
+
+
+

Conversely one could also imagine using tools without consumables, like

+
spell_book + wand + fireball_recipe -> fireball
+
+
+

The system is generic enough to be used also for adventure-like puzzles (but +one would need to change the command and determine the recipe on based on what +is being combined instead):

+
stick + string + hook -> makeshift_fishing_rod
+makeshift_fishing_rod + storm_drain -> key
+
+
+

See the sword example for an example +of how to design a recipe tree for crafting a sword from base elements.

+
+
+

Installation and Usage

+

Import the CmdCraft command from evennia/contrib/crafting/crafting.py and +add it to your Character cmdset. Reload and the craft command will be +available to you:

+
craft <recipe> [from <ingredient>,...] [using <tool>, ...]
+
+
+

In code, you can craft using the +evennia.contrib.game_systems.crafting.craft function:

+
from evennia.contrib.game_systems.crafting import craft
+
+result = craft(caller, "recipename", *inputs)
+
+
+
+

Here, caller is the one doing the crafting and *inputs is any combination of +consumables and/or tool Objects. The system will identify which is which by the +Tags on them (see below) The result is always a list.

+

To use crafting you need recipes. Add a new variable to +mygame/server/conf/settings.py:

+
CRAFT_RECIPE_MODULES = ['world.recipes']
+
+
+

All top-level classes in these modules (whose name does not start with _) will +be parsed by Evennia as recipes to make available to the crafting system. Using +the above example, create mygame/world/recipes.py and add your recipies in +there:

+

A quick example (read on for more details):

+

+from evennia.contrib.game_systems.crafting import CraftingRecipe, CraftingValidationError
+
+
+class RecipeBread(CraftingRecipe):
+  """
+  Bread is good for making sandwitches!
+
+  """
+
+  name = "bread"   # used to identify this recipe in 'craft' command
+  tool_tags = ["bowl", "oven"]
+  consumable_tags = ["flour", "salt", "yeast", "water"]
+  output_prototypes = [
+    {"key": "Loaf of Bread",
+     "aliases": ["bread"],
+     "desc": "A nice load of bread.",
+     "typeclass": "typeclasses.objects.Food",  # assuming this exists
+     "tags": [("bread", "crafting_material")]  # this makes it usable in other recipes ...
+    }
+
+  ]
+
+  def pre_craft(self, **kwargs):
+    # validates inputs etc. Raise `CraftingValidationError` if fails
+
+  def do_craft(self, **kwargs):
+    # performs the craft - report errors directly to user and return None (if
+    # failed) and the created object(s) if successful.
+
+  def post_craft(self, result, **kwargs):
+    # any post-crafting effects. Always called, even if do_craft failed (the
+    # result would be None then)
+
+
+
+
+
+

Adding new recipes

+

A recipe is a class inheriting from +evennia.contrib.game_systems.crafting.CraftingRecipe. This class implements the +most common form of crafting - that using in-game objects. Each recipe is a +separate class which gets initialized with the consumables/tools you provide.

+

For the craft command to find your custom recipes, you need to tell Evennia +where they are. Add a new line to your mygame/server/conf/settings.py file, +with a list to any new modules with recipe classes.

+
CRAFT_RECIPE_MODULES = ["world.myrecipes"]
+
+
+

(You need to reload after adding this). All global-level classes in these +modules (whose names don’t start with underscore) are considered by the system +as viable recipes.

+

Here we assume you created mygame/world/myrecipes.py to match the above +example setting:

+
# in mygame/world/myrecipes.py
+
+from evennia.contrib.game_systems.crafting import CraftingRecipe
+
+class WoodenPuppetRecipe(CraftingRecipe):
+    """A puppet""""
+    name = "wooden puppet"  # name to refer to this recipe as
+    tool_tags = ["knife"]
+    consumable_tags = ["wood"]
+    output_prototypes = [
+        {"key": "A carved wooden doll",
+         "typeclass": "typeclasses.objects.decorations.Toys",
+         "desc": "A small carved doll"}
+    ]
+
+
+
+

This specifies which tags to look for in the inputs. It defines a +Prototype for the recipe to use to spawn the +result on the fly (a recipe could spawn more than one result if needed). +Instead of specifying the full prototype-dict, you could also just provide a +list of prototype_keys to existing prototypes you have.

+

After reloading the server, this recipe would now be available to use. To try it +we should create materials and tools to insert into the recipe.

+

The recipe analyzes inputs, looking for Tags with +specific tag-categories. The tag-category used can be set per-recipe using the +(.consumable_tag_category and .tool_tag_category respectively). The defaults +are crafting_material and crafting_tool. For +the puppet we need one object with the wood tag and another with the knife +tag:

+
from evennia import create_object
+
+knife = create_object(key="Hobby knife", tags=[("knife", "crafting_tool")])
+wood = create_object(key="Piece of wood", tags[("wood", "crafting_material")])
+
+
+

Note that the objects can have any name, all that matters is the +tag/tag-category. This means if a “bayonet” also had the “knife” crafting tag, +it could also be used to carve a puppet. This is also potentially interesting +for use in puzzles and to allow users to experiment and find alternatives to +know ingredients.

+

By the way, there is also a simple shortcut for doing this:

+
tools, consumables = WoodenPuppetRecipe.seed()
+
+
+

The seed class-method will create simple dummy objects that fulfills the +recipe’s requirements. This is great for testing.

+

Assuming these objects were put in our inventory, we could now craft using the +in-game command:

+
> craft wooden puppet from wood using hobby knife
+
+
+

In code we would do

+
from evennia.contrib.game_systems.crafting import craft
+puppet = craft(crafter, "wooden puppet", knife, wood)
+
+
+
+

In the call to craft, the order of knife and wood doesn’t matter - the +recipe will sort out which is which based on their tags.

+
+
+

Deeper customization of recipes

+

For customizing recipes further, it helps to understand how to use the +recipe-class directly:

+
class MyRecipe(CraftingRecipe):
+    # ...
+
+tools, consumables = MyRecipe.seed()
+recipe = MyRecipe(crafter, *(tools + consumables))
+result = recipe.craft()
+
+
+
+

This is useful for testing and allows you to use the class directly without +adding it to a module in settings.CRAFTING_RECIPE_MODULES.

+

Even without modifying more than the class properties, there are a lot of +options to set on the CraftingRecipe class. Easiest is to refer to the +CraftingRecipe api +documentation. For example, +you can customize the validation-error messages, decide if the ingredients have +to be exactly right, if a failure still consumes the ingredients or not, and +much more.

+

For even more control you can override hooks in your own class:

+
    +
  • pre_craft - this should handle input validation and store its data in .validated_consumables and +validated_tools respectively. On error, this reports the error to the crafter and raises the +CraftingValidationError.

  • +
  • craft - this will only be called if pre_craft finished without an exception. This should +return the result of the crafting, by spawnging the prototypes. Or the empty list if crafting +fails for some reason. This is the place to add skill-checks or random chance if you need it +for your game.

  • +
  • post_craft - this receives the result from craft and handles error messages and also deletes +any consumables as needed. It may also modify the result before returning it.

  • +
  • msg - this is a wrapper for self.crafter.msg and should be used to send messages to the +crafter. Centralizing this means you can also easily modify the sending style in one place later.

  • +
+

The class constructor (and the craft access function) takes optional **kwargs. These are passed +into each crafting hook. These are unused by default but could be used to customize things per-call.

+
+

Skilled crafters

+

What the crafting system does not have out of the box is a ‘skill’ system - the +notion of being able to fail the craft if you are not skilled enough. Just how +skills work is game-dependent, so to add this you need to make your own recipe +parent class and have your recipes inherit from this.

+
from random import randint
+from evennia.contrib.game_systems.crafting import CraftingRecipe
+
+class SkillRecipe(CraftingRecipe):
+   """A recipe that considers skill"""
+
+    difficulty = 20
+
+    def craft(self, **kwargs):
+        """The input is ok. Determine if crafting succeeds"""
+
+        # this is set at initialization
+        crafter = self.crafte
+
+        # let's assume the skill is stored directly on the crafter
+        # - the skill is 0..100.
+        crafting_skill = crafter.db.skill_crafting
+        # roll for success:
+        if randint(1, 100) <= (crafting_skill - self.difficulty):
+            # all is good, craft away
+            return super().craft()
+        else:
+            self.msg("You are not good enough to craft this. Better luck next time!")
+            return []
+
+
+

In this example we introduce a .difficulty for the recipe and makes a ‘dice roll’ to see +if we succed. We would of course make this a lot more immersive and detailed in a full game. In +principle you could customize each recipe just the way you want it, but you could also inherit from +a central parent like this to cut down on work.

+

The sword recipe example module also shows an example +of a random skill-check being implemented in a parent and then inherited for multiple use.

+
+
+
+

Even more customization

+

If you want to build something even more custom (maybe using different input types of validation logic) +you could also look at the CraftingRecipe parent class CraftingRecipeBase. +It implements just the minimum needed to be a recipe and for big changes you may be better off starting +from this rather than the more opinionated CraftingRecipe.

+
+

This document page is generated from evennia/contrib/game_systems/crafting/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Custom-Gametime.html b/docs/latest/Contribs/Contrib-Custom-Gametime.html new file mode 100644 index 0000000000..a90c8cb037 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Custom-Gametime.html @@ -0,0 +1,187 @@ + + + + + + + + + Custom gameime — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Custom gameime

+

Contrib by vlgeoff, 2017 - based on Griatch’s core original

+

This reimplements the evennia.utils.gametime module but with a custom +calendar (unusual number of days per week/month/year etc) for your game world. +Like the original, it allows for scheduling events to happen at given +in-game times, but now taking this custom calendar into account.

+
+

Installation

+

Import and use this in the same way as you would the normal +evennia.utils.gametime module.

+

Customize the calendar by adding a TIME_UNITS dict to your settings (see +example below).

+
+
+

Usage:

+
    from evennia.contrib.base_systems import custom_gametime
+
+    gametime = custom_gametime.realtime_to_gametime(days=23)
+
+    # scedule an event to fire every in-game 10 hours
+    custom_gametime.schedule(callback, repeat=True, hour=10)
+
+
+
+

The calendar can be customized by adding the TIME_UNITS dictionary to your +settings file. This maps unit names to their length, expressed in the smallest +unit. Here’s the default as an example:

+
TIME_UNITS = {
+    "sec": 1,
+    "min": 60,
+    "hr": 60 * 60,
+    "hour": 60 * 60,
+    "day": 60 * 60 * 24,
+    "week": 60 * 60 * 24 * 7,
+    "month": 60 * 60 * 24 * 7 * 4,
+    "yr": 60 * 60 * 24 * 7 * 4 * 12,
+    "year": 60 * 60 * 24 * 7 * 4 * 12, }
+
+
+

When using a custom calendar, these time unit names are used as kwargs to +the converter functions in this module. Even if your calendar uses other names +for months/weeks etc the system needs the default names internally.

+
+

This document page is generated from evennia/contrib/base_systems/custom_gametime/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Dice.html b/docs/latest/Contribs/Contrib-Dice.html new file mode 100644 index 0000000000..715621481e --- /dev/null +++ b/docs/latest/Contribs/Contrib-Dice.html @@ -0,0 +1,268 @@ + + + + + + + + + Dice roller — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Dice roller

+

Contribution by Griatch, 2012, 2023

+

A dice roller for any number and side of dice. Adds in-game dice rolling +(like roll 2d10 + 1) as well as conditionals (roll under/over/equal to a target) +and functions for rolling dice in code. Command also supports hidden or secret +rolls for use by a human game master.

+
+

Installation:

+

Add the CmdDice command from this module to your character’s cmdset +(and then restart the server):

+
# in mygame/commands/default_cmdsets.py
+
+# ...
+from evennia.contrib.rpg import dice  <---
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(dice.CmdDice())  # <---
+
+
+
+
+
+

Usage:

+
> roll 1d100 + 2
+> roll 1d20
+> roll 1d20 - 4
+
+
+

The result of the roll will be echoed to the room.

+

One can also specify a standard Python operator in order to specify +eventual target numbers and get results in a fair and guaranteed +unbiased way. For example:

+
> roll 2d6 + 2 < 8
+
+
+

Rolling this will inform all parties if roll was indeed below 8 or not.

+
> roll/hidden 1d100
+
+
+

Informs the room that the roll is being made without telling what the result +was.

+
> roll/secret 1d20
+
+
+

This a hidden roll that does not inform the room it happened.

+
+
+

Rolling dice from code

+

You can specify the first argument as a string on standard RPG d-syntax (NdM, +where N is the number of dice to roll, and M is the number sides per dice):

+
from evennia.contrib.rpg.dice import roll
+
+roll("3d10 + 2")
+
+
+

You can also give a conditional (you’ll then get a True/False back):

+
roll("2d6 - 1 >= 10")
+
+
+

If you specify the first argument as an integer, it’s interpret as the number of +dice to roll and you can then build the roll more explicitly. This can be +useful if you are using the roller together with some other system and want to +construct the roll from components.

+
roll(dice, dicetype=6, modifier=None, conditional=None, return_tuple=False,
+      max_dicenum=10, max_dicetype=1000)
+
+
+

Here’s how to roll 3d10 + 2 with explicit syntax:

+
roll(3, 10, modifier=("+", 2))
+
+
+

Here’s how to roll 2d6 - 1 >= 10 (you’ll get back True/False back):

+
roll(2, 6, modifier=("-", 1), conditional=(">=", 10))
+
+
+
+

Dice pools and other variations

+

You can only roll one set of dice at a time. If your RPG requires you to roll multiple +sets of dice and combine them in more advanced ways, you can do so with multiple +roll() calls. Depending on what you need, you may just want to express this as +helper functions specific for your game.

+

Here’s how to roll a D&D advantage roll (roll d20 twice, pick highest):

+
    from evennia.contrib.rpg.dice import roll
+
+    def roll_d20_with_advantage():
+        """Get biggest result of two d20 rolls"""
+        return max(roll("d20"), roll("d20"))
+
+
+
+

Here’s an example of a Free-League style dice pool, where you roll a pile of d6 +and want to know how many 1s and sixes you get:

+
from evennia.contrib.rpg.dice import roll
+
+def roll_dice_pool(poolsize):
+    """Return (number_of_ones, number_of_sixes)"""
+    results = [roll("1d6") for _ in range(poolsize)]
+    return results.count(1), results.count(6)
+
+
+
+
+
+

Get all roll details

+

If you need the individual rolls (e.g. for a dice pool), set the return_tuple kwarg:

+
roll("3d10 > 10", return_tuple=True)
+(13, True, 3, (3, 4, 6))  # (result, outcome, diff, rolls)
+
+
+

The return is a tuple (result, outcome, diff, rolls), where result is the +result of the roll, outcome is True/False if a conditional was +given (None otherwise), diff is the absolute difference between the +conditional and the result (None otherwise) and rolls is a tuple containing +the individual roll results.

+
+

This document page is generated from evennia/contrib/rpg/dice/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Email-Login.html b/docs/latest/Contribs/Contrib-Email-Login.html new file mode 100644 index 0000000000..b88ab3edd6 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Email-Login.html @@ -0,0 +1,170 @@ + + + + + + + + + Email-based login system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Email-based login system

+

Contrib by Griatch, 2012

+

This is a variant of the login system that asks for an email-address +instead of a username to login. Note that it does not verify the email, +it just uses it as the identifier rather than a username.

+

This used to be the default Evennia login before replacing it with a +more standard username + password system (having to supply an email +for some reason caused a lot of confusion when people wanted to expand +on it. The email is not strictly needed internally, nor is any +confirmation email sent out anyway).

+
+

Installation

+

To your settings file, add/edit the line:

+
CMDSET_UNLOGGEDIN = "contrib.base_systems.email_login.UnloggedinCmdSet"
+CONNECTION_SCREEN_MODULE = "contrib.base_systems.email_login.connection_screens"
+
+
+
+

That’s it. Reload the server and reconnect to see it.

+
+
+

Notes:

+

If you want to modify the way the connection screen looks, point +CONNECTION_SCREEN_MODULE to your own module. Use the default as a +guide (see also Evennia docs).

+
+

This document page is generated from evennia/contrib/base_systems/email_login/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Evadventure.html b/docs/latest/Contribs/Contrib-Evadventure.html new file mode 100644 index 0000000000..b543787592 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Evadventure.html @@ -0,0 +1,178 @@ + + + + + + + + + EvAdventure — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

EvAdventure

+

Contrib by Griatch 2023-

+
+

Warning

+

NOTE - this tutorial is WIP and NOT complete yet! You will still learn +things from it, but don’t expect perfection.

+
+

A complete example MUD using Evennia. This is the final result of what is +implemented if you follow Part 3 of the Getting-Started tutorial. +It’s recommended that you follow the tutorial step by step and write your own +code. But if you prefer you can also pick apart or use this as a starting point +for your own game.

+
+

Features

+
    +
  • Uses a MUD-version of the Knave old-school +fantasy ruleset by Ben Milton (classless and overall compatible with early +edition D&D), released under the Creative Commons Attribution (all uses, +including commercial are allowed +as long as attribution is given).

  • +
  • Character creation using an editable character sheet

  • +
  • Weapons, effects, healing and resting

  • +
  • Two alternative combat systems (turn-based and twitch based)

  • +
  • Magic (three spells)

  • +
  • NPC/mobs with simple AI.

  • +
  • Simple Quest system.

  • +
  • Small game world.

  • +
  • Coded using best Evennia practices, with unit tests.

  • +
+
+
+

Installation

+

TODO

+
+

This document page is generated from evennia/contrib/tutorials/evadventure/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Evscaperoom.html b/docs/latest/Contribs/Contrib-Evscaperoom.html new file mode 100644 index 0000000000..206b739836 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Evscaperoom.html @@ -0,0 +1,262 @@ + + + + + + + + + EvscapeRoom — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

EvscapeRoom

+

Contribution by Griatch, 2019

+

A full engine for creating multiplayer escape-rooms in Evennia. Allows players to +spawn and join puzzle rooms that track their state independently. Any number of players +can join to solve a room together. This is the engine created for ‘EvscapeRoom’, which won +the MUD Coders Guild “One Room” Game Jam in April-May, 2019. The contrib has no game +content but contains the utilities and base classes and an empty example room.

+

The original code for the contest is found at +https://github.com/Griatch/evscaperoom but the version on the public Evennia +demo is more updated, so if you really want the latest bug fixes etc you should +rather look at https://github.com/evennia/evdemo/tree/master/evdemo/evscaperoom +instead. A copy of the full game can also be played on the Evennia demo server +at https://demo.evennia.com - just connect to the server and write evscaperoom +in the first room to start!

+
+

Introduction

+

Evscaperoom is, as it sounds, an escaperoom in text form. You start locked into +a room and have to figure out how to get out. This engine contains everything +needed to make a fully-featured puzzle game of this type!

+
+
+

Installation

+

The Evscaperoom is installed by adding the evscaperoom command to your +character cmdset. When you run that command in-game you’re ready to play!

+

In mygame/commands/default_cmdsets.py:

+

+from evennia.contrib.full_systems.evscaperoom.commands import CmdEvscapeRoomStart
+
+class CharacterCmdSet(...):
+
+  # ...
+
+  self.add(CmdEvscapeRoomStart())
+
+
+
+

Reload the server and the evscaperoom command will be available. The contrib +comes with a small (very small) escape room as an example.

+
+
+

Making your own evscaperoom

+

To do this, you need to make your own states. First make sure you can play the +simple example room installed above.

+

Copy evennia/contrib/full_systems/evscaperoom/states to somewhere in your game folder (let’s +assume you put it under mygame/world/).

+

Next you need to re-point Evennia to look for states in this new location. Add +the following to your mygame/server/conf/settings.py file:

+
  EVSCAPEROOM_STATE_PACKAGE = "world.states"
+
+
+
+

Reload and the example evscaperoom should still work, but you can now modify and +expand it from your game dir!

+
+

Other useful settings

+

There are a few other settings that may be useful:

+
    +
  • EVSCAPEROOM_START_STATE - default is state_001_start and is the name of +the state-module to start from (without .py). You can change this if you +want some other naming scheme.

  • +
  • HELP_SUMMARY_TEXT - this is the help blurb shown when entering help in +the room without an argument. The original is found at the top of +evennia/contrib/full_systems/evscaperoom/commands.py.

  • +
+
+
+
+

Playing the game

+

You should start by looking around and at objects.

+

The examine <object> command allows you to ‘focus’ on an object. When you do +you’ll learn actions you could try for the object you are focusing on, such as +turning it around, read text on it or use it with some other object. Note that +more than one player can focus on the same object, so you won’t block anyone +when you focus. Focusing on another object or use examine again will remove +focus.

+

There is also a full hint system.

+
+
+

Technical

+

When connecting to the game, the user has the option to join an existing room +(which may already be in some state of ongoing progress), or may create a fresh +room for them to start solving on their own (but anyone may still join them later).

+

The room will go through a series of ‘states’ as the players progress through +its challenges. These states are describes as modules in .states/ and the +room will load and execute the State-object within each module to set up +and transition between states as the players progress. This allows for isolating +the states from each other and will hopefully make it easier to track +the logic and (in principle) inject new puzzles later.

+

Once no players remain in the room, the room and its state will be wiped.

+
+
+

Design Philosophy

+

Some basic premises inspired the design of this.

+
    +
  • You should be able to resolve the room alone. So no puzzles should require the +collaboration of multiple players. This is simply because there is no telling +if others will actually be online at a given time (or stay online throughout).

  • +
  • You should never be held up by the actions/inactions of other players. This +is why you cannot pick up anything (no inventory system) but only +focus/operate on items. This avoids the annoying case of a player picking up +a critical piece of a puzzle and then logging off.

  • +
  • A room’s state changes for everyone at once. My first idea was to have a given +room have different states depending on who looked (so a chest could be open +and closed to two different players at the same time). But not only does this +add a lot of extra complexity, it also defeats the purpose of having multiple +players. This way people can help each other and collaborate like in a ‘real’ +escape room. For people that want to do it all themselves I instead made it +easy to start “fresh” rooms for them to take on.

  • +
+

All other design decisions flowed from these.

+
+

This document page is generated from evennia/contrib/full_systems/evscaperoom/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Extended-Room.html b/docs/latest/Contribs/Contrib-Extended-Room.html new file mode 100644 index 0000000000..9299e4fed9 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Extended-Room.html @@ -0,0 +1,322 @@ + + + + + + + + + Extended Room — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Extended Room

+

Contribution - Griatch 2012, vincent-lg 2019, Griatch 2023

+

This extends the normal Room typeclass to allow its description to change with +time-of-day and/or season as well as any other state (like flooded or dark). +Embedding $state(burning, This place is on fire!) in the description will +allow for changing the description based on room state. The room also supports +details for the player to look at in the room (without having to create a new +in-game object for each), as well as support for random echoes. The room +comes with a set of alternate commands for look and @desc, as well as new +commands detail, roomstate and time.

+
+

Installation

+

Add the ExtendedRoomCmdset to the default character cmdset will add all +new commands for use.

+

In more detail, in mygame/commands/default_cmdsets.py:

+
...
+from evennia.contrib.grid import extended_room   # <---
+
+class CharacterCmdset(default_cmds.CharacterCmdSet):
+    ...
+    def at_cmdset_creation(self):
+        super().at_cmdset_creation()
+        ...
+        self.add(extended_room.ExtendedRoomCmdSet)  # <---
+
+
+
+

Then reload to make the new commands available. Note that they only work +on rooms with the typeclass ExtendedRoom. Create new rooms with the right +typeclass or use the typeclass command to swap existing rooms. Note that since +this contrib overrides the look and @desc commands, you will need to add the +extended_room.ExtendedRoomCmdSet to the default character cmdset after +super().at_cmdset_creation(), or they will be overridden by the default look.

+

To dig a new extended room:

+
dig myroom:evennia.contrib.grid.extended_room.ExtendedRoom = north,south
+
+
+

To make all new rooms ExtendedRooms without having to specify it, make your +Room typeclass inherit from the ExtendedRoom and then reload:

+
# in mygame/typeclasses/rooms.py
+
+from evennia.contrib.grid.extended_room import ExtendedRoom
+
+# ...
+
+class Room(ObjectParent, ExtendedRoom):
+    # ...
+
+
+
+
+
+

Features

+
+

State-dependent description slots

+

By default, the normal room.db.desc description is used. You can however +add new state-ful descriptions with room.add_desc(description, room_state=roomstate) or with the in-game command

+
@desc/roomstate [<description>]
+
+
+

For example

+
@desc/dark This room is pitch black.`.
+
+
+
+

These will be stored in Attributes desc_<roomstate>. To set the default, +fallback description, just use @desc <description>. +To activate a state on the room, use room.add/remove_state(*roomstate) or the in-game +command

+
roomstate <state>      (use it again to toggle the state off)
+
+
+

For example

+
roomstate dark
+
+
+

There is one in-built, time-based state season. By default these are ‘spring’, +‘summer’, ‘autumn’ and ‘winter’. The room.get_season() method returns the +current season based on the in-game time. By default they change with a 12-month +in-game time schedule. You can control them with

+
ExtendedRoom.months_per_year      # default 12
+ExtendedRoom.seasons_per year     # a dict of {"season": (start, end), ...} where
+                                  # start/end are given in fractions of the whole year
+
+
+

To set a seasonal description, just set it as normal, with room.add_desc or +in-game with

+
@desc/winter This room is filled with snow.
+@desc/autumn Red and yellow leaves cover the ground.
+
+
+

Normally the season changes with the in-game time, you can also ‘force’ a given +season by setting its state

+
roomstate winter
+
+
+

If you set the season manually like this, it won’t change automatically again +until you unset it.

+

You can get the stateful description from the room with room.get_stateful_desc().

+
+
+

Changing parts of description based on state

+

All descriptions can have embedded $state(roomstate, description) +FuncParser tags embedded in them. Here is an example:

+
room.add_desc("This a nice beach. "
+              "$state(empty, It is completely empty)"
+              "$state(full, It is full of people).", room_state="summer")
+
+
+

This is a summer-description with special embedded strings. If you set the room +with

+
> room.add_room_state("summer", "empty")
+> room.get_stateful_desc()
+
+This is a nice beach. It is completely empty.
+
+> room.remove_room_state("empty")
+> room.add_room_state("full")
+> room.get_stateful_desc()
+
+This is a nice beach. It is full of people.
+
+
+

There are four default time-of-day states that are meant to be used with these tags. The +room tracks and changes these automatically. By default they are ‘morning’, +‘afternoon’, ‘evening’ and ‘night’. You can get the current time-slot with +room.get_time_of_day. You can control them with

+
ExtendedRoom.hours_per_day    # default 24
+ExtendedRoom.times_of_day     # dict of {season: (start, end), ...} where
+                              # the start/end are given as fractions of the day.
+
+
+

You use these inside descriptions as normal:

+
"A glade. $(morning, The morning sun shines down through the branches)."
+
+
+
+
+

Details

+

Details are “virtual” targets to look at in a room, without having to create a +new database instance for every thing. It’s good to add more information to a +location. The details are stored as strings in a dictionary.

+
detail window = There is a window leading out.
+detail rock = The rock has a text written on it: 'Do not dare lift me'.
+
+
+

When you are in the room you can then do look window or look rock and get +the matching detail-description. This requires the new custom look command.

+
+
+

Random echoes

+

The ExtendedRoom supports random echoes. Just set them as an Attribute list +room_messages:

+
room.room_message_rate = 120   # in seconds. 0 to disable
+room.db.room_messages = ["A car passes by.", "You hear the sound of car horns."]
+room.start_repeat_broadcast_messages()   # also a server reload works
+
+
+

These will start randomly echoing to the room every 120s.

+
+
+

Extra commands

+
    +
  • CmdExtendedRoomLook (look) - look command supporting room details

  • +
  • CmdExtendedRoomDesc (@desc) - desc command allowing to add stateful descs,

  • +
  • CmdExtendeRoomState (roomstate) - toggle room states

  • +
  • CmdExtendedRoomDetail (detail) - list and manipulate room details

  • +
  • CmdExtendedRoomGameTime (time) - Shows the current time and season in the room.

  • +
+
+

This document page is generated from evennia/contrib/grid/extended_room/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Fieldfill.html b/docs/latest/Contribs/Contrib-Fieldfill.html new file mode 100644 index 0000000000..e2ec71089d --- /dev/null +++ b/docs/latest/Contribs/Contrib-Fieldfill.html @@ -0,0 +1,291 @@ + + + + + + + + + Easy fillable form — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Easy fillable form

+

Contribution by Tim Ashley Jenkins, 2018

+

This module contains a function that generates an EvMenu for you - this +menu presents the player with a form of fields that can be filled +out in any order (e.g. for character generation or building). Each field’s value can +be verified, with the function allowing easy checks for text and integer input, +minimum and maximum values / character lengths, or can even be verified by a custom +function. Once the form is submitted, the form’s data is submitted as a dictionary +to any callable of your choice.

+
+

Usage

+

The function that initializes the fillable form menu is fairly simple, and +includes the caller, the template for the form, and the callback(caller, result) +to which the form data will be sent to upon submission.

+
init_fill_field(formtemplate, caller, formcallback)
+
+
+

Form templates are defined as a list of dictionaries - each dictionary +represents a field in the form, and contains the data for the field’s name and +behavior. For example, this basic form template will allow a player to fill out +a brief character profile:

+
PROFILE_TEMPLATE = [
+    {"fieldname":"Name", "fieldtype":"text"},
+    {"fieldname":"Age", "fieldtype":"number"},
+    {"fieldname":"History", "fieldtype":"text"},
+]
+
+
+

This will present the player with an EvMenu showing this basic form:

+
      Name:
+       Age:
+   History:
+
+
+

While in this menu, the player can assign a new value to any field with the +syntax = , like so:

+
    > name = Ashley
+    Field 'Name' set to: Ashley
+
+
+

Typing ‘look’ by itself will show the form and its current values.

+
    > look
+
+      Name: Ashley
+       Age:
+    History:
+
+
+

Number fields require an integer input, and will reject any text that can’t +be converted into an integer.

+
    > age = youthful
+    Field 'Age' requires a number.
+    > age = 31
+    Field 'Age' set to: 31
+
+
+

Form data is presented as an EvTable, so text of any length will wrap cleanly.

+
    > history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...]
+    Field 'History' set to: EVERY MORNING I WAKE UP AND[...]
+    > look
+
+      Name: Ashley
+       Age: 31
+   History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT.
+            IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING
+            THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY
+            MOVE AND I DO EVERY MOVE HARD.
+
+
+

When the player types ‘submit’ (or your specified submit command), the menu +quits and the form’s data is passed to your specified function as a dictionary, +like so:

+
formdata = {"Name":"Ashley", "Age":31, "History":"EVERY MORNING I[...]"}
+
+
+

You can do whatever you like with this data in your function - forms can be used +to set data on a character, to help builders create objects, or for players to +craft items or perform other complicated actions with many variables involved.

+

The data that your form will accept can also be specified in your form template - +let’s say, for example, that you won’t accept ages under 18 or over 100. You can +do this by specifying “min” and “max” values in your field’s dictionary:

+
    PROFILE_TEMPLATE = [
+    {"fieldname":"Name", "fieldtype":"text"},
+    {"fieldname":"Age", "fieldtype":"number", "min":18, "max":100},
+    {"fieldname":"History", "fieldtype":"text"}
+    ]
+
+
+

Now if the player tries to enter a value out of range, the form will not acept the +given value.

+
    > age = 10
+    Field 'Age' reqiures a minimum value of 18.
+    > age = 900
+    Field 'Age' has a maximum value of 100.
+
+
+

Setting ‘min’ and ‘max’ for a text field will instead act as a minimum or +maximum character length for the player’s input.

+

There are lots of ways to present the form to the player - fields can have default +values or show a custom message in place of a blank value, and player input can be +verified by a custom function, allowing for a great deal of flexibility. There +is also an option for ‘bool’ fields, which accept only a True / False input and +can be customized to represent the choice to the player however you like (E.G. +Yes/No, On/Off, Enabled/Disabled, etc.)

+

This module contains a simple example form that demonstrates all of the included +functionality - a command that allows a player to compose a message to another +online character and have it send after a custom delay. You can test it by +importing this module in your game’s default_cmdsets.py module and adding +CmdTestMenu to your default character’s command set.

+
+
+

FIELD TEMPLATE KEYS:

+
+

Required:

+
    fieldname (str): Name of the field, as presented to the player.
+    fieldtype (str): Type of value required: 'text', 'number', or 'bool'.
+
+
+
+
+

Optional:

+
    +
  • max (int): Maximum character length (if text) or value (if number).

  • +
  • min (int): Minimum charater length (if text) or value (if number).

  • +
  • truestr (str): String for a ‘True’ value in a bool field. +(E.G. ‘On’, ‘Enabled’, ‘Yes’)

  • +
  • falsestr (str): String for a ‘False’ value in a bool field. +(E.G. ‘Off’, ‘Disabled’, ‘No’)

  • +
  • default (str): Initial value (blank if not given).

  • +
  • blankmsg (str): Message to show in place of value when field is blank.

  • +
  • cantclear (bool): Field can’t be cleared if True.

  • +
  • required (bool): If True, form cannot be submitted while field is blank.

  • +
  • verifyfunc (callable): Name of a callable used to verify input - takes +(caller, value) as arguments. If the function returns True, +the player’s input is considered valid - if it returns False, +the input is rejected. Any other value returned will act as +the field’s new value, replacing the player’s input. This +allows for values that aren’t strings or integers (such as +object dbrefs). For boolean fields, return ‘0’ or ‘1’ to set +the field to False or True.

  • +
+
+

This document page is generated from evennia/contrib/utils/fieldfill/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Gendersub.html b/docs/latest/Contribs/Contrib-Gendersub.html new file mode 100644 index 0000000000..eb3990cae7 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Gendersub.html @@ -0,0 +1,210 @@ + + + + + + + + + Gendersub — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Gendersub

+

Contribution by Griatch 2015

+

This is a simple gender-aware Character class for allowing users to +insert custom markers in their text to indicate gender-aware +messaging. It relies on a modified msg() and is meant as an +inspiration and starting point to how to do stuff like this.

+

An object can have the following genders:

+
    +
  • male (he/his)

  • +
  • female (her/hers)

  • +
  • neutral (it/its)

  • +
  • ambiguous (they/them/their/theirs)

  • +
+
+

Installation

+

Import and add the SetGender command to your default cmdset in +mygame/commands/default_cmdset.py:

+
# mygame/commands/default_cmdsets.py
+
+# ...
+
+from evennia.contrib.game_systems.gendersub import SetGender   # <---
+
+# ...
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(SetGender())   # <---
+
+
+

Make your Character inherit from GenderCharacter.

+
# mygame/typeclasses/characters.py
+
+# ...
+
+from evennia.contrib.game_systems.gendersub import GenderCharacter  # <---
+
+class Character(GenderCharacter):  # <---
+    # ...
+
+
+

Reload the server (evennia reload or reload from inside the game).

+
+
+

Usage

+

When in use, messages can contain special tags to indicate pronouns gendered +based on the one being addressed. Capitalization will be retained.

+
    +
  • |s, |S: Subjective form: he, she, it, He, She, It, They

  • +
  • |o, |O: Objective form: him, her, it, Him, Her, It, Them

  • +
  • |p, |P: Possessive form: his, her, its, His, Her, Its, Their

  • +
  • |a, |A: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs

  • +
+

For example,

+
char.msg("%s falls on |p face with a thud." % char.key)
+"Tom falls on his face with a thud"
+
+
+

The default gender is “ambiguous” (they/them/their/theirs).

+

To use, have DefaultCharacter inherit from this, or change +setting.DEFAULT_CHARACTER to point to this class.

+

The gender command is used to set the gender. It needs to be added to the +default cmdset before it becomes available.

+
+

This document page is generated from evennia/contrib/game_systems/gendersub/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Git-Integration.html b/docs/latest/Contribs/Contrib-Git-Integration.html new file mode 100644 index 0000000000..52bb9605fb --- /dev/null +++ b/docs/latest/Contribs/Contrib-Git-Integration.html @@ -0,0 +1,210 @@ + + + + + + + + + In-game Git Integration — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

In-game Git Integration

+

Contribution by helpme (2022)

+

A module to integrate a stripped-down version of git within the game, allowing developers to view their git status, change branches, and pull updated code of both their local mygame repo and Evennia core. After a successful pull or checkout, the git command will reload the game: Manual restarts may be required to to apply certain changes that would impact persistent scripts etc.

+

Once the contrib is set up, integrating remote changes is as simple as entering the following into your game:

+
git pull
+
+
+

The repositories you want to work with, be it only your local mygame repo, only Evennia core, or both, must be git directories for the command to function. If you are only interested in using this to get upstream Evennia changes, only the Evennia repository needs to be a git repository. Get started with version control here.

+
+

Dependencies

+

This package requires the dependency “gitpython”, a python library used to +interact with git repositories. To install, it’s easiest to install Evennia’s +extra requirements:

+
pip install evennia[extra]
+
+
+

If you installed with git you can also do

+
    +
  • cd to the root of the Evennia repository.

  • +
  • pip install --upgrade -e .[extra]

  • +
+
+
+

Installation

+

This utility adds a simple assortment of ‘git’ commands. Import the module into your commands and add it to your command set to make it available.

+

Specifically, in mygame/commands/default_cmdsets.py:

+
...
+from evennia.contrib.utils.git_integration import GitCmdSet   # <---
+
+class CharacterCmdset(default_cmds.Character_CmdSet):
+    ...
+    def at_cmdset_creation(self):
+        ...
+        self.add(GitCmdSet)  # <---
+
+
+
+

Then reload to make the git command available.

+
+
+

Usage

+

This utility will only work if the directory you wish to work with is a git directory. If they are not, you will be prompted to initiate your directory as a git repository using the following commands in your terminal:

+
git init
+git remote add origin 'link to your repository'
+
+
+

By default, the git commands are only available to those with Developer permissions and higher. You can change this by overriding the command and setting its locks from “cmd:pperm(Developer)” to the lock of your choice.

+

The supported commands are:

+
    +
  • git status: An overview of your git repository, which files have been changed locally, and the commit you’re on.

  • +
  • git branch: What branches are available for you to check out.

  • +
  • git checkout ‘branch’: Checkout a branch.

  • +
  • git pull: Pull the latest code from your current branch.

  • +
  • All of these commands are also available with ‘evennia’, to serve the same functionality related to your Evennia directory. So:

  • +
  • git evennia status

  • +
  • git evennia branch

  • +
  • git evennia checkout ‘branch’

  • +
  • git evennia pull: Pull the latest Evennia code.

  • +
+
+
+

Settings Used

+

The utility uses the existing GAME_DIR and EVENNIA_DIR settings from settings.py. You should not need to alter these if you have a standard directory setup, they ought to exist without any setup required from you.

+
+

This document page is generated from evennia/contrib/utils/git_integration/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Godotwebsocket.html b/docs/latest/Contribs/Contrib-Godotwebsocket.html new file mode 100644 index 0000000000..2ec5a37645 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Godotwebsocket.html @@ -0,0 +1,408 @@ + + + + + + + + + Godot Websocket — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Godot Websocket

+

Contribution by ChrisLR, 2022

+

This contrib allows you to connect a Godot Client directly to your mud, +and display regular text with color in Godot’s RichTextLabel using BBCode. +You can use Godot to provide advanced functionality with proper Evennia support.

+
+

Installation

+

You need to add the following settings in your settings.py and restart evennia.

+
PORTAL_SERVICES_PLUGIN_MODULES.append('evennia.contrib.base_systems.godotwebsocket.webclient')
+GODOT_CLIENT_WEBSOCKET_PORT = 4008
+GODOT_CLIENT_WEBSOCKET_CLIENT_INTERFACE = "127.0.0.1"
+
+
+

This will make evennia listen on the port 4008 for Godot. +You can change the port and interface as you want.

+
+
+

Usage

+

The tl;dr of it is to connect using a Godot Websocket using the port defined above. +It will let you transfer data from Evennia to Godot, allowing you +to get styled text in a RichTextLabel with bbcode enabled or to handle +the extra data given from Evennia as needed.

+

This section assumes you have basic knowledge on how to use Godot. +You can read the following url for more details on Godot Websockets +and to implement a minimal client.

+

https://docs.godotengine.org/en/stable/tutorials/networking/websocket.html

+

The rest of this document will be for Godot 3, an example is left at the bottom +of this readme for Godot 4.

+

At the top of the file you must change the url to point at your mud.

+
extends Node
+
+# The URL we will connect to
+export var websocket_url = "ws://localhost:4008"
+
+
+
+

You must also remove the protocol from the connect_to_url call made +within the _ready function.

+
func _ready():
+    # ...
+    # Change the following line from this
+    var err = _client.connect_to_url(websocket_url, ["lws-mirror-protocol"])
+    # To this
+    var err = _client.connect_to_url(websocket_url)
+    # ...
+
+
+

This will allow you to connect to your mud. +After that you need to properly handle the data sent by evennia. +To do this, you should replace your _on_data method. +You will need to parse the JSON received to properly act on the data. +Here is an example

+
func _on_data():
+    # The following two lines will get us the data from Evennia.
+	var data = _client.get_peer(1).get_packet().get_string_from_utf8()
+	var json_data = JSON.parse(data).result
+	# The json_data is an array
+
+	# The first element informs us this is simple text
+	# so we add it to the RichTextlabel
+	if json_data[0] == 'text':
+		for msg in json_data[1]: 			label.append_bbcode(msg)
+
+	# Always useful to print the data and see what we got.
+	print(data)
+
+
+

The first element is the type, it will be text if it is a message +It can be anything you would provide to the Evennia msg function. +The second element will be the data related to the type of message, in this case it is a list of text to display. +Since it is parsed BBCode, we can add that directly to a RichTextLabel by calling its append_bbcode method.

+

If you want anything better than fancy text in Godot, you will have +to leverage Evennia’s OOB to send extra data.

+

You can read more on OOB here.

+

In this example, we send coordinates whenever we message our character.

+

Evennia

+
caller.msg(coordinates=(9, 2))
+
+
+

Godot

+
func _on_data():
+    ...
+	if json_data[0] == 'text':
+		for msg in json_data[1]: 			label.append_bbcode(msg)
+
+	# Notice the first element is the name of the kwarg we used from evennia.
+	elif json_data[0] == 'coordinates':
+		var coords_data = json_data[2]
+		player.set_pos(coords_data)
+
+    ...
+
+
+

A good idea would be to set up Godot Signals you can trigger based on the data +you receive, so you can manage the code better.

+
+
+

Known Issues

+
    +
  • Sending SaverDicts and similar objects straight from Evennia .DB will cause issues, +cast them to dict() or list() before doing so.

  • +
  • Background colors are only supported by Godot 4.

  • +
+
+
+

Godot 3 Example

+

This is an example of a Script to use in Godot 3. +The script can be attached to the root UI node.

+
extends Node
+
+# The URL to connect to, should be your mud.
+export var websocket_url = "ws://127.0.0.1:4008"
+
+# These are references to controls in the scene
+onready var parent = get_parent()
+onready var label = parent.get_node("%ChatLog")
+onready var txtEdit = parent.get_node("%ChatInput")
+
+onready var room = get_node("/root/World/Room")
+
+# Our WebSocketClient instance
+var _client = WebSocketClient.new()
+
+var is_connected = false
+
+func _ready():
+	# Connect base signals to get notified of connection open, close, errors and messages
+	_client.connect("connection_closed", self, "_closed")
+	_client.connect("connection_error", self, "_closed")
+	_client.connect("connection_established", self, "_connected")
+	_client.connect("data_received", self, "_on_data")
+	print('Ready')
+
+	# Initiate connection to the given URL.
+	var err = _client.connect_to_url(websocket_url)
+	if err != OK:
+		print("Unable to connect")
+		set_process(false)
+
+func _closed(was_clean = false):
+	# was_clean will tell you if the disconnection was correctly notified
+	# by the remote peer before closing the socket.
+	print("Closed, clean: ", was_clean)
+	set_process(false)
+
+func _connected(proto = ""):
+	is_connected = true
+	print("Connected with protocol: ", proto)
+
+func _on_data():
+	# This is called when Godot receives data from evennia
+	var data = _client.get_peer(1).get_packet().get_string_from_utf8()
+	var json_data = JSON.parse(data).result
+	# Here we have the data from Evennia which is an array.
+	# The first element will be text if it is a message
+	# and would be the key of the OOB data you passed otherwise.
+	if json_data[0] == 'text':
+		# In this case, we simply append the data as bbcode to our label.
+		for msg in json_data[1]: 			label.append_bbcode(msg)
+	elif json_data[0] == 'coordinates':
+		# Dummy signal emitted if we wanted to handle the new coordinates
+		# elsewhere in the project.
+		self.emit_signal('updated_coordinates', json_data[1])
+
+
+	# We only print this for easier debugging.
+	print(data)
+
+func _process(delta):
+	# Required for websocket to properly react
+	_client.poll()
+
+func _on_button_send():
+	# This is called when we press the button in the scene
+	# with a connected signal, it sends the written message to Evennia.
+	var msg = txtEdit.text
+	var msg_arr = ['text', [msg], {}]
+	var msg_str = JSON.print(msg_arr)
+	_client.get_peer(1).put_packet(msg_str.to_utf8())
+
+func _notification(what):
+	# This is a special method that allows us to notify Evennia we are closing.
+	if what == MainLoop.NOTIFICATION_WM_QUIT_REQUEST:
+		if is_connected:
+			var msg_arr = ['text', ['quit'], {}]
+			var msg_str = JSON.print(msg_arr)
+			_client.get_peer(1).put_packet(msg_str.to_utf8())
+		get_tree().quit() # default behavior
+
+
+
+
+
+

Godot 4 Example

+

This is an example of a Script to use in Godot 4. +Note that the version is not final so the code may break. +It requires a WebSocketClientNode as a child of the root node. +The script can be attached to the root UI node.

+
extends Control
+
+# The URL to connect to, should be your mud.
+var websocket_url = "ws://127.0.0.1:4008"
+
+# These are references to controls in the scene
+@onready
+var label: RichTextLabel = get_node("%ChatLog")
+@onready
+var txtEdit: TextEdit = get_node("%ChatInput")
+@onready
+var websocket = get_node("WebSocketClient")
+
+func _ready():
+	# We connect the various signals
+	websocket.connect('connected_to_server', self._connected)
+	websocket.connect('connection_closed', self._closed)
+	websocket.connect('message_received', self._on_data)
+
+	# We attempt to connect and print out the error if we have one.
+	var result = websocket.connect_to_url(websocket_url)
+	if result != OK:
+		print('Could not connect:' + str(result))
+
+
+func _closed():
+	# This emits if the connection was closed by the remote host or unexpectedly
+	print('Connection closed.')
+	set_process(false)
+
+func _connected():
+	# This emits when the connection succeeds.
+	print('Connected!')
+
+func _on_data(data):
+	# This is called when Godot receives data from evennia
+	var json_data = JSON.parse_string(data)
+	# Here we have the data from Evennia which is an array.
+	# The first element will be text if it is a message
+	# and would be the key of the OOB data you passed otherwise.
+	if json_data[0] == 'text':
+		# In this case, we simply append the data as bbcode to our label.
+		for msg in json_data[1]: 			# Here we include a newline at every message.
+			label.append_text("\n" + msg)
+	elif json_data[0] == 'coordinates':
+		# Dummy signal emitted if we wanted to handle the new coordinates
+		# elsewhere in the project.
+		self.emit_signal('updated_coordinates', json_data[1])
+
+	# We only print this for easier debugging.
+	print(data)
+
+func _on_button_pressed():
+	# This is called when we press the button in the scene
+	# with a connected signal, it sends the written message to Evennia.
+	var msg = txtEdit.text
+	var msg_arr = ['text', [msg], {}]
+	var msg_str = JSON.stringify(msg_arr)
+	websocket.send(msg_str)
+
+
+
+
+

This document page is generated from evennia/contrib/base_systems/godotwebsocket/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Health-Bar.html b/docs/latest/Contribs/Contrib-Health-Bar.html new file mode 100644 index 0000000000..182cd2dab8 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Health-Bar.html @@ -0,0 +1,173 @@ + + + + + + + + + Health Bar — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Health Bar

+

Contribution by Tim Ashley Jenkins, 2017

+

The function provided in this module lets you easily display visual +bars or meters as a colorful bar instead of just a number. A “health bar” +is merely the most obvious use for this, but the bar is highly customizable +and can be used for any sort of appropriate data besides player health.

+

Today’s players may be more used to seeing statistics like health, +stamina, magic, and etc. displayed as bars rather than bare numerical +values, so using this module to present this data this way may make it +more accessible. Keep in mind, however, that players may also be using +a screen reader to connect to your game, which will not be able to +represent the colors of the bar in any way. By default, the values +represented are rendered as text inside the bar which can be read by +screen readers.

+
+

Usage

+

No installation, just import and use display_meter from this +module:

+
    from evennia.contrib.rpg.health_bar import display_meter
+
+    # health is 23/100
+    health_bar = display_meter(23, 100)
+    caller.msg(prompt=health_bar)
+
+
+
+

The health bar will account for current values above the maximum or +below 0, rendering them as a completely full or empty bar with the +values displayed within.

+
+

This document page is generated from evennia/contrib/rpg/health_bar/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Ingame-Map-Display.html b/docs/latest/Contribs/Contrib-Ingame-Map-Display.html new file mode 100644 index 0000000000..c25f0425a9 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Ingame-Map-Display.html @@ -0,0 +1,196 @@ + + + + + + + + + Basic Map — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Basic Map

+

Contribution - helpme 2022

+

This adds an ascii map to a given room which can be viewed with the map command. +You can easily alter it to add special characters, room colors etc. The map shown is +dynamically generated on use, and supports all compass directions and up/down. Other +directions are ignored.

+

If you don’t expect the map to be updated frequently, you could choose to save the +calculated map as a .ndb value on the room and render that instead of running mapping +calculations anew each time.

+
+

Installation:

+

Adding the MapDisplayCmdSet to the default character cmdset will add the map command.

+

Specifically, in mygame/commands/default_cmdsets.py:

+
...
+from evennia.contrib.grid.ingame_map_display import MapDisplayCmdSet   # <---
+
+class CharacterCmdset(default_cmds.CharacterCmdSet):
+    ...
+    def at_cmdset_creation(self):
+        ...
+        self.add(MapDisplayCmdSet)  # <---
+
+
+
+

Then reload to make the new commands available.

+
+
+

Settings:

+

In order to change your default map size, you can add to mygame/server/settings.py:

+
BASIC_MAP_SIZE = 5  # This changes the default map width/height.
+
+
+
+
+
+

Features:

+
+

ASCII map (and evennia supports UTF-8 characters and even emojis)

+

This produces an ASCII map for players of configurable size.

+
+
+

New command

+
    +
  • CmdMap - view the map

  • +
+
+

This document page is generated from evennia/contrib/grid/ingame_map_display/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Ingame-Python-Tutorial-Dialogue.html b/docs/latest/Contribs/Contrib-Ingame-Python-Tutorial-Dialogue.html new file mode 100644 index 0000000000..c69f743cb0 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Ingame-Python-Tutorial-Dialogue.html @@ -0,0 +1,364 @@ + + + + + + + + + Dialogues in events — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Dialogues in events

+

This tutorial will walk you through the steps to create several dialogues with +characters, using the Ingame-Python system. This tutorial assumes the in-game +Python system is installed in your game. If it isn’t, you can follow the +installation steps given in The main In-game Python +docs and come back on this tutorial once the +system is installed. You do not need to read the entire documentation, it’s +a good reference, but not the easiest way to learn about it. Hence these +tutorials.

+

The in-game Python system allows to run code on individual objects in some +situations. You don’t have to modify the source code to add these features, +past the installation. The entire system makes it easy to add specific features +to some objects, but not all. This is why it can be very useful to create a +dialogue system taking advantage of the in-game Python system.

+
+

What will we try to do?

+
+

In this tutorial, we are going to create a basic dialogue to have several characters automatically +respond to specific messages said by others.

+
+

A first example with a first character

+

Let’s create a character to begin with.

+
@charcreate a merchant
+
+
+

This will create a merchant in the room where you currently are. It doesn’t have anything, like a +description, you can decorate it a bit if you like.

+

As said above, the in-game Python system consists in linking objects with arbitrary code. This code +will be executed in some circumstances. Here, the circumstance is “when someone says something in +the same room”, and might be more specific like “when someone says hello”. We’ll decide what code +to run (we’ll actually type the code in-game). Using the vocabulary of the in-game Python system, +we’ll create a callback: a callback is just a set of lines of code that will run under some +conditions.

+

You can have an overview of every “conditions” in which callbacks can be created using the @call +command (short for @callback). You need to give it an object as argument. Here for instance, we +could do:

+
@call a merchant
+
+
+

You should see a table with three columns, showing the list of events existing on our newly-created +merchant. There are quite a lot of them, as it is, althougn no line of code has been set yet. For +our system, you might be more interested by the line describing the say event:

+
| say              |   0 (0) | After another character has said something in |
+|                  |         | the character's room.                         |
+
+
+

We’ll create a callback on the say event, called when we say “hello” in the merchant’s room:

+
@call/add a merchant = say hello
+
+
+

Before seeing what this command displays, let’s see the command syntax itself:

+
    +
  • @call is the command name, /add is a switch. You can read the help of the command to get the +help of available switches and a brief overview of syntax.

  • +
  • We then enter the object’s name, here “a merchant”. You can enter the ID too (“#3” in my case), +which is useful to edit the object when you’re not in the same room. You can even enter part of the +name, as usual.

  • +
  • An equal sign, a simple separator.

  • +
  • The event’s name. Here, it’s “say”. The available events are displayed when you use @call +without switch.

  • +
  • After a space, we enter the conditions in which this callback should be called. Here, the +conditions represent what the other character should say. We enter “hello”. Meaning that if +someone says something containing “hello” in the room, the callback we are now creating will be +called.

  • +
+

When you enter this command, you should see something like this:

+
After another character has said something in the character's room.
+This event is called right after another character has said
+something in the same location.  The action cannot be prevented
+at this moment.  Instead, this event is ideal to create keywords
+that would trigger a character (like a NPC) in doing something
+if a specific phrase is spoken in the same location.
+
+To use this event, you have to specify a list of keywords as
+parameters that should be present, as separate words, in the
+spoken phrase.  For instance, you can set a callback that would
+fire if the phrase spoken by the character contains "menu" or
+"dinner" or "lunch":
+    @call/add ... = say menu, dinner, lunch
+Then if one of the words is present in what the character says,
+this callback will fire.
+
+Variables you can use in this event:
+    speaker: the character speaking in this room.
+    character: the character connected to this event.
+    message: the text having been spoken by the character.
+
+
+

That’s some list of information. What’s most important to us now is:

+
    +
  • The “say” event is called whenever someone else speaks in the room.

  • +
  • We can set callbacks to fire when specific keywords are present in the phrase by putting them as +additional parameters. Here we have set this parameter to “hello”. We can have several keywords +separated by a comma (we’ll see this in more details later).

  • +
  • We have three default variables we can use in this callback: speaker which contains the +character who speaks, character which contains the character who’s modified by the in-game Python +system (here, or merchant), and message which contains the spoken phrase.

  • +
+

This concept of variables is important. If it makes things more simple to you, think of them as +parameters in a function: they can be used inside of the function body because they have been set +when the function was called.

+

This command has opened an editor where we can type our Python code.

+
----------Line Editor [Callback say of a merchant]--------------------------------
+01|
+----------[l:01 w:000 c:0000]------------(:h for help)----------------------------
+
+
+

For our first test, let’s type something like:

+
character.location.msg_contents("{character} shrugs and says: 'well, yes, hello to you!'",
+mapping=dict(character=character))
+
+
+

Once you have entered this line, you can type :wq to save the editor and quit it.

+

And now if you use the “say” command with a message containing “hello”:

+
You say, "Hello sir merchant!"
+a merchant(#3) shrugs and says: 'well, yes, hello to you!'
+
+
+

If you say something that doesn’t contain “hello”, our callback won’t execute.

+

In summary:

+
    +
  1. When we say something in the room, using the “say” command, the “say” event of all characters +(except us) is called.

  2. +
  3. The in-game Python system looks at what we have said, and checks whether one of our callbacks in +the “say” event contains a keyword that we have spoken.

  4. +
  5. If so, call it, defining the event variables as we have seen.

  6. +
  7. The callback is then executed as normal Python code. Here we have called the msg_contents +method on the character’s location (probably a room) to display a message to the entire room. We +have also used mapping to easily display the character’s name. This is not specific to the in-game +Python system. If you feel overwhelmed by the code we’ve used, just shorten it and use something +more simple, for instance:

  8. +
+
speaker.msg("You have said something to me.")
+
+
+
+
+

The same callback for several keywords

+

It’s easy to create a callback that will be triggered if the sentence contains one of several +keywords.

+
@call/add merchant = say trade, trader, goods
+
+
+

And in the editor that opens:

+
character.location.msg_contents("{character} says: 'Ho well, trade's fine as long as roads are
+safe.'", mapping=dict(character=character))
+
+
+

Then you can say something with either “trade”, “trader” or “goods” in your sentence, which should +call the callback:

+
You say, "and how is your trade going?"
+a merchant(#3) says: 'Ho well, trade's fine as long as roads are safe.'
+
+
+

We can set several keywords when adding the callback. We just need to separate them with commas.

+
+
+

A longer callback

+

So far, we have only set one line in our callbacks. Which is useful, but we often need more. For +an entire dialogue, you might want to do a bit more than that.

+
@call/add merchant = say bandit, bandits
+
+
+

And in the editor you can paste the following lines:

+
character.location.msg_contents("{character} says: 'Bandits he?'",
+mapping=dict(character=character))
+character.location.msg_contents("{character} scratches his head, considering.",
+mapping=dict(character=character))
+character.location.msg_contents("{character} whispers: 'Aye, saw some of them, north from here.  No
+trouble o' mine, but...'", mapping=dict(character=character))
+speaker.msg("{character} looks at you more
+closely.".format(character=character.get_display_name(speaker)))
+speaker.msg("{character} continues in a low voice: 'Ain't my place to say, but if you need to find
+'em, they're encamped some distance away from the road, I guess near a cave or
+something.'.".format(character=character.get_display_name(speaker)))
+
+
+

Now try to ask the merchant about bandits:

+
You say, "have you seen bandits?"
+a merchant(#3) says: 'Bandits he?'
+a merchant(#3) scratches his head, considering.
+a merchant(#3) whispers: 'Aye, saw some of them, north from here.  No trouble o' mine, but...'
+a merchant(#3) looks at you more closely.
+a merchant(#3) continues in a low voice: 'Ain't my place to say, but if you need to find 'em,
+they're encamped some distance away from the road, I guess near a cave or something.'.
+
+
+

Notice here that the first lines of dialogue are spoken to the entire room, but then the merchant is +talking directly to the speaker, and only the speaker hears it. There’s no real limit to what you +can do with this.

+
    +
  • You can set a mood system, storing attributes in the NPC itself to tell you in what mood he is, +which will influence the information he will give… perhaps the accuracy of it as well.

  • +
  • You can add random phrases spoken in some context.

  • +
  • You can use other actions (you’re not limited to having the merchant say something, you can ask +him to move, gives you something, attack if you have a combat system, or whatever else).

  • +
  • The callbacks are in pure Python, so you can write conditions or loops.

  • +
  • You can add in “pauses” between some instructions using chained events. This tutorial won’t +describe how to do that however. You already have a lot to play with.

  • +
+
+
+

Tutorial F.A.Q.

+
    +
  • Q: can I create several characters who would answer to specific dialogue?

  • +
  • A: of course. Te in-game Python system is so powerful because you can set unique code for +various objects. You can have several characters answering to different things. You can even have +different characters in the room answering to greetings. All callbacks will be executed one after +another.

  • +
  • Q: can I have two characters answering to the same dialogue in exactly the same way?

  • +
  • A: It’s possible but not so easy to do. Usually, event grouping is set in code, and depends +on different games. However, if it is for some infrequent occurrences, it’s easy to do using +chained events).

  • +
  • Q: is it possible to deploy callbacks on all characters sharing the same prototype?

  • +
  • A: not out of the box. This depends on individual settings in code. One can imagine that all +characters of some type would share some events, but this is game-specific. Rooms of the same zone could share the same events as well. It is possible to do but requires modification of the source code.

  • +
  • Next tutorial: [adding a voice-operated elevator with events](A-voice-operated-elevator-using- events).

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Ingame-Python-Tutorial-Elevator.html b/docs/latest/Contribs/Contrib-Ingame-Python-Tutorial-Elevator.html new file mode 100644 index 0000000000..1146edf004 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Ingame-Python-Tutorial-Elevator.html @@ -0,0 +1,557 @@ + + + + + + + + + A voice operated elevator using events — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

A voice operated elevator using events

+

This tutorial will walk you through the steps to create a voice-operated elevator, using the in- +game Python system. This tutorial assumes the in-game Python +system is installed per the instructions in that doc. You do not need to read the entire +documentation, it’s a good reference, but not the easiest way to learn about it. Hence these +tutorials.

+

The in-game Python system allows to run code on individual objects in some situations. You don’t +have to modify the source code to add these features, past the installation. The entire system +makes it easy to add specific features to some objects, but not all.

+
+

What will we try to do?

+
+

In this tutorial, we are going to create a simple voice-operated elevator. In terms of features, we +will:

+
    +
  • Explore events with parameters.

  • +
  • Work on more interesting callbacks.

  • +
  • Learn about chained events.

  • +
  • Play with variable modification in callbacks.

  • +
+
+

Our study case

+

Let’s summarize what we want to achieve first. We would like to create a room that will represent +the inside of our elevator. In this room, a character could just say “1”, “2” or “3”, and the +elevator will start moving. The doors will close and open on the new floor (the exits leading in +and out of the elevator will be modified).

+

We will work on basic features first, and then will adjust some, showing you how easy and powerfully +independent actions can be configured through the in-game Python system.

+
+
+

Creating the rooms and exits we need

+

We’ll create an elevator right in our room (generally called “Limbo”, of ID 2). You could easily +adapt the following instructions if you already have some rooms and exits, of course, just remember +to check the IDs.

+
+

Note: the in-game Python system uses IDs for a lot of things. While it is not mandatory, it is +good practice to know the IDs you have for your callbacks, because it will make manipulation much +quicker. There are other ways to identify objects, but as they depend on many factors, IDs are +usually the safest path in our callbacks.

+
+

Let’s go into limbo (#2) to add our elevator. We’ll add it to the north. To create this room, +in-game you could type:

+
tunnel n = Inside of an elevator
+
+
+

The game should respond by telling you:

+
Created room Inside of an elevator(#3) of type typeclasses.rooms.Room.
+Created Exit from Limbo to Inside of an elevator: north(#4) (n).
+Created Exit back from Inside of an elevator to Limbo: south(#5) (s).
+
+
+

Note the given IDs:

+
    +
  • #2 is limbo, the first room the system created.

  • +
  • #3 is our room inside of an elevator.

  • +
  • #4 is the north exit from Limbo to our elevator.

  • +
  • #5 is the south exit from an elevator to Limbo.

  • +
+

Keep these IDs somewhere for the demonstration. You will shortly see why they are important.

+
+

Why have we created exits to our elevator and back to Limbo? Isn’t the elevator supposed to move?

+
+

It is. But we need to have exits that will represent the way inside the elevator and out. What we +will do, at every floor, will be to change these exits so they become connected to the right room. +You’ll see this process a bit later.

+

We have two more rooms to create: our floor 2 and 3. This time, we’ll use dig, because we don’t +need exits leading there, not yet anyway.

+
dig The second floor
+dig The third floor
+
+
+

Evennia should answer with:

+
Created room The second floor(#6) of type typeclasses.rooms.Room.
+Created room The third floor(#7) of type typeclasses.rooms.Room.
+
+
+

Add these IDs to your list, we will use them too.

+
+
+

Our first callback in the elevator

+

Let’s go to the elevator (you could use tel #3 if you have the same IDs I have).

+

This is our elevator room. It looks a bit empty, feel free to add a prettier description or other +things to decorate it a bit.

+

But what we want now is to be able to say “1”, “2” or “3” and have the elevator move in that +direction.

+

If you have read +the other in-game Python tutorial about adding dialogues in events, you +may remember what we need to do. If not, here’s a summary: we need to run some code when somebody +speaks in the room. So we need to create a callback (the callback will contain our lines of code). +We just need to know on which event this should be set. You can enter call here to see the +possible events in this room.

+

In the table, you should see the “say” event, which is called when somebody says something in the +room. So we’ll need to add a callback to this event. Don’t worry if you’re a bit lost, just follow +the following steps, the way they connect together will become more obvious.

+
call/add here = say 1, 2, 3
+
+
+
    +
  1. We need to add a callback. A callback contains the code that will be executed at a given time. +So we use the call/add command and switch.

  2. +
  3. here is our object, the room in which we are.

  4. +
  5. An equal sign.

  6. +
  7. The name of the event to which the callback should be connected. Here, the event is “say”. +Meaning this callback will be executed every time somebody says something in the room.

  8. +
  9. But we add an event parameter to indicate the keywords said in the room that should execute our +callback. Otherwise, our callback would be called every time somebody speaks, no matter what. Here +we limit, indicating our callback should be executed only if the spoken message contains “1”, “2” or +“3”.

  10. +
+

An editor should open, inviting you to enter the Python code that should be executed. The first +thing to remember is to read the text provided (it can contain important information) and, most of +all, the list of variables that are available in this callback:

+
Variables you can use in this event:
+
+    character: the character having spoken in this room.
+    room: the room connected to this event.
+    message: the text having been spoken by the character.
+
+----------Line Editor [Callback say of Inside of an elevator]---------------------
+01|
+----------[l:01 w:000 c:0000]------------(:h for help)----------------------------
+
+
+

This is important, in order to know what variables we can use in our callback out-of-the-box. Let’s +write a single line to be sure our callback is called when we expect it to:

+
character.msg(f"You just said {message}.")
+
+
+

You can paste this line in-game, then type the :wq command to exit the editor and save your +modifications.

+

Let’s check. Try to say “hello” in the room. You should see the standard message, but nothing +more. Now try to say “1”. Below the standard message, you should see:

+
You just said 1.
+
+
+

You can try it. Our callback is only called when we say “1”, “2” or “3”. Which is just what we +want.

+

Let’s go back in our code editor and add something more useful.

+
call/edit here = say
+
+
+
+

Notice that we used the “edit” switch this time, since the callback exists, we just want to edit +it.

+
+

The editor opens again. Let’s empty it first:

+
:DD
+
+
+

And turn off automatic indentation, which will help us:

+
:=
+
+
+
+

Auto-indentation is an interesting feature of the code editor, but we’d better not use it at this +point, it will make copy/pasting more complicated.

+
+
+
+

Our entire callback in the elevator

+

So here’s the time to truly code our callback in-game. Here’s a little reminder:

+
    +
  1. We have all the IDs of our three rooms and two exits.

  2. +
  3. When we say “1”, “2” or “3”, the elevator should move to the right room, that is change the +exits. Remember, we already have the exits, we just need to change their location and destination.

  4. +
+

It’s a good idea to try to write this callback yourself, but don’t feel bad about checking the +solution right now. Here’s a possible code that you could paste in the code editor:

+
# First let's have some constants
+ELEVATOR = get(id=3)
+FLOORS = {
+    "1": get(id=2),
+    "2": get(id=6),
+    "3": get(id=7),
+}
+TO_EXIT = get(id=4)
+BACK_EXIT = get(id=5)
+
+# Now we check that the elevator isn't already at this floor
+floor = FLOORS.get(message)
+if floor is None:
+    character.msg("Which floor do you want?")
+elif TO_EXIT.location is floor:
+    character.msg("The elevator already is at this floor.")
+else:
+    # 'floor' contains the new room where the elevator should be
+    room.msg_contents("The doors of the elevator close with a clank.")
+    TO_EXIT.location = floor
+    BACK_EXIT.destination = floor
+    room.msg_contents("The doors of the elevator open to {floor}.",
+            mapping=dict(floor=floor))
+
+
+

Let’s review this longer callback:

+
    +
  1. We first obtain the objects of both exits and our three floors. We use the get() eventfunc, +which is a shortcut to obtaining objects. We usually use it to retrieve specific objects with an +ID. We put the floors in a dictionary. The keys of the dictionary are the floor number (as str), +the values are room objects.

  2. +
  3. Remember, the message variable contains the message spoken in the room. So either “1”, “2”, or +“3”. We still need to check it, however, because if the character says something like “1 2” in the +room, our callback will be executed. Let’s be sure what she says is a floor number.

  4. +
  5. We then check if the elevator is already at this floor. Notice that we use TO_EXIT.location. +TO_EXIT contains our “north” exit, leading inside of our elevator. Therefore, its location will +be the room where the elevator currently is.

  6. +
  7. If the floor is a different one, have the elevator “move”, changing just the location and +destination of both exits.

    +
      +
    • The BACK_EXIT (that is “north”) should change its location. The elevator shouldn’t be +accessible through our old floor.

    • +
    • The TO_EXIT (that is “south”, the exit leading out of the elevator) should have a different +destination. When we go out of the elevator, we should find ourselves in the new floor, not the old +one.

    • +
    +
  8. +
+

Feel free to expand on this example, changing messages, making further checks. Usage and practice +are keys.

+

You can quit the editor as usual with :wq and test it out.

+
+
+

Adding a pause in our callback

+

Let’s improve our callback. One thing that’s worth adding would be a pause: for the time being, +when we say the floor number in the elevator, the doors close and open right away. It would be +better to have a pause of several seconds. More logical.

+

This is a great opportunity to learn about chained events. Chained events are very useful to create +pauses. Contrary to the events we have seen so far, chained events aren’t called automatically. +They must be called by you, and can be called after some time.

+
    +
  • Chained events always have the name "chain_X". Usually, X is a number, but you can give the +chained event a more explicit name.

  • +
  • In our original callback, we will call our chained events in, say, 15 seconds.

  • +
  • We’ll also have to make sure the elevator isn’t already moving.

  • +
+

Other than that, a chained event can be connected to a callback as usual. We’ll create a chained +event in our elevator, that will only contain the code necessary to open the doors to the new floor.

+
call/add here = chain_1
+
+
+

The callback is added to the "chain_1" event, an event that will not be automatically called by the +system when something happens. Inside this event, you can paste the code to open the doors at the +new floor. You can notice a few differences:

+
TO_EXIT.location = floor
+TO_EXIT.destination = ELEVATOR
+BACK_EXIT.location = ELEVATOR
+BACK_EXIT.destination = floor
+room.msg_contents("The doors of the elevator open to {floor}.",
+        mapping=dict(floor=floor))
+
+
+

Paste this code into the editor, then use :wq to save and quit the editor.

+

Now let’s edit our callback in the “say” event. We’ll have to change it a bit:

+
    +
  • The callback will have to check the elevator isn’t already moving.

  • +
  • It must change the exits when the elevator move.

  • +
  • It has to call the "chain_1" event we have defined. It should call it 15 seconds later.

  • +
+

Let’s see the code in our callback.

+
call/edit here = say
+
+
+

Remove the current code and disable auto-indentation again:

+
:DD
+:=
+
+
+

And you can paste instead the following code. Notice the differences with our first attempt:

+
# First let's have some constants
+ELEVATOR = get(id=3)
+FLOORS = {
+    "1": get(id=2),
+    "2": get(id=6),
+    "3": get(id=7),
+}
+TO_EXIT = get(id=4)
+BACK_EXIT = get(id=5)
+
+# Now we check that the elevator isn't already at this floor
+floor = FLOORS.get(message)
+if floor is None:
+    character.msg("Which floor do you want?")
+elif BACK_EXIT.location is None:
+    character.msg("The elevator is between floors.")
+elif TO_EXIT.location is floor:
+    character.msg("The elevator already is at this floor.")
+else:
+    # 'floor' contains the new room where the elevator should be
+    room.msg_contents("The doors of the elevator close with a clank.")
+    TO_EXIT.location = None
+    BACK_EXIT.location = None
+    call_event(room, "chain_1", 15)
+
+
+

What changed?

+
    +
  1. We added a little test to make sure the elevator wasn’t already moving. If it is, the +BACK_EXIT.location (the “south” exit leading out of the elevator) should be None. We’ll remove +the exit while the elevator is moving.

  2. +
  3. When the doors close, we set both exits’ location to None. Which “removes” them from their +room but doesn’t destroy them. The exits still exist but they don’t connect anything. If you say +“2” in the elevator and look around while the elevator is moving, you won’t see any exits.

  4. +
  5. Instead of opening the doors immediately, we call call_event. We give it the object containing +the event to be called (here, our elevator), the name of the event to be called (here, “chain_1”) +and the number of seconds from now when the event should be called (here, 15).

  6. +
  7. The chain_1 callback we have created contains the code to “re-open” the elevator doors. That +is, besides displaying a message, it reset the exits’ location and destination.

  8. +
+

If you try to say “3” in the elevator, you should see the doors closing. Look around you and you +won’t see any exit. Then, 15 seconds later, the doors should open, and you can leave the elevator +to go to the third floor. While the elevator is moving, the exit leading to it will be +inaccessible.

+
+

Note: we don’t define the variables again in our chained event, we just call them. When we +execute call_event, a copy of our current variables is placed in the database. These variables +will be restored and accessible again when the chained event is called.

+
+

You can use the call/tasks command to see the tasks waiting to be executed. For instance, say “2” +in the room, notice the doors closing, and then type the call/tasks command. You will see a task +in the elevator, waiting to call the chain_1 event.

+
+
+

Changing exit messages

+

Here’s another nice little feature of events: you can modify the message of a single exit without +altering the others. In this case, when someone goes north into our elevator, we’d like to see +something like: “someone walks into the elevator.” Something similar for the back exit would be +great too.

+

Inside of the elevator, you can look at the available events on the exit leading outside (south).

+
call south
+
+
+

You should see two interesting rows in this table:

+
| msg_arrive       |   0 (0) | Customize the message when a character        |
+|                  |         | arrives through this exit.                    |
+| msg_leave        |   0 (0) | Customize the message when a character leaves |
+|                  |         | through this exit.                            |
+
+
+

So we can change the message others see when a character leaves, by editing the “msg_leave” event. +Let’s do that:

+
call/add south = msg_leave
+
+
+

Take the time to read the help. It gives you all the information you should need. We’ll need to +change the “message” variable, and use custom mapping (between braces) to alter the message. We’re +given an example, let’s use it. In the code editor, you can paste the following line:

+
message = "{character} walks out of the elevator."
+
+
+

Again, save and quit the editor by entering :wq. You can create a new character to see it leave.

+
charcreate A beggar
+tel #8 = here
+
+
+

(Obviously, adapt the ID if necessary.)

+
py self.search("beggar").move_to(self.search("south"))
+
+
+

This is a crude way to force our beggar out of the elevator, but it allows us to test. You should +see:

+
A beggar(#8) walks out of the elevator.
+
+
+

Great! Let’s do the same thing for the exit leading inside of the elevator. Follow the beggar, +then edit “msg_leave” of “north”:

+
call/add north = msg_leave
+
+
+
message = "{character} walks into the elevator."
+
+
+

Again, you can force our beggar to move and see the message we have just set. This modification +applies to these two exits, obviously: the custom message won’t be used for other exits. Since we +use the same exits for every floor, this will be available no matter at what floor the elevator is, +which is pretty neat!

+
+
+

Tutorial F.A.Q.

+
    +
  • Q: what happens if the game reloads or shuts down while a task is waiting to happen?

  • +
  • A: if your game reloads while a task is in pause (like our elevator between floors), when the +game is accessible again, the task will be called (if necessary, with a new time difference to take +into account the reload). If the server shuts down, obviously, the task will not be called, but +will be stored and executed when the server is up again.

  • +
  • Q: can I use all kinds of variables in my callback? Whether chained or not?

  • +
  • A: you can use every variable type you like in your original callback. However, if you +execute call_event, since your variables are stored in the database, they will need to respect the +constraints on persistent attributes. A callback will not be stored in this way, for instance. +This variable will not be available in your chained event.

  • +
  • Q: when you say I can call my chained events something else than “chain_1”, “chain_2” and +such, what is the naming convention?

  • +
  • A: chained events have names beginning by "chain_". This is useful for you and for the +system. But after the underscore, you can give a more useful name, like "chain_open_doors" in our +case.

  • +
  • Q: do I have to pause several seconds to call a chained event?

  • +
  • A: no, you can call it right away. Just leave the third parameter of call_event out (it +will default to 0, meaning the chained event will be called right away). This will not create a +task.

  • +
  • Q: can I have chained events calling themselves?

  • +
  • A: you can. There’s no limitation. Just be careful, a callback that calls itself, +particularly without delay, might be a good recipe for an infinite loop. However, in some cases, it +is useful to have chained events calling themselves, to do the same repeated action every X seconds +for instance.

  • +
  • Q: what if I need several elevators, do I need to copy/paste these callbacks each time?

  • +
  • A: not advisable. There are definitely better ways to handle this situation. One of them is +to consider adding the code in the source itself. Another possibility is to call chained events +with the expected behavior, which makes porting code very easy. This side of chained events will be +shown in the next tutorial.

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Ingame-Python.html b/docs/latest/Contribs/Contrib-Ingame-Python.html new file mode 100644 index 0000000000..2a6c497e9c --- /dev/null +++ b/docs/latest/Contribs/Contrib-Ingame-Python.html @@ -0,0 +1,1050 @@ + + + + + + + + + Evennia in-game Python system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia in-game Python system

+

Contrib by Vincent Le Goff 2017

+

This contrib adds the ability to script with Python in-game. It allows trusted +staff/builders to dynamically add features and triggers to individual objects +without needing to do it in external Python modules. Using custom Python in-game, +specific rooms, exits, characters, objects etc can be made to behave differently from +its “cousins”. This is similar to how softcode works for MU or MudProgs for DIKU. +Keep in mind, however, that allowing Python in-game comes with severe +security concerns (you must trust your builders deeply), so read the warnings in +this module carefully before continuing.

+
+

A WARNING REGARDING SECURITY

+

Evennia’s in-game Python system will run arbitrary Python code without much +restriction. Such a system is as powerful as potentially dangerous, and you +will have to keep in mind these points before deciding to install it:

+
    +
  1. Untrusted people can run Python code on your game server with this system. +Be careful about who can use this system (see the permissions below).

  2. +
  3. You can do all of this in Python outside the game. The in-game Python system +is not to replace all your game feature.

  4. +
+
+
+

Extra tutorials

+

These tutorials cover examples of using ingame python. Once you have the system +installed (see below) they may be an easier way to learn than reading the full +documentation from beginning to end.

+ +
+
+

Basic structure and vocabulary

+
    +
  • At the basis of the in-game Python system are events. An event +defines the context in which we would like to call some arbitrary code. For +instance, one event is defined on exits and will fire every time a character +traverses through this exit. Events are described on a typeclass +(exits in our example). All objects inheriting from this +typeclass will have access to this event.

  • +
  • Callbacks can be set on individual objects, on events defined in code. +These callbacks can contain arbitrary code and describe a specific +behavior for an object. When the event fires, all callbacks connected to this +object’s event are executed.

  • +
+

To see the system in context, when an object is picked up (using the default +get command), a specific event is fired:

+
    +
  1. The event “get” is set on objects (on the Object typeclass).

  2. +
  3. When using the “get” command to pick up an object, this object’s at_get +hook is called.

  4. +
  5. A modified hook of DefaultObject is set by the event system. This hook will +execute (or call) the “get” event on this object.

  6. +
  7. All callbacks tied to this object’s “get” event will be executed in order. +These callbacks act as functions containing Python code that you can write +in-game, using specific variables that will be listed when you edit the callback +itself.

  8. +
  9. In individual callbacks, you can add multiple lines of Python code that will +be fired at this point. In this example, the character variable will +contain the character who has picked up the object, while obj will contain the +object that was picked up.

  10. +
+

Following this example, if you create a callback “get” on the object “a sword”, +and put in it:

+
character.msg("You have picked up {} and have completed this quest!".format(obj.get_display_name(character)))
+
+
+
+

When you pick up this object you should see something like:

+
You pick up a sword.
+You have picked up a sword and have completed this quest!
+
+
+
+
+

Installation

+

Being in a separate contrib, the in-game Python system isn’t installed by +default. You need to do it manually, following these steps:

+

This is the quick summary. Scroll down for more detailed help on each step.

+
    +
  1. Launch the main script (important!):

    +
     py evennia.create_script("evennia.contrib.base_systems.ingame_python.scripts.EventHandler")
    +
    +
    +
  2. +
  3. Set the permissions (optional):

    +
      +
    • EVENTS_WITH_VALIDATION: a group that can edit callbacks, but will need approval (default to +None).

    • +
    • EVENTS_WITHOUT_VALIDATION: a group with permission to edit callbacks without need of +validation (default to "immortals").

    • +
    • EVENTS_VALIDATING: a group that can validate callbacks (default to "immortals").

    • +
    • EVENTS_CALENDAR: type of the calendar to be used (either None, "standard" or "custom", +default to None).

    • +
    +
  4. +
  5. Add the call command.

  6. +
  7. Inherit from the custom typeclasses of the in-game Python system.

    +
      +
    • evennia.contrib.base_systems.ingame_python.typeclasses.EventCharacter: to replace DefaultCharacter.

    • +
    • evennia.contrib.base_systems.ingame_python.typeclasses.EventExit: to replace DefaultExit.

    • +
    • evennia.contrib.base_systems.ingame_python.typeclasses.EventObject: to replace DefaultObject.

    • +
    • evennia.contrib.base_systems.ingame_python.typeclasses.EventRoom: to replace DefaultRoom.

    • +
    +
  8. +
+

The following sections describe in details each step of the installation.

+
+

Note: If you were to start the game without having started the main script (such as when +resetting your database) you will most likely face a traceback when logging in, telling you +that a ‘callback’ property is not defined. After performing step 1 the error will go away.

+
+
+

Starting the event script

+

To start the event script, you only need a single command, using @py.

+
py evennia.create_script("evennia.contrib.base_systems.ingame_python.scripts.EventHandler")
+
+
+

This command will create a global script (that is, a script independent from any object). This +script will hold basic configuration, individual callbacks and so on. You may access it directly, +but you will probably use the callback handler. Creating this script will also create a callback +handler on all objects (see below for details).

+
+
+

Editing permissions

+

This contrib comes with its own set of permissions. They define who can edit callbacks without +validation, and who can edit callbacks but needs validation. Validation is a process in which an +administrator (or somebody trusted as such) will check the callbacks produced by others and will +accept or reject them. If accepted, the callbacks are connected, otherwise they are never run.

+

By default, callbacks can only be created by immortals: no one except the immortals can edit +callbacks, and immortals don’t need validation. It can easily be changed, either through settings +or dynamically by changing permissions of users.

+

The ingame-python contrib adds three permissions) in the settings. You can +override them by changing the settings into your server/conf/settings.py file (see below for an +example). The settings defined in the events contrib are:

+
    +
  • EVENTS_WITH_VALIDATION: this defines a permission that can edit callbacks, but will need +approval. If you set this to "wizards", for instance, users with the permission "wizards" +will be able to edit callbacks. These callbacks will not be connected, though, and will need to be +checked and approved by an administrator. This setting can contain None, meaning that no user is +allowed to edit callbacks with validation.

  • +
  • EVENTS_WITHOUT_VALIDATION: this setting defines a permission allowing editing of callbacks +without needing validation. By default, this setting is set to "immortals". It means that +immortals can edit callbacks, and they will be connected when they leave the editor, without needing +approval.

  • +
  • EVENTS_VALIDATING: this last setting defines who can validate callbacks. By default, this is +set to "immortals", meaning only immortals can see callbacks needing validation, accept or +reject them.

  • +
+

You can override all these settings in your server/conf/settings.py file. For instance:

+
# ... other settings ...
+
+# Event settings
+EVENTS_WITH_VALIDATION = "wizards"
+EVENTS_WITHOUT_VALIDATION = "immortals"
+EVENTS_VALIDATING = "immortals"
+
+
+

In addition, there is another setting that must be set if you plan on using the time-related events +(events that are scheduled at specific, in-game times). You would need to specify the type of +calendar you are using. By default, time-related events are disabled. You can change the +EVENTS_CALENDAR to set it to:

+
    +
  • "standard": the standard calendar, with standard days, months, years and so on.

  • +
  • "custom": a custom calendar that will use the custom_gametime contrib to schedule events.

  • +
+

This contrib defines two additional permissions that can be set on individual users:

+
    +
  • events_without_validation: this would give this user the rights to edit callbacks but not +require validation before they are connected.

  • +
  • events_validating: this permission allows this user to run validation checks on callbacks +needing to be validated.

  • +
+

For instance, to give the right to edit callbacks without needing approval to the player ‘kaldara’, +you might do something like:

+
perm *kaldara = events_without_validation
+
+
+

To remove this same permission, just use the /del switch:

+
perm/del *kaldara = events_without_validation
+
+
+

The rights to use the call command are directly related to these permissions: by default, only +users who have the events_without_validation permission or are in (or above) the group defined in +the EVENTS_WITH_VALIDATION setting will be able to call the command (with different switches).

+
+
+

Adding the call command

+

You also have to add the @call command to your Character CmdSet. This command allows your users +to add, edit and delete callbacks in-game. In your commands/default_cmdsets, it might look like +this:

+
from evennia import default_cmds
+from evennia.contrib.base_systems.ingame_python.commands import CmdCallback
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    """
+    The `CharacterCmdSet` contains general in-game commands like `look`,
+    `get`, etc available on in-game Character objects. It is merged with
+    the `PlayerCmdSet` when a Player puppets a Character.
+    """
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+        """
+        Populates the cmdset
+        """
+        super().at_cmdset_creation()
+        self.add(CmdCallback())
+
+
+
+
+

Changing parent classes of typeclasses

+

Finally, to use the in-game Python system, you need to have your typeclasses inherit from the modified event +classes. For instance, in your typeclasses/characters.py module, you should change inheritance +like this:

+
from evennia.contrib.base_systems.ingame_python.typeclasses import EventCharacter
+
+class Character(EventCharacter):
+
+    # ...
+
+
+

You should do the same thing for your rooms, exits and objects. Note that the +in-game Python system works by overriding some hooks. Some of these features +might not be accessible in your game if you don’t call the parent methods when +overriding hooks.

+
+
+
+

Using the call command

+

The in-game Python system relies, to a great extent, on its call command. +Who can execute this command, and who can do what with it, will depend on your +set of permissions.

+

The call command allows to add, edit and delete callbacks on specific objects’ events. The event +system can be used on most Evennia objects, mostly typeclassed objects (excluding players). The +first argument of the call command is the name of the object you want to edit. It can also be +used to know what events are available for this specific object.

+
+

Examining callbacks and events

+

To see the events connected to an object, use the call command and give the name or ID of the +object to examine. For instance, call here to examine the events on your current location. Or +call self to see the events on yourself.

+

This command will display a table, containing:

+
    +
  • The name of each event in the first column.

  • +
  • The number of callbacks of this name, and the number of total lines of these callbacks in the +second column.

  • +
  • A short help to tell you when the event is triggered in the third column.

  • +
+

If you execute call #1 for instance, you might see a table like this:

+
+------------------+---------+-----------------------------------------------+
+| Event name       |  Number | Description                                   |
++~~~~~~~~~~~~~~~~~~+~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
+| can_delete       |   0 (0) | Can the character be deleted?                 |
+| can_move         |   0 (0) | Can the character move?                       |
+| can_part         |   0 (0) | Can the departing character leave this room?  |
+| delete           |   0 (0) | Before deleting the character.                |
+| greet            |   0 (0) | A new character arrives in the location of    |
+|                  |         | this character.                               |
+| move             |   0 (0) | After the character has moved into its new    |
+|                  |         | room.                                         |
+| puppeted         |   0 (0) | When the character has been puppeted by a     |
+|                  |         | player.                                       |
+| time             |   0 (0) | A repeated event to be called regularly.      |
+| unpuppeted       |   0 (0) | When the character is about to be un-         |
+|                  |         | puppeted.                                     |
++------------------+---------+-----------------------------------------------+
+
+
+
+
+

Creating a new callback

+

The /add switch should be used to add a callback. It takes two arguments beyond the object’s +name/DBREF:

+
    +
  1. After an = sign, the name of the event to be edited (if not supplied, will display the list of +possible events, like above).

  2. +
  3. The parameters (optional).

  4. +
+

We’ll see callbacks with parameters later. For the time being, let’s try to prevent a character +from going through the “north” exit of this room:

+
call north
++------------------+---------+-----------------------------------------------+
+| Event name       |  Number | Description                                   |
++~~~~~~~~~~~~~~~~~~+~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
+| can_traverse     |   0 (0) | Can the character traverse through this exit? |
+| msg_arrive       |   0 (0) | Customize the message when a character        |
+|                  |         | arrives through this exit.                    |
+| msg_leave        |   0 (0) | Customize the message when a character leaves |
+|                  |         | through this exit.                            |
+| time             |   0 (0) | A repeated event to be called regularly.      |
+| traverse         |   0 (0) | After the character has traversed through     |
+|                  |         | this exit.                                    |
++------------------+---------+-----------------------------------------------+
+
+
+

If we want to prevent a character from traversing through this exit, the best event for us would be +“can_traverse”.

+
+

Why not “traverse”? If you read the description of both events, you will see “traverse” is called +after the character has traversed through this exit. It would be too late to prevent it. On +the other hand, “can_traverse” is obviously checked before the character traverses.

+
+

When we edit the event, we have some more information:

+
call/add north = can_traverse
+
+
+

Can the character traverse through this exit? +This event is called when a character is about to traverse this +exit. You can use the deny() eventfunc to deny the character from +exiting for this time.

+

Variables you can use in this event:

+
- character: the character that wants to traverse this exit.
+- exit: the exit to be traversed.
+- room: the room in which stands the character before moving.
+
+
+

The section dedicated to eventfuncs will elaborate on the deny() function and +other eventfuncs. Let us say, for the time being, that it can prevent an action (in this case, it +can prevent the character from traversing through this exit). In the editor that opened when you +used call/add, you can type something like:

+
if character.id == 1:
+    character.msg("You're the superuser, 'course I'll let you pass.")
+else:
+    character.msg("Hold on, what do you think you're doing?")
+    deny()
+
+
+

You can now enter :wq to leave the editor by saving the callback.

+

If you enter call north, you should see that “can_traverse” now has an active callback. You can +use call north = can_traverse to see more details on the connected callbacks:

+
call north = can_traverse
++--------------+--------------+----------------+--------------+--------------+
+|       Number | Author       | Updated        | Param        | Valid        |
++~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+
+|            1 | XXXXX        | 5 seconds ago  |              | Yes          |
++--------------+--------------+----------------+--------------+--------------+
+
+
+

The left column contains callback numbers. You can use them to have even more information on a +specific event. Here, for instance:

+
call north = can_traverse 1
+Callback can_traverse 1 of north:
+Created by XXXXX on 2017-04-02 17:58:05.
+Updated by XXXXX on 2017-04-02 18:02:50
+This callback is connected and active.
+Callback code:
+if character.id == 1:
+    character.msg("You're the superuser, 'course I'll let you pass.")
+else:
+    character.msg("Hold on, what do you think you're doing?")
+    deny()
+
+
+

Then try to walk through this exit. Do it with another character if possible, too, to see the +difference.

+
+
+

Editing and removing a callback

+

You can use the /edit switch to the @call command to edit a callback. You should provide, after +the name of the object to edit and the equal sign:

+
    +
  1. The name of the event (as seen above).

  2. +
  3. A number, if several callbacks are connected at this location.

  4. +
+

You can type call/edit <object> = <event name> to see the callbacks that are linked at this +location. If there is only one callback, it will be opened in the editor; if more are defined, you +will be asked for a number to provide (for instance, call/edit north = can_traverse 2).

+

The command call also provides a /del switch to remove a callback. It takes the same arguments +as the /edit switch.

+

When removed, callbacks are logged, so an administrator can retrieve its content, assuming the +/del was an error.

+
+
+

The code editor

+

When adding or editing a callback, the event editor should open in code mode. The additional +options supported by the editor in this mode are describe in a dedicated section of the EvEditor’s +documentation.

+
+
+
+

Using events

+

The following sections describe how to use events for various tasks, from the most simple to the +most complex.

+
+

The eventfuncs

+

In order to make development a little easier, the in-game Python system provides eventfuncs to be used in +callbacks themselves. You don’t have to use them, they are just shortcuts. An eventfunc is just a +simple function that can be used inside of your callback code.

+ + + + + + + + + + + + + + + + + + + + + + + + + +

Function

Argument

Description

Example

deny

()

Prevent an action from happening.

deny()

get

(**kwargs)

Get a single object.

char = get(id=1)

call_event

(obj, name, seconds=0)

Call another event.

call_event(char, "chain_1", 20)

+
+

deny

+

The deny() function allows to interrupt the callback and the action that called it. In the +can_* events, it can be used to prevent the action from happening. For instance, in can_say on +rooms, it can prevent the character from saying something in the room. One could have a can_eat +event set on food that would prevent this character from eating this food.

+

Behind the scenes, the deny() function raises an exception that is being intercepted by the +handler of events. The handler will then report that the action was cancelled.

+
+
+

get

+

The get eventfunc is a shortcut to get a single object with a specific identity. It’s often used +to retrieve an object with a given ID. In the section dedicated to chained +events, you will see a concrete example of this function in action.

+
+
+

call_event

+

Some callbacks will call other events. It is particularly useful for chained +events that are described in a dedicated section. This eventfunc is used to call +another event, immediately or in a defined time.

+

You need to specify as first parameter the object containing the event. The second parameter is the +name of the event to call. The third parameter is the number of seconds before calling this event. +By default, this parameter is set to 0 (the event is called immediately).

+
+
+
+

Variables in callbacks

+

In the Python code you will enter in individual callbacks, you will have access to variables in your +locals. These variables will depend on the event, and will be clearly listed when you add or edit a +callback. As you’ve seen in the previous example, when we manipulate characters or character +actions, we often have a character variable that holds the character doing the action.

+

In most cases, when an event is fired, all callbacks from this event are called. Variables are +created for each event. Sometimes, however, the callback will execute and then ask for a variable +in your locals: in other words, some callbacks can alter the actions being performed by changing +values of variables. This is always clearly specified in the help of the event.

+

One example that will illustrate this system is the “msg_leave” event that can be set on exits. +This event can alter the message that will be sent to other characters when someone leaves through +this exit.

+
call/add down = msg_leave
+
+
+

Which should display:

+
Customize the message when a character leaves through this exit.
+This event is called when a character leaves through this exit.
+To customize the message that will be sent to the room where the
+character came from, change the value of the variable "message"
+to give it your custom message.  The character itself will not be
+notified.  You can use mapping between braces, like this:
+    message = "{character} falls into a hole!"
+In your mapping, you can use {character} (the character who is
+about to leave), {exit} (the exit), {origin} (the room in which
+the character is), and {destination} (the room in which the character
+is heading for).  If you need to customize the message with other
+information, you can also set "message" to None and send something
+else instead.
+
+Variables you can use in this event:
+    character: the character who is leaving through this exit.
+    exit: the exit being traversed.
+    origin: the location of the character.
+    destination: the destination of the character.
+    message: the message to be displayed in the location.
+    mapping: a dictionary containing additional mapping.
+
+
+

If you write something like this in your event:

+
message = "{character} falls into a hole in the ground!"
+
+
+
+

And if the character Wilfred takes this exit, others in the room will see:

+
Wildred falls into a hole in the ground!
+
+
+

In this case, the in-game Python system placed the variable “message” in the callback locals, but will read +from it when the event has been executed.

+
+
+

Callbacks with parameters

+

Some callbacks are called without parameter. It has been the case for all examples we have seen +before. In some cases, you can create callbacks that are triggered under only some conditions. A +typical example is the room’s “say” event. This event is triggered when somebody says something in +the room. Individual callbacks set on this event can be configured to fire only when some words are +used in the sentence.

+

For instance, let’s say we want to create a cool voice-operated elevator. You enter into the +elevator and say the floor number… and the elevator moves in the right direction. In this case, +we could create an callback with the parameter “one”:

+
call/add here = say one
+
+
+

This callback will only fire when the user says a sentence that contains “one”.

+

But what if we want to have a callback that would fire if the user says 1 or one? We can provide +several parameters, separated by a comma.

+
call/add here = say 1, one
+
+
+

Or, still more keywords:

+
call/add here = say 1, one, ground
+
+
+

This time, the user could say something like “take me to the ground floor” (“ground” is one of our +keywords defined in the above callback).

+

Not all events can take parameters, and these who do have different ways of handling them. There +isn’t a single meaning to parameters that could apply to all events. Refer to the event +documentation for details.

+
+

If you get confused between callback variables and parameters, think of parameters as checks +performed before the callback is run. Event with parameters will only fire some specific +callbacks, not all of them.

+
+
+ +
+

Chained events

+

Callbacks can call other events, either now or a bit later. It is potentially very powerful.

+

To use chained events, just use the call_event eventfunc. It takes 2-3 arguments:

+
    +
  • The object containing the event.

  • +
  • The name of the event to call.

  • +
  • Optionally, the number of seconds to wait before calling this event.

  • +
+

All objects have events that are not triggered by commands or game-related operations. They are +called “chain_X”, like “chain_1”, “chain_2”, “chain_3” and so on. You can give them more specific +names, as long as it begins by “chain_”, like “chain_flood_room”.

+

Rather than a long explanation, let’s look at an example: a subway that will go from one place to +the next at regular times. Connecting exits (opening its doors), waiting a bit, closing them, +rolling around and stopping at a different station. That’s quite a complex set of callbacks, as it +is, but let’s only look at the part that opens and closes the doors:

+
call/add here = time 10:00
+
+
+
# At 10:00 AM, the subway arrives in the room of ID 22.
+# Notice that exit #23 and #24 are respectively the exit leading
+# on the platform and back in the subway.
+station = get(id=22)
+to_exit = get(id=23)
+back_exit = get(id=24)
+
+# Open the door
+to_exit.name = "platform"
+to_exit.aliases = ["p"]
+to_exit.location = room
+to_exit.destination = station
+back_exit.name = "subway"
+back_exit.location = station
+back_exit.destination = room
+
+# Display some messages
+room.msg_contents("The doors open and wind gushes in the subway")
+station.msg_contents("The doors of the subway open with a dull clank.")
+
+# Set the doors to close in 20 seconds
+call_event(room, "chain_1", 20)
+
+
+

This callback will:

+
    +
  1. Be called at 10:00 AM (specify 22:00 to set it to 10:00 PM).

  2. +
  3. Set an exit between the subway and the station. Notice that the exits already exist (you will +not have to create them), but they don’t need to have specific location and destination.

  4. +
  5. Display a message both in the subway and on the platform.

  6. +
  7. Call the event “chain_1” to execute in 20 seconds.

  8. +
+

And now, what should we have in “chain_1”?

+
call/add here = chain_1
+
+
+
# Close the doors
+to_exit.location = None
+to_exit.destination = None
+back_exit.location = None
+back_exit.destination = None
+room.msg_content("After a short warning signal, the doors close and the subway begins moving.")
+station.msg_content("After a short warning signal, the doors close and the subway begins moving.")
+
+
+

Behind the scenes, the call_event function freezes all variables (“room”, “station”, “to_exit”, +“back_exit” in our example), so you don’t need to define them again.

+

A word of caution on callbacks that call chained events: it isn’t impossible for a callback to call +itself at some recursion level. If chain_1 calls chain_2 that calls chain_3 that calls +chain_, particularly if there’s no pause between them, you might run into an infinite loop.

+

Be also careful when it comes to handling characters or objects that may very well move during your +pause between event calls. When you use call_event(), the MUD doesn’t pause and commands can be +entered by players, fortunately. It also means that, a character could start an event that pauses +for awhile, but be gone when the chained event is called. You need to check that, even lock the +character into place while you are pausing (some actions should require locking) or at least, +checking that the character is still in the room, for it might create illogical situations if you +don’t.

+
+

Chained events are a special case: contrary to standard events, they are created in-game, not +through code. They usually contain only one callback, although nothing prevents you from creating +several chained events in the same object.

+
+
+
+
+

Using events in code

+

This section describes callbacks and events from code, how to create new events, how to call them in +a command, and how to handle specific cases like parameters.

+

Along this section, we will see how to implement the following example: we would like to create a +“push” command that could be used to push objects. Objects could react to this command and have +specific events fired.

+
+

Adding new events

+

Adding new events should be done in your typeclasses. Events are contained in the _events class +variable, a dictionary of event names as keys, and tuples to describe these events as values. You +also need to register this class, to tell the in-game Python system that it contains events to be added to +this typeclass.

+

Here, we want to add a “push” event on objects. In your typeclasses/objects.py file, you should +write something like:

+
from evennia.contrib.base_systems.ingame_python.utils import register_events
+from evennia.contrib.base_systems.ingame_python.typeclasses import EventObject
+
+EVENT_PUSH = """
+A character push the object.
+This event is called when a character uses the "push" command on
+an object in the same room.
+
+Variables you can use in this event:
+    character: the character that pushes this object.
+    obj: the object connected to this event.
+"""
+
+@register_events
+class Object(EventObject):
+    """
+    Class representing objects.
+    """
+
+    _events = {
+        "push": (["character", "obj"], EVENT_PUSH),
+    }
+
+
+
    +
  • Line 1-2: we import several things we will need from the in-game Python system. Note that we use +EventObject as a parent instead of DefaultObject, as explained in the installation.

  • +
  • Line 4-12: we usually define the help of the event in a separate variable, this is more readable, +though there’s no rule against doing it another way. Usually, the help should contain a short +explanation on a single line, a longer explanation on several lines, and then the list of variables +with explanations.

  • +
  • Line 14: we call a decorator on the class to indicate it contains events. If you’re not familiar +with decorators, you don’t really have to worry about it, just remember to put this line just +above the class definition if your class contains events.

  • +
  • Line 15: we create the class inheriting from EventObject.

  • +
  • Line 20-22: we define the events of our objects in an _events class variable. It is a +dictionary. Keys are event names. Values are a tuple containing:

    +
      +
    • The list of variable names (list of str). This will determine what variables are needed when +the event triggers. These variables will be used in callbacks (as we’ll see below).

    • +
    • The event help (a str, the one we have defined above).

    • +
    +
  • +
+

If you add this code and reload your game, create an object and examine its events with @call, you +should see the “push” event with its help. Of course, right now, the event exists, but it’s not +fired.

+
+
+

Calling an event in code

+

The in-game Python system is accessible through a handler on all objects. This handler is named callbacks +and can be accessed from any typeclassed object (your character, a room, an exit…). This handler +offers several methods to examine and call an event or callback on this object.

+

To call an event, use the callbacks.call method in an object. It takes as argument:

+
    +
  • The name of the event to call.

  • +
  • All variables that will be accessible in the event as positional arguments. They should be +specified in the order chosen when creating new events.

  • +
+

Following the same example, so far, we have created an event on all objects, called “push”. This +event is never fired for the time being. We could add a “push” command, taking as argument the name +of an object. If this object is valid, it will call its “push” event.

+
from commands.command import Command
+
+class CmdPush(Command):
+
+    """
+    Push something.
+
+    Usage:
+        push <something>
+
+    Push something where you are, like an elevator button.
+
+    """
+
+    key = "push"
+
+    def func(self):
+        """Called when pushing something."""
+        if not self.args.strip():
+            self.msg("Usage: push <something>")
+            return
+
+        # Search for this object
+        obj = self.caller.search(self.args)
+        if not obj:
+            return
+
+        self.msg("You push {}.".format(obj.get_display_name(self.caller)))
+
+        # Call the "push" event of this object
+        obj.callbacks.call("push", self.caller, obj)
+
+
+

Here we use callbacks.call with the following arguments:

+
    +
  • "push": the name of the event to be called.

  • +
  • self.caller: the one who pushed the button (this is our first variable, character).

  • +
  • obj: the object being pushed (our second variable, obj).

  • +
+

In the “push” callbacks of our objects, we then can use the “character” variable (containing the one +who pushed the object), and the “obj” variable (containing the object that was pushed).

+
+
+

See it all work

+

To see the effect of the two modifications above (the added event and the “push” command), let us +create a simple object:

+
@create/drop rock
+@desc rock = It's a single rock, apparently pretty heavy.  Perhaps you can try to push it though.
+@call/add rock = push
+
+
+

In the callback you could write:

+
from random import randint
+number = randint(1, 6)
+character.msg("You push a rock... is... it... going... to... move?")
+if number == 6:
+    character.msg("The rock topples over to reveal a beautiful ant-hill!")
+
+
+

You can now try to “push rock”. You’ll try to push the rock, and once out of six times, you will +see a message about a “beautiful ant-hill”.

+
+
+

Adding new eventfuncs

+

Eventfuncs, like deny(), are defined in +contrib/base_systesm/ingame_python/eventfuncs.py. You can add your own +eventfuncs by creating a file named eventfuncs.py in your world directory. +The functions defined in this file will be added as helpers.

+

You can also decide to create your eventfuncs in another location, or even in +several locations. To do so, edit the EVENTFUNCS_LOCATION setting in your +server/conf/settings.py file, specifying either a python path or a list of +Python paths in which your helper functions are defined. For instance:

+
EVENTFUNCS_LOCATIONS = [
+        "world.events.functions",
+]
+
+
+
+
+

Creating events with parameters

+

If you want to create events with parameters (if you create a “whisper” or “ask” command, for +instance, and need to have some characters automatically react to words), you can set an additional +argument in the tuple of events in your typeclass’ _events class variable. This third argument +must contain a callback that will be called to filter through the list of callbacks when the event +fires. Two types of parameters are commonly used (but you can define more parameter types, although +this is out of the scope of this documentation).

+
    +
  • Keyword parameters: callbacks of this event will be filtered based on specific keywords. This is +useful if you want the user to specify a word and compare this word to a list.

  • +
  • Phrase parameters: callbacks will be filtered using an entire phrase and checking all its words. +The “say” command uses phrase parameters (you can set a “say” callback to fires if a phrase +contains one specific word).

  • +
+

In both cases, you need to import a function from +evennia.contrib.base_systems.ingame_python.utils and use it as third parameter in your +event definition.

+
    +
  • keyword_event should be used for keyword parameters.

  • +
  • phrase_event should be used for phrase parameters.

  • +
+

For example, here is the definition of the “say” event:

+
from evennia.contrib.base_systems.ingame_python.utils import register_events, phrase_event
+# ...
+@register_events
+class SomeTypeclass:
+    _events = {
+        "say": (["speaker", "character", "message"], CHARACTER_SAY, phrase_event),
+    }
+
+
+

When you call an event using the obj.callbacks.call method, you should also provide the parameter, +using the parameters keyword:

+
obj.callbacks.call(..., parameters="<put parameters here>")
+
+
+

It is necessary to specifically call the event with parameters, otherwise the system will not be +able to know how to filter down the list of callbacks.

+
+
+
+

Disabling all events at once

+

When callbacks are running in an infinite loop, for instance, or sending unwanted information to +players or other sources, you, as the game administrator, have the power to restart without events. +The best way to do this is to use a custom setting, in your setting file +(server/conf/settings.py):

+
# Disable all events
+EVENTS_DISABLED = True
+
+
+

The in-game Python system will still be accessible (you will have access to the call command, to debug), +but no event will be called automatically.

+
+
+
+

This document page is generated from evennia/contrib/base_systems/ingame_python/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Llm.html b/docs/latest/Contribs/Contrib-Llm.html new file mode 100644 index 0000000000..3d29a009e8 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Llm.html @@ -0,0 +1,375 @@ + + + + + + + + + Large Language Model (“Chat-bot AI”) integration — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Large Language Model (“Chat-bot AI”) integration

+

Contribution by Griatch 2023

+

This adds an LLMClient that allows Evennia to send prompts to a LLM server (Large Language Model, along the lines of ChatGPT). Example uses a local OSS LLM install. Included is an NPC you can chat with using a new talk command. The NPC will respond using the AI responses from the LLM server. All calls are asynchronous, so if the LLM is slow, Evennia is not affected.

+
> create/drop villager:evennia.contrib.rpg.llm.LLMNPC
+You create a new LLMNPC: villager
+
+> talk villager Hello there friend, what's up?
+You say (to villager): Hello there friend, what's up?
+villager says (to You): Hello! Not much going on, really.
+
+> talk villager Do you know where we are?
+You say (to villager): Do you know where we are?
+villager says (to You): We are in this strange place called 'Limbo'. Not much to do here.
+
+
+
+

Installation

+

You need two components for this contrib - Evennia, and an LLM webserver that operates and provides an API to an LLM AI model.

+
+

LLM Server

+

There are many LLM servers, but they can be pretty technical to install and set up. This contrib was tested with text-generation-webui. It has a lot of features while also being easy to install. |

+
    +
  1. Go to the Installation section and grab the ‘one-click installer’ for your OS.

  2. +
  3. Unzip the files in a folder somewhere on your hard drive (you don’t have to put it next to your evennia stuff if you don’t want to).

  4. +
  5. In a terminal/console, cd into the folder and execute the source file in whatever way it’s done for your OS (like source start_linux.sh for Linux, or .\start_windows for Windows). This is an installer that will fetch and install everything in a conda virtual environment. When asked, make sure to select your GPU (NVIDIA/AMD etc) if you have one, otherwise use CPU.

  6. +
  7. Once all is loaded, stop the server with Ctrl-C (or Cmd-C) and open the file webui.py (it’s one of the top files in the archive you unzipped). Find the text string CMD_FLAGS = '' near the top and change this to CMD_FLAGS = '--api'. Then save and close. This makes the server activate its api automatically.

  8. +
  9. Now just run that server starting script (start_linux.sh etc) again. This is what you’ll use to start the LLM server henceforth.

  10. +
  11. Once the server is running, point your browser to http://127.0.0.1:7860 to see the running Text generation web ui running. If you turned on the API, you’ll find it’s now active on port 5000. This should not collide with default Evennia ports unless you changed something.

  12. +
  13. At this point you have the server and API, but it’s not actually running any Large-Language-Model (LLM) yet. In the web ui, go to the models tab and enter a github-style path in the Download custom model or LoRA field. To test so things work, enter DeepPavlov/bart-base-en-persona-chat and download. This is a small model (350 million parameters) so should be possible to run on most machines using only CPU. Update the models in the drop-down on the left and select it, then load it with the Transformers loader. It should load pretty quickly. If you want to load this every time, you can select the Autoload the model checkbox; otherwise you’ll need to select and load the model every time you start the LLM server.

  14. +
  15. To experiment, you can find thousands of other open-source text-generation LLM models on huggingface.co/models. Beware to not download a too huge model; your machine may not be able to load it! If you try large models, don’t set the Autoload the model checkbox, in case the model crashes your server on startup.

  16. +
+

For troubleshooting, you can look at the terminal output of the text-generation-webui server; it will show you the requests you do to it and also list any errors. See the text-generation-webui homepage for more details.

+
+
+

Evennia config

+

To be able to talk to NPCs, import and add the evennia.contrib.rpg.llm.llm_npc.CmdLLMTalk to your default cmdset in mygame/commands/default_cmdsets.py:

+
# in mygame/commands/default_cmdsets.py
+
+# ... 
+from evennia.contrib.rpg.llm import CmdLLMTalk  # <----
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet): 
+    # ...
+    def at_cmdset_creation(self): 
+        # ... 
+        self.add(CmdLLMTalk())     # <-----
+
+
+
+
+

See this the tutorial on adding commands for more info.

+

The default LLM api config should work with the text-generation-webui LLM server running its API on port 5000. You can also customize it via settings (if a setting is not added, the default below is used):

+
# in mygame/server/conf/settings.py
+
+# path to the LLM server
+LLM_HOST = "http://127.0.0.1:5000"
+LLM_PATH = "/api/v1/generate"
+
+# if you wanted to authenticated to some external service, you could
+# add an Authenticate header here with a token
+LLM_HEADERS = {"Content-Type": "application/json"}
+
+# this key will be inserted in the request, with your user-input
+LLM_PROMPT_KEYNAME = "prompt"
+
+# defaults are set up for text-generation-webui and most models
+LLM_REQUEST_BODY = {
+    "max_new_tokens": 250,  # set how many tokens are part of a response
+    "temperature": 0.7, # 0-2. higher=more random, lower=predictable
+}
+# helps guide the NPC AI. See the LLNPC section.
+LLM_PROMPT_PREFIx = (
+  "You are roleplaying as {name}, a {desc} existing in {location}. "
+  "Answer with short sentences. Only respond as {name} would. "
+  "From here on, the conversation between {name} and {character} begins."
+)
+
+
+

Don’t forget to reload Evennia (reload in game, or evennia reload from the terminal) if you make any changes.

+

It’s also important to note that the PROMPT_PREFIX needed by each model depends on how they were trained. There are a bunch of different formats. So you need to look into what should be used for each model you try. Report your findings!

+
+
+
+

Usage

+

With the LLM server running and the new talk command added, create a new LLM-connected NPC and talk to it in-game.

+
> create/drop girl:evennia.contrib.rpg.llm.LLMNPC
+> talk girl Hello!
+You say (to girl): Hello
+girl ponders ...
+girl says (to You): Hello! How are you?
+
+
+

The conversation will be echoed to everyone in the room. The NPC will show a thinking/pondering message if the server responds slower than 2 seconds (by default).

+
+
+

Primer on open-source LLM models

+

Hugging Face is becoming a sort of standard for downloading OSS models. In the text generation category (which is what we want for chat bots), there are some 20k models to choose from (2023). Just to get you started, check out models by TheBloke. TheBloke has taken on ‘quantizing’ (lowering their resolution) models released by others for them to fit on consumer hardware. Models from TheBloke follows roughly this naming standard:

+
TheBloke/ModelName-ParameterSize-other-GGML/GPTQ
+
+
+

For example

+
TheBloke/Llama-2-7B-Chat-GGML
+TheBloke/StableBeluga-13B-GPTQ
+
+
+

Here, Llama-2 is a ‘base model’ released open-source by Meta for free (also commercial) use. A base model takes millions of dollars and a supercomputer to train from scratch. Then others “fine tune” that base model. The StableBeluga model is created by someone partly retraining the Llama-2 to make it more focused in some particular area, like chatting in a particular style.

+

Models come in sizes, given as number of parameters they have, sort of how many ‘neurons’ they have in their brain. In the two examples above, the top one has 7B - 7 billion parameters and the second 13B - 13 billion. The small model we suggested to try during install is only 0.35B by comparson.

+

Running these models in their base form would still not be possible to do without people like TheBloke “quantizing” them, basically reducing their precision. Quantiziation are given in byte precision. So if the original supercomputer version uses 32bit precision, the model you can actually run on your machine often only uses 8bit or 4bit resolution. The common wisdom seems to be that being able to run a model with more parameters at low resolution is better than a smaller one with a higher resolution.

+

You will see GPTQ or GGML endings to TheBloke’s quantized models. Simplified, GPTQ are the main quantized models. To run this model, you need to have a beefy enough GPU to be able to fit the entire model in VRAM. GGML, in contrast, allows you to offload some of the model to normal RAM and use your CPU intead. Since you probably have more RAM than VRAM, this means you can run much bigger models this way, but they will run much slower.

+

Moreover, you need additional memory space for the context of the model. If you are chatting, this would be the chat history. While this sounds like it would just be some text, the length of the context determines how much the AI must ‘keep in mind’ in order to draw conclusions. This is measured in ‘tokens’ (roughly parts of words). Common context length is 2048 tokens, and a model must be specifically trained to be able to handle longer contexts.

+

Here are some rough estimates of hardware requirements for the most common model sizes and 2048 token context. Use GPTQ models if you have enough VRAM on your GPU, otherwise use GMML models to also be able to put some or all data in RAM.

+ + + + + + + + + + + + + + + + + + + + + + + +

Model size

approx VRAM or RAM needed (4bit / 8bit)

3B

1.5 GB / 3 GB

7B

3.5 GB / 7 GB

13B

7 GB/13 GB

33B

14 GB / 33 GB

70B

35 GB / 70 GB

+

The results from a 7B or even a 3B model can be astounding! But set your expectations. Current (2023) top of the line consumer gaming GPUs have 24GB or VRAM and can at most fit a 33B 4bit quantized model at full speed (GPTQ).

+

By comparison, Chat-GPT 3.5 is a 175B model. We don’t know how large Chat-GPT 4 is, but it may be up to 1700B. For this reason you may also consider paying a commercial provider to run the model for you, over an API. This is discussed a little later, but try running locally with a small model first to see everything worls.

+
+
+

Using an AI cloud service

+

You could also call out to an external API, like OpenAI (chat-GPT) or Google. Most cloud-hosted services are commercial and costs money. But since they have the hardware to run bigger models (or their own, proprietary models), they may give better and faster results.

+
+

Warning

+

Calling an external API is currently untested, so report any findings. Since the Evennia Server (not the Portal) is doing the calling, you are recommended to put a proxy between you and the internet if you call out like this.

+
+

Here is an untested example of the Evennia setting for calling OpenAI’s v1/completions API:

+
LLM_HOST = "https://api.openai.com"
+LLM_PATH = "/v1/completions"
+LLM_HEADERS = {"Content-Type": "application/json",
+               "Authorization": "Bearer YOUR_OPENAI_API_KEY"}
+LLM_PROMPT_KEYNAME = "prompt"
+LLM_REQUEST_BODY = {
+                        "model": "gpt-3.5-turbo",
+                        "temperature": 0.7,
+                        "max_tokens": 128,
+                   }
+
+
+
+
+

TODO: OpenAI’s more modern v1/chat/completions api does currently not work out of the gate since it’s a bit more complex.

+
+
+
+

The LLMNPC class

+

The LLM-able NPC class has a new method at_talked_to which does the connection to the LLM server and responds. This is called by the new talk command. Note that all these calls are asynchronous, meaning a slow response will not block Evennia.

+

The NPC’s AI is controlled with a few extra properties and Attributes, most of which can be customized directly in-game by a builder.

+
+

prompt_prefix

+

The prompt_prefix is very important. This will be added in front of your prompt and helps the AI know how to respond. Remember that an LLM model is basically an auto-complete mechaniss, so by providing examples and instructions in the prefix, you can help it respond in a better way.

+

The prefix string to use for a given NPC is looked up from one of these locations, in order:

+
    +
  1. An Attribute npc.db.chat_prefix stored on the NPC (not set by default)

  2. +
  3. A property chat_prefix on the the LLMNPC class (set to None by default).

  4. +
  5. The LLM_PROMPT_PREFIX setting (unset by default)

  6. +
  7. If none of the above locations are set, the following default is used:

    +
    "You are roleplaying as {name}, a {desc} existing in {location}.
    +Answer with short sentences. Only respond as {name} would.
    +From here on, the conversation between {name} and {character} begins."
    +
    +
    +
  8. +
+

Here, the formatting tag {name} is replaced with the NPCs’s name, desc by it’s description, the location by its current location’s name and character by the one talking to it. All names of characters are given by the get_display_name(looker) call, so this may be different +from person to person.

+

Depending on the model, it can be very important to extend the prefix both with more information about the character as well as communication examples. A lot of tweaking may be necessary before producing something remniscent of human speech.

+
+
+

Response template

+

The response_template AttributeProperty defaults to being

+
$You() $conj(say) (to $You(character)): {response}"
+
+
+

following common msg_contents FuncParser syntax. The character string will be mapped to the one talking to the NPC and the response will be what is said by the NPC.

+
+
+

Memory

+

The NPC remembers what has been said to it by each player. This memory will be included with the prompt to the LLM and helps it understand the context of the conversation. The length of this memory is given by the max_chat_memory_size AttributeProperty. Default is 25 messages. Once the memory is maximum is reached, older messages are forgotten. Memory is stored separately for each player talking to the NPC.

+
+
+

Thinking

+

If the LLM server is slow to respond, the NPC will echo a random ‘thinking message’ to show it has not forgotten about you (something like “The villager ponders your words …”).

+

They are controlled by two AttributeProperties on the LLMNPC class:

+
    +
  • thinking_timeout: How long, in seconds to wait before showing the message. Default is 2 seconds.

  • +
  • thinking_messages: A list of messages to randomly pick between. Each message string can contain {name}, which will be replaced by the NPCs name.

  • +
+
+
+
+

TODO

+

There is a lot of expansion potential with this contrib. Some ideas:

+
    +
  • Easier support for different cloud LLM provider API structures.

  • +
  • More examples of useful prompts and suitable models for MUD use.

  • +
+
+

This document page is generated from evennia/contrib/rpg/llm/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Mail.html b/docs/latest/Contribs/Contrib-Mail.html new file mode 100644 index 0000000000..00b5e4797c --- /dev/null +++ b/docs/latest/Contribs/Contrib-Mail.html @@ -0,0 +1,186 @@ + + + + + + + + + In-Game Mail system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

In-Game Mail system

+

Contribution by grungies1138 2016

+

A simple Brandymail style mail system that uses the Msg class from Evennia +Core. It has two Commands for either sending mails between Accounts (out of game) +or between Characters (in-game). The two types of mails can be used together or +on their own.

+
    +
  • CmdMail - this should sit on the Account cmdset and makes the mail command +available both IC and OOC. Mails will always go to Accounts (other players).

  • +
  • CmdMailCharacter - this should sit on the Character cmdset and makes the mail +command ONLY available when puppeting a character. Mails will be sent to other +Characters only and will not be available when OOC.

  • +
  • If adding both commands to their respective cmdsets, you’ll get two separate +IC and OOC mailing systems, with different lists of mail for IC and OOC modes.

  • +
+
+

Installation:

+

Install one or both of the following (see above):

+
    +
  • CmdMail (IC + OOC mail, sent between players)

    +
    # mygame/commands/default_cmds.py
    +
    +from evennia.contrib.game_systems import mail
    +
    +# in AccountCmdSet.at_cmdset_creation:
    +    self.add(mail.CmdMail())
    +
    +
    +
  • +
  • CmdMailCharacter (optional, IC only mail, sent between characters)

    +
    # mygame/commands/default_cmds.py
    +
    +from evennia.contrib.game_systems import mail
    +
    +# in CharacterCmdSet.at_cmdset_creation:
    +    self.add(mail.CmdMailCharacter())
    +
    +
    +
  • +
+

Once installed, use help mail in game for help with the mail command. Use +ic/ooc to switch in and out of IC/OOC modes.

+
+

This document page is generated from evennia/contrib/game_systems/mail/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Mapbuilder-Tutorial.html b/docs/latest/Contribs/Contrib-Mapbuilder-Tutorial.html new file mode 100644 index 0000000000..772f4ea6bc --- /dev/null +++ b/docs/latest/Contribs/Contrib-Mapbuilder-Tutorial.html @@ -0,0 +1,527 @@ + + + + + + + + + Creating rooms from an ascii map — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Creating rooms from an ascii map

+

This tutorial describes the creation of an in-game map display based on a pre-drawn map. It goes with the Mapbuilder contrib. It also details how to use the Batch code processor for advanced building.

+

Evennia does not require its rooms to be positioned in a “logical” way. Your exits could be named +anything. You could make an exit “west” that leads to a room described to be in the far north. You +could have rooms inside one another, exits leading back to the same room or describing spatial +geometries impossible in the real world.

+

That said, most games do organize their rooms in a logical fashion, if nothing else to retain the +sanity of their players. And when they do, the game becomes possible to map. This tutorial will give +an example of a simple but flexible in-game map system to further help player’s to navigate. We will

+

To simplify development and error-checking we’ll break down the work into bite-size chunks, each +building on what came before. For this we’ll make extensive use of the [Batch code processor](Batch- +Code-Processor), so you may want to familiarize yourself with that.

+
    +
  1. Planning the map - Here we’ll come up with a small example map to use for the rest of the +tutorial.

  2. +
  3. Making a map object - This will showcase how to make a static in-game “map” object a +Character could pick up and look at.

  4. +
  5. Building the map areas - Here we’ll actually create the small example area according to the +map we designed before.

  6. +
  7. Map code - This will link the map to the location so our output looks something like this:

    +
    crossroads(#3)
    +↑╚∞╝↑
    +≈↑│↑∩  The merger of two roads. To the north looms a mighty castle.
    +O─O─O  To the south, the glow of a campfire can be seen. To the east lie
    +≈↑│↑∩  the vast mountains and to the west is heard the waves of the sea.
    +↑▲O▲↑
    +
    +Exits: north(#8), east(#9), south(#10), west(#11)
    +
    +
    +
  8. +
+

We will henceforth assume your game folder is name named mygame and that you haven’t modified the +dkefault commands. We will also not be using Colors for our map since they +don’t show in the documentation wiki.

+
+

Planning the Map

+

Let’s begin with the fun part! Maps in MUDs come in many different [shapes and +sizes](http://journal.imaginary-realities.com/volume-05/issue-01/modern-interface-modern- +mud/index.html). Some appear as just boxes connected by lines. Others have complex graphics that are +external to the game itself.

+

Our map will be in-game text but that doesn’t mean we’re restricted to the normal alphabet! If +you’ve ever selected the Wingdings font in Microsoft Word +you will know there are a multitude of other characters around to use. When creating your game with +Evennia you have access to the UTF-8 character encoding which +put at your disposal thousands of letters, number and geometric shapes.

+

For this exercise, we’ve copy-and-pasted from the pallet of special characters used over at +Dwarf Fortress to create what is hopefully +a pleasing and easy to understood landscape:

+
≈≈↑↑↑↑↑∩∩
+≈≈↑╔═╗↑∩∩   Places the account can visit are indicated by "O".
+≈≈↑║O║↑∩∩   Up the top is a castle visitable by the account.
+≈≈↑╚∞╝↑∩∩   To the right is a cottage and to the left the beach.
+≈≈≈↑│↑∩∩∩   And down the bottom is a camp site with tents.
+≈≈O─O─O⌂∩   In the center is the starting location, a crossroads
+≈≈≈↑│↑∩∩∩   which connect the four other areas.
+≈≈↑▲O▲↑∩∩   
+≈≈↑↑▲↑↑∩∩
+≈≈↑↑↑↑↑∩∩
+
+
+

There are many considerations when making a game map depending on the play style and requirements +you intend to implement. Here we will display a 5x5 character map of the area surrounding the +account. This means making sure to account for 2 characters around every visitable location. Good +planning at this stage can solve many problems before they happen.

+
+
+

Creating a Map Object

+

In this section we will try to create an actual “map” object that an account can pick up and look +at.

+

Evennia offers a range of default commands for +creating objects and rooms in-game. While readily accessible, these commands are made to do very +specific, restricted things and will thus not offer as much flexibility to experiment (for an +advanced exception see the FuncParser). Additionally, entering long +descriptions and properties over and over in the game client can become tedious; especially when +testing and you may want to delete and recreate things over and over.

+

To overcome this, Evennia offers batch processors that work as input-files +created out-of-game. In this tutorial we’ll be using the more powerful of the two available batch +processors, the Batch Code Processor , called with the @batchcode command. +This is a very powerful tool. It allows you to craft Python files to act as blueprints of your +entire game world. These files have access to use Evennia’s Python API directly. Batchcode allows +for easy editing and creation in whatever text editor you prefer, avoiding having to manually build +the world line-by-line inside the game.

+
+

Important warning: @batchcode’s power is only rivaled by the @py command. Batchcode is so +powerful it should be reserved only for the superuser. Think carefully +before you let others (such as Developer- level staff) run @batchcode on their own - make sure +you are okay with them running arbitrary Python code on your server.

+
+

While a simple example, the map object it serves as good way to try out @batchcode. Go to +mygame/world and create a new file there named batchcode_map.py:

+
# mygame/world/batchcode_map.py
+
+from evennia import create_object
+from evennia import DefaultObject
+
+# We use the create_object function to call into existence a 
+# DefaultObject named "Map" wherever you are standing.
+
+map = create_object(DefaultObject, key="Map", location=caller.location)
+
+# We then access its description directly to make it our map.
+
+map.db.desc = """
+≈≈↑↑↑↑↑∩∩
+≈≈↑╔═╗↑∩∩
+≈≈↑║O║↑∩∩
+≈≈↑╚∞╝↑∩∩
+≈≈≈↑│↑∩∩∩
+≈≈O─O─O⌂∩
+≈≈≈↑│↑∩∩∩
+≈≈↑▲O▲↑∩∩
+≈≈↑↑▲↑↑∩∩
+≈≈↑↑↑↑↑∩∩
+"""
+
+# This message lets us know our map was created successfully.
+caller.msg("A map appears out of thin air and falls to the ground.")
+
+
+

Log into your game project as the superuser and run the command

+
@batchcode batchcode_map
+
+
+

This will load your batchcode_map.py file and execute the code (Evennia will look in your world/ +folder automatically so you don’t need to specify it).

+

A new map object should have appeared on the ground. You can view the map by using look map. Let’s +take it with the get map command. We’ll need it in case we get lost!

+
+
+

Building the map areas

+

We’ve just used batchcode to create an object useful for our adventures. But the locations on that +map does not actually exist yet - we’re all mapped up with nowhere to go! Let’s use batchcode to +build a game area based on our map. We have five areas outlined: a castle, a cottage, a campsite, a +coastal beach and the crossroads which connects them. Create a new batchcode file for this in +mygame/world, named batchcode_world.py.

+
# mygame/world/batchcode_world.py
+
+from evennia import create_object, search_object
+from typeclasses import rooms, exits
+
+# We begin by creating our rooms so we can detail them later.
+
+centre = create_object(rooms.Room, key="crossroads")
+north = create_object(rooms.Room, key="castle")
+east = create_object(rooms.Room, key="cottage")
+south = create_object(rooms.Room, key="camp")
+west = create_object(rooms.Room, key="coast")
+
+# This is where we set up the cross roads.
+# The rooms description is what we see with the 'look' command.
+
+centre.db.desc = """
+The merger of two roads. A single lamp post dimly illuminates the lonely crossroads.
+To the north looms a mighty castle. To the south the glow of a campfire can be seen.
+To the east lie a wall of mountains and to the west the dull roar of the open sea.
+"""
+
+# Here we are creating exits from the centre "crossroads" location to 
+# destinations to the north, east, south, and west. We will be able 
+# to use the exit by typing it's key e.g. "north" or an alias e.g. "n".
+
+centre_north = create_object(exits.Exit, key="north", 
+                            aliases=["n"], location=centre, destination=north)
+centre_east = create_object(exits.Exit, key="east", 
+                            aliases=["e"], location=centre, destination=east)
+centre_south = create_object(exits.Exit, key="south", 
+                            aliases=["s"], location=centre, destination=south)
+centre_west = create_object(exits.Exit, key="west", 
+                            aliases=["w"], location=centre, destination=west)
+
+# Now we repeat this for the other rooms we'll be implementing.
+# This is where we set up the northern castle.
+
+north.db.desc = "An impressive castle surrounds you. " \
+                "There might be a princess in one of these towers."
+north_south = create_object(exits.Exit, key="south", 
+                            aliases=["s"], location=north, destination=centre)
+
+# This is where we set up the eastern cottage.
+
+east.db.desc = "A cosy cottage nestled among mountains " \
+               "stretching east as far as the eye can see."
+east_west = create_object(exits.Exit, key="west", 
+                            aliases=["w"], location=east, destination=centre)
+
+# This is where we set up the southern camp.
+
+south.db.desc = "Surrounding a clearing are a number of " \
+                "tribal tents and at their centre a roaring fire."
+south_north = create_object(exits.Exit, key="north", 
+                            aliases=["n"], location=south, destination=centre)
+
+# This is where we set up the western coast.
+
+west.db.desc = "The dark forest halts to a sandy beach. " \
+               "The sound of crashing waves calms the soul."
+west_east = create_object(exits.Exit, key="east", 
+                            aliases=["e"], location=west, destination=centre)
+
+# Lastly, lets make an entrance to our world from the default Limbo room.
+
+limbo = search_object('Limbo')[0]
+limbo_exit = create_object(exits.Exit, key="enter world", 
+                            aliases=["enter"], location=limbo, destination=centre)
+
+
+

Apply this new batch code with @batchcode batchcode_world. If there are no errors in the code we +now have a nice mini-world to explore. Remember that if you get lost you can look at the map we +created!

+
+
+

In-game minimap

+

Now we have a landscape and matching map, but what we really want is a mini-map that displays +whenever we move to a room or use the look command.

+

We could manually enter a part of the map into the description of every room like we did our map +object description. But some MUDs have tens of thousands of rooms! Besides, if we ever changed our +map we would have to potentially alter a lot of those room descriptions manually to match the +change. So instead we will make one central module to hold our map. Rooms will reference this +central location on creation and the map changes will thus come into effect when next running our +batchcode.

+

To make our mini-map we need to be able to cut our full map into parts. To do this we need to put it +in a format which allows us to do that easily. Luckily, python allows us to treat strings as lists +of characters allowing us to pick out the characters we need.

+

mygame/world/map_module.py

+
# We place our map into a sting here.
+world_map = """\
+≈≈↑↑↑↑↑∩∩
+≈≈↑╔═╗↑∩∩
+≈≈↑║O║↑∩∩
+≈≈↑╚∞╝↑∩∩
+≈≈≈↑│↑∩∩∩
+≈≈O─O─O⌂∩
+≈≈≈↑│↑∩∩∩
+≈≈↑▲O▲↑∩∩
+≈≈↑↑▲↑↑∩∩
+≈≈↑↑↑↑↑∩∩
+"""
+
+# This turns our map string into a list of rows. Because python 
+# allows us to treat strings as a list of characters, we can access 
+# those characters with world_map[5][5] where world_map[row][column].
+world_map = world_map.split('\n')
+
+def return_map():
+    """
+    This function returns the whole map
+    """
+    map = ""
+    
+    #For each row in our map, add it to map
+    for valuey in world_map:
+        map += valuey
+        map += "\n"
+    
+    return map
+
+def return_minimap(x, y, radius = 2):
+    """
+    This function returns only part of the map.
+    Returning all chars in a 2 char radius from (x,y)
+    """
+    map = ""
+    
+    #For each row we need, add the characters we need.
+    for valuey in world_map[y-radius:y+radius+1]:         for valuex in valuey[x-radius:x+radius+1]:
+            map += valuex
+        map += "\n"
+    
+    return map
+
+
+

With our map_module set up, let’s replace our hardcoded map in mygame/world/batchcode_map.py with +a reference to our map module. Make sure to import our map_module!

+
# mygame/world/batchcode_map.py
+
+from evennia import create_object
+from evennia import DefaultObject
+from world import map_module
+
+map = create_object(DefaultObject, key="Map", location=caller.location)
+
+map.db.desc = map_module.return_map()
+
+caller.msg("A map appears out of thin air and falls to the ground.")
+
+
+

Log into Evennia as the superuser and run this batchcode. If everything worked our new map should +look exactly the same as the old map - you can use @delete to delete the old one (use a number to +pick which to delete).

+

Now, lets turn our attention towards our game’s rooms. Let’s use the return_minimap method we +created above in order to include a minimap in our room descriptions. This is a little more +complicated.

+

By itself we would have to settle for either the map being above the description with +room.db.desc = map_string + description_string, or the map going below by reversing their order. +Both options are rather unsatisfactory - we would like to have the map next to the text! For this +solution we’ll explore the utilities that ship with Evennia. Tucked away in evennia\evennia\utils +is a little module called EvTable . This is an advanced ASCII table +creator for you to utilize in your game. We’ll use it by creating a basic table with 1 row and two +columns (one for our map and one for our text) whilst also hiding the borders. Open the batchfile +again

+
# mygame\world\batchcode_world.py
+
+# Add to imports
+from evennia.utils import evtable
+from world import map_module
+
+# [...]
+
+# Replace the descriptions with the below code.
+
+# The cross roads.
+# We pass what we want in our table and EvTable does the rest.
+# Passing two arguments will create two columns but we could add more.
+# We also specify no border.
+centre.db.desc = evtable.EvTable(map_module.return_minimap(4,5), 
+                 "The merger of two roads. A single lamp post dimly " \
+                 "illuminates the lonely crossroads. To the north " \
+                 "looms a mighty castle. To the south the glow of " \
+                 "a campfire can be seen. To the east lie a wall of " \
+                 "mountains and to the west the dull roar of the open sea.", 
+                 border=None)
+# EvTable allows formatting individual columns and cells. We use that here
+# to set a maximum width for our description, but letting the map fill
+# whatever space it needs. 
+centre.db.desc.reformat_column(1, width=70)
+
+# [...]
+
+# The northern castle.
+north.db.desc = evtable.EvTable(map_module.return_minimap(4,2), 
+                "An impressive castle surrounds you. There might be " \
+                "a princess in one of these towers.", 
+                border=None)
+north.db.desc.reformat_column(1, width=70)   
+
+# [...]
+
+# The eastern cottage.
+east.db.desc = evtable.EvTable(map_module.return_minimap(6,5), 
+               "A cosy cottage nestled among mountains stretching " \
+               "east as far as the eye can see.", 
+               border=None)
+east.db.desc.reformat_column(1, width=70)
+
+# [...]
+
+# The southern camp.
+south.db.desc = evtable.EvTable(map_module.return_minimap(4,7), 
+                "Surrounding a clearing are a number of tribal tents " \
+                "and at their centre a roaring fire.", 
+                border=None)
+south.db.desc.reformat_column(1, width=70)
+
+# [...]
+
+# The western coast.
+west.db.desc = evtable.EvTable(map_module.return_minimap(2,5), 
+               "The dark forest halts to a sandy beach. The sound of " \
+               "crashing waves calms the soul.", 
+               border=None)
+west.db.desc.reformat_column(1, width=70)
+
+
+

Before we run our new batchcode, if you are anything like me you would have something like 100 maps +lying around and 3-4 different versions of our rooms extending from limbo. Let’s wipe it all and +start with a clean slate. In Command Prompt you can run evennia flush to clear the database and +start anew. It won’t reset dbref values however, so if you are at #100 it will start from there. +Alternatively you can navigate to mygame/server and delete the evennia.db3 file. Now in Command +Prompt use evennia migrate to have a completely freshly made database.

+

Log in to evennia and run @batchcode batchcode_world and you’ll have a little world to explore.

+
+
+

Conclusions

+

You should now have a mapped little world and a basic understanding of batchcode, EvTable and how +easily new game defining features can be added to Evennia.

+

You can easily build from this tutorial by expanding the map and creating more rooms to explore. Why +not add more features to your game by trying other tutorials: [Add weather to your world](Weather- +Tutorial), fill your world with NPC’s or +implement a combat system.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Mapbuilder.html b/docs/latest/Contribs/Contrib-Mapbuilder.html new file mode 100644 index 0000000000..a4164eb94b --- /dev/null +++ b/docs/latest/Contribs/Contrib-Mapbuilder.html @@ -0,0 +1,426 @@ + + + + + + + + + Map Builder — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Map Builder

+

Contribution by Cloud_Keeper 2016

+

Build a game map from the drawing of a 2D ASCII map.

+

This is a command which takes two inputs:

+
≈≈≈≈≈
+≈♣n♣≈   MAP_LEGEND = {("♣", "♠"): build_forest,
+≈∩▲∩≈                 ("∩", "n"): build_mountains,
+≈♠n♠≈                 ("▲"): build_temple}
+≈≈≈≈≈
+
+
+

A string of ASCII characters representing a map and a dictionary of functions +containing build instructions. The characters of the map are iterated over and +compared to a list of trigger characters. When a match is found the +corresponding function is executed generating the rooms, exits and objects as +defined by the users build instructions. If a character is not a match to +a provided trigger character (including spaces) it is simply skipped and the +process continues.

+

For instance, the above map represents a temple (▲) amongst mountains (n,∩) +in a forest (♣,♠) on an island surrounded by water (≈). Each character on the +first line is iterated over but as there is no match with our MAP_LEGEND, it +is skipped. On the second line it finds “♣” which is a match and so the +build_forest function is called. Next the build_mountains function is +called and so on until the map is completed. Building instructions are passed +the following arguments:

+
x         - The rooms position on the maps x axis
+y         - The rooms position on the maps y axis
+caller    - The account calling the command
+iteration - The current iterations number (0, 1 or 2)
+room_dict - A dictionary containing room references returned by build
+            functions where tuple coordinates are the keys (x, y).
+            ie room_dict[(2, 2)] will return the temple room above.
+
+
+

Building functions should return the room they create. By default these rooms +are used to create exits between valid adjacent rooms to the north, south, +east and west directions. This behaviour can turned off with the use of switch +arguments. In addition to turning off automatic exit generation the switches +allow the map to be iterated over a number of times. This is important for +something like custom exit building. Exits require a reference to both the +exits location and the exits destination. During the first iteration it is +possible that an exit is created pointing towards a destination that +has not yet been created resulting in error. By iterating over the map twice +the rooms can be created on the first iteration and room reliant code can be +be used on the second iteration. The iteration number and a dictionary of +references to rooms previously created is passed to the build commands.

+

You then call the command in-game using the path to the MAP and MAP_LEGEND vars +The path you provide is relative to the evennia or mygame folder.

+

See also the separate tutorial in the docs.

+
+

Installation

+

Use by importing and including the command in your default_cmdsets module. +For example:

+
    # mygame/commands/default_cmdsets.py
+
+    from evennia.contrib.grid import mapbuilder
+
+    ...
+
+    self.add(mapbuilder.CmdMapBuilder())
+
+
+
+
+

Usage:

+
mapbuilder[/switch] <path.to.file.MAPNAME> <path.to.file.MAP_LEGEND>
+
+one - execute build instructions once without automatic exit creation.
+two - execute build instructions twice without automatic exit creation.
+
+
+
+
+

Examples

+
mapbuilder world.gamemap.MAP world.maplegend.MAP_LEGEND
+mapbuilder evennia.contrib.grid.mapbuilder.EXAMPLE1_MAP EXAMPLE1_LEGEND
+mapbuilder/two evennia.contrib.grid.mapbuilder.EXAMPLE2_MAP EXAMPLE2_LEGEND
+        (Legend path defaults to map path)
+
+
+

Below are two examples showcasing the use of automatic exit generation and +custom exit generation. Whilst located, and can be used, from this module for +convenience The below example code should be in mymap.py in mygame/world.

+
+

Example One

+

+from django.conf import settings
+from evennia.utils import utils
+
+# mapbuilder evennia.contrib.grid.mapbuilder.EXAMPLE1_MAP EXAMPLE1_LEGEND
+
+# -*- coding: utf-8 -*-
+
+# Add the necessary imports for your instructions here.
+from evennia import create_object
+from typeclasses import rooms, exits
+from random import randint
+import random
+
+
+# A map with a temple (▲) amongst mountains (n,∩) in a forest (♣,♠) on an
+# island surrounded by water (≈). By giving no instructions for the water
+# characters we effectively skip it and create no rooms for those squares.
+EXAMPLE1_MAP = '''
+≈≈≈≈≈
+≈♣n♣≈
+≈∩▲∩≈
+≈♠n♠≈
+≈≈≈≈≈
+'''
+
+def example1_build_forest(x, y, **kwargs):
+    '''A basic example of build instructions. Make sure to include **kwargs
+    in the arguments and return an instance of the room for exit generation.'''
+
+    # Create a room and provide a basic description.
+    room = create_object(rooms.Room, key="forest" + str(x) + str(y))
+    room.db.desc = "Basic forest room."
+
+    # Send a message to the account
+    kwargs["caller"].msg(room.key + " " + room.dbref)
+
+    # This is generally mandatory.
+    return room
+
+
+def example1_build_mountains(x, y, **kwargs):
+    '''A room that is a little more advanced'''
+
+    # Create the room.
+    room = create_object(rooms.Room, key="mountains" + str(x) + str(y))
+
+    # Generate a description by randomly selecting an entry from a list.
+    room_desc = [
+        "Mountains as far as the eye can see",
+        "Your path is surrounded by sheer cliffs",
+        "Haven't you seen that rock before?",
+    ]
+    room.db.desc = random.choice(room_desc)
+
+    # Create a random number of objects to populate the room.
+    for i in range(randint(0, 3)):
+        rock = create_object(key="Rock", location=room)
+        rock.db.desc = "An ordinary rock."
+
+    # Send a message to the account
+    kwargs["caller"].msg(room.key + " " + room.dbref)
+
+    # This is generally mandatory.
+    return room
+
+
+def example1_build_temple(x, y, **kwargs):
+    '''A unique room that does not need to be as general'''
+
+    # Create the room.
+    room = create_object(rooms.Room, key="temple" + str(x) + str(y))
+
+    # Set the description.
+    room.db.desc = (
+        "In what, from the outside, appeared to be a grand and "
+        "ancient temple you've somehow found yourself in the the "
+        "Evennia Inn! It consists of one large room filled with "
+        "tables. The bardisk extends along the east wall, where "
+        "multiple barrels and bottles line the shelves. The "
+        "barkeep seems busy handing out ale and chatting with "
+        "the patrons, which are a rowdy and cheerful lot, "
+        "keeping the sound level only just below thunderous. "
+        "This is a rare spot of mirth on this dread moor."
+    )
+
+    # Send a message to the account
+    kwargs["caller"].msg(room.key + " " + room.dbref)
+
+    # This is generally mandatory.
+    return room
+
+
+# Include your trigger characters and build functions in a legend dict.
+EXAMPLE1_LEGEND = {
+    ("♣", "♠"): example1_build_forest,
+    ("∩", "n"): example1_build_mountains,
+    ("▲"): example1_build_temple,
+}
+
+
+
+
+

Example Two

+
# @mapbuilder/two evennia.contrib.grid.mapbuilder.EXAMPLE2_MAP EXAMPLE2_LEGEND
+
+# -*- coding: utf-8 -*-
+
+# Add the necessary imports for your instructions here.
+# from evennia import create_object
+# from typeclasses import rooms, exits
+# from evennia.utils import utils
+# from random import randint
+# import random
+
+# This is the same layout as Example 1 but included are characters for exits.
+# We can use these characters to determine which rooms should be connected.
+EXAMPLE2_MAP = '''
+≈ ≈ ≈ ≈ ≈
+
+≈ ♣-♣-♣ ≈
+  |   |
+≈ ♣ ♣ ♣ ≈
+  | | |
+≈ ♣-♣-♣ ≈
+
+≈ ≈ ≈ ≈ ≈
+'''
+
+def example2_build_forest(x, y, **kwargs):
+    '''A basic room'''
+    # If on anything other than the first iteration - Do nothing.
+    if kwargs["iteration"] > 0:
+        return None
+
+    room = create_object(rooms.Room, key="forest" + str(x) + str(y))
+    room.db.desc = "Basic forest room."
+
+    kwargs["caller"].msg(room.key + " " + room.dbref)
+
+    return room
+
+def example2_build_verticle_exit(x, y, **kwargs):
+    '''Creates two exits to and from the two rooms north and south.'''
+    # If on the first iteration - Do nothing.
+    if kwargs["iteration"] == 0:
+        return
+
+    north_room = kwargs["room_dict"][(x, y - 1)]
+    south_room = kwargs["room_dict"][(x, y + 1)]
+
+    # create exits in the rooms
+    create_object(
+        exits.Exit, key="south", aliases=["s"], location=north_room, destination=south_room
+    )
+
+    create_object(
+        exits.Exit, key="north", aliases=["n"], location=south_room, destination=north_room
+    )
+
+    kwargs["caller"].msg("Connected: " + north_room.key + " & " + south_room.key)
+
+
+def example2_build_horizontal_exit(x, y, **kwargs):
+    '''Creates two exits to and from the two rooms east and west.'''
+    # If on the first iteration - Do nothing.
+    if kwargs["iteration"] == 0:
+        return
+
+    west_room = kwargs["room_dict"][(x - 1, y)]
+    east_room = kwargs["room_dict"][(x + 1, y)]
+
+    create_object(exits.Exit, key="east", aliases=["e"], location=west_room, destination=east_room)
+
+    create_object(exits.Exit, key="west", aliases=["w"], location=east_room, destination=west_room)
+
+    kwargs["caller"].msg("Connected: " + west_room.key + " & " + east_room.key)
+
+
+# Include your trigger characters and build functions in a legend dict.
+EXAMPLE2_LEGEND = {
+    ("♣", "♠"): example2_build_forest,
+    ("|"): example2_build_verticle_exit,
+    ("-"): example2_build_horizontal_exit,
+}
+
+
+
+
+
+
+

This document page is generated from evennia/contrib/grid/mapbuilder/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Menu-Login.html b/docs/latest/Contribs/Contrib-Menu-Login.html new file mode 100644 index 0000000000..25ae52bf36 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Menu-Login.html @@ -0,0 +1,164 @@ + + + + + + + + + Menu-based login system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ + + + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Mirror.html b/docs/latest/Contribs/Contrib-Mirror.html new file mode 100644 index 0000000000..06c58bbfef --- /dev/null +++ b/docs/latest/Contribs/Contrib-Mirror.html @@ -0,0 +1,159 @@ + + + + + + + + + TutorialMirror — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

TutorialMirror

+

Contribution by Griatch, 2017

+

A simple mirror object to experiment with. It will respond to being looked at.

+
    +
  • echoes back the description of the object looking at it

  • +
  • echoes back whatever is being sent to its .msg - to the +sender, if given, otherwise to the location of the mirror.

  • +
+
+

Installation

+

Create the mirror with

+
create/drop mirror:contrib.tutorials.mirror.TutorialMirror
+
+
+

Then look at it.

+
+

This document page is generated from evennia/contrib/tutorials/mirror/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Multidescer.html b/docs/latest/Contribs/Contrib-Multidescer.html new file mode 100644 index 0000000000..9daa95f951 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Multidescer.html @@ -0,0 +1,212 @@ + + + + + + + + + Evennia Multidescer — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia Multidescer

+

Contribution by Griatch 2016

+

A “multidescer” is a concept from the MUSH world. It allows for +splitting your descriptions into arbitrary named ‘sections’ which you can +then swap out at will. It is a way for quickly managing your look (such as when +changing clothes) in more free-form roleplaying systems. This will also +work well together with the rpsystem contrib.

+

This multidescer will not require any changes to the Character class, rather it +will use the multidescs Attribute (a list) and create it if it does not exist. +It adds a new +desc command (where the + is optional in Evennia).

+
+

Installation

+

Like for any custom command, you just add the new +desc command to a default +cmdset: Import the evennia.contrib.game_systems.multidescer.CmdMultiDesc into +mygame/commands/default_cmdsets.py and add it to the CharacterCmdSet class.

+

Reload the server and you should have the +desc command available (it +will replace the default desc command).

+
+
+

Usage

+

Use the +desc command in-game:

+
+desc [key]                - show current desc desc with <key>
++desc <key> = <text>       - add/replace desc with <key>
++desc/list                 - list descriptions (abbreviated)
++desc/list/full            - list descriptions (full texts)
++desc/edit <key>           - add/edit desc <key> in line editor
++desc/del <key>            - delete desc <key>
++desc/swap <key1>-<key2>   - swap positions of <key1> and <key2> in list
++desc/set <key> [+key+...] - set desc as default or combine multiple descs
+
+
+

As an example, you can set one description for clothing, another for your boots, +hairstyle or whatever you like. Use |/ to add line breaks for multi-line descriptions and +paragraphs, as well as |_ to enforce indentations and whitespace (we don’t +include colors in the example since they don’t show in this documentation).

+
+desc base = A handsome man.|_
++desc mood = He is cheerful, like all is going his way.|/|/
++desc head = On his head he has a red hat with a feather in it.|_
++desc shirt = His chest is wrapped in a white shirt. It has golden buttons.|_
++desc pants = He wears blue pants with a dragorn pattern on them.|_
++desc boots = His boots are dusty from the road.
++desc/set base + mood + head + shirt + pants + boots
+
+
+

When looking at this character, you will now see (assuming auto-linebreaks)

+
A hansome man. He is cheerful, like all is going his way.
+
+On his head he has a red hat with a feather in it. His chest is wrapped in a
+white shirt. It has golden buttons. He wears blue pants with a dragon
+pattern on them. His boots are dusty from the road.
+
+
+

If you now do

+
+desc mood = He looks sullen and forlorn.|/|/
++desc shirt = His formerly white shirt is dirty and has a gash in it.|_
+
+
+

Your description will now be

+
A handsome man. He looks sullen and forlorn.
+
+On his head he as a red hat with a feathre in it. His formerly white shirt
+is dirty and has a gash in it. He wears blue pants with a pattern on them.
+His boots are dusty from the road.
+
+
+

You can use any number of ‘pieces’ to build up your description, and can swap +and replace them as you like and RP requires.

+
+

This document page is generated from evennia/contrib/game_systems/multidescer/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Mux-Comms-Cmds.html b/docs/latest/Contribs/Contrib-Mux-Comms-Cmds.html new file mode 100644 index 0000000000..ab84e9d725 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Mux-Comms-Cmds.html @@ -0,0 +1,209 @@ + + + + + + + + + Legacy Comms-commands — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Legacy Comms-commands

+

Contribution by Griatch 2021

+

In Evennia 1.0+, the old Channel commands (originally inspired by MUX) were +replaced by the single channel command that performs all these functions. +This contrib (extracted from Evennia 0.9.5) breaks out the functionality into +separate Commands more familiar to MU* users. This is just for show though, the +main channel command is still called under the hood.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Contrib syntax

Default channel syntax

allcom

channel/all and channel

addcom

channel/alias, channel/sub and channel/unmute

delcom

channel/unalias, alias/unsub and channel/mute

cboot

channel/boot (channel/ban and /unban not supported)

cwho

channel/who

ccreate

channel/create

cdestroy

channel/destroy

clock

channel/lock

cdesc

channel/desc

+
+

Installation

+
    +
  • Import the CmdSetLegacyComms cmdset from this module into mygame/commands/default_cmdsets.py

  • +
  • Add it to the CharacterCmdSet’s at_cmdset_creation method (see below).

  • +
  • Reload the server.

  • +
+
# in mygame/commands/default_cmdsets.py
+
+# ..
+from evennia.contrib.base_systems.mux_comms_cmds import CmdSetLegacyComms   # <----
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(CmdSetLegacyComms)   # <----
+
+
+
+

Note that you will still be able to use the channel command; this is actually +still used under the hood by these commands.

+
+

This document page is generated from evennia/contrib/base_systems/mux_comms_cmds/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Name-Generator.html b/docs/latest/Contribs/Contrib-Name-Generator.html new file mode 100644 index 0000000000..1b820d2d85 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Name-Generator.html @@ -0,0 +1,415 @@ + + + + + + + + + Random Name Generator — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Random Name Generator

+

Contribution by InspectorCaracal (2022)

+

A module for generating random names, both real-world and fantasy. Real-world +names can be generated either as first (personal) names, family (last) names, or +full names (first, optional middles, and last). The name data is from Behind the Name +and used under the CC BY-SA 4.0 license.

+

Fantasy names are generated from basic phonetic rules, using CVC syllable syntax.

+

Both real-world and fantasy name generation can be extended to include additional +information via your game’s settings.py

+
+

Installation

+

This is a stand-alone utility. Just import this module (from evennia.contrib.utils import name_generator) and use its functions wherever you like.

+
+
+

Usage

+

Import the module where you need it with the following:

+
from evennia.contrib.utils.name_generator import namegen
+
+
+

By default, all of the functions will return a string with one generated name. +If you specify more than one, or pass return_list=True as a keyword argument, the returned value will be a list of strings.

+

The module is especially useful for naming newly-created NPCs, like so:

+
npc_name = namegen.full_name()
+npc_obj = create_object(key=npc_name, typeclass="typeclasses.characters.NPC")
+
+
+
+
+

Available Settings

+

These settings can all be defined in your game’s server/conf/settings.py file.

+
    +
  • NAMEGEN_FIRST_NAMES adds a new list of first (personal) names.

  • +
  • NAMEGEN_LAST_NAMES adds a new list of last (family) names.

  • +
  • NAMEGEN_REPLACE_LISTS - set to True if you want to use only the names defined in your settings.

  • +
  • NAMEGEN_FANTASY_RULES lets you add new phonetic rules for generating entirely made-up names. See the section “Custom Fantasy Name style rules” for details on how this should look.

  • +
+

Examples:

+
NAMEGEN_FIRST_NAMES = [
+		("Evennia", 'mf'),
+		("Green Tea", 'f'),
+	]
+
+NAMEGEN_LAST_NAMES = [ "Beeblebrox", "Son of Odin" ]
+
+NAMEGEN_FANTASY_RULES = {
+  "example_style": {
+			"syllable": "(C)VC",
+			"consonants": [ 'z','z','ph','sh','r','n' ],
+			"start": ['m'],
+			"end": ['x','n'],
+			"vowels": [ "e","e","e","a","i","i","u","o", ],
+			"length": (2,4),
+	}
+}
+
+
+
+
+

Generating Real Names

+

The contrib offers three functions for generating random real-world names: +first_name(), last_name(), and full_name(). If you want more than one name +generated at once, you can use the num keyword argument to specify how many.

+

Example:

+
>>> namegen.first_name(num=5)
+['Genesis', 'Tali', 'Budur', 'Dominykas', 'Kamau']
+>>> namegen.first_name(gender='m')
+'Blanchard'
+
+
+

The first_name function also takes a gender keyword argument to filter names +by gender association. ‘f’ for feminine, ‘m’ for masculine, ‘mf’ for feminine +and masculine, or the default None to match any gendering.

+

The full_name function also takes the gender keyword, as well as parts which +defines how many names make up the full name. The minimum is two: a first name and +a last name. You can also generate names with the family name first by setting +the keyword arg surname_first to True

+

Example:

+
>>> namegen.full_name()
+'Keeva Bernat'
+>>> namegen.full_name(parts=4)
+'Suzu Shabnam Kafka Baier'
+>>> namegen.full_name(parts=3, surname_first=True)
+'Ó Muircheartach Torunn Dyson'
+>>> namegen.full_name(gender='f')
+'Wikolia Ó Deasmhumhnaigh'
+
+
+
+

Adding your own names

+

You can add additional names with the settings NAMEGEN_FIRST_NAMES and +NAMEGEN_LAST_NAMES

+

NAMEGEN_FIRST_NAMES should be a list of tuples, where the first value is the name +and then second value is the gender flag - ‘m’ for masculine-only, ‘f’ for feminine- +only, and ‘mf’ for either one.

+

NAMEGEN_LAST_NAMES should be a list of strings, where each item is an available +surname.

+

Examples:

+
NAMEGEN_FIRST_NAMES = [
+		("Evennia", 'mf'),
+		("Green Tea", 'f'),
+	]
+
+NAMEGEN_LAST_NAMES = [ "Beeblebrox", "Son of Odin" ]
+
+
+

Set NAMEGEN_REPLACE_LISTS = True if you want your custom lists above to entirely replace the built-in lists rather than extend them.

+
+
+
+

Generating Fantasy Names

+

Generating completely made-up names is done with the fantasy_name function. The +contrib comes with three built-in styles of names which you can use, or you can +put a dictionary of custom name rules into settings.py

+

Generating a fantasy name takes the ruleset key as the “style” keyword, and can +return either a single name or multiple names. By default, it will return a +single name in the built-in “harsh” style. The contrib also comes with “fluid” and “alien” styles.

+
>>> namegen.fantasy_name()
+'Vhon'
+>>> namegen.fantasy_name(num=3, style="harsh")
+['Kha', 'Kizdhu', 'Godögäk']
+>>> namegen.fantasy_name(num=3, style="fluid")
+['Aewalisash', 'Ayi', 'Iaa']
+>>> namegen.fantasy_name(num=5, style="alien")
+["Qz'vko'", "Xv'w'hk'hxyxyz", "Wxqv'hv'k", "Wh'k", "Xbx'qk'vz"]
+
+
+
+

Multi-Word Fantasy Names

+

The fantasy_name function will only generate one name-word at a time, so for multi-word names +you’ll need to combine pieces together. Depending on what kind of end result you want, there are +several approaches.

+
+

The simple approach

+

If all you need is for it to have multiple parts, you can generate multiple names at once and join them.

+
>>> name = " ".join(namegen.fantasy_name(num=2))
+>>> name
+'Dezhvözh Khäk'
+
+
+

If you want a little more variation between first/last names, you can also generate names for +different styles and then combine them.

+
>>> first = namegen.fantasy_name(style="fluid")
+>>> last = namegen.fantasy_name(style="harsh")
+>>> name = f"{first} {last}"
+>>> name
+'Ofasa Käkudhu'
+
+
+
+
+

“Nakku Silversmith”

+

One common fantasy name practice is profession- or title-based surnames. To achieve this effect, +you can use the last_name function with a custom list of last names and combine it with your generated +fantasy name.

+

Example:

+
NAMEGEN_LAST_NAMES = [ "Silversmith", "the Traveller", "Destroyer of Worlds" ]
+NAMEGEN_REPLACE_LISTS = True
+
+>>> first = namegen.fantasy_name()
+>>> last = namegen.last_name()
+>>> name = f"{first} {last}"
+>>> name
+'Tözhkheko the Traveller'
+
+
+
+
+

Elarion d’Yrinea, Thror Obinson

+

Another common flavor of fantasy names is to use a surname suffix or prefix. For that, you’ll +need to add in the extra bit yourself.

+

Examples:

+
>>> names = namegen.fantasy_name(num=2)
+>>> name = f"{names[0]} za'{names[1]}"
+>>> name
+"Tithe za'Dhudozkok"
+
+>>> names = namegen.fantasy_name(num=2)
+>>> name = f"{names[0]} {names[1]}son"
+>>> name
+'Kön Ködhöddoson'
+
+
+
+
+
+

Custom Fantasy Name style rules

+

The style rules are contained in a dictionary of dictionaries, where the style name +is the key and the style rules are the dictionary value.

+

The following is how you would add a custom style to settings.py:

+
NAMEGEN_FANTASY_RULES = {
+  "example_style": {
+			"syllable": "(C)VC",
+			"consonants": [ 'z','z','ph','sh','r','n' ],
+			"start": ['m'],
+			"end": ['x','n'],
+			"vowels": [ "e","e","e","a","i","i","u","o", ],
+			"length": (2,4),
+	}
+}
+
+
+

Then you could generate names following that ruleset with namegen.fantasy_name(style="example_style").

+

The keys syllable, consonants, vowels, and length must be present, and length must be the minimum and maximum syllable counts. start and end are optional.

+
+

syllable

+

The “syllable” field defines the structure of each syllable. C is consonant, V is vowel, +and parentheses mean it’s optional. So, the example (C)VC means that every syllable +will always have a vowel followed by a consonant, and will sometimes have another +consonant at the beginning. e.g. en, bak

+

Note: While it’s not standard, the contrib lets you nest parentheses, with each layer +being less likely to show up. Additionally, any other characters put into the syllable +structure - e.g. an apostrophe - will be read and inserted as written. The +“alien” style rules in the module gives an example of both: the syllable structure is C(C(V))(')(C) +which results in syllables such as khq, xho'q, and q' with a much lower frequency of vowels than +C(C)(V)(')(C) would have given.

+
+
+

consonants

+

A simple list of consonant phonemes that can be chosen from. Multi-character strings are +perfectly acceptable, such as “th”, but each one will be treated as a single consonant.

+

The function uses a naive form of weighting, where you make a phoneme more likely to +occur by putting more copies of it into the list.

+
+
+

start and end

+

These are optional lists for the first and last letters of a syllable, if they’re +a consonant. You can add on additional consonants which can only occur at the beginning +or end of a syllable, or you can add extra copies of already-defined consonants to +increase the frequency of them at the start/end of syllables.

+

For example, in the example_style above, we have a start of m, and end of x and n. +Taken with the rest of the consonants/vowels, this means you can have the syllables of mez +but not zem, and you can have phex or phen but not xeph or neph.

+

They can be left out of custom rulesets entirely.

+
+
+

vowels

+

Vowels is a simple list of vowel phonemes - exactly like consonants, but instead used for the +vowel selection. Single-or multi-character strings are equally fine. It uses the same naive weighting system +as consonants - you can increase the frequency of any given vowel by putting it into the list multiple times.

+
+
+

length

+

A tuple with the minimum and maximum number of syllables a name can have.

+

When setting this, keep in mind how long your syllables can get! 4 syllables might +not seem like very many, but if you have a ©(V)VC structure with one- and +two-letter phonemes, you can get up to eight characters per syllable.

+
+

This document page is generated from evennia/contrib/utils/name_generator/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Puzzles.html b/docs/latest/Contribs/Contrib-Puzzles.html new file mode 100644 index 0000000000..9f3b27f38d --- /dev/null +++ b/docs/latest/Contribs/Contrib-Puzzles.html @@ -0,0 +1,212 @@ + + + + + + + + + Puzzles System — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Puzzles System

+

Contribution by Henddher 2018

+

Intended for adventure-game style combination puzzles, such as combining fruits +and a blender to create a smoothie. Provides a typeclass and commands for objects +that can be combined (i.e. used together). Unlike the crafting contrib, each +puzzle is built from unique objects rather than using tags and a builder can create +the puzzle entirely from in-game.

+

A Puzzle is a recipe of what objects (aka parts) must be combined by a player so +a new set of objects (aka results) are automatically created.

+
+

Installation

+

Add the PuzzleSystemCmdSet to all players (e.g. in their Character typeclass).

+

Alternatively (for quick testing):

+
py self.cmdset.add('evennia.contrib.game_systems.puzzles.PuzzleSystemCmdSet')
+
+
+
+
+

Usage

+

Consider this simple Puzzle:

+
orange, mango, yogurt, blender = fruit smoothie
+
+
+

As a Builder:

+
create/drop orange
+create/drop mango
+create/drop yogurt
+create/drop blender
+create/drop fruit smoothie
+
+puzzle smoothie, orange, mango, yogurt, blender = fruit smoothie
+...
+Puzzle smoothie(#1234) created successfuly.
+
+destroy/force orange, mango, yogurt, blender, fruit smoothie
+
+armpuzzle #1234
+Part orange is spawned at ...
+Part mango is spawned at ...
+....
+Puzzle smoothie(#1234) has been armed successfully
+
+
+

As Player:

+
use orange, mango, yogurt, blender
+...
+Genius, you blended all fruits to create a fruit smoothie!
+
+
+
+
+

Details

+

Puzzles are created from existing objects. The given +objects are introspected to create prototypes for the +puzzle parts and results. These prototypes become the +puzzle recipe. (See PuzzleRecipe and puzzle +command). Once the recipe is created, all parts and result +can be disposed (i.e. destroyed).

+

At a later time, a Builder or a Script can arm the puzzle +and spawn all puzzle parts in their respective +locations (See armpuzzle).

+

A regular player can collect the puzzle parts and combine +them (See use command). If player has specified +all pieces, the puzzle is considered solved and all +its puzzle parts are destroyed while the puzzle results +are spawened on their corresponding location.

+
+

This document page is generated from evennia/contrib/game_systems/puzzles/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-RPSystem.html b/docs/latest/Contribs/Contrib-RPSystem.html new file mode 100644 index 0000000000..f388496482 --- /dev/null +++ b/docs/latest/Contribs/Contrib-RPSystem.html @@ -0,0 +1,339 @@ + + + + + + + + + Roleplaying base system for Evennia — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Roleplaying base system for Evennia

+

Contribution by Griatch, 2015

+

A full roleplaying emote system. Short-descriptions and recognition (only know people by their looks until you assign a name to them). Room poses. Masks/disguises (hide your description). Speak directly in emote, with optional language obscuration (words get garbled if you don’t know the language, you can also have different languages with different ‘sounding’ garbling). Whispers can be partly overheard from a distance. A very powerful in-emote reference system, for referencing and differentiate targets (including objects).

+

The system contains of two main modules - the roleplaying emote system and the language obscuration module.

+
+

Roleplaying emotes

+

This module contains the ContribRPObject, ContribRPRoom and ContribRPCharacter typeclasses. If you inherit your objects/rooms/character from these (or make them the defaults) from these you will get the following features:

+
    +
  • Objects/Rooms will get the ability to have poses and will report the poses of items inside them (the latter most useful for Rooms).

  • +
  • Characters will get poses and also sdescs (short descriptions) that will be used instead of their keys. They will gain commands for managing recognition (custom sdesc-replacement), masking themselves as well as an advanced free-form emote command.

  • +
+

In more detail, This RP base system introduces the following features to a game, common to many RP-centric games:

+
    +
  • emote system using director stance emoting (names/sdescs). This uses a customizable replacement noun (/me, @ etc) to represent you in the emote. You can use /sdesc, /nick, /key or /alias to reference objects in the room. You can use any number of sdesc sub-parts to differentiate a local sdesc, or use /1-sdesc etc to differentiate them. The emote also identifies nested says and separates case.

  • +
  • sdesc obscuration of real character names for use in emotes and in any referencing such as object.search(). This relies on an SdescHandler sdesc being set on the Character and makes use of a custom Character.get_display_name hook. If sdesc is not set, the character’s key is used instead. This is particularly used in the emoting system.

  • +
  • recog system to assign your own nicknames to characters, can then be used for referencing. The user may recog a user and assign any personal nick to them. This will be shown in descriptions and used to reference them. This is making use of the nick functionality of Evennia.

  • +
  • masks to hide your identity (using a simple lock).

  • +
  • pose system to set room-persistent poses, visible in room descriptions and when looking at the person/object. This is a simple Attribute that modifies how the characters is viewed when in a room as sdesc + pose.

  • +
  • in-emote says, including seamless integration with language obscuration routine (such as contrib/rplanguage.py)

  • +
+
+

Installation:

+

Add RPSystemCmdSet from this module to your CharacterCmdSet:

+
# mygame/commands/default_cmdsets.py
+
+# ...
+
+from evennia.contrib.rpg.rpsystem import RPSystemCmdSet  <---
+
+class CharacterCmdSet(default_cmds.CharacterCmdset):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(RPSystemCmdSet())  # <---
+
+
+
+

You also need to make your Characters/Objects/Rooms inherit from +the typeclasses in this module:

+
# in mygame/typeclasses/characters.py
+
+from evennia.contrib.rpg.rpsystem import ContribRPCharacter
+
+class Character(ContribRPCharacter):
+    # ...
+
+
+
+
# in mygame/typeclasses/objects.py
+
+from evennia.contrib.rpg.rpsystem import ContribRPObject
+
+class Object(ContribRPObject):
+    # ...
+
+
+
+
# in mygame/typeclasses/rooms.py
+
+from evennia.contrib.rpg.rpsystem import ContribRPRoom
+
+class Room(ContribRPRoom):
+    # ...
+
+
+
+

You will then need to reload the server and potentially force-reload +your objects, if you originally created them without this.

+

Example for your character:

+
> type/reset/force me = typeclasses.characters.Character
+
+
+

Examples:

+
> look
+
+Tavern
+The tavern is full of nice people
+
+*A tall man* is standing by the bar.
+
+
+

Above is an example of a player with an sdesc “a tall man”. It is also an example of a static pose: The “standing by the bar” has been set by the player of the tall man, so that people looking at him can tell at a glance what is going on.

+
> emote /me looks at /Tall and says "Hello!"
+
+
+

I see:

+
Griatch looks at Tall man and says "Hello".
+
+
+

Tall man (assuming his name is Tom) sees:

+
The godlike figure looks at Tom and says "Hello".
+
+
+

Note that by default, the case of the tag matters, so /tall will lead to ‘tall man’ while /Tall will become ‘Tall man’ and /TALL becomes /TALL MAN. If you don’t want this behavior, you can pass case_sensitive=False to the send_emote function.

+
+
+
+

Language and whisper obfuscation system

+

This module is intented to be used with an emoting system (such as contrib/rpg/rpsystem.py). It offers the ability to obfuscate spoken words in the game in various ways:

+
    +
  • Language: The language functionality defines a pseudo-language map to any number of languages. The string will be obfuscated depending on a scaling that (most likely) will be input as a weighted average of the language skill of the speaker and listener.

  • +
  • Whisper: The whisper functionality will gradually “fade out” a whisper along as scale 0-1, where the fading is based on gradually removing sections of the whisper that is (supposedly) easier to overhear (for example “s” sounds tend to be audible even when no other meaning can be determined).

  • +
+
+

Installation

+

This module adds no new commands; embed it in your say/emote/whisper commands.

+
+
+

Usage:

+
from evennia.contrib.rpg.rpsystem import rplanguage
+
+# need to be done once, here we create the "default" lang
+rplanguage.add_language()
+
+say = "This is me talking."
+whisper = "This is me whispering.
+
+print rplanguage.obfuscate_language(say, level=0.0)
+<<< "This is me talking."
+print rplanguage.obfuscate_language(say, level=0.5)
+<<< "This is me byngyry."
+print rplanguage.obfuscate_language(say, level=1.0)
+<<< "Daly ly sy byngyry."
+
+result = rplanguage.obfuscate_whisper(whisper, level=0.0)
+<<< "This is me whispering"
+result = rplanguage.obfuscate_whisper(whisper, level=0.2)
+<<< "This is m- whisp-ring"
+result = rplanguage.obfuscate_whisper(whisper, level=0.5)
+<<< "---s -s -- ---s------"
+result = rplanguage.obfuscate_whisper(whisper, level=0.7)
+<<< "---- -- -- ----------"
+result = rplanguage.obfuscate_whisper(whisper, level=1.0)
+<<< "..."
+
+
+
+

To set up new languages, import and use the add_language() helper method in this module. This allows you to customize the “feel” of the semi-random language you are creating. Especially the word_length_variance helps vary the length of translated words compared to the original and can help change the “feel” for the language you are creating. You can also add your own dictionary and “fix” random words for a list of input words.

+

Below is an example of “elvish”, using “rounder” vowels and sounds:

+
# vowel/consonant grammar possibilities
+grammar = ("v vv vvc vcc vvcc cvvc vccv vvccv vcvccv vcvcvcc vvccvvcc "
+           "vcvvccvvc cvcvvcvvcc vcvcvvccvcvv")
+
+# all not in this group is considered a consonant
+vowels = "eaoiuy"
+
+# you need a representative of all of the minimal grammars here, so if a
+# grammar v exists, there must be atleast one phoneme available with only
+# one vowel in it
+phonemes = ("oi oh ee ae aa eh ah ao aw ay er ey ow ia ih iy "
+            "oy ua uh uw y p b t d f v t dh s z sh zh ch jh k "
+            "ng g m n l r w")
+
+# how much the translation varies in length compared to the original. 0 is
+# smallest, higher values give ever bigger randomness (including removing
+# short words entirely)
+word_length_variance = 1
+
+# if a proper noun (word starting with capitalized letter) should be
+# translated or not. If not (default) it means e.g. names will remain
+# unchanged across languages.
+noun_translate = False
+
+# all proper nouns (words starting with a capital letter not at the beginning
+# of a sentence) can have either a postfix or -prefix added at all times
+noun_postfix = "'la"
+
+# words in dict will always be translated this way. The 'auto_translations'
+# is instead a list or filename to file with words to use to help build a
+# bigger dictionary by creating random translations of each word in the
+# list *once* and saving the result for subsequent use.
+manual_translations = {"the":"y'e", "we":"uyi", "she":"semi", "he":"emi",
+                      "you": "do", 'me':'mi','i':'me', 'be':"hy'e", 'and':'y'}
+
+rplanguage.add_language(key="elvish", phonemes=phonemes, grammar=grammar,
+                         word_length_variance=word_length_variance,
+                         noun_translate=noun_translate,
+                         noun_postfix=noun_postfix, vowels=vowels,
+                         manual_translations=manual_translations,
+                         auto_translations="my_word_file.txt")
+
+
+
+

This will produce a decicively more “rounded” and “soft” language than the default one. The few manual_translations also make sure to make it at least look superficially “reasonable”.

+

The auto_translations keyword is useful, this accepts either a list or a path to a text-file (with one word per line). This listing of words is used to ‘fix’ translations for those words according to the grammatical rules. These translations are stored persistently as long as the language exists.

+

This allows to quickly build a large corpus of translated words that never change. This produces a language that seem moderately consistent, since words like ‘the’ will always be translated to the same thing. The disadvantage (or advantage, depending on your game) is that players can end up learn what words mean even if their characters don’t know the langauge.

+
+

This document page is generated from evennia/contrib/rpg/rpsystem/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Random-String-Generator.html b/docs/latest/Contribs/Contrib-Random-String-Generator.html new file mode 100644 index 0000000000..adfbf3ef39 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Random-String-Generator.html @@ -0,0 +1,199 @@ + + + + + + + + + Pseudo-random generator and registry — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Pseudo-random generator and registry

+

Contribution by Vincent Le Goff (vlgeoff), 2017

+

This utility can be used to generate pseudo-random strings of information +with specific criteria. You could, for instance, use it to generate +phone numbers, license plate numbers, validation codes, in-game security +passwords and so on. The strings generated will be stored and won’t be repeated.

+
+

Usage Example

+

Here’s a very simple example:

+

+from evennia.contrib.utils.random_string_generator import RandomStringGenerator
+
+# Create a generator for phone numbers
+phone_generator = RandomStringGenerator("phone number", r"555-[0-9]{3}-[0-9]{4}")
+
+# Generate a phone number (555-XXX-XXXX with X as numbers)
+number = phone_generator.get()
+
+# `number` will contain something like: "555-981-2207"
+# If you call `phone_generator.get`, it won't give the same anymore.phone_generator.all()
+# Will return a list of all currently-used phone numbers
+phone_generator.remove("555-981-2207")
+
+# The number can be generated again
+
+
+
+
+

Importing

+
    +
  1. Import the RandomStringGenerator class from the contrib.

  2. +
  3. Create an instance of this class taking two arguments:

    +
      +
    • The name of the gemerator (like “phone number”, “license plate”…).

    • +
    • The regular expression representing the expected results.

    • +
    +
  4. +
  5. Use the generator’s all, get and remove methods as shown above.

  6. +
+

To understand how to read and create regular expressions, you can refer to +the documentation on the re module. +Some examples of regular expressions you could use:

+
    +
  • r"555-\d{3}-\d{4}": 555, a dash, 3 digits, another dash, 4 digits.

  • +
  • r"[0-9]{3}[A-Z][0-9]{3}": 3 digits, a capital letter, 3 digits.

  • +
  • r"[A-Za-z0-9]{8,15}": between 8 and 15 letters and digits.

  • +
  • +
+

Behind the scenes, a script is created to store the generated information +for a single generator. The RandomStringGenerator object will also +read the regular expression you give to it to see what information is +required (letters, digits, a more restricted class, simple characters…)… +More complex regular expressions (with branches for instance) might not be +available.

+
+

This document page is generated from evennia/contrib/utils/random_string_generator/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Red-Button.html b/docs/latest/Contribs/Contrib-Red-Button.html new file mode 100644 index 0000000000..c004595669 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Red-Button.html @@ -0,0 +1,172 @@ + + + + + + + + + Red Button example — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Red Button example

+

Contribution by Griatch, 2011

+

A red button that you can press to have an effect. This is a more advanced example +object with its own functionality and state tracking.

+

Create the button with

+
create/drop button:contrib.tutorials.red_button.RedButton
+
+
+

Note that you must drop the button before you can see its messages! It’s +imperative that you press the red button. You know you want to.

+

Use del button to destroy/stop the button when you are done playing.

+
+

Technical

+

The button’s functionality is controlled by CmdSets that gets added and removed +depending on the ‘state’ the button is in.

+
    +
  • Lid-closed state: In this state the button is covered by a glass cover and +trying to ‘push’ it will fail. You can ‘nudge’, ‘smash’ or ‘open’ the lid.

  • +
  • Lid-open state: In this state the lid is open but will close again after a +certain time. Using ‘push’ now will press the button and trigger the +Blind-state.

  • +
  • Blind-state: In this mode you are blinded by a bright flash. This will affect +your normal commands like ‘look’ and help until the blindness wears off after +a certain time.

  • +
+

Timers are handled by persistent delays on the button. These are examples of +evennia.utils.utils.delay calls that wait a certain time before calling a +method - such as when closing the lid and un-blinding a character.

+
+

This document page is generated from evennia/contrib/tutorials/red_button/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Simpledoor.html b/docs/latest/Contribs/Contrib-Simpledoor.html new file mode 100644 index 0000000000..eebe6e565a --- /dev/null +++ b/docs/latest/Contribs/Contrib-Simpledoor.html @@ -0,0 +1,186 @@ + + + + + + + + + SimpleDoor — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

SimpleDoor

+

Contribution by Griatch, 2016

+

A simple two-way exit that represents a door that can be opened and +closed from both sides. Can easily be expanded to make it lockable, +destroyable etc.

+

Note that the simpledoor is based on Evennia locks, so it will +not work for a superuser (which bypasses all locks). The superuser +will always appear to be able to close/open the door over and over +without the locks stopping you. To use the door, use quell or a +non-superuser account.

+
+

Installation:

+

Import SimpleDoorCmdSet from this module into mygame/commands/default_cmdsets +and add it to your CharacterCmdSet:

+
# in mygame/commands/default_cmdsets.py
+
+from evennia.contrib.grid import simpledoor  <---
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(simpledoor.SimpleDoorCmdSet)
+
+
+
+
+
+

Usage:

+

To try it out, dig a new room and then use the (overloaded) @open +commmand to open a new doorway to it like this:

+
@open doorway:contrib.grid.simpledoor.SimpleDoor = otherroom
+
+open doorway
+close doorway
+
+
+

Note: This uses locks, so if you are a superuser you will not be blocked by +a locked door - quell yourself, if so. Normal users will find that they +cannot pass through either side of the door once it’s closed from the other +side.

+
+

This document page is generated from evennia/contrib/grid/simpledoor/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Slow-Exit.html b/docs/latest/Contribs/Contrib-Slow-Exit.html new file mode 100644 index 0000000000..e7ca62aa35 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Slow-Exit.html @@ -0,0 +1,198 @@ + + + + + + + + + Slow Exit — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Slow Exit

+

Contribution by Griatch 2014

+

An example of an Exit-type that delays its traversal. This simulates +slow movement, common in many games. The contrib also +contains two commands, setspeed and stop for changing the movement speed +and abort an ongoing traversal, respectively.

+
+

Installation:

+

To try out an exit of this type, you could connect two existing rooms +using something like this:

+
@open north:contrib.grid.slow_exit.SlowExit = <destination>
+
+
+

To make this your new default exit, modify mygame/typeclasses/exits.py +to import this module and change the default Exit class to inherit +from SlowExit instead.

+
# in mygame/typeclasses/exits.py
+
+from evennia.contrib.grid.slowexit import SlowExit
+
+class Exit(SlowExit):
+    # ...
+
+
+
+

To get the ability to change your speed and abort your movement, import

+
# in mygame/commands/default_cmdsets.py
+
+from evennia.contrib.grid import slow_exit  <---
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(slow_exit.SlowDoorCmdSet)  <---
+
+
+
+

simply import and add CmdSetSpeed and CmdStop from this module to your +default cmdset (see tutorials on how to do this if you are unsure).

+

To try out an exit of this type, you could connect two existing rooms using +something like this:

+
@open north:contrib.grid.slow_exit.SlowExit = <destination>
+
+
+
+
+

Notes:

+

This implementation is efficient but not persistent; so incomplete +movement will be lost in a server reload. This is acceptable for most +game types - to simulate longer travel times (more than the couple of +seconds assumed here), a more persistent variant using Scripts or the +TickerHandler might be better.

+
+

This document page is generated from evennia/contrib/grid/slow_exit/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Talking-Npc.html b/docs/latest/Contribs/Contrib-Talking-Npc.html new file mode 100644 index 0000000000..0ba6c2bc46 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Talking-Npc.html @@ -0,0 +1,160 @@ + + + + + + + + + Talkative NPC example — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Talkative NPC example

+

Contribution by Griatch 2011. Updated by grungies1138, 2016

+

This is an example of a static NPC object capable of holding a simple menu-driven +conversation. Suitable for example as a quest giver or merchant.

+
+

Installation

+

Create the NPC by creating an object of typeclass contrib.tutorials.talking_npc.TalkingNPC, +For example:

+
create/drop John : contrib.tutorials.talking_npc.TalkingNPC
+
+
+

Use talk in the same room as the NPC to start a conversation.

+

If there are many talkative npcs in the same room you will get to choose which +one’s talk command to call (Evennia handles this automatically).

+

This use of EvMenu is very simplistic; See EvMenu for a lot more complex +possibilities.

+
+

This document page is generated from evennia/contrib/tutorials/talking_npc/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Traits.html b/docs/latest/Contribs/Contrib-Traits.html new file mode 100644 index 0000000000..fee378c356 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Traits.html @@ -0,0 +1,609 @@ + + + + + + + + + Traits — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Traits

+

Contribution by Griatch 2020, based on code by Whitenoise and Ainneve contribs, 2014

+

A Trait represents a modifiable property on (usually) a Character. They can +be used to represent everything from attributes (str, agi etc) to skills +(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc. +Traits differ from normal Attributes in that they track their changes and limit +themselves to particular value-ranges. One can add/subtract from them easily and +they can even change dynamically at a particular rate (like you being poisoned or +healed).

+

Traits use Evennia Attributes under the hood, making them persistent (they survive +a server reload/reboot).

+
+

Installation

+

Traits are always added to a typeclass, such as the Character class.

+

There are two ways to set up Traits on a typeclass. The first sets up the TraitHandler +as a property .traits on your class and you then access traits as e.g. .traits.strength. +The other alternative uses a TraitProperty, which makes the trait available directly +as e.g. .strength. This solution also uses the TraitHandler, but you don’t need to +define it explicitly. You can combine both styles if you like.

+
+

Traits with TraitHandler

+

Here’s an example for adding the TraitHandler to the Character class:

+
# mygame/typeclasses/objects.py
+
+from evennia import DefaultCharacter
+from evennia.utils import lazy_property
+from evennia.contrib.rpg.traits import TraitHandler
+
+# ...
+
+class Character(DefaultCharacter):
+    ...
+    @lazy_property
+    def traits(self):
+        # this adds the handler as .traits
+        return TraitHandler(self)
+
+
+    def at_object_creation(self):
+        # (or wherever you want)
+        self.traits.add("str", "Strength", trait_type="static", base=10, mod=2)
+        self.traits.add("hp", "Health", trait_type="gauge", min=0, max=100)
+        self.traits.add("hunting", "Hunting Skill", trait_type="counter",
+                        base=10, mod=1, min=0, max=100)
+
+
+
+
+

When adding the trait, you supply the name of the property (hunting) along +with a more human-friendly name (“Hunting Skill”). The latter will show if you +print the trait etc. The trait_type is important, this specifies which type +of trait this is (see below).

+
+
+

TraitProperties

+

Using TraitProperties makes the trait available directly on the class, much like Django model +fields. The drawback is that you must make sure that the name of your Traits don’t collide with any +other properties/methods on your class.

+
# mygame/typeclasses/objects.py
+
+from evennia import DefaultObject
+from evennia.utils import lazy_property
+from evennia.contrib.rpg.traits import TraitProperty
+
+# ...
+
+class Object(DefaultObject):
+    ...
+    strength = TraitProperty("Strength", trait_type="static", base=10, mod=2)
+    health = TraitProperty("Health", trait_type="gauge", min=0, base=100, mod=2)
+    hunting = TraitProperty("Hunting Skill", trait_type="counter", base=10, mod=1, min=0, max=100)
+
+
+
+
+

Note that the property-name will become the name of the trait and you don’t supply trait_key +separately.

+
+
+

The .traits TraitHandler will still be created (it’s used under the +hood. But it will only be created when the TraitProperty has been accessed at least once, +so be careful if mixing the two styles. If you want to make sure .traits is always available, +add the TraitHandler manually like shown earlier - the TraitProperty will by default use +the same handler (.traits).

+
+
+
+
+

Using traits

+

A trait is added to the traithandler (if you use TraitProperty the handler is just created under +the hood) after which one can access it as a property on the handler (similarly to how you can do +.db.attrname for Attributes in Evennia).

+

All traits have a read-only field .value. This is only used to read out results, you never +manipulate it directly (if you try, it will just remain unchanged). The .value is calculated based +on combining fields, like .base and .mod - which fields are available and how they relate to +each other depends on the trait type.

+
> obj.traits.strength.value
+12                                  # base + mod
+
+> obj.traits.strength.base += 5
+obj.traits.strength.value
+17
+
+> obj.traits.hp.value
+102                                 # base + mod
+
+> obj.traits.hp.base -= 200
+> obj.traits.hp.value
+0                                   # min of 0
+
+> obj.traits.hp.reset()
+> obj.traits.hp.value
+100
+
+# you can also access properties like a dict
+> obj.traits.hp["value"]
+100
+
+# you can store arbitrary data persistently for easy reference
+> obj.traits.hp.effect = "poisoned!"
+> obj.traits.hp.effect
+"poisoned!"
+
+# with TraitProperties:
+
+> obj.hunting.value
+12
+
+> obj.strength.value += 5
+> obj.strength.value
+17
+
+
+
+
+
+

Trait types

+

All default traits have a read-only .value property that shows the relevant or +‘current’ value of the trait. Exactly what this means depends on the type of trait.

+

Traits can also be combined to do arithmetic with their .value, if both have a +compatible type.

+
> trait1 + trait2
+54
+
+> trait1.value
+3
+
+> trait1 + 2
+> trait1.value
+5
+
+
+
+

Two numerical traits can also be compared (bigger-than etc), which is useful in +all sorts of rule-resolution.

+

+if trait1 > trait2:
+    # do stuff
+
+
+
+
+
+

Static trait

+

value = base + mod

+

The static trait has a base value and an optional mod-ifier. A typical use +of a static trait would be a Strength stat or Skill value. That is, something +that varies slowly or not at all, and which may be modified in-place.

+
> obj.traits.add("str", "Strength", trait_type="static", base=10, mod=2)
+> obj.traits.mytrait.value
+
+12   # base + mod
+> obj.traits.mytrait.base += 2
+> obj.traits.mytrait.mod += 1
+> obj.traits.mytrait.value
+15
+
+> obj.traits.mytrait.mod = 0
+> obj.traits.mytrait.value
+12
+
+
+
+
+

Counter

+
min/unset     base    base+mod                       max/unset
+|--------------|--------|---------X--------X------------|
+                              current    value
+                                         = current
+                                         + mod
+
+
+

A counter describes a value that can move from a base. The .current property +is the thing usually modified. It starts at the .base. One can also add a +modifier, which will both be added to the base and to current (forming +.value). The min/max of the range are optional, a boundary set to None will +remove it. A suggested use for a Counter Trait would be to track skill values.

+
> obj.traits.add("hunting", "Hunting Skill", trait_type="counter",
+                   base=10, mod=1, min=0, max=100)
+> obj.traits.hunting.value
+11  # current starts at base + mod
+
+> obj.traits.hunting.current += 10
+> obj.traits.hunting.value
+21
+
+# reset back to base+mod by deleting current
+> del obj.traits.hunting.current
+> obj.traits.hunting.value
+11
+> obj.traits.hunting.max = None  # removing upper bound
+
+# for TraitProperties, pass the args/kwargs of traits.add() to the
+# TraitProperty constructor instead.
+
+
+
+
+

Counters have some extra properties:

+
+

.descs

+

The descs property is a dict {upper_bound:text_description}. This allows for easily +storing a more human-friendly description of the current value in the +interval. Here is an example for skill values between 0 and 10:

+
{0: "unskilled", 1: "neophyte", 5: "trained", 7: "expert", 9: "master"}
+
+
+

The keys must be supplied from smallest to largest. Any values below the lowest and above the +highest description will be considered to be included in the closest description slot. +By calling .desc() on the Counter, you will get the text matching the current value.

+
# (could also have passed descs= to traits.add())
+> obj.traits.hunting.descs = {
+    0: "unskilled", 10: "neophyte", 50: "trained", 70: "expert", 90: "master"}
+> obj.traits.hunting.value
+11
+
+> obj.traits.hunting.desc()
+"neophyte"
+> obj.traits.hunting.current += 60
+> obj.traits.hunting.value
+71
+
+> obj.traits.hunting.desc()
+"expert"
+
+
+
+
+
+

.rate

+

The rate property defaults to 0. If set to a value different from 0, it +allows the trait to change value dynamically. This could be used for example +for an attribute that was temporarily lowered but will gradually (or abruptly) +recover after a certain time. The rate is given as change of the current +.value per-second, and this will still be restrained by min/max boundaries, +if those are set.

+

It is also possible to set a .ratetarget, for the auto-change to stop at +(rather than at the min/max boundaries). This allows the value to return to +a previous value.

+

+> obj.traits.hunting.value
+71
+
+> obj.traits.hunting.ratetarget = 71
+# debuff hunting for some reason
+> obj.traits.hunting.current -= 30
+> obj.traits.hunting.value
+41
+
+> obj.traits.hunting.rate = 1  # 1/s increase
+# Waiting 5s
+> obj.traits.hunting.value
+46
+
+# Waiting 8s
+> obj.traits.hunting.value
+54
+
+# Waiting 100s
+> obj.traits.hunting.value
+71    # we have stopped at the ratetarget
+
+> obj.traits.hunting.rate = 0  # disable auto-change
+
+
+
+
+

Note that when retrieving the current, the result will always be of the same +type as the .base even rate is a non-integer value. So if base is an int +(default), the currentvalue will also be rounded the closest full integer. If you want to see the exactcurrentvalue, setbaseto a float - you will then need to useround()` yourself on the result if you want integers.

+
+
+

.percent()

+

If both min and max are defined, the .percent() method of the trait will +return the value as a percentage.

+
> obj.traits.hunting.percent()
+"71.0%"
+
+> obj.traits.hunting.percent(formatting=None)
+71.0
+
+
+
+
+
+
+

Gauge

+

This emulates a [fuel-] gauge that empties from a base+mod value.

+
min/0                                            max=base+mod
+ |-----------------------X---------------------------|
+                       value
+                      = current
+
+
+

The .current value will start from a full gauge. The .max property is +read-only and is set by .base + .mod. So contrary to a Counter, the +.mod modifier only applies to the max value of the gauge and not the current +value. The minimum bound defaults to 0 if not set explicitly.

+

This trait is useful for showing commonly depletable resources like health, +stamina and the like.

+
> obj.traits.add("hp", "Health", trait_type="gauge", base=100)
+> obj.traits.hp.value  # (or .current)
+100
+
+> obj.traits.hp.mod = 10
+> obj.traits.hp.value
+110
+
+> obj.traits.hp.current -= 30
+> obj.traits.hp.value
+80
+
+
+
+

The Gauge trait is subclass of the Counter, so you have access to the same +methods and properties where they make sense. So gauges can also have a +.descs dict to describe the intervals in text, and can use .percent() to +get how filled it is as a percentage etc.

+

The .rate is particularly relevant for gauges - useful for everything +from poison slowly draining your health, to resting gradually increasing it.

+
+
+

Trait

+

A single value of any type.

+

This is the ‘base’ Trait, meant to inherit from if you want to invent +trait-types from scratch (most of the time you’ll probably inherit from some of +the more advanced trait-type classes though).

+

Unlike other Trait-types, the single .value property of the base Trait can +be editied. The value can hold any data that can be stored in an Attribute. If +it’s an integer/float you can do arithmetic with it, but otherwise this acts just +like a glorified Attribute.

+
> obj.traits.add("mytrait", "My Trait", trait_type="trait", value=30)
+> obj.traits.mytrait.value
+30
+
+> obj.traits.mytrait.value = "stringvalue"
+> obj.traits.mytrait.value
+"stringvalue"
+
+
+
+
+
+
+

Expanding with your own Traits

+

A Trait is a class inhering from evennia.contrib.rpg.traits.Trait (or from one of +the existing Trait classes).

+
# in a file, say, 'mygame/world/traits.py'
+
+from evennia.contrib.rpg.traits import StaticTrait
+
+class RageTrait(StaticTrait):
+
+    trait_type = "rage"
+    default_keys = {
+        "rage": 0
+    }
+
+    def berserk(self):
+        self.mod = 100
+
+    def sedate(self):
+        self.mod = 0
+
+
+
+
+

Above is an example custom-trait-class “rage” that stores a property “rage” on +itself, with a default value of 0. This has all the functionality of a Trait - +for example, if you do del on the rage property, it will be set back to its +default (0). Above we also added some helper methods.

+

To add your custom RageTrait to Evennia, add the following to your settings file +(assuming your class is in mygame/world/traits.py):

+
TRAIT_CLASS_PATHS = ["world.traits.RageTrait"]
+
+
+

Reload the server and you should now be able to use your trait:

+
> obj.traits.add("mood", "A dark mood", rage=30, trait_type='rage')
+> obj.traits.mood.rage
+30
+
+# as TraitProperty
+
+class Character(DefaultCharacter):
+    rage = TraitProperty("A dark mood", rage=30, trait_type='rage')
+
+
+
+
+
+

Adding additional TraitHandlers

+

Sometimes, it is easier to top-level classify traits, such as stats, skills, or other categories of traits you want to handle independantly of each other. Here is an example showing an example on the object typeclass, expanding on the first installation example:

+
# mygame/typeclasses/objects.py
+
+from evennia import DefaultCharacter
+from evennia.utils import lazy_property
+from evennia.contrib.rpg.traits import TraitHandler
+
+# ...
+
+class Character(DefaultCharacter):
+    ...
+    @lazy_property
+    def traits(self):
+        # this adds the handler as .traits
+        return TraitHandler(self)
+    
+    @lazy_property
+    def stats(self):
+        # this adds the handler as .stats
+        return TraitHandler(self, db_attribute_key="stats")
+
+    @lazy_property
+    def skills(self):
+        # this adds the handler as .skills
+        return TraitHandler(self, db_attribute_key="skills")
+
+
+    def at_object_creation(self):
+        # (or wherever you want)
+        self.stats.add("str", "Strength", trait_type="static", base=10, mod=2)
+        self.traits.add("hp", "Health", trait_type="gauge", min=0, max=100)
+        self.skills.add("hunting", "Hunting Skill", trait_type="counter",
+                        base=10, mod=1, min=0, max=100)
+
+
+
+

This document page is generated from evennia/contrib/rpg/traits/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Tree-Select.html b/docs/latest/Contribs/Contrib-Tree-Select.html new file mode 100644 index 0000000000..5700a945db --- /dev/null +++ b/docs/latest/Contribs/Contrib-Tree-Select.html @@ -0,0 +1,288 @@ + + + + + + + + + Easy menu selection tree — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Easy menu selection tree

+

Contribution by Tim Ashley Jenkins, 2017

+

This utility allows you to create and initialize an entire branching EvMenu +instance from a multi-line string passed to one function.

+
+

Note: Since the time this contrib was created, EvMenu itself got its own templating +language that has more features and is not compatible with the style used in +this contrib. Both can still be used in parallel.

+
+

EvMenu is incredibly powerful and flexible but it can be a little overwhelming +and offers a lot of power that may not be needed for a simple multiple-choice menu.

+

This module provides a function, init_tree_selection, which acts as a frontend +for EvMenu, dynamically sourcing the options from a multi-line string you +provide. For example, if you define a string as such:

+
TEST_MENU = '''Foo
+Bar
+Baz
+Qux'''
+
+
+

And then use TEST_MENU as the ‘treestr’ source when you call +init_tree_selection on a player:

+
init_tree_selection(TEST_MENU, caller, callback)
+
+
+

The player will be presented with an EvMenu, like so:

+
___________________________
+
+Make your selection:
+___________________________
+
+Foo
+Bar
+Baz
+Qux
+
+
+

Making a selection will pass the selection’s key to the specified callback as a +string along with the caller, as well as the index of the selection (the line +number on the source string) along with the source string for the tree itself.

+

In addition to specifying selections on the menu, you can also specify +categories. Categories are indicated by putting options below it preceded with +a ‘-’ character. If a selection is a category, then choosing it will bring up a +new menu node, prompting the player to select between those options, or to go +back to the previous menu. In addition, categories are marked by default with a +‘[+]’ at the end of their key. Both this marker and the option to go back can be +disabled.

+

Categories can be nested in other categories as well - just go another ‘-’ +deeper. You can do this as many times as you like. There’s no hard limit to the +number of categories you can go down.

+

For example, let’s add some more options to our menu, turning ‘Bar’ into a +category.

+
TEST_MENU = '''Foo
+Bar
+-You've got to know
+--When to hold em
+--When to fold em
+--When to walk away
+Baz
+Qux'''
+
+
+

Now when we call the menu, we can see that ‘Bar’ has become a category instead of a +selectable option.

+
_______________________________
+
+Make your selection:
+_______________________________
+
+Foo
+Bar [+]
+Baz
+Qux
+
+
+

Note the [+] next to ‘Bar’. If we select ‘Bar’, it’ll show us the option listed +under it.

+
________________________________________________________________
+
+Bar
+________________________________________________________________
+
+You've got to know [+]
+<< Go Back: Return to the previous menu.
+
+
+

Just the one option, which is a category itself, and the option to go back, +which will take us back to the previous menu. Let’s select ‘You’ve got to know’.

+
________________________________________________________________
+
+You've got to know
+________________________________________________________________
+
+When to hold em
+When to fold em
+When to walk away
+<< Go Back: Return to the previous menu.
+
+
+

Now we see the three options listed under it, too. We can select one of them or +use ‘Go Back’ to return to the ‘Bar’ menu we were just at before. It’s very +simple to make a branching tree of selections!

+

One last thing - you can set the descriptions for the various options simply by +adding a ‘:’ character followed by the description to the option’s line. For +example, let’s add a description to ‘Baz’ in our menu:

+
TEST_MENU = '''Foo
+Bar
+-You've got to know
+--When to hold em
+--When to fold em
+--When to walk away
+Baz: Look at this one: the best option.
+Qux'''
+
+
+

Now we see that the Baz option has a description attached that’s separate from its key:

+
_______________________________________________________________
+
+Make your selection:
+_______________________________________________________________
+
+Foo
+Bar [+]
+Baz: Look at this one: the best option.
+Qux
+
+
+

Once the player makes a selection - let’s say, ‘Foo’ - the menu will terminate +and call your specified callback with the selection, like so:

+
callback(caller, TEST_MENU, 0, "Foo")
+
+
+

The index of the selection is given along with a string containing the +selection’s key. That way, if you have two selections in the menu with the same +key, you can still differentiate between them.

+

And that’s all there is to it! For simple branching-tree selections, using this +system is much easier than manually creating EvMenu nodes. It also makes +generating menus with dynamic options much easier - since the source of the menu +tree is just a string, you could easily generate that string procedurally before +passing it to the init_tree_selection function. For example, if a player casts +a spell or does an attack without specifying a target, instead of giving them an +error, you could present them with a list of valid targets to select by +generating a multi-line string of targets and passing it to +init_tree_selection, with the callable performing the maneuver once a +selection is made.

+

This selection system only works for simple branching trees - doing anything +really complicated like jumping between categories or prompting for arbitrary +input would still require a full EvMenu implementation. For simple selections, +however, I’m sure you will find using this function to be much easier!

+

Included in this module is a sample menu and function which will let a player +change the color of their name - feel free to mess with it to get a feel for how +this system works by importing this module in your game’s default_cmdsets.py +module and adding CmdNameColor to your default character’s command set.

+
+

This document page is generated from evennia/contrib/utils/tree_select/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Turnbattle.html b/docs/latest/Contribs/Contrib-Turnbattle.html new file mode 100644 index 0000000000..cd89bb6fa4 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Turnbattle.html @@ -0,0 +1,186 @@ + + + + + + + + + Turn based battle system framework — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Turn based battle system framework

+

Contribution by Tim Ashley Jenkins, 2017

+

This is a framework for a simple turn-based combat system, similar +to those used in D&D-style tabletop role playing games. It allows +any character to start a fight in a room, at which point initiative +is rolled and a turn order is established. Each participant in combat +has a limited time to decide their action for that turn (30 seconds by +default), and combat progresses through the turn order, looping through +the participants until the fight ends.

+

This folder contains multiple examples of how such a system can be +implemented and customized:

+
tb_basic.py - The simplest system, which implements initiative and turn
+        order, attack rolls against defense values, and damage to hit
+        points. Only very basic game mechanics are included.
+
+tb_equip.py - Adds weapons and armor to the basic implementation of
+        the battle system, including commands for wielding weapons and
+        donning armor, and modifiers to accuracy and damage based on
+        currently used equipment.
+
+tb_items.py - Adds usable items and conditions/status effects, and gives
+    a lot of examples for each. Items can perform nearly any sort of
+    function, including healing, adding or curing conditions, or
+    being used to attack. Conditions affect a fighter's attributes
+    and options in combat and persist outside of fights, counting
+    down per turn in combat and in real time outside combat.
+
+tb_magic.py - Adds a spellcasting system, allowing characters to cast
+    spells with a variety of effects by spending MP. Spells are
+    linked to functions, and as such can perform any sort of action
+    the developer can imagine - spells for attacking, healing and
+    conjuring objects are included as examples.
+
+tb_range.py - Adds a system for abstract positioning and movement, which
+        tracks the distance between different characters and objects in
+        combat, as well as differentiates between melee and ranged
+        attacks.
+
+
+

This system is meant as a basic framework to start from, and is modeled +after the combat systems of popular tabletop role playing games rather than +the real-time battle systems that many MMOs and some MUDs use. As such, it +may be better suited to role-playing or more story-oriented games, or games +meant to closely emulate the experience of playing a tabletop RPG.

+

Each of these modules contains the full functionality of the battle system +with different customizations added in - the instructions to install each +one is contained in the module itself. It’s recommended that you install +and test tb_basic first, so you can better understand how the other +modules expand on it and get a better idea of how you can customize the +system to your liking and integrate the subsystems presented here into +your own combat system.

+
+

This document page is generated from evennia/contrib/game_systems/turnbattle/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Tutorial-World.html b/docs/latest/Contribs/Contrib-Tutorial-World.html new file mode 100644 index 0000000000..10abbc9174 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Tutorial-World.html @@ -0,0 +1,238 @@ + + + + + + + + + Evennia Tutorial World — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia Tutorial World

+

Contribution by Griatch 2011, 2015

+

A stand-alone tutorial area for an unmodified Evennia install. +Think of it as a sort of single-player adventure rather than a +full-fledged multi-player game world. The various rooms and objects +are designed to show off features of Evennia, not to be a +very challenging (nor long) gaming experience. As such it’s of course +only skimming the surface of what is possible. Taking this apart +is a great way to start learning the system.

+

The tutorial world also includes a game tutor menu example, exemplifying +Evmenu.

+
+

Installation

+

Log in as superuser (#1), then run

+
batchcommand contrib.tutorials.tutorial_world.build
+
+
+

Wait a little while for building to complete and don’t run the command +again even if it’s slow. This builds the world and connect it to Limbo +and creates a new exit tutorial.

+

If you are a superuser (User #1), use the quell command to play +the tutorial as intended.

+
+
+

Comments

+

The tutorial world is intended to be explored and analyzed. It will help you +learn how to accomplish some more advanced effects and might give some good +ideas along the way.

+

It’s suggested you play it through (as a normal user, NOT as Superuser!) and +explore it a bit, then come back here and start looking into the (heavily +documented) build/source code to find out how things tick - that’s the +“tutorial” in Tutorial world after all.

+

Please report bugs in the tutorial to the Evennia issue tracker.

+

Spoilers below - don’t read on unless you already played the +tutorial game

+
+
+

Tutorial World Room map

+
     ?
+     |
+ +---+----+    +-------------------+    +--------+   +--------+
+ |        |    |                   |    |gate    |   |corner  |
+ | cliff  +----+      bridge       +----+        +---+        |
+ |        |    |                   |    |        |   |        |
+ +---+---\+    +---------------+---+    +---+----+   +---+----+
+     |    \                    |            |   castle   |
+     |     \  +--------+  +----+---+    +---+----+   +---+----+
+     |      \ |under-  |  |ledge   |    |along   |   |court-  |
+     |       \|ground  +--+        |    |wall    +---+yard    |
+     |        \        |  |        |    |        |   |        |
+     |        +------\-+  +--------+    +--------+   +---+----+
+     |                \                                  |
+    ++---------+       \  +--------+    +--------+   +---+----+
+    |intro     |        \ |cell    |    |trap    |   |temple  |
+ o--+          |         \|        +----+        |   |        |
+L   |          |          \        |   /|        |   |        |
+I   +----+-----+          +--------+  / ---+-+-+-+   +---+----+
+M        |                           /     | | |         |
+B   +----+-----+          +--------+/   +--+-+-+---------+----+
+O   |outro     |          |tomb    |    |antechamber          |
+ o--+          +----------+        |    |                     |
+    |          |          |        |    |                     |
+    +----------+          +--------+    +---------------------+
+
+
+
+
+

Hints/Notes:

+
    +
  • o– connections to/from Limbo

  • +
  • intro/outro areas are rooms that automatically sets/cleans the +Character of any settings assigned to it during the +tutorial game.

  • +
  • The Cliff is a good place to get an overview of the surroundings.

  • +
  • The Bridge may seem like a big room, but it is really only one room +with custom move commands to make it take longer to cross. You can +also fall off the bridge if you are unlucky or take your time to +take in the view too long.

  • +
  • In the Castle areas an aggressive mob is patrolling. It implements +rudimentary AI but packs quite a punch unless you have +found yourself a weapon that can harm it. Combat is only +possible once you find a weapon.

  • +
  • The Antechamber features a puzzle for finding the correct Grave +chamber.

  • +
  • The Cell is your reward if you fail in various ways. Finding a +way out of it is a small puzzle of its own.

  • +
  • The Tomb is a nice place to find a weapon that can hurt the +castle guardian. This is the goal of the tutorial. +Explore on, or take the exit to finish the tutorial.

  • +
  • ? - look into the code if you cannot find this bonus area!

  • +
+
+

This document page is generated from evennia/contrib/tutorials/tutorial_world/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Unixcommand.html b/docs/latest/Contribs/Contrib-Unixcommand.html new file mode 100644 index 0000000000..a7ea47bc41 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Unixcommand.html @@ -0,0 +1,204 @@ + + + + + + + + + Unix-like Command style — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Unix-like Command style

+

Contribution by Vincent Le Geoff (vlgeoff), 2017

+

This module contains a command class with an alternate syntax parser implementing +Unix-style command syntax in-game. This means --options, positional arguments +and stuff like -n 10. It might not the best syntax for the average player +but can be really useful for builders when they need to have a single command do +many things with many options. It uses the ArgumentParser from Python’s standard +library under the hood.

+
+

Installation

+

To use, inherit UnixCommand from this module from your own commands. You need +to override two methods:

+
    +
  • The init_parser method, which adds options to the parser. Note that you +should normally not override the normal parse method when inheriting from +UnixCommand.

  • +
  • The func method, called to execute the command once parsed (like any Command).

  • +
+

Here’s a short example:

+
from evennia.contrib.base_systems.unixcommand import UnixCommand
+
+
+class CmdPlant(UnixCommand):
+
+    '''
+    Plant a tree or plant.
+
+    This command is used to plant something in the room you are in.
+
+    Examples:
+      plant orange -a 8
+      plant strawberry --hidden
+      plant potato --hidden --age 5
+
+    '''
+
+    key = "plant"
+
+    def init_parser(self):
+        "Add the arguments to the parser."
+        # 'self.parser' inherits `argparse.ArgumentParser`
+        self.parser.add_argument("key",
+                help="the key of the plant to be planted here")
+        self.parser.add_argument("-a", "--age", type=int,
+                default=1, help="the age of the plant to be planted")
+        self.parser.add_argument("--hidden", action="store_true",
+                help="should the newly-planted plant be hidden to players?")
+
+    def func(self):
+        "func is called only if the parser succeeded."
+        # 'self.opts' contains the parsed options
+        key = self.opts.key
+        age = self.opts.age
+        hidden = self.opts.hidden
+        self.msg("Going to plant '{}', age={}, hidden={}.".format(
+                key, age, hidden))
+
+
+

To see the full power of argparse and the types of supported options, visit +the documentation of argparse.

+
+

This document page is generated from evennia/contrib/base_systems/unixcommand/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-Wilderness.html b/docs/latest/Contribs/Contrib-Wilderness.html new file mode 100644 index 0000000000..cff3a376f4 --- /dev/null +++ b/docs/latest/Contribs/Contrib-Wilderness.html @@ -0,0 +1,270 @@ + + + + + + + + + Wilderness system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Wilderness system

+

Contribution by titeuf87, 2017

+

This contrib provides a wilderness map without actually creating a large number +of rooms - as you move, you instead end up back in the same room but its description +changes. This means you can make huge areas with little database use as +long as the rooms are relatively similar (e.g. only the names/descs changing).

+
+

Installation

+

This contrib does not provide any new commands. Instead the default py command +is used to call functions/classes in this contrib directly.

+
+
+

Usage

+

A wilderness map needs to created first. There can be different maps, all +with their own name. If no name is provided, then a default one is used. Internally, +the wilderness is stored as a Script with the name you specify. If you don’t +specify the name, a script named “default” will be created and used.

+
py from evennia.contrib.grid import wilderness; wilderness.create_wilderness()
+
+
+

Once created, it is possible to move into that wilderness map:

+
py from evennia.contrib.grid import wilderness; wilderness.enter_wilderness(me)
+
+
+

All coordinates used by the wilderness map are in the format of (x, y) +tuples. x goes from left to right and y goes from bottom to top. So (0, 0) +is the bottom left corner of the map.

+
+

You can also add a wilderness by defining a WildernessScript in your GLOBAL_SCRIPT +settings. If you do, make sure define the map provider.

+
+
+
+

Customisation

+

The defaults, while useable, are meant to be customised. When creating a +new wilderness map it is possible to give a “map provider”: this is a +python object that is smart enough to create the map.

+

The default provider, WildernessMapProvider, just creates a grid area that +is unlimited in size.

+

WildernessMapProvider can be subclassed to create more interesting +maps and also to customize the room/exit typeclass used.

+

The WildernessScript also has an optional preserve_items property, which +when set to True will not recycle rooms that contain any objects. By default, +a wilderness room is recycled whenever there are no players left in it.

+

There is also no command that allows players to enter the wilderness. This +still needs to be added: it can be a command or an exit, depending on your +needs.

+
+
+

Example

+

To give an example of how to customize, we will create a very simple (and +small) wilderness map that is shaped like a pyramid. The map will be +provided as a string: a “.” symbol is a location we can walk on.

+

Let’s create a file world/pyramid.py:

+
# mygame/world/pyramid.py
+
+map_str = '''
+     .
+    ...
+   .....
+  .......
+'''
+
+from evennia.contrib.grid import wilderness
+
+class PyramidMapProvider(wilderness.WildernessMapProvider):
+
+    def is_valid_coordinates(self, wilderness, coordinates):
+        "Validates if these coordinates are inside the map"
+        x, y = coordinates
+        try:
+            lines = map_str.split("\n")
+            # The reverse is needed because otherwise the pyramid will be
+            # upside down
+            lines.reverse()
+            line = lines[y]
+            column = line[x]
+            return column == "."
+        except IndexError:
+            return False
+
+    def get_location_name(self, coordinates):
+        "Set the location name"
+        x, y = coordinates
+        if y == 3:
+            return "Atop the pyramid."
+        else:
+            return "Inside a pyramid."
+
+    def at_prepare_room(self, coordinates, caller, room):
+        "Any other changes done to the room before showing it"
+        x, y = coordinates
+        desc = "This is a room in the pyramid."
+        if y == 3 :
+            desc = "You can see far and wide from the top of the pyramid."
+        room.ndb.active_desc = desc
+
+
+

Note that the currently active description is stored as .ndb.active_desc. When +looking at the room, this is what will be pulled and shown.

+
+

Exits on a room are always present, but locks hide those not used for a +location. So make sure to quell if you are a superuser (since the superuser ignores +locks, those exits will otherwise not be hidden)

+
+

Now we can use our new pyramid-shaped wilderness map. From inside Evennia we +create a new wilderness (with the name “default”) but using our new map provider:

+
py from world import pyramid as p; p.wilderness.create_wilderness(mapprovider=p.PyramidMapProvider())
+py from evennia.contrib.grid import wilderness; wilderness.enter_wilderness(me, coordinates=(4, 1))
+
+
+
+
+

Implementation details

+

When a character moves into the wilderness, they get their own room. If +they move, instead of moving the character, the room changes to match the +new coordinates.

+

If a character meets another character in the wilderness, then their room +merges. When one of the character leaves again, they each get their own +separate rooms.

+

Rooms are created as needed. Unneeded rooms are stored away to avoid the +overhead cost of creating new rooms again in the future.

+
+

This document page is generated from evennia/contrib/grid/wilderness/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contrib-XYZGrid.html b/docs/latest/Contribs/Contrib-XYZGrid.html new file mode 100644 index 0000000000..843b58c3cd --- /dev/null +++ b/docs/latest/Contribs/Contrib-XYZGrid.html @@ -0,0 +1,1685 @@ + + + + + + + + + XYZgrid — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

XYZgrid

+

Contribution by Griatch 2021

+

Places Evennia’s game world on an xy (z being different maps) coordinate grid. +Grid is created and maintained externally by drawing and parsing 2D ASCII maps, +including teleports, map transitions and special markers to aid pathfinding. +Supports very fast shortest-route pathfinding on each map. Also includes a +fast view function for seeing only a limited number of steps away from your +current location (useful for displaying the grid as an in-game, updating map).

+

Grid-management is done outside of the game using a new evennia-launcher +option.

+
+

Examples

+ +
#-#-#-#   #
+|  /      d
+#-#       |   #
+   \      u   |\
+o---#-----#---+-#-#
+|         ^   |/
+|         |   #
+v         |    \
+#-#-#-#-#-# #---#
+    |x|x|     /
+    #-#-#    #-
+
+
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+                                     #---#
+                                    /
+                                   @-
+-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Dungeon Entrance
+To the east, a narrow opening leads into darkness.
+Exits: northeast and east
+
+
+
+
+
+

Installation

+
    +
  1. XYZGrid requires the scipy library. Easiest is to get the ‘extra’ +dependencies of Evennia with

    +
    pip install evennia[extra]
    +
    +
    +

    If you use the git install, you can also

    +
    (cd to evennia/ folder)
    +pip install --upgrade -e .[extra]
    +
    +
    +

    This will install all optional requirements of Evennia.

    +
  2. +
  3. Import and add the evennia.contrib.grid.xyzgrid.commands.XYZGridCmdSet to the +CharacterCmdset cmdset in mygame/commands.default_cmds.py. Reload +the server. This makes the map, goto/path and the modified teleport and +open commands available in-game.

  4. +
+
    +
  1. Edit mygame/server/conf/settings.py and add

    +
    EXTRA_LAUNCHER_COMMANDS['xyzgrid'] = 'evennia.contrib.grid.xyzgrid.launchcmd.xyzcommand'
    +PROTOTYPE_MODULES += ['evennia.contrib.grid.xyzgrid.prototypes']
    +
    +
    +

    This will add the new ability to enter evennia xyzgrid <option> on the +command line. It will also make the xyz_room and xyz_exit prototypes +available for use as prototype-parents when spawning the grid.

    +
  2. +
  3. Run evennia xyzgrid help for available options.

  4. +
  5. (Optional): By default, the xyzgrid will only spawn module-based +prototypes. This is an optimization and usually makes sense +since the grid is entirely defined outside the game anyway. If you want to +also make use of in-game (db-) created prototypes, add +XYZGRID_USE_DB_PROTOTYPES = True to settings.

  6. +
+
+
+

Overview

+

The grid contrib consists of multiple components.

+
    +
  1. The XYMap - This class parses modules with special Map strings +and Map legends into one Python object. It has helpers for pathfinding and +visual-range handling.

  2. +
  3. The XYZGrid - This is a singleton Script that +stores all XYMaps in the game. It is the central point for managing the ‘grid’ +of the game.

  4. +
  5. XYZRoom and XYZExitare custom typeclasses that use +Tags +to know which X,Y,Z coordinate they are located at. The XYZGrid is +abstract until it is used to spawn these database entities into +something you can actually interract with in the game. The XYZRoom +typeclass is using its return_appearance hook to display the in-game map.

  6. +
  7. Custom Commands have been added for interacting with XYZ-aware locations.

  8. +
  9. A new custom Launcher command, evennia xyzgrid <options> is used to +manage the grid from the terminal (no game login is needed).

  10. +
+

We’ll start exploring these components with an example.

+
+
+

First example usage

+

After installation, do the following from your command line (where the +evennia command is available):

+
$ evennia xyzgrid init
+
+
+

use evennia xyzgrid help to see all options) +This will create a new XYZGrid Script if one didn’t already exist. +The evennia xyzgrid is a custom launch option added only for this contrib.

+

The xyzgrid-contrib comes with a full grid example. Let’s add it:

+
$ evennia xyzgrid add evennia.contrib.grid.xyzgrid.example
+
+
+

You can now list the maps on your grid:

+
$ evennia xyzgrid list
+
+
+

You’ll find there are two new maps added. You can find a lot of extra info +about each map with the show subcommand:

+
$ evennia xyzgrid show "the large tree"
+$ evennia xyzgrid show "the small cave"
+
+
+

If you want to peek at how the grid’s code, open +evennia/contrib/grid/xyzgrid/example.py. +(We’ll explain the details in later sections).

+

So far the grid is ‘abstract’ and has no actual in-game presence. Let’s +spawn actual rooms/exits from it. This will take a little while.

+
$ evennia xyzgrid spawn
+
+
+

This will take prototypes stored with each map’s map legend and use that +to build XYZ-aware rooms there. It will also parse all links to make suitable +exits between locations. You should rerun this command if you ever modify the +layout/prototypes of your grid. Running it multiple times is safe.

+
$ evennia reload
+
+
+

(or evennia start if server was not running). This is important to do after +every spawning operation, since the evennia xyzgrid operates outside of the +regular evennia process. Reloading makes sure all caches are refreshed.

+

Now you can log into the server. Some new commands should be available to you.

+
teleport (3,0,the large tree)
+
+
+

The teleport command now accepts an optional (X, Y, Z) coordinate. Teleporting +to a room-name or #dbref still works the same. This will teleport you onto the +grid. You should see a map-display. Try walking around.

+
map
+
+
+

This new builder-only command shows the current map in its full form (also +showing ‘invisible’ markers usually not visible to users.

+
teleport (3, 0)
+
+
+

Once you are in a grid-room, you can teleport to another grid room on the same +map without specifying the Z coordinate/map name.

+

You can use open to make an exit back to the ‘non-grid’, but remember that you +mustn’t use a cardinal direction to do so - if you do, the evennia xyzgrid spawn +will likely remove it next time you run it.

+
open To limbo;limbo = #2
+limbo
+
+
+

You are back in Limbo (which doesn’t know anything about XYZ coordinates). You +can however make a permanent link back into the gridmap:

+
open To grid;grid = (3,0,the large tree)
+grid
+
+
+

This is how you link non-grid and grid locations together. You could for example +embed a house ‘inside’ the grid this way.

+

the (3,0,the large tree) is a ‘Dungeon entrance’. If you walk east you’ll +transition into “the small cave” map. This is a small underground dungeon +with limited visibility. Go back outside again (back on “the large tree” map).

+
path view
+
+
+

This finds the shortest path to the “A gorgeous view” room, high up in the large +tree. If you have color in your client, you should see the start of the path +visualized in yellow.

+
goto view
+
+
+

This will start auto-walking you to the view. On the way you’ll both move up +into the tree as well as traverse an in-map teleporter. Use goto on its own +to abort the auto-walk.

+

When you are done exploring, open the terminal (outside the game) again and +remove everything:

+
$ evennia xyzgrid delete
+
+
+

You will be asked to confirm the deletion of the grid and unloading of the +XYZGrid script. Reload the server afterwards. If you were on a map that was +deleted you will have been moved back to your home location.

+
+
+

Defining an XYMap

+

For a module to be suitable to pass to evennia xyzgrid add <module>, the +module must contain one of the following variables:

+
    +
  • XYMAP_DATA - a dict containing data that fully defines the XYMap

  • +
  • XYMAP_DATA_LIST - a list of XYMAP_DATA dicts. If this exists, it will take +precedence. This allows for storing multiple maps in one module.

  • +
+

The XYMAP_DATA dict has the following form:

+
XYMAP_DATA = {
+    "zcoord": <str>
+    "map": <str>,
+    "legend": <dict, optional>,
+    "prototypes": <dict, optional>
+    "options": <dict, optional>
+}
+
+
+
+
    +
  • "zcoord" (str): The Z-coordinate/map name of the map.

  • +
  • "map" (str): A Map string describing the topology of the map.

  • +
  • "legend" (dict, optional): Maps each symbol on the map to Python code. This +dict can be left out or only partially filled - any symbol not specified will +instead use the default legend from the contrib.

  • +
  • "prototypes" (dict, optional): This is a dict that maps map-coordinates +to custom prototype overrides. This is used when spawning the map into +actual rooms/exits.

  • +
  • "options" (dict, optional): These are passed into the return_appearance +hook of the room and allows for customizing how a map should be displayed, +how pathfinding should work etc.

  • +
+

Here’s a minimal example of the whole setup:

+
# In, say, a module gamedir/world/mymap.py
+
+MAPSTR = r"""
+
++ 0 1 2
+
+2 #-#-#
+     /
+1 #-#
+  |  \
+0 #---#
+
++ 0 1 2
+
+
+"""
+# use only defaults
+LEGEND = {}
+
+# tweak only one room. The 'xyz_room/exit' parents are made available
+# by adding the xyzgrid prototypes to settings during installation.
+# the '*' are wildcards and allows for giving defaults on this map.
+PROTOTYPES = {
+    (0, 0): {
+        "prototype_parent": "xyz_room",
+        "key": "A nice glade",
+        "desc": "Sun shines through the branches above.",
+    },
+    (0, 0, 'e'): {
+        "prototype_parent": "xyz_exit",
+        "desc": "A quiet path through the foilage",
+    },
+    ('*', '*'): {
+        "prototype_parent": "xyz_room",
+        "key": "In a bright forest",
+        "desc": "There is green all around.",
+    },
+    ('*', '*', '*'): {
+        "prototype_parent": "xyz_exit",
+        "desc": "The path leads further into the forest.",
+    },
+}
+
+# collect all info for this one map
+XYMAP_DATA = {
+    "zcoord": "mymap",  # important!
+    "map": MAPSTR,
+    "legend": LEGEND,
+    "prototypes": PROTOTYPES,
+    "options": {}
+}
+
+# this can be skipped if there is only one map in module
+XYMAP_DATA_LIST = [
+    XYMAP_DATA
+]
+
+
+

The above map would be added to the grid with

+
$ evennia xyzgrid add world.mymap
+
+
+

In the following sections we’ll discuss each component in turn.

+
+

The Zcoord

+

Each XYMap on the grid has a Z-coordinate which usually can be treated just as +the name of the map. The Z-coordinate can be either a string or an integer, and must +be unique across the entire grid. It is added as the key ‘zcoord’ to XYMAP_DATA.

+

Most users will want to just treat each map as a location, and name the +“Z-coordinate” things like Dungeon of Doom, The ice queen's palace or City of Blackhaven. But you could also name it -1, 0, 1, 2, 3 if you wanted.

+
+

Note that the Zcoord is searched non-case senstively in the

+
+

Pathfinding happens only within each XYMap (up/down is normally ‘faked’ by moving +sideways to a new area of the XY plane).

+
+

A true 3D map

+

Even for the most hardcore of sci-fi space game, consider sticking to 2D +movement. It’s hard enough for players to visualize a 3D volume with graphics. +In text it’s even harder.

+

That said, if you want to set up a true X, Y, Z 3D coordinate system (where +you can move up/down from every point), you can do that too.

+

This contrib provides an example command commands.CmdFlyAndDive that provides the player +with the ability to use fly and dive to move straight up/down between Z +coordinates. Just add it (or its cmdset commands.XYZGridFlyDiveCmdSet) to your +Character cmdset and reload to try it out.

+

For the fly/dive to work you need to build your grid as a ‘stack’ of XY-grid maps +and name them by their Z-coordinate as an integer. The fly/dive actions will +only work if there is actually a matching room directly above/below.

+
+

Note that since pathfinding only works within each XYmap, the player will not +be able to include fly/dive in their autowalking - this is always a manual +action.

+
+

As an example, let’s assume coordinate (1, 1, -3) +is the bottom of a deep well leading up to the surface (at level 0)

+
LEVEL_MINUS_3 = r"""
++ 0 1
+
+1   #
+    |
+0 #-#
+
++ 0 1
+"""
+
+LEVEL_MINUS_2 = r"""
++ 0 1
+
+1   #
+
+0
+
++ 0 1
+"""
+
+LEVEL_MINUS_1 = r"""
++ 0 1
+
+1   #
+
+0
+
++ 0 1
+"""
+
+LEVEL_0 = r"""
++ 0 1
+
+1 #-#
+  |x|
+0 #-#
+
++ 0 1
+"""
+
+XYMAP_DATA_LIST = [
+    {"zcoord": -3, "map": LEVEL_MINUS_3},
+    {"zcoord": -2, "map": LEVEL_MINUS_2},
+    {"zcoord": -1, "map": LEVEL_MINUS_1},
+    {"zcoord": 0, "map": LEVEL_0},
+]
+
+
+

In this example, if we arrive to the bottom of the well at (1, 1, -3) we +fly straight up three levels until we arrive at (1, 1, 0), at the corner +of some sort of open field.

+

We can dive down from (1, 1, 0). In the default implementation you must dive 3 times +to get to the bottom. If you wanted you could tweak the command so you +automatically fall to the bottom and take damage etc.

+

We can’t fly/dive up/down from any other XY positions because there are no open rooms at the +adjacent Z coordinates.

+
+
+
+

Map String

+

The creation of a new map starts with a Map string. This allows you to ‘draw’ +your map, describing and how rooms are positioned in an X,Y coordinate system. +It is added to XYMAP_DATA with the key ‘map’.

+
MAPSTR = r"""
+
++ 0 1 2
+
+2 #-#-#
+     /
+1 #-#
+  |  \
+0 #---#
+
++ 0 1 2
+
+"""
+
+
+
+

On the coordinate axes, only the two + are significant - the numbers are +optional, so this is equivalent:

+
MAPSTR = r"""
+
++
+
+  #-#-#
+     /
+  #-#
+  | \
+  #---#
+
++
+
+"""
+
+
+
+

Even though it’s optional, it’s highly recommended that you add numbers to +your axes - if only for your own sanity.

+
+

The coordinate area starts two spaces to the right and two spaces +below/above the mandatory + signs (which marks the corners of the map area). +Origo (0,0) is in the bottom left (so X-coordinate increases to the right and +Y-coordinate increases towards the top). There is no limit to how high/wide the +map can be, but splitting a large world into multiple maps can make it easier +to organize.

+

Position is important on the grid. Full coordinates are placed on every second +space along all axes. Between these ‘full’ coordinates are .5 coordinates. +Note that there are no .5 coordinates spawned in-game; they are only used +in the map string to have space to describe how rooms/nodes link to one another.

+
+ 0 1 2 3 4 5
+
+4           E
+   B
+3
+
+2         D
+
+1    C
+
+0 A
+
++ 0 1 2 3 4 5
+
+
+
    +
  • A is at origo, (0, 0) (a ‘full’ coordinate)

  • +
  • B is at (0.5, 3.5)

  • +
  • C is at (1.5, 1)

  • +
  • D is at (4, 2) (a ‘full’ coordinate).

  • +
  • E is the top-right corner of the map, at (5, 4) (a ‘full’ coordinate)

  • +
+

The map string consists of two main classes of entities - nodes and links.

+
    +
  • A node usually represents a room in-game (but not always). Nodes must +always be placed on a ‘full’ coordinate.

  • +
  • A link describes a connection between two nodes. In-game, links are usuallyj +represented by exits. A link can be placed +anywhere in the coordinate space (both on full and 0.5 coordinates). Multiple +links are often chained together, but the chain must always end in nodes +on both sides.

  • +
+
+

Even though a link-chain may consist of several steps, like #-----#, +in-game it will still only represent one ‘step’ (e.g. you go ‘east’ only once +to move from leftmost to the rightmost node/room).

+
+
+
+

Map legend

+

There can be many different types of nodes and links. Whereas the map +string describes where they are located, the Map Legend connects each symbol +on the map to Python code.

+

+LEGEND = {
+    '#': xymap_legend.MapNode,
+    '-': xymap_legende.EWMapLink
+}
+
+# added to XYMAP_DATA dict as 'legend': LEGEND below
+
+
+
+

The legend is optional, and any symbol not explicitly given in your legend will +fall back to its value in the default legend outlined below.

+
    +
  • MapNode +is the base class for all nodes.

  • +
  • MapLink +is the base class for all links.

  • +
+

As the Map String is parsed, each found symbol is looked up in the legend and +initialized into the corresponding MapNode/Link instance.

+ +
+

Default Legend

+

Below is the default map legend. The symbol is what should be put in the Map +string. It must always be a single character. The display-symbol is what is +actually visualized when displaying the map to players in-game. This could have +colors etc. All classes are found in evennia.contrib.grid.xyzgrid.xymap_legend and +their names are included to make it easy to know what to override.

+ +++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

symbol

display-symbol

type

class

description

#

#

node

BasicMapNode

A basic node/room.

T

node

MapTransitionNode

Transition-target for links between maps +(see below)

I (letter I)

#

node

InterruptMapNode

Point of interest, auto-step will always +stop here (see below).

|

|

link

NSMapLink

North-South two-way

-

-

link

EWMapLink

East-West two-way

/

/

link

NESWMapLink

NorthEast-SouthWest two-way

\

\

link

SENWMapLink

NorthWest two-way

u

u

link

UpMapLink

Up, one or two-way (see below)

d

d

link

DownMapLink

Down, one or two-way (see below)

x

x

link

CrossMapLink

SW-NE and SE-NW two-way

+

+

link

PlusMapLink

Crossing N-S and E-W two-way

v

v

link

NSOneWayMapLink

North-South one-way

^

^

link

SNOneWayMapLink

South-North one-way

<

<

link

EWOneWayMapLink

East-West one-way

>

>

link

WEOneWayMapLink

West-East one-way

o

o

link

RouterMapLink

Routerlink, used for making link ‘knees’ +and non-orthogonal crosses (see below)

b

(varies)

link

BlockedMapLink

Block pathfinder from using this link. +Will appear as logically placed normal +link (see below).

i

(varies)

link

InterruptMapLink

Interrupt-link; auto-step will never +cross this link (must move manually, see +below)

t

link

TeleporterMapLink

Inter-map teleporter; will teleport to +same-symbol teleporter on the same map. +(see below)

+
+
+

Map Nodes

+

The basic map node (#) usually represents a ‘room’ in the game world. Links +can connect to the node from any of the 8 cardinal directions, but since nodes +must only exist on full coordinates, they can never appear directly next to +each other.

+
\|/
+-#-
+/|\
+
+##     invalid!
+
+
+

All links or link-chains must end in nodes on both sides.

+
#-#-----#
+
+#-#-----   invalid!
+
+
+
+ + +
+

Interrupt-nodes

+

An interrupt-node (I, InterruptMapNode) is a node that acts like any other +node except it is considered a ‘point of interest’ and the auto-walk of the +goto command will always stop auto-stepping at this location.

+
#-#-I-#-#
+
+
+

So if auto-walking from left to right, the auto-walk will correctly map a path +to the end room, but will always stop at the I node. If the user starts from +the I room, they will move away from it without interruption (so you can +manually run the goto again to resume the auto-step).

+

The use of this room is to anticipate blocks not covered by the map. For example +there could be a guard standing in this room that will arrest you unless you +show them the right paperwork - trying to auto-walk past them would be bad!

+

By default, this node looks just like a normal # to the player.

+
+ + + + +
+

Map-Transition Nodes

+

The map transition (MapTransitionNode) teleports between XYMaps (a +Z-coordinate transition, if you will), like walking from the “Dungeon” map to +the “Castle” map. Unlike other nodes, the MapTransitionNode is never spawned +into an actual room (it has no prototype). It just holds an XYZ +coordinate pointing to somewhere on the other map. The link leading to the +node will use those coordinates to make an exit pointing there. Only one single +link may lead to this type of node.

+

Unlike for TeleporterMapLink, there need not be a matching +MapTransitionNode on the other map - the transition can choose to send the +player to any valid coordinate on the other map.

+

Each MapTransitionNode has a property target_map_xyz that holds the XYZ +coordinate the player should end up in when going towards this node. This +must be customized in a child class for every transition.

+

If there are more than one transition, separate transition classes should be +added, with different map-legend symbols:

+
# in your map definition module (let's say this is mapB)
+
+from evennia.contrib.grid.xyzgrid import xymap_legend
+
+MAPSTR = r"""
+
++ 0 1 2
+
+2   #-C
+    |
+1 #-#-#
+     \
+0 A-#-#
+
++ 0 1 2
+
+
+"""
+
+class TransitionToMapA(xymap_legend.MapTransitionNode):
+    """Transition to MapA"""
+    target_map_xyz = (1, 4, "mapA")
+
+class TransitionToMapC(xymap_legend.MapTransitionNode):
+    """Transition to MapB"""
+    target_map_xyz = (12, 14, "mapC")
+
+LEGEND = {
+    'A': TransitionToMapA
+    'C': TransitionToMapC
+
+}
+
+XYMAP_DATA = {
+    # ...
+    "map": MAPSTR,
+    "legend": LEGEND
+    # ...
+}
+
+
+
+

Moving west from (1,0) will bring you to (1,4) of MapA, and moving east from +(1,2) will bring you to (12,14) on MapC (assuming those maps exist).

+

A map transition is always one-way, and can lead to the coordinates of any +existing node on the other map:

+
map1        map2
+
+#-T         #-#---#-#-#-#
+
+
+

A player moving east towards T could for example end up at the 4th # from +the left on map2 if so desired (even though it doesn’t make sense visually). +There is no way to get back to map1 from there.

+

To create the effect of a two-way transition, one can set up a mirrored +transition-node on the other map:

+
citymap    dungeonmap
+
+#-T        T-#
+
+
+

The transition-node of each map above has target_map_xyz pointing to the +coordinate of the # node of the other map (not to the other T, that is not +spawned and would lead to the exit finding no destination!). The result is that +one can go east into the dungeon and then immediately go back west to the city +across the map boundary.

+
+
+
+

Prototypes

+

Prototypes are dicts that describe how to spawn a new instance +of an object. Each of the nodes and links above have a default prototype +that allows the evennia xyzgrid spawn command to convert them to +a XYZRoom +or an XYZExit respectively.

+

The default prototypes are found in evennia.contrib.grid.xyzgrid.prototypes (added +during installation of this contrib), with prototype_keys "xyz_room" and +"xyz_exit" - use these as prototype_parent to add your own custom prototypes.

+

The "prototypes" key of the XYMap-data dict allows you to customize which +prototype is used for each coordinate in your XYMap. The coordinate is given as +(X, Y) for nodes/rooms and (X, Y, direction) for links/exits, where the +direction is one of “n”, “ne”, “e”, “se”, “s”, “sw”, “w”, “nw”, “u” or “d”. For +exits, it’s recommended to not set a key since this is generated +automatically by the grid spawner to be as expected (“north” with alias “n”, for +example).

+

A special coordinate is *. This acts as a wild card for that coordinate and +allows you to add ‘default’ prototypes to be used for rooms.

+

+MAPSTR = r"""
+
++ 0 1
+
+1 #-#
+   \
+0 #-#
+
++ 0 1
+
+
+"""
+
+
+PROTOTYPES = {
+    (0,0): {
+	"prototype_parent": "xyz_room",
+	"key": "End of a the tunnel",
+	"desc": "This is is the end of the dark tunnel. It smells of sewage."
+    },
+    (0,0, 'e') : {
+	"prototype_parent": "xyz_exit",
+	"desc": "The tunnel continues into darkness to the east"
+    },
+    (1,1): {
+	"prototype_parent": "xyz_room",
+	"key": "Other end of the tunnel",
+	"desc": The other end of the dark tunnel. It smells better here."
+    }
+    # defaults
+    ('*', '*'): {
+    	"prototype_parent": "xyz_room",
+	"key": "A dark tunnel",
+	"desc": "It is dark here."
+    },
+    ('*', '*', '*'): {
+	"prototype_parent": "xyz_exit",
+	"desc": "The tunnel stretches into darkness."
+    }
+}
+
+XYMAP_DATA = {
+    # ...
+    "map": MAPSTR,
+    "prototypes": PROTOTYPES
+    # ...
+}
+
+
+
+

When spawning the above map, the room at the bottom-left and top-right of the +map will get custom descriptions and names, while the others will have default +values. One exit (the east exit out of the room in the bottom-left will have a +custom description.

+
+

If you are used to using prototypes, you may notice that we didn’t add a +prototype_key for the above prototypes. This is normally required for every +prototype. This is for convenience - if +you don’t add a prototype_key, the grid will automatically generate one for +you - a hash based on the current XYZ (+ direction) of the node/link to spawn.

+
+

If you find yourself changing your prototypes after already spawning the +grid/map, you can rerun evennia xyzgrid spawn again; The changes will be +picked up and applied to the existing objects.

+
+

Extending the base prototypes

+

The default prototypes are found in evennia.contrib.grid.xyzgrid.prototypes and +should be included as prototype_parents for prototypes on the map. Would it +not be nice to be able to change these and have the change apply to all of the +grid? You can, by adding the following to your mygame/server/conf/settings.py:

+
XYZROOM_PROTOTYPE_OVERRIDE = {"typeclass": "myxyzroom.MyXYZRoom"}
+XYZEXIT_PROTOTYPE_OVERRIDE = {...}
+
+
+
+

If you override the typeclass in your prototypes, the typeclass used MUST +inherit from XYZRoom and/or XYZExit. The BASE_ROOM_TYPECLASS and +BASE_EXIT_TYPECLASS settings will not help - these are still useful for +non-xyzgrid rooms/exits though.

+
+

Only add what you want to change - these dicts will extend the default parent +prototypes rather than replace them. As long as you define your map’s prototypes +to use a prototype_parent of "xyz_room" and/or "xyz_exit", your changes +will now be applied. You may need to respawn your grid and reload the server +after a change like this.

+
+
+
+

Options

+

The last element of the XYMAP_DATA dict is the "options", for example

+
XYMAP_DATA = {
+    # ...
+    "options": {
+	"map_visual_range": 2
+    }
+}
+
+
+
+

The options dict is passed as **kwargs to XYZRoom.return_appearance +when visualizing the map in-game. It allows for making different maps display +differently from one another (note that while these options are convenient one +could of course also override return_appearance entirely by inheriting from +XYZRoom and then pointing to it in your prototypes).

+

The default visualization is this:

+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+                                     #---#
+                                    /
+                                   @-
+-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Dungeon Entrance
+To the east, a narrow opening leads into darkness.
+Exits: northeast and east
+
+
+
+
    +
  • map_display (bool): This turns off the display entirely for this map.

  • +
  • map_character_symbol (str): The symbol used to show ‘you’ on the map. It can +have colors but should only take up one character space. By default this is a +green @.

  • +
  • map_visual_range (int): This how far away from your current location you can +see.

  • +
  • map_mode (str): This is either “node” or “scan” and affects how the visual +range is calculated. +In “node” mode, the range shows how many nodes away from that you can see. In “scan” +mode you can instead see that many on-screen characters away from your character. +To visualize, assume this is the full map (where ‘@’ is the character location):

    +
    #----------------#
    +|                |
    +|                |
    +# @------------#-#
    +|                |
    +#----------------#
    +
    +
    +

    This is what the player will see in ‘nodes’ mode with map_visual_range=2:

    +
    @------------#-#
    +
    +
    +

    … and in ‘scan’ mode:

    +
    |
    +|
    +# @--
    +|
    +#----
    +
    +
    +

    The ‘nodes’ mode has the advantage of showing only connected links and is +great for navigation but depending on the map it can include nodes quite +visually far away from you. The ‘scan’ mode can accidentally reveal unconnected +parts of the map (see example above), but limiting the range can be used as a +way to hide information.

    +

    This is what the player will see in ‘nodes’ mode with map_visual_range=1:

    +
    @------------#
    +
    +
    +

    … and in ‘scan’ mode:

    +
    @-
    +
    +
    +

    One could for example use ‘nodes’ for outdoor/town maps and ‘scan’ for +exploring dungeons.

    +
  • +
  • map_align (str): One of ‘r’, ‘c’ or ‘l’. This shifts the map relative to +the room text. By default it’s centered.

  • +
  • map_target_path_style: How to visualize the path to a target. This is a +string that takes the {display_symbol} formatting tag. This will be replaced +with the display_symbol of each map element in the path. By default this is +"|y{display_symbol}|n", that is, the path is colored yellow.

  • +
  • map_fill_all (bool): If the map area should fill the entire client width +(default) or change to always only be as wide as the room description. Note +that in the latter case, the map can end up ‘dancing around’ in the client window +if descriptions vary a lot in width.

  • +
  • map_separator_char (str): The char to use for the separator-lines between the map +and the room description. Defaults to "|x~|n" - wavy, dark-grey lines.

  • +
+

Changing the options of an already spawned map does not require re-spawning the +map, but you do need to reload the server!

+
+
+

About the Pathfinder

+

The new goto command exemplifies the use of the Pathfinder. This +is an algorithm that calculates the shortest route between nodes (rooms) on an +XY-map of arbitrary size and complexity. It allows players to quickly move to +a location if they know that location’s name. Here are some details about

+
    +
  • The pathfinder parses the nodes and links to build a matrix of distances +of moving from each node to all other nodes on one XYMap. The path +is solved using the +Dijkstra algorithm.

  • +
  • The pathfinder’s matrices can take a long time to build for very large maps. +Therefore they are are cached as pickled binary files in +mygame/server/.cache/ and only rebuilt if the map changes. They are safe to +delete (you can also use evennia xyzgrid initpath to force-create/rebuild the cache files).

  • +
  • Once cached, the pathfinder is fast (Finding a 500-step shortest-path over +20 000 nodes/rooms takes below 0.1s).

  • +
  • It’s important to remember that the pathfinder only works within one XYMap. +It will not find paths across map transitions. If this is a concern, one can consider +making all regions of the game as one XYMap. This probably works fine, but makes it +harder to add/remove new maps to/from the grid.

  • +
  • The pathfinder will actually sum up the ‘weight’ of each link to determine which is +the ‘cheapest’ (shortest) route. By default every link except blocking links have +a cost of 1 (so cost is equal to the number of steps to move between nodes). +Individual links can however change this to a higher/lower weight (must be >=1). +A higher weight means the pathfinder will be less likely to use that route +compared to others (this can also be vidually confusing for the user, so use with care).

  • +
  • The pathfinder will average the weight of long link-chains. Since all links +default to having the same weight (=1), this means that +#-# has the same movement cost as #----# even though it is visually ‘shorter’. +This behavior can be changed per-link by using links with +average_long_link_weights = False.

  • +
+
+
+
+

XYZGrid

+

The XYZGrid is a Global Script that holds all XYMap objects on +the grid. There should be only one XYZGrid created at any time.

+

To access the grid in-code, there are several ways:

+
    +
  • You can search for the grid like any other Script. It’s named “XYZGrid”.

    +

    grid = evennia.search_script(“XYZGrid”)[0]

    +

    (search_script always returns a list)

    +
  • +
  • You can get it with evennia.contrib.grid.xyzgrid.xyzgrid.get_xyzgrid

    +

    from evennia.contrib.grid.xyzgrid.xyzgrid import get_xyzgrid +grid = get_xyzgrid()

    +

    This will always return a grid, creating an empty grid if one didn’t +previously exist. So this is also the recommended way of creating a fresh grid +in-code.

    +
  • +
  • You can get it from an existing XYZRoom/Exit by accessing their .xyzgrid +property

    +

    grid = self.caller.location.xyzgrid # if currently in grid room

    +
  • +
+

Most tools on the grid class have to do with loading/adding and deleting maps, +something you are expected to use the evennia xyzgrid commands for. But there +are also several methods that are generally useful:

+
    +
  • .get_room(xyz) - Get a room at a specific coordinate (X, Y, Z). This will +only work if the map has been actually spawned first. For example +.get_room((0,4,"the dark castle)). Use '*' as a wild card, so +.get_room(('*','*',"the dark castle)) will get you all rooms spawned on the dark +castle map.

  • +
  • .get_exit(xyz, name) - get a particular exit, e.g. +.get_exit((0,4,"the dark castle", "north"). You can also use '*' as +wildcards.

  • +
+

One can also access particular parsed XYMap objects on the XYZGrid directly:

+
    +
  • .grid - this is the actual (cached) store of all XYMaps, as {zcoord: XYMap, ...}

  • +
  • .get_map(zcoord) - get a specific XYMap.

  • +
  • .all_maps() - get a list of all XYMaps.

  • +
+

Unless you want to heavily change how the map works (or learn what it does), you +will probably never need to modify the XYZMap object itself. You may want to +know how to call find the pathfinder though:

+
    +
  • xymap.get_shortest_path(start_xy, end_xy)

  • +
  • xymap.get_visual_range(xy, dist=2, **kwargs)

  • +
+

See the XYMap documentation for +details.

+
+
+

XYZRoom and XYZExit

+

These are new custom Typeclasses located in +evennia.contrib.xyzgrid.xyzroom. They extend the base DefaultRoom and +DefaultExit to be aware of their X, Y and Z coordinates.

+
+

Warning

+
You should usually **not** create XYZRooms/Exits manually. They are intended
+to be created/deleted based on the layout of the grid. So to add a new room, add
+a new node to your map. To delete it, you remove it. Then rerun
+**evennia xyzgrid spawn**. Having manually created XYZRooms/exits in the mix
+can lead to them getting deleted or the system getting confused.
+
+If you **still** want to create XYZRoom/Exits manually (don't say we didn't
+warn you!), you should do it with their `XYZRoom.create()` and
+`XYZExit.create()` methods. This makes sure the XYZ they use are unique.
+
+
+
+

Useful (extra) properties on XYZRoom, XYZExit:

+
    +
  • xyz The (X, Y, Z) coordinate of the entity, for example (23, 1, "greenforest")

  • +
  • xyzmap The XYMap this belongs to.

  • +
  • get_display_name(looker) - this has been modified to show the coordinates of +the entity as well as the #dbref if you have Builder or higher privileges.

  • +
  • return_appearance(looker, **kwargs) - this has been extensively modified for +XYZRoom, to display the map. The options given in XYMAP_DATA will appear +as **kwargs to this method and if you override this you can customize the +map display in depth.

  • +
  • xyz_destination (only for XYZExits) - this gives the xyz-coordinate of +the exit’s destination.

  • +
+

The coordinates are stored as Tags where both rooms and exits tag +categories room_x_coordinate, room_y_coordinate and room_z_coordinate +while exits use the same in addition to tags for their destination, with tag +categories exit_dest_x_coordinate, exit_dest_y_coordinate and +exit_dest_z_coordinate.

+

The make it easier to query the database by coordinates, each typeclass offers +custom manager methods. The filter methods allow for '*' as a wildcard.

+

+# find a list of all rooms in map foo
+rooms = XYZRoom.objects.filter_xyz(('*', '*', 'foo'))
+
+# find list of all rooms with name "Tunnel" on map foo
+rooms = XYZRoom.objects.filter_xyz(('*', '*', 'foo'), db_key="Tunnel")
+
+# find all rooms in the first column of map footer
+rooms = XYZRoom.objects.filter_xyz((0, '*', 'foo'))
+
+# find exactly one room at given coordinate (no wildcards allowed)
+room = XYZRoom.objects.get_xyz((13, 2, foo))
+
+# find all exits in a given room
+exits = XYZExit.objects.filter_xyz((10, 4, foo))
+
+# find all exits pointing to a specific destination (from all maps)
+exits = XYZExit.objects.filter_xyz_exit(xyz_destination=(13,5,'bar'))
+
+# find exits from a room to anywhere on another map
+exits = XYZExit.objects.filter_xyz_exit(xyz=(1, 5, 'foo'), xyz_destination=('*', '*', 'bar'))
+
+# find exactly one exit to specific destination (no wildcards allowed)
+exit = XYZExit.objects.get_xyz_exit(xyz=(0, 12, 'foo'), xyz_destination=(5, 2, 'foo'))
+
+
+
+

You can customize the XYZRoom/Exit by having the grid spawn your own subclasses +of them. To do this you need to override the prototype used to spawn rooms on +the grid. Easiest is to modify the base prototype-parents in settings (see the +XYZRoom and XYZExit section above).

+
+
+

Working with the grid

+

The work flow of working with the grid is usually as follows:

+
    +
  1. Prepare a module with a Map String, Map Legend, Prototypes and +Options packaged into a dict XYMAP_DATA. Include multiple maps per module +by adding several XYMAP_DATA to a variable XYMAP_DATA_LIST instead.

  2. +
  3. If your map contains TransitionMapNodes, the target map must either also be +added or already exist in the grid. If not, you should skip that node for +now (otherwise you’ll face errors when spawning because the exit-destination +does not exist).

  4. +
  5. Run evennia xyzgrid add <module> to register the maps with the grid. If no +grid existed, it will be created by this. Fix any errors reported by the +parser.

  6. +
  7. Inspect the parsed map(s) with evennia xyzgrid show <zcoord> and make sure +they look okay.

  8. +
  9. Run evennia xyzgrid spawn to spawn/update maps into actual XYZRooms and +XYZExits.

  10. +
  11. If you want you can now tweak your grid manually by usual building commands. +Anything you do not specify in your grid prototypes you can +modify locally in your game - as long as the whole room/exit is not deleted, +those will be untouched by evennia xyzgrid spawn. You can also dig/open +exits to other rooms ‘embedded’ in your grid. These exits must not be named +one of the grid directions (north, northeast, etc, nor up/down) or the grid +will delete it next evennia xyzgrid spawn runs (since it’s not on the map).

  12. +
  13. If you want to add new grid-rooms/exits you should always do so by +modifying the Map String and then rerunning evennia xyzgrid spawn to +apply the changes.

  14. +
+
+
+

Details

+

The default Evennia’s rooms are non-euclidian - they can connect +to each other with any types of exits without necessarily having a clear +position relative to each other. This gives maximum flexibility, but many games +want to use cardinal movements (north, east etc) and also features like finding +the shortest-path between two points.

+

This contrib forces each room to exist on a 3-dimensional XYZ grid and also +implements very efficient pathfinding along with tools for displaying +your current visual-range and a lot of related features.

+

The rooms of the grid are entirely controlled from outside the game, using +python modules with strings and dicts defining the map(s) of the game. It’s +possible to combine grid- with non-grid rooms, and you can decorate +grid rooms as much as you like in-game, but you cannot spawn new grid +rooms without editing the map files outside of the game.

+
+
+

Installation

+
    +
  1. If you haven’t before, install the extra contrib requirements. +You can do so by doing pip install evennia[extra], or if you used git to +install, do pip install --upgrade -e .[extra] from the evennia/ repo +folder.

  2. +
  3. Import and add the evennia.contrib.grid.xyzgrid.commands.XYZGridCmdSet to the +CharacterCmdset cmdset in mygame/commands.default_cmds.py. Reload +the server. This makes the map, goto/path and modified teleport and +open commands available in-game.

  4. +
  5. Edit mygame/server/conf/settings.py and set

    +
     EXTRA_LAUNCHER_COMMANDS['xyzgrid'] = 'evennia.contrib.grid.xyzgrid.launchcmd.xyzcommand'
    +
    +
    +
  6. +
  7. Run the new evennia xyzgrid help for instructions on how to spawn the grid.

  8. +
+
+
+

Example usage

+

After installation, do the following (from your command line, where the +evennia command is available) to install an example grid:

+
evennia xyzgrid init
+evennia xyzgrid add evennia.contrib.grid.xyzgrid.example
+evennia xyzgrid list
+evennia xyzgrid show "the large tree"
+evennia xyzgrid show "the small cave"
+evennia xyzgrid spawn
+evennia reload
+
+
+

(remember to reload the server after spawn operations).

+

Now you can log into the +server and do teleport (3,0,the large tree) to teleport into the map.

+

You can use open togrid = (3, 0, the large tree) to open a permanent (one-way) +exit from your current location into the grid. To make a way back to a non-grid +location just stand in a grid room and open a new exit out of it: +open tolimbo = #2.

+

Try goto view to go to the top of the tree and goto dungeon to go down to +the dungeon entrance at the bottom of the tree.

+
+

This document page is generated from evennia/contrib/grid/xyzgrid/README.md. Changes to this +file will be overwritten, so edit that file rather than this one.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contribs-Guidelines.html b/docs/latest/Contribs/Contribs-Guidelines.html new file mode 100644 index 0000000000..a9edf05131 --- /dev/null +++ b/docs/latest/Contribs/Contribs-Guidelines.html @@ -0,0 +1,249 @@ + + + + + + + + + Guidelines for Evennia contribs — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Guidelines for Evennia contribs

+

Evennia has a contrib directory which contains optional, community-shared code organized by category. Anyone is welcome to contribute.

+
+

What is suitable for a contrib?

+
    +
  • In general, you can contribute anything that you think may be useful to another developer. Unlike the ‘core’ Evennia, contribs can also be highly game-type-specific.

  • +
  • Very small or incomplete snippets of code (e.g. meant to paste into some other code) are better shared as a post in the Community Contribs & Snippets discussion forum category.

  • +
  • If your code is intended primarily as an example or to show a concept/principle rather than a working system, consider if it may be better to instead contribute to the documentation by writing a new tutorial or howto.

  • +
  • If possible, try to make your contribution as genre-agnostic as possible and assume +your code will be applied to a very different game than you had in mind when creating it.

  • +
  • The contribution should preferably work in isolation from other contribs (only make use of core Evennia) so it can easily be dropped into use. If it does depend on other contribs or third-party modules, these must be clearly documented and part of the installation instructions.

  • +
  • If you are unsure about if your contrib idea is suitable or sound, ask in discussions or chat before putting any work into it. We are, for example, unlikely to accept contribs that require large modifications of the game directory structure.

  • +
+
+
+

Layout of a contrib

+
    +
  • The contrib must be contained only within a single folder under one of the contrib categories below. Ask if you are unsure which category fits best for your contrib.

  • +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

base_systems/

Systems that are not necessarily tied to a specific in-game mechanic but which are useful for the game as a whole. Examples include login systems, new command syntaxes, and build helpers.

full_systems/

‘Complete’ game engines that can be used directly to start creating content without no further additions (unless you want to).

game_systems/

In-game gameplay systems like crafting, mail, combat and more. Each system is meant to be adopted piecemeal and adopted for your game. This does not include roleplaying-specific systems, those are found in the rpg category.

grid/

Systems related to the game world’s topology and structure. Contribs related to rooms, exits and map building.

rpg/

Systems specifically related to roleplaying and rule implementation like character traits, dice rolling and emoting.

tutorials/

Helper resources specifically meant to teach a development concept or to exemplify an Evennia system. Any extra resources tied to documentation tutorials are found here. Also the home of the Tutorial-World and Evadventure demo codes.

tools/

Miscellaneous tools for manipulating text, security auditing, and more.

+
    +
  • The folder (package) should be on the following form:

    +
    evennia/
    +   contrib/ 
    +       category/    # rpg/, game_systems/ etc
    +           mycontribname/
    +               __init__.py
    +               README.md
    +               module1.py
    +               module2.py
    +               ...
    +               tests.py
    +
    +
    +

    It’s often a good idea to import useful resources in __init__.py to make it easier to import them.

    +
  • +
  • Your code should abide by the Evennia Style Guide. Write it to be easy to read.

  • +
  • Your contribution must be covered by unit tests. Put your tests in a module tests.py under your contrib folder (as seen above) - Evennia will find them automatically. Use a folder tests/ to group your tests if there are many of them across multiple modules.

  • +
  • The README.md file will be parsed and converted into a document linked from the contrib overview page. It needs to be on the following form:

    +
    # MyContribName
    +
    +Contribution by <yourname>, <year>
    +
    +A paragraph (can be multi-line)
    +summarizing the contrib (required)
    +
    +Optional other text
    +
    +## Installation
    +
    +Detailed installation instructions for using the contrib (required)
    +
    +## Usage
    +
    +## Examples
    +
    +etc.
    +
    +
    +
  • +
+
+

The credit and first paragraph-summary will be automatically included on the Contrib overview page index for each contribution, so it needs to be just on this form.

+
+
+
+

Submitting a contrib

+ +
    +
  • A contrib must always be presented as a pull request (PR).

  • +
  • PRs are reviewed so don’t be surprised (or disheartened) if you are asked to modify or change your code before it can be merged. Your code can end up going through several iterations before it is accepted.

  • +
  • To make the licensing situation clear we assume all contributions are released with the same license as Evennia. If this is not possible for some reason, talk to us and we’ll handle it on a case-by-case basis.

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contribs/Contribs-Overview.html b/docs/latest/Contribs/Contribs-Overview.html new file mode 100644 index 0000000000..dad494dc66 --- /dev/null +++ b/docs/latest/Contribs/Contribs-Overview.html @@ -0,0 +1,894 @@ + + + + + + + + + Contribs — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Contribs

+ +

Contribs are optional code snippets and systems contributed by +the Evennia community. They vary in size and complexity and +may be more specific about game types and styles than ‘core’ Evennia. +This page is auto-generated and summarizes all 49 contribs currently included +with the Evennia distribution.

+

All contrib categories are imported from evennia.contrib, such as

+
from evennia.contrib.base_systems import building_menu
+
+
+

Each contrib contains installation instructions for how to integrate it +with your other code. If you want to tweak the code of a contrib, just +copy its entire folder to your game directory and modify/use it from there.

+

If you want to add a contrib, see the contrib guidelines!

+
+

Index

+ + + + + + + + + + + + + + + + + + + + + +

base_systems

full_systems

game_systems

grid

rpg

tutorials

utils

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

auditing

awsstorage

barter

batchprocessor

bodyfunctions

buffs

building_menu

character_creator

clothing

color_markups

components

containers

cooldowns

crafting

custom_gametime

dice

email_login

evadventure

evscaperoom

extended_room

fieldfill

gendersub

git_integration

godotwebsocket

health_bar

ingame_map_display

ingame_python

llm

mail

mapbuilder

menu_login

mirror

multidescer

mux_comms_cmds

name_generator

puzzles

random_string_generator

red_button

rpsystem

simpledoor

slow_exit

talking_npc

traits

tree_select

turnbattle

tutorial_world

unixcommand

wilderness

xyzgrid

+
+
+

base_systems

+

Systems that are not necessarily tied to a specific +in-game mechanic but which are useful for the game as a whole. Examples include +login systems, new command syntaxes, and build helpers.

+
+
+ +
+

awsstorage

+

Contrib by The Right Honourable Reverend (trhr), 2020

+

This plugin migrates the Web-based portion of Evennia, namely images, +javascript, and other items located inside staticfiles into Amazon AWS (S3) +cloud hosting. Great for those serving media with the game.

+

Read the documentation - Browse the Code

+
+
+

building_menu

+

Contrib by vincent-lg, 2018

+

Building menus are in-game menus, not unlike EvMenu though using a +different approach. Building menus have been specifically designed to edit +information as a builder. Creating a building menu in a command allows +builders quick-editing of a given object, like a room. If you follow the +steps to add the contrib, you will have access to an edit command +that will edit any default object, offering to change its key and description.

+

Read the documentation - Browse the Code

+
+
+

color_markups

+

Contrib by Griatch, 2017

+

Additional color markup styles for Evennia (extending or replacing the default +|r, |234). Adds support for MUSH-style (%cr, %c123) and/or legacy-Evennia +({r, {123).

+

Read the documentation - Browse the Code

+
+
+

components

+

Contrib by ChrisLR, 2021

+

Expand typeclasses using a components/composition approach.

+

Read the documentation - Browse the Code

+
+
+

custom_gametime

+

Contrib by vlgeoff, 2017 - based on Griatch’s core original

+

This reimplements the evennia.utils.gametime module but with a custom +calendar (unusual number of days per week/month/year etc) for your game world. +Like the original, it allows for scheduling events to happen at given +in-game times, but now taking this custom calendar into account.

+

Read the documentation - Browse the Code

+
+
+

email_login

+

Contrib by Griatch, 2012

+

This is a variant of the login system that asks for an email-address +instead of a username to login. Note that it does not verify the email, +it just uses it as the identifier rather than a username.

+

Read the documentation - Browse the Code

+
+
+

godotwebsocket

+

Contribution by ChrisLR, 2022

+

This contrib allows you to connect a Godot Client directly to your mud, +and display regular text with color in Godot’s RichTextLabel using BBCode. +You can use Godot to provide advanced functionality with proper Evennia support.

+

Read the documentation - Browse the Code

+
+
+

ingame_python

+

Contrib by Vincent Le Goff 2017

+

This contrib adds the ability to script with Python in-game. It allows trusted +staff/builders to dynamically add features and triggers to individual objects +without needing to do it in external Python modules. Using custom Python in-game, +specific rooms, exits, characters, objects etc can be made to behave differently from +its “cousins”. This is similar to how softcode works for MU or MudProgs for DIKU. +Keep in mind, however, that allowing Python in-game comes with severe +security concerns (you must trust your builders deeply), so read the warnings in +this module carefully before continuing.

+

Read the documentation - Browse the Code

+
+ +
+

mux_comms_cmds

+

Contribution by Griatch 2021

+

In Evennia 1.0+, the old Channel commands (originally inspired by MUX) were +replaced by the single channel command that performs all these functions. +This contrib (extracted from Evennia 0.9.5) breaks out the functionality into +separate Commands more familiar to MU* users. This is just for show though, the +main channel command is still called under the hood.

+

Read the documentation - Browse the Code

+
+
+

unixcommand

+

Contribution by Vincent Le Geoff (vlgeoff), 2017

+

This module contains a command class with an alternate syntax parser implementing +Unix-style command syntax in-game. This means --options, positional arguments +and stuff like -n 10. It might not the best syntax for the average player +but can be really useful for builders when they need to have a single command do +many things with many options. It uses the ArgumentParser from Python’s standard +library under the hood.

+

Read the documentation - Browse the Code

+
+
+
+

full_systems

+

‘Complete’ game engines that can be used directly to start creating content +without no further additions (unless you want to).

+
+
+
+ +
+
+

evscaperoom

+

Contribution by Griatch, 2019

+

A full engine for creating multiplayer escape-rooms in Evennia. Allows players to +spawn and join puzzle rooms that track their state independently. Any number of players +can join to solve a room together. This is the engine created for ‘EvscapeRoom’, which won +the MUD Coders Guild “One Room” Game Jam in April-May, 2019. The contrib has no game +content but contains the utilities and base classes and an empty example room.

+

Read the documentation - Browse the Code

+
+
+
+

game_systems

+

In-game gameplay systems like crafting, mail, combat and more. +Each system is meant to be adopted piecemeal and adopted for your game. +This does not include roleplaying-specific systems, those are found in +the rpg category.

+
+
+ +
+

barter

+

Contribution by Griatch, 2012

+

This implements a full barter system - a way for players to safely +trade items between each other in code rather than simple give/get +commands. This increases both safety (at no time will one player have +both goods and payment in-hand) and speed, since agreed goods will +be moved automatically). By just replacing one side with coin objects, +(or a mix of coins and goods), this also works fine for regular money +transactions.

+

Read the documentation - Browse the Code

+
+
+

clothing

+

Contribution by Tim Ashley Jenkins, 2017

+

Provides a typeclass and commands for wearable clothing. These +look of these clothes are appended to the character’s description when worn.

+

Read the documentation - Browse the Code

+
+
+

containers

+

Adds the ability to put objects into other container objects by providing a container typeclass and extending certain base commands.

+
+
+
+

Installation

+

Read the documentation - Browse the Code

+
+

cooldowns

+

Contribution by owllex, 2021

+

Cooldowns are used to model rate-limited actions, like how often a +character can perform a given action; until a certain time has passed their +command can not be used again. This contrib provides a simple cooldown +handler that can be attached to any typeclass. A cooldown is a lightweight persistent +asynchronous timer that you can query to see if a certain time has yet passed.

+

Read the documentation - Browse the Code

+
+
+

crafting

+

Contribution by Griatch 2020

+

This implements a full crafting system. The principle is that of a ‘recipe’, +where you combine items (tagged as ingredients) create something new. The recipe can also +require certain (non-consumed) tools. An example would be to use the ‘bread recipe’ to +combine ‘flour’, ‘water’ and ‘yeast’ with an ‘oven’ to bake a ‘loaf of bread’.

+

Read the documentation - Browse the Code

+
+
+

gendersub

+

Contribution by Griatch 2015

+

This is a simple gender-aware Character class for allowing users to +insert custom markers in their text to indicate gender-aware +messaging. It relies on a modified msg() and is meant as an +inspiration and starting point to how to do stuff like this.

+

Read the documentation - Browse the Code

+
+
+

mail

+

Contribution by grungies1138 2016

+

A simple Brandymail style mail system that uses the Msg class from Evennia +Core. It has two Commands for either sending mails between Accounts (out of game) +or between Characters (in-game). The two types of mails can be used together or +on their own.

+

Read the documentation - Browse the Code

+
+
+

multidescer

+

Contribution by Griatch 2016

+

A “multidescer” is a concept from the MUSH world. It allows for +splitting your descriptions into arbitrary named ‘sections’ which you can +then swap out at will. It is a way for quickly managing your look (such as when +changing clothes) in more free-form roleplaying systems. This will also +work well together with the rpsystem contrib.

+

Read the documentation - Browse the Code

+
+
+

puzzles

+

Contribution by Henddher 2018

+

Intended for adventure-game style combination puzzles, such as combining fruits +and a blender to create a smoothie. Provides a typeclass and commands for objects +that can be combined (i.e. used together). Unlike the crafting contrib, each +puzzle is built from unique objects rather than using tags and a builder can create +the puzzle entirely from in-game.

+

Read the documentation - Browse the Code

+
+
+

turnbattle

+

Contribution by Tim Ashley Jenkins, 2017

+

This is a framework for a simple turn-based combat system, similar +to those used in D&D-style tabletop role playing games. It allows +any character to start a fight in a room, at which point initiative +is rolled and a turn order is established. Each participant in combat +has a limited time to decide their action for that turn (30 seconds by +default), and combat progresses through the turn order, looping through +the participants until the fight ends.

+

Read the documentation - Browse the Code

+
+
+
+

grid

+

Systems related to the game world’s topology and structure. Contribs related +to rooms, exits and map building.

+
+
+ +
+

extended_room

+

Contribution - Griatch 2012, vincent-lg 2019, Griatch 2023

+

This extends the normal Room typeclass to allow its description to change with +time-of-day and/or season as well as any other state (like flooded or dark). +Embedding $state(burning, This place is on fire!) in the description will +allow for changing the description based on room state. The room also supports +details for the player to look at in the room (without having to create a new +in-game object for each), as well as support for random echoes. The room +comes with a set of alternate commands for look and @desc, as well as new +commands detail, roomstate and time.

+

Read the documentation - Browse the Code

+
+
+

ingame_map_display

+

Contribution - helpme 2022

+

This adds an ascii map to a given room which can be viewed with the map command. +You can easily alter it to add special characters, room colors etc. The map shown is +dynamically generated on use, and supports all compass directions and up/down. Other +directions are ignored.

+

Read the documentation - Browse the Code

+
+
+

mapbuilder

+

Contribution by Cloud_Keeper 2016

+

Build a game map from the drawing of a 2D ASCII map.

+

Read the documentation - Browse the Code

+
+
+

simpledoor

+

Contribution by Griatch, 2016

+

A simple two-way exit that represents a door that can be opened and +closed from both sides. Can easily be expanded to make it lockable, +destroyable etc.

+

Read the documentation - Browse the Code

+
+
+

slow_exit

+

Contribution by Griatch 2014

+

An example of an Exit-type that delays its traversal. This simulates +slow movement, common in many games. The contrib also +contains two commands, setspeed and stop for changing the movement speed +and abort an ongoing traversal, respectively.

+

Read the documentation - Browse the Code

+
+
+

wilderness

+

Contribution by titeuf87, 2017

+

This contrib provides a wilderness map without actually creating a large number +of rooms - as you move, you instead end up back in the same room but its description +changes. This means you can make huge areas with little database use as +long as the rooms are relatively similar (e.g. only the names/descs changing).

+

Read the documentation - Browse the Code

+
+
+

xyzgrid

+

Contribution by Griatch 2021

+

Places Evennia’s game world on an xy (z being different maps) coordinate grid. +Grid is created and maintained externally by drawing and parsing 2D ASCII maps, +including teleports, map transitions and special markers to aid pathfinding. +Supports very fast shortest-route pathfinding on each map. Also includes a +fast view function for seeing only a limited number of steps away from your +current location (useful for displaying the grid as an in-game, updating map).

+

Read the documentation - Browse the Code

+
+
+
+

rpg

+

Systems specifically related to roleplaying +and rule implementation like character traits, dice rolling and emoting.

+
+
+ +
+

buffs

+

Contribution by Tegiminis 2022

+

A buff is a timed object, attached to a game entity. It is capable of modifying values, triggering code, or both. +It is a common design pattern in RPGs, particularly action games.

+

Read the documentation - Browse the Code

+
+
+

character_creator

+

Contribution by InspectorCaracal, 2022

+

Commands for managing and initiating an in-game character-creation menu.

+

Read the documentation - Browse the Code

+
+
+

dice

+

Contribution by Griatch, 2012, 2023

+

A dice roller for any number and side of dice. Adds in-game dice rolling +(like roll 2d10 + 1) as well as conditionals (roll under/over/equal to a target) +and functions for rolling dice in code. Command also supports hidden or secret +rolls for use by a human game master.

+

Read the documentation - Browse the Code

+
+
+

health_bar

+

Contribution by Tim Ashley Jenkins, 2017

+

The function provided in this module lets you easily display visual +bars or meters as a colorful bar instead of just a number. A “health bar” +is merely the most obvious use for this, but the bar is highly customizable +and can be used for any sort of appropriate data besides player health.

+

Read the documentation - Browse the Code

+
+
+

llm

+

Contribution by Griatch 2023

+

This adds an LLMClient that allows Evennia to send prompts to a LLM server (Large Language Model, along the lines of ChatGPT). Example uses a local OSS LLM install. Included is an NPC you can chat with using a new talk command. The NPC will respond using the AI responses from the LLM server. All calls are asynchronous, so if the LLM is slow, Evennia is not affected.

+

Read the documentation - Browse the Code

+
+
+

rpsystem

+

Contribution by Griatch, 2015

+

A full roleplaying emote system. Short-descriptions and recognition (only know people by their looks until you assign a name to them). Room poses. Masks/disguises (hide your description). Speak directly in emote, with optional language obscuration (words get garbled if you don’t know the language, you can also have different languages with different ‘sounding’ garbling). Whispers can be partly overheard from a distance. A very powerful in-emote reference system, for referencing and differentiate targets (including objects).

+

Read the documentation - Browse the Code

+
+
+

traits

+

Contribution by Griatch 2020, based on code by Whitenoise and Ainneve contribs, 2014

+

A Trait represents a modifiable property on (usually) a Character. They can +be used to represent everything from attributes (str, agi etc) to skills +(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc. +Traits differ from normal Attributes in that they track their changes and limit +themselves to particular value-ranges. One can add/subtract from them easily and +they can even change dynamically at a particular rate (like you being poisoned or +healed).

+

Read the documentation - Browse the Code

+
+
+
+

tutorials

+

Helper resources specifically meant to teach a development concept or +to exemplify an Evennia system. Any extra resources tied to documentation +tutorials are found here. Also the home of the Tutorial-World and Evadventure +demo codes.

+
+
+ +
+

batchprocessor

+

Contibution by Griatch, 2012

+

Simple examples for the batch-processor. The batch processor is used for generating +in-game content from one or more static files. Files can be stored with version +control and then ‘applied’ to the game to create content.

+

Read the documentation - Browse the Code

+
+
+

bodyfunctions

+

Contribution by Griatch, 2012

+

Example script for testing. This adds a simple timer that has your +character make small verbal observations at irregular intervals.

+

Read the documentation - Browse the Code

+
+
+

evadventure

+

Contrib by Griatch 2023-

+
+

Warning

+

NOTE - this tutorial is WIP and NOT complete yet! You will still learn +things from it, but don’t expect perfection.

+
+

Read the documentation - Browse the Code

+
+
+

mirror

+

Contribution by Griatch, 2017

+

A simple mirror object to experiment with. It will respond to being looked at.

+

Read the documentation - Browse the Code

+
+
+

red_button

+

Contribution by Griatch, 2011

+

A red button that you can press to have an effect. This is a more advanced example +object with its own functionality and state tracking.

+

Read the documentation - Browse the Code

+
+
+

talking_npc

+

Contribution by Griatch 2011. Updated by grungies1138, 2016

+

This is an example of a static NPC object capable of holding a simple menu-driven +conversation. Suitable for example as a quest giver or merchant.

+

Read the documentation - Browse the Code

+
+
+

tutorial_world

+

Contribution by Griatch 2011, 2015

+

A stand-alone tutorial area for an unmodified Evennia install. +Think of it as a sort of single-player adventure rather than a +full-fledged multi-player game world. The various rooms and objects +are designed to show off features of Evennia, not to be a +very challenging (nor long) gaming experience. As such it’s of course +only skimming the surface of what is possible. Taking this apart +is a great way to start learning the system.

+

Read the documentation - Browse the Code

+
+
+
+

utils

+

Miscellaneous, tools for manipulating text, security auditing, and more.

+
+
+ +
+

auditing

+

Contribution by Johnny, 2017

+

Utility that taps and intercepts all data sent to/from clients and the +server and passes it to a callback of your choosing. This is intended for +quality assurance, post-incident investigations and debugging.

+

Read the documentation - Browse the Code

+
+
+

fieldfill

+

Contribution by Tim Ashley Jenkins, 2018

+

This module contains a function that generates an EvMenu for you - this +menu presents the player with a form of fields that can be filled +out in any order (e.g. for character generation or building). Each field’s value can +be verified, with the function allowing easy checks for text and integer input, +minimum and maximum values / character lengths, or can even be verified by a custom +function. Once the form is submitted, the form’s data is submitted as a dictionary +to any callable of your choice.

+

Read the documentation - Browse the Code

+
+
+

git_integration

+

Contribution by helpme (2022)

+

A module to integrate a stripped-down version of git within the game, allowing developers to view their git status, change branches, and pull updated code of both their local mygame repo and Evennia core. After a successful pull or checkout, the git command will reload the game: Manual restarts may be required to to apply certain changes that would impact persistent scripts etc.

+

Read the documentation - Browse the Code

+
+
+

name_generator

+

Contribution by InspectorCaracal (2022)

+

A module for generating random names, both real-world and fantasy. Real-world +names can be generated either as first (personal) names, family (last) names, or +full names (first, optional middles, and last). The name data is from Behind the Name +and used under the CC BY-SA 4.0 license.

+

Read the documentation - Browse the Code

+
+
+

random_string_generator

+

Contribution by Vincent Le Goff (vlgeoff), 2017

+

This utility can be used to generate pseudo-random strings of information +with specific criteria. You could, for instance, use it to generate +phone numbers, license plate numbers, validation codes, in-game security +passwords and so on. The strings generated will be stored and won’t be repeated.

+

Read the documentation - Browse the Code

+
+
+

tree_select

+

Contribution by Tim Ashley Jenkins, 2017

+

This utility allows you to create and initialize an entire branching EvMenu +instance from a multi-line string passed to one function.

+

Read the documentation - Browse the Code

+
+

This document page is auto-generated. Manual changes +will be overwritten.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contributing-Docs.html b/docs/latest/Contributing-Docs.html new file mode 100644 index 0000000000..e452f2d986 --- /dev/null +++ b/docs/latest/Contributing-Docs.html @@ -0,0 +1,776 @@ + + + + + + + + + Contributing to Evennia Docs — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Contributing to Evennia Docs

+ +
    +
  • You can contribute to docs by creating a Documentation issue.

  • +
  • You can contribute to docs by making a PR like for any other code. The sources are found in evennia/docs/source/.

  • +
+

The documentation source files are *.md (Markdown) files. Markdown files are simple text files that can be edited with a normal text editor. They can also contain raw HTML directives (but that is very rarely needed). They use the Markdown syntax with MyST extensions.

+
+

Source file structure

+

The sources are organized into several rough categories, with only a few administrative documents +at the root of evennia/docs/source/.

+
    +
  • source/Components/ are docs describing separate Evennia building blocks, that is, things +that you can import and use. This extends and elaborates on what can be found out by reading the api docs themselves. Example are documentation for Accounts, Objects and Commands.

  • +
  • source/Concepts/ describes how larger-scale features of Evennia hang together - things that can’t easily be broken down into one isolated component. This can be general descriptions of how Models and Typeclasses interact to the path a message takes from the client to the server and back.

  • +
  • source/Setup/ holds detailed docs on installing, running and maintaining the Evennia server and the infrastructure around it.

  • +
  • source/Coding/ has help on how to interact with, use and navigate the Evennia codebase itself. This also has non-Evennia-specific help on general development concepts and how to set up a sane development environment.

  • +
  • source/Contribs/ holds documentation specifically for packages in the evennia/contribs/ folder. Any contrib-specific tutorials will be found here instead of in Howtos

  • +
  • source/Howtos/ holds docs that describe how to achieve a specific goal, effect or +result in Evennia. This is often on a tutorial or FAQ form and will refer to the rest of the documentation for further reading.

  • +
  • source/Howtos/Beginner-Tutorial/ holds all documents part of the initial tutorial sequence.

  • +
+

Other files and folders:

+
    +
  • source/api/ contains the auto-generated API documentation as .html files. Don’t edit these files manually, they are auto-generated from sources.

  • +
  • source/_templates and source/_static hold files for the doc itself. They should only be modified if wanting to change the look and structure of the documentation generation itself.

  • +
  • conf.py holds the Sphinx configuration. It should usually not be modified except to update the Evennia version on a new branch.

  • +
+
+
+

Editing syntax

+

The format used for Evennia’s docs is Markdown (Commonmark). While markdown +supports a few alternative forms for some of these, we try to stick to the below forms for consistency.

+
+

Italic/Bold

+

We generally use underscores for italics and double-asterisks for bold:

+
    +
  • _Italic text_ - Italic text

  • +
  • **Bold Text** - Bold text

  • +
+
+
+

Headings

+

We use # to indicate sections/headings. The more # the more of a sub-heading it is (will get smaller and smaller font).

+
    +
  • # Heading

  • +
  • ## SubHeading

  • +
  • ### SubSubHeading

  • +
  • #### SubSubSubHeading

  • +
+
+

Don’t use the same heading/subheading name more than once in one page. While Markdown does not prevent it, it will make it impossible to refer to that heading uniquely. The Evennia documentation preparser will detect this and give an error.

+
+
+
+

Lists

+

One can create both bullet-point lists and numbered lists:

+
- first bulletpoint
+- second bulletpoint
+- third bulletpoint
+
+
+
    +
  • first bulletpoint

  • +
  • second bulletpoint

  • +
  • third bulletpoint

  • +
+
1. Numbered point one
+2. Numbered point two
+3. Numbered point three
+
+
+
    +
  1. Numbered point one

  2. +
  3. Numbered point two

  4. +
  5. Numbered point three

  6. +
+
+
+

Blockquotes

+

A blockquote will create an indented block. It’s useful for emphasis and is +added by starting one or more lines with >. For ‘notes’ you can also use +an explicit Note.

+
> This is an important
+> thing to remember.
+
+
+
+

Note: This is an important +thing to remember.

+
+
+ +
+

Urls/References in one place

+

Urls can get long and if you are using the same url/reference in many places it can get a +little cluttered. So you can also put the url as a ‘footnote’ at the end of your document. +You can then refer to it by putting your reference within square brackets [ ]. Here’s an example:

+
This is a [clickable link][mylink]. This is [another link][1].
+
+...
+
+
+[mylink]: http://...
+[1]: My-Document.md#this-is-a-long-ref
+
+
+
+

This makes the main text a little shorter.

+
+
+

Tables

+

A table is done like this:

+
| heading1 | heading2 | heading3 |
+| --- | --- | --- |
+| value1 | value2 | value3 |
+|  | value 4 | |
+| value 5 | value 6 | |
+
+
+ + + + + + + + + + + + + + + + + + + + + +

heading1

heading2

heading3

value1

value2

value3

value 4

value 5

value 6

+

As seen, the Markdown syntax can be pretty sloppy (columns don’t need to line up) as long as you +include the heading separators and make sure to add the correct number of | on every line.

+
+
+

Verbatim text

+

It’s common to want to mark something to be displayed verbatim - just as written - without any +Markdown parsing. In running text, this is done using backticks (`), like `verbatim text` becomes +verbatim text.

+

If you want to put the verbatim text on its own line, you can do so easily by simply indenting +it 4 spaces (add empty lines on each side for readability too):

+
This is normal text
+
+    This is verbatim text
+
+This is normal text
+
+
+

Another way is to use triple-backticks:

+
```
+Everything within these backticks will be verbatim.
+
+```
+
+
+
+
+

Code blocks

+

A special ‘verbatim’ case is code examples - we want them to get code-highlighting for readability. +This is done by using the triple-backticks and specify which language we use:

+
```python
+from evennia import Command
+class CmdEcho(Command):
+    """
+    Usage: echo <arg>
+    """
+    key = "echo"
+    def func(self):
+        self.caller.msg(self.args.strip())
+```
+
+
+
from evennia import Command
+class CmdEcho(Command):
+  """
+  Usage: echo <arg>
+  """
+  key = "echo"
+  def func(self):
+    self.caller.msg(self.args.strip())
+
+
+

For examples of using the Python command-line, use python language and >>> prompt.

+
```python
+>>> print("Hello World")
+Hello World
+```
+
+
+
>>> print("Hello World")
+Hello World
+
+
+

When showing an in-game command, use the shell language type and > as the prompt. +Indent returns from the game.

+
```shell
+> look at flower
+  Red Flower(#34)
+  A flower with red petals.
+```
+
+
+
> look at flower
+  Red Flower(#34)
+  A flower with red petals.
+
+
+

For actual shell prompts you can either use bash language type or just indent the line. +Use $ for the prompt when wanting to show what is an input and what is an output, otherwise +skip it - it can be confusing to users not that familiar with the command line.

+
```bash
+$ ls
+evennia/ mygame/
+```
+    evennia start --log
+
+
+
$ ls
+evennia/ mygame/
+
+
+
evennia start --log
+
+
+
+
+

MyST directives

+

Markdown is easy to read and use. But while it does most of what we need, there are some things it’s +not quite as expressive as it needs to be. For this we use extended MyST syntax. This is +on the form

+
```{directive} any_options_here
+
+content
+
+```
+
+
+
+

Note

+

This kind of note may pop more than doing a > Note: ....

+
```{note}
+
+This is some noteworthy content that stretches over more than one line to show how the content indents.
+Also the important/warning notes indents like this.
+
+```
+
+
+
+

Note

+

This is some noteworthy content that stretches over more than one line to show how the content indents. +Also the important/warning notes indents like this.

+
+
+
+

Important

+

This is for particularly important and visible notes.

+
```{important}
+  This is important because it is!
+```
+
+
+
+
+

Important

+

This is important because it is!

+
+
+
+

Warning

+

A warning block is used to draw attention to particularly dangerous things, or features easy to +mess up.

+
```{warning}
+  Be careful about this ...
+```
+
+
+
+

Warning

+

Be careful about this …

+
+
+
+

Version changes and deprecations

+

These will show up as one-line warnings that suggest an added, changed or deprecated +feature beginning with particular version.

+
```{versionadded} 1.0
+```
+
+
+
+

New in version 1.0.

+
+
```{versionchanged} 1.0
+  How the feature changed with this version.
+```
+
+
+
+

Changed in version 1.0: How the feature changed with this version.

+
+
```{deprecated} 1.0
+```
+
+
+
+

Deprecated since version 1.0.

+
+
+ +
+

A more flexible code block

+

The regular Markdown Python codeblock is usually enough but for more direct control over the style, one +can also use the {code-block} directive that takes a set of additional :options::

+
```{code-block} python
+:linenos:
+:emphasize-lines: 1-2,8
+:caption: An example code block
+:name: A full code block example
+
+from evennia import Command
+class CmdEcho(Command):
+    """
+    Usage: echo <arg>
+    """
+    key = "echo"
+    def func(self):
+        self.caller.msg(self.args.strip())
+```
+
+
+
+
An example code block
+
1
+2
+3
+4
+5
+6
+7
+8
from evennia import Command
+class CmdEcho(Command):
+    """
+    Usage: echo <arg>
+    """
+    key = "echo"
+    def func(self):
+        self.caller.msg(self.args.strip())
+
+
+
+

Here, :linenos: turns on line-numbers and :emphasize-lines: allows for emphasizing certain lines +in a different color. The :caption: shows an instructive text and :name: is used to reference +this +block through the link that will appear (so it should be unique for a given document).

+
+
+

eval-rst directive

+

As a last resort, we can also fall back to writing ReST directives directly:

+
```{eval-rst}
+
+    This will be evaluated as ReST.
+    All content must be indented.
+
+```
+
+
+

Within a ReST block, one must use Restructured Text syntax, which is not the +same as Markdown.

+
    +
  • Single backticks around text makes it italic.

  • +
  • Double backticks around text makes it verbatim.

  • +
  • A link is written within back-ticks, with an underscore at the end:

    +
    `python <www.python.org>`_
    +
    +
    +
  • +
+

Here is a ReST formatting cheat sheet.

+
+
+
+
+

Writing Code docstrings for autodocs

+

The source code docstrings will be parsed as Markdown. When writing a module docstring, you can use Markdown formatting, including header levels down to 4th level (#### SubSubSubHeader).

+

After the module documentation it’s a good idea to end with four dashes ----. This will create a visible line between the documentation and the class/function docs to follow. See for example the Traits docs.

+

All non-private classes, methods and functions must have a Google-style docstring, as per the [Evennia coding style guidelines][github:evennia/CODING_STYLE.md]. This will then be correctly formatted into pretty api docs.

+
+
+

Building the docs locally

+

Evennia leverages Sphinx with the MyST extension, which allows us to write our docs in light-weight Markdown (more specifically CommonMark, like on github) rather than Sphinx’ normal ReST syntax. The MyST parser allows for some extra syntax to make us able to express more complex displays than plain Markdown can.

+

For autodoc-generation generation, we use the sphinx-napoleon extension to understand our friendly Google-style docstrings used in classes and functions etc.

+

The sources in evennia/docs/source/ are built into a documentation using the Sphinx static generator system together with Evennia-custom pre-parsers (also included in the repo).

+

To do this locally you need to use a system with make (Linux/Unix/Mac or Windows-WSL). Lacking that, you could in principle also run the sphinx build-commands manually - read the evennia/docs/Makefile to see which commands are run by the make-commands referred to in this document.

+
+

Important

+

As mentioned at the top, you don’t have to build the docs locally to contribute. Markdown is not hard and can be written decently without seeing it processed. We can polish it before merging.

+

You can furthermore get a good feel for how things will look using a Markdown-viewer like Grip. Editors like ReText or IDE’s like PyCharm also have native Markdown previews.

+

That said, building the docs locally is the only way to make sure the outcome is exactly as you expect. The processor will also find any mistakes you made, like making a typo in a link.

+
+
+

Building only the main documentation

+

This is the fastest way to compile and view your changes. It will only build the main documentation pages and not the API auto-docs or versions. All is done in your terminal/console.

+
    +
  • (Optional, but recommended): Activate a virtualenv with Python 3.11.

  • +
  • cd to into the evennia/docs folder.

  • +
  • Install the documentation-build requirements:

    +
    make install
    +or
    +pip install -r requirements.txt
    +
    +
    +
  • +
  • Next, build the html-based documentation (re-run this in the future to build your changes):

    +
    make quick
    +
    +
    +
  • +
  • Note any errors from files you have edited.

  • +
  • The html-based documentation will appear in the new folder evennia/docs/build/html/.

  • +
  • Use a web browser to open file://<path-to-folder>/evennia/docs/build/html/index.html and view the docs. Note that you will get errors if clicking a link to the auto-docs, because you didn’t build them!

  • +
+
+
+

Building the main documentation and API docs

+

The full documentation includes both the doc pages and the API documentation generated from the Evennia source. For this you must install Evennia and initialize a new game with a default database (you don’t need to have any server running)

+
    +
  • It’s recommended that you use a virtualenv. Install your cloned version of Evennia into by pointing to the repo folder (the one containing /docs):

    +
    pip install -e evennia
    +
    +
    +
  • +
  • Make sure you are in the parent folder containing your evennia/ repo (so two levels up from evennia/docs/).

  • +
  • Create a new game folder called exactly gamedir at the same level as your evennia repo with

    +
    evennia --init gamedir
    +
    +
    +
  • +
  • Then cd into it and create a new, empty database. You don’t need to start the game or do any further changes after this.

    +
    evennia migrate
    +
    +
    +
  • +
  • This is how the structure should look at this point:

    +
      (top)
    +  |
    +  ----- evennia/  (the top-level folder, containing docs/)
    +  |
    +  ----- gamedir/
    +
    +
    +
  • +
+

(If you are already working on a game, you may of course have your ‘real’ game folder there as well. We won’t touch that.)

+
    +
  • Go to evennia/docs/ and install the doc-building requirements (you only need to do this once):

    +
    make install
    +or
    +pip install -r requirements.txt
    +
    +
    +
  • +
  • Finally, build the full documentation, including the auto-docs:

    +
    make local
    +
    +
    +
  • +
  • The rendered files will appear in a new folder evennia/docs/build/html/. Note any errors from files you have edited.

  • +
  • Point your web browser to file://<path-to-folder>/evennia/docs/build/html/index.html to view the full docs.

  • +
+
+

Building with another gamedir

+

If you for some reason want to use another location of your gamedir/, or want it named something else (maybe you already use the name ‘gamedir’ for your development …), you can do so by setting the EVGAMEDIR environment variable to the absolute path of your alternative game dir. For example:

+
EVGAMEDIR=/my/path/to/mygamedir make local
+
+
+
+
+
+

Building multiversion docs

+

The full Evennia documentation contains docs from many Evennia versions, old and new. This is done by pulling documentation from Evennia’s old release branches and building them all so readers can choose which one to view. Only specific official Evennia branches will be built, so you can’t use this to build your own testing branch.

+
    +
  • All local changes must have been committed to git first, since the versioned docs are built by looking at the git tree.

  • +
  • To build for local checking, run (mv stands for “multi-version”):

    +
    make mv-local
    +
    +
    +
  • +
+

This is as close to the ‘real’ version of the docs as you can get locally. The different versions will be found under evennia/docs/build/versions/. During deploy a symlink latest will point to the latest version of the docs.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Contributing.html b/docs/latest/Contributing.html new file mode 100644 index 0000000000..b36e4a341c --- /dev/null +++ b/docs/latest/Contributing.html @@ -0,0 +1,185 @@ + + + + + + + + + How To Contribute And Get Help — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

How To Contribute And Get Help

+
+

Getting Help

+

If you cannot find what you are looking for in the documentation, here’s where to go next:

+
    +
  • The Discussions forums are great for getting help, starting longer-form discussions, showing off your work or get some input on what you are working on. This is also the place to follow Evennia development.

  • +
  • The Discord server has a very helpful #getting-help channel for both big and small Evennia problems. It allows for more direct discussion. You can also track both the evennia code changes and the discussion forum from discord.

  • +
+
+
+

Giving Help

+

In general, being active and helpful in the discssion forums or discord chat is already a big help!

+

If you want to spread the word, consider writing about Evennia on your blog or in your favorite (relevant) forum. Write a review somewhere (good or bad, we like feedback either way). Rate it on listings. Talk about it to your friends … that kind of thing.

+
+

Help with Documentation

+

Evennia is highly dependent on good-quality documentation!

+
    +
  • Reporting a Documentation issue is the easiest way to help out. The more eyes we get on things, the better - if we don’t know about the problems, we can’t fix them! Even reporting typos is a great help.

  • +
  • Contributing directly to the docs is also possible; you just need a text editor. You can fix issues or even propose a new tutorial!

  • +
+
+
+

Helping with code

+

Even if you don’t feel confident with tackling a bug or feature, just correcting typos, adjusting formatting or simply using the thing and reporting when stuff doesn’t make sense helps us a lot!

+
    +
  • Reporting a code issue or bug is the easiest way to help out. Don’t assume we are aware of a problem - if it’s not written down in an issue, the problem will most likely be forgotten. Do this even if you plan to make a Pull Request and fix the issue yourself.

  • +
  • Make a feature request if you want to see a new Evennia feature. You can also bring it up with the community first so you are sure it’s something that would be interesting to be included with Evennia core.

  • +
  • Make a Pull Request (PR) to fix bugs or new features. Make sure to follow the Evennia Code-Style guide.

  • +
  • The Guide for making an Evennia contrib outlines how you do to make your own Evennia contrib to distribute with Evennia.

  • +
+
+
+

Helping with Donations

+

Evennia is a free and open-source project. Any monetary donations you want to offer are completely voluntary. While highly appreciated, we don’t expect you to donate and don’t hide any secret features behind a donation-paywall. Just see it as a way of showing appreciation by dropping a few coins in the cup.

+
    +
  • Become an Evennia patron which donates a (usually small) sum every month to show continued support.

  • +
  • Make a one-time donation if a monthly donation is not your thing. This is a PayPal link but you don’t need PayPal yourself to use it.

  • +
+

[codestyle]:: Evennia-Code-Style

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Evennia-API.html b/docs/latest/Evennia-API.html new file mode 100644 index 0000000000..dafb2acdc9 --- /dev/null +++ b/docs/latest/Evennia-API.html @@ -0,0 +1,266 @@ + + + + + + + + + API Summary — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

API Summary

+

evennia - library source tree

+ +
+

Shortcuts

+

Evennia’s ‘flat API’ has shortcuts to common tools, available by only importing evennia. +The flat API is defined in __init__.py viewable here

+
+

Main config

+ +
+
+

Search functions

+ +
+
+

Create functions

+ +
+
+

Typeclasses

+ +
+
+

Commands

+ +
+
+

Utilities

+ +
+
+

Global singleton handlers

+ +
+
+

Database core models (for more advanced lookups)

+ +
+
+

Contributions

+ +
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Evennia-In-Pictures.html b/docs/latest/Evennia-In-Pictures.html new file mode 100644 index 0000000000..bfdd252711 --- /dev/null +++ b/docs/latest/Evennia-In-Pictures.html @@ -0,0 +1,235 @@ + + + + + + + + + Evennia in pictures — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia in pictures

+ +

This article tries to give a high-level overview of the Evennia server and some of its moving parts. It should hopefully give a better understanding of how everything hangs together.

+
+
+

The two main Evennia pieces

+

evennia portal and server

+

What you see in this figure is the part of Evennia that you download from us. It will not start a game on its own. We’ll soon create the missing ‘jigsaw puzzle piece’. But first, let’s see what we have.

+

First, you’ll notice that Evennia has two main components - the Portal and Server. These are separate processes.

+

The Portal tracks all connections to the outside world and understands Telnet protocols, websockets, SSH and so on. It knows nothing about the database or the game state. Data sent between the Portal and the Server is protocol-agnostic, meaning the Server sends/receives the same data regardless of how the user is connected. Hiding behind the Portal also means that the Server can be completely rebooted without anyone getting disconnected.

+

The Server is the main “mud driver” and handles everything related to the game world and its database. It’s asynchronous and uses Twisted.

+

In the same process of the Server is also the Evennia Web Server . This serves the game’s website.

+
+
+

Initializing the game folder

+

creating the game folder

+

After installing evennia you will have the evennia command available. Using this you create a game directory (let’s call it mygame). This is the darker grey piece in this figure. It was missing previously. This is where you will create your dream game!

+

During initialization, Evennia will create Python module templates in mygame/ and link up all configurations to make mygame a fully functioning, if empty, game, ready to start extending.

+

As part of the intialization, you’ll create the database and then start the server. From this point on, your new game is up and running and you can connect to your new game with telnet on localhost:4000 or by pointing your browser to http://localhost:4001.

+

Now, our new mygame world needs Characters, locations, items and more!

+
+
+
+

The database

+

image3

+

Evennia is fully persistent and abstracts its database in Python using Django. The database tables are few and generic, each represented by a single Python class. As seen in this figure, the example ObjectDB Python class represents one database table. The properties on the class are the columns (fields) of the table. Each row is an instance of the class (one entity in the game).

+

Among the example columns shown is the key (name) of the ObjectDB entity as well as a Foreign key-relationship for its current “location”.

+

From the figure we can see that Trigger is in the Dungeon, carrying his trusty crossbow Old Betsy!

+

The db_typeclass_path is an important field. This is a python-style path and tells Evennia which subclass of ObjectDB is actually representing this entity. This is the core of Evennia’s Typeclass system, which allows you to work with database entities using normal Python.

+
+

From database to Python

+

image4

+

Here we see the (somewhat simplified) Python class inheritance tree that you as an Evennia developer will see, along with the three instanced entities.

+

Objects represent stuff you will actually see in-game and its child classes implement all the handlers, helper code and the hook methods that Evennia makes use of. In your mygame/ folder you just import these and overload the things you want to modify. In this way, the Crossbow is modified to do the stuff only crossbows can do and CastleRoom adds whatever it is that is special about rooms in the castle.

+

When creating a new entity in-game, a new row will automatically be created in the database table and then Trigger will appear in-game! If we, in code, search the database for Trigger, we will get an instance of the Character class back - a Python object we can work with normally.

+

Looking at this you may think that you will be making a lot of classes for every different object in the game. Your exact layout is up to you but Evennia also offers other ways to customize each individual object. Read on.

+
+
+

Attributes

+

image5

+

The Attribute is another class directly tied to the database behind the scenes. Each Attribute basically has a key, a value and a ForeignKey relation to another ObjectDB.

+

An Attribute serializes Python constructs into the database, meaning you can store basically any valid Python, like the dictionary of skills in this image. The “strength” and “skills” Attributes will henceforth be reachable directly from the Trigger object. This (and a few other resources) allow you to create individualized entities while only needing to create classes for those that really behave fundamentally different.

+
+
+
+
+

Controlling the action

+

image6

+

Trigger is most likely played by a human. This human connects to the game via one or more Sessions, one for each client they connect with.

+

Their account on mygame is represented by a Account entity. The AccountDB holds the password and other account info but has no existence in the game world. Through the Account entity, Sessions can control (“puppet”) one or more Object entities in-game.

+

In this figure, a user is connected to the game with three Sessions simultaneously. They are logged in to their player Account named Richard. Through these Sessions they are simultaneously puppeting the in-game entities Trigger and Sir Hiss. Evennia can be configured to allow or disallow a range of different Connection Styles like this.

+
+

Commands

+

image7

+

For users to be able to control their game entities and actually play the game, they need to be able to send Commands.

+

A Command can be made to represent anything a user can input actively to the game, such as the look command, get, quit, emote and so on.

+

Each Command handles both argument parsing and execution. Since each Command is described with a normal Python class, it means that you can implement parsing once and then just have the rest of your commands inherit the effect. In the above figure, the DIKUCommand parent class implements parsing of all the syntax common for all DIKU-style commands so CmdLook and others won’t have to.

+
+
+

Command Sets

+

image8

+

All Evennia Commands are are always joined together in CommandSets. These are containers that can hold many Command instances. A given Command class can contribute instances to any number of CommandSets. These sets are always associated with game entities.

+

In this figure, Trigger has received a CommandSet with a bunch of useful commands that he (and by extension his controlling Account/Player) can now use.

+

image9

+

Trigger’s CommandSet is only available to himself. In this figure we put a CommandSet with three commands on the Dungeon room. The room itself has no use for commands but we configure this set to affect those inside it instead. Note that we let these be different versions of these commands (hence the different color)! We’ll explain why below.

+
+
+
+

Merging Command Sets

+

image10

+

Multiple CommandSets can be dynamically (and temporarily) merged together in a similar fashion as Set Theory, except the merge priority can be customized. In this figure we see a Union-type merger where the Commands from Dungeon of the same name temporarily override the commands from Trigger. While in the Dungeon, Trigger will be using this version of those commands. When Trigger leaves, his own CommandSet will be restored unharmed.

+

Why would we want to do this? Consider for example that the dungeon is in darkness. We can then let the Dungeon’s version of the look command show only the contents of the room if Trigger is carrying a light source. You might also not be able to easily get things in the room without light - you might even be fumbling randomly in your inventory!

+

Any number of Command Sets can be merged on the fly. This allows you to implement multiple overlapping states (like combat in a darkened room while intoxicated) without needing huge if statements for every possible combination. The merger is non-destructive, so you can remove cmdsets to get back previous states as needed.

+
+
+
+

Now go and explore!

+

This is by no means a full list of Evennia features. But it should give you a bunch of interesting concepts to read more about.

+

You can find a lot more detail in the Core Components and Core Concepts sections of this manual. If you haven’t read it already, you should also check out the Evennia Introduction.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Evennia-Introduction.html b/docs/latest/Evennia-Introduction.html new file mode 100644 index 0000000000..2b9fcae492 --- /dev/null +++ b/docs/latest/Evennia-Introduction.html @@ -0,0 +1,235 @@ + + + + + + + + + Evennia Introduction — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia Introduction

+
+

A MUD (originally Multi-User Dungeon, with later variants Multi-User Dimension and Multi-User +Domain) is a multiplayer real-time virtual world described primarily in text. MUDs combine elements +of role-playing games, hack and slash, player versus player, interactive fiction and online chat. +Players can read or view descriptions of rooms, objects, other players, non-player characters, and +actions performed in the virtual world. Players typically interact with each other and the world by +typing commands that resemble a natural language. - Wikipedia

+
+

If you are reading this, it’s quite likely you are dreaming of creating and running a text-based massively-multiplayer game (MUD/MUX/MUSH etc) of your very own. You might just be starting to think about it, or you might have lugged around that perfect game in your mind for years … you know just how good it would be, if you could only make it come to reality.

+

We know how you feel. That is, after all, why Evennia came to be.

+
+

What is Evennia?

+

Evennia is a MU*-building framework: a bare-bones Python codebase and server intended to be highly extendable for any style of game.

+
+

Bare-bones?

+

Evennia is “bare-bones” in the sense that we try to impose as few game-specific things on you as possible. We don’t prescribe any combat rules, mob AI, races, skills, character classes or other things.

+

We figure you will want to make that for yourself, just like you want it!

+
+
+

Framework?

+

Evennia is bare-bones, but not that barebones. We do offer basic building blocks like objects, characters and rooms, in-built channels and so on. We also provide of useful commands for building and administration etc.

+

Out of the box you’ll have a ‘talker’ type of game - an empty but fully functional social game where you can build rooms, walk around and chat/roleplay. Evennia handles all the boring database, networking, and behind-the-scenes administration stuff that all online games need whether they like it or not. It’s a blank slate for you to expand on.

+

We also include a growing list of optional contribs you can use with your game. These are more game-specific and can help to inspire or have something to build from.

+
+
+

Server?

+

Evennia is its own webserver. When you start Evennia, your server hosts a game website and a browser webclient. This allows your players to play both in their browsers as well as connect using traditional MUD clients. None of this is visible to the internet until you feel ready to share your game with the world.

+
+
+

Python?

+

Python is not only one of the most popular programming languages languages in use today, it is also considered one of the easiest to learn. In the Evennia community, we have many people who learned Python or programming by making a game. Some even got a job from the skills they learned working with Evennia!

+

All your coding, from object definitions and custom commands to AI scripts and economic systems is done in normal Python modules rather than some ad-hoc scripting language.

+
+
+
+

Can I test it somewhere?

+

Evennia’s demo server can be found at https://demo.evennia.com or on demo.evennia.com, port 4000 if you are using a traditional MUD client.

+

Once you installed Evennia, you can also create a tutorial mini-game with a single command. Read more about it here.

+
+
+

What do I need to know to work with Evennia?

+

Once you installed Evennia and connected, you should decide on what you want to do.

+
+

I don’t know (or don’t want to do) any programming - I just want to run a game!

+

Evennia comes with a default set of commands for the Python newbies and for those who need to get a game running now.

+

Stock Evennia is enough for running a simple ‘Talker’-type game - you can build and describe rooms and basic objects, have chat channels, do emotes and other things suitable for a social or free-form MU*.

+

Combat, mobs and other game elements are not included, so you’ll have a very basic game indeed if you are not willing to do at least some coding.

+
+
+

I know basic Python, or I am willing to learn

+

Start small. Evennia’s Beginner tutorial is a good place to start.

+ +

While Python is considered a very easy programming language to get into, you do have a learning curve to climb if you are new to programming. The beginner-tutorial has a basic introduction to Python, but if you are completely new, you should probably also sit down with a full Python beginner’s tutorial at some point. There are plenty of them on the web if you look around.

+

To code your dream game in Evennia you don’t need to be a Python guru, but you do need to be able to read example code containing at least these basic Python features:

+ +

Obviously, the more things you feel comfortable with, the easier time you’ll have to find your way.

+

With just basic knowledge you can set out to build your game by expanding Evennia’s examples.

+
+
+

I know my Python stuff and I am willing to use it!

+

Even if you started out as a Python beginner, you will likely get to this point after working on your game for a while.

+

With more general knowledge in Python the full power of Evennia opens up for you. Apart from modifying commands, objects and scripts, you can develop everything from advanced mob AI and economic systems, through sophisticated combat and social mini games, to redefining how commands, players, rooms or channels themselves work. Since you code your game by importing normal Python modules, there are few limits to what you can accomplish.

+

If you also happen to know some web programming (HTML, CSS, Javascript) there is also a web +presence (a website and a mud web client) to play around with …

+
+
+
+

Where to from here?

+

To get a top-level overview of Evennia, you can check out Evennia in pictures.

+

After that it’s a good idea to jump into the Beginner Tutorial. You can either follow it lesson for lesson or jump around to what seems interesting. There are also more Tutorials and Howto’s to look over.

+

You can also read the lead developer’s dev blog for many tidbits and snippets about Evennia’s development and structure.

+

Sometimes it’s easier to ask for help. Get engaged in the Evennia community by joining our Discord for direct support. Make an introductory post to our Discussion forum and say hi! See here for more ways to get and give help to the project.

+

Welcome to Evennia!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Beginner-Tutorial-Overview.html b/docs/latest/Howtos/Beginner-Tutorial/Beginner-Tutorial-Overview.html new file mode 100644 index 0000000000..c749f93973 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Beginner-Tutorial-Overview.html @@ -0,0 +1,721 @@ + + + + + + + + + Beginner Tutorial — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Beginner Tutorial

+ +

Welcome to Evennia! This multi-part Beginner Tutorial will help get you off the ground and running.

+

You may choose topics that seem interesting but, if you follow this tutorial through to the end, you will have created your own small online game to play and share with others!

+

Use the menu on the right to navigate the index of each of the tutorial’s parts. Use the next and previous links at the top/bottom right of each page to jump between lessons.

+
+

Things You Need

+
    +
  • A command line interface

  • +
  • A MUD client (or web browser)

  • +
  • A text-editor/IDE

  • +
  • Evennia installed and a game-dir initialized

  • +
+
+

A Command Line Interface

+

You need to know how to find the terminal/console in your OS. The Evennia server can be controlled from in-game, but you will realistically need to use the command-line interface to get anywhere. Here are some starters:

+ +
+

Note that the documentation typically uses forward-slashes (/) for file system paths. Windows users should convert these to back-slashes (\) instead.

+
+
+
+

A Fresh Game-Dir?

+

You should make sure that you have successfully installed Evennia. If you followed the instructions, you will have already created a game-dir. The documentation will continue to refer to this game-dir as mygame, so you may want to re-use it or make a new one specific to this tutorial only – it’s up to you.

+

If you already have a game-dir and want a new one specific to this tutorial, use the evennia stop command to halt the running server. Then, initialize a new game-dir somewhere else (not inside the previous game-dir!).

+
+
+

A MUD Client

+

You may already have a preferred MUD client. Check out the grid of supported clients. Or, if telnet’s not your thing, you may also simply use Evennia’s web-client in your preferred browser.

+

Make sure you know how to connect and log in to your locally running Evennia server.

+
+

In this documentation we often interchangeably use the terms ‘MUD’, ‘MU’, and ‘MU*’ to represent all the historically different forms of text-based multiplayer game-styles (i.e., MUD, MUX, MUSH, MUCK, MOO, etc.). Evennia can be used to create any of these game-styles… and more!

+
+
+
+

A Text Editor or IDE

+

You need a text editor application to edit Python source files. Most anything that can edit and output raw text should work (…so not Microsoft Word).

+ +
+

Important

+

Use Spaces, Not Tabs< br/> +Make sure to configure your text editor so that pressing the ‘Tab’ key inserts 4 spaces rather than a tab-character. Because Python is whitespace-aware, this simple practice will make your life much easier.

+
+

You should now be ready to move on to the first part of the Beginner Tutorial! (In the future, use the previous | next buttons on the top/bottom of the page to progress.)

+
+ +Click here to see the full index of all parts and lessons of the Beginner-Tutorial. + +
+ +
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.html new file mode 100644 index 0000000000..a9a9651e77 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.html @@ -0,0 +1,563 @@ + + + + + + + + + 8. Adding custom commands — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

8. Adding custom commands

+

In this lesson we’ll learn how to create our own Evennia Commands If you are new to Python you’ll also learn some more basics about how to manipulate strings and get information out of Evennia.

+

A Command is something that handles the input from a user and causes a result to happen. +An example is look, which examines your current location and tells you what it looks like and +what is in it.

+ +

In Evennia, a Command is a Python class. If you are unsure about what a class is, review the +previous lesson about it! A Command inherits from evennia.Command or from one of the alternative command- classes, such as MuxCommand which is what most default commands use.

+

All Commands are grouped in another class called a Command Set. Think of a Command Set as a bag holding many different commands. One CmdSet could for example hold all commands for combat, another for building etc.

+

Command-Sets are then associated with objects, for example with your Character. Doing so makes the commands in that cmdset available to the object. By default, Evennia groups all character-commands into one big cmdset called the CharacterCmdSet. It sits on DefaultCharacter (and thus, through inheritance, on typeclasses.characters.Character).

+
+

8.1. Creating a custom command

+

Open mygame/commands/command.py:

+
"""
+(module docstring)
+"""
+
+from evennia import Command as BaseCommand
+# from evennia import default_cmds
+
+class Command(BaseCommand):
+    """
+    (class docstring)
+    """
+    pass
+
+# (lots of commented-out stuff)
+# ...
+
+
+

Ignoring the docstrings (which you can read if you want), this is the only really active code in the module.

+

We can see that we import Command from evennia and use the from ... import ... as ... form to rename it to BaseCommand. This is so we can let our child class also be named Command to make it easier to reference. The class itself doesn’t do anything, it just has pass. So in the same way as Object and Character in the previous lessons, this class is identical to its parent.

+
+

The commented out default_cmds gives us access to Evennia’s default commands for easy overriding. We’ll try that a little later.

+
+

We could modify this module directly, but let’s work in a separate module just for the heck of it. Open a new file mygame/commands/mycommands.py and add the following code:

+
# in mygame/commands/mycommands.py
+
+from commands.command import Command
+
+class CmdEcho(Command):
+    key = "echo"
+
+
+
+

This is the simplest form of command you can imagine. It just gives itself a name, “echo”. This is what you will use to call this command later.

+

Next we need to put this in a CmdSet. It will be a one-command CmdSet for now! Change your file as such:

+
# in mygame/commands/mycommands.py
+
+from commands.command import Command
+from evennia import CmdSet
+
+class CmdEcho(Command):
+    key = "echo"
+
+
+class MyCmdSet(CmdSet):
+
+    def at_cmdset_creation(self):
+        self.add(CmdEcho)
+
+
+
+

Our MyCmdSet class must have an at_cmdset_creation method, named exactly like this - this is what Evennia will be looking for when setting up the cmdset later, so if you didn’t set it up, it will use the parent’s version, which is empty. Inside we add the command class to the cmdset by self.add(). If you wanted to add more commands to this CmdSet you could just add more lines of self.add after this.

+

Finally, let’s add this command to ourselves so we can try it out. In-game you can experiment with py again:

+
> py me.cmdset.add("commands.mycommands.MyCmdSet")
+
+
+

The me.cmdset is the store of all cmdsets stored on us. By giving the path to our CmdSet class, it will be added.

+

Now try

+
> echo
+Command echo has no defined `func()` - showing on-command variables:
+...
+...
+
+
+

echo works! You should be getting a long list of outputs. The reason for this is that your echo function is not really “doing” anything yet and the default function is then to show all useful resources available to you when you use your Command. Let’s look at some of those listed:

+
Command echo has no defined `func()` - showing on-command variables:
+obj (<class 'typeclasses.characters.Character'>): YourName
+lockhandler (<class 'evennia.locks.lockhandler.LockHandler'>): cmd:all()
+caller (<class 'typeclasses.characters.Character'>): YourName
+cmdname (<class 'str'>): echo
+raw_cmdname (<class 'str'>): echo
+cmdstring (<class 'str'>): echo
+args (<class 'str'>):
+cmdset (<class 'evennia.commands.cmdset.CmdSet'>): @mail, about, access, accounts, addcom, alias, allcom, ban, batchcode, batchcommands, boot, cboot, ccreate,
+    cdesc, cdestroy, cemit, channels, charcreate, chardelete, checklockstring, clientwidth, clock, cmdbare, cmdsets, color, copy, cpattr, create, cwho, delcom,
+    desc, destroy, dig, dolphin, drop, echo, emit, examine, find, force, get, give, grapevine2chan, help, home, ic, inventory, irc2chan, ircstatus, link, lock,
+    look, menutest, mudinfo, mvattr, name, nick, objects, ooc, open, option, page, password, perm, pose, public, py, quell, quit, reload, reset, rss2chan, say,
+    script, scripts, server, service, sessions, set, setdesc, sethelp, sethome, shutdown, spawn, style, tag, tel, test2010, test2028, testrename, testtable,
+    tickers, time, tunnel, typeclass, unban, unlink, up, up, userpassword, wall, whisper, who, wipe
+session (<class 'evennia.server.serversession.ServerSession'>): Griatch(#1)@1:2:7:.:0:.:0:.:1
+account (<class 'typeclasses.accounts.Account'>): Griatch(account 1)
+raw_string (<class 'str'>): echo
+
+--------------------------------------------------
+echo - Command variables from evennia:
+--------------------------------------------------
+name of cmd (self.key): echo
+cmd aliases (self.aliases): []
+cmd locks (self.locks): cmd:all();
+help category (self.help_category): General
+object calling (self.caller): Griatch
+object storing cmdset (self.obj): Griatch
+command string given (self.cmdstring): echo
+current cmdset (self.cmdset): ChannelCmdSet
+
+
+

These are all properties you can access with . on the Command instance, such as .key, .args and so on. Evennia makes these available to you and they will be different every time a command is run. The most important ones we will make use of now are:

+
    +
  • caller - this is ‘you’, the person calling the command.

  • +
  • args - this is all arguments to the command. Now it’s empty, but if you tried echo foo bar you’d find that this would be " foo bar".

  • +
  • obj - this is object on which this Command (and CmdSet) “sits”. So you, in this case.

  • +
+

The reason our command doesn’t do anything yet is because it’s missing a func method. This is what Evennia looks for to figure out what a Command actually does. Modify your CmdEcho class:

+
# in mygame/commands/mycommands.py
+# ...
+
+class CmdEcho(Command):
+    """
+    A simple echo command
+
+    Usage:
+        echo <something>
+
+    """
+    key = "echo"
+
+    def func(self):
+        self.caller.msg(f"Echo: '{self.args}'")
+
+# ...
+
+
+

First we added a docstring. This is always a good thing to do in general, but for a Command class, it will also automatically become the in-game help entry!

+ +

Next we add the func method. It has one active line where it makes use of some of those variables the Command class offers to us. If you did the basic Python tutorial, you will recognize .msg - this will send a message to the object it is attached to us - in this case self.caller, that is, us. We grab self.args and includes that in the message.

+

Since we haven’t changed MyCmdSet, that will work as before. Reload and re-add this command to ourselves to try out the new version:

+
> reload
+> py self.cmdset.add("commands.mycommands.MyCmdSet")
+> echo
+Echo: ''
+
+
+

Try to pass an argument:

+
> echo Woo Tang!
+Echo: ' Woo Tang!'
+
+
+

Note that there is an extra space before Woo. That is because self.args contains everything after the command name, including spaces. Let’s remove that extra space with a small tweak:

+
# in mygame/commands/mycommands.py
+# ...
+
+class CmdEcho(Command):
+    """
+    A simple echo command
+
+    Usage:
+        echo <something>
+
+    """
+    key = "echo"
+
+    def func(self):
+        self.caller.msg(f"Echo: '{self.args.strip()}'")
+
+# ...
+
+
+

The only difference is that we called .strip() on self.args. This is a helper method available on all strings - it strips out all whitespace before and after the string. Now the Command-argument will no longer have any space in front of it.

+
> reload
+> py self.cmdset.add("commands.mycommands.MyCmdSet")
+> echo Woo Tang!
+Echo: 'Woo Tang!'
+
+
+

Don’t forget to look at the help for the echo command:

+
> help echo
+
+
+

You will get the docstring you put in your Command-class!

+
+

8.1.1. Making our cmdset persistent

+

It’s getting a little annoying to have to re-add our cmdset every time we reload, right? It’s simple +enough to make echo a persistent change though:

+
> py self.cmdset.add("commands.mycommands.MyCmdSet", persistent=True)
+
+
+

Now you can reload as much as you want and your code changes will be available directly without needing to re-add the MyCmdSet again.

+

We will add this cmdset in another way, so remove it manually:

+
> py self.cmdset.remove("commands.mycommands.MyCmdSet")
+
+
+
+
+

8.1.2. Add the echo command to the default cmdset

+

Above we added the echo command to ourselves. It will only be available to us and noone else in the game. But all commands in Evennia are part of command-sets, including the normal look and py commands we have been using all the while. You can easily extend the default command set with your echo command - this way everyone in the game will have access to it!

+

In mygame/commands/ you’ll find an existing module named default_cmdsets.py Open it and you’ll find four empty cmdset-classes:

+
    +
  • CharacterCmdSet - this sits on all Characters (this is the one we usually want to modify)

  • +
  • AccountCmdSet - this sits on all Accounts (shared between Characters, like logout etc)

  • +
  • UnloggedCmdSet - commands available before you login, like the commands for creating your password and connecting to the game.

  • +
  • SessionCmdSet - commands unique to your Session (your particular client connection). This is unused by default.

  • +
+

Tweak this file as follows:

+
# in mygame/commands/default_cmdsets.py 
+
+# ,.. 
+
+from . import mycommands    # <-------  
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    """
+    The `CharacterCmdSet` contains general in-game commands like `look`,
+    `get`, etc available on in-game Character objects. It is merged with
+    the `AccountCmdSet` when an Account puppets a Character.
+    """
+ 
+    key = "DefaultCharacter"
+ 
+    def at_cmdset_creation(self):
+        """
+        Populates the cmdset
+        """
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones.
+        #
+        self.add(mycommands.CmdEcho)    # <-----------
+
+# ... 
+
+
+ +

This works the same way as when you added CmdEcho to your MyCmdSet. The only difference cmdsets are automatically added to all Characters/Accounts etc so you don’t have to do so manually. We must also make sure to import the CmdEcho from your mycommands module in order for this module to know about it. The period ‘’.‘’ in from . import mycommands means that we are telling Python that mycommands.py sits in the same directory as this current module. We want to import the entire module. Further down we access mycommands.CmdEcho to add it to the character cmdset.

+

Just reload the server and your echo command will be available again. There is no limit to how many cmdsets a given Command can be a part of.

+

To remove, you just comment out or delete the self.add() line. Keep it like this for now though - we’ll expand on it below.

+
+
+

8.1.3. Figuring out who to hit

+

Let’s try something a little more exciting than just echo. Let’s make a hit command, for punching someone in the face! This is how we want it to work:

+
> hit <target>
+You hit <target> with full force!
+
+
+

Not only that, we want the <target> to see

+
You got hit by <hitter> with full force!
+
+
+

Here, <hitter> would be the one using the hit command and <target> is the one doing the punching; so if your name was Anna, and you hit someone named Bob, this would look like this:

+
> hit bob
+You hit Bob with full force!
+
+
+

And Bob would see

+
You got hit by by Anna with full force!
+
+
+

Still in mygame/commands/mycommands.py, add a new class, between CmdEcho and MyCmdSet.

+
# in mygame/commands/mycommands.py
+
+:linenos:
+:emphasize-lines: 3,4,11,14,15,17,18,19,21
+
+# ...
+
+class CmdHit(Command):
+    """
+    Hit a target.
+
+    Usage:
+      hit <target>
+
+    """
+    key = "hit"
+
+    def func(self):
+        args = self.args.strip()
+        if not args:
+            self.caller.msg("Who do you want to hit?")
+            return
+        target = self.caller.search(args)
+        if not target:
+            return
+        self.caller.msg(f"You hit {target.key} with full force!")
+        target.msg(f"You got hit by {self.caller.key} with full force!")
+# ...
+
+
+

A lot of things to dissect here:

+
    +
  • Line 3: The normal class header. We inherit from Command which we imported at the top of this file.

  • +
  • Lines 4-10: The docstring and help-entry for the command. You could expand on this as much as you wanted.

  • +
  • Line 11: We want to write hit to use this command.

  • +
  • Line 14: We strip the whitespace from the argument like before. Since we don’t want to have to do +self.args.strip() over and over, we store the stripped version +in a local variable args. Note that we don’t modify self.args by doing this, self.args will still +have the whitespace and is not the same as args in this example.

  • +
+ +
    +
  • Line 15 has our first conditional, an if statement. This is written on the form if <condition>: and only if that condition is ‘truthy’ will the indented code block under the if statement run. To learn what is truthy in Python it’s usually easier to learn what is “falsy”:

    +
      +
    • False - this is a reserved boolean word in Python. The opposite is True.

    • +
    • None - another reserved word. This represents nothing, a null-result or value.

    • +
    • 0 or 0.0

    • +
    • The empty strings "", '', or empty triple-strings like """""", ''''''

    • +
    • Empty iterables we haven’t used yet, like empty lists [], empty tuples () and empty dicts {}.

    • +
    • Everything else is “truthy”.

    • +
    +
  • +
  • Line 16’s condition is not args. The not inverses the result, so if args is the empty string (falsy), the whole conditional becomes truthy. Let’s continue in the code:

  • +
  • Lines 16-17: This code will only run if the if statement is truthy, in this case if args is the empty string.

  • +
  • Line 17: return is a reserved Python word that exits func immediately.

  • +
  • Line 18: We use self.caller.search to look for the target in the current location.

  • +
  • Lines 19-20: A feature of .search is that it will already inform self.caller if it couldn’t find the target. In that case, target will be None and we should just directly return.

  • +
  • Lines 21-22: At this point we have a suitable target and can send our punching strings to each.

  • +
+

Finally we must also add this to a CmdSet. Let’s add it to MyCmdSet.

+
# in mygame/commands/mycommands.py
+
+# ...
+class MyCmdSet(CmdSet):
+
+    def at_cmdset_creation(self):
+        self.add(CmdEcho)
+        self.add(CmdHit)
+
+
+
+ +

Note that since we did py self.cmdset.remove("commands.mycommands.MyCmdSet") earlier, this cmdset is no longer available on our Character. Instead we will add these commands directly to our default cmdset.

+
# in mygame/commands/default_cmdsets.py 
+
+# ,.. 
+
+from . import mycommands    
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    """
+    The `CharacterCmdSet` contains general in-game commands like `look`,
+    `get`, etc available on in-game Character objects. It is merged with
+    the `AccountCmdSet` when an Account puppets a Character.
+    """
+ 
+    key = "DefaultCharacter"
+ 
+    def at_cmdset_creation(self):
+        """
+        Populates the cmdset
+        """
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones.
+        #
+        self.add(mycommands.MyCmdSet)    # <-----------
+# ... 
+
+
+

We changed from adding the individual echo command to adding the entire MyCmdSet in one go! This will add all commands in that cmdset to the CharacterCmdSet and is a practical way to add a lot of command in one go. Once you explore Evennia further, you’ll find that Evennia contribs all distribute their new commands in cmdsets, so you can easily add them to your game like this.

+

Next we reload to let Evennia know of these code changes and try it out:

+
> reload
+hit
+Who do you want to hit?
+hit me
+You hit YourName with full force!
+You got hit by YourName with full force!
+
+
+

Lacking a target, we hit ourselves. If you have one of the dragons still around from the previous lesson you could try to hit it (if you dare):

+
hit smaug
+You hit Smaug with full force!
+
+
+

You won’t see the second string. Only Smaug sees that (and is not amused).

+
+
+
+

8.2. Summary

+

In this lesson we learned how to create our own Command, add it to a CmdSet and then to ourselves. We also upset a dragon.

+

In the next lesson we’ll learn how to hit Smaug with different weapons. We’ll also +get into how we replace and extend Evennia’s default Commands.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Building-Quickstart.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Building-Quickstart.html new file mode 100644 index 0000000000..9868fefa03 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Building-Quickstart.html @@ -0,0 +1,375 @@ + + + + + + + + + 1. Using Commands and Building Stuff — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

1. Using Commands and Building Stuff

+

In this lesson, we will test out what we can do in-game out-of-the-box. Evennia ships with +around 90 default commands and, while you can override those as you please, the defaults can be quite useful.

+

Connect and login to your new game. You will find yourself in the “Limbo” location. This +is the only room in the game at this point. Let’s explore the default commands a little.

+

The default commands have syntax similar to MUX:

+
 command[/switch/switch...] [arguments ...]
+
+
+

An example would be:

+
 create/drop box
+
+
+

A /switch is a special, optional flag to make a command behave differently. A switch is always put directly after the command name, and begins with a forward slash (/). The arguments are one or more inputs to the commands. It’s common to use an equal sign (=) when assigning something to an object.

+
+

Are you used to commands starting with @, like @create? That will work, too. Evennia simply ignores the preceeding @.

+
+
+

1.1. Getting Help

+
help
+
+
+

Will give you a list of all commands available to you. Use

+
help <commandname>
+
+
+

to see the in-game help for that command.

+
+
+

1.2. Looking Around

+

The most common command is

+
look
+
+
+

This will show you the description of the current location. l is an alias for the look command.

+

When targeting objects in commands, you have two special labels you can use: here for the current room, or me/self to point back to yourself. Thus,

+
look me
+
+
+

will give you your own description. look here is, in this case, the same as just plain look.

+
+
+

1.3. Stepping Down from Godhood

+

If you just installed Evennia, your very first player account is called user #1 — also known as the superuser or god user. This user is very powerful — so powerful that it will override many game restrictions (such as locks). This can be useful, but it also hides some functionality that you might want to test.

+

To step down temporarily from your superuser position, you may use the quell command in-game:

+
quell
+
+
+

This will make you start using the permission of your current character’s level instead of your superuser level. If you didn’t change any settings, your initial game Character should have Developer level permission — as high as can be without bypassing locks like the superuser does. This will work fine for the examples on this page. Use

+
unquell
+
+
+

to get superuser status again when you are done.

+
+
+

1.4. Creating an Object

+

Basic objects can be anything — swords, flowers, and non-player characters. They are created using the create command. For example:

+
create box
+
+
+

This creates a new ‘box’ (of the default object type) in your inventory. Use the command inventory (or i) to see it. Now, ‘box’ is a rather short name, so let’s rename it and tack on a few aliases:

+
name box = very large box;box;very;crate
+
+
+
+

Warning

+

MUD Clients and Semi-Colons: +Some traditional MUD clients use the semi-colon ; to separate client inputs. If so, the above line will give an error and you’ll need to change your client to use another command-separator or put it in ‘verbatim’ mode. If you still have trouble, use the Evennia web client instead.

+
+

We have now renamed the box as very large box — and this is what we will see when looking at it. However, we will also recognize it by any of the other names we have offered as arguments to the name command above (i.e., crate or simply box as before). We also could have given these aliases directly after the name in the initial create object command. This is true for all creation commands — you can always provide a list of ;-separated aliases to the name of your new object. In our example, if you had not wanted to change the box object’s name itself, but to add aliases only, you could use the alias command.

+

At this point in the building tutorial, our Character is currently carrying the box. Let’s drop it:

+
drop box
+
+
+

Hey presto, — there it is on the ground, in all its normality. There is also a shortcut to both create and drop an object in one go by using the /drop switch (e.g, create/drop box).

+

Let us take a closer look at our new box:

+
examine box
+
+
+

The examine command will show some technical details about the box object. For now, we will ignore what this +information means.

+

Try to look at the box to see the (default) description:

+
> look box
+You see nothing special.
+
+
+

The default description is not very exciting. Let’s add some flavor:

+
desc box = This is a large and very heavy box.
+
+
+

If you try the get command, we will pick up the box. So far so good. But, if we really want this to be a large and heavy box, people should not be able to run off with it so easily. To prevent this, we must lock it down. This is done by assigning a lock to it. TO do so, first make sure the box was dropped in the room, then use the lock command:

+
lock box = get:false()
+
+
+

Locks represent a rather big topic but, for now, this will do what we want. The above command will lock the box so no one can lift it — with one exception. Remember that superusers override all locks and will pick it up anyway. Make sure you are quelling your superuser powers, and try to get it again:

+
> get box
+You can't get that.
+
+
+

Think this default error message looks dull? The get command looks for an Attribute named get_err_msg to return as a custom error message. We set attributes using the set command:

+
set box/get_err_msg = It's way too heavy for you to lift.
+
+
+

Now try to get the box and you should see a more pertinent error message echoed back to you. To see what this message string is in the future, you can use ‘examine’.

+
examine box/get_err_msg
+
+
+

Examine will return the value of attributes, including color codes. For instance, examine here/desc would return the raw description of the current room (including color codes), so that you can copy-and-paste to set its description to something else.

+

You create new Commands — or modify existing ones — in Python code outside the game. We explore doing so later in the Commands tutorial.

+
+
+

1.5. Get a Personality

+

Scripts are powerful out-of-character objects useful for many “under the hood” things. One of their optional abilities is to do things on a timer. To try out our first script, let’s apply one to ourselves. There is an example script in evennia/contrib/tutorials/bodyfunctions/bodyfunctions.py that is called BodyFunctions. To add this to our self, we may use the script command:

+
script self = tutorials.bodyfunctions.BodyFunctions
+
+
+

The above string tells Evennia to dig up the Python code at the place we indicate. It already knows to look in the contrib/ folder, so we don’t have to give the full path.

+
+

Note also how we use . instead of / (or \ on Windows). This convention is a so-called “Python-path.” In a Python-path, you separate the parts of the path with . and skip the .py file-ending. Importantly, it also allows you to point to Python code inside files as in our example where the BodyFunctions class is inside the bodyfunctions.py file. We’ll get to classes later. These “Python-paths” are used extensively throughout Evennia.

+
+

Wait a while and you will notice yourself starting to make random observations…

+
script self =
+
+
+

The above command will show details about scripts on the given object, in this case your self. The examine command also includes such details.

+

You will see how long it is until it “fires” next. Don’t be alarmed if nothing happens when the countdown reaches zero — this particular script has a randomizer to determine if it will say something or not. So you will not see output every time it fires.

+

When you are tired of your character’s “insights,” stop the script with:

+
script/stop self = tutorials.bodyfunctions.BodyFunctions
+
+
+

You may create your own scripts in Python, outside the game; the path you give to script is literally the Python path to your script file. The Scripts page explains more details.

+
+
+

1.6. Pushing Your Buttons

+

If we get back to the box we made, there is only so much fun you can have with it at this point. It’s just a dumb generic object. If you renamed it to stone and changed its description, no one would be the wiser. However, with the combined use of custom Typeclasses, Scripts and object-based Commands, you can expand it — and other items — to be as unique, complex, and interactive as you want.

+

So, let’s work though just such an example. So far, we have only created objects that use the default object typeclass named simply Object. Let’s create an object that is a little more interesting. Under +evennia/contrib/tutorials there is a module red_button.py. It contains the enigmatic RedButton class.

+

Let’s make us one of those!

+
create/drop button:tutorials.red_button.RedButton
+
+
+

Enter the above command with Python-path and there you go — one red button! Just as in the Script example earlier, we have specified a Python-path to the Python code that we want Evennia to use for creating the object.

+

The RedButton is an example object intended to show off a few of Evennia’s features. You will find that the Typeclass and Commands controlling it are inside evennia/contrib/tutorials/red_button.

+

If you wait for a while (make sure you dropped it!) the button will blink invitingly.

+

Why don’t you try to push it…?

+

Surely a big red button is meant to be pushed.

+

You know you want to.

+
+

Warning

+

Don’t press the invitingly blinking red button.

+
+
+
+

1.7. Making Yourself a House

+

The main command for shaping your game world is dig. For example, if you are standing in Limbo, you can dig a route to your new house location like this:

+
dig house = large red door;door;in,to the outside;out
+
+
+

The above command will create a new room named “house.” It will also create an exit from your current location named ‘large red door’ and a corresponding exit named ‘to the outside’ in the new house room leading back to Limbo. In above, we also define a few aliases to those exits so that players don’t need to type the full exit name.

+

If you wanted to use regular compass directions (north, west, southwest, etc.), you could do that with dig, too. However, Evennia also has a specialized version of dig that helps with cardinal directions (as well as up/down and in/out). It’s called tunnel:

+
tunnel sw = cliff
+
+
+

This will create a new room named “cliff” with a “southwest” exit leading there, and a “northeast” path leading back from the cliff to your current location.

+

You can create new exits from where you are standing, using the open command:

+
open north;n = house
+
+
+

This opens an exit north (with an alias n) to the previously created room house.

+

If you have many rooms named house, you will get a list of matches and must select to which specific one you want to link.

+

Next, follow the northern exit to your “house” by walking north. You can also teleport to it:

+
north
+
+
+

or:

+
teleport house
+
+
+

To open an exit back to Limbo manually (in case you didn’t do so automatically by using the dig command):

+
open door = limbo
+
+
+

(You can also use the #dbref of Limbo, which you can find by using examine here when standing in Limbo.)

+
+
+

1.8. Reshuffling the World

+

Assuming you are back at Limbo, let’s teleport the large box to our house:

+
teleport box = house
+    very large box is leaving Limbo, heading for house.
+    Teleported very large box -> house.
+
+
+

You can find things in the game world, such as our box, by using the find command:

+
find box
+    One Match(#1-#8):
+    very large box(#8) - src.objects.objects.Object
+
+
+

Knowing the #dbref of the box (#8 in this example), you can grab the box and get it back here without actually going to the house first:

+
teleport #8 = here
+
+
+

As mentioned, here is an alias for “your current location.” The box should now be back in Limbo with you.

+

We are getting tired of the box. Let’s destroy it:

+
destroy box
+
+
+

Issuing the `destroy`` command will ask you for confirmation. Once you confirm, the box will be gone.

+

You can destroy many objects in one go by providing a comma-separated list of objects (or a range of #dbrefs, if they are not in the same location) to the command.

+
+
+

1.9. Adding a Help Entry

+

Command-related help entries are something that you modify in Python code — we’ll cover that when we explain how to add Commands — but you may also add non-command-related help entries. For example, to explain something about the history of your game world:

+
sethelp History = At the dawn of time ...
+
+
+

You will now find your new History entry in the help list, and can read your help-text with help History.

+
+
+

1.10. Adding a World

+

After this brief introduction to building and using in-game commands, you may be ready to see a more fleshed-out example. Fortunately, Evennia comes with an tutorial world for you to explore — which we will try in the next lesson.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Creating-Things.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Creating-Things.html new file mode 100644 index 0000000000..cef86d5401 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Creating-Things.html @@ -0,0 +1,337 @@ + + + + + + + + + 10. Creating things — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

10. Creating things

+

We have already created some things - dragons for example. There are many different things to create in Evennia though. In the Typeclasses tutorial, we noted that there are 7 default Typeclasses coming with Evennia out of the box:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Evennia base typeclass

mygame.typeclasses child

description

evennia.DefaultObject

typeclasses.objects.Object

Everything with a location

evennia.DefaultCharacter (child of DefaultObject)

typeclasses.characters.Character

Player avatars

evennia.DefaultRoom (child of DefaultObject)

typeclasses.rooms.Room

In-game locations

evennia.DefaultExit (chld of DefaultObject)

typeclasses.exits.Exit

Links between rooms

evennia.DefaultAccount

typeclasses.accounts.Account

A player account

evennia.DefaultChannel

typeclasses.channels.Channel

In-game comms

evennia.DefaultScript

typeclasses.scripts.Script

Entities with no location

+

Given you have an imported Typeclass, there are four ways to create an instance of it:

+
    +
  • Firstly, you can call the class directly, and then .save() it:

    +
      obj = SomeTypeClass(db_key=...)
    +  obj.save()
    +
    +
    +

    This has the drawback of being two operations; you must also import the class and have to pass +the actual database field names, such as db_key instead of key as keyword arguments. This is closest to how a ‘normal’ Python class works, but is not recommended.

    +
  • +
  • Secondly you can use the Evennia creation helpers:

    +
      obj = evennia.create_object(SomeTypeClass, key=...)
    +
    +
    +

    This is the recommended way if you are trying to create things in Python. The first argument can either be the class or the python-path to the typeclass, like "path.to.SomeTypeClass". It can also be None in which case the Evennia default will be used. While all the creation methods +are available on evennia, they are actually implemented in evennia/utils/create.py. Each of the different base classes have their own creation function, like create_account and create_script etc.

    +
  • +
  • Thirdly, you can use the .create method on the Typeclass itself:

    +
    obj, err = SomeTypeClass.create(key=...)
    +
    +
    +

    Since .create is a method on the typeclass, this form is useful if you want to customize how the creation process works for your custom typeclasses. Note that it returns two values - the obj is either the new object or None, in which case err should be a list of error-strings detailing what went wrong.

    +
  • +
  • Finally, you can create objects using an in-game command, such as

    +
      create obj:path.to.SomeTypeClass
    +
    +
    +

    As a developer you are usually best off using the other methods, but a command is usually the only way to let regular players or builders without Python-access help build the game world.

    +
  • +
+
+

10.1. Creating Objects

+

An Object is one of the most common creation-types. These are entities that inherits from DefaultObject at any distance. They have an existence in the game world and includes rooms, characters, exits, weapons, flower pots and castles.

+
> py
+> import evennia 
+> rose = evennia.create_object(key="rose")
+
+
+

Since we didn’t specify the typeclass as the first argument, the default given by settings.BASE_OBJECT_TYPECLASS (typeclasses.objects.Object out of the box) will be used.

+

The create_object has a lot of options. A more detailed example in code:

+
from evennia import create_object, search_object
+
+meadow = search_object("Meadow")[0]
+
+lasgun = create_object("typeclasses.objects.guns.LasGun", 
+					   key="lasgun", 
+					   location=meadow,
+					   attributes=[("desc", "A fearsome Lasgun.")])
+
+
+
+

Here we set the location of a weapon as well as gave it an Attribute desc, which is what the look command will use when looking this and other things.

+
+
+

10.2. Creating Rooms, Characters and Exits

+

Characters, Rooms and Exits are all subclasses of DefaultObject. So there is for example no separate create_character, you just create characters with create_object pointing to the Character typeclass.

+
+

10.2.1. Linking Exits and Rooms in code

+

An Exit is a one-way link between rooms. For example, east could be an Exit between the Forest room and the Meadow room.

+
Meadow -> east -> Forest 
+
+
+

The east exit has a key of east, a location of Meadow and a destination of Forest. If you wanted to be able to go back from Forest to Meadow, you’d need to create a new Exit, say, west, where location is Forest and destination is Meadow.

+
Meadow -> east -> Forest 
+Forest -> west -> Meadow
+
+
+

In-game you do this with tunnel and dig commands, bit if you want to ever set up these links in code, you can do it like this:

+
from evennia import create_object 
+from mygame.typeclasses import rooms, exits 
+
+# rooms
+meadow = create_object(rooms.Room, key="Meadow")
+forest = create_object(rooms.Room, key="Forest")
+
+# exits 
+create_object(exits.Exit, key="east", location=meadow, destination=forest)
+create_object(exits.Exit, key="west", location=forest, destination=meadow)
+
+
+
+
+
+

10.3. Creating Accounts

+

An Account is an out-of-character (OOC) entity, with no existence in the game world. +You can find the parent class for Accounts in typeclasses/accounts.py.

+

Normally, you want to create the Account when a user authenticates. By default, this happens in the create account and login default commands in the UnloggedInCmdSet. This means that customizing this just means replacing those commands!

+

So normally you’d modify those commands rather than make something from scratch. But here’s the principle:

+
from evennia import create_account 
+
+new_account = create_account(
+            accountname, email, password, 
+            permissions=["Player"], 
+            typeclass="typeclasses.accounts.MyAccount"
+ )
+
+
+

The inputs are usually taken from the player via the command. The email must be given, but can be None if you are not using it. The accountname must be globally unique on the server. The password is stored encrypted in the database. If typeclass is not given, the settings.BASE_ACCOUNT_TYPECLASS will be used (typeclasses.accounts.Account).

+
+
+

10.4. Creating Channels

+

A Channel acts like a switchboard for sending in-game messages between users; like an IRC- or discord channel but inside the game.

+

Users interact with channels via the channel command:

+
channel/all 
+channel/create channelname 
+channel/who channelname 
+channel/sub channel name 
+...
+(see 'help channel')
+
+
+

If a channel named, say, myguild exists, a user can send a message to it just by writing the channel name:

+
> myguild Hello! I have some questions ... 
+
+
+

Creating channels follows a familiar syntax:

+
from evennia import create_channel
+
+new_channel = create_channel(channelname)
+
+
+

Channels can also be auto-created by the server by setting the DEFAULT_CHANNELS setting. See Channels documentation for details.

+
+
+

10.5. Creating Scripts

+

A Script is an entity that has no in-game location. It can be used to store arbitrary data and is often used for game systems that need persistent storage but which you can’t ‘look’ at in-game. Examples are economic systems, weather and combat handlers.

+

Scripts are multi-use and depending on what they do, a given script can either be ‘global’ or be attached “to” another object (like a Room or Character).

+
from evennia import create_script, search_object 
+# global script 
+new_script = create_script("typeclasses.scripts.MyScript", key="myscript")
+
+# on-object script 
+meadow = search_object("Meadow")[0]
+new_script = create_script("typeclasses.scripts.MyScripts", 
+						   key"myscript2", obj=meadow)
+
+
+
+

A convenient way to create global scripts is define them in the GLOBAL_SCRIPTS setting; Evennia will then make sure to initialize them. Scripts also have an optional ‘timer’ component. See the dedicated Script documentation for more info.

+
+
+

10.6. Conclusion

+

Any game will need peristent storage of data. This was a quick run-down of how to create each default type of typeclassed entity. If you make your own typeclasses (as children of the default ones), you create them in the same way.

+

Next we’ll learn how to find them again by searching for them in the database.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Django-queries.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Django-queries.html new file mode 100644 index 0000000000..b92b897480 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Django-queries.html @@ -0,0 +1,540 @@ + + + + + + + + + 12. Advanced searching - Django Database queries — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

12. Advanced searching - Django Database queries

+
+

Important

+

More advanced lesson!

+

Learning about Django’s query language is very useful once you start doing more +advanced things in Evennia. But it’s not strictly needed out the box and can be +a little overwhelming for a first reading. So if you are new to Python and +Evennia, feel free to just skim this lesson and refer back to it later when +you’ve gained more experience.

+
+

The search functions and methods we used in the previous lesson are enough for most cases. +But sometimes you need to be more specific:

+
    +
  • You want to find all Characters

  • +
  • … who are in Rooms tagged as moonlit

  • +
  • and who has the Attribute lycantrophy with a level higher than 2 …

  • +
  • … because they should immediately transform to werewolves!

  • +
+

In principle you could achieve this with the existing search functions combined with a lot of loops +and if statements. But for something non-standard like this, querying the database directly will be +much more efficient.

+

Evennia uses Django to handle its connection to the database. +A django queryset represents a database query. One can add querysets together to build ever-more complicated queries. Only when you are trying to use the results of the queryset will it actually call the database.

+

The normal way to build a queryset is to define what class of entity you want to search by getting its .objects resource, and then call various methods on that. We’ve seen variants of this before:

+
all_weapons = Weapon.objects.all()
+
+
+

This is now a queryset representing all instances of Weapon. If Weapon had a subclass Cannon and we only wanted the cannons, we would do

+
all_cannons = Cannon.objects.all()
+
+
+

Note that Weapon and Cannon are different typeclasses. This means that you won’t find any Weapon-typeclassed results in all_cannons. Vice-versa, you won’t find any Cannon-typeclassed results in all_weapons. This may not be what you expect.

+

If you want to get all entities with typeclass Weapon as well as all the subclasses of Weapon, such as Cannon, you need to use the _family type of query:

+ +
really_all_weapons = Weapon.objects.all_family()
+
+
+

This result now contains both Weapon and Cannon instances (and any other +entities whose typeclasses inherit at any distance from Weapon, like Musket or +Sword).

+

To limit your search by other criteria than the Typeclass you need to use .filter +(or .filter_family) instead:

+
roses = Flower.objects.filter(db_key="rose")
+
+
+

This is a queryset representing all flowers having a db_key equal to "rose". +Since this is a queryset you can keep adding to it; this will act as an AND condition.

+
local_roses = roses.filter(db_location=myroom)
+
+
+

We could also have written this in one statement:

+
local_roses = Flower.objects.filter(db_key="rose", db_location=myroom)
+
+
+

We can also .exclude something from results

+
local_non_red_roses = local_roses.exclude(db_key="red_rose")
+
+
+

It’s important to note that we haven’t called the database yet! Not until we +actually try to examine the result will the database be called. Here the +database is called when we try to loop over it (because now we need to actually +get results out of it to be able to loop):

+
for rose in local_non_red_roses:
+    print(rose)
+
+
+

From now on, the queryset is evaluated and we can’t keep adding more queries to it - we’d need to create a new queryset if we wanted to find some other result. Other ways to evaluate the queryset is to print it, convert it to a list with list() and otherwise try to access its results.

+ +

Note how we use db_key and db_location. This is the actual names of these database fields. By convention Evennia uses db_ in front of every database field. When you use the normal Evennia search helpers and objects you can skip the db_ but here we are calling the database directly and need to use the ‘real’ names.

+

Here are the most commonly used methods to use with the objects managers:

+
    +
  • filter - query for a listing of objects based on search criteria. Gives empty queryset if none +were found.

  • +
  • get - query for a single match - raises exception if none were found, or more than one was +found.

  • +
  • all - get all instances of the particular type.

  • +
  • filter_family - like filter, but search all subclasses as well.

  • +
  • get_family - like get, but search all subclasses as well.

  • +
  • all_family - like all, but return entities of all subclasses as well.

  • +
+
+

All of Evennia search functions use querysets under the hood. The evennia.search_* functions actually return querysets (we have just been treating them as lists so far). This means you could in principle add a .filter query to the result of evennia.search_object to further refine the search.

+
+
+

12.1. Queryset field lookups

+

Above we found roses with exactly the db_key "rose". This is an exact match that is case sensitive, +so it would not find "Rose".

+
# this is case-sensitive and the same as =
+roses = Flower.objects.filter(db_key__exact="rose"
+
+# the i means it's case-insensitive
+roses = Flower.objects.filter(db_key__iexact="rose")
+
+
+

The Django field query language uses __ similarly to how Python uses . to access resources. This +is because . is not allowed in a function keyword.

+
roses = Flower.objects.filter(db_key__icontains="rose")
+
+
+

This will find all flowers whose name contains the string "rose", like "roses", "wild rose" etc. The i in the beginning makes the search case-insensitive. Other useful variations to use +are __istartswith and __iendswith. You can also use __gt, __ge for “greater-than”/“greater-or-equal-than” comparisons (same for __lt and __le). There is also __in:

+
swords = Weapons.objects.filter(db_key__in=("rapier", "two-hander", "shortsword"))
+
+
+

One also uses __ to access foreign objects like Tags. Let’s for example assume +this is how we have identified mages:

+
char.tags.add("mage", category="profession")
+
+
+

Now, in this case we already have an Evennia helper to do this search:

+
mages = evennia.search_tags("mage", category="profession")
+
+
+

Here is what it would look as a query if you were only looking for Vampire mages:

+ +
sparkly_mages = (
+	Vampire.objects.filter(									   
+           db_tags__db_key="mage", 
+           db_tags__db_category="profession")
+    )
+
+
+

This looks at the db_tags field on the Vampire and filters on the values of each tag’s +db_key and db_category together.

+

For more field lookups, see the django docs on the subject.

+
+
+

12.2. Let’s get that werewolf …

+

Let’s see if we can make a query for the werewolves in the moonlight we mentioned at the beginning +of this lesson.

+

Firstly, we make ourselves and our current location match the criteria, so we can test:

+
> py here.tags.add("moonlit")
+> py me.db.lycantrophy = 3
+
+
+

This is an example of a more complex query. We’ll consider it an example of what is +possible.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
from typeclasses.characters import Character
+
+will_transform = (
+    Character.objects
+    .filter(
+        db_location__db_tags__db_key__iexact="moonlit",
+        db_attributes__db_key="lycantrophy",
+        db_attributes__db_value__gt=2
+    )
+)
+
+
+ +
    +
  • Line 4 We want to find Characters, so we access .objects on the Character typeclass.

  • +
  • We start to filter …

    +
      +
    • Line 6: … by accessing the db_location field (usually this is a Room)

      +
        +
      • … and on that location, we get the value of db_tags (this is a many-to-many database field +that we can treat like an object for this purpose; it references all Tags on the location)

      • +
      • … and from those Tags, we looking for Tags whose db_key is “monlit” (non-case sensitive).

      • +
      +
    • +
    • Line 7: … We also want only Characters with Attributes whose db_key is exactly "lycantrophy"

    • +
    • Line 8 :… at the same time as the Attribute’s db_value is greater-than 2.

    • +
    +
  • +
+

Running this query makes our newly lycantrophic Character appear in will_transform so we +know to transform it. Success!

+
+
+

12.3. Queries with OR or NOT

+

All examples so far used AND relations. The arguments to .filter are added together with AND +(“we want tag room to be “monlit” and lycantrhopy be > 2”).

+

For queries using OR and NOT we need Django’s Q object. It is imported from Django directly:

+
from django.db.models import Q
+
+
+

The Q is an object that is created with the same arguments as .filter, for example

+
Q(db_key="foo")
+
+
+

You can then use this Q instance as argument in a filter:

+
q1 = Q(db_key="foo")
+Character.objects.filter(q1)
+# this is the same as 
+Character.objects.filter(db_key="foo")
+
+
+

The useful thing about Q is that these objects can be chained together with special symbols (bit operators): | for OR and & for AND. A tilde ~ in front negates the expression inside the Q and thus +works like NOT.

+
q1 = Q(db_key="Dalton")
+q2 = Q(db_location=prison)
+Character.objects.filter(q1 | ~q2)
+
+
+

Would get all Characters that are either named “Dalton” or which is not in prison. The result is a mix +of Daltons and non-prisoners.

+

Let us expand our original werewolf query. Not only do we want to find all Characters in a moonlit room with a certain level of lycanthrophy - we decide that if they have been newly bitten, they should also turn, regardless of their lycantrophy level (more dramatic that way!).

+

Let’s say that getting bitten means that you’ll get assigned a Tag recently_bitten.

+

This is how we’d change our query:

+
from django.db.models import Q
+
+will_transform = (
+    Character.objects
+    .filter(
+        Q(db_location__db_tags__db_key__iexact="moonlit")
+        & (
+          Q(db_attributes__db_key="lycantrophy",
+            db_attributes__db_value__gt=2)
+          | Q(db_tags__db_key__iexact="recently_bitten")
+        ))
+    .distinct()
+)
+
+
+

That’s quite compact. It may be easier to see what’s going on if written this way:

+
from django.db.models import Q
+
+q_moonlit = Q(db_location__db_tags__db_key__iexact="moonlit")
+q_lycantropic = Q(db_attributes__db_key="lycantrophy", db_attributes__db_value__gt=2)
+q_recently_bitten = Q(db_tags__db_key__iexact="recently_bitten")
+
+will_transform = (
+    Character.objects
+    .filter(q_moonlit & (q_lycantropic | q_recently_bitten))
+    .distinct()
+)
+
+
+ +

This reads as “Find all Characters in a moonlit room that either has the +Attribute lycantrophy higher than two, or which has the Tag +recently_bitten”. With an OR-query like this it’s possible to find the same +Character via different paths, so we add .distinct() at the end. This makes +sure that there is only one instance of each Character in the result.

+
+
+

12.4. Annotations

+

What if we wanted to filter on some condition that isn’t represented easily by a +field on the object? An example would wanting to find rooms only containing five or more objects.

+

We could do it like this (don’t actually do it this way!):

+
from typeclasses.rooms import Room
+
+  all_rooms = Rooms.objects.all()
+
+  rooms_with_five_objects = []
+  for room in all_rooms:
+      if len(room.contents) >= 5:
+          rooms_with_five_objects.append(room)
+
+
+ +

Above we get all rooms and then use list.append() to keep adding the right +rooms to an ever-growing list. This is not a good idea, once your database +grows this will be unnecessarily compute-intensive. It’s much better to query the +database directly

+

Annotations allow you to set a ‘variable’ inside the query that you can then +access from other parts of the query. Let’s do the same example as before +directly in the database:

+
1
+2
+3
+4
+5
+6
+7
+8
+9
from typeclasses.rooms import Room
+from django.db.models import Count
+
+rooms = (
+    Room.objects
+    .annotate(
+        num_objects=Count('locations_set'))
+    .filter(num_objects__gte=5)
+)
+
+
+ +

Count is a Django class for counting the number of things in the database.

+
    +
  • Line 6-7: Here we first create an annotation num_objects of type Count. It creates an in-database function that will count the number of results inside the database. The fact annotation means that now num_objects is avaiable to be used in other parts of the query.

  • +
  • Line 8 We filter on this annotation, using the name num_objects as something we +can filter for. We use num_objects__gte=5 which means that num_objects +should be greater than or equal to 5.

  • +
+

Annotations can be a little harder to get one’s head around but much more efficient than lopping over all objects in Python.

+
+
+

12.5. F-objects

+

What if we wanted to compare two dynamic parameters against one another in a +query? For example, what if instead of having 5 or more objects, we only wanted +objects that had a bigger inventory than they had tags (silly example, but …)?

+

This can be with Django’s F objects. So-called F expressions allow you to do a query that looks at a value of each object in the database.

+
from django.db.models import Count, F
+from typeclasses.rooms import Room
+
+result = (
+    Room.objects
+    .annotate(
+        num_objects=Count('locations_set'),
+        num_tags=Count('db_tags'))
+    .filter(num_objects__gt=F('num_tags'))
+)
+
+
+

Here we used .annotate to create two in-query ‘variables’ num_objects and num_tags. We then +directly use these results in the filter. Using F() allows for also the right-hand-side of the filter +condition to be calculated on the fly, completely within the database.

+
+
+

12.6. Grouping and returning only certain properties

+

Suppose you used tags to mark someone belonging to an organization. Now you want to make a list and need to get the membership count of every organization all at once.

+

The .annotate, .values_list, and .order_by queryset methods are useful for this. Normally when you run a .filter, what you get back is a bunch of full typeclass instances, like roses or swords. Using .values_list you can instead choose to only get back certain properties on objects. The .order_by method finally allows for sorting the results according to some criterion:

+
1
+2
+3
+4
+5
+6
+7
+8
+9
from django.db.models import Count
+from typeclasses.rooms import Room
+
+result = (
+    Character.objects
+    .filter(db_tags__db_category="organization")
+    .annotate(tagcount=Count('id'))
+    .order_by('-tagcount'))
+    .values_list('db_tags__db_key', "tagcount")
+
+
+

Here we fetch all Characters who …

+
    +
  • Line 6: … has a tag of category “organization” on them

  • +
  • Line 7:… along the way we count how many different Characters (each id is unique) we find for each organization and store it in a ‘variable’ tagcount using .annotate and Count

  • +
  • Line 8: … we use this count to sort the result in descending order of tagcount (descending because there is a minus sign, default is increasing order but we want the most popular organization to be first).

  • +
  • Line 9: … and finally we make sure to only return exactly the properties we want, namely the name of the organization tag and how many matches we found for that organization. For this we use the values_list method on the queryset. This will evaluate the queryset immediately.

  • +
+

The result will be a list of tuples ordered in descending order by the number of matches, +in a format like the following:

+
[
+ ('Griatch's poets society', 3872),
+ ("Chainsol's Ainneve Testers", 2076),
+ ("Blaufeuer's Whitespace Fixers", 1903),
+ ("Volund's Bikeshed Design Crew", 1764),
+ ("Tehom's Glorious Misanthropes", 1763)
+]
+
+
+
+
+

12.7. Conclusions

+

We have covered a lot of ground in this lesson and covered several more complex topics. Knowing how to query using Django is a powerful skill to have.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Evennia-Library-Overview.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Evennia-Library-Overview.html new file mode 100644 index 0000000000..ce277fd983 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Evennia-Library-Overview.html @@ -0,0 +1,266 @@ + + + + + + + + + 6. Overview of the Evennia library — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

6. Overview of the Evennia library

+ +

There are several good ways to explore the Evennia library.

+
    +
  • This documentation contains the Evennia-API docs, generated automatically from sources. Try clicking through to a few entries - once you get deep enough you’ll see full descriptions of each component along with their documentation. You can also click [source] to see the full Python source code for each thing.

  • +
  • There are separate doc pages for each component if you want more detailed explanations.

  • +
  • You can browse the evennia repository on github. This is exactly what you can download from us.

  • +
  • Finally, you can clone the evennia repo to your own computer and read the sources. This is necessary if you want to really understand what’s going on, or help with Evennia’s development. See the extended install instructions if you want to do this.

  • +
+
+

6.1. Where is it?

+

If Evennia is installed, you can import from it simply with

+
import evennia
+from evennia import some_module
+from evennia.some_module.other_module import SomeClass
+
+
+

and so on.

+

If you installed Evennia with pip install, the library folder will be installed deep inside your Python installation; you are better off looking at it on github. If you cloned it, you should have an evennia folder to look into.

+

You’ll find this being the outermost structure:

+
evennia/
+    bin/
+    CHANGELOG.md
+    ...
+    ...
+    docs/
+    evennia/
+
+
+

This outer layer is for Evennia’s installation and package distribution. That internal folder evennia/evennia/ is the actual library, the thing covered by the API auto-docs and what you get when you do import evennia.

+
+

The evennia/docs/ folder contains the sources for this documentation. See +contributing to the docs if you want to learn more about how this works.

+
+

This is the structure of the Evennia library:

+
    +
  • evennia

    +
      +
    • __init__.py - The “flat API” of Evennia resides here.

    • +
    • settings_default.py - Root settings of Evennia. Copy settings from here to mygame/server/settings.py file.

    • +
    • commands/ - The command parser and handler.

      + +
    • +
    • comms/ - Systems for communicating in-game.

    • +
    • contrib/ - Optional plugins too game-specific for core Evennia.

    • +
    • game_template/ - Copied to become the “game directory” when using evennia --init.

    • +
    • help/ - Handles the storage and creation of help entries.

    • +
    • locale/ - Language files (i18n).

    • +
    • locks/ - Lock system for restricting access to in-game entities.

    • +
    • objects/ - In-game entities (all types of items and Characters).

    • +
    • prototypes/ - Object Prototype/spawning system and OLC menu

    • +
    • accounts/ - Out-of-game Session-controlled entities (accounts, bots etc)

    • +
    • scripts/ - Out-of-game entities equivalence to Objects, also with timer support.

    • +
    • server/ - Core server code and Session handling.

      +
        +
      • portal/ - Portal proxy and connection protocols.

      • +
      +
    • +
    • typeclasses/ - Abstract classes for the typeclass storage and database system.

    • +
    • utils/ - Various miscellaneous useful coding resources.

    • +
    • web/ - Web resources and webserver. Partly copied into game directory on initialization.

    • +
    +
  • +
+ +

While all the actual Evennia code is found in the various folders, the __init__.py represents the entire package evennia. It contains “shortcuts” to code that is actually located elsewhere. Most of these shortcuts are listed if you scroll down a bit on the Evennia-API page.

+
+
+

6.2. An example of exploring the library

+

In the previous lesson we took a brief look at mygame/typeclasses/objects as an example of a Python module. Let’s open it again.

+
"""
+module docstring
+"""
+from evennia import DefaultObject
+
+class Object(DefaultObject):
+    """
+    class docstring
+    """
+    pass
+
+
+

We have the Object class, which inherits from DefaultObject. Near the top of the module is this line:

+
from evennia import DefaultObject
+
+
+

We want to figure out just what this DefaultObject offers. Since this is imported directly from evennia, we are actually importing from evennia/__init__.py.

+

Look at Line 160 of evennia/__init__.py and you’ll find this line:

+
from .objects.objects import DefaultObject
+
+
+ +
+

You can also look at the right section of the API frontpage and click through to the code that way.

+
+

The fact that DefaultObject is imported into __init__.py here is what makes it possible to also import it as from evennia import DefaultObject even though the code for the class is not actually here.

+

So to find the code for DefaultObject we need to look in evennia/objects/objects.py. Here’s how to look it up in the docs:

+
    +
  1. Open the API frontpage

  2. +
  3. Locate the link to evennia.objects.objects and click on it.

  4. +
  5. You are now in the python module. Scroll down (or search in your web browser) to find the DefaultObject class.

  6. +
  7. You can now read what this does and what methods are on it. If you want to see the full source, click the [source] link next to it.

  8. +
+
+
+

6.3. Conclusions

+

This is an important lesson. It teaches you how to find information for yourself. Knowing how to follow the class inheritance tree and navigate to things you need is a big part in learning a new library like Evennia.

+

Next we’ll start to make use of what we have learned so far and combine it with the building blocks provided by Evennia.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Gamedir-Overview.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Gamedir-Overview.html new file mode 100644 index 0000000000..98dde9ad9d --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Gamedir-Overview.html @@ -0,0 +1,282 @@ + + + + + + + + + 4. Overview of your new Game Dir — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

4. Overview of your new Game Dir

+

Until now we have ‘run the game’ a bit and started playing with Python inside Evennia. +It is time to start to look at how things look ‘outside of the game’.

+

Let’s do a tour of your game-dir (we assume it’s called mygame).

+
+

When looking through files, ignore files ending with .pyc and the __pycache__ folder if it exists. This is internal Python compilation files that you should never need to touch. Files __init__.py is also often empty and can be ignored (they have to do with Python package management).

+
+

You may have noticed when we were building things in-game that we would often refer to code through “python paths”, such as

+
create/drop button:tutorial_examples.red_button.RedButton
+
+
+

This is a fundamental aspect of coding Evennia - you create code and then you tell Evennia where that code is and when it should be used. Above we told it to create a red button by pulling from specific code in the contrib/ folder. The same principle is true everywhere. So it’s important to know where code is and how you point to it correctly.

+ +
    +
  • mygame/

    +
      +
    • commands/ - This holds all your custom commands (user-input handlers). You both add your own and override Evennia’s defaults from here.

    • +
    • server/ - The structure of this folder should not change since Evennia expects it.

      +
        +
      • conf/ - All server configuration files sits here. The most important file is settings.py.

      • +
      • logs/ - Server log files are stored here. When you use evennia --log you are actually +tailing the files in this directory.

      • +
      +
    • +
    • typeclasses/ - this holds empty templates describing all database-bound entities in the game, like Characters, Scripts, Accounts etc. Adding code here allows to customize and extend the defaults.

    • +
    • web/ - This is where you override and extend the default templates, views and static files used for Evennia’s web-presence, like the website and the HTML5 webclient.

    • +
    • world/ - this is a “miscellaneous” folder holding everything related to the world you are building, such as build scripts and rules modules that don’t fit with one of the other folders.

    • +
    +
  • +
+
+

The server/ subfolder should remain the way it is - Evennia expects this. But you can change the structure of the rest of your game dir as best fits your preferences. +Maybe you don’t want a single world/ folder but prefer many folders with different aspects of your world? A new folder ‘rules’ for your RPG rules? Group your commands with your objects instead of having them separate? This is fine. If you move things around you just need to update Evennia’s default settings to point to the right places in the new structure.

+
+
+

4.1. commands/

+

The commands/ folder holds Python modules related to creating and extending the Commands +of Evennia. These manifest in game like the server understanding input like look or dig.

+ +
    +
  • command.py (Python-path: commands.command) - this contain the +base classes for designing new input commands, or override the defaults.

  • +
  • default_cmdsets.py (Python path: commands.default_commands) - +a cmdset (Command-Set) groups Commands together. Command-sets can be added and removed from objects on the fly, +meaning a user could have a different set of commands (or versions of commands) available depending on their circumstance +in the game. In order to add a new command to the game, it’s common to import the new command-class +from command.py and add it to one of the default cmdsets in this module.

  • +
+
+
+

4.2. server/

+

This folder contains resource necessary for running Evennia. Contrary to the other folders, the structure of this should be kept the way it is.

+
    +
  • evennia.db3 - you will only have this file if you are using the default SQLite3 database. This file contains the entire database. Just copy it to make a backup. For development you could also just make a copy once you have set up everything you need and just copy that back to ‘reset’ the state. If you delete this file you can easily recreate it by running evennia migrate.

  • +
+
+

4.2.1. server/logs/

+

This holds the server logs. When you do evennia --log, the evennia program is in fact tailing and concatenating the server.log and portal.log files in this directory. The logs are rotated every week. Depending on your settings, other logs, like the webserver HTTP request log can also be found here.

+
+
+

4.2.2. server/conf/

+

This contains all configuration files of the Evennia server. These are regular Python modules which means that they must be extended with valid Python. You can also add logic to them if you wanted to.

+

Common for the settings is that you generally will never them directly via their python-path; instead Evennia knows where they are and will read them to configure itself at startup.

+
    +
  • settings.py - this is by far the most important file. It’s nearly empty by default, rather you +are expected to copy&paste the changes you need from evennia/default_settings.py. The default settings file is extensively documented. Importing/accessing the values in the settings file is done in a special way, like this:

    +
      from django.conf import settings 
    +
    +
    +

    To get to the setting TELNET_PORT in the settings file you’d then do

    +
      telnet_port = settings.TELNET_PORT
    +
    +
    +

    You cannot assign to the settings file dynamically; you must change the settings.py file directly to change a setting. See Settings documentation for more details.

    +
  • +
  • secret_settings.py - If you are making your code effort public, you may not want to share all settings online. There may be server-specific secrets or just fine-tuning for your game systems that you prefer be kept secret from the players. Put such settings in here, it will override values in settings.py and not be included in version control.

  • +
  • at_initial_setup.py - When Evennia starts up for the very first time, it does some basic tasks, like creating the superuser and Limbo room. Adding to this file allows to add more actions for it to for first-startup.

  • +
  • at_search.py - When searching for objects and either finding no match or more than one match, it will respond by giving a warning or offering the user to differentiate between the multiple matches. Modifying the code here will change this behavior to your liking.

  • +
  • at_server_startstop.py - This allows to inject code to execute every time the server starts, stops or reloads in different ways.

  • +
  • connection_screens.py - This allows for changing the connection screen you see when you first connect to your game.

  • +
  • inlinefuncs.py - Inlinefuncs are optional and limited ‘functions’ that can be embedded in any strings being sent to a player. They are written as $funcname(args) and are used to customize the output depending on the user receiving it. For example sending people the text "Let's meet at $realtime(13:00, GMT)! would show every player seeing that string the time given in their own time zone. The functions added to this module will become new inlinefuncs in the game. See also the FuncParser.

  • +
  • inputfucs.py - When a command like look is received by the server, it is handled by an Inputfunc that redirects it to the cmdhandler system. But there could be other inputs coming from the clients, like button-presses or the request to update a health-bar. While most common cases are already covered, this is where one adds new functions to process new types of input.

  • +
  • lockfuncs.py - Locks and their component LockFuncs restrict access to things in-game. Lock funcs are used in a mini-language to defined more complex locks. For example you could have a lockfunc that checks if the user is carrying a given item, is bleeding or has a certain skill value. New functions added in this modules will become available for use in lock definitions.

  • +
  • mssp.py - Mud Server Status Protocol is a way for online MUD archives/listings (which you usually have to sign up for) to track which MUDs are currently online, how many players they have etc. While Evennia handles the dynamic information automatically, this is were you set up the meta-info about your game, such as its theme, if player-killing is allowed and so on. This is a more generic form of the Evennia Game directory.

  • +
  • portal_services_plugins.py - If you want to add new external connection protocols to Evennia, this is the place to add them.

  • +
  • server_services_plugins.py - This allows to override internal server connection protocols.

  • +
  • web_plugins.py - This allows to add plugins to the Evennia webserver as it starts.

  • +
+
+
+

4.2.3. typeclasses/

+

The Typeclasses of Evennia are Evennia-specific Python classes whose instances save themselves to the database. This allows a Character to remain in the same place and your updated strength stat to still be the same after a server reboot.

+
    +
  • accounts.py (Python-path: typeclasses.accounts) - An Account represents the player connecting to the game. It holds information like email, password and other out-of-character details.

  • +
  • channels.py (Python-path: typeclasses.channels) - Channels are used to manage in-game communication between players.

  • +
  • objects.py (Python-path: typeclasses.objects) - Objects represent all things having a location within the game world.

  • +
  • characters.py (Python-path: typeclasses.characters) - The Character is a subclass of Objects, controlled by Accounts - they are the player’s avatars in the game world.

  • +
  • rooms.py (Python-path: typeclasses.rooms) - A Room is also a subclass of Object; describing discrete locations. While the traditional term is ‘room’, such a location can be anything and on any scale that fits your game, from a forest glade, an entire planet or an actual dungeon room.

  • +
  • exits.py (Python-path: typeclasses.exits) - Exits is another subclass of Object. Exits link one Room to another.

  • +
  • scripts.py (Python-path: typeclasses.scripts) - Scripts are ‘out-of-character’ objects. They have no location in-game and can serve as basis for anything that needs database persistence, such as combat, weather, or economic systems. They also have the ability to execute code repeatedly, on a timer.

  • +
+
+
+

4.2.4. web/

+

This folder contains subfolders for overriding the default web-presence of Evennia with your own designs. Most of these folders are empty except for a README file or a subset of other empty folders. See the Web overview for more details (we’ll also get back to the web later in this beginner tutorial).

+
    +
  • media/ - this empty folder is where you can place your own images or other media files you want the web server to serve. If you are releasing your game with a lot of media (especially if you want videos) you should consider re-pointing Evennia to use some external service to serve your media instead.

  • +
  • static_overrides/ - ‘static’ files include fonts, CSS and JS. Within this folder you’ll find sub-folders for overriding the static files for the admin (this is the Django web-admin), the webclient (this is thet HTML5 webclient) and the website. Adding files to this folder will replace same-named files in the default web presence.

  • +
  • template_overrides/ - these are HTML files, for the webclient and the website. HTML files are written using Jinja templating, which means that one can override +only particular parts of a default template without touching others.

  • +
  • static/ - this is a work-directory for the web system and should not be manually modified. Basically, Evennia will copy static data from static_overrides here when the server starts.

  • +
  • urls.py - this module links up the Python code to the URLs you go to in the browser.

  • +
+
+
+

4.2.5. world/

+

This folder only contains some example files. It’s meant to hold ‘the rest’ of your game implementation. Many people change and re-structure this in various ways to better fit their ideas.

+
    +
  • batch_cmds.ev - This is an .ev file, which is essentially just a list of Evennia commands to execute in sequence. This one is empty and ready to expand on. The Tutorial World was built with such a batch-file.

  • +
  • prototypes.py - A prototype is a way to easily vary objects without changing their base typeclass. For example, one could use prototypes to tell that Two goblins, while both of the class ‘Goblin’ (so they follow the same code logic), should have different equipment, stats and looks.

  • +
  • help_entries.py - You can add new in-game Help entries in several ways, such as adding them in the database using the sethelp command, or (for Commands) read the help directly from the source code. You can also add them through python modules. This module is an example on how to do so.

  • +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Learning-Typeclasses.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Learning-Typeclasses.html new file mode 100644 index 0000000000..04c7795fbd --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Learning-Typeclasses.html @@ -0,0 +1,729 @@ + + + + + + + + + 7. Making objects persistent — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

7. Making objects persistent

+

Now that we have learned a little about how to find things in the Evennia library, let’s use it.

+

In the Python classes and objects lesson we created the dragons Fluffy, Cuddly +and Smaug and made them fly and breathe fire. So far our dragons are short-lived - whenever we restart the server or quit() out of python mode they are gone.

+

This is what you should have in mygame/typeclasses/monsters.py so far:

+

+class Monster:
+    """
+    This is a base class for Monsters.
+    """
+ 
+    def __init__(self, key):
+        self.key = key 
+
+    def move_around(self):
+        print(f"{self.key} is moving!")
+
+
+class Dragon(Monster):
+    """
+    This is a dragon-specific monster.
+    """
+
+    def move_around(self):
+        super().move_around()
+        print("The world trembles.")
+
+    def firebreath(self):
+        """ 
+        Let our dragon breathe fire.
+        """
+        print(f"{self.key} breathes fire!")
+
+
+
+
+

7.1. Our first persistent object

+

At this point we should know enough to understand what is happening in mygame/typeclasses/objects.py. Let’s +open it:

+
"""
+module docstring
+"""
+from evennia import DefaultObject
+
+class ObjectParent:
+    """ 
+    class docstring 
+    """
+    pass
+
+class Object(ObjectParent, DefaultObject):
+    """
+    class docstring
+    """
+    pass
+
+
+

So we have a class Object that inherits from ObjectParent (which is empty) and DefaultObject, which we have imported from Evennia. The ObjectParent acts as a place to put code you want all +of your Objects to have. We’ll focus on Object and DefaultObject for now.

+

The class itself doesn’t do anything (it just passes) but that doesn’t mean it’s useless. As we’ve seen, it inherits all the functionality of its parent. It’s in fact an exact replica of DefaultObject right now. Once we know what kind of methods and resources are available on DefaultObject we could add our own and change the way it works!

+

One thing that Evennia classes offers and which you don’t get with vanilla Python classes is persistence - they survive a server reload since they are stored in the database.

+

Go back to mygame/typeclasses/monsters.py. Change it as follows:

+

+from typeclasses.objects import Object
+
+class Monster(Object):
+    """
+    This is a base class for Monsters.
+    """
+    def move_around(self):
+        print(f"{self.key} is moving!")
+
+
+class Dragon(Monster):
+    """
+    This is a dragon-specific Monster.
+    """
+
+    def move_around(self):
+        super().move_around()
+        print("The world trembles.")
+
+    def firebreath(self):
+        """ 
+        Let our dragon breathe fire.
+        """
+        print(f"{self.key} breathes fire!")
+
+
+
+

Don’t forget to save. We removed Monster.__init__ and made Monster inherit from Evennia’s Object (which in turn inherits from Evennia’s DefaultObject, as we saw). By extension, this means that Dragon also inherits from DefaultObject, just from further away!

+
+

7.1.1. Making a new object by calling the class

+

First reload the server as usual. We will need to create the dragon a little differently this time:

+ +
> py
+> from typeclasses.monsters import Dragon
+> smaug = Dragon(db_key="Smaug", db_location=here)
+> smaug.save()
+> smaug.move_around()
+Smaug is moving!
+The world trembles.
+
+
+

Smaug works the same as before, but we created him differently: first we used +Dragon(db_key="Smaug", db_location=here) to create the object, and then we used smaug.save() afterwards.

+ +
> quit()
+Python Console is closing.
+> look 
+
+
+

You should now see that Smaug is in the room with you. Woah!

+
> reload 
+> look 
+
+
+

He’s still there… What we just did was to create a new entry in the database for Smaug. We gave the object its name (key) and set its location to our current location.

+

To make use of Smaug in code we must first find him in the database. For an object in the current +location we can easily do this in py by using me.search():

+
> py smaug = me.search("Smaug") ; smaug.firebreath()
+Smaug breathes fire!  
+
+
+
+
+

7.1.2. Creating using create_object

+

Creating Smaug like we did above is nice because it’s similar to how we created non-database +bound Python instances before. But you need to use db_key instead of key and you also have to +remember to call .save() afterwards. Evennia has a helper function that is more common to use, +called create_object. Let’s recreate Cuddly this time:

+
> py evennia.create_object('typeclasses.monsters.Monster', key="Cuddly", location=here)
+> look 
+
+
+

Boom, Cuddly should now be in the room with you, a little less scary than Smaug. You specify the +python-path to the code you want and then set the key and location (if you had the Monster class already imported, you could have passed that too). Evennia sets things up and saves for you.

+

If you want to find Cuddly from anywhere (not just in the same room), you can use Evennia’s search_object function:

+
> py cuddly = evennia.search_object("Cuddly")[0] ; cuddly.move_around()
+Cuddly is moving!
+
+
+
+

The [0] is because search_object always returns a list of zero, one or more found objects. The [0] means that we want the first element of this list (counting in Python always starts from 0). If there were multiple Cuddlies we could get the second one with [1].

+
+
+
+

7.1.3. Creating using create-command

+

Finally, you can also create a new dragon using the familiar builder-commands we explored a few lessons ago:

+
> create/drop Fluffy:typeclasses.monsters.Dragon
+
+
+

Fluffy is now in the room. After learning about how objects are created you’ll realize that all this command really does is to parse your input, figure out that /drop means to “give the object the same location as the caller”, and then do a call very similar to

+
evennia.create_object("typeclasses.monsters.Dragon", key="Cuddly", location=here)
+
+
+

That’s pretty much all there is to the mighty create command! The rest is just parsing for the command to understand just what the user wants to create.

+
+
+
+

7.2. Typeclasses

+

The Object (and DefafultObject class we inherited from above is what we refer to as a Typeclass. This is an Evennia thing. The instance of a typeclass saves itself to the database when it is created, and after that you can just search for it to get it back.

+

We use the term typeclass or typeclassed to differentiate these types of classes and objects from the normal Python classes, whose instances go away on a reload.

+

The number of typeclasses in Evennia are so few they can be learned by heart:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Evennia base typeclass

mygame.typeclasses child

description

evennia.DefaultObject

typeclasses.objects.Object

Everything with a location

evennia.DefaultCharacter (child of DefaultObject)

typeclasses.characters.Character

Player avatars

evennia.DefaultRoom (child of DefaultObject)

typeclasses.rooms.Room

In-game locations

evennia.DefaultExit (chld of DefaultObject)

typeclasses.exits.Exit

Links between rooms

evennia.DefaultAccount

typeclasses.accounts.Account

A player account

evennia.DefaultChannel

typeclasses.channels.Channel

In-game comms

evennia.DefaultScript

typeclasses.scripts.Script

Entities with no location

+

The child classes under mygame/typeclasses/ are meant for you to conveniently modify and +work with. Every class inheriting (at any distance) from a Evennia base typeclass is also considered a typeclass.

+
from somewhere import Something 
+from evennia import DefaultScript 
+
+class MyOwnClass(Something): 
+    # not inheriting from an Evennia core typeclass, so this 
+    # is just a 'normal' Python class inheriting from somewhere
+    pass 
+
+class MyOwnClass2(DefaultScript):
+    # inherits from one of the core Evennia typeclasses, so 
+    # this is also considered a 'typeclass'.
+    pass
+
+
+
+ +

Notice that the classes in mygame/typeclasses/ are not inheriting from each other. For example, Character is inheriting from evennia.DefaultCharacter and not from typeclasses.objects.Object. So if you change Object you will not cause any change in the Character class. If you want that you can easily just change the child classes to inherit in that way instead; Evennia doesn’t care.

+

As seen with our Dragon example, you don’t have to modify these modules directly. You can just make your own modules and import the base class.

+
+

7.2.1. Examining objects

+

When you do

+
> create/drop giantess:typeclasses.monsters.Monster
+You create a new Monster: giantess.
+
+
+

or

+
> py evennia.create_object("typeclasses.monsters.Monster", key="Giantess", location=here)
+
+
+

You are specifying exactly which typeclass you want to use to build the Giantess. Let’s examine the result:

+
> examine giantess
+------------------------------------------------------------------------------- 
+Name/key: Giantess (#14)
+Typeclass: Monster (typeclasses.monsters.Monster)
+Location: Limbo (#2)
+Home: Limbo (#2)
+Permissions: <None>
+Locks: call:true(); control:id(1) or perm(Admin); delete:id(1) or perm(Admin);
+   drop:holds(); edit:perm(Admin); examine:perm(Builder); get:all();
+   puppet:pperm(Developer); tell:perm(Admin); view:all()
+Persistent attributes:
+ desc = You see nothing special. 
+------------------------------------------------------------------------------- 
+
+
+

We used the examine command briefly in the lesson about building in-game. Now these lines +may be more useful to us:

+
    +
  • Name/key - The name of this thing. The value (#14) is probably different for you. This is the +unique ‘primary key’ or dbref for this entity in the database.

  • +
  • Typeclass: This show the typeclass we specified, and the path to it.

  • +
  • Location: We are in Limbo. If you moved elsewhere you’ll see that instead. Also the #dbref of Limbo is shown.

  • +
  • Home: All objects with a location (inheriting from DefaultObject) must have a home location. This is a backup to move the object to if its current location is deleted.

  • +
  • Permissions: Permissions are like the inverse to Locks - they are like keys to unlock access to other things. The giantess have no such keys (maybe fortunately). The Permissions has more info.

  • +
  • Locks: Locks are the inverse of Permissions - specify what criterion other objects must fulfill in order to access the giantess object. This uses a very flexible mini-language. For examine, the line examine:perm(Builders) is read as “Only those with permission Builder or higher can examine this object”. Since we are the superuser we pass (even bypass) such locks with ease. See the Locks documentation for more info.

  • +
  • Persistent attributes: This allows for storing arbitrary, persistent data on the typeclassed entity. We’ll get to those in the next section.

  • +
+

Note how the Typeclass line describes exactly where to find the code of this object? This is very useful for understanding how any object in Evennia works.

+
+
+

7.2.2. Default typeclasses

+

What happens if we create an object and don’t specify its typeclass though?

+
> create/drop box 
+You create a new Object: box.
+
+
+

or

+
> py create.create_object(None, key="box", location=here)
+
+
+

Now check it out:

+
> examine box  
+
+
+

You will find that the Typeclass line now reads

+
Typeclass: Object (typeclasses.objects.Object) 
+
+
+

So when you didn’t specify a typeclass, Evennia used a default, more specifically the (so far) empty Object class in mygame/typeclasses/objects.py. This is usually what you want, especially since you can tweak that class as much as you like.

+

But the reason Evennia knows to fall back to this class is not hard-coded - it’s a setting. The default is in evennia/settings_default.py, with the name BASE_OBJECT_TYPECLASS, which is set to typeclasses.objects.Object.

+ +

So if you wanted the creation commands and methods to default to some other class you could +add your own BASE_OBJECT_TYPECLASS line to mygame/server/conf/settings.py. The same is true for all the other typeclasseses, like characters, rooms and accounts. This way you can change the layout of your game dir considerably if you wanted. You just need to tell Evennia where everything is.

+
+
+
+

7.3. Modifying ourselves

+

Let’s try to modify ourselves a little. Open up mygame/typeclasses/characters.py.

+
"""
+(module docstring)
+"""
+from evennia import DefaultCharacter
+
+class Character(DefaultCharacter):
+    """
+    (class docstring)
+    """
+    pass
+
+
+

This looks quite familiar now - an empty class inheriting from the Evennia base typeclass (it’s even easier than Object since there is no equvalent ParentObject mixin class here). As you would expect, this is also the default typeclass used for creating Characters if you don’t specify it. You can verify it:

+
> examine me
+------------------------------------------------------------------------------
+Name/key: YourName (#1)
+Session id(s): #1
+Account: YourName
+Account Perms: <Superuser> (quelled)
+Typeclass: Character (typeclasses.characters.Character)
+Location: Limbo (#2)
+Home: Limbo (#2)
+Permissions: developer, player
+Locks:      boot:false(); call:false(); control:perm(Developer); delete:false();
+      drop:holds(); edit:false(); examine:perm(Developer); get:false();
+      msg:all(); puppet:false(); tell:perm(Admin); view:all()
+Stored Cmdset(s):
+ commands.default_cmdsets.CharacterCmdSet [DefaultCharacter] (Union, prio 0)
+Merged Cmdset(s):
+   ...
+Commands available to YourName (result of Merged CmdSets):
+   ...
+Persistent attributes:
+ desc = This is User #1.
+ prelogout_location = Limbo
+Non-Persistent attributes:
+ last_cmd = None
+------------------------------------------------------------------------------
+
+
+

Yes, the examine command understands me. You got a lot longer output this time. You have a lot more going on than a simple Object. Here are some new fields of note:

+
    +
  • Session id(s): This identifies the Session (that is, the individual connection to a player’s game client).

  • +
  • Account shows, well the Account object associated with this Character and Session.

  • +
  • Stored/Merged Cmdsets and Commands available is related to which Commands are stored on you. We will get to them in the next lesson. For now it’s enough to know these consitute all the +commands available to you at a given moment.

  • +
  • Non-Persistent attributes are Attributes that are only stored temporarily and will go away on next reload.

  • +
+

Look at the Typeclass field and you’ll find that it points to typeclasses.character.Character as expected. So if we modify this class we’ll also modify ourselves.

+
+

7.3.1. A method on ourselves

+

Let’s try something simple first. Back in mygame/typeclasses/characters.py:

+

+class Character(DefaultCharacter):
+    """
+    (class docstring)
+    """
+
+    strength = 10
+    dexterity = 12
+    intelligence = 15
+
+    def get_stats(self):
+        """
+        Get the main stats of this character
+        """
+        return self.strength, self.dexterity, self.intelligence
+
+
+
+
> reload 
+> py self.get_stats()
+(10, 12, 15)
+
+
+ +

We made a new method, gave it a docstring and had it return the RP-esque values we set. It comes back as a tuple (10, 12, 15). To get a specific value you could specify the index of the value you want, starting from zero:

+
> py stats = self.get_stats() ; print(f"Strength is {stats[0]}.")
+Strength is 10.
+
+
+
+
+

7.3.2. Attributes

+

So what happens when we increase our strength? This would be one way:

+
> py self.strength = self.strength + 1
+> py self.strength
+11
+
+
+

Here we set the strength equal to its previous value + 1. A shorter way to write this is to use Python’s += operator:

+
> py self.strength += 1
+> py self.strength
+12     
+> py self.get_stats()
+(12, 12, 15)
+
+
+

This looks correct! Try to change the values for dex and int too; it works fine. However:

+
> reload 
+> py self.get_stats()
+(10, 12, 15)
+
+
+

After a reload all our changes were forgotten. When we change properties like this, it only changes in memory, not in the database (nor do we modify the python module’s code). So when we reloaded, the ‘fresh’ Character class was loaded, and it still has the original stats we wrote in it.

+

In principle we could change the python code. But we don’t want to do that manually every time. And more importantly since we have the stats hardcoded in the class, every character instance in the game will have exactly the same str, dex and int now! This is clearly not what we want.

+

Evennia offers a special, persistent type of property for this, called an Attribute. Rework your mygame/typeclasses/characters.py like this:

+

+class Character(DefaultCharacter):
+    """
+    (class docstring)
+    """
+
+    def get_stats(self):
+        """
+        Get the main stats of this character
+        """
+        return self.db.strength, self.db.dexterity, self.db.intelligence
+
+
+ +

We removed the hard-coded stats and added added .db for every stat. The .db handler makes the stat into an an Evennia Attribute.

+
> reload 
+> py self.get_stats()
+(None, None, None) 
+
+
+

Since we removed the hard-coded values, Evennia don’t know what they should be (yet). So all we get back is None, which is a Python reserved word to represent nothing, a no-value. This is different from a normal python property:

+
> py me.strength
+AttributeError: 'Character' object has no attribute 'strength'
+> py me.db.strength
+(nothing will be displayed, because it's None)
+
+
+

Trying to get an unknown normal Python property will give an error. Getting an unknown Evennia Attribute will never give an error, but only result in None being returned. This is often very practical.

+

Next, let us test out assigning those Attributes

+
> py me.db.strength, me.db.dexterity, me.db.intelligence = 10, 12, 15
+> py me.get_stats()
+(10, 12, 15)
+> reload 
+> py me.get_stats()
+(10, 12, 15)
+
+
+

Now we set the Attributes to the right values, and they survive a server reload! Let’s modify the strength:

+
> py self.db.strength += 2 
+> py self.get_stats()
+(12, 12, 15)
+> reload 
+> py self.get_stats()
+(12, 12, 15)
+
+
+

Also our change now survives a reload since Evennia automatically saves the Attribute to the database for us.

+
+
+

7.3.3. Setting things on new Characters

+

Things are looking better, but one thing remains strange - the stats start out with a value None and we have to manually set them to something reasonable. In a later lesson we will investigate character-creation in more detail. For now, let’s give every new character some random stats to start with.

+

We want those stats to be set only once, when the object is first created. For the Character, this method is called at_object_creation.

+
# up by the other imports
+import random 
+
+class Character(DefaultCharacter):
+    """
+    (class docstring)
+    """
+
+    def at_object_creation(self):       
+        self.db.strength = random.randint(3, 18)
+        self.db.dexterity = random.randint(3, 18)
+        self.db.intelligence = random.randint(3, 18)
+    
+    def get_stats(self):
+        """
+        Get the main stats of this character
+        """
+        return self.db.strength, self.db.dexterity, self.db.intelligence
+
+
+

We imported a new module, random. This is part of Python’s standard library. We used random.randint to +set a random value from 3 to 18 to each stat. Simple, but for some classical RPGs this is all you need!

+
> reload 
+> py self.get_stats()
+(12, 12, 15)
+
+
+ +

Hm, this is the same values we set before. They are not random. The reason for this is of course that, as said, at_object_creation only runs once, the very first time a character is created. Our character object was already created long before, so it will not be called again.

+

It’s simple enough to run it manually though:

+
> py self.at_object_creation()
+> py self.get_stats()
+(5, 4, 8)
+
+
+

Lady luck didn’t smile on us for this example; maybe you’ll fare better. Evennia has a helper command +update that re-runs the creation hook and also cleans up any other Attributes not re-created by at_object_creation:

+
> update self
+> py self.get_stats()
+(8, 16, 14)
+
+
+
+
+

7.3.4. Updating all Characters in a loop

+ +

Needless to say, you are wise to have a feel for what you want to go into the at_object_creation hook before you create a lot of objects (characters in this case).

+

Luckily you only need to update objects once, and you don’t have to go around and re-run the at_object_creation method on everyone manually. For this we’ll try out a Python loop. Let’s go into multi-line Python mode:

+
> py
+> for a in [1, 2, "foo"]:   
+>     print(a)
+1
+2
+foo
+
+
+

A python for-loop allows us to loop over something. Above, we made a list of two numbers and a string. In every iteration of the loop, the variable a becomes one element in turn, and we print that.

+

For our list, we want to loop over all Characters, and want to call .at_object_creation on each. This is how this is done (still in python multi-line mode):

+
> from typeclasses.characters import Character
+> for char in Character.objects.all():
+>     char.at_object_creation()
+
+
+ +

We import the Character class and then we use .objects.all() to get all Character instances. Simplified, +.objects is a resource from which one can query for all Characters. Using .all() gets us a listing +of all of them that we then immediately loop over. Boom, we just updated all Characters, including ourselves:

+
> quit()
+Closing the Python console.
+> py self.get_stats()
+(3, 18, 10)
+
+
+
+
+
+

7.4. Extra Credits

+

This principle is the same for other typeclasses. So using the tools explored in this lesson, try to expand the default room with an is_dark flag. It can be either True or False. Have all new rooms start with is_dark = False and make it so that once you change it, it survives a reload. +Oh, and if you created any other rooms before, make sure they get the new flag too!

+
+
+

7.5. Conclusions

+

In this lesson we created database-persistent dragons by having their classes inherit from one Object, one of Evennia’s typeclasses. We explored where Evennia looks for typeclasses if we don’t specify the path explicitly. We then modified ourselves - via the Character class - to give us some simple RPG stats. This led to the need to use Evennia’s Attributes, settable via .db and to use a for-loop to update ourselves.

+

Typeclasses are a fundamental part of Evennia and we will see a lot of more uses of them in the course of this tutorial. But that’s enough of them for now. It’s time to take some action. Let’s learn about Commands.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Making-A-Sittable-Object.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Making-A-Sittable-Object.html new file mode 100644 index 0000000000..69449d0bfc --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Making-A-Sittable-Object.html @@ -0,0 +1,942 @@ + + + + + + + + + 13. Building a chair you can sit on — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

13. Building a chair you can sit on

+

In this lesson we will make use of what we have learned to create a new game object: a chair you can sit on.

+

Our goals are:

+
    +
  • We want a new ‘sittable’ object, a Chair in particular.

  • +
  • We want to be able to use a command to sit in the chair.

  • +
  • Once we are sitting in the chair it should affect us somehow. To demonstrate this store +the current chair in an attribute is_sitting. Other systems could check this to affect us in different ways.

  • +
  • A character should be able to stand up and move away from the chair.

  • +
  • When you sit down you should not be able to walk to another room without first standing up.

  • +
+
+

13.1. Make us not able to move while sitting

+

When you are sitting in a chair you can’t just walk off without first standing up. +This requires a change to our Character typeclass. Open mygame/typeclasses/characters.py:

+
# in mygame/typeclasses/characters.py
+
+# ...
+
+class Character(DefaultCharacter):
+    # ...
+
+    def at_pre_move(self, destination, **kwargs):
+       """
+       Called by self.move_to when trying to move somewhere. If this returns
+       False, the move is immediately cancelled.
+       """
+       if self.db.is_sitting:
+           self.msg("You need to stand up first.")
+           return False
+       return True
+
+
+
+

When moving somewhere, character.move_to is called. This in turn +will call character.at_pre_move. If this returns False, the move is aborted.

+

Here we look for an Attribute is_sitting (which we will assign below) to determine if we are stuck on the chair or not.

+
+
+

13.2. Making the Chair itself

+

Next we need the Chair itself, or rather a whole family of “things you can sit on” that we will call sittables. We can’t just use a default Object since we want a sittable to contain some custom code. We need a new, custom Typeclass. Create a new module mygame/typeclasses/sittables.py with the following content:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
# in mygame/typeclasses/sittables.py
+
+from typeclasses.objects import Object
+
+class Sittable(Object):
+
+    def do_sit(self, sitter):
+        """
+        Called when trying to sit on/in this object.
+
+        Args:
+            sitter (Object): The one trying to sit down.
+
+        """
+        current = self.db.sitter
+        if current:
+            if current == sitter:
+                sitter.msg(f"You are already sitting on {self.key}.")
+            else:
+                sitter.msg(f"You can't sit on {self.key} "
+                        f"- {current.key} is already sitting there!")
+            return
+        self.db.sitter = sitter
+        sitter.db.is_sitting = self
+        sitter.msg(f"You sit on {self.key}")
+
+
+

This handles the logic of someone sitting down on the chair.

+
    +
  • Line 3: We inherit from the empty Object class in mygame/typeclasses/objects.py. This means we can theoretically modify that in the future and have those changes affect sittables too.

  • +
  • Line 7: The do_sit method expects to be called with the argument sitter, which is to be an Object (most likely a Character). This is the one wanting to sit down.

  • +
  • Line 15: Note that, if the Attribute sitter is not defined on the chair (because this is the first time someone sits in it), this will simply return None, which is fine.

  • +
  • Lines 16-22 We check if someone is already sitting on the chair and returns appropriate error messages depending on if it’s you or someone else. We use return to abort the sit-action.

  • +
  • Line 23: If we get to this point, sitter gets to, well, sit down. We store them in the sitter Attribute on the chair.

  • +
  • Line 24: self.obj is the chair this command is attachd to. We store that in the is_sitting Attribute on the sitter itself.

  • +
  • Line 25: Finally we tell the sitter that they could sit down.

  • +
+

Let’s continue:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
# add this right after the `do_sit method` in the same class 
+
+    def do_stand(self, stander):
+        """
+        Called when trying to stand from this object.
+
+        Args:
+            stander (Object): The one trying to stand up.
+
+        """
+        current = self.db.sitter
+        if not stander == current:
+            stander.msg(f"You are not sitting on {self.key}.")
+        else:
+            self.db.sitter = None
+            del stander.db.is_sitting
+            stander.msg(f"You stand up from {self.key}.")
+
+
+

This is the inverse of sitting down; we need to do some cleanup.

+
    +
  • Line 12: If we are not sitting on the chair, it makes no sense to stand up from it.

  • +
  • Line 15: If we get here, we could stand up. We make sure to un-set the sitter Attribute so someone else could use the chair later.

  • +
  • Line 16: The character is no longer sitting, so we delete their is_sitting Attribute. We could also have done stander.db.is_sitting = None here, but deleting the Attribute feels cleaner.

  • +
  • Line 17: Finally, we inform them that they stood up successfully.

  • +
+

One could imagine that one could have the future sit command (which we haven’t created yet) check if someone is already sitting in the chair instead. This would work too, but letting the Sittable class handle the logic around who can sit on it makes sense.

+

We let the typeclass handle the logic, and also let it do all the return messaging. This makes it easy to churn out a bunch of chairs for people to sit on.

+
+

13.2.1. Sitting on or in?

+

It’s fine to sit ‘on’ a chair. But what if our Sittable is an armchair?

+
> py armchair = evennia.create_object("typeclasses.sittables.Sittable", key="armchair", location=here)
+> py armchair.do_sit(me)
+You sit on armchair.
+
+
+

This is not grammatically correct, you actually sit “in” an armchair rather than “on” it. It’s also possible to both sit ‘in’ or ‘on’ a chair depending on the type of chair (English is weird). We want to be able to control this.

+

We could make a child class of Sittable named SittableIn that makes this change, but that feels excessive. Instead we will modify what we have:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
# in mygame/typeclasses/sittables.py
+
+from typeclasses.objects import Object
+
+class Sittable(Object):
+
+    def do_sit(self, sitter):
+        """
+        Called when trying to sit on/in this object.
+
+        Args:
+            sitter (Object): The one trying to sit down.
+
+        """
+        adjective = self.db.adjective or "on"
+        current = self.db.sitter
+        if current:
+            if current == sitter:
+                sitter.msg(f"You are already sitting {adjective} {self.key}.")
+            else:
+                sitter.msg(
+                    f"You can't sit {adjective} {self.key} "
+                    f"- {current.key} is already sitting there!")
+            return
+        self.db.sitter = sitter
+        sitter.db.is_sitting = self
+        sitter.msg(f"You sit {adjective} {self.key}")
+
+    def do_stand(self, stander):
+        """
+        Called when trying to stand from this object.
+
+        Args:
+            stander (Object): The one trying to stand up.
+
+        """
+        current = self.db.sitter
+        if not stander == current:
+            stander.msg(f"You are not sitting {self.db.adjective} {self.key}.")
+        else:
+            self.db.sitter = None
+            del stander.db.is_sitting
+            stander.msg(f"You stand up from {self.key}.")
+
+
+
    +
  • Line 15: We grab the adjective Attribute. Using self.db.adjective or "on" here means that if the Attribute is not set (is None/falsy) the default “on” string will be assumed.

  • +
  • Lines 19,22,27,39, and 43: We use this adjective to modify the return text we see.

  • +
+

reload the server. An advantage of using Attributes like this is that they can be modified on the fly, in-game. Let’s look at a builder could use this by normal building commands (no need for py):

+
> set armchair/adjective = in 
+
+
+

Since we haven’t added the sit command yet, we must still use py to test:

+
> py armchair = evennia.search_object("armchair")[0];armchair.do_sit(me)
+You sit in armchair.
+
+
+
+
+

13.2.2. Extra credits

+

What if we want some more dramatic flair when you sit down in certain chairs?

+
You sit down and a whoopie cushion makes a loud fart noise!
+
+
+

You can make this happen by tweaking your Sittable class having the return messages be replaceable by Attributes that you can set on the object you create. You want something like this:

+
> py
+> chair = evennia.create_object("typeclasses.sittables.Sittable", key="pallet")
+> chair.do_sit(me)
+You sit down on pallet.
+> chair.do_stand(me)
+You stand up from pallet.
+> chair.db.msg_sitting_down = "You sit down and a whoopie cushion makes a loud fart noise!"
+> chair.do_sit(me)
+You sit down and a whoopie cushion makes a loud fart noise!
+
+
+

That is, if you are not setting the Attribute, you should get a default value. We leave this implementation up to the reader.

+
+
+
+

13.3. Adding commands

+

As we discussed in the lesson about adding Commands, there are two main ways to design the commands for sitting and standing up:

+
    +
  • You can store the commands on the chair so they are only available when a chair is in the room

  • +
  • You can store the commands on the Character so they are always available and you must always specify which chair to sit on.

  • +
+

Both of these are very useful to know about, so in this lesson we’ll try both.

+
+

13.3.1. Command variant 1: Commands on the chair

+

This way to implement sit and stand puts new cmdsets on the Sittable itself. +As we’ve learned before, commands on objects are made available to others in the room. +This makes the command easy but instead adds some complexity in the management of the CmdSet.

+

This is how it could look if armchair is in the room (Extra credits: Change the sit message on the armchair to match this output instead of getting the default You sit in armchair!):

+
> sit
+As you sit down in armchair, life feels easier.
+
+
+

What happens if there are sittables sofa and barstool also in the room? Evennia will +automatically handle this for us and allow us to specify which one we want:

+
> sit
+More than one match for 'sit' (please narrow target):
+ sit-1 (armchair)
+ sit-2 (sofa)
+ sit-3 (barstool)
+> sit-1
+As you sit down in armchair, life feels easier.
+
+
+

To keep things separate we’ll make a new module mygame/commands/sittables.py:

+ +
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
# in mygame/commands/sittables.py 
+
+from evennia import Command, CmdSet
+
+class CmdSit(Command):
+    """
+    Sit down.
+    """
+    key = "sit"
+    def func(self):
+        self.obj.do_sit(self.caller)
+
+class CmdStand(Command):
+     """
+     Stand up.
+     """
+     key = "stand"
+     def func(self):
+         self.obj.do_stand(self.caller)
+
+
+class CmdSetSit(CmdSet):
+    priority = 1
+    def at_cmdset_creation(self):
+        self.add(CmdSit)
+        self.add(CmdStand)
+
+
+

As seen, the commands are nearly trivial.

+
    +
  • Lines 11 and 19: The self.obj is the object to which we added the cmdset with this Command (so the chair). We just call the do_sit/stand on that object and pass the caller (the person sitting down). The Sittable will do the rest.

  • +
  • Line 23: The priority = 1 on CmdSetSit means that same-named Commands from this cmdset merge with a bit higher priority than Commands from the on-Character-cmdset (which has priority = 0). This means that if you have a sit command on your Character and comes into a room with a chair, the sit command on the chair will take precedence.

  • +
+

We also need to make a change to our Sittable typeclass. Open mygame/typeclasses/sittables.py:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
# in mygame/typeclasses/sittables.py
+
+from typeclasses.objects import Object
+from commands.sittables import CmdSetSit 
+
+class Sittable(Object):
+    """
+    (docstring)
+    """
+    def at_object_creation(self):
+        self.cmdset.add_default(CmdSetSit)
+    # ... 
+
+
+
    +
  • Line 4: We must install the CmdSetSit .

  • +
  • Line 10: The at_object_creation method will only be called once, when the object is first created.

  • +
  • Line 11: We add the command-set as a ‘default’ cmdset with add_default. This makes it persistent also protects it from being deleted should another cmdset be added. See Command Sets for more info.

  • +
+

Make sure to reload to make the code changes available.

+

All new Sittables will now have your sit Command. Your existing armchair will not though. This is because at_object_creation will not re-run for already existing objects. We can update it manually:

+
> update armchair
+
+
+

We could also update all existing sittables (all on one line):

+ +
> py from typeclasses.sittables import Sittable ;
+       [sittable.at_object_creation() for sittable in Sittable.objects.all()]
+
+
+

We should now be able to use sit while in the room with the armchair.

+
> sit
+As you sit down in armchair, life feels easier.
+> stand
+You stand up from armchair.
+
+
+

One issue with placing the sit (or stand) Command “on” the chair is that it will not be available when in a room without a Sittable object:

+
> sit
+Command 'sit' is not available. ...
+
+
+

This is practical but not so good-looking; it makes it harder for the user to know a sit action is at all possible. Here is a trick for fixing this. Let’s add another Command to the bottom +of mygame/commands/sittables.py:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
# after the other commands in mygame/commands/sittables.py
+# ...
+
+class CmdNoSitStand(Command):
+    """
+    Sit down or Stand up
+    """
+    key = "sit"
+    aliases = ["stand"]
+
+    def func(self):
+        if self.cmdname == "sit":
+            self.msg("You have nothing to sit on.")
+        else:
+            self.msg("You are not sitting down.")
+
+
+
    +
  • Line 9: This command responds both to sit and stand because we added stand to its aliases list. Command aliases have the same ‘weight’ as the key of the command, both equally identify the Command.

  • +
  • Line 12: The .cmdname of a Command holds the name actually used to call it. This will be one of "sit" or "stand". This leads to different return messages.

  • +
+

We don’t need a new CmdSet for this, instead we will add this to the default Character cmdset. Open mygame/commands/default_cmdsets.py:

+
# in mygame/commands/default_cmdsets.py
+
+# ...
+from commands import sittables
+
+class CharacterCmdSet(CmdSet):
+    """
+    (docstring)
+    """
+    def at_cmdset_creation(self):
+        # ...
+        self.add(sittables.CmdNoSitStand)
+
+
+
+

As usual, make sure to reload the server to have the new code recognized.

+

To test we’ll build a new location without any comfy armchairs and go there:

+
> tunnel n = kitchen
+north
+> sit
+You have nothing to sit on.
+> south
+sit
+As you sit down in armchair, life feels easier.
+
+
+

We now have a fully functioning sit action that is contained with the chair itself. When no chair is around, a default error message is shown.

+

How does this work? There are two cmdsets at play, both of which have a sit/stand Command - one on the Sittable (armchair) and the other on us (via the CharacterCmdSet). Since we set a priority=1 on the chair’s cmdset (and CharacterCmdSet has priority=0), there will be no command-collision: the chair’s sit takes precedence over the sit defined on us … until there is no chair around.

+

So this handles sit. What about stand? That will work just fine:

+
> stand
+You stand up from armchair.
+> north
+> stand
+You are not sitting down.
+
+
+

We have one remaining problem with stand though - what happens when you are sitting down and try to stand in a room with more than one Sittable:

+
> stand
+More than one match for 'stand' (please narrow target):
+ stand-1 (armchair)
+ stand-2 (sofa)
+ stand-3 (barstool)
+
+
+

Since all the sittables have the stand Command on them, you’ll get a multi-match error. This works … but you could pick any of those sittables to “stand up from”. That’s really weird.

+

With sit it was okay to get a choice - Evennia can’t know which chair we intended to sit on. But once we sit we sure know from which chair we should stand up from! We must make sure that we only get the command from the chair we are actually sitting on.

+

We will fix this with a Lock and a custom lock function. We want a lock on the stand Command that only makes it available when the caller is actually sitting on the chair that particular stand command is attached to.

+

First let’s add the lock so we see what we want. Open mygame/commands/sittables.py:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
# in mygame/commands/sittables.py
+
+# ...
+
+class CmdStand(Command):
+     """
+     Stand up.
+     """
+     key = "stand"
+     locks = "cmd:sitsonthis()"
+
+     def func(self):
+         self.obj.do_stand(self.caller)
+# ...
+
+
+
    +
  • Line 10: This is the lock definition. It’s on the form condition:lockfunc. The cmd: type lock is checked by Evennia when determining if a user has access to a Command at all. We want the lock-function to only return True if this command is on a chair which the caller is sitting on. +What will be checked is the sitsonthis lock function which doesn’t exist yet.

  • +
+

Open mygame/server/conf/lockfuncs.py to add it!

+
# mygame/server/conf/lockfuncs.py
+
+"""
+(module lockstring)
+"""
+# ...
+
+def sitsonthis(accessing_obj, accessed_obj, *args, **kwargs):
+    """
+    True if accessing_obj is sitting on/in the accessed_obj.
+    """
+    return accessed_obj.obj.db.sitter == accessing_obj
+
+# ...
+
+
+

Evennia knows that all functions in mygame/server/conf/lockfuncs should be possible to use in a lock definition.

+

All lock functions must acccept the same arguments. The arguments are required and Evennia will pass all relevant objects as needed.

+ +
    +
  • accessing_obj is the one trying to access the lock. So us, in this case.

  • +
  • accessed_obj is the entity we are trying to gain a particular type of access to. Since we define the lock on the CmdStand class, this is the command instance. We are however not interested in that, but the object the command is assigned to (the chair). The object is available on the Command as .obj. So here, accessed_obj.obj is the chair.

  • +
  • args is a tuple holding any arguments passed to the lockfunc. Since we use sitsondthis() this will be empty (and if we add anything, it will be ignored).

  • +
  • kwargs is a tuple of keyword arguments passed to the lockfuncs. This will be empty as well in our example.

  • +
+

Make sure you reload.

+

If you are superuser, it’s important that you quell yourself before trying this out. This is because the superuser bypasses all locks - it can never get locked out, but it means it will also not see the effects of a lock like this.

+
> quell
+> stand
+You stand up from armchair
+
+
+

None of the other sittables’ stand commands passed the lock and only the one we are actually sitting on did! This is a fully functional chair now!

+

Adding a Command to the chair object like this is powerful and is a good technique to know. It does come with some caveats though, as we’ve seen.

+

We’ll now try another way to add the sit/stand commands.

+
+
+

13.3.2. Command variant 2: Command on Character

+

Before we start with this, delete the chairs you’ve created:

+
> del armchair 
+> del sofa 
+> (etc)
+
+
+

The make the following changes:

+
    +
  • In mygame/typeclasses/sittables.py, comment out the entire at_object_creation method.

  • +
  • In mygame/commands/default_cmdsets.py, comment out the line self.add(sittables.CmdNoSitStand).

  • +
+

This disables the on-object command solution so we can try an alternative. Make sure to reload so the changes are known to Evennia.

+

In this variation we will put the sit and stand commands on the Character instead of on the chair. This makes some things easier, but makes the Commands themselves more complex because they will not know which chair to sit on. We can’t just do sit anymore. This is how it will work:

+
> sit <chair>
+You sit on chair.
+> stand
+You stand up from chair.
+
+
+

Open mygame/commands/sittables.py again. We’ll add a new sit-command. We name the class CmdSit2 since we already have CmdSit from the previous example. We put everything at the end of the module to keep it separate.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
# in mygame/commands/sittables.py
+
+from evennia import Command, CmdSet
+from evennia import InterruptCommand
+
+class CmdSit(Command):
+    # ...
+
+# ...
+
+# new from here
+
+class CmdSit2(Command):
+    """
+    Sit down.
+
+    Usage:
+        sit <sittable>
+
+    """
+    key = "sit"
+
+    def parse(self):
+        self.args = self.args.strip()
+        if not self.args:
+            self.caller.msg("Sit on what?")
+            raise InterruptCommand
+
+    def func(self):
+
+        # self.search handles all error messages etc.
+        sittable = self.caller.search(self.args)
+        if not sittable:
+            return
+        try:
+            sittable.do_sit(self.caller)
+        except AttributeError:
+            self.caller.msg("You can't sit on that!")
+
+
+ +
    +
  • Line 4: We need the InterruptCommand to be able to abort command parsing early (see below).

  • +
  • Line 27: The parse method runs before the func method on a Command. If no argument is provided to the command, we want to fail early, already in parse, so func never fires. Just return is not enough to do that, we need to raise InterruptCommand. Evennia will see a raised InterruptCommand as a sign it should immediately abort the command execution.

  • +
  • Line 32: We use the parsed command arguments as the target-chair to search for. As discussed in the search tutorial, self.caller.search() will handle error messages itself. So if it returns None, we can just return.

  • +
  • Line 35-38: The try...except block ‘catches’ and exception and handles it. In this case we try to run do_sit on the object. If the object we found is not a Sittable, it will likely not have a do_sit method and an AttributeError will be raised. We should handle that case gracefully.

  • +
+

Let’s do the stand command while we are at it. Since the Command is external to the chair we need to figure out if we are sitting down or not.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
# end of mygame/commands/sittables.py
+
+class CmdStand2(Command):
+    """
+    Stand up.
+
+    Usage:
+        stand
+
+    """
+    key = "stand"
+
+    def func(self):
+
+    caller = self.caller
+    # if we are sitting, this should be set on us
+    sittable = caller.db.is_sitting
+    if not sittable:
+        caller.msg("You are not sitting down.")
+    else:
+        sittable.do_stand(caller)
+
+
+
    +
  • Line 17: We didn’t need the is_sitting Attribute for the first version of these Commands, but we do need it now. Since we have this, we don’t need to search and know just which chair we sit on. If we don’t have this Attribute set, we are not sitting anywhere.

  • +
  • Line 21: We stand up using the sittable we found.

  • +
+

All that is left now is to make sit and stand available to us. This type of Command should be available to us all the time so we can put it in the default Cmdset on the Character. Open mygame/commands/default_cmdsets.py.

+
# in mygame/commands/default_cmdsets.py
+
+# ...
+from commands import sittables
+
+class CharacterCmdSet(CmdSet):
+    """
+    (docstring)
+    """
+    def at_cmdset_creation(self):
+        # ...
+        self.add(sittables.CmdSit2)
+        self.add(sittables.CmdStand2)
+
+
+
+

Make sure to reload.

+

Now let’s try it out:

+
> create/drop sofa : sittables.Sittable
+> sit sofa
+You sit down on sofa.
+> stand
+You stand up from sofa.
+> north 
+> sit sofa 
+> You can't find 'sofa'.
+
+
+

Storing commands on the Character centralizes them, but you must instead search or store any external objects you want that command to interact on.

+
+
+
+

13.4. Conclusions

+

In this lesson we built ourselves a chair and even a sofa!

+
    +
  • We modified our Character class to avoid moving when sitting down.

  • +
  • We made a new Sittable typeclass

  • +
  • We tried two ways to allow a user to interact with sittables using sit and stand commands.

  • +
+

Eagle-eyed readers will notice that the stand command sitting “on” the chair (variant 1) would work just fine together with the sit command sitting “on” the Character (variant 2). There is nothing stopping you from mixing them, or even try a third solution that better fits what you have in mind.

+

This concludes the first part of the Beginner tutorial!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-More-on-Commands.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-More-on-Commands.html new file mode 100644 index 0000000000..8c1fea7e5d --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-More-on-Commands.html @@ -0,0 +1,642 @@ + + + + + + + + + 9. Parsing Command input — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

9. Parsing Command input

+

In this lesson we learn some basics about parsing the input of Commands. We will +also learn how to add, modify and extend Evennia’s default commands.

+
+

9.1. More advanced parsing

+

In the last lesson we made a hit Command and struck a dragon with it. You should have the code +from that still around.

+

Let’s expand our simple hit command to accept a little more complex input:

+
hit <target> [[with] <weapon>]
+
+
+

That is, we want to support all of these forms

+
hit target
+hit target weapon
+hit target with weapon
+
+
+

If you don’t specify a weapon you’ll use your fists. It’s also nice to be able to skip “with” if +you are in a hurry. Time to modify mygame/commands/mycommands.py again. Let us break out the parsing a little, in a new method parse:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
#...
+
+class CmdHit(Command):
+    """
+    Hit a target.
+
+    Usage:
+      hit <target>
+
+    """
+    key = "hit"
+
+    def parse(self):
+        self.args = self.args.strip()
+        target, *weapon = self.args.split(" with ", 1)
+        if not weapon:
+            target, *weapon = target.split(" ", 1)
+        self.target = target.strip()
+        if weapon:
+            self.weapon = weapon[0].strip()
+        else:
+            self.weapon = ""
+
+    def func(self):
+        if not self.args:
+            self.caller.msg("Who do you want to hit?")
+            return
+        # get the target for the hit
+        target = self.caller.search(self.target)
+        if not target:
+            return
+        # get and handle the weapon
+        weapon = None
+        if self.weapon:
+            weapon = self.caller.search(self.weapon)
+        if weapon:
+            weaponstr = f"{weapon.key}"
+        else:
+            weaponstr = "bare fists"
+
+        self.caller.msg(f"You hit {target.key} with {weaponstr}!")
+        target.msg(f"You got hit by {self.caller.key} with {weaponstr}!")
+# ...
+
+
+

The parse method is a special one Evennia knows to call before func. At this time it has access to all the same on-command variables as func does. Using parse not only makes things a little easier to read, it also means you can easily let other Commands inherit your parsing - if you wanted some other Command to also understand input on the form <arg> with <arg> you’d inherit from this class and just implement the func needed for that command without implementing parse anew.

+ +
    +
  • Line 14 - We do the stripping of self.args once and for all here. We also store the stripped version back +into self.args, overwriting it. So there is no way to get back the non-stripped version from here on, which is fine +for this command.

  • +
  • Line 15 - This makes use of the .split method of strings. .split will, well, split the string by some criterion. +.split(" with ", 1) means “split the string once, around the substring " with " if it exists”. The result +of this split is a list. Just how that list looks depends on the string we are trying to split:

    +
      +
    1. If we entered just hit smaug, we’d be splitting just "smaug" which would give the result ["smaug"].

    2. +
    3. hit smaug sword gives ["smaug sword"]

    4. +
    5. hit smaug with sword gives ["smaug", "sword"]

    6. +
    +

    So we get a list of 1 or 2 elements. We assign it to two variables like this, target, *weapon = . That asterisk in *weapon is a nifty trick - it will automatically become a tuple of 0 or more values. It sorts of “soaks” up everything left over.

    +
      +
    1. target becomes "smaug" and weapon becomes () (an empty tuple)

    2. +
    3. target becomes "smaug sword" and weapon becomes ()

    4. +
    5. target becomes "smaug" and weapon becomes ("sword",) (this is a tuple with one element, the comma is required to indicate this).

    6. +
    +
  • +
  • Lines 16-17 - In this if condition we check if weapon is falsy (that is, the empty list). This can happen +under two conditions (from the example above):

    +
      +
    1. target is simply smaug

    2. +
    3. target is smaug sword

    4. +
    +

    To separate these cases we split target once again, this time by empty space " ". Again we store the result back with target, *weapon =. The result will be one of the following:

    +
      +
    1. target remains "smaug" and weapon remains []

    2. +
    3. target becomes "smaug" and weapon becomes ("sword",)

    4. +
    +
  • +
  • Lines 18-22 - We now store target and weapon into self.target and self.weapon. We must store on self in order for these local variables to become available in func later. Note that once we know that weapon exists, it must be a tuple (like ("sword",)), so we use weapon[0] to get the first element of that tuple (tuples and lists in Python are indexed from 0). The instruction weapon[0].strip() can be read as “get the first string stored in the tuple weapon and remove all extra whitespace on it with .strip()”. If we forgot the [0] here, we’d get an error since a tuple (unlike the string inside the tuple) does not have the .strip() method.

  • +
+

Now onto the func method. The main difference is we now have self.target and self.weapon available for convenient use.

+ +
    +
  • Lines 29 and 35 - We make use of the previously parsed search terms for the target and weapon to find the +respective resource.

  • +
  • Lines 34-39 - Since the weapon is optional, we need to supply a default (use our fists!) if it’s not set. We +use this to create a weaponstr that is different depending on if we have a weapon or not.

  • +
  • Lines 41-42 - We merge the weaponstr with our attack texts and send it to attacker and target respectively. +Let’s try it out!

    +
    +

    reload +hit smaug with sword +Could not find ‘sword’. +You hit smaug with bare fists!

    +
    +
  • +
+

Oops, our self.caller.search(self.weapon) is telling us that it found no sword. This is reasonable (we don’t have a sword). Since we are not returning when failing to find a weapon in the way we do if we find no target, we still continue fighting with our bare hands.

+

This won’t do. Let’s make ourselves a sword:

+
> create sword
+
+
+

Since we didn’t specify /drop, the sword will end up in our inventory and can seen with the i or +inventory command. The .search helper will still find it there. There is no need to reload to see this +change (no code changed, only stuff in the database).

+
> hit smaug with sword
+You hit smaug with sword!
+
+
+

Poor Smaug.

+
+
+

9.2. Adding a Command to an object

+ +

As we learned in the lesson about Adding commands, Commands are are grouped in Command Sets. Such Command Sets are attached to an object with obj.cmdset.add() and will then be available for that object to use.

+

What we didn’t mention before is that by default those commands are also available to those in the same location as that object. If you did the Building quickstart lesson you’ve seen an example of this with the “Red Button” object. The Tutorial world also has many examples of objects with commands on them.

+

To show how this could work, let’s put our ‘hit’ Command on our simple sword object from the previous section.

+
> py self.search("sword").cmdset.add("commands.mycommands.MyCmdSet", persistent=True)
+
+
+

We find the sword (it’s still in our inventory so self.search should be able to find it), then +add MyCmdSet to it. This actually adds both hit and echo to the sword, which is fine.

+

Let’s try to swing it!

+
> hit
+More than one match for 'hit' (please narrow target):
+hit-1 (sword #11)
+hit-2
+
+
+ +

Woah, that didn’t go as planned. Evennia actually found two hit commands and didn’t know which one to use (we know they are the same, but Evennia can’t be sure of that). As we can see, hit-1 is the one found on the sword. The other one is from adding MyCmdSet to ourself earlier. It’s easy enough to tell Evennia which one you meant:

+
> hit-1
+Who do you want to hit?
+> hit-2
+Who do you want to hit?
+
+
+

In this case we don’t need both command-sets, so let’s just keep the one on the sword:

+
> py self.cmdset.remove("commands.mycommands.MyCmdSet")
+> hit
+Who do you want to hit?
+
+
+

Now try making a new location and then drop the sword in it.

+
> tunnel n = kitchen
+> n
+> drop sword
+> s
+> hit
+Command 'hit' is not available. Maybe you meant ...
+> n
+> hit
+Who do you want to hit?
+
+
+

The hit command is only available if you hold or are in the same room as the sword.

+
+

9.2.1. You need to hold the sword!

+ +

Let’s get a little ahead of ourselves and make it so you have to hold the sword for the hit command to be available. This involves a Lock. We’ll cover locks in more detail later, just know that they are useful for limiting the kind of things you can do with an object, including limiting just when you can call commands on it.

+
> py self.search("sword").locks.add("call:holds()")
+
+
+

We added a new lock to the sword. The lockstring "call:holds()" means that you can only call commands on this object if you are holding the object (that is, it’s in your inventory).

+

For locks to work, you cannot be superuser, since the superuser passes all locks. You need to quell yourself first:

+ +
> quell
+
+
+

If the sword lies on the ground, try

+
> hit
+Command 'hit' is not available. ..
+> get sword
+> hit
+> Who do you want to hit?
+
+
+

Finally, we get rid of ours sword so we have a clean slate with no more hit commands floating around. We can do that in two ways:

+
delete sword
+
+
+

or

+
py self.search("sword").delete()
+
+
+
+
+
+

9.3. Adding the Command to a default Cmdset

+

As we have seen we can use obj.cmdset.add() to add a new cmdset to objects, whether that object is ourself (self) or other objects like the sword. Doing this this way is a little cumbersome though. It would be better to add this to all characters.

+

The default cmdset are defined in mygame/commands/default_cmdsets.py. Open that file now:

+
"""
+(module docstring)
+"""
+
+from evennia import default_cmds
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones
+        #
+
+class AccountCmdSet(default_cmds.AccountCmdSet):
+
+    key = "DefaultAccount"
+
+    def at_cmdset_creation(self):
+
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones
+        #
+
+class UnloggedinCmdSet(default_cmds.UnloggedinCmdSet):
+
+    key = "DefaultUnloggedin"
+
+    def at_cmdset_creation(self):
+
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones
+        #
+
+class SessionCmdSet(default_cmds.SessionCmdSet):
+
+    key = "DefaultSession"
+
+    def at_cmdset_creation(self):
+
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones
+        #
+
+
+ +

evennia.default_cmds is a container that holds all of Evennia’s default commands and cmdsets. In this module we can see that this was imported and then a new child class was made for each cmdset. Each class looks familiar (except the key, that’s mainly used to easily identify the cmdset in listings). In each at_cmdset_creation all we do is call super().at_cmdset_creation which means that we call `at_cmdset_creation() on the parent CmdSet. +This is what adds all the default commands to each CmdSet.

+

When the DefaultCharacter (or a child of it) is created, you’ll find that the equivalence of self.cmdset.add("default_cmdsets.CharacterCmdSet, persistent=True") gets called. This means that all new Characters get this cmdset. After adding more commands to it, you just need to reload to have all characters see it.

+
    +
  • Characters (that is ‘you’ in the gameworld) has the CharacterCmdSet.

  • +
  • Accounts (the thing that represents your out-of-character existence on the server) has the AccountCmdSet

  • +
  • Sessions (representing one single client connection) has the SessionCmdSet

  • +
  • Before you log in (at the connection screen) your Session have access to the UnloggedinCmdSet.

  • +
+

For now, let’s add our own hit and echo commands to the CharacterCmdSet:

+
# ...
+
+from commands import mycommands
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones
+        #
+        self.add(mycommands.CmdEcho)
+        self.add(mycommands.CmdHit)
+
+
+
+
> reload
+> hit
+Who do you want to hit?
+
+
+

Your new commands are now available for all player characters in the game. There is another way to add a bunch of commands at once, and that is to add your own CmdSet to the other cmdset.

+
from commands import mycommands
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones
+        #
+        self.add(mycommands.MyCmdSet)
+
+
+

Which way you use depends on how much control you want, but if you already have a CmdSet, +this is practical. A Command can be a part of any number of different CmdSets.

+
+

9.3.1. Removing Commands

+

To remove your custom commands again, you of course just delete the change you did to +mygame/commands/default_cmdsets.py. But what if you want to remove a default command?

+

We already know that we use cmdset.remove() to remove a cmdset. It turns out you can +do the same in at_cmdset_creation. For example, let’s remove the default get Command +from Evennia. If you investigate the default_cmds.CharacterCmdSet parent, you’ll find that its class is default_cmds.CmdGet (the ‘real’ location is evennia.commands.default.general.CmdGet).

+
# ...
+from commands import mycommands
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones
+        #
+        self.add(mycommands.MyCmdSet)
+        self.remove(default_cmds.CmdGet)
+# ...
+
+
+
> reload
+> get
+Command 'get' is not available ...
+
+
+
+
+
+

9.4. Replace a default command

+

At this point you already have all the pieces for how to do this! We just need to add a new +command with the same key in the CharacterCmdSet to replace the default one.

+

Let’s combine this with what we know about classes and how to override a parent class. Open mygame/commands/mycommands.py and make a new get command:

+
1
+2
+3
+4
+5
+6
+7
+8
+9
# up top, by the other imports
+from evennia import default_cmds
+
+# somewhere below
+class MyCmdGet(default_cmds.CmdGet):
+
+    def func(self):
+        super().func()
+        self.caller.msg(str(self.caller.location.contents))
+
+
+
    +
  • Line 2: We import default_cmds so we can get the parent class. +We made a new class and we make it inherit default_cmds.CmdGet. We don’t +need to set .key or .parse, that’s already handled by the parent. +In func we call super().func() to let the parent do its normal thing,

  • +
  • Line 7: By adding our own func we replace the one in the parent.

  • +
  • Line 8: For this simple change we still want the command to work the +same as before, so we use super() to call func on the parent.

  • +
  • Line 9: .location is the place an object is at. .contents contains, well, the +contents of an object. If you tried py self.contents you’d get a list that equals +your inventory. For a room, the contents is everything in it. +So self.caller.location.contents gets the contents of our current location. This is +a list. In order send this to us with .msg we turn the list into a string. Python +has a special function str() to do this.

  • +
+

We now just have to add this so it replaces the default get command. Open +mygame/commands/default_cmdsets.py again:

+
# ...
+from commands import mycommands
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones
+        #
+        self.add(mycommands.MyCmdSet)
+        self.add(mycommands.MyCmdGet)
+# ...
+
+
+

We don’t need to use self.remove() first; just adding a command with the same key (get) will replace the default get we had from before.

+ +
> reload
+> get
+Get What?
+[smaug, fluffy, YourName, ...]
+
+
+

We just made a new get-command that tells us everything we could pick up (well, we can’t pick up ourselves, so there’s some room for improvement there …).

+
+
+

9.5. Summary

+

In this lesson we got into some more advanced string formatting - many of those tricks will help you a lot in the future! We also made a functional sword. Finally we got into how to add to, extend and replace a default command on ourselves. Knowing to add commands is a big part of making a game!

+

We have been beating on poor Smaug for too long. Next we’ll create more things to play around with.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Part1-Overview.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Part1-Overview.html new file mode 100644 index 0000000000..e98c3e7e26 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Part1-Overview.html @@ -0,0 +1,273 @@ + + + + + + + + + Part 1: What We Have — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Part 1: What We Have

+ +

In this first part, we’ll focus on what we have out of the box with Evennia. We’ll familiarize you with the tools and how to find things that you are looking for. We will also dive into some of the things you’ll need to know to utilize the system fully, including giving you a brief rundown of Python concepts. If you are an experienced Python programmer, some sections may feel a bit basic – yet seeing things in the context of Evennia will still be worthwhile.

+
+

Lessons

+
+ +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-basic-introduction.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-basic-introduction.html new file mode 100644 index 0000000000..8e7f86c8c3 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-basic-introduction.html @@ -0,0 +1,686 @@ + + + + + + + + + 3. Intro to using Python with Evennia — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

3. Intro to using Python with Evennia

+

Time to dip our toe into some coding! Evennia is written and extended in Python. Python is a mature and professional programming language that is very fast to work with.

+

That said, even though Python is widely considered easy to learn, we can only cover the basics in these lessons. While we will hopefully get you started with the most important bits you need, you may likely need to compliment with some learning on your own. Luckily there’s a vast amount of free online learning resources available for Python. See our link section for some examples.

+
+

While this will be quite basic if you are an experienced developer, you may want to at least stay around for the first few sections where we cover how to run Python from inside Evennia.

+
+

First, if you were quelling yourself to play the tutorial world, make sure to get your +superuser powers back:

+
   unquell
+
+
+
+

3.1. Evennia Hello world

+

The py Command (or !, which is an alias) allows you as a superuser to execute raw Python from in-game. This is useful for quick testing. From the game’s input line, enter the following:

+
> py print("Hello World!")
+
+
+ +

You will see

+
> print("Hello world!")
+Hello World!
+
+
+

The print(...) function is the basic, in-built way to output text in Python. We are sending “Hello World” as a single argument to this function. If we were to send multiple arguments, they’d be separated by commas.

+

The quotes "..." mean that you are inputting a string (i.e. text). You could also have used single-quotes '...' - Python accepts both.

+
+

A third way to enter Python strings is to use triple-quotes ("""...""" or '''...'''. This is used for longer strings stretching across multiple lines. When we insert code directly to py like this we can only use one line though.

+
+
+
+

3.2. Making some text ‘graphics’

+

When making a text-game you will, unsurprisingly, be working a lot with text. Even if you have the occational button or even graphical element, the normal process is for the user to input commands as text and get text back. As we saw above, a piece of text is called a string in Python and is enclosed in either single- or double-quotes.

+

Strings can be added together:

+
> py print("This is a " + "breaking change.")
+This is a breaking change.
+
+
+

A string multiplied with a number will repeat that string as many times:

+
> py print("|" + "-" * 40 + "|")
+|----------------------------------------|
+
+
+

or

+
> py print("A" + "a" * 5 + "rgh!")
+Aaaaaargh!
+
+
+
+

3.2.1. .format()

+ +

While combining different strings is useful, even more powerful is the ability to modify the contents of the string in-place. There are several ways to do this in Python and we’ll show two of them here. The first is to use the .format method of the string:

+
> py print("This is a {} idea!".format("good"))
+This is a good idea!
+
+
+

A method can be thought of as a resource “on” another object. The method knows on which object it sits and can thus affect it in various ways. You access it with the period .. In this case, the string has a resource format(...) that modifies it. More specifically, it replaced the {} marker inside the string with the value passed to the format. You can do so many times:

+
> py print("This is a {} idea!".format("good"))
+This is a good idea!
+
+
+

or

+
> py print("This is the {} and {} {} idea!".format("first", "second", "great"))
+This is the first and second great idea!
+
+
+
+

Note the double-parenthesis at the end - the first closes the format(... method and the outermost closes the print(.... Not closing them will give you a scary SyntaxError. We will talk a little more about errors in the next section, for now just fix until it prints as expected.

+
+

Here we passed three comma-separated strings as arguments to the string’s format method. These replaced the {} markers in the same order as they were given.

+

The input does not have to be strings either:

+
> py print("STR: {}, DEX: {}, INT: {}".format(12, 14, 8))
+STR: 12, DEX: 14, INT: 8
+
+
+

To separate two Python instructions on the same line, you use the semi-colon, ;. Try this:

+
> py a = "awesome sauce" ; print("This is {}!".format(a))
+This is awesome sauce!
+
+
+
+

Warning

+

MUD clients and semi-colon

+

Some MUD clients use the semi-colon ; to split client-inputs +into separate sends. If so, the above will give an error. Most clients allow you to run in ‘verbatim’ mode or to remap to use some other separator than ;. If you still have trouble, use the Evennia web client.

+
+

What happened here was that we assigned the string "awesome sauce" to a variable we chose to name a. In the next statement, Python remembered what a was and we passed that into format() to get the output. If you replaced the value of a with something else in between, that would be printed instead.

+

Here’s the stat-example again, moving the stats to variables (here we just set them, but in a real game they may be changed over time, or modified by circumstance):

+
> py stren, dext, intel = 13, 14, 8 ; print("STR: {}, DEX: {}, INT: {}".format(stren, dext, intel))
+STR: 13, DEX: 14, INT: 8
+
+
+

The point is that even if the values of the stats change, the print() statement would not change - it just keeps pretty-printing whatever is given to it.

+

You can also use named markers, like this:

+
 > py print("STR: {stren}, INT: {intel}, STR again: {stren}".format(dext=10, intel=18, stren=9))
+ STR: 9, INT: 18, Str again: 9
+
+
+

the key=value pairs we add are called keyword arguments for the format() method. Each named argument will go to the matching {key} in the string. When using keywords, the order we add them doesn’t matter. We have no {dext} and two {stren} in the string, and that works fine.

+
+
+

3.2.2. f-strings

+

Using .format() is powerful (and there is a lot more you can do with it). But the f-string can be even more convenient. An f-string looks like a normal string … except there is an f front of it, like this:

+
f"this is now an f-string."
+
+
+

An f-string on its own is just like any other string. But let’s redo the example we did before, using an f-string:

+
> py a = "awesome sauce" ; print(f"This is {a}!")
+This is awesome sauce!
+
+
+

We insert that a variable directly into the f-string using {a}. Fewer parentheses to +remember and arguable easier to read as well!

+
> py stren, dext, intel = 13, 14, 8 ; print(f"STR: {stren}, DEX: {dext}, INT: {intel}")
+STR: 13, DEX: 14, INT: 8
+
+
+

In modern Python code, f-strings are more often used than .format() but to read code you need to be aware of both.

+

We will be exploring more complex string concepts when we get to creating Commands and need to parse and understand player input.

+
+
+

3.2.3. Colored text

+

Python itself knows nothing about colored text, this is an Evennia thing. Evennia supports the standard color schemes of traditional MUDs.

+
> py print("|rThis is red text!|n This is normal color.")
+
+
+

Adding that |r at the start will turn our output bright red. |R will make it dark red. |n +gives the normal text color. You can also use RGB (Red-Green-Blue) values from 0-5 (Xterm256 colors):

+
> py print("|043This is a blue-green color.|[530|003 Now dark blue text on orange background.")
+
+
+
+

If you don’t see the expected color, your client or terminal may not support Xterm256 (or +color at all). Use the Evennia webclient.

+
+

Use the commands color ansi or color xterm to see which colors are available. Experiment! You can also read a lot more in the Colors documentation.

+
+
+
+

3.3. Importing code from other modules

+

As we saw in the previous sections, we used .format to format strings and me.msg to access the msg method on me. This use of the full-stop character is used to access all sorts of resources, including that in other Python modules.

+

Keep your game running, then open a text editor of your choice. If your game folder is called +mygame, create a new text file test.py in the subfolder mygame/world. This is how the file +structure should look:

+
mygame/
+    world/
+        test.py
+
+
+

For now, only add one line to test.py:

+
print("Hello World!")
+
+
+ +

Don’t forget to save the file. We just created our first Python module! +To use this in-game we have to import it. Try this:

+
> py import world.test
+Hello World
+
+
+

If you make some error (we’ll cover how to handle errors below), make sure the text looks exactly like above and then run the reload command in-game for your changes to take effect.

+

… So as you can see, importing world.test actually means importing world/test.py. Think of the period . as replacing / (or \ for Windows) in your path.

+

The .py ending of test.py is never included in this “Python-path”, but only files with that ending can be imported this way. Where is mygame in that Python-path? The answer is that Evennia has already told Python that your mygame folder is a good place to look for imports. So we should not include mygame in the path - Evennia handles this for us.

+

When you import the module, the top “level” of it will execute. In this case, it will immediately +print “Hello World”.

+

Now try to run this a second time:

+
> py import world.test
+
+
+

You will not see any output this or any subsequent times! This is not a bug. Rather it is because of how Python importing works - it stores all imported modules and will avoid importing them more than once. So your print will only run the first time, when the module is first imported.

+

Try this:

+
> reload
+
+
+

And then

+
> py import world.test
+Hello World!
+
+
+

Now we see it again. The reload wiped the server’s memory of what was imported, so it had to import it anew. You’d have to do this every time you wanted the hello-world to show, which is not very useful.

+
+

We’ll get back to more advanced ways to import code in a later lesson - this is an important topic. But for now, let’s press on and resolve this particular problem.

+
+
+

3.3.1. Our first own function

+

We want to be able to print our hello-world message at any time, not just once after a server +reload. Change your mygame/world/test.py file to look like this:

+
def hello_world():
+    print("Hello World!")
+
+
+ +

As we are moving to multi-line Python code, there are some important things to remember:

+
    +
  • Capitalization matters in Python. It must be def and not DEF, hello_world() is not the same as Hello_World().

  • +
  • Indentation matters in Python. The second line must be indented or it’s not valid code. You should also use a consistent indentation length. We strongly recommend that you, for your own sanity’s sake, set up your editor to always indent 4 spaces (not a single tab-character) when you press the TAB key.

  • +
+

So about that function. Line 1:

+
    +
  • def is short for “define” and defines a function (or a method, if sitting on an object). This is a reserved Python keyword; try not to use these words anywhere else.

  • +
  • A function name can not have spaces but otherwise we could have called it almost anything. We call it hello_world. Evennia follows Python’s standard naming style with lowercase letters and underscores. We recommend you do the same.

  • +
  • The colon (:) at the end of line 1 indicates that the header of the function is complete.

  • +
+

Line 2:

+
    +
  • The indentation marks the beginning of the actual operating code of the function (the function’s body). If we wanted more lines to belong to this function those lines would all have to start at least at this indentation level.

  • +
+

Now let’s try this out. First reload your game to have it pick up our updated Python module, then import it.

+
> reload
+> py import world.test
+
+
+

Nothing happened! That is because the function in our module won’t do anything just by importing it (this is what we wanted). It will only act when we call it. So we need to first import the module and then access the function within:

+
> py import world.test ; world.test.hello_world()
+Hello world!
+
+
+

There is our “Hello World”! As mentioned earlier, use use semi-colon to put multiple Python-statements on one line. Note also the previous warning about mud-clients using the ; to their own ends.

+

So what happened there? First we imported world.test as usual. But this time the ‘top level’ of the module only defined a function. It didn’t actually execute the body of that function.

+

By adding () to the hello_world function we call it. That is, we execute the body of the function and print our text. We can now redo this as many times as we want without having to reload in between:

+
> py import world.test ; world.test.hello_world()
+Hello world!
+> py import world.test ; world.test.hello_world()
+Hello world!
+
+
+
+
+
+

3.4. Sending text to others

+

The print command is a standard Python structure. We can use that here in the py command since we can se the output. It’s great for debugging and quick testing. But if you need to send a text to an actual player, print won’t do, because it doesn’t know who to send to. Try this:

+
> py me.msg("Hello world!")
+Hello world!
+
+
+

This looks the same as the print result, but we are now actually messaging a specific object, me. The me is a shortcut to ‘us’, the one running the py command. It is not some special Python thing, but something Evennia just makes available in the py command for convenience (self is an alias).

+

The me is an example of an Object instance. Objects are fundamental in Python and Evennia. The me object also contains a lot of useful resources for doing things with that object. We access those resources with ‘.’.

+

One such resource is msg, which works like print except it sends the text to the object it +is attached to. So if we, for example, had an object you, doing you.msg(...) would send a message to the object you.

+

For now, print and me.msg behaves the same, just remember that print is mainly used for +debugging and .msg() will be more useful for you in the future.

+
+
+

3.5. Parsing Python errors

+

Let’s try this new text-sending in the function we just created. Go back to +your test.py file and Replace the function with this instead:

+
def hello_world():
+    me.msg("Hello World!")
+
+
+

Save your file and reload your server to tell Evennia to re-import new code, +then run it like before:

+
 > py import world.test ; world.test.hello_world()
+
+
+

No go - this time you get an error!

+
File "./world/test.py", line 2, in hello_world
+    me.msg("Hello World!")
+NameError: name 'me' is not defined
+
+
+ +

This is called a traceback. Python’s errors are very friendly and will most of the time tell you exactly what and where things go wrong. It’s important that you learn to parse tracebacks so you know how to fix your code.

+

A traceback is to be read from the bottom up:

+
    +
  • (line 3) An error of type NameError is the problem …

  • +
  • (line 3) … more specifically it is due to the variable me not being defined.

  • +
  • (line 2) This happened on the line me.msg("Hello world!")

  • +
  • (line 1) … which is on line 2 of the file ./world/test.py.

  • +
+

In our case the traceback is short. There may be many more lines above it, tracking just how +different modules called each other until the program got to the faulty line. That can +sometimes be useful information, but reading from the bottom is always a good start.

+

The NameError we see here is due to a module being its own isolated thing. It knows nothing about the environment into which it is imported. It knew what print is because that is a special reserved Python keyword. But me is not such a reserved word (as mentioned, it’s just something Evennia came up with for convenience in the py command). As far as the module is concerned me is an unfamiliar name, appearing out of nowhere. Hence the NameError.

+
+
+

3.6. Passing arguments to functions

+

We know that me exists at the point when we run the py command, because we can do py me.msg("Hello World!") with no problem. So let’s pass that me along to the function so it knows what it should be. Go back to your test.py and change it to this:

+
def hello_world(who):
+    who.msg("Hello World!")
+
+
+

We now added an argument to the function. We could have named it anything. Whatever who is, we will call a method .msg() on it.

+

As usual, reload the server to make sure the new code is available.

+
> py import world.test ; world.test.hello_world(me)
+Hello World!
+
+
+

Now it worked. We passed me to our function. It will appear inside the function renamed as who and now the function works and prints as expected. Note how the hello_world function doesn’t care what you pass into it as long as it has a .msg() method on it. So you could reuse this function over and over for other suitable targets.

+
+

Extra Credit: As an exercise, try to pass something else into hello_world. Try for example +to pass the number 5 or the string "foo". You’ll get errors telling you that they don’t have +the attribute msg. They don’t care about me itself not being a string or a number. If you are +familiar with other programming languages (especially C/Java) you may be tempted to start validating who to make sure it’s of the right type before you send it. This is usually not recommended in Python. Python philosophy is to handle the error if it happens +rather than to add a lot of code to prevent it from happening. See duck typing +and the concept of Leap before you Look.

+
+
+
+

3.7. Finding others to send to

+

Let’s wrap up this first Python py crash-course by finding someone else to send to.

+

In Evennia’s contrib/ folder (evennia/contrib/tutorial_examples/mirror.py) is a handy little object called the TutorialMirror. The mirror will echo whatever is being sent to it to +the room it is in.

+

On the game command-line, let’s create a mirror:

+
> create/drop mirror:contrib.tutorials.mirror.TutorialMirror
+
+
+ +

A mirror should appear in your location.

+
> look mirror
+mirror shows your reflection:
+This is User #1
+
+
+

What you are seeing is actually your own avatar in the game, the same thing that is available as me in the py command.

+

What we are aiming for now is the equivalent of mirror.msg("Mirror Mirror on the wall"). But the first thing that comes to mind will not work:

+
> py mirror.msg("Mirror, Mirror on the wall ...")
+NameError: name 'mirror' is not defined.
+
+
+

This is not surprising: Python knows nothing about “mirrors” or locations or anything. The me we’ve been using is, as mentioned, just a convenient thing the Evennia devs makes available to the py command. They couldn’t possibly predict that you wanted to talk to mirrors.

+

Instead we will need to search for that mirror object before we can send to it. Make sure you are in the same location as the mirror and try:

+
> py me.search("mirror")
+mirror
+
+
+

me.search("name") will, by default, search and return an object with the given name found in the same location as the me object is. If it can’t find anything you’ll see an error.

+ +
> py me.search("dummy")
+Could not find 'dummy'.
+
+
+

Wanting to find things in the same location is very common, but as we continue we’ll +find that Evennia provides ample tools for tagging, searching and finding things from all over your game.

+

Now that we know how to find the ‘mirror’ object, we just need to use that instead of me!

+
> py mirror = self.search("mirror") ; mirror.msg("Mirror, Mirror on the wall ...")
+mirror echoes back to you:
+"Mirror, Mirror on the wall ..."
+
+
+

The mirror is useful for testing because its .msg method just echoes whatever is sent to it back to the room. More common would be to talk to a player character, in which case the text you sent would have appeared in their game client.

+
+
+

3.8. Multi-line py

+

So far we have use py in single-line mode, using ; to separate multiple inputs. This is very convenient when you want to do some quick testing. But you can also start a full multi-line Python interactive interpreter inside Evennia.

+
> py
+Evennia Interactive Python mode
+Python 3.11.0 (default, Nov 22 2022, 11:21:55)
+[GCC 8.2.0] on Linux
+[py mode - quit() to exit]
+
+
+

(the details of the output will vary with your Python version and OS). You are now in python interpreter mode. It means +that everything you insert from now on will become a line of Python (you can no longer look around or do other +commands).

+
> print("Hello World")
+
+>>> print("Hello World")
+Hello World
+[py mode - quit() to exit]
+
+
+

Note that we didn’t need to put py in front now. The system will also echo your input (that’s the bit after the >>>). For brevity in this tutorual we’ll turn the echo off. First exit py and then start again with the /noecho flag.

+
> quit()
+Closing the Python console.
+> py/noecho
+Evennia Interactive Python mode (no echoing of prompts)
+Python 3.11.0 (default, Nov 22 2022, 11:21:56)
+[GCC 8.2.0] on Linux
+[py mode - quit() to exit]
+
+
+ +

We can now enter multi-line Python code:

+
> a = "Test"
+> print(f"This is a {a}.")
+This is a Test.
+
+
+

Let’s try to define a function:

+
> def hello_world(who, txt):
+...
+>     who.msg(txt)
+...
+>
+[py mode - quit() to exit]
+
+
+

Some important things above:

+
    +
  • Definining a function with def means we are starting a new code block. Python works so that you mark the content +of the block with indention. So the next line must be manually indented (4 spaces is a good standard) in order +for Python to know it’s part of the function body.

  • +
  • We expand the hello_world function with another argument txt. This allows us to send any text, not just +“Hello World” over and over.

  • +
  • To tell py that no more lines will be added to the function body, we end with an empty input. When the normal prompt returns, we know we are done.

  • +
+

Now we have defined a new function. Let’s try it out:

+
> hello_world(me, "Hello world to me!")
+Hello world to me!
+
+
+

The me is still available to us, so we pass that as the who argument, along with a little longer +string. Let’s combine this with searching for the mirror.

+
> mirror = me.search("mirror")
+> hello_world(mirror, "Mirror, Mirror on the wall ...")
+mirror echoes back to you:
+"Mirror, Mirror on the wall ..."
+
+
+

Exit the py mode with

+
> quit()
+Closing the Python console.
+
+
+
+
+

3.9. Other ways to test Python code

+

The py command is very powerful for experimenting with Python in-game. It’s great for quick testing. +But you are still limited to working over telnet or the webclient, interfaces that doesn’t know anything +about Python per-se.

+

Outside the game, go to the terminal where you ran Evennia (or any terminal where the evennia command +is available).

+
    +
  • cd to your game dir.

  • +
  • evennia shell

  • +
+

A Python shell opens. This works like py did inside the game, with the exception that you don’t have +me available out of the box. If you want me, you need to first find yourself:

+
> import evennia
+> me = evennia.search_object("YourChar")[0]
+
+
+

Here we make use of one of evennia’s search functions, available by importing evennia directly. +We will cover more advanced searching later, but suffice to say, you put your own character name instead of +“YourChar” above.

+
+

The [0] at the end is because .search_object returns a list of objects and we want to +get at the first of them (counting starts from 0).

+
+

Use Ctrl-D (Cmd-D on Mac) or quit() to exit the Python console.

+
+
+

3.10. ipython

+

The default Python shell is quite limited and ugly. It’s highly recommended to install ipython instead. This +is a much nicer, third-party Python interpreter with colors and many usability improvements.

+
pip install ipython
+
+
+

If ipython is installed, evennia shell will use it automatically.

+
evennia shell
+...
+IPython 7.4.0 -- An enhanced Interactive Python. Type '?' for help
+In [1]: You now have Tab-completion:
+
+> import evennia
+> evennia.<TAB>
+
+
+

That is, enter evennia. and then press the TAB key - you will be given a list of all the resources +available on the evennia object. This is great for exploring what Evennia has to offer. For example, +use your arrow keys to scroll to search_object() to fill it in.

+
> evennia.search_object?
+
+
+

Adding a ? and pressing return will give you the full documentation for .search_object. Use ?? if you +want to see the entire source code.

+

As for the normal python interpreter, use Ctrl-D/Cmd-D or quit() to exit ipython.

+
+

Important

+

Persistent code

+

Common for both py and python/ipython is that the code you write is not persistent - it will +be gone after you shut down the interpreter (but ipython will remember your input history). For making long-lasting +Python code, we need to save it in a Python module, like we did for world/test.py.

+
+
+
+

3.11. Conclusions

+

This covers quite a lot of basic Python usage. We printed and formatted strings, defined our own +first function, fixed an error and even searched and talked to a mirror! Being able to access +python inside and outside of the game is an important skill for testing and debugging, but in +practice you will be writing most your code in Python modules.

+

To that end we also created a first new Python module in the mygame/ game dir, then imported and used it. Now let’s look at the rest of the stuff you’ve got going on inside that mygame/ folder …

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-classes-and-objects.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-classes-and-objects.html new file mode 100644 index 0000000000..f007684d57 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-classes-and-objects.html @@ -0,0 +1,534 @@ + + + + + + + + + 5. Introduction to Python classes and objects — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

5. Introduction to Python classes and objects

+

We have now learned how to run some simple Python code from inside (and outside) your game server. +We have also taken a look at what our game dir looks and what is where. Now we’ll start to use it.

+
+

5.1. Importing things

+

In a previous lesson we already learned how to import resources into our code. Now we’ll dive a little deeper.

+

No one writes something as big as an online game in one single huge file. Instead one breaks up the code into separate files (modules). Each module is dedicated to different purposes. Not only does it make things cleaner, organized and easier to understand.

+

Splitting code also makes it easier to re-use - you just import the resources you need and know you only get just what you requested. This makes it easier to spot errors and to know what code is good and which has issues.

+
+

Evennia itself uses your code in the same way - you just tell it where a particular type of code is, +and it will import and use it (often instead of its defaults).

+
+

Here’s a familiar example:

+
> py import world.test ; world.test.hello_world(me)
+Hello World!
+
+
+

In this example, on your hard drive, the files looks like this:

+
mygame/
+    world/
+        test.py    <- inside this file is a function hello_world
+
+
+
+

If you followed earlier tutorial lessons, the mygame/world/test.py file should look like this (if +not, make it so):

+
def hello_world(who):
+    who.msg("Hello World!")
+
+
+ +

To reiterate, the python_path describes the relation between Python resources, both between and inside Python modules (that is, files ending with .py). Paths use . and always skips the .py file endings. Also, Evennia already knows to start looking for python resources inside mygame/ so this should never be included.

+
import world.test
+
+
+

The import Python instruction loads world.test so you have it available. You can now go “into” +this module to get to the function you want:

+
world.test.hello_world(me)
+
+
+

Using import like this means that you have to specify the full world.test every time you want +to get to your function. Here’s an alternative:

+
from world.test import hello_world
+
+
+

The from ... import ... is very, very common as long as you want to get something with a longer +python path. It imports hello_world directly, so you can use it right away!

+
 > py from world.test import hello_world ; hello_world(me)
+ Hello World!
+
+
+

Let’s say your test.py module had a bunch of interesting functions. You could then import them +all one by one:

+
from world.test import hello_world, my_func, awesome_func
+
+
+

If there were a lot of functions, you could instead just import test and get the function +from there when you need (without having to give the full world.test every time):

+
> from world import test ; test.hello_world(me)
+Hello World!
+
+
+

You can also rename stuff you import. Say for example that the module you import to already has a function hello_world but we also want to use the one from world/test.py:

+
from world.test import hello_world as test_hello_world
+
+
+

The form from ... import ... as ... renames the import.

+
> from world.test import hello_world as hw ; hw(me)
+Hello World!
+
+
+
+

Avoid renaming unless it’s to avoid a name-collistion like above - you want to make things as easy to read as possible, and renaming adds another layer of potential confusion.

+
+

In the basic intro to Python we learned how to open the in-game +multi-line interpreter.

+
> py
+Evennia Interactive Python mode
+Python 3.7.1 (default, Oct 22 2018, 11:21:55)
+[GCC 8.2.0] on Linux
+[py mode - quit() to exit]
+
+
+

You now only need to import once to use the imported function over and over.

+
> from world.test import hello_world
+> hello_world()
+Hello World!
+> hello_world()
+Hello World!
+> hello_world()
+Hello World!
+> quit()
+Closing the Python console.
+
+
+ +

The same goes when writing code in a module - in most Python modules you will see a bunch of imports at the top, resources that are then used by all code in that module.

+
+
+

5.2. On classes and objects

+

Now that we know about imports, let look at a real Evennia module and try to understand it.

+

Open mygame/typeclasses/scripts.py in your text editor of choice.

+
# mygame/typeclasses/script.py
+"""
+module docstring
+"""
+from evennia import DefaultScript
+
+class Script(DefaultScript):
+    """
+    class docstring
+    """
+    pass
+
+
+ +

The real file is much longer but we can ignore the multi-line strings (""" ... """). These serve as documentation-strings, or docstrings for the module (at the top) and the class below.

+

Below the module doc string we have the import. In this case we are importing a resource +from the core evennia library itself. We will dive into this later, for now we just treat this +as a black box.

+

The class named Script _ inherits_ from DefaultScript. As you can see Script is pretty much empty. All the useful code is actually in DefaultScript (Script inherits that code unless it overrides it with same-named code of its own).

+

We need to do a little detour to understand what a ‘class’, an ‘object’ or ‘instance’ is. These are fundamental things to understand before you can use Evennia efficiently.

+ +
+

5.2.1. Classes and instances

+

A ‘class’ can be seen as a ‘template’ for a ‘type’ of object. The class describes the basic functionality of everyone of that class. For example, we could have a class Monster which has resources for moving itself from room to room.

+

Open a new file mygame/typeclasses/monsters.py. Add the following simple class:

+

+class Monster:
+
+    key = "Monster"
+
+    def move_around(self):
+        print(f"{self.key} is moving!")
+
+
+
+

Above we have defined a Monster class with one variable key (that is, the name) and one +method on it. A method is like a function except it sits “on” the class. It also always has +at least one argument (almost always written as self although you could in principle use +another name), which is a reference back to itself. So when we print self.key we are referring back to the key on the class.

+ +

A class is just a template. Before it can be used, we must create an instance of the class. If +Monster is a class, then an instance is Fluffy, a specific dragon individual. You instantiate +by calling the class, much like you would a function:

+
fluffy = Monster()
+
+
+

Let’s try it in-game (we use py multi-line mode, it’s easier)

+
> py
+> from typeclasses.monsters import Monster
+> fluffy = Monster()
+> fluffy.move_around()
+Monster is moving!
+
+
+

We created an instance of Monster, which we stored in the variable fluffy. We then +called the move_around method on fluffy to get the printout.

+
+

Note how we didn’t call the method as fluffy.move_around(self). While the self has to be there when defining the method, we never add it explicitly when we call the method (Python will add the correct self for us automatically behind the scenes).

+
+

Let’s create the sibling of Fluffy, Cuddly:

+
> cuddly = Monster()
+> cuddly.move_around()
+Monster is moving!
+
+
+

We now have two monsters and they’ll hang around until with call quit() to exit this Python +instance. We can have them move as many times as we want. But no matter how many monsters we create, they will all show the same printout since key is always fixed as “Monster”.

+

Let’s make the class a little more flexible:

+

+class Monster:
+
+    def __init__(self, key):
+        self.key = key
+
+    def move_around(self):
+        print(f"{self.key} is moving!")
+
+
+
+

The __init__ is a special method that Python recognizes. If given, this handles extra arguments when you instantiate a new Monster. We have it add an argument key that we store on self.

+

Now, for Evennia to see this code change, we need to reload the server. You can either do it this way:

+
> quit()
+Python Console is closing.
+> reload
+
+
+

Or you can use a separate terminal and restart from outside the game:

+ +
$ evennia reload   (or restart)
+
+
+

Either way you’ll need to go into py again:

+
> py
+> from typeclasses.monsters import Monster
+fluffy = Monster("Fluffy")
+fluffy.move_around()
+Fluffy is moving!
+
+
+

Now we passed "Fluffy" as an argument to the class. This went into __init__ and set self.key, which we later used to print with the right name!

+
+
+

5.2.2. What’s so good about objects?

+

So far all we’ve seen a class do is to behave like our first hello_world function but being more complex. We could just have made a function:

+
     def monster_move_around(key):
+        print(f"{key} is moving!")
+
+
+

The difference between the function and an instance of a class (the object), is that the object retains state. Once you called the function it forgets everything about what you called it with last time. The object, on the other hand, remembers changes:

+
> fluffy.key = "Fluffy, the red dragon"
+> fluffy.move_around()
+Fluffy, the red dragon is moving!
+
+
+

The fluffy object’s key was changed for as long as it’s around. This makes objects extremely useful for representing and remembering collections of data - some of which can be other objects in turn. Some examples:

+
    +
  • A player character with all its stats

  • +
  • A monster with HP

  • +
  • A chest with a number of gold coins in it

  • +
  • A room with other objects inside it

  • +
  • The current policy positions of a political party

  • +
  • A rule with methods for resolving challenges or roll dice

  • +
  • A multi-dimenstional data-point for a complex economic simulation

  • +
  • And so much more!

  • +
+
+
+

5.2.3. Classes can have children

+

Classes can inherit from each other. A “child” class will inherit everything from its “parent” class. But if the child adds something with the same name as its parent, it will override whatever it got from its parent.

+

Let’s expand mygame/typeclasses/monsters.py with another class:

+

+class Monster:
+    """
+    This is a base class for Monster.
+    """
+
+    def __init__(self, key):
+        self.key = key
+
+    def move_around(self):
+        print(f"{self.key} is moving!")
+
+
+class Dragon(Monster):
+    """
+    This is a dragon monster.
+    """
+
+    def move_around(self):
+        print(f"{self.key} flies through the air high above!")
+
+    def firebreath(self):
+        """
+        Let our dragon breathe fire.
+        """
+        print(f"{self.key} breathes fire!")
+
+
+
+

We added some docstrings for clarity. It’s always a good idea to add doc strings; you can do so also for methods, as exemplified for the new firebreath method.

+

We created the new class Dragon but we also specified that Monster is the parent of Dragon but adding the parent in parenthesis. class Classname(Parent) is the way to do this.

+ +

Let’s try out our new class. First reload the server and then:

+
> py
+> from typeclasses.monsters import Dragon
+> smaug = Dragon("Smaug")
+> smaug.move_around()
+Smaug flies through the air high above!
+> smaug.firebreath()
+Smaug breathes fire!
+
+
+

Because we didn’t (re)implement __init__ in Dragon, we got the one from Monster. We did implement our own move_around in Dragon, so it overrides the one in Monster. And firebreath is only available for Dragons. Having that on Monster would not have made much sense, since not every monster can breathe fire.

+

One can also force a class to use resources from the parent even if you are overriding some of it. This is done with the super() method. Modify your Dragon class as follows:

+
# ...
+
+class Dragon(Monster):
+
+    def move_around(self):
+        super().move_around()
+        print("The world trembles.")
+
+    # ...
+
+
+
+

Keep Monster and the firebreath method. The # ... above indicates the rest of the code is unchanged.

+
+

The super().move_around() line means that we are calling move_around() on the parent of the class. So in this case, we will call Monster.move_around first, before doing our own thing.

+

To see, reload the server and then:

+
> py
+> from typeclasses.monsters import Dragon
+> smaug = Dragon("Smaug")
+> smaug.move_around()
+Smaug is moving!
+The world trembles.
+
+
+

We can see that Monster.move_around() is called first and prints “Smaug is moving!”, followed by the extra bit about the trembling world from the Dragon class.

+

Inheritance is a powerful concept. It allows you to organize and re-use code while only adding the special things you want to change. Evennia uses this a lot.

+
+
+

5.2.4. A look at multiple inheritance

+

Open mygame/typeclasses/objects.py in your text editor of choice.

+
"""
+module docstring
+"""
+from evennia import DefaultObject
+
+class ObjectParent:
+    """
+    class docstring 
+    """
+    pass
+
+class Object(ObjectParent, DefaultObject):
+    """
+    class docstring
+    """
+    pass
+
+
+

In this module we have an empty class named ObjectParent. It doesn’t do anything, its only code (except the docstring) is pass which means, well, to pass and don’t do anything. Since it also doesn’t inherit from anything, it’s just an empty container.

+

The class named Object_ inherits_ from ObjectParent and DefaultObject. Normally a class only has one parent, but here there are two. We already learned that a child inherits everything from a parent unless it overrides it. When there are more than one parents (“multiple inheritance”), inheritance happens from left to right.

+

So if obj is an instance of Object and we try to access obj.foo, Python will first check if the Object class has a property/method foo. Next it will check if ObjectParent has it. Finally, it will check in DefaultObject. If neither have it, you get an error.

+

Why has Evennia set up an empty class parent like this? To answer, let’s check out another module, mygame/typeclasses/rooms.py:

+
"""
+...
+"""
+
+from evennia.objects.objects import DefaultRoom
+
+from .objects import ObjectParent
+
+class Room(ObjectParent, DefaultRoom):
+    """
+	...
+    """
+    pass
+
+
+

Here we see that a Room inherits from the same ObjectParent (imported from objects.py) along with a DefaultRoom parent from the evennia library. You’ll find the same is true for Character and Exit as well. These are all examples of ‘in-game objects’, so they could well have a lot in common. The precense of ObjectParent gives you an (optional) way to add code that should be the same for all those in-game entities. Just put that code in ObjectParent and all the objects, characters, rooms and exits will automatically have it as well!

+

We will get back to the objects.py module in the next lesson.

+
+
+
+

5.3. Summary

+

We have created our first dragons from classes. We have learned a little about how you instantiate a class into an object. We have seen some examples of inheritance and we tested to override a method in the parent with one in the child class. We also used super() to good effect.

+

We have used pretty much raw Python so far. In the coming lessons we’ll start to look at the extra bits that Evennia provides. But first we need to learn just where to find everything.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Searching-Things.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Searching-Things.html new file mode 100644 index 0000000000..ccfd4cfc9c --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Searching-Things.html @@ -0,0 +1,435 @@ + + + + + + + + + 11. Searching for things — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

11. Searching for things

+

We have gone through how to create the various entities in Evennia. But creating something is of little use if we cannot find and use it afterwards.

+
+

11.1. Main search functions

+

The base tools are the evennia.search_* functions, such as evennia.search_object.

+
import evennia 
+
+roses = evennia.search_object("rose")
+accts = evennia.search_account("MyAccountName", email="foo@bar.com")
+
+
+ +

This searches by key of the object. Strings are always case-insensitive, so searching for "rose", "Rose" or "rOsE" give the same results. It’s important to remember that what is returned from these search methods is a listing of zero, one or more elements - all the matches to your search. To get the first match:

+
rose = roses[0]
+
+
+

Often you really want all matches to the search parameters you specify. In other situations, having zero or more than one match is a sign of a problem and you need to handle this case yourself.

+
    the_one_ring = evennia.search_object("The one Ring")
+    if not the_one_ring:
+        # handle not finding the ring at all
+    elif len(the_one_ring) > 1:
+        # handle finding more than one ring
+    else:
+        # ok - exactly one ring found
+        the_one_ring = the_one_ring[0]
+
+
+

There are equivalent search functions for all the main resources. You can find a listing of them +in the Search functions section of the API frontpage.

+
+ +
+

11.3. What can be searched for

+

These are the main database entities one can search for:

+ +

Most of the time you’ll likely spend your time searching for Objects and the occasional Accounts.

+

So to find an entity, what can be searched for?

+
+

11.3.1. Search by key

+

The key is the name of the entity. Searching for this is always case-insensitive.

+
+
+

11.3.2. Search by aliases

+

Objects and Accounts can have any number of aliases. When searching for key these will searched too, you can’t easily search only for aliases.

+
rose.aliases.add("flower")
+
+
+

If the above rose has a key "Rose", it can now also be found by searching for flower. In-game +you can assign new aliases to things with the alias command.

+
+
+

11.3.3. Search by location

+

Only Objects (things inheriting from evennia.DefaultObject) has a location. The location is usually a room. The Object.search method will automatically limit it search by location, but it also works for the general search function. If we assume room is a particular Room instance,

+
chest = evennia.search_object("Treasure chest", location=room)
+
+
+
+
+

11.3.4. Search by Tags

+

Think of a Tag as the label the airport puts on your luggage when flying. Everyone going on the same plane gets a tag grouping them together so the airport can know what should go to which plane. Entities in Evennia can be grouped in the same way. Any number of tags can be attached +to each object.

+
rose.tags.add("flowers")
+rose.tags.add("thorny")
+daffodil.tags.add("flowers")
+tulip.tags.add("flowers")
+cactus.tags.add("flowers")
+cactus.tags.add("thorny")	
+
+
+

You can now find all flowers using the search_tag function:

+
all_flowers = evennia.search_tag("flowers")
+roses_and_cactii = evennia.search_tag("thorny")
+
+
+

Tags can also have categories. By default this category is None which is also considered a category.

+
silmarillion.tags.add("fantasy", category="books")
+ice_and_fire.tags.add("fantasy", category="books")
+mona_lisa_overdrive.tags.add("cyberpunk", category="books")
+
+
+

Note that if you specify the tag you must also include its category, otherwise that category +will be None and find no matches.

+
all_fantasy_books = evennia.search_tag("fantasy")  # no matches!
+all_fantasy_books = evennia.search_tag("fantasy", category="books")
+
+
+

Only the second line above returns the two fantasy books. If we specify a category however, +we can get all tagged entities within that category:

+
all_books = evennia.search_tag(category="books")
+
+
+

This gets all three books.

+
+
+

11.3.5. Search by Attribute

+

We can also search by the Attributes associated with entities.

+

For example, let’s give our rose thorns:

+
rose.db.has_thorns = True
+wines.db.has_thorns = True
+daffodil.db.has_thorns = False
+
+
+

Now we can find things attribute and the value we want it to have:

+
is_ouch = evennia.search_object_attribute("has_thorns", True)
+
+
+

This returns the rose and the wines.

+
+

Searching by Attribute can be very practical. But if you plan to do a search very often, searching +by-tag is generally faster.

+
+
+
+

11.3.6. Search by Typeclass

+

Sometimes it’s useful to find all objects of a specific Typeclass. All of Evennia’s search tools support this.

+
all_roses = evennia.search_object(typeclass="typeclasses.flowers.Rose")
+
+
+

If you have the Rose class already imported you can also pass it directly:

+
all_roses = evennia.search_object(typeclass=Rose)
+
+
+

You can also search using the typeclass itself:

+
all_roses = Rose.objects.all()
+
+
+

This last way of searching is a simple form of a Django query. This is a way to express SQL queries using Python. See the next lesson, where we’ll explore this way to searching in more detail.

+
+
+

11.3.7. Search by dbref

+ +

The database id or #dbref is unique and never-reused within each database table. In search methods you can replace the search for key with the dbref to search for. This must be written as a string #dbref:

+
the_answer = self.caller.search("#42")
+eightball = evennia.search_object("#8")
+
+
+

Since #dbref is always unique, this search is always global.

+
+

Warning

+

Relying on #dbrefs

+

In legacy code bases you may be used to relying a lot on #dbrefs to find and track things. Looking something up by #dbref can be practical - if used occationally. It is however considered bad practice to rely on hard-coded #dbrefs in Evennia. Especially to expect end users to know them. It makes your code fragile and hard to maintain, while tying your code to the exact layout of the database. In 99% of use cases you should organize your code such that you pass the actual objects around and search by key/tags/attribute instead.

+
+
+
+
+

11.4. Finding objects relative each other

+

It’s important to understand how objects relate to one another when searching. +Let’s consider a chest with a coin inside it. The chests stand in a room dungeon. In the dungeon is also a door. This is an exit leading outside.

+
┌───────────────────────┐
+│dungeon                │
+│    ┌─────────┐        │
+│    │chest    │ ┌────┐ │
+│    │  ┌────┐ │ │door│ │
+│    │  │coin│ │ └────┘ │
+│    │  └────┘ │        │
+│    │         │        │
+│    └─────────┘        │
+│                       │
+└───────────────────────┘
+
+
+
    +
  • coin.location is chest.

  • +
  • chest.location is dungeon.

  • +
  • door.location is dungeon.

  • +
  • room.location is None since it’s not inside something else.

  • +
+

One can use this to find what is inside what. For example, coin.location.location is the room. +We can also find what is inside each object. This is a list of things.

+
    +
  • room.contents is [chest, door]

  • +
  • chest.contents is [coin]

  • +
  • coin.contents is [], the empty list since there’s nothing ‘inside’ the coin.

  • +
  • door.contents is [] too.

  • +
+

A convenient helper is .contents_get - this allows to restrict what is returned:

+
    +
  • room.contents_get(exclude=chest) - this returns everything in the room except the chest (maybe it’s hidden?)

  • +
+

There is a special property for finding exits:

+
    +
  • room.exits is [door]

  • +
  • coin.exits is [] (same for all the other objects)

  • +
+

There is a property .destination which is only used by exits:

+
    +
  • door.destination is outside (or wherever the door leads)

  • +
  • room.destination is None (same for all the other non-exit objects)

  • +
+

You can also include this information in searches:

+
from evennia import search_object
+
+# we assume only one match of each 
+dungeons = search_object("dungeon", typeclass="typeclasses.rooms.Room")
+chests = search_object("chest", location=dungeons[0])
+# find if there are any skulls in the chest 
+skulls = search_object("Skull", candidates=chests[0].contents)
+
+
+

More advanced, nested queries like this can however often be made more efficient by using the hints in the next lesson.

+
+
+

11.5. Summary

+

Knowing how to find things is important and the tools from this section will serve you well. These tools will cover most of your needs …

+

… but not always. In the next lesson we will dive further into more complex searching when we look at Django queries and querysets in earnest.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Tutorial-World.html b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Tutorial-World.html new file mode 100644 index 0000000000..fb18b51c20 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Tutorial-World.html @@ -0,0 +1,239 @@ + + + + + + + + + 2. The Tutorial World — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

2. The Tutorial World

+

The Tutorial World is a small, functioning MUD-style game world shipped with Evennia. +It’s a small showcase of what is possible. It may also be useful for those who have an easier +time learning by deconstructing existing code.

+

To install the tutorial world, stand in the Limbo room and input:

+
batchcommand tutorial_world.build
+
+
+

This command runs the build script in evennia/contrib/tutorials/tutorial_world/build.ev. +Basically, this script is a list of build-commands executed in sequence by the batchcommand command. Wait for the building to complete and don’t run it twice.

+
+

After having run the batchcommand, the intro command becomes available in Limbo. Try it out for in-game help using an example of EvMenu, Evennia’s in-built +menu generation system!

+
+

The tutorial world consists of a single-player quest and has some 20 rooms to explore as you seek to discover the whereabouts of a mythical weapon.

+

A new exit should have appeared named Tutorial. Enter the tutorial world by typing tutorial.

+

You will automatically quell when you enter (and unquell when you leave), so you can play the way it was intended. Whether you are triumphant or use the give up command, you will eventually end up back in Limbo.

+
+

Important

+

Only LOSERS and QUITTERS use the give up command.

+
+
+

2.1. Gameplay

+

the castle off the moor +(image by Griatch)

+

To get into the mood of our 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 depths, a warrior princess was buried together with her powerful magical weapon — a valuable prize, if true. Of course, this is a chance for adventure that you simply cannot turn down!

+

You reach the ocean in the midst of a raging thunderstorm. With wind and rain screaming in your face, you stand where the moor meets the sea along a high, rocky coast…

+
+
+

2.1.1. Gameplay hints

+
    +
  • Use the command tutorial to get code insight behind the scenes of every room.

  • +
  • Look at everything. While a demo, the Tutorial World is not necessarily trivial to solve - it depends on your experience with text-based adventure games. Just remember that everything can be solved or bypassed.

  • +
  • Some objects are interactive in more than one way. Use the normal help command to get a feel for which commands are available at any given time.

  • +
  • In order to fight, you need to first find some type of weapon.

    +
      +
    • slash is a normal attack

    • +
    • stab launches an attack that makes more damage but has a lower chance to hit.

    • +
    • defend will lower the chance to taking damage on your enemy’s next attack.

    • +
    +
  • +
  • Some things cannot be hurt by mundane weapons. In that case it’s OK to run away. Expect to be chased …

  • +
  • Being defeated is a part of the experience. You can’t actually die, but getting knocked out +means being left in the dark …

  • +
+
+
+
+

2.2. Once you are done (or had enough)

+

Afterwards you’ll either have conquered the old ruin and returned in glory and triumph … or +you returned limping and whimpering from the challenge by using the give up command. +Either way you should now be back in Limbo, able to reflect on the experience.

+

Some features exemplified by the tutorial world:

+
    +
  • Rooms with custom ability to show details (like looking at the wall in the dark room)

  • +
  • Hidden or impassable exits until you fulfilled some criterion

  • +
  • Objects with multiple custom interactions (like swords, the well, the obelisk …)

  • +
  • Large-area rooms (that bridge is actually only one room!)

  • +
  • Outdoor weather rooms with weather (the rain pummeling you)

  • +
  • Dark room, needing light source to reveal itself (the burning splinter even burns out after a while)

  • +
  • Puzzle object (the wines in the dark cell; hope you didn’t get stuck!)

  • +
  • Multi-room puzzle (the obelisk and the crypt)

  • +
  • Aggressive mobile with roam, pursue and battle state-engine AI (quite deadly until you find the right weapon)

  • +
  • Weapons, also used by mobs (most are admittedly not that useful against the big baddie)

  • +
  • Simple combat system with attack/defend commands (teleporting on-defeat)

  • +
  • Object spawning (the weapons in the barrel and the final weapoon is actually randomized)

  • +
  • Teleporter trap rooms (if you fail the obelisk puzzle)

  • +
+ +

Quite a lot of stuff crammed in such a small area!

+
+
+

2.3. Uninstall the tutorial world

+

Once you are done playing with the tutorial world, let’s uninstall it. Uninstalling the tutorial world basically means deleting all the rooms and objects it consists of. Make sure you are back in Limbo, then

+
 find tut#01
+ find tut#16
+
+
+

This should locate the first and last rooms created by build.ev - Intro and Outro. If you installed normally, everything created between these two numbers should be part of the tutorial. Note their #dbref numbers, for example 5 and 80. Next we just delete all objects in that range:

+
 del 5-80
+
+
+

You will see some errors since some objects are auto-deleted and so cannot be found when the delete mechanism gets to them. That’s fine. You should have removed the tutorial completely once the command finishes.

+

Even if the game-style of the Tutorial-world was not similar to the one you are interested in, it should hopefully have given you a little taste of some of the possibilities of Evennia. Now we’ll move on with how to access this power through code.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Game-Planning.html b/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Game-Planning.html new file mode 100644 index 0000000000..9cb815e27c --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Game-Planning.html @@ -0,0 +1,356 @@ + + + + + + + + + 2. On Planning a Game — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

2. On Planning a Game

+

Last lesson we asked ourselves some questions about our motivation. In this one we’ll present +some more technical questions to consider. In the next lesson we’ll answer them for the sake of +our tutorial game.

+

Note that the suggestions on this page are just that - suggestions. Also, they are primarily aimed at a lone +hobby designer or a small team developing a game in their free time.

+
+

Important

+

Your first all overshadowing goal is to beat the odds and get something out the door! +Even if it’s a scaled-down version of your dream game, lacking many “must-have” features!

+
+

Remember: 99.99999% of all great game ideas never lead to a game. Especially not to an online +game that people can actually play and enjoy. It’s better to get your game out there and expand on it +later than to code in isolation until you burn out, lose interest or your hard drive crashes.

+
    +
  • Keep the scope of your initial release down. Way down.

  • +
  • Start small, with an eye towards expansions later, after first release.

  • +
  • If the suggestions here seems boring or a chore to you, do it your way instead. Everyone’s different.

  • +
  • Keep having fun. You must keep your motivation up, whichever way works for you.

  • +
+
+

2.1. The steps

+

Here are the rough steps towards your goal.

+
    +
  1. Planning

  2. +
  3. Coding + Gradually building a tech-demo

  4. +
  5. Building the actual game world

  6. +
  7. Release

  8. +
  9. Celebrate

  10. +
+
+
+

2.2. Planning

+

You need to have at least a rough idea about what you want to create. Some like a lot of planning, others +do it more seat-of-the-pants style. Regardless, while some planning is always good to do, it’s common +to have your plans change on you as you create your code prototypes. So don’t get too bogged down in +the details out of the gate.

+

Many prospective game developers are very good at parts of this process, namely in defining what their +world is “about”: The theme, the world concept, cool monsters and so on. Such things are very important. But +unfortunately, they are not enough to make your game. You need to figure out how to accomplish your ideas in +Evennia.

+

Below are some questions to get you going. In the next lesson we will try to answer them for our particular +tutorial game. There are of course many more questions you could be asking yourself.

+
+

2.2.1. Administration

+
    +
  • Should your game rules be enforced by coded systems or by human game masters?

  • +
  • What is the staff hierarchy in your game? Is vanilla Evennia roles enough or do you need something else?

  • +
  • Should players be able to post out-of-characters on channels and via other means like bulletin-boards?

  • +
+
+
+

2.2.2. Building

+
    +
  • How will the world be built? Traditionally (from in-game with build-commands) or externally (by batchcmds/code +or directly with custom code)?

  • +
  • Can only privileged Builders create things or should regular players also have limited build-capability?

  • +
+
+
+

2.2.3. Systems

+
    +
  • Do you base your game off an existing RPG system or make up your own?

  • +
  • What are the game mechanics? How do you decide if an action succeeds or fails?

  • +
  • Does the flow of time matter in your game - does night and day change? What about seasons?

  • +
  • Do you want changing, global weather or should weather just be set manually in roleplay?

  • +
  • Do you want a coded world-economy or just a simple barter system? Or no formal economy at all?

  • +
  • Do you have concepts like reputation and influence?

  • +
  • Will your characters be known by their name or only by their physical appearance?

  • +
+
+
+

2.2.4. Rooms

+
    +
  • Is a simple room description enough or should the description be able to change (such as with time, by +light conditions, weather or season)?

  • +
  • Should the room have different statuses? Can it have smells, sounds? Can it be affected by +dramatic weather, fire or magical effects? If so, how would this affect things in the room? Or are +these things something admins/game masters should handle manually?

  • +
  • Can objects be hidden in the room? Can a person hide in the room? How does the room display this?

  • +
+
+
+

2.2.5. Objects / items

+
    +
  • How numerous are your objects? Do you want large loot-lists or are objects just role playing props +created on demand?

  • +
  • If you use money, is each coin a separate object or do you just store a bank account value?

  • +
  • Do multiple similar objects form stacks and how are those stacks handled in that case?

  • +
  • Does an object have weight or volume (so you cannot carry an infinite amount of them)?

  • +
  • Can objects be broken? Can they be repaired?

  • +
  • Can you fight with a chair or a flower or must you use a specific ‘weapon’ kind of thing?

  • +
  • Will characters be able to craft new objects?

  • +
  • Should mobs/NPCs have some sort of AI?

  • +
  • Are NPCs and mobs different entities? How do they differ?

  • +
  • Should there be NPCs giving quests? If so, how do you track Quest status?

  • +
+
+
+

2.2.6. Characters

+
    +
  • Can players have more than one Character active at a time or are they allowed to multi-play?

  • +
  • How does the character-generation work? Walk from room-to-room? A menu?

  • +
  • How do you implement different “classes” or “races”? Are they separate types of objects or do you +simply load different stats on a basic object depending on what the Player wants?

  • +
  • If a Character can hide in a room, what skill will decide if they are detected?

  • +
  • What does the skill tree look like? Can a Character gain experience to improve? By killing +enemies? Solving quests? By roleplaying?

  • +
  • May player-characters attack each other (PvP)?

  • +
  • What are the penalties of defeat? Permanent death? Quick respawn? Time in prison?

  • +
+

A MUD’s a lot more involved than you would think and these things hang together in a complex web. It +can easily become overwhelming and it’s tempting to want all functionality right out of the door. +Try to identify the basic things that “make” your game and focus only on them for your first +release. Make a list. Keep future expansions in mind but limit yourself.

+
+
+
+

2.3. Coding and Tech demo

+

This is the actual work of creating the “game” part of your game. As you code and test systems you should +build a little “tech demo” along the way.

+ +

Try to avoid going wild with building a huge game world before you have a tech-demo showing off all parts +you expect to have in the first version of your game. Otherwise you run the risk of having to redo it all +again.

+

Evennia tries hard to make the coding easier for you, but there is no way around the fact that if you want +anything but a basic chat room you will have to bite the bullet and code your game (or find a coder willing +to do it for you).

+
+

Even if you won’t code anything yourself, as a designer you need to at least understand the basic +paradigms and components of Evennia. It’s recommended you look over the rest of this Beginner Tutorial to learn +what tools you have available.

+
+

During Coding you look back at the things you wanted during the Planning phase and try to +implement them. Don’t be shy to update your plans if you find things easier/harder than you thought. +The earlier you revise problems, the easier they will be to fix.

+

A good idea is to host your code online using version control. Github.com offers free Private repos +these days if you don’t want the world to learn your secrets. Not only version control +make it easy for your team to collaborate, it also means +your work is backed up at all times. The page on Version Control +will help you to setting up a sane developer environment with proper version control.

+
+
+

2.4. World Building

+

Up until this point we’ve only had a few tech-demo objects in the database. This step is the act of +populating the database with a larger, thematic world. Too many would-be developers jump to this +stage too soon (skipping the Coding or even Planning stages). What if the rooms you build +now doesn’t include all the nice weather messages the code grows to support? Or the way you store +data changes under the hood? Your building work would at best require some rework and at worst you +would have to redo the whole thing. You could be in for a lot of unnecessary work if you build stuff +en masse without having the underlying code systems in some reasonable shape first.

+

So before starting to build, the “game” bit (Coding + Testing) should be more or less +complete, at least to the level of your initial release.

+

Make sure it is clear to yourself and your eventual builders just which parts of the world you want +for your initial release. Establish for everyone which style, quality and level of detail you expect.

+

Your goal should not be to complete your entire world in one go. You want just enough to make the +game’s “feel” come across. You want a minimal but functioning world where the intended game play can +be tested and roughly balanced. You can always add new areas later.

+

During building you get free and extensive testing of whatever custom build commands and systems you +have made at this point. If Builders and coders are different people you also +get a chance to hear if some things are hard to understand or non-intuitive. Make sure to respond +to this feedback.

+
+
+

2.5. Alpha Release

+

As mentioned, don’t hold onto your world more than necessary. Get it out there with a huge Alpha +flag and let people try it!

+

Call upon your alpha-players to try everything - they will find ways to break your game in ways that +you never could have imagined. In Alpha you might be best off to +focus on inviting friends and maybe other MUD developers, people who you can pester to give proper +feedback and bug reports (there will be bugs, there is no way around it).

+

Follow the quick instructions for Online Setup to make your +game visible online.

+

If you hadn’t already, make sure to put up your game on the +Evennia game index so people know it’s in the works (actually, even +pre-alpha games are allowed in the index so don’t be shy)!

+
+
+

2.6. Beta Release/Perpetual Beta

+

Once things stabilize in Alpha you can move to Beta and let more people in. Many MUDs are in +perpetual beta, meaning they are never considered +“finished”, but just repeat the cycle of Planning, Coding, Testing and Building over and over as new +features get implemented or Players come with suggestions. As the game designer it is now up to you +to gradually perfect your vision.

+
+
+

2.7. Congratulate yourself!

+

You are worthy of a celebration since at this point you have joined the small, exclusive crowd who +have made their dream game a reality!

+
+
+

2.8. Planning our tutorial game

+

In the next lesson we’ll make use of these general points and try to plan out our tutorial game.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Part2-Overview.html b/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Part2-Overview.html new file mode 100644 index 0000000000..7d0bbeaea0 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Part2-Overview.html @@ -0,0 +1,198 @@ + + + + + + + + + Part 2: What We Want — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Part 2: What We Want

+ +

In Part Two of the Evennia Beginner Tutorial, we’ll take a step back to plan the type of tutorial game that you will create. This part is more ‘theoretical’ in that we won’t do any hands-on programming.

+

In the process, we’ll address the common questions of “where to start” and “what to think about” when creating a multiplayer online text game.

+
+

Lessons

+ +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Planning-The-Tutorial-Game.html b/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Planning-The-Tutorial-Game.html new file mode 100644 index 0000000000..6ebbe52b47 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Planning-The-Tutorial-Game.html @@ -0,0 +1,587 @@ + + + + + + + + + 3. Planning our tutorial game — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

3. Planning our tutorial game

+

Using the general plan from last lesson we’ll now establish what kind of game we want to create for this tutorial. We’ll call it … EvAdventure. +Remembering that we need to keep the scope down, let’s establish some parameters.

+
    +
  • We want EvAdventure be a small game we can play ourselves for fun, but which could in principle be expanded to something more later.

  • +
  • We want to have a clear game-loop, with clear goals.

  • +
  • Let’s go with a fantasy theme, it’s well understood.

  • +
  • We will use a small, existing tabletop RPG rule set (Knave, more info later)

  • +
  • We want to be able to create and customize a character of our own.

  • +
  • While not roleplay-focused, it should still be possible to socialize and to collaborate.

  • +
  • We don’t want to have to rely on a Game master to resolve things, but will rely on code for skill resolution and combat.

  • +
  • We want monsters to fight and NPCs we can talk to. So some sort of AI.

  • +
  • We want some sort of quest system and merchants to buy stuff from.

  • +
+
+

3.1. Game concept

+

With these points in mind, here’s a quick blurb for our game:

+

Recently, the nearby village discovered that the old abandoned well contained a dark secret. The bottom of the well led to a previously undiscovered dungeon of ever shifting passages. No one knew why it was there or what its purpose was, but local rumors abound. The first adventurer that went down didn’t come back. The second … brought back a handful of glittering riches.

+

Now the rush is on - there’s a dungeon to explore and coin to earn. Knaves, cutthroats, adventurers and maybe even a hero or two are coming from all over the realm to challenge whatever lurks at the bottom of that well.

+

Local merchants and opportunists have seen a chance for profit. A camp of tents has sprung up around the old well, providing food and drink, equipment, entertainment and rumors for a price. It’s a festival to enjoy before paying the entrance fee for dropping down the well to find your fate among the shadows below …

+

Our game will consist of two main game modes - above ground and below. The player starts above ground and is expected to do ‘expeditions’ into the dark. The design goal is for them to be forced back up again when their health, equipment and luck is about to run out.

+
    +
  • Above, in the “dungeon festival”, the player can restock and heal up, buy things and do a small set of quests. It’s the only place where the characters can sleep and fully heal. They also need to spend coin here to gain XP and levels. This is a place for players to socialize and RP. There is no combat above ground except for an optional spot for non-lethal PvP.

  • +
  • Below is the mysterious dungeon. This is a procedurally generated set of rooms. Players can collaborate if they go down the well together, they will not be able to run into each other otherwise (so this works as an instance). Each room generally presents some challenge (normally a battle). Pushing deeper is more dangerous but can grant greater rewards. While the rooms could in theory go on forever, there should be a boss encounter once a player reaches deep enough.

  • +
+

Here’s an overview of the topside camp for inspiration (quickly thrown together in the free version of Inkarnate). We’ll explore how to break this up into “rooms” (locations) when we get to creating the game world later.

+

Last Step Camp

+

For the rest of this lesson we’ll answer and reason around the specific questions posed in the previous Game Planning lesson.

+
+
+

3.2. Administration

+
+

3.2.1. Should your game rules be enforced by coded systems by human game masters?

+

Generally, the more work you expect human staffers/GMs to do, the less your code needs to work. To support GMs you’d need to design commands to support GM-specific actions and the type of game-mastering you want them to do. You may need to expand communication channels so you can easily talk to groups people in private and split off gaming groups from each other. RPG rules could be as simple +as the GM sitting with the rule books and using a dice-roller for visibility.

+

GM:ing is work-intensive however, and even the most skilled and enthusiastic GM can’t be awake all hours of the day to serve an international player base. The computer never needs sleep, so having the ability for players to “self-serve” their RP itch when no GMs are around is a good idea even for the most GM-heavy games.

+

On the other side of the spectrum are games with no GMs at all; all gameplay are driven either by the computer or by the interactions between players. Such games still need an active staff, but nowhere as much active involvement. Allowing for solo-play with the computer also allows players to have fun when the number of active +players is low.

+

EvAdventure Answer:

+

We want EvAdventure to work entirely without depending on human GMs. That said, there’d be nothing stopping a GM from stepping in and run an adventure for some players should they want to.

+
+
+

3.2.2. What is the staff hierarchy in your game? Is vanilla Evennia roles enough or do you need something else?

+

The default hierarchy is

+
    +
  • Player - regular players

  • +
  • Player Helper - can create/edit help entries

  • +
  • Builder - can use build commands

  • +
  • Admin - can kick and ban accounts

  • +
  • Developer - full access, usually also trusted with server access

  • +
+

There is also the superuser, the “owner” of the game you create when you first set up your database. This user goes outside the regular hierarchy and while powerful it’s not so suitable for testing since it bypasses all locks (using quell or a separate Developer-level account is recommended).

+

EvAdventure Answer

+

We are okay with keeping the default permission structure for our game.

+
+
+

3.2.3. Should players be able to post out-of-characters on channels and via other means like bulletin-boards?

+

Evennia’s Channels are by default only available between Accounts. That is, for players to communicate with each +other. By default, the public channel is created for general discourse. +Channels are logged to a file and when you are coming back to the game you can view the history of a channel in case you missed something.

+
> public Hello world!
+[Public] MyName: Hello world!
+
+
+

But Channels can also be set up to work between Characters instead of Accounts. This would mean the channels would have an in-game meaning:

+
    +
  • Members of a guild could be linked telepathically.

  • +
  • Survivors of the apocalypse can communicate over walkie-talkies.

  • +
  • Radio stations you can tune into or have to discover.

  • +
+

Bulletin boards are a sort of in-game forum where posts are made publicly or privately. Contrary to a channel, the messages are usually stored and are grouped into topics with replies. Evennia has no default bulletin-board system.

+

EvAdventure Answer

+

In EvAdventure we will just use the default inter-account channels. We will also not be implementing any bulletin boards; instead the merchant NPCs will act as quest givers.

+
+
+
+

3.3. Building

+
+

3.3.1. How will the world be built?

+

There are two main ways to handle this:

+
    +
  • Traditionally, from in-game with build-commands: This means builders creating content in their game client. This has the advantage of not requiring Python skills nor server access. This can often be a quite intuitive way to build since you are sort-of walking around in your creation as you build it. However, the developer (you) must make sure to provide build-commands that are flexible enough for builders to be able to create the content you want for your game.

  • +
  • Externally (by batchcmds): Evennia’s batchcmd takes a text file with Evennia Commands and executes them in sequence. This allows the build process to be repeated and applied quickly to a new database during development. +It also allows builders to use proper text-editing tools rather than writing things line-by-line in their clients. The drawback is that for their changes to go live they either need server access or they need to send their batchcode to the game administrator so they can apply the changes. Or use version control.

  • +
  • Externally (with batchcode or custom code): This is the “professional game development” approach. This gives the builders maximum power by creating the content in Python using Evennia primitives. The batchcode processor +allows Evennia to apply and re-apply build-scripts that are raw Python modules. Again, this would require the builder to have server access or to use version control to share their work with the rest of the development team.

  • +
+

EvAdventure Answer

+

For EvAdventure, we will build the above-ground part of the game world using batch-scripts. The world below-ground we will build procedurally, using raw code.

+
+
+

3.3.2. Can only privileged Builders create things or should regular players also have limited build-capability?

+

In some game styles, players have the ability to create objects and even script them. While giving regular users the ability to create objects with in-built commands is easy and safe, actual code-creation (aka softcode ) is not something Evennia supports natively.

+

Regular, untrusted users should never be allowed to execute raw Python +code (such as what you can do with the py command). You can +read more about Evennia’s stance on softcode here. If you want users to do limited scripting, it’s suggested that this is accomplished by adding more powerful build-commands for them to use.

+

EvAdventure Answer

+

For our tutorial-game, we will only allow privileged builders and admins to modify the world.

+
+
+
+

3.4. Systems

+
+

3.4.1. Do you base your game off an existing RPG system or make up your own?

+

There is a plethora of options out there, and what you choose depends on the game you want. It can be tempting to grab a short free-form ruleset, but remember that the computer does not have any intuitiion or common sense to interpret the rules like a human GM could. Conversely, if you pick a very ‘crunchy’ game system, with detailed simulation of the real world, remember that you’ll need to actually code all those exceptions and tables yourself.

+

For speediest development, what you want is a game with a consolidated resolution mechanic - one you can code once and then use in a lot of situations. But you still want enough rules to help telling the computer how various situations should be resolved (combat is the most common system that needs such structure).

+

EvAdventure Answer

+

For this tutorial, we will make use of Knave, a very light OSR ruleset by Ben Milton. It’s only a few pages long but highly compatible with old-school D&D games. It’s consolidates all rules around a few opposed d20 rolls and includes clear rules for combat, inventory, equipment and so on. Since Knave is a tabletop RPG, we will have to do some minor changes here and there to fit it to the computer medium.

+

Knave is available under a Creative Commons Attributions 4.0 License, meaning it can be used for derivative work (even commercially). The above link allows you to purchase the PDF and supporting the author. Alternatively you can find unofficial fan releases of the rules on this page.

+
+
+

3.4.2. What are the game mechanics? How do you decide if an action succeeds or fails?

+

This follows from the RPG system decided upon in the previous question.

+

EvAdventure Answer

+

Knave gives every character a set of six traditional stats: Strength, Intelligence, Dexterity, Constitution, Intelligence, Wisdom and Charisma. Each has a value from +1 to +10. To find its “Defense” value, you add 10.

+
You have Strength +1. Your Strength-Defense is 10 + 1 = 11
+
+
+

To make a check, say an arm-wrestling challenge you roll a twenty-sided die (d20) and add your stat. You have to roll higher than the opponents defense for that stat.

+
I have Strength +1, my opponent has a Strength of +2. To beat them in arm wrestling I must roll d20 + 1 and hope to get higher than 12, which is their Strength defense (10 + 2). 
+
+
+

If you attack someone you do the same, except you roll against their Armor defense. If you rolled higher, you roll for how much damage you do (depends on your weapon). +You can have advantage or disadvantage on a roll. This means rolling 2d20 and picking highest or lowest value.

+

In Knave, combat is turn-based. In our implementation we’ll also play turn-based, but we’ll resolve everything simultaneously. This changes Knave’s feel quite a bit, but is a case where the computer can do things not practical to do when playing around a table.

+

There are also a few tables we’ll need to implement. For example, if you lose all health, there’s a one-in-six chance you’ll die outright. We’ll keep this perma-death aspect, but make it very easy to start a new character and jump back in.

+
+

In this tutorial we will not add opportunities to make use of all of the character stats, making some, like strength, intelligence and dexterity more useful than others. In a full game, one would want to expand so a user can utilize all of their character’s strengths.

+
+
+
+

3.4.3. Does the flow of time matter in your game - does night and day change? What about seasons?

+

Most commonly, game-time runs faster than real-world time. There are +a few advantages with this:

+
    +
  • Unlike in a single-player game, you can’t fast-forward time in a multiplayer game if you are waiting for something, like NPC shops opening.

  • +
  • Healing and other things that we know takes time will go faster while still being reasonably ‘realistic’.

  • +
+

The main drawback is for games with slower roleplay pace. While you are having a thoughtful roleplaying scene over dinner, the game world reports that two days have passed. Having a slower game time than real-time is a less common, but possible solution for such games.

+

It is however not recommended to let game-time exactly equal the speed of real time. The reason for this is that people will join your game from all around the world, and they will often only be able to play at particular times of their day. With a game-time drifting relative real-time, everyone will eventually be able to experience both day and night in the game.

+

EvAdventure Answer

+

The passage of time will have no impact on our particular game example, so we’ll go with Evennia’s default, which is that the game-time runs two times faster than real time.

+
+
+

3.4.4. Do you want changing, global weather or should weather just be set manually in roleplay?

+

A weather system is a good example of a game-global system that affects a subset of game entities (outdoor rooms).

+

EvAdventure Answer

+

We’ll not change the weather, but will add some random messages to echo through +the game world at random intervals just to show the principle.

+
+
+

3.4.5. Do you want a coded world-economy or just a simple barter system? Or no formal economy at all?

+

This is a big question and depends on how deep and interconnected the virtual transactions are that are happening in the game. Shop prices could rice and drop due to supply and demand, supply chains could involve crafting and production. One also could consider adding money sinks and manipulate the in-game market to combat inflation.

+

The Barter contrib provides a full interface for trading with another player in a safe way.

+

EvAdventure Answer

+

We will not deal with any of this complexity. We will allow for players to buy from npc sellers and players will be able to trade using the normal give command.

+
+
+

3.4.6. Do you have concepts like reputation and influence?

+

These are useful things for a more social-interaction heavy game.

+

EvAdventure Answer

+

We will not include them for this tutorial. Adding the Barter contrib is simple though.

+
+
+

3.4.7. Will your characters be known by their name or only by their physical appearance?

+

This is a common thing in RP-heavy games. Others will only see you as “The tall woman” until you introduce yourself and they ‘recognize’ you with a name. Linked to this is the concept of more complex emoting and posing.

+

Implementing such a system is not trivial, but the RPsystem Evennia contrib offers a ready system with everything needed for free emoting, recognizing people by their appearance and more.

+

EvAdventure Answer

+

We will not use any special RP systems for this tutorial. Adding the RPSystem contrib is a good extra expansion though!

+
+
+
+

3.5. Rooms

+
+

3.5.1. Is a simple room description enough or should the description be able to change?

+

Changing room descriptions for day and night, winder and summer is actually quite easy to do, but looks very impressive. We happen to know there is also a contrib that helps with this, so we’ll show how to include that.

+

There is an Extended Room contrib that adds a Room type that is aware of the time-of-day as well as seasonal variations.

+

EvAdventure Answer

+

We will stick to a normal room in this tutorial and let the world be in a perpetual daylight. Making Rooms into ExtendedRooms is not hard though.

+
+
+

3.5.2. Should the room have different statuses?

+

One could picture weather making outdoor rooms wet, cold or burnt. In rain, bow strings could get wet and fireballs fizz out. In a hot room, characters could require drinking more water, or even take damage if not finding shelter.

+

EvAdventure Answer

+

For the above-ground we need to be able to disable combat all rooms except for the PvP location. We also need to consider how to auto-generate the rooms under ground. So we probably will need some statuses to control that.

+

Since each room under ground should present some sort of challenge, we may need a few different room types different from the above-ground Rooms.

+
+
+

3.5.3. Can objects be hidden in the room? Can a person hide in the room?

+

This ties into if you have hide/stealth mechanics. Maybe you could evesdrop or attack out of hiding.

+

EvAdventure Answer

+

We will not model hiding and stealth. This will be a game of honorable face-to-face conflict.

+
+
+
+

3.6. Objects

+
+

3.6.1. How numerous are your objects? Do you want large loot-lists or are objects just role playing props?

+

This also depends on the type of game. In a pure freeform RPG, most objects may be ‘imaginary’ and just appearing in fiction. If the game is more coded, you want objects with properties that the computer can measure, track and calculate. In many roleplaying-heavy games, you find a mixture of the two, with players imagining items for roleplaying scenes, but only using ‘real’ objects to resolve conflicts.

+

EvAdventure Answer

+

We will want objects with properties, like weapons and potions and such. Monsters should drop loot even though our list of objects will not be huge in this example game.

+
+
+

3.6.2. Is each coin a separate object or do you just store a bank account value?

+

The advantage of having multiple items is that it can be more immersive. The drawback is that it’s also very fiddly to deal with individual coins, especially if you have to deal with different currencies.

+

EvAdventure Answer

+

Knave uses the “copper” as the base coin and so will we. Knave considers the weight of coin and one inventory “slot” can hold 100 coins. So we’ll implement a “coin item” to represent many coins.

+
+
+

3.6.3. Do multiple similar objects form stack and how are those stacks handled in that case?

+

If you drop two identical apples on the ground, Evennia will default to show this in the room as “two apples”, but this is just a visual effect - there are still two apple-objects in the room. One could picture instead merging the two into a single object “X nr of apples” when you drop the apples.

+

EvAdventure Answer

+

We will keep Evennia’s default.

+
+
+

3.6.4. Does an object have weight or volume (so you cannot carry an infinite amount of them)?

+

Limiting carrying weight is one way to stop players from hoarding. It also makes it more important for players to pick only the equipment they need. Carrying limits can easily come across as annoying to players though, so one needs to be careful with it.

+

EvAdventure Answer

+

Knave limits your inventory to Constitution + 10 “slots”, where most items take up one slot and some large things, like armor, uses two. Small items (like rings) can fit 2-10 per slot and you can fit 100 coins in a slot. This is an important game mechanic to limit players from hoarding. Especially since you need coin to level up.

+
+
+

3.6.5. Can objects be broken? Can they be repaired?

+

Item breakage is very useful for a game economy; breaking weapons adds tactical considerations (if it’s not too common, then it becomes annoying) and repairing things gives work for crafting players.

+

EvAdventure Answer

+

In Knave, items will break if you make a critical failure on using them (rolls a native 1 on d20). This means they lose a level of quality and once at 0, it’s unusable. We will not allow players to repair, but we could allow merchants to repair items for a fee.

+
+
+

3.6.6. Can you fight with a chair or a flower or must you use a special ‘weapon’ kind of thing?

+

Traditionally, only ‘weapons’ could be used to fight with. In the past this was a useful +simplification, but with Python classes and inheritance, it’s not actually more work to just let all items in game work as a weapon in a pinch.

+

EvAdventure Answer

+

Since Knave deals with weapon lists and positions where items can be wielded, we will have a separate “Weapon” class for everything you can use for fighting. So, you won’t be able to fight with a chair (unless we make it a weapon-inherited chair).

+
+
+

3.6.7. Will characters be able to craft new objects?

+

Crafting is a common feature in multiplayer games. In code it usually means using a skill-check to combine base ingredients from a fixed recipe in order to create a new item. The classic example is to combine leather straps, a hilt, a pommel and a blade to make a new sword.

+

A full-fledged crafting system could require multiple levels of crafting, including having to mine for ore or cut down trees for wood.

+

Evennia’s Crafting contrib adds a full crafting system to any game. It’s based on Tags, meaning that pretty much any object can be made usable for crafting, even used in an unexpected way.

+

EvAdventure Answer

+

In our case we will not add any crafting in order to limit the scope of our game. Maybe NPCs will be able to repair items - for a cost?

+
+
+

3.6.8. Should mobs/NPCs have some sort of AI?

+

As a rule, you should not hope to fool anyone into thinking your AI is actually intelligent. The best you will be able to do is to give interesting results and unless you have a side-gig as an AI researcher, users will likely not notice any practical difference between a simple state-machine and you spending a lot of time learning +how to train a neural net.

+

EvAdventure Answer

+

For this tutorial, we will show how to add a simple state-machine AI for monsters. NPCs will only be shop-keepers and quest-gives so they won’t need any real AI to speak of.

+
+
+

3.6.9. Are NPCs and mobs different entities? How do they differ?

+

“Mobs” or “mobiles” are things that move around. This is traditionally monsters you can fight with, but could also be city guards or the baker going to chat with the neighbor. Back in the day, they were often fundamentally different these days it’s often easier to just make NPCs and mobs essentially the same thing.

+

EvAdventure Answer

+

In EvAdventure, Monsters and NPCs do very different things, so they will be different classes, sharing some code where possible.

+
+
+

3.6.10. _Should there be NPCs giving quests? If so, how do you track Quest status?

+

Quests are a staple of many classic RPGs.

+

EvAdventure Answer

+

We will design a simple quest system with some simple conditions for success, like carrying the right item or items back to the quest giver.

+
+
+
+

3.7. Characters

+
+

3.7.1. Can players have more than one Character active at a time or are they allowed to multi-play?

+

Since Evennia differentiates between Sessions (the client-connection to the game), Accounts and Characters, it natively supports multi-play. This is controlled by the MULTISESSION_MODE setting, which has a value from 0 (default) to 3.

+
    +
  • 0- One Character per Account and one Session per Account. This means that if you login to the same +account from another client you’ll be disconnected from the first. When creating a new account, a Character +will be auto-created with the same name as your Account. This is default mode and mimics legacy code bases +which had no separation between Account and Character.

  • +
  • 1 - One Character per Account, multiple Sessions per Account. So you can connect simultaneously from +multiple clients and see the same output in all of them.

  • +
  • 2 - Multiple Characters per Account, one Session per Character. This will not auto-create a same-named +Character for you, instead you get to create/choose between a number of Characters up to a max limit given by +the MAX_NR_CHARACTERS setting (default 1). You can play them all simultaneously if you have multiple clients +open, but only one client per Character.

  • +
  • 3 - Multiple Characters per Account, Multiple Sessions per Character. This is like mode 2, except players +can control each Character from multiple clients, seeing the same output from each Character.

  • +
+

EvAdventure Answer

+

Due to the nature of Knave, characters are squishy and probably short-lived. So it makes little sense to keep a stable of them. We’ll use use mode 0 or 1.

+
+
+

3.7.2. How does the character-generation work?

+

There are a few common ways to do character generation:

+
    +
  • Rooms. This is the traditional way. Each room’s description tells you what command to use to modify your character. When you are done you move to the next room. Only use this if you have another reason for using a room, like having a training dummy to test skills on, for example.

  • +
  • A Menu. The Evennia EvMenu system allows you to code very flexible in-game menus without needing to walk between rooms. You can both have a step-by-step menu (a ‘wizard’) or allow the user to jump between the +steps as they please. This tends to be a lot easier for newcomers to understand since it doesn’t require +using custom commands they will likely never use again after this.

  • +
  • Questions. A fun way to build a character is to answer a series of questions. This is usually implemented with a sequential menu.

  • +
+

EvAdventure Answer

+

Knave randomizes almost aspects of the Character generation. We’ll use a menu to let the player add their name and sex as well as do the minor re-assignment of stats allowed by the rules.

+
+
+

3.7.3. How do you implement different “classes” or “races”?

+

The way classes and races work in most RPGs is that they act as static ‘templates’ that inform which bonuses and special abilities you have. Much of this only comes into play during character generation or when leveling up.

+

Often all we need to store on the Character is which class and which race they have; the actual logic can sit in Python code and just be looked up when we need it.

+

EvAdventure Answer

+

There are no races and no classes in Knave. Every character is a human.

+
+
+

3.7.4. If a Character can hide in a room, what skill will decide if they are detected?

+

Hiding means a few things.

+
    +
  • The Character should not appear in the room’s description / character list

  • +
  • Others hould not be able to interact with a hidden character. It’d be weird if you could do attack <name> +or look <name> if the named character is in hiding.

  • +
  • There must be a way for the person to come out of hiding, and probably for others to search or accidentally +find the person (probably based on skill checks).

  • +
  • The room will also need to be involved, maybe with some modifier as to how easy it is to hide in the room.

  • +
+

EvAdventure Answer

+

We will not be including a hide-mechanic in EvAdventure.

+
+
+

3.7.5. What does the skill tree look like? Can a Character gain experience to improve? By killing enemies? Solving quests? By roleplaying?

+

Gaining experience points (XP) and improving one’s character is a staple of roleplaying games. There are many +ways to implement this:

+
    +
  • Gaining XP from kills is very common; it’s easy to let a monster be ‘worth’ a certain number of XP and it’s easy to tell when you should gain it.

  • +
  • Gaining XP from quests is the same - each quest is ‘worth’ XP and you get them when completing the test.

  • +
  • Gaining XP from roleplay is harder to define. Different games have tried a lot of different ways to do this:

    +
      +
    • XP from being online - just being online gains you XP. This inflates player numbers but many players may +just be lurking and not be actually playing the game at any given time.

    • +
    • XP from roleplaying scenes - you gain XP according to some algorithm analyzing your emotes for ‘quality’, +how often you post, how long your emotes are etc.

    • +
    • XP from actions - you gain XP when doing things, anything. Maybe your XP is even specific to each action, so +you gain XP only for running when you run, XP for your axe skill when you fight with an axe etc.

    • +
    • XP from fails - you only gain XP when failing rolls.

    • +
    • XP from other players - other players can award you XP for good RP.

    • +
    +
  • +
+

EvAdventure Answer

+

We will use an alternative rule in Knave, where Characters gain XP by spending coins they carry back from their adventures. The above-ground merchants will allow you to spend your coins and exchange them for XP 1:1. Each level costs 1000 coins. Every level you have 1d8  * new level (minimum what you had before + 1) HP, and can raise 3 different ability scores by 1 (max +10). There are no skills in Knave, but the principle of increasing them would be the same.

+
+
+

3.7.6. May player-characters attack each other (PvP)?

+

Deciding this affects the style of your entire game. PvP makes for exciting gameplay but it opens a whole new can of worms when it comes to “fairness”. Players will usually accept dying to an overpowered NPC dragon. They will not be as accepting if they perceive another player as being overpowered. PvP means that you +have to be very careful to balance the game - all characters does not have to be exactly equal but they should all be viable to play a fun game with.

+

PvP does not only mean combat though. Players can compete in all sorts of ways, including gaining influence in a political game or gaining market share when selling their crafted merchandise.

+

EvAdventure Answer

+

We will allow PvP only in one place - a special Dueling location where players can play-fight each other for training and prestige, but not actually get killed. Otherwise no PvP will be allowed. Note that without a full Barter system in place (just regular give, it makes it theoretically easier for players to scam one another.

+
+
+

3.7.7. What are the penalties of defeat? Permanent death? Quick respawn? Time in prison?

+

This is another big decision that strongly affects the mood and style of your game.

+

Perma-death means that once your character dies, it’s gone and you have to make a new one.

+
    +
  • It allows for true heroism. If you genuinely risk losing your character of two years to fight the dragon, +your triumph is an actual feat.

  • +
  • It limits the old-timer dominance problem. If long-time players dies occationally, it will open things +up for newcomers.

  • +
  • It lowers inflation, since the hoarded resources of a dead character can be removed.

  • +
  • It gives capital punishment genuine discouraging power.

  • +
  • It’s realistic.

  • +
+

Perma-death comes with some severe disadvantages however.

+
    +
  • Many players say they like the idea of permadeath except when it could happen to them.

  • +
  • Some players refuse to take any risks if death is permanent.

  • +
  • It may make players even more reluctant to play conflict-driving ‘bad guys’.

  • +
  • Balancing PvP becomes very hard. Fairness and avoiding exploits becomes critical when the outcome +is permanent.

  • +
+

For these reasons, it’s very common to do hybrid systems. Some tried variations:

+
    +
  • NPCs cannot kill you, only other players can.

  • +
  • Death is permanent, but it’s difficult to actually die - you are much more likely to end up being severely hurt/incapacitated.

  • +
  • You can pre-pay ‘insurance’ to magically/technologically avoid actually dying. Only if don’t have insurance will +you die permanently.

  • +
  • Death just means harsh penalties, not actual death.

  • +
  • When you die you can fight your way back to life from some sort of afterlife.

  • +
  • You’ll only die permanently if you as a player explicitly allows it.

  • +
+

EvAdventure Answer

+

In Knave, when you hit 0 HP, you roll on a death table, with a 1/8 chance of immediate death (otherwise you lose +points in a random stat). We will offer an “Insurance” that allows you to resurrect if you carry enough coin on you when +you die. If not, you are perma-dead and have to create a new character (which is easy and quick since it’s mostly +randomized).

+
+
+
+

3.8. Conclusions

+

Going through the questions has helped us get a little bit more of a feel for the game we want to do. There are many, many other things we could ask ourselves, but if we can cover these points we will be a good way towards a complete, +playable game!

+

In the last of these planning lessons we’ll sketch out how these ideas will map to Evennia.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Planning-Where-Do-I-Begin.html b/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Planning-Where-Do-I-Begin.html new file mode 100644 index 0000000000..5cd8fc1590 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part2/Beginner-Tutorial-Planning-Where-Do-I-Begin.html @@ -0,0 +1,285 @@ + + + + + + + + + 1. Where do I begin? — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

1. Where do I begin?

+

The good news is that following this Starting tutorial is a great way to begin making an Evennia game.

+

The bad news is that everyone’s different and when it comes to starting your own game there is no +one-size-fits-all answer. Instead we will ask a series of questions +to help you figure this out for yourself. It will also help you evaluate your own skills and maybe +put some more realistic limits on how fast you can achieve your goals.

+
+

The questions in this lesson do not really apply to our tutorial game since we know we are doing it +to learn Evennia. If you just want to follow along with the technical bits you can skip this lesson and +come back later when you feel ready to take on making your own game.

+
+
+

1.1. What is your motivation for doing this?

+

So you want to make a game. First you need to make a few things clear to yourself.

+

Making a multiplayer online game is a big undertaking. You will (if you are like most of us) be +doing it as a hobby, without getting paid. And you’ll be doing it for a long time.

+

So the very first thing you should ask yourself (and your team, if you have any) is +why am I doing this? Do some soul-searching here. Here are some possible answers:

+
    +
  • I want to earn recognition and fame from my online community and/or among my friends.

  • +
  • I want to build the game so I can play and enjoy it myself.

  • +
  • I want to build the same game I already play but without the bad people.

  • +
  • I want to create a game so that I can control it and be the head honcho.

  • +
  • A friend or online acquaintance talked me into working on it.

  • +
  • I work on this because I’m paid to (wow!)

  • +
  • I only build this for my own benefit or to see if I can pull it off.

  • +
  • I want to create something to give back to the community I love.

  • +
  • I want to use this project as a stepping-stone towards other projects (like a career in game design +or programming).

  • +
  • I am interested in coding or server and network architectures, making a MUD just seems to be a good +way to teach myself.

  • +
  • I want to build a commercial game and earn money.

  • +
  • I want to fulfill a life-long dream of game making.

  • +
+

There are many other possibilities. How “solid” your answer is for a long-term development project +is up to you. The important point is that you ask yourself the question.

+

Help someone else instead - Maybe you should not start a new project - maybe you’re better off +helping someone else or improve on something that already exists. Or maybe you find you are more of a +game engine developer than a game designer.

+

Driven by emotion - Some answers may suggest that you are driven by emotions of revenge or disconcert. Be careful with that and +check so that’s not your only driving force. Those emotions may have abated later when the project +most needs your enthusiasm and motivation.

+

Going commercial - If your aim is to earn money, your design goals will likely be very different from +those of a person who only creates as a hobby or for their own benefit. You may also have a much stricter +timeline for release.

+

Whichever your motivation, you should at least have it clear in your own mind. It’s worth to make +sure your eventual team is on the same page too.

+
+
+

1.2. What are your skills?

+

Once you have your motivations straight you need to take a stock of your own skills and the skills +available in your team, if you have any.

+

Your game will have two principal components and you will need skills to cater for both:

+
    +
  • The game engine / code base - Evennia in this case.

  • +
  • The assets created for using the game engine (“the game world”)

  • +
+
+

1.2.1. The game engine

+

The game engine is maintained and modified by programmers (coders). It represents the infrastructure +that runs the game - the network code, the protocol support, the handling of commands, scripting and +data storage.

+

If you are just evaluating Evennia, it’s worth to do the following:

+
    +
  • Hang out in the community/forums/chat. Expect to need to ask a lot of “stupid” questions as you start +developing (hint: no question is stupid). Is this a community in which you would feel comfortable doing so?

  • +
  • Keep tabs on the manual (you’re already here).

  • +
  • How’s your Python skills? What are the skills in your team? Do you or your team already know it or are +you willing to learn? Learning the language as you go is not too unusual with Evennia devs, but expect it +to add development time. You will also be worse at predicting how ‘hard’ something is to do.

  • +
  • If you don’t know Python, you should have gotten a few tastes from the first part of this tutorial. But +expect to have to refer to external online tutorials - there are many details of Python that will not be +covered.

  • +
+
+
+

1.2.2. Asset creation

+

Compared to the level of work needed to produce professional graphics for an MMORPG, detailed text +assets for a mud are cheap to create. This is one of the many reasons muds are so well suited for a +small team.

+

This is not to say that making “professional” text content is easy though. Knowing how to write +imaginative and grammatically correct prose is only the minimal starting requirement. A good asset- +creator (traditionally called a “builder”) must also be able to utilize the tools of the game engine +to its fullest in order to script events, make quests, triggers and interactive, interesting +environments.

+

Assuming you are not coding all alone, your team’s in-house builders will be the first ones to actually +“use” your game framework and build tools. They will stumble on all the bugs. This means that you +need people who are just not “artsy” or “good with words”. Assuming coders and builders are not the +same people (common for early testing), builders need to be able to collaborate well and give clear +and concise feedback.

+

If you know your builders are not tech-savvy, you may need to spend more time making easier +build-tools and commands for them.

+
+
+
+

1.3. So, where do I begin, then?

+

Right, after all this soul-searching and skill-inventory-checking, let’s go back to the original +question. And maybe you’ll find that you have a better feeling for the answer yourself already:

+
    +
  • Keep following this tutorial and spend the time +to really understand what is happening in the examples. Not only will this give you a better idea +of how parts hang together, it may also give you ideas for what is possible. Maybe something +is easier than you expected!

  • +
  • Introduce yourself in the IRC/Discord chat and don’t be shy to ask questions as you go through +the tutorial. Don’t get hung up on trying to resolve something that a seasoned Evennia dev may +clear up for you in five minutes. Also, not all errors are your faults - it’s possible the +tutorial is unclear or has bugs, asking will quickly bring those problems to light, if so.

  • +
  • If Python is new to you, you should complement the tutorial with third-party Python references +so you can read, understand and replicate example code without being completely in the dark.

  • +
+

Once you are out of the starting tutorial, you’ll be off to do your own thing.

+
    +
  • The starting tutorial cannot cover everything. Skim through the Evennia docs. +Even if you don’t read everything, it gives you a feeling for what’s available should you need +to look for something later. Make sure to use the search function.

  • +
  • You can now start by expanding on the tutorial-game we will have created. In the last part there +there will be a list of possible future projects you could take on. Working on your own, without help +from a tutorial is the next step.

  • +
+

As for your builders, they can start getting familiar with Evennia’s default build commands … but +keep in mind that your game is not yet built! Don’t set your builders off on creating large zone projects. +If they build anything at all, it should be small test areas to agree on a homogenous form, mood +and literary style.

+
+
+

1.4. Conclusions

+

Remember that what kills a hobby game project will usually be your own lack of +motivation. So do whatever you can to keep that motivation burning strong! Even if it means +deviating from what you read in a tutorial like this one. Just get that game out there, whichever way +works best for you.

+

In the next lesson we’ll go through some of the technical questions you need to consider. This should +hopefully help you figure out more about the game you want to make. In the lesson following that we’ll +then try to answer those questions for the sake of creating our little tutorial game.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.html new file mode 100644 index 0000000000..b6dc366caa --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.html @@ -0,0 +1,141 @@ + + + + + + + + + 12. NPC and monster AI — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

12. NPC and monster AI

+
+

Warning

+

This part of the Beginner tutorial is still being developed.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Characters.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Characters.html new file mode 100644 index 0000000000..a5ab9ffaa5 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Characters.html @@ -0,0 +1,556 @@ + + + + + + + + + 3. Player Characters — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

3. Player Characters

+

In the previous lesson about rules and dice rolling we made some +assumptions about the “Player Character” entity:

+
    +
  • It should store Abilities on itself as character.strength, character.constitution etc.

  • +
  • It should have a .heal(amount) method.

  • +
+

So we have some guidelines of how it should look! A Character is a database entity with values that +should be able to be changed over time. It makes sense to base it off Evennia’s +DefaultCharacter Typeclass. The Character class is like a ‘character sheet’ in a tabletop +RPG, it will hold everything relevant to that PC.

+
+

3.1. Inheritance structure

+

Player Characters (PCs) are not the only “living” things in our world. We also have NPCs +(like shopkeepers and other friendlies) as well as monsters (mobs) that can attack us.

+

In code, there are a few ways we could structure this. If NPCs/monsters were just special cases of PCs, +we could use a class inheritance like this:

+
from evennia import DefaultCharacter 
+
+class EvAdventureCharacter(DefaultCharacter): 
+    # stuff 
+    
+class EvAdventureNPC(EvAdventureCharacter):
+    # more stuff 
+    
+class EvAdventureMob(EvAdventureNPC): 
+    # more stuff 
+
+
+

All code we put on the Character class would now be inherited to NPC and Mob automatically.

+

However, in Knave, NPCs and particularly monsters are not using the same rules as PCs - they are +simplified to use a Hit-Die (HD) concept. So while still character-like, NPCs should be separate from +PCs like this:

+
from evennia import DefaultCharacter 
+
+class EvAdventureCharacter(DefaultCharacter): 
+    # stuff 
+
+class EvAdventureNPC(DefaultCharacter):
+    # separate stuff 
+    
+class EvAdventureMob(EvadventureNPC):
+    # more separate stuff
+
+
+

Nevertheless, there are some things that should be common for all ‘living things’:

+
    +
  • All can take damage.

  • +
  • All can die.

  • +
  • All can heal

  • +
  • All can hold and lose coins

  • +
  • All can loot their fallen foes.

  • +
  • All can get looted when defeated.

  • +
+

We don’t want to code this separately for every class but we no longer have a common parent +class to put it on. So instead we’ll use the concept of a mixin class:

+
from evennia import DefaultCharacter 
+
+class LivingMixin:
+    # stuff common for all living things
+
+class EvAdventureCharacter(LivingMixin, DefaultCharacter): 
+    # stuff 
+
+class EvAdventureNPC(LivingMixin, DefaultCharacter):
+    # stuff 
+    
+class EvAdventureMob(LivingMixin, EvadventureNPC):
+    # more stuff
+
+
+ +

Above, the LivingMixin class cannot work on its own - it just ‘patches’ the other classes with some +extra functionality all living things should be able to do. This is an example of +multiple inheritance. It’s useful to know about, but one should not over-do multiple inheritance +since it can also get confusing to follow the code.

+
+
+

3.2. Living mixin class

+
+

Create a new module mygame/evadventure/characters.py

+
+

Let’s get some useful common methods all living things should have in our game.

+
# in mygame/evadventure/characters.py 
+
+from .rules import dice 
+
+class LivingMixin:
+
+    # makes it easy for mobs to know to attack PCs
+    is_pc = False  
+
+	@property
+    def hurt_level(self):
+        """
+        String describing how hurt this character is.
+        """
+        percent = max(0, min(100, 100 * (self.hp / self.hp_max)))
+        if 95 < percent <= 100:
+            return "|gPerfect|n"
+        elif 80 < percent <= 95:
+            return "|gScraped|n"
+        elif 60 < percent <= 80:
+            return "|GBruised|n"
+        elif 45 < percent <= 60:
+            return "|yHurt|n"
+        elif 30 < percent <= 45:
+            return "|yWounded|n"
+        elif 15 < percent <= 30:
+            return "|rBadly wounded|n"
+        elif 1 < percent <= 15:
+            return "|rBarely hanging on|n"
+        elif percent == 0:
+            return "|RCollapsed!|n"
+
+    def heal(self, hp): 
+        """ 
+        Heal hp amount of health, not allowing to exceed our max hp
+         
+        """ 
+        damage = self.hp_max - self.hp 
+        healed = min(damage, hp) 
+        self.hp += healed 
+        
+        self.msg(f"You heal for {healed} HP.") 
+        
+    def at_pay(self, amount):
+        """When paying coins, make sure to never detract more than we have"""
+        amount = min(amount, self.coins)
+        self.coins -= amount
+        return amount
+        
+    def at_attacked(self, attacker, **kwargs): 
+		"""Called when being attacked and combat starts."""
+		pass
+    
+    def at_damage(self, damage, attacker=None):
+        """Called when attacked and taking damage."""
+        self.hp -= damage  
+        
+    def at_defeat(self): 
+        """Called when defeated. By default this means death."""
+        self.at_death()
+        
+    def at_death(self):
+        """Called when this thing dies."""
+        # this will mean different things for different living things
+        pass 
+        
+    def at_do_loot(self, looted):
+        """Called when looting another entity""" 
+        looted.at_looted(self)
+        
+    def at_looted(self, looter):
+        """Called when looted by another entity""" 
+        
+        # default to stealing some coins 
+        max_steal = dice.roll("1d10") 
+        stolen = self.at_pay(max_steal)
+        looter.coins += stolen
+
+
+
+

Most of these are empty since they will behave differently for characters and npcs. But having them in the mixin means we can expect these methods to be available for all living things.

+

Once we create more of our game, we will need to remember to actually call these hook methods so they serve a purpose. For example, once we implement combat, we must remember to call at_attacked as well as the other methods involving taking damage, getting defeated or dying.

+
+
+

3.3. Character class

+

We will now start making the basic Character class, based on what we need from Knave.

+
# in mygame/evadventure/characters.py
+
+from evennia import DefaultCharacter, AttributeProperty
+from .rules import dice 
+
+class LivingMixin:
+    # ... 
+
+
+class EvAdventureCharacter(LivingMixin, DefaultCharacter):
+    """ 
+    A character to use for EvAdventure. 
+    """
+    is_pc = True 
+
+    strength = AttributeProperty(1) 
+    dexterity = AttributeProperty(1)
+    constitution = AttributeProperty(1)
+    intelligence = AttributeProperty(1)
+    wisdom = AttributeProperty(1)
+    charisma = AttributeProperty(1)
+    
+    hp = AttributeProperty(8) 
+    hp_max = AttributeProperty(8)
+    
+    level = AttributeProperty(1)
+    xp = AttributeProperty(0)
+    coins = AttributeProperty(0)
+
+    def at_defeat(self):
+        """Characters roll on the death table"""
+        if self.location.allow_death:
+            # this allow rooms to have non-lethal battles
+            dice.roll_death(self)
+        else:
+            self.location.msg_contents(
+                "$You() $conj(collapse) in a heap, alive but beaten.",
+                from_obj=self)
+            self.heal(self.hp_max)
+            
+    def at_death(self):
+        """We rolled 'dead' on the death table."""
+        self.location.msg_contents(
+            "$You() collapse in a heap, embraced by death.",
+            from_obj=self) 
+        # TODO - go back into chargen to make a new character!            
+
+
+

We make an assumption about our rooms here - that they have a property .allow_death. We need to make a note to actually add such a property to rooms later!

+

In our Character class we implement all attributes we want to simulate from the Knave ruleset. +The AttributeProperty is one way to add an Attribute in a field-like way; these will be accessible on every character in several ways:

+
    +
  • As character.strength

  • +
  • As character.db.strength

  • +
  • As character.attributes.get("strength")

  • +
+

See Attributes for seeing how Attributes work.

+

Unlike in base Knave, we store coins as a separate Attribute rather than as items in the inventory, this makes it easier to handle barter and trading later.

+

We implement the Player Character versions of at_defeat and at_death. We also make use of .heal() from the LivingMixin class.

+
+

3.3.1. Funcparser inlines

+

This piece of code is worth some more explanation:

+
self.location.msg_contents(
+    "$You() $conj(collapse) in a heap, alive but beaten.",
+    from_obj=self)
+
+
+

Remember that self is the Character instance here. So self.location.msg_contents means “send a message to everything inside my current location”. In other words, send a message to everyone in the same place as the character.

+

The $You() $conj(collapse) are FuncParser inlines. These are functions that +execute in the string. The resulting string may look different for different audiences. The $You() inline function will use from_obj to figure out who ‘you’ are and either show your name or ‘You’. The $conj() (verb conjugator) will tweak the (English) verb to match.

+
    +
  • You will see: "You collapse in a heap, alive but beaten."

  • +
  • Others in the room will see: "Thomas collapses in a heap, alive but beaten."

  • +
+

Note how $conj() chose collapse/collapses to make the sentences grammatically correct.

+
+
+

3.3.2. Backtracking

+

We make our first use of the rules.dice roller to roll on the death table! As you may recall, in the previous lesson, we didn’t know just what to do when rolling ‘dead’ on this table. Now we know - we should be calling at_death on the character. So let’s add that where we had TODOs before:

+
# mygame/evadventure/rules.py 
+
+class EvAdventureRollEngine:
+    
+    # ... 
+
+    def roll_death(self, character): 
+        ability_name = self.roll_random_table("1d8", death_table)
+
+        if ability_name == "dead":
+            # kill the character!
+            character.at_death()  # <------ TODO no more
+        else: 
+            # ... 
+                        
+            if current_ability < -10: 
+                # kill the character!
+                character.at_death()  # <------- TODO no more
+            else:
+                # ... 
+
+
+
+
+
+

3.4. Connecting the Character with Evennia

+

You can easily make yourself an EvAdventureCharacter in-game by using the +type command:

+
type self = evadventure.characters.EvAdventureCharacter
+
+
+

You can now do examine self to check your type updated.

+

If you want all new Characters to be of this type you need to tell Evennia about it. Evennia +uses a global setting BASE_CHARACTER_TYPECLASS to know which typeclass to use when creating +Characters (when logging in, for example). This defaults to typeclasses.characters.Character (that is, +the Character class in mygame/typeclasses/characters.py).

+

There are thus two ways to weave your new Character class into Evennia:

+
    +
  1. Change mygame/server/conf/settings.py and add BASE_CHARACTER_TYPECLASS = "evadventure.characters.EvAdventureCharacter".

  2. +
  3. Or, change typeclasses.characters.Character to inherit from EvAdventureCharacter.

  4. +
+

You must always reload the server for changes like this to take effect.

+
+

Important

+

In this tutorial we are making all changes in a folder mygame/evadventure/. This means we can isolate +our code but means we need to do some extra steps to tie the character (and other objects) into Evennia. +For your own game it would be just fine to start editing mygame/typeclasses/characters.py directly +instead.

+
+
+
+

3.5. Unit Testing

+
+

Create a new module mygame/evadventure/tests/test_characters.py

+
+

For testing, we just need to create a new EvAdventure character and check +that calling the methods on it doesn’t error out.

+
# mygame/evadventure/tests/test_characters.py 
+
+from evennia.utils import create
+from evennia.utils.test_resources import BaseEvenniaTest 
+
+from ..characters import EvAdventureCharacter 
+
+class TestCharacters(BaseEvenniaTest):
+    def setUp(self):
+        super().setUp()
+        self.character = create.create_object(EvAdventureCharacter, key="testchar")
+
+    def test_heal(self):
+        self.character.hp = 0 
+        self.character.hp_max = 8 
+        
+        self.character.heal(1)
+        self.assertEqual(self.character.hp, 1)
+        # make sure we can't heal more than max
+        self.character.heal(100)
+        self.assertEqual(self.character.hp, 8)
+        
+    def test_at_pay(self):
+        self.character.coins = 100 
+        
+        result = self.character.at_pay(60)
+        self.assertEqual(result, 60) 
+        self.assertEqual(self.character.coins, 40)
+        
+        # can't get more coins than we have 
+        result = self.character.at_pay(100)
+        self.assertEqual(result, 40)
+        self.assertEqual(self.character.coins, 0)
+        
+    # tests for other methods ... 
+
+
+
+

If you followed the previous lessons, these tests should look familiar. Consider adding +tests for other methods as practice. Refer to previous lessons for details.

+

For running the tests you do:

+
 evennia test --settings settings.py .evadventure.tests.test_character
+
+
+
+
+

3.6. About races and classes

+

Knave doesn’t have any D&D-style classes (like Thief, Fighter etc). It also does not bother with +races (like dwarves, elves etc). This makes the tutorial shorter, but you may ask yourself how you’d +add these functions.

+

In the framework we have sketched out for Knave, it would be simple - you’d add your race/class as +an Attribute on your Character:

+
# mygame/evadventure/characters.py
+
+from evennia import DefaultCharacter, AttributeProperty
+# ... 
+
+class EvAdventureCharacter(LivingMixin, DefaultCharacter):
+    
+    # ... 
+
+    charclass = AttributeProperty("Fighter")
+    charrace = AttributeProperty("Human")
+
+
+
+

We use charclass rather than class here, because class is a reserved Python keyword. Naming +race as charrace thus matches in style.

+

We’d then need to expand our rules module (and later +character generation to check and include what these classes mean.

+
+
+

3.7. Summary

+

With the EvAdventureCharacter class in place, we have a better understanding of how our PCs will look +like under Knave.

+

For now, we only have bits and pieces and haven’t been testing this code in-game. But if you want +you can swap yourself into EvAdventureCharacter right now. Log into your game and run +the command

+
type self = evadventure.characters.EvAdventureCharacter 
+
+
+

If all went well, ex self will now show your typeclass as being EvAdventureCharacter. +Check out your strength with

+
py self.strength = 3
+
+
+
+

Important

+

When doing ex self you will not see all your Abilities listed yet. That’s because +Attributes added with AttributeProperty are not available until they have been accessed at +least once. So once you set (or look at) .strength above, strength will show in examine from +then on.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.html new file mode 100644 index 0000000000..084bda60a0 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Chargen.html @@ -0,0 +1,768 @@ + + + + + + + + + 6. Character Generation — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

6. Character Generation

+

In previous lessons we have established how a character looks. Now we need to give the player a +chance to create one.

+
+

6.1. How it will work

+

A fresh Evennia install will automatically create a new Character with the same name as your +Account when you log in. This is quick and simple and mimics older MUD styles. You could picture +doing this, and then customizing the Character in-place.

+

We will be a little more sophisticated though. We want the user to be able to create a character +using a menu when they log in.

+

We do this by editing mygame/server/conf/settings.py and adding the line

+
AUTO_CREATE_CHARACTER_WITH_ACCOUNT = False 
+
+
+

When doing this, connecting with the game with a new account will land you in “OOC” mode. The ooc-version of look (sitting in the Account cmdset) will show a list of available characters if you have any. You can also enter charcreate to make a new character. The charcreate is a simple command coming with Evennia that just lets you make a new character with a given name and description. We will later modify that to kick off our chargen. For now we’ll just keep in mind that’s how we’ll start off the menu.

+

In Knave, most of the character-generation is random. This means this tutorial can be pretty +compact while still showing the basic idea. What we will create is a menu looking like this:

+
Silas 
+
+STR +1
+DEX +2
+CON +1
+INT +3
+WIS +1
+CHA +2
+
+You are lanky with a sunken face and filthy hair, breathy speech, and foreign clothing.
+You were a herbalist, but you were pursued and ended up a knave. You are honest but also 
+suspicious. You are of the neutral alignment. 
+
+Your belongings: 
+Brigandine armor, ration, ration, sword, torch, torch, torch, torch, torch, 
+tinderbox, chisel, whistle
+
+----------------------------------------------------------------------------------------
+1. Change your name 
+2. Swap two of your ability scores (once)
+3. Accept and create character
+
+
+

If you select 1, you get a new menu node:

+
Your current name is Silas. Enter a new name or leave empty to abort.
+-----------------------------------------------------------------------------------------
+
+
+

You can now enter a new name. When pressing return you’ll get back to the first menu node +showing your character, now with the new name.

+

If you select 2, you go to another menu node:

+
Your current abilities: 
+
+STR +1
+DEX +2
+CON +1
+INT +3
+WIS +1
+CHA +2
+
+You can swap the values of two abilities around.
+You can only do this once, so choose carefully!
+
+To swap the values of e.g. STR and INT, write 'STR INT'. Empty to abort.
+------------------------------------------------------------------------------------------
+
+
+

If you enter WIS CHA here, WIS will become +2 and CHA +1. You will then again go back +to the main node to see your new character, but this time the option to swap will no longer be +available (you can only do it once).

+

If you finally select the Accept and create character option, the character will be created +and you’ll leave the menu;

+
Character was created! 
+
+
+
+
+

6.2. Random tables

+ +
+

Make a new module mygame/evadventure/random_tables.py.

+
+

Since most of Knave’s character generation is random we will need to roll on random tables +from the Knave rulebook. While we added the ability to roll on a random table back in the +Rules Tutorial, we haven’t added the relevant tables yet.

+
# in mygame/evadventure/random_tables.py 
+
+chargen_tables = {
+    "physique": [
+        "athletic", "brawny", "corpulent", "delicate", "gaunt", "hulking", "lanky",
+        "ripped", "rugged", "scrawny", "short", "sinewy", "slender", "flabby",
+        "statuesque", "stout", "tiny", "towering", "willowy", "wiry",
+    ],
+    "face": [
+        "bloated", "blunt", "bony", # ... 
+    ], # ... 
+}
+
+
+
+

The tables are just copied from the Knave rules. We group the aspects in a dict +character_generation to separate chargen-only tables from other random tables we’ll also +keep in here.

+
+
+

6.3. Storing state of the menu

+ +
+

create a new module mygame/evadventure/chargen.py.

+
+

During character generation we will need an entity to store/retain the changes, like a +‘temporary character sheet’.

+
# in mygame/evadventure/chargen.py 
+
+from .random_tables import chargen_tables
+from .rules import dice 
+
+class TemporaryCharacterSheet:
+    
+    def _random_ability(self):
+        return min(dice.roll("1d6"), dice.roll("1d6"), dice.roll("1d6"))
+        
+    def __init__(self):
+        self.ability_changes = 0  # how many times we tried swap abilities
+        
+        # name will likely be modified later
+        self.name = dice.roll_random_table("1d282", chargen_tables["name"])
+
+        # base attribute values
+        self.strength = self._random_ability()
+        self.dexterity = self._random_ability()
+        self.constitution = self._random_ability()
+        self.intelligence = self._random_ability()
+        self.wisdom = self._random_ability()
+        self.charisma = self._random_ability()
+
+        # physical attributes (only for rp purposes)
+        physique = dice.roll_random_table("1d20", chargen_tables["physique"])
+        face = dice.roll_random_table("1d20", chargen_tables["face"])
+        skin = dice.roll_random_table("1d20", chargen_tables["skin"])
+        hair = dice.roll_random_table("1d20", chargen_tables["hair"])
+        clothing = dice.roll_random_table("1d20", chargen_tables["clothing"])
+        speech = dice.roll_random_table("1d20", chargen_tables["speech"])
+        virtue = dice.roll_random_table("1d20", chargen_tables["virtue"])
+        vice = dice.roll_random_table("1d20", chargen_tables["vice"])
+        background = dice.roll_random_table("1d20", chargen_tables["background"])
+        misfortune = dice.roll_random_table("1d20", chargen_tables["misfortune"])
+        alignment = dice.roll_random_table("1d20", chargen_tables["alignment"])
+
+        self.desc = (
+            f"You are {physique} with a {face} face, {skin} skin, {hair} hair, {speech} speech,"
+            f" and {clothing} clothing. You were a {background.title()}, but you were"
+            f" {misfortune} and ended up a knave. You are {virtue} but also {vice}. You are of the"
+            f" {alignment} alignment."
+        )
+
+        # 
+        self.hp_max = max(5, dice.roll("1d8"))
+        self.hp = self.hp_max
+        self.xp = 0
+        self.level = 1
+
+        # random equipment
+        self.armor = dice.roll_random_table("1d20", chargen_tables["armor"])
+
+        _helmet_and_shield = dice.roll_random_table("1d20", chargen_tables["helmets and shields"])
+        self.helmet = "helmet" if "helmet" in _helmet_and_shield else "none"
+        self.shield = "shield" if "shield" in _helmet_and_shield else "none"
+
+        self.weapon = dice.roll_random_table("1d20", chargen_tables["starting weapon"])
+
+        self.backpack = [
+            "ration",
+            "ration",
+            dice.roll_random_table("1d20", chargen_tables["dungeoning gear"]),
+            dice.roll_random_table("1d20", chargen_tables["dungeoning gear"]),
+            dice.roll_random_table("1d20", chargen_tables["general gear 1"]),
+            dice.roll_random_table("1d20", chargen_tables["general gear 2"]),
+        ]
+
+
+

Here we have followed the Knave rulebook to randomize abilities, description and equipment. The dice.roll() and dice.roll_random_table methods now become very useful! Everything here should be easy to follow.

+

The main difference from baseline Knave is that we make a table of “starting weapon” (in Knave you can pick whatever you like).

+

We also initialize .ability_changes = 0. Knave only allows us to swap the values of two +Abilities once. We will use this to know if it has been done or not.

+
+

6.3.1. Showing the sheet

+

Now that we have our temporary character sheet, we should make it easy to visualize it.

+
# in mygame/evadventure/chargen.py 
+
+_TEMP_SHEET = """
+{name}
+
+STR +{strength}
+DEX +{dexterity}
+CON +{constitution}
+INT +{intelligence}
+WIS +{wisdom}
+CHA +{charisma}
+
+{description}
+    
+Your belongings:
+{equipment}
+"""
+
+class TemporaryCharacterSheet: 
+    
+    # ... 
+    
+    def show_sheet(self):
+        equipment = (
+            str(item)
+            for item in [self.armor, self.helmet, self.shield, self.weapon] + self.backpack
+            if item
+        )
+
+        return _TEMP_SHEET.format(
+            name=self.name,
+            strength=self.strength,
+            dexterity=self.dexterity,
+            constitution=self.constitution,
+            intelligence=self.intelligence,
+            wisdom=self.wisdom,
+            charisma=self.charisma,
+            description=self.desc,
+            equipment=", ".join(equipment),
+        )
+
+
+
+

The new show_sheet method collect the data from the temporary sheet and return it in a pretty form. Making a ‘template’ string like _TEMP_SHEET makes it easier to change things later if you want to change how things look.

+
+
+

6.3.2. Apply character

+

Once we are happy with our character, we need to actually create it with the stats we chose. +This is a bit more involved.

+
# in mygame/evadventure/chargen.py 
+
+# ... 
+
+from .characters import EvAdventureCharacter
+from evennia import create_object
+from evennia.prototypes.spawner import spawn 
+
+
+class TemporaryCharacterSheet:
+     
+    # ...  
+
+    def apply(self):
+        # create character object with given abilities
+        new_character = create_object(
+            EvAdventureCharacter,
+            key=self.name,
+            attrs=(
+                ("strength", self.strength),
+                ("dexterity", self.dexterity),
+                ("constitution", self.constitution),
+                ("intelligence", self.intelligence),
+                ("wisdom", self.wisdom),
+                ("charisma", self.wisdom),
+                ("hp", self.hp),
+                ("hp_max", self.hp_max),
+                ("desc", self.desc),     
+            ),                           
+        )                                
+        # spawn equipment (will require prototypes created before it works)
+        if self.weapon:                  
+            weapon = spawn(self.weapon)  
+            new_character.equipment.move(weapon)
+        if self.shield:                  
+            shield = spawn(self.shield)  
+            new_character.equipment.move(shield)
+        if self.armor:                   
+            armor = spawn(self.armor)    
+            new_character.equipment.move(armor)
+        if self.helmet:                  
+            helmet = spawn(self.helmet)  
+            new_character.equipment.move(helmet)
+            
+        for item in self.backpack:
+            item = spawn(item)
+            new_character.equipment.store(item)
+                                        
+        return new_character  
+
+
+

We use create_object to create a new EvAdventureCharacter. We feed it with all relevant data from the temporary character sheet. This is when these become an actual character.

+ +

Each piece of equipment is an object in in its own right. We will here assume that all game +items are defined as Prototypes keyed to its name, such as “sword”, “brigandine +armor” etc.

+

We haven’t actually created those prototypes yet, so for now we’ll need to assume they are there. Once a piece of equipment has been spawned, we make sure to move it into the EquipmentHandler we created in the Equipment lesson.

+
+
+
+

6.4. Initializing EvMenu

+

Evennia comes with a full menu-generation system based on Command sets, called +EvMenu.

+
# in mygame/evadventure/chargen.py
+
+from evennia import EvMenu 
+
+# ...
+
+# chargen menu 
+
+
+# this goes to the bottom of the module
+
+def start_chargen(caller, session=None):
+    """
+    This is a start point for spinning up the chargen from a command later.
+
+    """
+
+    menutree = {}  # TODO!
+
+    # this generates all random components of the character
+    tmp_character = TemporaryCharacterSheet()
+
+    EvMenu(caller, menutree, session=session, tmp_character=tmp_character)
+
+
+
+

This first function is what we will call from elsewhere (for example from a custom charcreate +command) to kick the menu into gear.

+

It takes the caller (the one to want to start the menu) and a session argument. The latter will help track just which client-connection we are using (depending on Evennia settings, you could be connecting with multiple clients).

+

We create a TemporaryCharacterSheet and call .generate() to make a random character. We then feed all this into EvMenu.

+

The moment this happens, the user will be in the menu, there are no further steps needed.

+

The menutree is what we’ll create next. It describes which menu ‘nodes’ are available to jump +between.

+
+
+

6.5. Main Node: Choosing what to do

+

This is the first menu node. It will act as a central hub, from which one can choose different +actions.

+
# in mygame/evadventure/chargen.py 
+
+# ...
+
+# at the end of the module, but before the `start_chargen` function
+
+def node_chargen(caller, raw_string, **kwargs): 
+
+    tmp_character = kwargs["tmp_character"]
+
+    text = tmp_character.show_sheet()
+
+    options = [
+        {
+           "desc": "Change your name", 
+           "goto": ("node_change_name", kwargs)
+        }
+    ]
+    if tmp_character.ability_changes <= 0:
+        options.append( 
+            { 
+                "desc": "Swap two of your ability scores (once)",
+                "goto": ("node_swap_abilities", kwargs),
+            }
+        )
+    options.append(
+        {
+            "desc": "Accept and create character", 
+            "goto": ("node_apply_character", kwargs)
+        },
+    )
+
+    return text, options
+
+# ...
+
+
+

A lot to unpack here! In Evennia, it’s convention to name your node-functions node_*. While +not required, it helps you track what is a node and not.

+

Every menu-node, should accept caller, raw_string, **kwargs as arguments. Here caller is the caller you passed into the EvMenu call. raw_string is the input given by the user in order to get to this node, so currently empty. The **kwargs are all extra keyword arguments passed into EvMenu. They can also be passed between nodes. In this case, we passed the keyword tmp_character to EvMenu. We now have the temporary character sheet available in the node!

+

An EvMenu node must always return two things - text and options. The text is what will +show to the user when looking at this node. The options are, well, what options should be +presented to move on from here to some other place.

+

For the text, we simply get a pretty-print of the temporary character sheet. A single option is +defined as a dict like this:

+
{ 
+    "key": ("name". "alias1", "alias2", ...),  # if skipped, auto-show a number
+    "desc": "text to describe what happens when selecting option",.
+    "goto": ("name of node or a callable", kwargs_to_pass_into_next_node_or_callable)
+}
+
+
+

Multiple option-dicts are returned in a list or tuple. The goto option-key is important to +understand. The job of this is to either point directly to another node (by giving its name), or +by pointing to a Python callable (like a function) that then returns that name. You can also +pass kwargs (as a dict). This will be made available as **kwargs in the callable or next node.

+

While an option can have a key, you can also skip it to just get a running number.

+

In our node_chargen node, we point to three nodes by name: node_change_name, +node_swap_abilities, and node_apply_character. We also make sure to pass along kwargs +to each node, since that contains our temporary character sheet.

+

The middle of these options only appear if we haven’t already switched two abilities around - to know this, we check the .ability_changes property to make sure it’s still 0.

+
+
+

6.6. Node: Changing your name

+

This is where you end up if you opted to change your name in node_chargen.

+
# in mygame/evadventure/chargen.py
+
+# ...
+
+# after previous node 
+
+def _update_name(caller, raw_string, **kwargs):
+    """
+    Used by node_change_name below to check what user 
+    entered and update the name if appropriate.
+
+    """
+    if raw_string:
+        tmp_character = kwargs["tmp_character"]
+        tmp_character.name = raw_string.lower().capitalize()
+
+    return "node_chargen", kwargs
+
+
+def node_change_name(caller, raw_string, **kwargs):
+    """
+    Change the random name of the character.
+
+    """
+    tmp_character = kwargs["tmp_character"]
+
+    text = (
+        f"Your current name is |w{tmp_character.name}|n. "
+        "Enter a new name or leave empty to abort." 
+    )
+
+    options = {
+                   "key": "_default", 
+                   "goto": (_update_name, kwargs)
+              }
+
+    return text, options
+
+
+

There are two functions here - the menu node itself (node_change_name) and a +helper goto_function (_update_name) to handle the user’s input.

+

For the (single) option, we use a special key named _default. This makes this option +a catch-all: If the user enters something that does not match any other option, this is +the option that will be used. Since we have no other options here, we will always use this option no matter what the user enters.

+

Also note that the goto part of the option points to the _update_name callable rather than to +the name of a node. It’s important we keep passing kwargs along to it!

+

When a user writes anything at this node, the _update_name callable will be called. This has +the same arguments as a node, but it is not a node - we will only use it to figure out which +node to go to next.

+

In _update_name we now have a use for the raw_string argument - this is what was written by the user on the previous node, remember? This is now either an empty string (meaning to ignore it) or the new name of the character.

+

A goto-function like _update_name must return the name of the next node to use. It can also +optionally return the kwargs to pass into that node - we want to always do this, so we don’t +loose our temporary character sheet. Here we will always go back to the node_chargen.

+
+

Hint: If returning None from a goto-callable, you will always return to the last node you +were at.

+
+
+
+

6.7. Node: Swapping Abilities around

+

You get here by selecting the second option from the node_chargen node.

+
# in mygame/evadventure/chargen.py 
+
+# ...
+
+# after previous node 
+
+_ABILITIES = {
+    "STR": "strength",
+    "DEX": "dexterity",
+    "CON": "constitution",
+    "INT": "intelligence",
+    "WIS": "wisdom",
+    "CHA": "charisma",
+}
+
+
+def _swap_abilities(caller, raw_string, **kwargs):
+    """
+    Used by node_swap_abilities to parse the user's input and swap ability
+    values.
+
+    """
+    if raw_string:
+        abi1, *abi2 = raw_string.split(" ", 1)
+        if not abi2:
+            caller.msg("That doesn't look right.")
+            return None, kwargs
+        abi2 = abi2[0]
+        abi1, abi2 = abi1.upper().strip(), abi2.upper().strip()
+        if abi1 not in _ABILITIES or abi2 not in _ABILITIES:
+            caller.msg("Not a familiar set of abilites.")
+            return None, kwargs
+        
+        # looks okay = swap values. We need to convert STR to strength etc
+        tmp_character = kwargs["tmp_character"]
+        abi1 = _ABILITIES[abi1]
+        abi2 = _ABILITIES[abi2]
+        abival1 = getattr(tmp_character, abi1)
+        abival2 = getattr(tmp_character, abi2)
+            
+        setattr(tmp_character, abi1, abival2)
+        setattr(tmp_character, abi2, abival1)
+        
+        tmp_character.ability_changes += 1
+        
+    return "node_chargen", kwargs
+
+            
+def node_swap_abilities(caller, raw_string, **kwargs):
+    """ 
+    One is allowed to swap the values of two abilities around, once.
+
+    """
+    tmp_character = kwargs["tmp_character"]
+
+    text = f"""
+Your current abilities:
+
+STR +{tmp_character.strength}
+DEX +{tmp_character.dexterity}
+CON +{tmp_character.constitution}
+INT +{tmp_character.intelligence}
+WIS +{tmp_character.wisdom}
+CHA +{tmp_character.charisma}
+
+You can swap the values of two abilities around.
+You can only do this once, so choose carefully!
+
+To swap the values of e.g.  STR and INT, write |wSTR INT|n. Empty to abort.
+"""
+
+    options = {"key": "_default", "goto": (_swap_abilities, kwargs)}
+    
+        return text, options
+
+
+

This is more code, but the logic is the same - we have a node (node_swap_abilities) and +and a goto-callable helper (_swap_abilities). We catch everything the user writes on the +node (such as WIS CON) and feed it into the helper.

+

In _swap_abilities, we need to analyze the raw_string from the user to see what they +want to do.

+

Most code in the helper is validating the user didn’t enter nonsense. If they did, +we use caller.msg() to tell them and then return None, kwargs, which re-runs the same node (the name-selection) all over again.

+

Since we want users to be able to write “CON” instead of the longer “constitution”, we need a mapping _ABILITIES to easily convert between the two (it’s stored as consitution on the temporary character sheet). Once we know which abilities they want to swap, we do so and tick up the .ability_changes counter. This means this option will no longer be available from the main node.

+

Finally, we return to node_chargen again.

+
+
+

6.8. Node: Creating the Character

+

We get here from the main node by opting to finish chargen.

+
node_apply_character(caller, raw_string, **kwargs):
+    """                              
+    End chargen and create the character. We will also puppet it.
+                                     
+    """                              
+    tmp_character = kwargs["tmp_character"]
+    new_character = tmp_character.apply(caller)      
+    
+    caller.account.add_character(new_character) 
+    
+    text = "Character created!"
+    
+    return text, None 
+
+
+

When entering the node, we will take the Temporary character sheet and use its .appy method to create a new Character with all equipment.

+

This is what is called an end node, because it returns None instead of options. After this, the menu will exit. We will be back to the default character selection screen. The characters found on that screen are the ones listed in the _playable_characters Attribute, so we need to also the new character to it.

+
+
+

6.9. Tying the nodes together

+
def start_chargen(caller, session=None):
+"""
+This is a start point for spinning up the chargen from a command later.
+
+    """
+    menutree = {  # <----- can now add this!
+       "node_chargen": node_chargen, 
+       "node_change_name": node_change_name, 
+       "node_swap_abilities": node_swap_abilities,
+       "node_apply_character": node_apply_character
+    }
+        
+    # this generates all random components of the character
+    tmp_character = TemporaryCharacterSheet()
+
+    EvMenu(caller, menutree, session=session, 
+           startnode="node_chargen",   # <----- 
+           tmp_character=tmp_character)
+          
+
+
+

Now that we have all the nodes, we add them to the menutree we left empty before. We only add the nodes, not the goto-helpers! The keys we set in the menutree dictionary are the names we should use to point to nodes from inside the menu (and we did).

+

We also add a keyword argument startnode pointing to the node_chargen node. This tells EvMenu to first jump into that node when the menu is starting up.

+
+
+

6.10. Conclusions

+

This lesson taught us how to use EvMenu to make an interactive character generator. In an RPG more complex than Knave, the menu would be bigger and more intricate, but the same principles apply.

+

Together with the previous lessons we have now fished most of the basics around player +characters - how they store their stats, handle their equipment and how to create them.

+

In the next lesson we’ll address how EvAdventure Rooms work.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.html new file mode 100644 index 0000000000..2f25d70030 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.html @@ -0,0 +1,973 @@ + + + + + + + + + 9. Combat base framework — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

9. Combat base framework

+

Combat is core to many games. Exactly how it works is very game-dependent. In this lesson we will build a framework to implement two common flavors:

+
    +
  • “Twitch-based” combat (specific lesson here) means that you perform a combat action by entering a command, and after some delay (which may depend on your skills etc), the action happens. It’s called ‘twitch’ because actions often happen fast enough that changing your strategy may involve some element of quick thinking and a ‘twitchy trigger finger’.

  • +
  • “Turn-based” combat (specific lesson here) means that players input actions in clear turns. Timeout for entering/queuing your actions is often much longer than twitch-based style. Once everyone made their choice (or the timeout is reached), everyone’s action happens all at once, after which the next turn starts. This style of combat requires less player reflexes.

  • +
+

We will design a base combat system that supports both styles.

+
    +
  • We need a CombatHandler to track the progress of combat. This will be a Script. Exactly how this works (and where it is stored) will be a bit different between Twitch- and Turnbased combat. We will create its common framework in this lesson.

  • +
  • Combat are divided into actions. We want to be able to easily extend our combat with more possible actions. An action needs Python code to show what actually happens when the action is performed. We will define such code in Action classes.

  • +
  • We also need a way to describe a specific instance of a given action. That is, when we do an “attack” action, we need at the minimum to know who is being attacked. For this will we use Python dicts that we will refer to as action_dicts.

  • +
+
+

9.1. CombatHandler

+
+

Create a new module evadventure/combat_base.py

+
+ +

Our “Combat Handler” will handle the administration around combat. It needs to be persistent (even is we reload the server your combat should keep going).

+

Creating the CombatHandler is a little of a catch-22 - how it works depends on how Actions and Action-dicts look. But without having the CombatHandler, it’s hard to know how to design Actions and Action-dicts. So we’ll start with its general structure and fill out the details later in this lesson.

+

Below, methods with pass will be filled out this lesson while those raising NotImplementedError will be different for Twitch/Turnbased combat and will be implemented in their respective lessons following this one.

+
# in evadventure/combat_base.py 
+
+from evennia import DefaultScript
+
+
+class CombatFailure(RuntimeError):
+	"""If some error happens in combat"""
+    pass
+
+
+class EvAdventureCombatBaseHandler(DefaultSCript): 
+    """ 
+	This should be created when combat starts. It 'ticks' the combat 
+	and tracks all sides of it.
+	
+    """
+    # common for all types of combat
+
+    action_classes = {}          # to fill in later 
+    fallback_action_dict = {}
+
+    @classmethod 
+    def get_or_create_combathandler(cls, obj, **kwargs): 
+        """ Get or create combathandler on `obj`.""" 
+        pass
+
+    def msg(self, message, combatant=None, broadcast=True, location=True): 
+        """ 
+        Send a message to all combatants.
+		
+        """
+        pass  # TODO
+     
+    def get_combat_summary(self, combatant):
+        """ 
+        Get a nicely formatted 'battle report' of combat, from the 
+        perspective of the combatant.
+        
+    	""" 
+        pass  # TODO
+
+	# implemented differently by Twitch- and Turnbased combat
+
+    def get_sides(self, combatant):
+        """ 
+        Get who's still alive on the two sides of combat, as a 
+        tuple `([allies], [enemies])` from the perspective of `combatant` 
+	        (who is _not_ included in the `allies` list.
+        
+        """
+        raise NotImplementedError 
+
+    def give_advantage(self, recipient, target): 
+        """ 
+        Give advantage to recipient against target.
+        
+        """
+        raise NotImplementedError 
+
+    def give_disadvantage(self, recipient, target): 
+        """
+        Give disadvantage to recipient against target. 
+
+        """
+        raise NotImplementedError
+
+    def has_advantage(self, combatant, target): 
+        """ 
+        Does combatant have advantage against target?
+        
+        """ 
+        raise NotImplementedError 
+
+    def has_disadvantage(self, combatant, target): 
+        """ 
+        Does combatant have disadvantage against target?
+        
+        """ 
+        raise NotImplementedError
+
+    def queue_action(self, combatant, action_dict):
+        """ 
+        Queue an action for the combatant by providing 
+        action dict.
+        
+        """ 
+        raise NotImplementedError
+
+    def execute_next_action(self, combatant): 
+        """ 
+        Perform a combatant's next action.
+        
+        """ 
+        raise NotImplementedError
+
+    def start_combat(self): 
+        """ 
+        Start combat.
+        
+    	""" 
+    	raise NotImplementedError
+    
+    def check_stop_combat(self): 
+        """
+        Check if the combat is over and if it should be stopped.
+         
+        """
+        raise NotImplementedError 
+        
+    def stop_combat(self): 
+        """ 
+        Stop combat and do cleanup.
+        
+        """
+        raise NotImplementedError
+
+
+
+
+

The Combat Handler is a Script. Scripts are typeclassed entities, which means that they are persistently stored in the database. Scripts can optionally be stored “on” other objects (such as on Characters or Rooms) or be ‘global’ without any such connection. While Scripts has an optional timer component, it is not active by default and Scripts are commonly used just as plain storage. Since Scripts don’t have an in-game existence, they are great for storing data on ‘systems’ of all kinds, including our combat.

+

Let’s implement the generic methods we need.

+
+

9.1.1. CombatHandler.get_or_create_combathandler

+

A helper method for quickly getting the combathandler for an ongoing combat and combatant.

+

We expect to create the script “on” an object (which one we don’t know yet, but we expect it to be a typeclassed entity).

+
# in evadventure/combat_base.py
+
+from evennia import create_script
+
+# ... 
+
+class EvAdventureCombatBaseHandler(DefaultScript): 
+
+    # ... 
+
+    @classmethod
+    def get_or_create_combathandler(cls, obj, **kwargs):
+        """
+        Get or create a combathandler on `obj`.
+    
+        Args:
+            obj (any): The Typeclassed entity to store this Script on. 
+        Keyword Args:
+            combathandler_key (str): Identifier for script. 'combathandler' by
+                default.
+            **kwargs: Extra arguments to the Script, if it is created.
+    
+        """
+        if not obj:
+            raise CombatFailure("Cannot start combat without a place to do it!")
+    
+        combathandler_key = kwargs.pop("key", "combathandler")
+        combathandler = obj.ndb.combathandler
+        if not combathandler or not combathandler.id:
+            combathandler = obj.scripts.get(combathandler_key).first()
+            if not combathandler:
+                # have to create from scratch
+                persistent = kwargs.pop("persistent", True)
+                combathandler = create_script(
+                    cls,
+                    key=combathandler_key,
+                    obj=obj,
+                    persistent=persistent,
+                    **kwargs,
+                )
+            obj.ndb.combathandler = combathandler
+        return combathandler
+
+	# ... 
+
+
+
+

This helper method uses obj.scripts.get() to find if the combat script already exists ‘on’ the provided obj. If not, it will create it using Evennia’s create_script function. For some extra speed we cache the handler as obj.ndb.combathandler The .ndb. (non-db) means that handler is cached only in memory.

+ +

get_or_create_combathandler is decorated to be a classmethod, meaning it should be used on the handler class directly (rather than on an instance of said class). This makes sense because this method actually should return the new instance.

+

As a class method we’ll need to call this directly on the class, like this:

+
combathandler = EvAdventureCombatBaseHandler.get_or_create_combathandler(combatant)
+
+
+

The result will be a new handler or one that was already defined.

+
+
+

9.1.2. CombatHandler.msg

+
# in evadventure/combat_base.py 
+
+# ... 
+
+class EvAdventureCombatBaseHandler(DefaultScript): 
+	# ... 
+
+	def msg(self, message, combatant=None, broadcast=True, location=None):
+        """
+        Central place for sending messages to combatants. This allows
+        for adding any combat-specific text-decoration in one place.
+
+        Args:
+            message (str): The message to send.
+            combatant (Object): The 'You' in the message, if any.
+            broadcast (bool): If `False`, `combatant` must be included and
+                will be the only one to see the message. If `True`, send to
+                everyone in the location.
+            location (Object, optional): If given, use this as the location to
+                send broadcast messages to. If not, use `self.obj` as that
+                location.
+
+        Notes:
+            If `combatant` is given, use `$You/you()` markup to create
+            a message that looks different depending on who sees it. Use
+            `$You(combatant_key)` to refer to other combatants.
+
+        """
+        if not location:
+            location = self.obj
+
+        location_objs = location.contents
+
+        exclude = []
+        if not broadcast and combatant:
+            exclude = [obj for obj in location_objs if obj is not combatant]
+
+        location.msg_contents(
+            message,
+            exclude=exclude,
+            from_obj=combatant,
+            mapping={locobj.key: locobj for locobj in location_objs},
+        )
+
+	# ... 
+
+
+ +

We saw the location.msg_contents() method before in the Weapon class of the Objects lesson. Its purpose is to take a string on the form "$You() do stuff against $you(key)" and make sure all sides see a string suitable just to them. Our msg() method will by default broadcast the message to everyone in the room.

+
+

You’d use it like this:

+
combathandler.msg(
+	f"$You() $conj(throw) {item.key} at $you({target.key}).", 
+	combatant=combatant, 
+	location=combatant.location
+)
+
+
+

If combatant is Trickster, item.key is “a colorful ball” and target.key is “Goblin”, then

+

The combatant would see:

+
You throw a colorful ball at Goblin.
+
+
+

The Goblin sees

+
Trickster throws a colorful ball at you.
+
+
+

Everyone else in the room sees

+
Trickster throws a colorful ball at Goblin.
+
+
+
+
+

9.1.3. Combathandler.get_combat_summary

+

We want to be able to show a nice summary of the current combat:

+
                                        Goblin shaman (Perfect)
+        Gregor (Hurt)                   Goblin brawler(Hurt)
+        Bob (Perfect)         vs        Goblin grunt 1 (Hurt)
+                                        Goblin grunt 2 (Perfect)
+                                        Goblin grunt 3 (Wounded)
+
+
+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
# in evadventure/combat_base.py
+
+# ...
+
+from evennia import EvTable
+
+# ... 
+
+class EvAdventureCombatBaseHandler(DefaultScript):
+
+	# ... 
+
+	def get_combat_summary(self, combatant):
+
+        allies, enemies = self.get_sides(combatant)
+        nallies, nenemies = len(allies), len(enemies)
+
+        # prepare colors and hurt-levels
+        allies = [f"{ally} ({ally.hurt_level})" for ally in allies]
+        enemies = [f"{enemy} ({enemy.hurt_level})" for enemy in enemies]
+
+        # the center column with the 'vs'
+        vs_column = ["" for _ in range(max(nallies, nenemies))]
+        vs_column[len(vs_column) // 2] = "|wvs|n"
+
+        # the two allies / enemies columns should be centered vertically
+        diff = abs(nallies - nenemies)
+        top_empty = diff // 2
+        bot_empty = diff - top_empty
+        topfill = ["" for _ in range(top_empty)]
+        botfill = ["" for _ in range(bot_empty)]
+
+        if nallies >= nenemies:
+            enemies = topfill + enemies + botfill
+        else:
+            allies = topfill + allies + botfill
+
+        # make a table with three columns
+        return evtable.EvTable(
+            table=[
+                evtable.EvColumn(*allies, align="l"),
+                evtable.EvColumn(*vs_column, align="c"),
+                evtable.EvColumn(*enemies, align="r"),
+            ],
+            border=None,
+            maxwidth=78,
+        )
+
+	# ... 
+
+
+

This may look complex, but the complexity is only in figuring out how to organize three columns, especially how to to adjust to the two sides on each side of the vs are roughly vertically aligned.

+
    +
  • Line 15 : We make use of the self.get_sides(combatant) method which we haven’t actually implemented yet. This is because turn-based and twitch-based combat will need different ways to find out what the sides are. The allies and enemies are lists.

  • +
  • Line 17: The combatant is not a part of the allies list (this is how we defined get_sides to work), so we insert it at the top of the list (so they show first on the left-hand side).

  • +
  • Lines 21, 22: We make use of the .hurt_level values of all living things (see the LivingMixin of the Character lesson).

  • +
  • Lines 28-39: We determine how to vertically center the two sides by adding empty lines above and below the content.

  • +
  • Line 41: The Evtable is an Evennia utility for making, well, text tables. Once we are happy with the columns, we feed them to the table and let Evennia do the rest. It’s worth to explore EvTable since it can help you create all sorts of nice layouts.

  • +
+
+
+
+

9.2. Actions

+

In EvAdventure we will only support a few common combat actions, mapping to the equivalent rolls and checks used in Knave. We will design our combat framework so that it’s easy to expand with other actions later.

+
    +
  • hold - The simplest action. You just lean back and do nothing.

  • +
  • attack - You attack a given target using your currently equipped weapon. This will become a roll of STR or WIS against the targets’ ARMOR.

  • +
  • stunt - You make a ‘stunt’, which in roleplaying terms would mean you tripping your opponent, taunting or otherwise trying to gain the upper hand without hurting them. You can do this to give yourself (or an ally) advantage against a target on the next action. You can also give a target disadvantage against you or an ally for their next action.

  • +
  • use item - You make use of a Consumable in your inventory. When used on yourself, it’d normally be something like a healing potion. If used on an enemy it could be a firebomb or a bottle of acid.

  • +
  • wield - You wield an item. Depending on what is being wielded, it will be wielded in different ways: A helmet will be placed on the head, a piece of armor on the chest. A sword will be wielded in one hand, a shield in another. A two-handed axe will use up two hands. Doing so will move whatever was there previously to the backpack.

  • +
  • flee - You run away/disengage. This action is only applicable in turn-based combat (in twitch-based combat you just move to another room to flee). We will thus wait to define this action until the Turnbased combat lesson.

  • +
+
+
+

9.3. Action dicts

+

To pass around the details of an attack (the second point above), we will use a dict. A dict is simple and also easy to save in an Attribute. We’ll call this the action_dict and here’s what we need for each action.

+
+

You don’t need to type these out anywhere, it’s listed here for reference. We will use these dicts when calling combathandler.queue_action(combatant, action_dict).

+
+
hold_action_dict = {
+	"key": "hold"
+}
+attack_action_dict = { 
+	"key": "attack",
+	"target": <Character/NPC> 
+}
+stunt_action_dict = { 
+    "key": "stunt",					
+	"recipient": <Character/NPC>, # who gains advantage/disadvantage
+	"target": <Character/NPC>,  # who the recipient gainst adv/dis against
+	"advantage": bool,  # grant advantage or disadvantage?
+	"stunt_type": Ability,   # Ability to use for the challenge
+	"defense_type": Ability, # what Ability for recipient to defend with if we
+                    	     # are trying to give disadvantage 
+}
+use_item_action_dict = { 
+    "key": "use", 
+    "item": <Object>
+    "target": <Character/NPC/None> # if using item against someone else			   
+}
+wield_action_dict = { 
+    "key": "wield",
+    "item": <Object>					
+}
+
+# used only for the turnbased combat, so its Action will be defined there
+flee_action_dict = { 
+    "key": "flee"                   
+}
+
+
+

Apart from the stunt action, these dicts are all pretty simple. The key identifes the action to perform and the other fields identifies the minimum things you need to know in order to resolve each action.

+

We have not yet written the code to set these dicts, but we will assume that we know who is performing each of these actions. So if Beowulf attacks Grendel, Beowulf is not himself included in the attack dict:

+
attack_action_dict = { 
+    "key": "attack",
+    "target": Grendel
+}
+
+
+

Let’s explain the longest action dict, the Stunt action dict in more detail as well. In this example, The Trickster is performing a Stunt in order to help his friend Paladin to gain an INT- advantage against the Goblin (maybe the paladin is preparing to cast a spell of something). Since Trickster is doing the action, he’s not showing up in the dict:

+
stunt_action_dict - { 
+    "key": "stunt", 
+    "recipient": Paladin,
+    "target": Goblin,
+    "advantage": True,
+    "stunt_type": Ability.INT,
+    "defense_type": Ability.INT,
+}
+
+
+ +

This should result in an INT vs INT based check between the Trickster and the Goblin (maybe the trickster is trying to confuse the goblin with some clever word play). If the Trickster wins, the Paladin gains advantage against the Goblin on the Paladin’s next action .

+
+
+

9.4. Action classes

+

Once our action_dict identifies the particular action we should use, we need something that reads those keys/values and actually performs the action.

+
# in evadventure/combat_base.py 
+
+class CombatAction: 
+
+    def __init__(self, combathandler, combatant, action_dict):
+        self.combathandler = combathandler
+        self.combatant = combatant
+
+        for key, val in action_dict.items(); 
+            if key.startswith("_"):
+                setattr(self, key, val)
+
+
+

We will create a new instance of this class every time an action is happening. So we store some key things every action will need - we will need a reference to the common combathandler (which we will design in the next section), and to the combatant (the one performing this action). The action_dict is a dict matching the action we want to perform.

+

The setattr Python standard function assigns the keys/values of the action_dict to be properties “on” this action. This is very convenient to use in other methods. So for the stunt action, other methods could just access self.key, self.recipient, self.target and so on directly.

+
# in evadventure/combat_base.py 
+
+class CombatAction: 
+
+    # ... 
+
+    def msg(self, message, broadcast=True):
+        "Send message to others in combat"
+        self.combathandler.msg(message, combatant=self.combatant, broadcast=broadcast)
+
+    def can_use(self): 
+       """Return False if combatant can's use this action right now""" 
+        return True 
+
+    def execute(self): 
+        """Does the actional action"""
+        pass
+
+    def post_execute(self):
+        """Called after `execute`"""
+        pass 
+
+
+

It’s very common to want to send messages to everyone in combat - you need to tell people they are getting attacked, if they get hurt and so on. So having a msg helper method on the action is convenient. We offload all the complexity to the combathandler.msg() method.

+

The can_use, execute and post_execute should all be called in a chain and we should make sure the combathandler calls them like this:

+
if action.can_use(): 
+    action.execute() 
+    action.post_execute()
+
+
+
+

9.4.1. Hold Action

+
# in evadventure/combat_base.py 
+
+# ... 
+
+class CombatActionHold(CombatAction): 
+    """ 
+    Action that does nothing 
+    
+    action_dict = {
+        "key": "hold"
+    }
+    
+    """
+
+
+

Holding does nothing but it’s cleaner to nevertheless have a separate class for it. We use the docstring to specify how its action-dict should look.

+
+
+

9.4.2. Attack Action

+
# in evadventure/combat_base.py
+
+# ... 
+
+class CombatActionAttack(CombatAction):
+     """
+     A regular attack, using a wielded weapon.
+ 
+     action-dict = {
+             "key": "attack",
+             "target": Character/Object
+         }
+ 
+     """
+ 
+     def execute(self):
+         attacker = self.combatant
+         weapon = attacker.weapon
+         target = self.target
+ 
+         if weapon.at_pre_use(attacker, target):
+             weapon.use(
+                 attacker, target, advantage=self.combathandler.has_advantage(attacker, target)
+             )
+             weapon.at_post_use(attacker, target)
+
+
+

Refer to how we designed Evadventure weapons to understand what happens here - most of the work is performed by the weapon class - we just plug in the relevant arguments.

+
+
+

9.4.3. Stunt Action

+
# in evadventure/combat_base.py 
+
+# ... 
+
+class CombatActionStunt(CombatAction):
+    """
+    Perform a stunt the grants a beneficiary (can be self) advantage on their next action against a 
+    target. Whenever performing a stunt that would affect another negatively (giving them
+    disadvantage against an ally, or granting an advantage against them, we need to make a check
+    first. We don't do a check if giving an advantage to an ally or ourselves.
+
+    action_dict = {
+           "key": "stunt",
+           "recipient": Character/NPC,
+           "target": Character/NPC,
+           "advantage": bool,  # if False, it's a disadvantage
+           "stunt_type": Ability,  # what ability (like STR, DEX etc) to use to perform this stunt. 
+           "defense_type": Ability, # what ability to use to defend against (negative) effects of
+            this stunt.
+        }
+
+    """
+
+    def execute(self):
+        combathandler = self.combathandler
+        attacker = self.combatant
+        recipient = self.recipient  # the one to receive the effect of the stunt
+        target = self.target  # the affected by the stunt (can be the same as recipient/combatant)
+        txt = ""
+
+        if recipient == target:
+            # grant another entity dis/advantage against themselves
+            defender = recipient
+        else:
+            # recipient not same as target; who will defend depends on disadvantage or advantage
+            # to give.
+            defender = target if self.advantage else recipient
+
+        # trying to give advantage to recipient against target. Target defends against caller
+        is_success, _, txt = rules.dice.opposed_saving_throw(
+            attacker,
+            defender,
+            attack_type=self.stunt_type,
+            defense_type=self.defense_type,
+            advantage=combathandler.has_advantage(attacker, defender),
+            disadvantage=combathandler.has_disadvantage(attacker, defender),
+        )
+
+        self.msg(f"$You() $conj(attempt) stunt on $You({defender.key}). {txt}")
+
+        # deal with results
+        if is_success:
+            if self.advantage:
+                combathandler.give_advantage(recipient, target)
+            else:
+                combathandler.give_disadvantage(recipient, target)
+            if recipient == self.combatant:
+                self.msg(
+                    f"$You() $conj(gain) {'advantage' if self.advantage else 'disadvantage'} "
+                    f"against $You({target.key})!"
+                )
+            else:
+                self.msg(
+                    f"$You() $conj(cause) $You({recipient.key}) "
+                    f"to gain {'advantage' if self.advantage else 'disadvantage'} "
+                    f"against $You({target.key})!"
+                )
+            self.msg(
+                "|yHaving succeeded, you hold back to plan your next move.|n [hold]",
+                broadcast=False,
+            )
+        else:
+            self.msg(f"$You({defender.key}) $conj(resist)! $You() $conj(fail) the stunt.")
+
+
+
+

The main action here is the call to the rules.dice.opposed_saving_throw to determine if the stunt succeeds. After that, most lines is about figuring out who should be given advantage/disadvantage and to communicate the result to the affected parties.

+

Note that we make heavy use of the helper methods on the combathandler here, even those that are not yet implemented. As long as we pass the action_dict into the combathandler, the action doesn’t actually care what happens next.

+

After we have performed a successful stunt, we queue the combathandler.fallback_action_dict. This is because stunts are meant to be one-off things are if we are repeating actions, it would not make sense to repeat the stunt over and over.

+
+
+

9.4.4. Use Item Action

+
# in evadventure/combat_base.py 
+
+# ... 
+
+class CombatActionUseItem(CombatAction):
+    """
+    Use an item in combat. This is meant for one-off or limited-use items (so things like scrolls and potions, not swords and shields). If this is some sort of weapon or spell rune, we refer to the item to determine what to use for attack/defense rolls.
+
+    action_dict = {
+            "key": "use",
+            "item": Object
+            "target": Character/NPC/Object/None
+        }
+
+    """
+
+    def execute(self):
+        item = self.item
+        user = self.combatant
+        target = self.target
+
+        if item.at_pre_use(user, target):
+            item.use(
+                user,
+                target,
+                advantage=self.combathandler.has_advantage(user, target),
+                disadvantage=self.combathandler.has_disadvantage(user, target),
+            )
+            item.at_post_use(user, target)
+
+
+

See the Consumable items in the Object lesson to see how consumables work. Like with weapons, we offload all the logic to the item we use.

+
+
+

9.4.5. Wield Action

+
# in evadventure/combat_base.py 
+
+# ... 
+
+class CombatActionWield(CombatAction):
+    """
+    Wield a new weapon (or spell) from your inventory. This will 
+	    swap out the one you are currently wielding, if any.
+
+    action_dict = {
+            "key": "wield",
+            "item": Object
+        }
+
+    """
+
+    def execute(self):
+        self.combatant.equipment.move(self.item)
+
+
+
+

We rely on the Equipment handler we created to handle the swapping of items for us. Since it doesn’t make sense to keep swapping over and over, we queue the fallback action after this one.

+
+
+
+

9.5. Testing

+
+

Create a module evadventure/tests/test_combat.py.

+
+ +

Unit testing the combat base classes can seem impossible because we have not yet implemented most of it. We can however get very far by the use of Mocks. The idea of a Mock is that you replace a piece of code with a dummy object (a ‘mock’) that can be called to return some specific value.

+

For example, consider this following test of the CombatHandler.get_combat_summary. We can’t just call this because it internally calls .get_sides which would raise a NotImplementedError.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
# in evadventure/tests/test_combat.py 
+
+from unittest.mock import Mock
+
+from evennia.utils.test_resources import EvenniaTestCase
+from evennia import create_object
+from .. import combat_base
+from ..rooms import EvAdventureRoom
+from ..characters import EvAdventureCharacter
+
+
+class TestEvAdventureCombatBaseHandler(EvenniaTestCase):
+
+    def setUp(self): 
+
+		self.location = create_object(EvAdventureRoom, key="testroom")
+		self.combatant = create_object(EvAdventureCharacter, key="testchar")
+		self.target = create_object(EvAdventureMob, key="testmonster")
+
+        self.combathandler = combat_base.get_combat_summary(self.location)
+
+    def test_get_combat_summary(self):
+
+        # do the test from perspective of combatant
+	    self.combathandler.get_sides = Mock(return_value=([], [self.target]))
+        result = str(self.combathandler.get_combat_summary(self.combatant))
+		self.assertEqual(
+		    result, 
+		    " testchar (Perfect)  vs  testmonster (Perfect)"
+		)
+		# test from the perspective of the monster 
+		self.combathandler.get_sides = Mock(return_value=([], [self.combatant]))
+		result = str(self.combathandler.get_combat_summary(self.target))
+		self.assertEqual(
+			result,
+			" testmonster (Perfect)  vs  testchar (Perfect)"
+		)
+
+
+

The interesting places are where we apply the mocks:

+
    +
  • Line 25 and Line 32: While get_sides is not implemented yet, we know what it is supposed to return - a tuple of lists. So for the sake of the test, we replace the get_sides method with a mock that when called will return something useful.

  • +
+

With this kind of approach it’s possible to fully test a system also when it’s not ‘complete’ yet.

+
+
+

9.6. Conclusions

+

We have the core functionality we need for our combat system! In the following two lessons we will make use of these building blocks to create two styles of combat.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.html new file mode 100644 index 0000000000..90ce176042 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.html @@ -0,0 +1,1612 @@ + + + + + + + + + 11. Turnbased Combat — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

11. Turnbased Combat

+

In this lesson we will be building on the combat base to implement a combat system that works in turns and where you select your actions in a menu, like this:

+
> attack Troll
+______________________________________________________________________________
+
+ You (Perfect)  vs  Troll (Perfect)
+ Your queued action: [attack] (22s until next round,
+ or until all combatants have chosen their next action).
+______________________________________________________________________________
+
+ 1: attack an enemy
+ 2: Stunt - gain a later advantage against a target
+ 3: Stunt - give an enemy disadvantage against yourself or an ally
+ 4: Use an item on yourself or an ally
+ 5: Use an item on an enemy
+ 6: Wield/swap with an item from inventory
+ 7: flee!
+ 8: hold, doing nothing
+
+> 4
+_______________________________________________________________________________
+
+Select the item
+_______________________________________________________________________________
+
+ 1: Potion of Strength
+ 2. Potion of Dexterity
+ 3. Green Apple
+ 4. Throwing Daggers
+ back
+ abort
+
+> 1
+_______________________________________________________________________________
+
+Choose an ally to target.
+_______________________________________________________________________________
+
+ 1: Yourself
+ back
+ abort
+
+> 1
+_______________________________________________________________________________
+
+ You (Perfect)  vs Troll (Perfect)
+ Your queued action: [use] (6s until next round,
+ or until all combatants have chosen their next action).
+_______________________________________________________________________________
+
+ 1: attack an enemy
+ 2: Stunt - gain a later advantage against a target
+ 3: Stunt - give an enemy disadvantage against yourself or an ally
+ 4: Use an item on yourself or an ally
+ 5: Use an item on an enemy
+ 6: Wield/swap with an item from inventory
+ 7: flee!
+ 8: hold, doing nothing
+
+Troll attacks You with Claws: Roll vs armor (12):
+ rolled 4 on d20 + strength(+3) vs 12 -> Fail
+ Troll missed you.
+
+You use Potion of Strength.
+ Renewed strength coarses through your body!
+ Potion of Strength was used up.
+
+
+
+

Note that this documentation doesn’t show in-game colors. Also, if you interested in an alternative, see the previous lesson where we implemented a ‘twitch’-like combat system based on entering direct commands for every action.

+
+

With ‘turnbased’ combat, we mean combat that ‘ticks’ along at a slower pace, slow enough to allow the participants to select their options in a menu (the menu is not strictly necessary, but it’s a good way to learn how to make menus as well). Their actions are queued and will be executed when the turn timer runs out. To avoid waiting unnecessarily, we will also move on to the next round whenever everyone has made their choices.

+

The advantage of a turnbased system is that it removes player speed from the equation; your prowess in combat does not depend on how quickly you can enter a command. For RPG-heavy games you could also allow players time to make RP emotes during the rounds of combat to flesh out the action.

+

The advantage of using a menu is that you have all possible actions directly available to you, making it beginner friendly and easy to know what you can do. It also means a lot less writing which can be an advantage to some players.

+
+

11.1. General Principle

+ +

Here is the general principle of the Turnbased combat handler:

+
    +
  • The turnbased version of the CombatHandler will be stored on the current location. That means that there will only be one combat per location. Anyone else starting combat will join the same handler and be assigned a side to fight on.

  • +
  • The handler will run a central timer of 30s (in this example). When it fires, all queued actions will be executed. If everyone has submitted their actions, this will happen immediately when the last one submits.

  • +
  • While in combat you will not be able to move around - you are stuck in the room. Fleeing combat is a separate action that takes a few turns to complete (we will need to create this).

  • +
  • Starting the combat is done via the attack <target> command. After that you are in the combat menu and will use the menu for all subsequent actions.

  • +
+
+
+

11.2. Turnbased combat handler

+
+

Create a new module evadventure/combat_turnbased.py.

+
+
# in evadventure/combat_turnbased.py
+
+from .combat_base import (
+   CombatActionAttack,
+   CombatActionHold,
+   CombatActionStunt,
+   CombatActionUseItem,
+   CombatActionWield,
+   EvAdventureCombatBaseHandler,
+)
+
+from .combat_base import EvAdventureCombatBaseHandler
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+    action_classes = {
+        "hold": CombatActionHold,
+        "attack": CombatActionAttack,
+        "stunt": CombatActionStunt,
+        "use": CombatActionUseItem,
+        "wield": CombatActionWield,
+        "flee": None # we will add this soon!
+    }
+
+    # fallback action if not selecting anything
+    fallback_action_dict = AttributeProperty({"key": "hold"}, autocreate=False)
+
+	# track which turn we are on
+    turn = AttributeProperty(0)
+    # who is involved in combat, and their queued action
+    # as {combatant: actiondict, ...}
+    combatants = AttributeProperty(dict)
+
+    # who has advantage against whom. This is a structure
+    # like {"combatant": {enemy1: True, enemy2: True}}
+    advantage_matrix = AttributeProperty(defaultdict(dict))
+    # same for disadvantages
+    disadvantage_matrix = AttributeProperty(defaultdict(dict))
+
+    # how many turns you must be fleeing before escaping
+    flee_timeout = AttributeProperty(1, autocreate=False)
+
+	# track who is fleeing as {combatant: turn_they_started_fleeing}
+    fleeing_combatants = AttributeProperty(dict)
+
+    # list of who has been defeated so far
+    defeated_combatants = AttributeProperty(list)
+
+
+
+

We leave a placeholder for the "flee" action since we haven’t created it yet.

+

Since the turnbased combat handler is shared between all combatants, we need to store references to those combatants on the handler, in the combatants Attribute. In the same way we must store a matrix of who has advantage/disadvantage against whom. We must also track who is fleeing, in particular how long they have been fleeing, since they will be leaving combat after that time.

+
+

11.2.1. Getting the sides of combat

+

The two sides are different depending on if we are in an PvP room or not: In a PvP room everyone else is your enemy. Otherwise only NPCs in combat is your enemy (you are assumed to be teaming up with your fellow players).

+
# in evadventure/combat_turnbased.py
+
+# ...
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+	# ...
+
+    def get_sides(self, combatant):
+           """
+           Get a listing of the two 'sides' of this combat,
+           m the perspective of the provided combatant.
+           """
+           if self.obj.allow_pvp:
+               # in pvp, everyone else is an ememy
+               allies = [combatant]
+               enemies = [comb for comb in self.combatants if comb != combatant]
+           else:
+               # otherwise, enemies/allies depend on who combatant is
+               pcs = [comb for comb in self.combatants if inherits_from(comb, EvAdventureCharacter)]
+               npcs = [comb for comb in self.combatants if comb not in pcs]
+               if combatant in pcs:
+                   # combatant is a PC, so NPCs are all enemies
+                   allies = pcs
+                   enemies = npcs
+               else:
+                   # combatant is an NPC, so PCs are all enemies
+                   allies = npcs
+                   enemies = pcs
+        return allies, enemies
+
+
+

Note that since the EvadventureCombatBaseHandler (which our turnbased handler is based on) is a Script, it provides many useful features. For example self.obj is the entity on which this Script ‘sits’. Since we are planning to put this handler on the current location, then self.obj will be that Room.

+

All we do here is check if it’s a PvP room or not and use this to figure out who would be an ally or an enemy. Note that the combatant is not included in the allies return - we’ll need to remember this.

+
+
+

11.2.2. Tracking Advantage/Disadvantage

+
# in evadventure/combat_turnbased.py
+
+# ...
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+	# ...
+
+    def give_advantage(self, combatant, target):
+        self.advantage_matrix[combatant][target] = True
+
+    def give_disadvantage(self, combatant, target, **kwargs):
+        self.disadvantage_matrix[combatant][target] = True
+
+    def has_advantage(self, combatant, target, **kwargs):
+        return (
+	        target in self.fleeing_combatants
+	        or bool(self.advantage_matrix[combatant].pop(target, False))
+        )
+    def has_disadvantage(self, combatant, target):
+        return bool(self.disadvantage_matrix[combatant].pop(target, False))
+
+
+

We use the advantage/disadvantage_matrix Attributes to track who has advantage against whom.

+ +

In the has dis/advantage methods we pop the target from the matrix which will result either in the value True or False (the default value we give to pop if the target is not in the matrix). This means that the advantage, once gained, can only be used once.

+

We also consider everyone to have advantage against fleeing combatants.

+
+
+

11.2.3. Adding and removing combatants

+

Since the combat handler is shared we must be able to add- and remove combatants easily. +This is new compared to the base handler.

+
# in evadventure/combat_turnbased.py
+
+# ...
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+    # ...
+
+    def add_combatant(self, combatant):
+        """
+        Add a new combatant to the battle. Can be called multiple times safely.
+        """
+        if combatant not in self.combatants:
+            self.combatants[combatant] = self.fallback_action_dict
+            return True
+        return False
+
+    def remove_combatant(self, combatant):
+        """
+        Remove a combatant from the battle.
+        """
+        self.combatants.pop(combatant, None)
+        # clean up menu if it exists
+		# TODO!
+
+
+

We simply add the the combatant with the fallback action-dict to start with. We return a bool from add_combatant so that the calling function will know if they were actually added anew or not (we may want to do some extra setup if they are new).

+

For now we just pop the combatant, but in the future we’ll need to do some extra cleanup of the menu when combat ends (we’ll get to that).

+
+
+

11.2.4. Flee Action

+

Since you can’t just move away from the room to flee turnbased combat, we need to add a new CombatAction subclass like the ones we created in the base combat lesson.

+
# in evadventure/combat_turnbased.py
+
+from .combat_base import CombatAction
+
+# ...
+
+class CombatActionFlee(CombatAction):
+    """
+    Start (or continue) fleeing/disengaging from combat.
+
+    action_dict = {
+           "key": "flee",
+        }
+    """
+
+    def execute(self):
+        combathandler = self.combathandler
+
+        if self.combatant not in combathandler.fleeing_combatants:
+            # we record the turn on which we started fleeing
+            combathandler.fleeing_combatants[self.combatant] = self.combathandler.turn
+
+        # show how many turns until successful flight
+        current_turn = combathandler.turn
+        started_fleeing = combathandler.fleeing_combatants[self.combatant]
+        flee_timeout = combathandler.flee_timeout
+        time_left = flee_timeout - (current_turn - started_fleeing) - 1
+
+        if time_left > 0:
+            self.msg(
+                "$You() $conj(retreat), being exposed to attack while doing so (will escape in "
+                f"{time_left} $pluralize(turn, {time_left}))."
+            )
+
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+	action_classes = {
+        "hold": CombatActionHold,
+        "attack": CombatActionAttack,
+        "stunt": CombatActionStunt,
+        "use": CombatActionUseItem,
+        "wield": CombatActionWield,
+        "flee": CombatActionFlee # < ---- added!
+    }
+
+	# ...
+
+
+

We create the action to make use of the fleeing_combatants dict we set up in the combat handler. This dict stores the fleeing combatant along with the turn its fleeing started. If performing the flee action multiple times, we will just display how many turns are remaining.

+

Finally, we make sure to add our new CombatActionFlee to the action_classes registry on the combat handler.

+
+
+

11.2.5. Queue action

+
# in evadventure/combat_turnbased.py
+
+# ...
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+    # ...
+
+    def queue_action(self, combatant, action_dict):
+        self.combatants[combatant] = action_dict
+
+        # track who inserted actions this turn (non-persistent)
+        did_action = set(self.ndb.did_action or set())
+        did_action.add(combatant)
+        if len(did_action) >= len(self.combatants):
+            # everyone has inserted an action. Start next turn without waiting!
+            self.force_repeat()
+
+
+
+

To queue an action, we simply store its action_dict with the combatant in the combatants Attribute.

+

We use a Python set() to track who has queued an action this turn. If all combatants have entered a new (or renewed) action this turn, we use the .force_repeat() method, which is available on all Scripts. When this is called, the next round will fire immediately instead of waiting until it times out.

+
+
+

11.2.6. Execute an action and tick the round

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
# in evadventure/combat_turnbased.py
+
+import random
+
+# ...
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+    # ...
+
+    def execute_next_action(self, combatant):
+        # this gets the next dict and rotates the queue
+        action_dict = self.combatants.get(combatant, self.fallback_action_dict)
+
+        # use the action-dict to select and create an action from an action class
+        action_class = self.action_classes[action_dict["key"]]
+        action = action_class(self, combatant, action_dict)
+
+        action.execute()
+        action.post_execute()
+
+        if action_dict.get("repeat", False):
+            # queue the action again *without updating the
+            # *.ndb.did_action list* (otherwise
+            # we'd always auto-end the turn if everyone used
+            # repeating actions and there'd be
+            # no time to change it before the next round)
+            self.combatants[combatant] = action_dict
+        else:
+            # if not a repeat, set the fallback action
+            self.combatants[combatant] = self.fallback_action_dict
+
+
+   def at_repeat(self):
+        """
+        This method is called every time Script repeats
+        (every `interval` seconds). Performs a full turn of
+        combat, performing everyone's actions in random order.
+        """
+        self.turn += 1
+        # random turn order
+        combatants = list(self.combatants.keys())
+        random.shuffle(combatants)  # shuffles in place
+
+        # do everyone's next queued combat action
+        for combatant in combatants:
+            self.execute_next_action(combatant)
+
+        self.ndb.did_action = set()
+
+        # check if one side won the battle
+        self.check_stop_combat()
+
+
+

Our action-execution consists of two parts - the execute_next_action (which was defined in the parent class for us to implement) and the at_repeat method which is a part of the Script

+

For execute_next_action :

+
    +
  • Line 13: We get the action_dict from the combatants Attribute. We return the fallback_action_dict if nothing was queued (this defaults to hold).

  • +
  • Line 16: We use the key of the action_dict (which would be something like “attack”, “use”, “wield” etc) to get the class of the matching Action from the action_classes dictionary.

  • +
  • Line 17: Here the action class is instantiated with the combatant and action dict, making it ready to execute. This is then executed on the following lines.

  • +
  • Line 22: We introduce a new optional action-dict here, the boolean repeat key. This allows us to re-queue the action. If not the fallback action will be used.

  • +
+

The at_repeat is called repeatedly every interval seconds that the Script fires. This is what we use to track when each round ends.

+
    +
  • Lines 43: In this example, we have no internal order between actions. So we simply randomize in which order they fire.

  • +
  • Line 49: This set was assigned to in the queue_action method to know when everyone submitted a new action. We must make sure to unset it here before the next round.

  • +
+
+
+

11.2.7. Check and stop combat

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
# in evadventure/combat_turnbased.py
+
+import random
+from evennia.utils.utils import list_to_string
+
+# ...
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+    # ...
+
+     def stop_combat(self):
+        """
+        Stop the combat immediately.
+
+        """
+        for combatant in self.combatants:
+            self.remove_combatant(combatant)
+        self.stop()
+        self.delete()
+
+    def check_stop_combat(self):
+        """Check if it's time to stop combat"""
+
+        # check if anyone is defeated
+        for combatant in list(self.combatants.keys()):
+            if combatant.hp <= 0:
+                # PCs roll on the death table here, NPCs die.
+                # Even if PCs survive, they
+                # are still out of the fight.
+                combatant.at_defeat()
+                self.combatants.pop(combatant)
+                self.defeated_combatants.append(combatant)
+                self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant)
+            else:
+                self.combatants[combatant] = self.fallback_action_dict
+
+        # check if anyone managed to flee
+        flee_timeout = self.flee_timeout
+        for combatant, started_fleeing in self.fleeing_combatants.items():
+            if self.turn - started_fleeing >= flee_timeout - 1:
+                # if they are still alive/fleeing and have been fleeing long enough, escape
+                self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant)
+                self.remove_combatant(combatant)
+
+        # check if one side won the battle
+        if not self.combatants:
+            # noone left in combat - maybe they killed each other or all fled
+            surviving_combatant = None
+            allies, enemies = (), ()
+        else:
+            # grab a random survivor and check of they have any living enemies.
+            surviving_combatant = random.choice(list(self.combatants.keys()))
+            allies, enemies = self.get_sides(surviving_combatant)
+
+        if not enemies:
+            # if one way or another, there are no more enemies to fight
+            still_standing = list_to_string(f"$You({comb.key})" for comb in allies)
+            knocked_out = list_to_string(comb for comb in self.defeated_combatants if comb.hp > 0)
+            killed = list_to_string(comb for comb in self.defeated_combatants if comb.hp <= 0)
+
+            if still_standing:
+                txt = [f"The combat is over. {still_standing} are still standing."]
+            else:
+                txt = ["The combat is over. No-one stands as the victor."]
+            if knocked_out:
+                txt.append(f"{knocked_out} were taken down, but will live.")
+            if killed:
+                txt.append(f"{killed} were killed.")
+            self.msg(txt)
+            self.stop_combat()
+
+
+

The check_stop_combat is called at the end of the round. We want to figure out who is dead and if one of the ‘sides’ won.

+
    +
  • Lines 28-38: We go over all combatants and determine if they are out of HP. If so we fire the relevant hooks and add them to the defeated_combatants Attribute.

  • +
  • Line 38: For all surviving combatants, we make sure give them the fallback_action_dict.

  • +
  • Lines 41-46: The fleeing_combatant Attribute is a dict on the form {fleeing_combatant: turn_number}, tracking when they first started fleeing. We compare this with the current turn number and the flee_timeout to see if they now flee and should be allowed to be removed from combat.

  • +
  • Lines 49-56: Here on we are determining if one ‘side’ of the conflict has defeated the other side.

  • +
  • Line 60: The list_to_string Evennia utility converts a list of entries, like ["a", "b", "c" to a nice string "a, b and c". We use this to be able to present some nice ending messages to the combatants.

  • +
+
+
+

11.2.8. Start combat

+

Since we are using the timer-component of the Script to tick our combat, we also need a helper method to ‘start’ that.

+
from evennia.utils.utils import list_to_string
+
+# in evadventure/combat_turnbased.py
+
+# ...
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+    # ...
+
+    def start_combat(self, **kwargs):
+        """
+        This actually starts the combat. It's safe to run this multiple times
+        since it will only start combat if it isn't already running.
+
+        """
+        if not self.is_active:
+            self.start(**kwargs)
+
+
+
+

The start(**kwargs) method is a method on the Script, and will make it start to call at_repeat every interval seconds. We will pass that interval inside kwargs (so for example, we’ll do combathandler.start_combat(interval=30) later).

+
+
+
+

11.3. Using EvMenu for the combat menu

+

The EvMenu used to create in-game menues in Evennia. We used a simple EvMenu already in the Character Generation Lesson. This time we’ll need to be a bit more advanced. While The EvMenu documentation describe its functionality in more detail, we will give a quick overview of how it works here.

+

An EvMenu is made up of nodes, which are regular functions on this form (somewhat simplified here, there are more options):

+
def node_somenodename(caller, raw_string, **kwargs):
+
+    text = "some text to show in the node"
+    options = [
+        {
+           "key": "Option 1", # skip this to get a number
+           "desc": "Describing what happens when choosing this option."
+           "goto": "name of the node to go to"  # OR (callable, {kwargs}}) returning said name
+        },
+        # other options here
+    ]
+    return text, options
+
+
+

So basically each node takes the arguments of caller (the one using the menu), raw_string (the empty string or what the user input on the previous node) and **kwargs which can be used to pass data from node to node. It returns text and options.

+

The text is what the user will see when entering this part of the menu, such as “Choose who you want to attack!”. The options is a list of dicts describing each option. They will appear as a multi-choice list below the node text (see the example at the top of this lesson page).

+

When we create the EvMenu later, we will create a node index - a mapping between a unique name and these “node functions”. So something like this:

+
# example of a EvMenu node index
+    {
+      "start": node_combat_main,
+      "node1": node_func1,
+      "node2": node_func2,
+      "some name": node_somenodename,
+      "end": node_abort_menu,
+    }
+
+
+

Each option dict has a key "goto" that determines which node the player should jump to if they choose that option. Inside the menu, each node needs to be referenced with these names (like "start", "node1" etc).

+

The "goto" value of each option can either specify the name directly (like "node1") or it can be given as a tuple (callable, {keywords}). This callable is called and is expected to in turn return the next node-name to use (like "node1").

+

The callable (often called a “goto callable”) looks very similar to a node function:

+
def _goto_when_choosing_option1(caller, raw_string, **kwargs):
+    # do whatever is needed to determine the next node
+    return nodename  # also nodename, dict works
+
+
+ +

Here, caller is still the one using the menu and raw_string is the actual string you entered to choose this option. **kwargs is the keywords you added to the (callable, {keywords}) tuple.

+

The goto-callable must return the name of the next node. Optionally, you can return both nodename, {kwargs}. If you do the next node will get those kwargs as ingoing **kwargs. This way you can pass information from one node to the next. A special feature is that if nodename is returned as None, then the current node will be rerun again.

+

Here’s a (somewhat contrived) example of how the goto-callable and node-function hang together:

+
# goto-callable
+def _my_goto_callable(caller, raw_string, **kwargs):
+    info_number = kwargs["info_number"]
+    if info_number > 0:
+        return "node1"
+    else:
+        return "node2", {"info_number": info_number}  # will be **kwargs when "node2" runs next
+
+
+# node function
+def node_somenodename(caller, raw_string, **kwargs):
+    text = "Some node text"
+    options = [
+        {
+            "desc": "Option one",
+            "goto": (_my_goto_callable, {"info_number", 1})
+        },
+        {
+            "desc": "Option two",
+            "goto": (_my_goto_callable, {"info_number", -1})
+        },
+    ]
+
+
+
+ +
+

11.5. Attack Command

+

We will only need one single Command to run the Turnbased combat system. This is the attack command. Once you use it once, you will be in the menu.

+
# in evadventure/combat_turnbased.py
+
+from evennia import Command, CmdSet, EvMenu
+
+# ...
+
+class CmdTurnAttack(Command):
+    """
+    Start or join combat.
+
+    Usage:
+      attack [<target>]
+
+    """
+
+    key = "attack"
+    aliases = ["hit", "turnbased combat"]
+
+    turn_timeout = 30  # seconds
+    flee_time = 3  # rounds
+
+    def parse(self):
+        super().parse()
+        self.args = self.args.strip()
+
+    def func(self):
+        if not self.args:
+            self.msg("What are you attacking?")
+            return
+
+        target = self.caller.search(self.args)
+        if not target:
+            return
+
+        if not hasattr(target, "hp"):
+            self.msg("You can't attack that.")
+            return
+
+        elif target.hp <= 0:
+            self.msg(f"{target.get_display_name(self.caller)} is already down.")
+            return
+
+        if target.is_pc and not target.location.allow_pvp:
+            self.msg("PvP combat is not allowed here!")
+            return
+
+        combathandler = _get_combathandler(
+            self.caller, self.turn_timeout, self.flee_time)
+
+        # add combatants to combathandler. this can be done safely over and over
+        combathandler.add_combatant(self.caller)
+        combathandler.queue_action(self.caller, {"key": "attack", "target": target})
+        combathandler.add_combatant(target)
+        target.msg("|rYou are attacked by {self.caller.get_display_name(self.caller)}!|n")
+        combathandler.start_combat()
+
+        # build and start the menu
+        EvMenu(
+            self.caller,
+            {
+                "node_choose_enemy_target": node_choose_enemy_target,
+                "node_choose_allied_target": node_choose_allied_target,
+                "node_choose_enemy_recipient": node_choose_enemy_recipient,
+                "node_choose_allied_recipient": node_choose_allied_recipient,
+                "node_choose_ability": node_choose_ability,
+                "node_choose_use_item": node_choose_use_item,
+                "node_choose_wield_item": node_choose_wield_item,
+                "node_combat": node_combat,
+            },
+            startnode="node_combat",
+            combathandler=combathandler,
+            auto_look=False,
+            # cmdset_mergetype="Union",
+            persistent=True,
+        )
+
+
+class TurnCombatCmdSet(CmdSet):
+    """
+    CmdSet for the turn-based combat.
+    """
+
+    def at_cmdset_creation(self):
+        self.add(CmdTurnAttack())
+
+
+

The attack target Command will determine if the target has health (only things with health can be attacked) and that the room allows fighting. If the target is a pc, it will check so PvP is allowed.

+

It then proceeds to either start up a new command handler or reuse a new one, while adding the attacker and target to it. If the target was already in combat, this does nothing (same with the .start_combat() call).

+

As we create the EvMenu, we pass it the “menu index” we talked to about earlier, now with the actual node functions in every slot. We make the menu persistent so it survives a reload.

+

To make the command available, add the TurnCombatCmdSet to the Character’s default cmdset.

+
+
+

11.6. Making sure the menu stops

+

The combat can end for a bunch of reasons. When that happens, we must make sure to clean up the menu so we go back normal operation. We will add this to the remove_combatant method on the combat handler (we left a TODO there before):

+

+# in evadventure/combat_turnbased.py
+
+# ...
+
+class EvadventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
+
+    # ...
+    def remove_combatant(self, combatant):
+        """
+        Remove a combatant from the battle.
+        """
+        self.combatants.pop(combatant, None)
+        # clean up menu if it exists
+        if combatant.ndb._evmenu:                   # <--- new
+            combatant.ndb._evmenu.close_menu()      #     ''
+
+
+
+

When the evmenu is active, it is avaiable on its user as .ndb._evmenu (see the EvMenu docs). When we are removed from combat, we use this to get the evmenu and call its close_menu() method to shut down the menu.

+

Our turnbased combat system is complete!

+
+
+

11.7. Testing

+ +

Unit testing of the Turnbased combat handler is straight forward, you follow the process of earlier lessons to test that each method on the handler returns what you expect with mocked inputs.

+

Unit-testing the menu is more complex. You will find examples of doing this in evennia.utils.tests.test_evmenu.

+
+
+

11.8. A small combat test

+

Unit testing the code is not enough to see that combat works. We need to also make a little ‘functional’ test to see how it works in practice.

+

​This is what we need for a minimal test:

+
    +
  • A room with combat enabled.

  • +
  • An NPC to attack (it won’t do anything back yet since we haven’t added any AI)

  • +
  • A weapon we can wield.

  • +
  • An item (like a potion) we can use.

  • +
+ +

In The Twitch combat lesson we used a batch-command script to create the testing environment in game. This runs in-game Evennia commands in sequence. For demonstration purposes we’ll instead use a batch-code script, which runs raw Python code in a repeatable way. A batch-code script is much more flexible than a batch-command script.

+
+

Create a new subfolder evadventure/batchscripts/ (if it doesn’t already exist)

+
+
+

Create a new Python module evadventure/batchscripts/combat_demo.py

+
+

A batchcode file is a valid Python module. The only difference is that it has a # HEADER block and one or more # CODE sections. When the processor runs, the # HEADER part will be added on top of each # CODE part before executing that code block in isolation. Since you can run the file from in-game (including refresh it without reloading the server), this gives the ability to run longer Python codes on-demand.

+
# Evadventure (Turnbased) combat demo - using a batch-code file.
+#
+# Sets up a combat area for testing turnbased combat.
+#
+# First add mygame/server/conf/settings.py:
+#
+#    BASE_BATCHPROCESS_PATHS += ["evadventure.batchscripts"]
+#
+# Run from in-game as `batchcode turnbased_combat_demo`
+#
+
+# HEADER
+
+from evennia import DefaultExit, create_object, search_object
+from evennia.contrib.tutorials.evadventure.characters import EvAdventureCharacter
+from evennia.contrib.tutorials.evadventure.combat_turnbased import TurnCombatCmdSet
+from evennia.contrib.tutorials.evadventure.npcs import EvAdventureNPC
+from evennia.contrib.tutorials.evadventure.rooms import EvAdventureRoom
+
+# CODE
+
+# Make the player an EvAdventureCharacter
+player = caller  # caller is injected by the batchcode runner, it's the one running this script # E: undefined name 'caller'
+player.swap_typeclass(EvAdventureCharacter)
+
+# add the Turnbased cmdset
+player.cmdset.add(TurnCombatCmdSet, persistent=True)
+
+# create a weapon and an item to use
+create_object(
+    "contrib.tutorials.evadventure.objects.EvAdventureWeapon",
+    key="Sword",
+    location=player,
+    attributes=[("desc", "A sword.")],
+)
+
+create_object(
+    "contrib.tutorials.evadventure.objects.EvAdventureConsumable",
+    key="Potion",
+    location=player,
+    attributes=[("desc", "A potion.")],
+)
+
+# start from limbo
+limbo = search_object("#2")[0]
+
+arena = create_object(EvAdventureRoom, key="Arena", attributes=[("desc", "A large arena.")])
+
+# Create the exits
+arena_exit = create_object(DefaultExit, key="Arena", location=limbo, destination=arena)
+back_exit = create_object(DefaultExit, key="Back", location=arena, destination=limbo)
+
+# create the NPC dummy
+create_object(
+    EvAdventureNPC,
+    key="Dummy",
+    location=arena,
+    attributes=[("desc", "A training dummy."), ("hp", 1000), ("hp_max", 1000)],
+)
+
+
+
+

If editing this in an IDE, you may get errors on the player = caller line. This is because caller is not defined anywhere in this file. Instead caller (the one running the script) is injected by the batchcode runner.

+

But apart from the # HEADER and # CODE specials, this just a series of normal Evennia api calls.

+

Log into the game with a developer/superuser account and run

+
> batchcmd evadventure.batchscripts.turnbased_combat_demo
+
+
+

This should place you in the arena with the dummy (if not, check for errors in the output! Use objects and delete commands to list and delete objects if you need to start over.)

+

You can now try attack dummy and should be able to pound away at the dummy (lower its health to test destroying it). If you need to fix something, use q to exit the menu and get access to the reload command (for the final combat, you can disable this ability by passing auto_quit=False when you create the EvMenu).

+
+
+

11.9. Conclusions

+

At this point we have coverered some ideas on how to implement both twitch- and turnbased combat systems. Along the way you have been exposed to many concepts such as classes, scripts and handlers, Commands, EvMenus and more.

+

Before our combat system is actually usable, we need our enemies to actually fight back. We’ll get to that next.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Twitch.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Twitch.html new file mode 100644 index 0000000000..33ca6d27fe --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Twitch.html @@ -0,0 +1,1322 @@ + + + + + + + + + 10. Twitch Combat — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

10. Twitch Combat

+

In this lesson we will build upon the basic combat framework we devised in the previous lesson to create a ‘twitch-like’ combat system.

+
> attack troll 
+  You attack the Troll! 
+
+The Troll roars!
+
+You attack the Troll with Sword: Roll vs armor(11):
+ rolled 3 on d20 + strength(+1) vs 11 -> Fail
+ 
+Troll attacks you with Terrible claws: Roll vs armor(12): 
+ rolled 13 on d20 + strength(+3) vs 12 -> Success
+ Troll hits you for 5 damage! 
+ 
+You attack the Troll with Sword: Roll vs armor(11):
+ rolled 14 on d20 + strength(+1) vs 11 -> Success
+ You hit the Troll for 2 damage!
+ 
+> look 
+  A dark cave 
+  
+  Water is dripping from the ceiling. 
+  
+  Exits: south and west 
+  Enemies: The Troll 
+  --------- Combat Status ----------
+  You (Wounded)  vs  Troll (Scraped)
+
+> use potion 
+  You prepare to use a healing potion! 
+  
+Troll attacks you with Terrible claws: Roll vs armor(12): 
+ rolled 2 on d20 + strength(+3) vs 12 -> Fail
+ 
+You use a healing potion. 
+ You heal 4 damage. 
+ 
+Troll attacks you with Terrible claws: Roll vs armor(12): 
+ rolled 8 on d20 + strength(+3) vs 12 -> Fail
+ 
+You attack the troll with Sword: Roll vs armor(11):
+ rolled 20 on d20 + strength(+1) vs 11 -> Success (critical success)
+ You critically hit the Troll for 8 damage! 
+ The Troll falls to the ground, dead. 
+ 
+The battle is over. You are still standing. 
+
+
+
+

Note that this documentation doesn’t show in-game colors. If you are interested in an alternative, see the next lesson, where we’ll make a turnbased, menu-based system instead.

+
+

With “Twitch” combat, we refer to a type of combat system that runs without any clear divisions of ‘turns’ (the opposite of Turn-based combat). It is inspired by the way combat worked in the old DikuMUD codebase, but is more flexible.

+ +

Basically, a user enters an action and after a certain time that action will execute (normally an attack). If they don’t do anything, the attack will repeat over and over (with a random result) until the enemy or you is defeated.

+

You can change up your strategy by performing other actions (like drinking a potion or cast a spell). You can also simply move to another room to ‘flee’ the combat (but the enemy may of course follow you)

+
+

10.1. General principle

+ +

Here is the general design of the Twitch-based combat handler:

+
    +
  • The twitch-version of the CombatHandler will be stored on each combatant whenever combat starts. When combat is over, or they leave the room with combat, the handler will be deleted.

  • +
  • The handler will queue each action independently, starting a timer until they fire.

  • +
  • All input are handled via Evennia Commands.

  • +
+
+
+

10.2. Twitch combat handler

+
+

Create a new module evadventure/combat_twitch.py.

+
+

We will make use of the Combat Actions, Action dicts and the parent EvAdventureCombatBaseHandler we created previously.

+
# in evadventure/combat_twitch.py
+
+from .combat_base import (
+   CombatActionAttack,
+   CombatActionHold,
+   CombatActionStunt,
+   CombatActionUseItem,
+   CombatActionWield,
+   EvAdventureCombatBaseHandler,
+)
+
+from .combat_base import EvAdventureCombatBaseHandler
+
+class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
+    """
+    This is created on the combatant when combat starts. It tracks only 
+    the combatant's side of the combat and handles when the next action 
+    will happen.
+ 
+    """
+ 
+    def msg(self, message, broadcast=True):
+        """See EvAdventureCombatBaseHandler.msg"""
+        super().msg(message, combatant=self.obj, 
+                    broadcast=broadcast, location=self.obj.location)
+
+
+

We make a child class of EvAdventureCombatBaseHandler for our Twitch combat. The parent class is a Script, and when a Script sits ‘on’ an Object, that Object is available on the script as self.obj. Since this handler is meant to sit ‘on’ the combatant, then self.obj is thus the combatant and self.obj.location is the current room the combatant is in. By using super() we can reuse the parent class’ msg() method with these Twitch-specific details.

+
+

10.2.1. Getting the sides of combat

+
# in evadventure/combat_twitch.py 
+
+from evennia.utils import inherits_from
+
+# ...
+
+class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
+
+    # ... 
+
+    def get_sides(self, combatant):
+         """
+         Get a listing of the two 'sides' of this combat, from the 
+         perspective of the provided combatant. The sides don't need 
+         to be balanced.
+ 
+         Args:
+             combatant (Character or NPC): The basis for the sides.
+             
+         Returns:
+             tuple: A tuple of lists `(allies, enemies)`, from the 
+                 perspective of `combatant`. Note that combatant itself 
+                 is not included in either of these.
+
+        """
+        # get all entities involved in combat by looking up their combathandlers
+        combatants = [
+            comb
+            for comb in self.obj.location.contents
+            if hasattr(comb, "scripts") and comb.scripts.has(self.key)
+        ]
+        location = self.obj.location
+
+        if hasattr(location, "allow_pvp") and location.allow_pvp:
+            # in pvp, everyone else is an enemy
+            allies = [combatant]
+            enemies = [comb for comb in combatants if comb != combatant]
+        else:
+            # otherwise, enemies/allies depend on who combatant is
+            pcs = [comb for comb in combatants if inherits_from(comb, EvAdventureCharacter)]
+            npcs = [comb for comb in combatants if comb not in pcs]
+            if combatant in pcs:
+                # combatant is a PC, so NPCs are all enemies
+                allies = pcs
+                enemies = npcs
+            else:
+                # combatant is an NPC, so PCs are all enemies
+                allies = npcs
+                enemies = pcs
+        return allies, enemies
+
+
+
+

Next we add our own implementation of the get_sides() method. This presents the sides of combat from the perspective of the provided combatant. In Twitch combat, there are a few things that identifies a combatant:

+
    +
  • That they are in the same location

  • +
  • That they each have a EvAdventureCombatTwitchHandler script running on themselves

  • +
+ +

In a PvP-open room, it’s all for themselves - everyone else is considered an ‘enemy’. Otherwise we separate PCs from NPCs by seeing if they inherit from EvAdventureCharacter (our PC class) or not - if you are a PC, then the NPCs are your enemies and vice versa. The inherits_from is very useful for doing these checks - it will pass also if you inherit from EvAdventureCharacter at any distance.

+

Note that allies does not include the combatant itself, so if you are fighting a lone enemy, the return from this method will be ([], [enemy_obj]).

+
+
+

10.2.2. Tracking Advantage / Disadvantage

+
# in evadventure/combat_twitch.py 
+
+from evennia import AttributeProperty
+
+# ... 
+
+class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
+
+    self.advantage_against = AttributeProperty(dict) 
+    self.disadvantage_against = AttributeProperty(dict)
+
+    # ... 
+
+    def give_advantage(self, recipient, target):
+        """Let a recipient gain advantage against the target."""
+        self.advantage_against[target] = True
+
+    def give_disadvantage(self, recipient, target):
+        """Let an affected party gain disadvantage against a target."""
+        self.disadvantage_against[target] = True
+
+    def has_advantage(self, combatant, target):
+        """Check if the combatant has advantage against a target."""
+        return self.advantage_against.get(target, False)
+
+    def has_disadvantage(self, combatant, target):
+        """Check if the combatant has disadvantage against a target."""
+        return self.disadvantage_against.get(target, False)1
+
+
+
+

As seen in the previous lesson, the Actions call these methods to store the fact that +a given combatant has advantage.

+

In this Twitch-combat case, the one getting the advantage is always one on which the combathandler is defined, so we don’t actually need to use the recipient/combatant argument (it’s always going to be self.obj) - only target is important.

+

We create two new Attributes to store the relation as dicts.

+
+
+

10.2.3. Queue action

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
# in evadventure/combat_twitch.py 
+
+from evennia.utils import repeat, unrepeat
+from .combat_base import (
+    CombatActionAttack,
+    CombatActionHold,
+    CombatActionStunt,
+    CombatActionUseItem,
+    CombatActionWield,
+    EvAdventureCombatBaseHandler,
+)
+
+# ... 
+
+class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
+
+    action_classes = {
+         "hold": CombatActionHold,
+         "attack": CombatActionAttack,
+         "stunt": CombatActionStunt,
+         "use": CombatActionUseItem,
+         "wield": CombatActionWield,
+     }
+
+    action_dict = AttributeProperty(dict, autocreate=False)
+    current_ticker_ref = AttributeProperty(None, autocreate=False)
+
+    # ... 
+
+    def queue_action(self, action_dict, combatant=None):
+        """
+        Schedule the next action to fire.
+
+        Args:
+            action_dict (dict): The new action-dict to initialize.
+            combatant (optional): Unused.
+
+        """
+        if action_dict["key"] not in self.action_classes:
+            self.obj.msg("This is an unkown action!")
+            return
+
+        # store action dict and schedule it to run in dt time
+        self.action_dict = action_dict
+        dt = action_dict.get("dt", 0)
+
+        if self.current_ticker_ref:
+            # we already have a current ticker going - abort it
+            unrepeat(self.current_ticker_ref)
+        if dt <= 0:
+            # no repeat
+            self.current_ticker_ref = None
+        else:
+                # always schedule the task to be repeating, cancel later
+                # otherwise. We store the tickerhandler's ref to make sure 
+                # we can remove it later
+            self.current_ticker_ref = repeat(
+                dt, self.execute_next_action, id_string="combat")
+
+
+
    +
  • Line 30: The queue_action method takes an “Action dict” representing an action the combatant wants to perform next. It must be one of the keyed Actions added to the handler in the action_classes property (Line 17). We make no use of the combatant keyword argument since we already know that the combatant is self.obj.

  • +
  • Line 43: We simply store the given action dict in the Attribute action_dict on the handler. Simple and effective!

  • +
  • Line 44: When you enter e.g. attack, you expect in this type of combat to see the attack command repeat automatically even if you don’t enter anything more. To this end we are looking for a new key in action dicts, indicating that this action should repeat with a certain rate (dt, given in seconds). We make this compatible with all action dicts by simply assuming it’s zero if not specified.

  • +
+

evennia.utils.utils.repeat and evennia.utils.utils.unrepeat are convenient shortcuts to the TickerHandler. You tell repeat to call a given method/function at a certain rate. What you get back is a reference that you can then later use to ‘un-repeat’ (stop the repeating) later. We make sure to store this reference (we don’t care exactly how it looks, just that we need to store it) in the current_ticket_ref Attribute (Line 26).

+
    +
  • Line 48: Whenever we queue a new action (it may replace an existing one) we must make sure to kill (un-repeat) any old repeats that are ongoing. Otherwise we would get old actions firing over and over and new ones starting alongside them.

  • +
  • Line 49: If dt is set, we call repeat to set up a new repeat action at the given rate. We store this new reference. After dt seconds, the .execute_next_action method will fire (we’ll create that in the next section).

  • +
+
+
+

10.2.4. Execute an action

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
# in evadventure/combat_twitch.py
+
+class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
+
+    fallback_action_dict = AttributeProperty({"key": "hold", "dt": 0})
+
+    # ... 
+
+    def execute_next_action(self):
+            """
+            Triggered after a delay by the command
+            """
+            combatant = self.obj
+            action_dict = self.action_dict
+            action_class = self.action_classes[action_dict["key"]]
+            action = action_class(self, combatant, action_dict)
+    
+            if action.can_use():
+                action.execute()
+                action.post_execute()
+    
+            if not action_dict.get("repeat", True):
+                # not a repeating action, use the fallback (normally the original attack)
+                self.action_dict = self.fallback_action_dict
+                self.queue_action(self.fallback_action_dict)
+    
+            self.check_stop_combat()
+
+
+

This is the method called after dt seconds in queue_action.

+
    +
  • Line 5: We defined a ‘fallback action’. This is used after a one-time action (one that should not repeat) has completed.

  • +
  • Line 15: We take the 'key' from the action-dict and use the action_classes mapping to get an action class (e.g. ACtionAttack we defined here).

  • +
  • Line 16: Here we initialize the action class with the actual current data - the combatant and the action_dict. This calls the __init__ method on the class and makes the action ready to use.

  • +
+ +
    +
  • Line 18: Here we run through the usage methods of the action - where we perform the action. We let the action itself handle all the logics.

  • +
  • Line 22: We check for another optional flag on the action-dict: repeat. Unless it’s set, we use the fallback-action defined on Line 5. Many actions should not repeat - for example, it would not make sense to do wield for the same weapon over and over.

  • +
  • Line 27: It’s important that we know how to stop combat. We will write this method next.

  • +
+
+
+

10.2.5. Checking and stopping combat

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
# in evadventure/combat_twitch.py 
+
+class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
+
+    # ... 
+
+    def check_stop_combat(self):
+        """
+        Check if the combat is over.
+        """
+
+        allies, enemies = self.get_sides(self.obj)
+
+        location = self.obj.location
+
+        # only keep combatants that are alive and still in the same room
+        allies = [comb for comb in allies if comb.hp > 0 and comb.location == location]
+        enemies = [comb for comb in enemies if comb.hp > 0 and comb.location == location]
+
+        if not allies and not enemies:
+            self.msg("The combat is over. Noone stands.", broadcast=False)
+            self.stop_combat()
+            return
+        if not allies: 
+            self.msg("The combat is over. You lost.", broadcast=False)
+            self.stop_combat()
+        if not enemies:
+            self.msg("The combat is over. You won!", broadcast=False)
+            self.stop_combat()
+
+    def stop_combat(self):
+        pass  # We'll finish this last
+
+
+

We must make sure to check if combat is over.

+
    +
  • Line 12: With our .get_sides() method we can easily get the two sides of the conflict.

  • +
  • Lines 18, 19: We get everyone still alive and still in the same room. The latter condition is important in case we move away from the battle - you can’t hit your enemy from another room.

  • +
+

In the stop_method we’ll need to do a bunch of cleanup. We’ll hold off on implementing this until we have the Commands written out. Read on.

+
+
+
+

10.3. Commands

+

We want each action to map to a Command - an actual input the player can pass to the game.

+
+

10.3.1. Base Combat class

+

We should try to find the similarities between the commands we’ll need and group them into one parent class. When a Command fires, it will fire the following methods on itself, in sequence:

+
    +
  1. cmd.at_pre_command()

  2. +
  3. cmd.parse()

  4. +
  5. cmd.func()

  6. +
  7. cmd.at_post_command()

  8. +
+

We’ll override the first two for our parent.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
# in evadventure/combat_twitch.py
+
+from evennia import Command
+from evennia import InterruptCommand 
+
+# ... 
+
+# after the combat handler class
+
+class _BaseTwitchCombatCommand(Command):
+    """
+    Parent class for all twitch-combat commnads.
+
+    """
+
+    def at_pre_command(self):
+        """
+        Called before parsing.
+
+        """
+        if not self.caller.location or not self.caller.location.allow_combat:
+            self.msg("Can't fight here!")
+            raise InterruptCommand()
+
+    def parse(self):
+        """
+        Handle parsing of most supported combat syntaxes (except stunts).
+
+        <action> [<target>|<item>]
+        or
+        <action> <item> [on] <target>
+
+        Use 'on' to differentiate if names/items have spaces in the name.
+
+        """
+        self.args = args = self.args.strip()
+        self.lhs, self.rhs = "", ""
+
+        if not args:
+            return
+
+        if " on " in args:
+            lhs, rhs = args.split(" on ", 1)
+        else:
+            lhs, *rhs = args.split(None, 1)
+            rhs = " ".join(rhs)
+        self.lhs, self.rhs = lhs.strip(), rhs.strip()
+
+    def get_or_create_combathandler(self, target=None, combathandler_name="combathandler"):
+        """
+        Get or create the combathandler assigned to this combatant.
+
+        """
+        if target:
+            # add/check combathandler to the target
+            if target.hp_max is None:
+                self.msg("You can't attack that!")
+                raise InterruptCommand()
+
+            EvAdventureCombatTwitchHandler.get_or_create_combathandler(target)
+        return EvAdventureCombatTwitchHandler.get_or_create_combathandler(self.caller)
+
+
+
    +
  • Line 23: If the current location doesn’t allow combat, all combat commands should exit immediately. To stop the command before it reaches the .func(), we must raise the InterruptCommand().

  • +
  • Line 49: It’s convenient to add a helper method for getting the command handler because all our commands will be using it. It in turn calls the class method get_or_create_combathandler we inherit from the parent of EvAdventureCombatTwitchHandler.

  • +
+
+
+

10.3.2. In-combat look command

+
# in evadventure/combat_twitch.py 
+
+from evennia import default_cmds
+from evennia.utils import pad
+
+# ...
+
+class CmdLook(default_cmds.CmdLook, _BaseTwitchCombatCommand):
+    def func(self):
+        # get regular look, followed by a combat summary
+        super().func()
+        if not self.args:
+            combathandler = self.get_or_create_combathandler()
+            txt = str(combathandler.get_combat_summary(self.caller))
+            maxwidth = max(display_len(line) for line in txt.strip().split("\n"))
+            self.msg(f"|r{pad(' Combat Status ', width=maxwidth, fillchar='-')}|n\n{txt}")
+
+
+

When in combat we want to be able to do look and get the normal look but with the extra combat summary at the end (on the form Me (Hurt)  vs  Troll (Perfect)). So

+

The last line uses Evennia’s utils.pad function to put the text “Combat Status” surrounded by a line on both sides.

+

The result will be the look command output followed directly by

+
--------- Combat Status ----------
+You (Wounded)  vs  Troll (Scraped)
+
+
+
+
+

10.3.3. Hold command

+
class CmdHold(_BaseTwitchCombatCommand):
+    """
+    Hold back your blows, doing nothing.
+
+    Usage:
+        hold
+
+    """
+
+    key = "hold"
+
+    def func(self):
+        combathandler = self.get_or_create_combathandler()
+        combathandler.queue_action({"key": "hold"})
+        combathandler.msg("$You() $conj(hold) back, doing nothing.", self.caller)
+
+
+

The ‘do nothing’ command showcases the basic principle of how all following commands work:

+
    +
  1. Get the combathandler (will be created or loaded if it already existed).

  2. +
  3. Queue the action by passing its action-dict to the combathandler.queue_action method.

  4. +
  5. Confirm to the caller that they now queued this action.

  6. +
+
+
+

10.3.4. Attack command

+
# in evadventure/combat_twitch.py 
+
+# ... 
+
+class CmdAttack(_BaseTwitchCombatCommand):
+    """
+    Attack a target. Will keep attacking the target until
+    combat ends or another combat action is taken.
+
+    Usage:
+        attack/hit <target>
+
+    """
+
+    key = "attack"
+    aliases = ["hit"]
+    help_category = "combat"
+
+    def func(self):
+        target = self.caller.search(self.lhs)
+        if not target:
+            return
+
+        combathandler = self.get_or_create_combathandler(target)
+        combathandler.queue_action(
+            {"key": "attack", 
+             "target": target, 
+             "dt": 3, 
+             "repeat": True}
+        )
+        combathandler.msg(f"$You() $conj(attack) $You({target.key})!", self.caller)
+
+
+

The attack command becomes quite simple because we do all the heavy lifting in the combathandler and in the ActionAttack class. Note that we set dt to a fixed 3 here, but in a more complex system one could imagine your skills, weapon and circumstance affecting how long your attack will take.

+
# in evadventure/combat_twitch.py 
+
+from .enums import ABILITY_REVERSE_MAP
+
+# ... 
+
+class CmdStunt(_BaseTwitchCombatCommand):
+    """
+    Perform a combat stunt, that boosts an ally against a target, or
+    foils an enemy, giving them disadvantage against an ally.
+
+    Usage:
+        boost [ability] <recipient> <target>
+        foil [ability] <recipient> <target>
+        boost [ability] <target>       (same as boost me <target>)
+        foil [ability] <target>        (same as foil <target> me)
+
+    Example:
+        boost STR me Goblin
+        boost DEX Goblin
+        foil STR Goblin me
+        foil INT Goblin
+        boost INT Wizard Goblin
+
+    """
+
+    key = "stunt"
+    aliases = (
+        "boost",
+        "foil",
+    )
+    help_category = "combat"
+
+    def parse(self):
+        args = self.args
+
+        if not args or " " not in args:
+            self.msg("Usage: <ability> <recipient> <target>")
+            raise InterruptCommand()
+
+        advantage = self.cmdname != "foil"
+
+        # extract data from the input
+
+        stunt_type, recipient, target = None, None, None
+
+        stunt_type, *args = args.split(None, 1)
+        if stunt_type:
+            stunt_type = stunt_type.strip().lower()
+
+        args = args[0] if args else ""
+
+        recipient, *args = args.split(None, 1)
+        target = args[0] if args else None
+
+        # validate input and try to guess if not given
+
+        # ability is requried
+        if not stunt_type or stunt_type not in ABILITY_REVERSE_MAP:
+            self.msg(
+                f"'{stunt_type}' is not a valid ability. Pick one of"
+                f" {', '.join(ABILITY_REVERSE_MAP.keys())}."
+            )
+            raise InterruptCommand()
+
+        if not recipient:
+            self.msg("Must give at least a recipient or target.")
+            raise InterruptCommand()
+
+        if not target:
+            # something like `boost str target`
+            target = recipient if advantage else "me"
+            recipient = "me" if advantage else recipient
+ we still have None:s at this point, we can't continue
+        if None in (stunt_type, recipient, target):
+            self.msg("Both ability, recipient and  target of stunt must be given.")
+            raise InterruptCommand()
+
+        # save what we found so it can be accessed from func()
+        self.advantage = advantage
+        self.stunt_type = ABILITY_REVERSE_MAP[stunt_type]
+        self.recipient = recipient.strip()
+        self.target = target.strip()
+
+    def func(self):
+        target = self.caller.search(self.target)
+        if not target:
+            return
+        recipient = self.caller.search(self.recipient)
+        if not recipient:
+            return
+
+        combathandler = self.get_or_create_combathandler(target)
+
+        combathandler.queue_action(
+            {
+                "key": "stunt",
+                "recipient": recipient,
+                "target": target,
+                "advantage": self.advantage,
+                "stunt_type": self.stunt_type,
+                "defense_type": self.stunt_type,
+                "dt": 3,
+            },
+        )
+        combathandler.msg("$You() prepare a stunt!", self.caller)
+
+
+
+

This looks much longer, but that is only because the stunt command should understand many different input structures depending on if you are trying to create a advantage or disadvantage, and if an ally or enemy should receive the effect of the stunt.

+

Note the enums.ABILITY_REVERSE_MAP (created in the Utilities lesson) being useful to convert your input of ‘str’ into Ability.STR needed by the action dict.

+

Once we’ve sorted out the string parsing, the func is simple - we find the target and recipient and use them to build the needed action-dict to queue.

+
+
+

10.3.5. Using items

+
# in evadventure/combat_twitch.py 
+
+# ... 
+
+class CmdUseItem(_BaseTwitchCombatCommand):
+    """
+    Use an item in combat. The item must be in your inventory to use.
+
+    Usage:
+        use <item>
+        use <item> [on] <target>
+
+    Examples:
+        use potion
+        use throwing knife on goblin
+        use bomb goblin
+
+    """
+
+    key = "use"
+    help_category = "combat"
+
+    def parse(self):
+        super().parse()
+
+        if not self.args:
+            self.msg("What do you want to use?")
+            raise InterruptCommand()
+
+        self.item = self.lhs
+        self.target = self.rhs or "me"
+
+    def func(self):
+        item = self.caller.search(
+            self.item,
+            candidates=self.caller.equipment.get_usable_objects_from_backpack()
+        )
+        if not item:
+            self.msg("(You must carry the item to use it.)")
+            return
+        if self.target:
+            target = self.caller.search(self.target)
+            if not target:
+                return
+
+        combathandler = self.get_or_create_combathandler(self.target)
+        combathandler.queue_action(
+            {"key": "use", 
+             "item": item, 
+             "target": target, 
+             "dt": 3}
+        )
+        combathandler.msg(
+            f"$You() prepare to use {item.get_display_name(self.caller)}!", self.caller
+        )
+
+
+

To use an item, we need to make sure we are carrying it. Luckily our work in the Equipment lesson gives us easy methods we can use to search for suitable objects.

+
+
+

10.3.6. Wielding new weapons and equipment

+
# in evadventure/combat_twitch.py 
+
+# ... 
+
+class CmdWield(_BaseTwitchCombatCommand):
+    """
+    Wield a weapon or spell-rune. You will the wield the item, 
+        swapping with any other item(s) you were wielded before.
+
+    Usage:
+      wield <weapon or spell>
+
+    Examples:
+      wield sword
+      wield shield
+      wield fireball
+
+    Note that wielding a shield will not replace the sword in your hand, 
+        while wielding a two-handed weapon (or a spell-rune) will take 
+        two hands and swap out what you were carrying.
+
+    """
+
+    key = "wield"
+    help_category = "combat"
+
+    def parse(self):
+        if not self.args:
+            self.msg("What do you want to wield?")
+            raise InterruptCommand()
+        super().parse()
+
+    def func(self):
+        item = self.caller.search(
+            self.args, candidates=self.caller.equipment.get_wieldable_objects_from_backpack()
+        )
+        if not item:
+            self.msg("(You must carry the item to wield it.)")
+            return
+        combathandler = self.get_or_create_combathandler()
+        combathandler.queue_action({"key": "wield", "item": item, "dt": 3})
+        combathandler.msg(f"$You() reach for {item.get_display_name(self.caller)}!", self.caller)
+
+
+
+

The Wield command follows the same pattern as other commands.

+
+
+
+

10.4. Grouping Commands for use

+

To make these commands available to use we must add them to a Command Set.

+
# in evadventure/combat_twitch.py 
+
+from evennia import CmdSet
+
+# ... 
+
+# after the commands 
+
+class TwitchCombatCmdSet(CmdSet):
+    """
+    Add to character, to be able to attack others in a twitch-style way.
+    """
+
+    def at_cmdset_creation(self):
+        self.add(CmdAttack())
+        self.add(CmdHold())
+        self.add(CmdStunt())
+        self.add(CmdUseItem())
+        self.add(CmdWield())
+
+
+class TwitchLookCmdSet(CmdSet):
+    """
+    This will be added/removed dynamically when in combat.
+    """
+
+    def at_cmdset_creation(self):
+        self.add(CmdLook())
+
+
+
+
+

The first cmdset, TwitchCombatCmdSet is intended to be added to the Character. We can do so permanently by adding the cmdset to the default character cmdset (as outlined in the Beginner Command lesson). In the testing section below, we’ll do this in another way.

+

What about that TwitchLookCmdSet? We can’t add it to our character permanently, because we only want this particular version of look to operate while we are in combat.

+

We must make sure to add and clean this up when combat starts and ends.

+
+

10.4.1. Combat startup and cleanup

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
# in evadventure/combat_twitch.py
+
+# ... 
+
+class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
+
+    # ... 
+
+    def at_init(self): 
+        self.obj.cmdset.add(TwitchLookCmdSet, persistent=False)
+
+    def stop_combat(self): 
+        self.queue_action({"key": "hold", "dt": 0})  # make sure ticker is killed
+        del self.obj.ndb.combathandler
+        self.obj.cmdset.remove(TwitchLookCmdSet)
+        self.delete()
+
+
+

Now that we have the Look command set, we can finish the Twitch combat handler.

+
    +
  • Line 9: The at_init method is a standard Evennia method available on all typeclassed entities (including Scripts, which is what our combat handler is). Unlike at_object_creation (which only fires once, when the object is first created), at_init will be called every time the object is loaded into memory (normally after you do a server reload). So we add the TwitchLookCmdSet here. We do so non-persistently, since we don’t want to get an ever growing number of cmdsets added every time we reload.

  • +
  • Line 13: By queuing a hold action with dt of 0, we make sure to kill the repeat action that is going on. If not, it would still fire later - and find that the combat handler is gone.

  • +
  • Line 14: If looking at how we defined the get_or_create_combathandler classmethod (the one we have been using to get/create the combathandler during the combat), you’ll see that it caches the handler as .ndb.combathandler on the object we send to it. So we delete that cached reference here to make sure it’s gone.

  • +
  • Line 15: We remove the look-cmdset from ourselves (remember self.obj is you, the combatant that now just finished combat).

  • +
  • Line 16: We delete the combat handler itself.

  • +
+
+
+
+

10.5. Unit Testing

+ +
+

Create evadventure/tests/test_combat.py (if you don’t already have it).

+
+

Both the Twitch command handler and commands can and should be unit tested. Testing of commands are made easier by Evennia’s special EvenniaCommandTestMixin class. This makes the .call method available and makes it easy to check if a command returns what you expect.

+

Here’s an example:

+
# in evadventure/tests/test_combat.py 
+
+from unittest.mock import Mock, patch
+from evennia.utils.test_resources import EvenniaCommandTestMixin
+
+from .. import combat_twitch
+
+# ...
+
+class TestEvAdventureTwitchCombat(EvenniaCommandTestMixin)
+
+    def setUp(self): 
+        self.combathandler = (
+                combat_twitch.EvAdventureCombatTwitchHandler.get_or_create_combathandler(
+            self.char1, key="combathandler") 
+        )
+   
+    @patch("evadventure.combat_twitch.unrepeat", new=Mock())
+    @patch("evadventure.combat_twitch.repeat", new=Mock())
+    def test_hold_command(self): 
+        self.call(combat_twitch, CmdHold(), "", "You hold back, doing nothing")
+        self.assertEqual(self.combathandler.action_dict, {"key": "hold"})
+            
+
+
+

The EvenniaCommandTestMixin as a few default objects, including self.char1, which we make use of here.

+

The two @patch lines are Python decorators that ‘patch’ the test_hold_command method. What they do is basically saying “in the following method, whenever any code tries to access evadventure.combat_twitch.un/repeat, just return a Mocked object instead”.

+

We do this patching as an easy way to avoid creating timers in the unit test - these timers would finish after the test finished (which includes deleting its objects) and thus fail.

+

Inside the test, we use the self.call() method to explicitly fire the Command (with no argument) and check that the output is what we expect. Lastly we check that the combathandler is set up correctly, having stored the action-dict on itself.

+
+
+

10.6. A small combat test

+ +

Showing that the individual pieces of code works (unit testing) is not enough to be sure that your combat system is actually working. We need to test all the pieces together. This is often called functional testing. While functional testing can also be automated, wouldn’t it be fun to be able to actually see our code in action?

+

This is what we need for a minimal test:

+
    +
  • A room with combat enabled.

  • +
  • An NPC to attack (it won’t do anything back yet since we haven’t added any AI)

  • +
  • A weapon we can wield

  • +
  • An item (like a potion) we can use.

  • +
+

While you can create these manually in-game, it can be convenient to create a batch-command script to set up your testing environment.

+
+

create a new subfolder evadventure/batchscripts/ (if it doesn’t already exist)

+
+
+

create a new file evadventure/combat_demo.ev (note, it’s .ev not .py!)

+
+

A batch-command file is a text file with normal in-game commands, one per line, separated by lines starting with # (these are required between all command lines). Here’s how it looks:

+
# Evadventure combat demo 
+
+# start from limbo
+
+tel #2
+
+# turn ourselves into a evadventure-character
+
+type self = evadventure.characters.EvAdventureCharacter
+
+# assign us the twitch combat cmdset (requires superuser/developer perms)
+
+py self.cmdset.add("evadventure.combat_twitch.TwitchCombatCmdSet", persistent=True)
+
+# Create a weapon in our inventory (using all defaults)
+
+create sword:evadventure.objects.EvAdventureWeapon
+
+# create a consumable to use
+
+create potion:evadventure.objects.EvAdventureConsumable
+
+# dig a combat arena
+
+dig arena:evadventure.rooms.EvAdventureRoom = arena,back
+
+# go to arena
+
+arena
+
+# allow combat in this room
+
+set here/allow_combat = True
+
+# create a dummy enemy to hit on
+
+create/drop dummy puppet;dummy:evadventure.npcs.EvAdventureNPC
+
+# describe the dummy
+
+desc dummy = This is is an ugly training dummy made out of hay and wood.
+
+# make the dummy crazy tough
+
+set dummy/hp_max = 1000
+
+# 
+
+set dummy/hp = 1000
+
+
+

Log into the game with a developer/superuser account and run

+
> batchcmd evadventure.batchscripts.twitch_combat_demo 
+
+
+

This should place you in the arena with the dummy (if not, check for errors in the output! Use objects and delete commands to list and delete objects if you need to start over. )

+

You can now try attack dummy and should be able to pound away at the dummy (lower its health to test destroying it). Use back to ‘flee’ the combat.

+
+
+

10.7. Conclusions

+

This was a big lesson! Even though our combat system is not very complex, there are still many moving parts to keep in mind.

+

Also, while pretty simple, there is also a lot of growth possible with this system. You could easily expand from this or use it as inspiration for your own game.

+

Next we’ll try to achieve the same thing within a turn-based framework!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Commands.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Commands.html new file mode 100644 index 0000000000..041ae054db --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Commands.html @@ -0,0 +1,141 @@ + + + + + + + + + 16. In-game Commands — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

16. In-game Commands

+
+

Warning

+

This part of the Beginner tutorial is still being developed.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.html new file mode 100644 index 0000000000..fcf2fca5a0 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.html @@ -0,0 +1,141 @@ + + + + + + + + + 13. Dynamically generated Dungeon — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

13. Dynamically generated Dungeon

+
+

Warning

+

This part of the Beginner tutorial is still being developed.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Equipment.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Equipment.html new file mode 100644 index 0000000000..6622e7ac83 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Equipment.html @@ -0,0 +1,723 @@ + + + + + + + + + 5. Handling Equipment — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

5. Handling Equipment

+

In Knave, you have a certain number of inventory “slots”. The amount of slots is given by CON + 10. All items (except coins) have a size, indicating how many slots it uses. You can’t carry more items than you have slot-space for. Also items wielded or worn count towards the slots.

+

We still need to track what the character is using however: What weapon they have readied affects the damage they can do. The shield, helmet and armor they use affects their defense.

+

We have already set up the possible ‘wear/wield locations’ when we defined our Objects +in the previous lesson. This is what we have in enums.py:

+
# mygame/evadventure/enums.py
+
+# ...
+
+class WieldLocation(Enum):
+    
+    BACKPACK = "backpack"
+    WEAPON_HAND = "weapon_hand"
+    SHIELD_HAND = "shield_hand"
+    TWO_HANDS = "two_handed_weapons"
+    BODY = "body"  # armor
+    HEAD = "head"  # helmets
+
+
+

Basically, all the weapon/armor locations are exclusive - you can only have one item in each (or none). The BACKPACK is special - it contains any number of items (up to the maximum slot usage).

+
+

5.1. EquipmentHandler that saves

+
+

Create a new module mygame/evadventure/equipment.py.

+
+ +

In default Evennia, everything you pick up will end up “inside” your character object (that is, have you as its .location). This is called your inventory and has no limit. We will keep ‘moving items into us’ when we pick them up, but we will add more functionality using an Equipment handler.

+

A handler is (for our purposes) an object that sits “on” another entity, containing functionality for doing one specific thing (managing equipment, in our case).

+

This is the start of our handler:

+
# in mygame/evadventure/equipment.py 
+
+from .enums import WieldLocation
+
+class EquipmentHandler: 
+    save_attribute = "inventory_slots"
+    
+    def __init__(self, obj): 
+        # here obj is the character we store the handler on 
+        self.obj = obj 
+        self._load() 
+        
+    def _load(self):
+        """Load our data from an Attribute on `self.obj`"""
+        self.slots = self.obj.attributes.get(
+            self.save_attribute,
+            category="inventory",
+            default={
+                WieldLocation.WEAPON_HAND: None, 
+                WieldLocation.SHIELD_HAND: None, 
+                WieldLocation.TWO_HANDS: None, 
+                WieldLocation.BODY: None,
+                WieldLocation.HEAD: None,
+                WieldLocation.BACKPACK: []
+            } 
+        )
+    
+    def _save(self):
+        """Save our data back to the same Attribute"""
+        self.obj.attributes.add(self.save_attribute, self.slots, category="inventory") 
+
+
+

This is a compact and functional little handler. Before analyzing how it works, this is how +we will add it to the Character:

+
# mygame/evadventure/characters.py
+
+# ... 
+
+from evennia.utils.utils import lazy_property
+from .equipment import EquipmentHandler 
+
+# ... 
+
+class EvAdventureCharacter(LivingMixin, DefaultCharacter):
+    
+    # ... 
+
+    @lazy_property 
+    def equipment(self):
+        return EquipmentHandler(self)
+
+
+

After reloading the server, the equipment-handler will now be accessible on character-instances as

+
character.equipment
+
+
+

The @lazy_property works such that it will not load the handler until someone actually tries to fetch it with character.equipment. When that happens, we start up the handler and feed it self (the Character instance itself). This is what enters __init__ as .obj in the EquipmentHandler code above.

+

So we now have a handler on the character, and the handler has a back-reference to the character it sits on.

+

Since the handler itself is just a regular Python object, we need to use the Character to store +our data - our Knave “slots”. We must save them to the database, because we want the server to remember them even after reloading.

+

Using self.obj.attributes.add() and .get() we save the data to the Character in a specially named Attribute. Since we use a category, we are unlikely to collide with +other Attributes.

+

Our storage structure is a dict with keys after our available WieldLocation enums. Each can only have one item except WieldLocation.BACKPACK, which is a list.

+
+
+

5.2. Connecting the EquipmentHandler

+

Whenever an object leaves from one location to the next, Evennia will call a set of hooks (methods) on the object that moves, on the source-location and on its destination. This is the same for all moving things - whether it’s a character moving between rooms or an item being dropping from your hand to the ground.

+

We need to tie our new EquipmentHandler into this system. By reading the doc page on Objects, or looking at the DefaultObject.move_to docstring, we’ll find out what hooks Evennia will call. Here self is the object being moved from source_location to destination:

+
    +
  1. self.at_pre_move(destination) (abort if return False)

  2. +
  3. source_location.at_pre_object_leave(self, destination) (abort if return False)

  4. +
  5. destination.at_pre_object_receive(self, source_location) (abort if return False)

  6. +
  7. source_location.at_object_leave(self, destination)

  8. +
  9. self.announce_move_from(destination)

  10. +
  11. (move happens here)

  12. +
  13. self.announce_move_to(source_location)

  14. +
  15. destination.at_object_receive(self, source_location)

  16. +
  17. self.at_post_move(source_location)

  18. +
+

All of these hooks can be overridden to customize movement behavior. In this case we are interested in controlling how items ‘enter’ and ‘leave’ our character - being ‘inside’ the character is the same as them ‘carrying’ it. We have three good hook-candidates to use for this.

+
    +
  • .at_pre_object_receive - used to check if you can actually pick something up, or if your equipment-store is full.

  • +
  • .at_object_receive - used to add the item to the equipmenthandler

  • +
  • .at_object_leave - used to remove the item from the equipmenthandler

  • +
+

You could also picture using .at_pre_object_leave to restrict dropping (cursed?) items, but +we will skip that for this tutorial.

+
# mygame/evadventure/character.py 
+
+# ... 
+
+class EvAdventureCharacter(LivingMixin, DefaultCharacter): 
+
+    # ... 
+    
+    def at_pre_object_receive(self, moved_object, source_location, **kwargs): 
+        """Called by Evennia before object arrives 'in' this character (that is,
+        if they pick up something). If it returns False, move is aborted.
+        
+        """ 
+        return self.equipment.validate_slot_usage(moved_object)
+    
+    def at_object_receive(self, moved_object, source_location, **kwargs): 
+        """ 
+        Called by Evennia when an object arrives 'in' the character.
+        
+        """
+        self.equipment.add(moved_object)
+
+    def at_object_leave(self, moved_object, destination, **kwargs):
+        """ 
+        Called by Evennia when object leaves the Character. 
+        
+        """
+        self.equipment.remove(moved_object)
+
+
+

Above we have assumed the EquipmentHandler (.equipment) has methods .validate_slot_usage, .add and .remove. But we haven’t actually added them yet - we just put some reasonable names! Before we can use this, we need to go actually adding those methods.

+

When you do things like create/drop monster:NPC, the npc will briefly be in your inventory before being dropped on the ground. Since an NPC is not a valid thing to equip, the EquipmentHandler will complain with an EquipmentError (we define this see below). So we need to

+
+
+

5.3. Expanding the Equipmenthandler

+
+
+

5.4. .validate_slot_usage

+

Let’s start with implementing the first method we came up with above, validate_slot_usage:

+
# mygame/evadventure/equipment.py 
+
+from .enums import WieldLocation, Ability
+
+class EquipmentError(TypeError):
+    """All types of equipment-errors"""
+    pass
+
+class EquipmentHandler: 
+
+    # ... 
+    
+    @property
+    def max_slots(self):
+        """Max amount of slots, based on CON defense (CON + 10)""" 
+        return getattr(self.obj, Ability.CON.value, 1) + 10
+        
+    def count_slots(self):
+        """Count current slot usage""" 
+        slots = self.slots
+        wield_usage = sum(
+            getattr(slotobj, "size", 0) or 0
+            for slot, slotobj in slots.items()
+            if slot is not WieldLocation.BACKPACK
+        )
+        backpack_usage = sum(
+            getattr(slotobj, "size", 0) or 0 for slotobj in slots[WieldLocation.BACKPACK]
+        )
+        return wield_usage + backpack_usage
+    
+    def validate_slot_usage(self, obj):
+          """
+          Check if obj can fit in equipment, based on its size.
+          
+          """
+          if not inherits_from(obj, EvAdventureObject):
+              # in case we mix with non-evadventure objects
+              raise EquipmentError(f"{obj.key} is not something that can be equipped.")
+  
+         size = obj.size
+         max_slots = self.max_slots
+         current_slot_usage = self.count_slots()
+         return current_slot_usage + size <= max_slots
+
+
+
+ +

We add two helpers - the max_slots property and count_slots, a method that calculate the current slots being in use. Let’s figure out how they work.

+
+

5.4.1. .max_slots

+

For max_slots, remember that .obj on the handler is a back-reference to the EvAdventureCharacter we put this handler on. getattr is a Python method for retrieving a named property on an object. The Enum Ability.CON.value is the string Constitution (check out the first Utility and Enums tutorial if you don’t recall).

+

So to be clear,

+
getattr(self.obj, Ability.CON.value) + 10
+
+
+

is the same as writing

+
getattr(your_character, "Constitution") + 10 
+
+
+

which is the same as doing something like this:

+
your_character.Constitution + 10 
+
+
+

In our code we write getattr(self.obj, Ability.CON.value, 1) - that extra 1 means that if there should happen to not be a property “Constitution” on self.obj, we should not error out but just return 1.

+
+
+

5.4.2. .count_slots

+

In this helper we use two Python tools - the sum() function and a list comprehension. The former simply adds the values of any iterable together. The latter is a more efficient way to create a list:

+
new_list = [item for item in some_iterable if condition]
+all_above_5 = [num for num in range(10) if num > 5]  # [6, 7, 8, 9]
+all_below_5 = [num for num in range(10) if num < 5]  # [0, 1, 2, 3, 4]
+
+
+

To make it easier to understand, try reading the last line above as “for every number in the range 0-9, pick all with a value below 5 and make a list of them”. You can also embed such comprehensions directly in a function call like sum() without using [] around it.

+

In count_slots we have this code:

+
wield_usage = sum(
+    getattr(slotobj, "size", 0)
+    for slot, slotobj in slots.items()
+    if slot is not WieldLocation.BACKPACK
+)
+
+
+

We should be able to follow all except slots.items(). Since slots is a dict, we can use .items() to get a sequence of (key, value) pairs. We store these in slot and slotobj. So the above can be understood as “for every slot and slotobj-pair in slots, check which slot location it is. If it is not in the backpack, get its size and add it to the list. Sum over all these +sizes”.

+

A less compact but maybe more readonable way to write this would be:

+
backpack_item_sizes = [] 
+for slot, slotobj in slots.items(): 
+    if slot is not WieldLocation.BACKPACK:
+       size = getattr(slotobj, "size", 0) 
+       backpack_item_sizes.append(size)
+wield_usage = sum(backpack_item_sizes)
+
+
+

The same is done for the items actually in the BACKPACK slot. The total sizes are added +together.

+
+
+

5.4.3. Validating slots

+

With these helpers in place, validate_slot_usage now becomes simple. We use max_slots to see how much we can carry. We then get how many slots we are already using (with count_slots) and see if our new obj’s size would be too much for us.

+
+
+
+

5.5. .add and .remove

+

We will make it so .add puts something in the BACKPACK location and remove drops it, wherever it is (even if it was in your hands).

+
# mygame/evadventure/equipment.py 
+
+from .enums import WieldLocation, Ability
+
+# ... 
+
+class EquipmentHandler: 
+
+    # ... 
+     
+    def add(self, obj):
+        """
+        Put something in the backpack.
+        """
+        if self.validate_slot_usage(obj):
+	        self.slots[WieldLocation.BACKPACK].append(obj)
+	        self._save()
+
+ def remove(self, obj_or_slot):
+        """
+        Remove specific object or objects from a slot.
+
+        Returns a list of 0, 1 or more objects removed from inventory.
+        """
+        slots = self.slots
+        ret = []
+        if isinstance(obj_or_slot, WieldLocation):
+            # a slot; if this fails, obj_or_slot must be obj
+            if obj_or_slot is WieldLocation.BACKPACK:
+                # empty entire backpack
+                ret.extend(slots[obj_or_slot])
+                slots[obj_or_slot] = []
+            else:
+                ret.append(slots[obj_or_slot])
+                slots[obj_or_slot] = None
+        elif obj_or_slot in self.slots.values():
+            # obj in use/wear slot
+            for slot, objslot in slots.items():
+                if objslot is obj_or_slot:
+                    slots[slot] = None
+                    ret.append(objslot)
+        elif obj_or_slot in slots[WieldLocation.BACKPACK]:             # obj in backpack slot
+            try:
+                slots[WieldLocation.BACKPACK].remove(obj_or_slot)
+                ret.append(obj_or_slot)
+            except ValueError:
+                pass
+        if ret:
+            self._save()
+        return ret
+
+
+

In .add, we make use of validate_slot_usage to +double-check we can actually fit the thing, then we add the item to the backpack.

+

In .remove, we allow emptying both by WieldLocation or by explicitly saying which object to remove. Note that the first if statement checks if obj_or_slot is a slot. So if that fails then code in the other elif can safely assume that it must instead be an object!

+

Any removed objects are returned. If we gave BACKPACK as the slot, we empty the backpack and return all items inside it.

+

Whenever we change the equipment loadout we must make sure to ._save() the result, or it will be lost after a server reload.

+
+
+

5.6. Moving things around

+

With the help of .remove() and .add() we can get things in and out of the BACKPACK equipment location. We also need to grab stuff from the backpack and wield or wear it. We add a .move method on the EquipmentHandler to do this:

+
# mygame/evadventure/equipment.py 
+
+from .enums import WieldLocation, Ability
+
+# ... 
+
+class EquipmentHandler: 
+
+    # ... 
+    
+    def move(self, obj): 
+         """Move object from backpack to its intended `inventory_use_slot`.""" 
+         
+        # make sure to remove from equipment/backpack first, to avoid double-adding
+        self.remove(obj) 
+        if not self.validate_slot_usage(obj):
+            return
+
+        slots = self.slots
+        use_slot = getattr(obj, "inventory_use_slot", WieldLocation.BACKPACK)
+
+        to_backpack = []
+        if use_slot is WieldLocation.TWO_HANDS:
+            # two-handed weapons can't co-exist with weapon/shield-hand used items
+            to_backpack = [slots[WieldLocation.WEAPON_HAND], slots[WieldLocation.SHIELD_HAND]]
+            slots[WieldLocation.WEAPON_HAND] = slots[WieldLocation.SHIELD_HAND] = None
+            slots[use_slot] = obj
+        elif use_slot in (WieldLocation.WEAPON_HAND, WieldLocation.SHIELD_HAND):
+            # can't keep a two-handed weapon if adding a one-handed weapon or shield
+            to_backpack = [slots[WieldLocation.TWO_HANDS]]
+            slots[WieldLocation.TWO_HANDS] = None
+            slots[use_slot] = obj
+        elif use_slot is WieldLocation.BACKPACK:
+            # it belongs in backpack, so goes back to it
+            to_backpack = [obj]
+        else:
+            # for others (body, head), just replace whatever's there
+            replaced = [obj]
+            slots[use_slot] = obj
+       
+        for to_backpack_obj in to_backpack:
+            # put stuff in backpack
+            slots[use_slot].append(to_backpack_obj)
+       
+        # store new state
+        self._save() 
+
+
+

Here we remember that every EvAdventureObject has an inventory_use_slot property that tells us where it goes. So we just need to move the object to that slot, replacing whatever is in that place from before. Anything we replace goes back to the backpack.

+
+
+

5.7. Get everything

+

In order to visualize our inventory, we need some method to get everything we are carrying.

+
# mygame/evadventure/equipment.py 
+
+from .enums import WieldLocation, Ability
+
+# ... 
+
+class EquipmentHandler: 
+
+    # ... 
+
+    def all(self):
+        """
+        Get all objects in inventory, regardless of location.
+        """
+        slots = self.slots
+        lst = [
+            (slots[WieldLocation.WEAPON_HAND], WieldLocation.WEAPON_HAND),
+            (slots[WieldLocation.SHIELD_HAND], WieldLocation.SHIELD_HAND),
+            (slots[WieldLocation.TWO_HANDS], WieldLocation.TWO_HANDS),
+            (slots[WieldLocation.BODY], WieldLocation.BODY),
+            (slots[WieldLocation.HEAD], WieldLocation.HEAD),
+        ] + [(item, WieldLocation.BACKPACK) for item in slots[WieldLocation.BACKPACK]]
+        return lst
+
+
+

Here we get all the equipment locations and add their contents together into a list of tuples +[(item, WieldLocation), ...]. This is convenient for display.

+
+
+

5.8. Weapon and armor

+

It’s convenient to have the EquipmentHandler easily tell you what weapon is currently wielded and what armor level all worn equipment provides. Otherwise you’d need to figure out what item is in which wield-slot and to add up armor slots manually every time you need to know.

+
# mygame/evadventure/equipment.py 
+
+from .enums import WieldLocation, Ability
+from .objects import get_bare_hand
+
+# ... 
+
+class EquipmentHandler: 
+
+    # ... 
+    
+    @property
+    def armor(self):
+        slots = self.slots
+        return sum(
+            (
+                # armor is listed using its defense, so we remove 10 from it
+                # (11 is base no-armor value in Knave)
+                getattr(slots[WieldLocation.BODY], "armor", 1),
+                # shields and helmets are listed by their bonus to armor
+                getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0),
+                getattr(slots[WieldLocation.HEAD], "armor", 0),
+            )
+        )
+
+    @property
+    def weapon(self):
+        # first checks two-handed wield, then one-handed; the two
+        # should never appear simultaneously anyhow (checked in `move` method).
+        slots = self.slots
+        weapon = slots[WieldLocation.TWO_HANDS]
+        if not weapon:
+            weapon = slots[WieldLocation.WEAPON_HAND]
+        # if we still don't have a weapon, we return None here
+        if not weapon:
+ ~          weapon = get_bare_hands()
+        return weapon
+
+
+
+

In the .armor() method we get the item (if any) out of each relevant wield-slot (body, shield, head), and grab their armor Attribute. We then sum() them all up.

+

In .weapon(), we simply check which of the possible weapon slots (weapon-hand or two-hands) have something in them. If not we fall back to the ‘Bare Hands’ object we created in the Object tutorial lesson earlier.

+
+

5.8.1. Fixing the Character class

+

So we have added our equipment handler which validate what we put in it. This will however lead to a problem when we create things like NPCs in game, e.g. with

+
create/drop monster:evadventure.npcs.EvAdventureNPC
+
+
+

The problem is that when the/ monster is created it will briefly appear in your inventory before being dropped, so this code will fire on you when you do that (assuming you are an EvAdventureCharacter):

+
# mygame/evadventure/characters.py
+# ... 
+
+class EvAdventureCharacter(LivingMixin, DefaultCharacter): 
+
+    # ... 
+
+    def at_object_receive(self, moved_object, source_location, **kwargs): 
+        """ 
+        Called by Evennia when an object arrives 'in' the character.
+        
+        """
+        self.equipment.add(moved_object)
+
+
+

At this means that the equipmenthandler will check the NPC, and since it’s not a equippable thing, an EquipmentError will be raised, failing the creation. Since we want to be able to create npcs etc easily, we will handle this error with a try...except statement like so:

+
# mygame/evadventure/characters.py
+# ... 
+from evennia import logger 
+from .equipment import EquipmentError
+
+class EvAdventureCharacter(LivingMixin, DefaultCharacter): 
+
+    # ... 
+
+    def at_object_receive(self, moved_object, source_location, **kwargs): 
+        """ 
+        Called by Evennia when an object arrives 'in' the character.
+        
+        """
+        try:
+            self.equipment.add(moved_object)
+        except EquipmentError:
+            logger.log_trace()
+            
+
+
+

Using Evennia’s logger.log_trace() we catch the error and direct it to the server log. This allows you to see if there are real errors here as well, but once things work and these errors are spammy, you can also just replace the logger.log_trace() line with a pass to hide these errors.

+
+
+
+

5.9. Extra credits

+

This covers the basic functionality of the equipment handler. There are other useful methods that +can be added:

+
    +
  • Given an item, figure out which equipment slot it is currently in

  • +
  • Make a string representing the current loadout

  • +
  • Get everything in the backpack (only)

  • +
  • Get all wieldable items (weapons, shields) from backpack

  • +
  • Get all usable items (items with a use-location of BACKPACK) from the backpack

  • +
+

Experiment with adding those. A full example is found in +evennia/contrib/tutorials/evadventure/equipment.py.

+
+
+

5.10. Unit Testing

+
+

Create a new module mygame/evadventure/tests/test_equipment.py.

+
+ +

To test the EquipmentHandler, easiest is create an EvAdventureCharacter (this should by now +have EquipmentHandler available on itself as .equipment) and a few test objects; then test +passing these into the handler’s methods.

+
# mygame/evadventure/tests/test_equipment.py 
+
+from evennia.utils import create 
+from evennia.utils.test_resources import BaseEvenniaTest 
+
+from ..objects import EvAdventureObject, EvAdventureHelmet, EvAdventureWeapon
+from ..enums import WieldLocation
+from ..characters import EvAdventureCharacter
+
+class TestEquipment(BaseEvenniaTest): 
+    
+    def setUp(self): 
+        self.character = create.create_object(EvAdventureCharacter, key='testchar')
+        self.helmet = create.create_object(EvAdventureHelmet, key="helmet") 
+        self.weapon = create.create_object(EvAdventureWeapon, key="weapon") 
+         
+    def test_add_remove): 
+        self.character.equipment.add(self.helmet)
+        self.assertEqual(
+            self.character.equipment.slots[WieldLocation.BACKPACK],
+            [self.helmet]
+        )
+        self.character.equipment.remove(self.helmet)
+        self.assertEqual(self.character.equipment.slots[WieldLocation.BACKPACK], []) 
+        
+    # ... 
+
+
+
+
+

5.11. Summary

+

Handlers are useful for grouping functionality together. Now that we spent our time making the EquipmentHandler, we shouldn’t need to worry about item-slots anymore - the handler ‘handles’ all the details for us. As long as we call its methods, the details can be forgotten about.

+

We also learned to use hooks to tie Knave’s custom equipment handling into Evennia.

+

With Characters, Objects and now Equipment in place, we should be able to move on to character generation - where players get to make their own character!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.html new file mode 100644 index 0000000000..07cfcfa55c --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.html @@ -0,0 +1,346 @@ + + + + + + + + + 8. Non-Player-Characters — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

8. Non-Player-Characters

+ +

Non-Player-Characters, or NPCs, is the common term for all active agents that are not controlled by players. NPCs could be anything from merchants and quest givers, to monsters and bosses. They could also be ‘flavor’ - townsfolk doing their chores, farmers tending their fields - there to make the world feel “more alive”.

+

In this lesson we will create the base class of EvAdventure NPCs based on the Knave ruleset. According to the Knave rules, NPCs have some simplified stats compared to the PC characters we designed earlier.

+
+
+

8.1. The NPC base class

+ +
+

Create a new module evadventure/npcs.py.

+
+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
# in evadventure/npcs.py 
+
+from evennia import DefaultCharacter, AttributeProperty
+
+from .characters import LivingMixin
+from .enums import Ability
+
+
+class EvAdventureNPC(LivingMixin, DefaultCharacter): 
+	"""Base class for NPCs""" 
+
+    is_pc = False
+    hit_dice = AttributeProperty(default=1, autocreate=False)
+    armor = AttributeProperty(default=1, autocreate=False)  # +10 to get armor defense
+    hp_multiplier = AttributeProperty(default=4, autocreate=False)  # 4 default in Knave
+    hp = AttributeProperty(default=None, autocreate=False)  # internal tracking, use .hp property
+    morale = AttributeProperty(default=9, autocreate=False)
+    allegiance = AttributeProperty(default=Ability.ALLEGIANCE_HOSTILE, autocreate=False)
+
+    weapon = AttributeProperty(default=BARE_HANDS, autocreate=False)  # instead of inventory
+    coins = AttributeProperty(default=1, autocreate=False)  # coin loot
+ 
+    is_idle = AttributeProperty(default=False, autocreate=False)
+    
+    @property
+    def strength(self):
+        return self.hit_dice
+        
+    @property
+    def dexterity(self):
+        return self.hit_dice
+ 
+    @property
+    def constitution(self):
+        return self.hit_dice
+ 
+    @property
+    def intelligence(self):
+        return self.hit_dice
+ 
+    @property
+    def wisdom(self):
+        return self.hit_dice
+ 
+    @property
+    def charisma(self):
+        return self.hit_dice
+ 
+    @property
+    def hp_max(self):
+        return self.hit_dice * self.hp_multiplier
+    
+    def at_object_creation(self):
+         """
+         Start with max health.
+  
+         """
+         self.hp = self.hp_max
+         self.tags.add("npcs", category="group")
+                                                                                   
+     def ai_next_action(self, **kwargs):                     
+         """                                                        
+		 The system should regularly poll this method to have 
+		 the NPC do their next AI action. 
+                                                                    
+         """                                                        
+         pass                           
+
+
+
    +
  • Line 9: By use of multiple inheritance we use the LinvingMixin we created in the Character lesson. This includes a lot of useful methods, such as showing our ‘hurt level’, methods to use to heal, hooks to call when getting attacked, hurt and so on. We can re-use all of those in upcoming NPC subclasses.

  • +
  • Line 12: The is_pc is a quick and convenient way to check if this is, well, a PC or not. We will use it in the upcoming Combat base lesson.

  • +
  • Line 13: The NPC is simplified in that all stats are just based on the Hit dice number (see Lines 25-51). We store armor and a weapon as direct Attributes on the class rather than bother implementing a full equipment system.

  • +
  • Lines 17, 18: The morale and allegiance are Knave properties determining how likely the NPC is to flee in a combat situation and if they are hostile or friendly.

  • +
  • Line 19: The is_idle Attribute is a useful property. It should be available on all NPCs and will be used to disable AI entirely.

  • +
  • Line 59: We make sure to tag NPCs. We may want to group different NPCs together later, for example to have all NPCs with the same tag respond if one of them is attacked.

  • +
  • Line 61: The ai_next_action is a method we prepare for the system to be able to ask the NPC ‘what do you want to do next?’. In it we will add all logic related to the artificial intelligence of the NPC - such as walking around, attacking and performing other actions.

  • +
+
+
+

8.2. Testing

+
+

Create a new module evadventure/tests/test_npcs.py

+
+

Not so much to test yet, but we will be using the same module to test other aspects of NPCs in the future, so let’s create it now.

+
# in evadventure/tests/test_npcs.py
+
+from evennia import create_object                                           
+from evennia.utils.test_resources import EvenniaTest                        
+                                                                            
+from .. import npcs                                                         
+                                                                            
+class TestNPCBase(EvenniaTest):                                             
+	"""Test the NPC base class""" 
+	
+    def test_npc_base(self):
+        npc = create_object(
+            npcs.EvAdventureNPC,
+            key="TestNPC",
+            attributes=[("hit_dice", 4)],  # set hit_dice to 4
+        )
+        
+        self.assertEqual(npc.hp_multiplier, 4)
+        self.assertEqual(npc.hp, 16)
+        self.assertEqual(npc.strength, 4)
+        self.assertEqual(npc.charisma, 4)
+
+
+
+
+
+

Nothing special here. Note how the create_object helper function takes attributes as a keyword. This is a list of tuples we use to set different values than the default ones to Attributes. We then check a few of the properties to make sure they return what we expect.

+
+
+

8.3. Conclusions

+

In Knave, an NPC is a simplified version of a Player Character. In other games and rule systems, they may be all but identical.

+

With the NPC class in place, we have enough to create a ‘test dummy’. Since it has no AI yet, it won’t fight back, but it will be enough to have something to hit when we test our combat in the upcoming lessons.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.html new file mode 100644 index 0000000000..008e0d81b0 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.html @@ -0,0 +1,602 @@ + + + + + + + + + 4. In-game Objects and items — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

4. In-game Objects and items

+

In the previous lesson we established what a ‘Character’ is in our game. Before we continue +we also need to have a notion what an ‘item’ or ‘object’ is.

+

Looking at Knave’s item lists, we can get some ideas of what we need to track:

+
    +
  • size - this is how many ‘slots’ the item uses in the character’s inventory.

  • +
  • value - a base value if we want to sell or buy the item.

  • +
  • inventory_use_slot - some items can be worn or wielded. For example, a helmet needs to be worn on the head and a shield in the shield hand. Some items can’t be used this way at all, but only belong in the backpack.

  • +
  • obj_type - Which ‘type’ of item this is.

  • +
+
+

4.1. New Enums

+

We added a few enumberations for Abilities back in the Utilities tutorial. +Before we continue, let’s expand with enums for use-slots and object types.

+
# mygame/evadventure/enums.py
+
+# ...
+
+class WieldLocation(Enum):
+    
+    BACKPACK = "backpack"
+    WEAPON_HAND = "weapon_hand"
+    SHIELD_HAND = "shield_hand"
+    TWO_HANDS = "two_handed_weapons"
+    BODY = "body"  # armor
+    HEAD = "head"  # helmets
+
+class ObjType(Enum):
+    
+    WEAPON = "weapon"
+    ARMOR = "armor"
+    SHIELD = "shield"
+    HELMET = "helmet"
+    CONSUMABLE = "consumable"
+    GEAR = "gear"
+    MAGIC = "magic"
+    QUEST = "quest"
+    TREASURE = "treasure"
+
+
+

Once we have these enums, we will use them for referencing things.

+
+
+

4.2. The base object

+
+

Create a new module mygame/evadventure/objects.py

+
+ +
+

We will make a base EvAdventureObject class off Evennia’s standard DefaultObject. We will then add child classes to represent the relevant types:

+
# mygame/evadventure/objects.py
+
+from evennia import AttributeProperty, DefaultObject 
+from evennia.utils.utils import make_iter
+from .utils import get_obj_stats 
+from .enums import WieldLocation, ObjType
+
+
+class EvAdventureObject(DefaultObject): 
+    """ 
+    Base for all evadventure objects. 
+    
+    """ 
+    inventory_use_slot = WieldLocation.BACKPACK
+    size = AttributeProperty(1, autocreate=False)
+    value = AttributeProperty(0, autocreate=False)
+    
+    # this can be either a single type or a list of types (for objects able to be 
+    # act as multiple). This is used to tag this object during creation.
+    obj_type = ObjType.GEAR
+
+    # default evennia hooks
+
+    def at_object_creation(self): 
+        """Called when this object is first created. We convert the .obj_type 
+        property to a database tag."""
+        
+        for obj_type in make_iter(self.obj_type):
+            self.tags.add(self.obj_type.value, category="obj_type")
+
+    def get_display_header(self, looker, **kwargs):
+	    """The top of the description""" 
+	    return "" 
+
+	def get_display_desc(self, looker, **kwargs)
+		"""The main display - show object stats""" 
+		return get_obj_stats(self, owner=looker)
+
+    # custom evadventure methods
+
+	def has_obj_type(self, objtype): 
+		"""Check if object is of a certain type""" 
+		return objtype.value in make_iter(self.obj_type)
+
+    def at_pre_use(self, *args, **kwargs): 
+        """Called before use. If returning False, can't be used""" 
+        return True 
+
+	def use(self, *args, **kwargs): 
+		"""Use this object, whatever that means""" 
+		pass 
+
+    def post_use(self, *args, **kwargs): 
+	    """Always called after use.""" 
+	    pass
+
+    def get_help(self):
+        """Get any help text for this item"""
+        return "No help for this item"
+
+
+
+

4.2.1. Using Attributes or not

+

In theory, size and value does not change and could also be just set as a regular Python +property on the class:

+
class EvAdventureObject(DefaultObject):
+    inventory_use_slot = WieldLocation.BACKPACK 
+    size = 1 
+    value = 0 
+
+
+

The problem with this is that if we want to make a new object of size 3 and value 20, we have to make a new class for it. We can’t change it on the fly because the change would only be in memory and be lost on next server reload.

+

Because we use AttributeProperties, we can set size and value to whatever we like when we create the object (or later), and the Attributes will remember our changes to that object indefinitely.

+

To make this a little more efficient, we use autocreate=False. Normally when you create a new object with defined AttributeProperties, a matching Attribute is immediately created at the same time. So normally, the object would be created along with two Attributes size and value. With autocreate=False, no Attribute will be created unless the default is changed. That is, as long as your object has size=1 no database Attribute will be created at all. This saves time and resources when creating large number of objects.

+

The drawback is that since no Attribute is created you can’t refer to it with obj.db.size or obj.attributes.get("size") unless you change its default. You also can’t query the database for all objects with size=1, since most objects would not yet have an in-database +size Attribute to search for.

+

In our case, we’ll only refer to these properties as obj.size etc, and have no need to find +all objects of a particular size. So we should be safe.

+
+
+

4.2.2. Creating tags in at_object_creation

+

The at_object_creation is a method Evennia calls on every child of DefaultObject whenever it is first created.

+

We do a tricky thing here, converting our .obj_type to one or more Tags. Tagging the object like this means you can later efficiently find all objects of a given type (or combination of +types) with Evennia’s search functions:

+
    from .enums import ObjType 
+    from evennia.utils import search 
+    
+    # get all shields in the game
+    all_shields = search.search_object_by_tag(ObjType.SHIELD.value, category="obj_type")
+
+
+

We allow .obj_type to be given as a single value or a list of values. We use make_iter from the evennia utility library to make sure we don’t balk at either. This means you could have a Shield that is also Magical, for example.

+
+
+
+

4.3. Other object types

+

Some of the other object types are very simple so far.

+
# mygame/evadventure/objects.py 
+
+from evennia import AttributeProperty, DefaultObject
+from .enums import ObjType 
+
+class EvAdventureObject(DefaultObject): 
+    # ... 
+    
+    
+class EvAdventureQuestObject(EvAdventureObject):
+    """Quest objects should usually not be possible to sell or trade."""
+    obj_type = ObjType.QUEST
+ 
+class EvAdventureTreasure(EvAdventureObject):
+    """Treasure is usually just for selling for coin"""
+    obj_type = ObjType.TREASURE
+    value = AttributeProperty(100, autocreate=False)
+    
+
+
+
+
+

4.4. Consumables

+

A ‘consumable’ is an item that has a certain number of ‘uses’. Once fully consumed, it can’t be used anymore. An example would be a health potion.

+
# mygame/evadventure/objects.py 
+
+# ... 
+
+class EvAdventureConsumable(EvAdventureObject): 
+    """An item that can be used up""" 
+    
+    obj_type = ObjType.CONSUMABLE
+    value = AttributeProperty(0.25, autocreate=False)
+    uses = AttributeProperty(1, autocreate=False)
+    
+    def at_pre_use(self, user, target=None, *args, **kwargs):
+        """Called before using. If returning False, abort use."""
+		if target and user.location != target.location:
+			user.msg("You are not close enough to the target!")
+		    return False
+		
+		if self.uses <= 0:
+		    user.msg(f"|w{self.key} is used up.|n")
+		    return False
+
+    def use(self, user, *args, **kwargs):
+        """Called when using the item""" 
+        pass
+    
+    def at_post_use(self. user, *args, **kwargs):
+        """Called after using the item""" 
+        # detract a usage, deleting the item if used up.
+        self.uses -= 1
+        if self.uses <= 0: 
+            user.msg(f"{self.key} was used up.")
+            self.delete()
+
+
+

In at_pre_use we check if we have specified a target (heal someone else or throw a fire bomb at an enemy?), making sure we are in the same location. We also make sure we have usages left. In at_post_use we make sure to tick off usages.

+

What exactly each consumable does will vary - we will need to implement children of this class later, overriding at_use with different effects.

+
+
+

4.5. Weapons

+

All weapons need properties that describe how efficient they are in battle. To ‘use’ a weapon means to attack with it, so we can let the weapon itself handle all logic around performing an attack. Having the attack code on the weapon also means that if we in the future wanted a weapon doing something special on-attack (for example, a vampiric sword that heals the attacker when hurting the enemy), we could easily add that on the weapon subclass in question without modifying other code.

+
# mygame/evadventure/objects.py 
+
+from .enums import WieldLocation, ObjType, Ability
+
+# ... 
+
+class EvAdventureWeapon(EvAdventureObject): 
+    """Base class for all weapons"""
+
+    obj_type = ObjType.WEAPON 
+    inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND, autocreate=False)
+    quality = AttributeProperty(3, autocreate=False)
+    
+    attack_type = AttributeProperty(Ability.STR, autocreate=False)
+    defend_type = AttributeProperty(Ability.ARMOR, autocreate=False)
+    
+    damage_roll = AttributeProperty("1d6", autocreate=False)
+
+
+def at_pre_use(self, user, target=None, *args, **kwargs):
+       if target and user.location != target.location:
+           # we assume weapons can only be used in the same location
+           user.msg("You are not close enough to the target!")
+           return False
+
+       if self.quality is not None and self.quality <= 0:
+           user.msg(f"{self.get_display_name(user)} is broken and can't be used!")
+           return False
+       return super().at_pre_use(user, target=target, *args, **kwargs)
+
+   def use(self, attacker, target, *args, advantage=False, disadvantage=False, **kwargs):
+       """When a weapon is used, it attacks an opponent"""
+
+       location = attacker.location
+
+       is_hit, quality, txt = rules.dice.opposed_saving_throw(
+           attacker,
+           target,
+           attack_type=self.attack_type,
+           defense_type=self.defense_type,
+           advantage=advantage,
+           disadvantage=disadvantage,
+       )
+       location.msg_contents(
+           f"$You() $conj(attack) $You({target.key}) with {self.key}: {txt}",
+           from_obj=attacker,
+           mapping={target.key: target},
+       )
+       if is_hit:
+           # enemy hit, calculate damage
+           dmg = rules.dice.roll(self.damage_roll)
+
+           if quality is Ability.CRITICAL_SUCCESS:
+               # doble damage roll for critical success
+               dmg += rules.dice.roll(self.damage_roll)
+               message = (
+                   f" $You() |ycritically|n $conj(hit) $You({target.key}) for |r{dmg}|n damage!"
+               )
+           else:
+               message = f" $You() $conj(hit) $You({target.key}) for |r{dmg}|n damage!"
+
+           location.msg_contents(message, from_obj=attacker, mapping={target.key: target})
+           # call hook
+           target.at_damage(dmg, attacker=attacker)
+
+       else:
+           # a miss
+           message = f" $You() $conj(miss) $You({target.key})."
+           if quality is Ability.CRITICAL_FAILURE:
+               message += ".. it's a |rcritical miss!|n, damaging the weapon."
+			   if self.quality is not None:
+                   self.quality -= 1
+               location.msg_contents(message, from_obj=attacker, mapping={target.key: target})
+
+   def at_post_use(self, user, *args, **kwargs):
+       if self.quality is not None and self.quality <= 0:
+           user.msg(f"|r{self.get_display_name(user)} breaks and can no longer be used!")
+
+
+

In EvAdventure, we will assume all weapons (including bows etc) are used in the same location as the target. Weapons also have a quality attribute that gets worn down if the user rolls a critical failure. Once quality is down to 0, the weapon is broken and needs to be repaired.

+

The quality is something we need to track in Knave. When getting critical failures on attacks, a weapon’s quality will go down. When it reaches 0, it will break. We assume that a quality of None means that quality doesn’t apply (that is, the item is unbreakable), so we must consider that when checking.

+

The attack/defend type tracks how we resolve attacks with the weapon, like roll + STR vs ARMOR + 10.

+

In the use method we make use of the rules module we created earlier to perform all the dice rolls needed to resolve the attack.

+

This code requires some additional explanation:

+
location.msg_contents(
+    f"$You() $conj(attack) $you({target.key}) with {self.key}: {txt}",
+    from_obj=attacker,
+    mapping={target.key: target},
+)
+
+
+

location.msg_contents sends a message to everyone in location. Since people will usually notice if you swing a sword at somone, this makes sense to tell people about. This message should however look different depending on who sees it.

+

I should see:

+
You attack Grendel with sword: <dice roll results> 
+
+
+

Others should see

+
Beowulf attacks Grendel with sword: <dice roll results>  
+
+
+

And Grendel should see

+
Beowulf attacks you with sword: <dice roll results>
+
+
+

We provide the following string to msg_contents:

+
f"$You() $conj(attack) $You({target.key}) with {self.key}: {txt}"
+
+
+

The {...} are normal f-string formatting markers like those we have used before. The $func(...) bits are Evennnia FuncParser function calls. FuncParser calls are executed as functions and the result replaces their position in the string. As this string is parsed by Evennia, this is what happens:

+

First the f-string markers are replaced, so that we get this:

+
"$You() $cobj(attack) $you(Grendel) with sword: \n rolled 8 on d20 ..."
+
+
+

Next the funcparser functions are run:

+
    +
  • $You() becomes the name or You depending on if the string is to be sent to that object or not. It uses the from_obj= kwarg to the msg_contents method to know this. Since msg_contents=attacker , this becomes You or Beowulf in this example.

  • +
  • $you(Grendel) looks for the mapping= kwarg to msg_contents to determine who should be addressed here. If will replace this with the display name or the lowercase you. We have added mapping={target.key: target} - that is {"Grendel": <grendel_obj>}. So this will become you or Grendel depending on who sees the string.

  • +
  • $conj(attack) conjugates the verb depending on who sees it. The result will be You attack ... or Beowulf attacks (note the extra s).

  • +
+

A few funcparser calls compacts all these points of view into one string!

+
+
+

4.6. Magic

+

In Knave, anyone can use magic if they are wielding a rune stone (our name for spell books) in both hands. You can only use a rune stone once per rest. So a rune stone is an example of a ‘magical weapon’ that is also a ‘consumable’ of sorts.

+
# mygame/evadventure/objects.py 
+
+# ... 
+class EvAdventureConsumable(EvAdventureObject): 
+    # ... 
+
+class EvAdventureWeapon(EvAdventureObject): 
+    # ... 
+
+class EvAdventureRuneStone(EvAdventureWeapon, EvAdventureConsumable): 
+    """Base for all magical rune stones"""
+    
+    obj_type = (ObjType.WEAPON, ObjType.MAGIC)
+    inventory_use_slot = WieldLocation.TWO_HANDS  # always two hands for magic
+    quality = AttributeProperty(3, autocreate=False)
+
+    attack_type = AttributeProperty(Ability.INT, autocreate=False)
+    defend_type = AttributeProperty(Ability.DEX, autocreate=False)
+    
+    damage_roll = AttributeProperty("1d8", autocreate=False)
+
+    def at_post_use(self, user, *args, **kwargs):
+        """Called after usage/spell was cast""" 
+        self.uses -= 1 
+        # we don't delete the rune stone here, but 
+        # it must be reset on next rest.
+        
+    def refresh(self):
+        """Refresh the rune stone (normally after rest)"""
+        self.uses = 1
+
+
+

We make the rune stone a mix of weapon and consumable. Note that we don’t have to add .uses again, it’s inherited from EvAdventureConsumable parent. The at_pre_use and use methods are also inherited; we only override at_post_use since we don’t want the runestone to be deleted when it runs out of uses.

+

We add a little convenience method refresh - we should call this when the character rests, to make the runestone active again.

+

Exactly what rune stones do will be implemented in the at_use methods of subclasses to this base class. Since magic in Knave tends to be pretty custom, it makes sense that it will lead to a lot of custom code.

+
+
+

4.7. Armor

+

Armor, shields and helmets increase the ARMOR stat of the character. In Knave, what is stored is the defense value of the armor (values 11-20). We will instead store the ‘armor bonus’ (1-10). As we know, defending is always bonus + 10, so the result will be the same - this means we can use Ability.ARMOR as any other defensive ability without worrying about a special case.

+

``

+
# mygame/evadventure/objects.py 
+
+# ... 
+
+class EvAdventureAmor(EvAdventureObject): 
+    obj_type = ObjType.ARMOR
+    inventory_use_slot = WieldLocation.BODY 
+
+    armor = AttributeProperty(1, autocreate=False)
+    quality = AttributeProperty(3, autocreate=False)
+
+
+class EvAdventureShield(EvAdventureArmor):
+    obj_type = ObjType.SHIELD
+    inventory_use_slot = WieldLocation.SHIELD_HAND 
+
+
+class EvAdventureHelmet(EvAdventureArmor): 
+    obj_type = ObjType.HELMET
+    inventory_use_slot = WieldLocation.HEAD
+
+
+
+
+

4.8. Your Bare hands

+

When we don’t have any weapons, we’ll be using our bare fists to fight.

+

We will use this in the upcoming Equipment tutorial lesson to represent when you have ‘nothing’ in your hands. This way we don’t need to add any special case for this.

+
# mygame/evadventure/objects.py
+
+from evennia import search_object, create_object
+
+_BARE_HANDS = None 
+
+# ... 
+
+class WeaponBareHands(EvAdventureWeapon)
+     obj_type = ObjType.WEAPON
+     inventory_use_slot = WieldLocation.WEAPON_HAND
+     attack_type = Ability.STR
+     defense_type = Ability.ARMOR
+     damage_roll = "1d4"
+     quality = None  # let's assume fists are indestructible ...
+
+
+def get_bare_hands(): 
+    """Get the bare hands""" 
+    global _BARE_HANDS
+    if not _BARE_HANDS: 
+        _BARE_HANDS = search_object("Bare hands", typeclass=WeaponBareHands).first()
+    if not _BARE_HANDS:
+    	_BARE_HANDS = create_object(WeaponBareHands, key="Bare hands")
+    return _BARE_HANDS
+
+
+ +

Since everyone’s empty hands are the same (in our game), we create one Bare hands weapon object that everyone shares. We do this by searching for the object with search_object (the .first() means we grab the first one even if we should by accident have created multiple hands, see The Django querying tutorial for more info). If we find none, we create it.

+

By use of the global Python keyword, we cache the bare hands object get/create in a module level property _BARE_HANDS. So this acts as a cache to not have to search the database more than necessary.

+

From now on, other modules can just import and run this function to get the bare hands.

+
+
+

4.9. Testing and Extra credits

+

Remember the get_obj_stats function from the Utility Tutorial earlier? We had to use dummy-values since we didn’t yet know how we would store properties on Objects in the game.

+

Well, we just figured out all we need! You can go back and update get_obj_stats to properly read the data from the object it receives.

+

When you change this function you must also update the related unit test - so your existing test becomes a nice way to test your new Objects as well! Add more tests showing the output of feeding different object-types to get_obj_stats.

+

Try it out yourself. If you need help, a finished utility example is found in evennia/contrib/tutorials/evadventure/utils.py.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Part3-Overview.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Part3-Overview.html new file mode 100644 index 0000000000..0c1ea5ce2d --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Part3-Overview.html @@ -0,0 +1,301 @@ + + + + + + + + + Part 3: How We Get There (Example Game) — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Part 3: How We Get There (Example Game)

+
+

Warning

+

The tutorial game is under development and is not yet complete, nor tested. Use the existing lessons as inspiration and to help get you going, but don’t expect out-of-the-box perfection from it at this time.

+
+ +

In part three of the Evennia Beginner tutorial we will go through the actual creation of +our tutorial game EvAdventure, based on the Knave RPG ruleset.

+

If you followed the previous parts of this tutorial series you will have some notions about Python and where to find and make use of things in Evennia. We also have a good idea of the type of game we will create.

+

Even if this is not the game-style you are interested in, following along will give you a lot +of experience using Evennia and be really helpful for doing your own thing later! The EvAdventure game code is also built to easily be expanded upon.

+

Fully coded examples of all code we make in this part can be found in the +evennia/contrib/tutorials/evadventure package. There are three common ways to learn from this:

+
    +
  1. Follow the tutorial lessons in sequence and use it to write your own code, referring to the ready-made code as extra help, context, or as a ‘facit’ to check yourself.

  2. +
  3. Read through the code in the package and refer to the tutorial lesson for each part for more information on what you see.

  4. +
  5. Some mix of the two.

  6. +
+

Which approach you choose is individual - we all learn in different ways.

+

Either way, this is a big part. You’ll be seeing a lot of code and there are plenty of lessons to go through. We are making a whole game from scratch after all. Take your time!

+
+

Lessons

+
+ +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.html new file mode 100644 index 0000000000..61f3a0345b --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.html @@ -0,0 +1,141 @@ + + + + + + + + + 14. Game Quests — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

14. Game Quests

+
+

Warning

+

This part of the Beginner tutorial is still being developed.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Rooms.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Rooms.html new file mode 100644 index 0000000000..eab7f9f9c7 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Rooms.html @@ -0,0 +1,492 @@ + + + + + + + + + 7. In-game Rooms — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

7. In-game Rooms

+

A room describes a specific location in the game world. Being an abstract concept, it can represent any area of game content that is convenient to group together. In this lesson we will also create a small in-game automap.

+

In EvAdventure, we will have two main types of rooms:

+
    +
  • Normal, above-ground rooms. Based on a fixed map, these will be created once and then don’t change. We’ll cover them in this lesson.

  • +
  • Dungeon rooms - these will be examples of procedurally generated rooms, created on the fly as the players explore the underworld. Being subclasses of the normal room, we’ll get to them in the Dungeon generation lesson.

  • +
+
+

7.1. The base room

+
+

Create a new module evadventure/rooms.py.

+
+
# in evadventure/rooms.py
+
+from evennia import AttributeProperty, DefaultRoom
+
+class EvAdventureRoom(DefaultRoom):
+	"""
+    Simple room supporting some EvAdventure-specifics.
+ 
+    """
+ 
+    allow_combat = AttributeProperty(False, autocreate=False)
+    allow_pvp = AttributeProperty(False, autocreate=False)
+    allow_death = AttributeProperty(False, autocreate=False)
+
+
+
+

Our EvadventureRoom is very simple. We use Evennia’s DefaultRoom as a base and just add three additional Attributes that defines

+
    +
  • If combat is allowed to start in the room at all.

  • +
  • If combat is allowed, if PvP (player vs player) combat is allowed.

  • +
  • If combat is allowed, if any side is allowed to die from it.

  • +
+

Later on we must make sure our combat systems honors these values.

+
+
+

7.2. PvP room

+

Here’s a room that allows non-lethal PvP (sparring):

+
# in evadventure/rooms.py
+
+# ... 
+
+class EvAdventurePvPRoom(EvAdventureRoom):
+    """
+    Room where PvP can happen, but noone gets killed.
+    
+    """
+    
+    allow_combat = AttributeProperty(True, autocreate=False)
+    allow_pvp = AttributeProperty(True, autocreate=False)
+    
+    def get_display_footer(self, looker, **kwargs):
+        """
+        Customize footer of description.
+        """
+        return "|yNon-lethal PvP combat is allowed here!|n"
+
+
+

The return of get_display_footer will show after the main room description, showing that the room is a sparring room. This means that when a player drops to 0 HP, they will lose the combat, but don’t stand any risk of dying (weapons wear out normally during sparring though).

+
+
+

7.3. Adding a room map

+

We want a dynamic map that visualizes the exits you can use at any moment. Here’s how our room will display:

+
  o o o
+   \|/
+  o-@-o
+    | 
+    o
+The crossroads 
+A place where many roads meet. 
+Exits: north, northeast, south, west, and northwest
+
+
+
+

Documentation does not show ansi colors.

+
+

Let’s expand the base EvAdventureRoom with the map.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
# in evadventyre/rooms.py
+
+# ... 
+
+from copy import deepcopy
+from evennia import DefaultCharacter
+from evennia.utils.utils import inherits_from
+
+CHAR_SYMBOL = "|w@|n"
+CHAR_ALT_SYMBOL = "|w>|n"
+ROOM_SYMBOL = "|bo|n"
+LINK_COLOR = "|B"
+
+_MAP_GRID = [
+    [" ", " ", " ", " ", " "],
+    [" ", " ", " ", " ", " "],
+    [" ", " ", "@", " ", " "],
+    [" ", " ", " ", " ", " "],
+    [" ", " ", " ", " ", " "],
+]
+_EXIT_GRID_SHIFT = {
+    "north": (0, 1, "||"),
+    "east": (1, 0, "-"),
+    "south": (0, -1, "||"),
+    "west": (-1, 0, "-"),
+    "northeast": (1, 1, "/"),
+    "southeast": (1, -1, "\\"),
+    "southwest": (-1, -1, "/"),
+    "northwest": (-1, 1, "\\"),
+}
+
+class EvAdventureRoom(DefaultRoom): 
+
+    # ... 
+
+    def format_appearance(self, appearance, looker, **kwargs):
+        """Don't left-strip the appearance string"""
+        return appearance.rstrip()
+ 
+    def get_display_header(self, looker, **kwargs):
+        """
+        Display the current location as a mini-map.
+ 
+        """
+        # make sure to not show make a map for users of screenreaders.
+        # for optimization we also don't show it to npcs/mobs
+        if not inherits_from(looker, DefaultCharacter) or (
+            looker.account and looker.account.uses_screenreader()
+        ):
+            return ""
+ 
+        # build a map
+        map_grid = deepcopy(_MAP_GRID)
+        dx0, dy0 = 2, 2
+        map_grid[dy0][dx0] = CHAR_SYMBOL
+        for exi in self.exits:
+            dx, dy, symbol = _EXIT_GRID_SHIFT.get(exi.key, (None, None, None))
+            if symbol is None:
+                # we have a non-cardinal direction to go to - indicate this
+                map_grid[dy0][dx0] = CHAR_ALT_SYMBOL
+                continue
+            map_grid[dy0 + dy][dx0 + dx] = f"{LINK_COLOR}{symbol}|n"
+            if exi.destination != self:
+                map_grid[dy0 + dy + dy][dx0 + dx + dx] = ROOM_SYMBOL
+ 
+        # Note that on the grid, dy is really going *downwards* (origo is
+        # in the top left), so we need to reverse the order at the end to mirror it
+        # vertically and have it come out right.
+        return "  " + "\n  ".join("".join(line) for line in reversed(map_grid))
+
+
+

The string returned from get_display_header will end up at the top of the room description, a good place to have the map appear!

+
    +
  • Line 12: The map itself consists of the 2D matrix _MAP_GRID. This is a 2D area described by a list of Python lists. To find a given place in the list, you first first need to find which of the nested lists to use, and then which element to use in that list. Indices start from 0 in Python. So to draw the o symbol for the southermost room, you’d need to do so at _MAP_GRID[4][2].

  • +
  • Line 19: The _EXIT_GRID_SHIFT indicates the direction to go for each cardinal exit, along with the map symbol to draw at that point. So "east": (1, 0, "-") means the east exit will be drawn one step in the positive x direction (to the right), using the “-” symbol. For symbols like | and “\” we need to escape with a double-symbol since these would otherwise be interpreted as part of other formatting.

  • +
  • Line 51: We start by making a deepcopy of the _MAP_GRID. This is so that we don’t modify the original but always have an empty template to work from.

  • +
  • Line 52: We use @ to indicate the location of the player (at coordinate (2, 2)). We then take the actual exits from the room use their names to figure out what symbols to draw out from the center.

  • +
  • Line 58: We want to be able to get on/off the grid if so needed. So if a room has a non-cardinal exit in it (like ‘back’ or up/down), we’ll indicate this by showing the > symbol instead of the @ in your current room.

  • +
  • Line 67: Once we have placed all the exit- and room-symbols in the grid, we merge it all together into a single string. At the end we use Python’s standard join to convert the grid into a single string. In doing so we must flip the grid upside down (reverse the outermost list). Why is this? If you think about how a MUD game displays its data - by printing at the bottom and then scrolling upwards - you’ll realize that Evennia has to send out the top of your map first and the bottom of it last for it to show correctly to the user.

  • +
+
+
+

7.4. Adding life to a room

+

Normally the room is static until you do something in it. But let’s say you are in a room described to be a bustling market. Would it not be nice to occasionally get some random messages like

+
"You hear a merchant calling out his wares."
+"The sound of music drifts over the square from an open tavern door."
+"The sound of commerse rises and fall in a steady rythm."
+
+
+

Here’s an example of how to accomplish this:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
# in evadventure/rooms.py 
+
+# ... 
+
+from random import choice, random
+from evennia import TICKER_HANDLER
+
+# ... 
+
+class EchoingRoom(EvAdventureRoom):
+    """A room that randomly echoes messages to everyone inside it"""
+
+    echoes = AttributeProperty(list, autocreate=False)
+	echo_rate = AttributeProperty(60 * 2, autocreate=False)
+	echo_chance = AttributeProperty(0.1, autocreate=False)
+
+	def send_echo(self): 
+		if self.echoes and random() < self.echo_chance: 
+			self.msg_contents(choice(self.echoes))
+
+	def start_echo(self): 
+		TICKER_HANDLER.add(self.echo_rate, self.send_echo)
+
+	def stop_echo(self): 
+		TICKER_HANDLER.remove(self.echo_rate, self.send_echo)
+
+
+

The TickerHandler. This is acts as a ‘please tick me - subscription service’. In Line 22 we tell add our .send_echo method to the handler and tell the TickerHandler to call that method every .echo_rate seconds.

+

When the .send_echo method is called, it will use random.random() to check if we should actually do anything. In our example we only show a message 10% of the time. In that case we use Python’s random.choice() to grab a random text string from the .echoes list to send to everyone inside this room.

+

Here’s how you’d use this room in-game:

+
> dig market:evadventure.rooms.EchoingRoom = market,back 
+> market 
+> set here/echoes = ["You hear a merchant shouting", "You hear the clatter of coins"]
+> py here.start_echo() 
+
+
+

If you wait a while you’ll eventually see one of the two echoes show up. Use py here.stop_echo() if you want.

+

It’s a good idea to be able to turn on/off the echoes at will, if nothing else because you’d be surprised how annoying they can be if they show too often.

+

In this example we had to resort to py to activate/deactivate the echoes, but you could very easily make little utility Commands startecho and stopecho to do it for you. This we leave as a bonus exercise.

+
+
+

7.5. Testing

+
+

Create a new module evadventure/tests/test_rooms.py.

+
+ +

The main thing to test with our new rooms is the map. Here’s the basic principle for how to do this testing:

+
# in evadventure/tests/test_rooms.py
+
+from evennia import DefaultExit, create_object
+from evennia.utils.test_resources import EvenniaTestCase
+from ..characters import EvAdventureCharacter 
+from ..rooms import EvAdventureRoom
+
+class EvAdventureRoomTest(EvenniaTestCase): 
+
+    def test_map(self): 
+        center_room = create_object(EvAdventureRoom, key="room_center")
+        
+        n_room = create_object(EvAdventureRoom, key="room_n)
+        create_object(DefaultExit, 
+                      key="north", location=center_room, destination=n_room)
+        ne_room = create_object(EvAdventureRoom, key="room=ne")
+        create_object(DefaultExit,
+			          key="northeast", location=center_room, destination=ne_room)
+        # ... etc for all cardinal directions 
+        
+        char = create_object(EvAdventureCharacter, 
+					         key="TestChar", location=center_room)					        
+		desc = center_room.return_appearance(char)
+
+        # compare the desc we got with the expected description here
+
+
+
+

So we create a bunch of rooms, link them to one centr room and then make sure the map in that room looks like we’d expect.

+
+
+

7.6. Conclusion

+

In this lesson we manipulated strings and made a map. Changing the description of an object is a big part of changing the ‘graphics’ of a text-based game, so checking out the parts making up an object description is good extra reading.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Rules.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Rules.html new file mode 100644 index 0000000000..8d37d3e13d --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Rules.html @@ -0,0 +1,780 @@ + + + + + + + + + 2. Rules and dice rolling — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

2. Rules and dice rolling

+

In EvAdventure we have decided to use the Knave +RPG ruleset. This is commercial, but released under Creative Commons 4.0, meaning it’s okay to share and +adapt Knave for any purpose, even commercially. If you don’t want to buy it but still follow +along, you can find a free fan-version here.

+
+

2.1. Summary of Knave rules

+

Knave, being inspired by early Dungeons & Dragons, is very simple.

+
    +
  • It uses six Ability bonuses +Strength (STR), Dexterity (DEX), Constitution (CON), Intelligence (INT), Wisdom (WIS) +and Charisma (CHA). These are rated from +1 to +10.

  • +
  • Rolls are made with a twenty-sided die (1d20), usually adding a suitable Ability bonus to the roll.

  • +
  • If you roll with advantage, you roll 2d20 and pick the +highest value, If you roll with disadvantage, you roll 2d20 and pick the lowest.

  • +
  • Rolling a natural 1 is a critical failure. A natural 20 is a critical success. Rolling such +in combat means your weapon or armor loses quality, which will eventually destroy it.

  • +
  • A saving throw (trying to succeed against the environment) means making a roll to beat 15 (always). +So if you are lifting a heavy stone and have STR +2, you’d roll 1d20 + 2 and hope the result +is higher than 15.

  • +
  • An opposed saving throw means beating the enemy’s suitable Ability ‘defense’, which is always their +Ability bonus + 10. So if you have STR +1 and are arm wrestling someone with STR +2, you roll +1d20 + 1 and hope to roll higher than 2 + 10 = 12.

  • +
  • A special bonus is Armor, +1 is unarmored, additional armor is given by equipment. Melee attacks +test STR versus the Armor defense value while ranged attacks uses WIS vs Armor.

  • +
  • Knave has no skills or classes. Everyone can use all items and using magic means having a special +‘rune stone’ in your hands; one spell per stone and day.

  • +
  • A character has CON + 10 carry ‘slots’. Most normal items uses one slot, armor and large weapons uses +two or three.

  • +
  • Healing is random, 1d8 + CON health healed after food and sleep.

  • +
  • Monster difficulty is listed by hy many 1d8 HP they have; this is called their “hit die” or HD. If +needing to test Abilities, monsters have HD bonus in every Ability.

  • +
  • Monsters have a morale rating. When things go bad, they have a chance to panic and flee if +rolling 2d6 over their morale rating.

  • +
  • All Characters in Knave are mostly randomly generated. HP is <level>d8 but we give every +new character max HP to start.

  • +
  • Knave also have random tables, such as for starting equipment and to see if dying when +hitting 0. Death, if it happens, is permanent.

  • +
+
+
+

2.2. Making a rule module

+
+

Create a new module mygame/evadventure/rules.py

+
+ +

There are three broad sets of rules for most RPGS:

+
    +
  • Character generation rules, often only used during character creation

  • +
  • Regular gameplay rules - rolling dice and resolving game situations

  • +
  • Character improvement - getting and spending experience to improve the character

  • +
+

We want our rules module to cover as many aspeects of what we’d otherwise would have to look up +in a rulebook.

+
+
+

2.3. Rolling dice

+

We will start by making a dice roller. Let’s group all of our dice rolling into a structure like this +(not functional code yet):

+
class EvAdventureRollEngine:
+
+   def roll(...):
+       # get result of one generic roll, for any type and number of dice
+       
+   def roll_with_advantage_or_disadvantage(...)
+       # get result of normal d20 roll, with advantage/disadvantage (or not)
+       
+   def saving_throw(...):
+       # do a saving throw against a specific target number
+       
+   def opposed_saving_throw(...):
+       # do an opposed saving throw against a target's defense
+
+   def roll_random_table(...):
+       # make a roll against a random table (loaded elsewere)
+  
+   def morale_check(...):
+       # roll a 2d6 morale check for a target
+      
+   def heal_from_rest(...):
+       # heal 1d8 when resting+eating, but not more than max value.
+       
+   def roll_death(...):
+       # roll to determine penalty when hitting 0 HP. 
+       
+       
+dice = EvAdventureRollEngine() 
+       
+
+
+ +

This structure (called a singleton) means we group all dice rolls into one class that we then initiate +into a variable dice at the end of the module. This means that we can do the following from other +modules:

+
    from .rules import dice 
+
+    dice.roll("1d8")
+
+
+
+

2.3.1. Generic dice roller

+

We want to be able to do roll("1d20") and get a random result back from the roll.

+
# in mygame/evadventure/rules.py 
+
+from random import randint
+
+class EvAdventureRollEngine:
+    
+    def roll(self, roll_string):
+        """ 
+        Roll XdY dice, where X is the number of dice 
+        and Y the number of sides per die. 
+        
+        Args:
+            roll_string (str): A dice string on the form XdY.
+        Returns:
+            int: The result of the roll. 
+            
+        """ 
+        
+        # split the XdY input on the 'd' one time
+        number, diesize = roll_string.split("d", 1)     
+        
+        # convert from string to integers
+        number = int(number) 
+        diesize = int(diesize)
+            
+        # make the roll
+        return sum(randint(1, diesize) for _ in range(number))
+
+
+ +

The randint standard Python library module produces a random integer
+in a specific range. The line

+
sum(randint(1, diesize) for _ in range(number))
+
+
+

works like this:

+
    +
  • For a certain number of times …

  • +
  • … create a random integer between 1 and diesize

  • +
  • … and sum all those integers together.

  • +
+

You could write the same thing less compactly like this:

+
rolls = []
+for _ in range(number): 
+   random_result = randint(1, diesize)
+   rolls.append(random_result)
+return sum(rolls)
+
+
+ +

We don’t ever expect end users to call this method; if we did, we would have to validate the inputs +much more - We would have to make sure that number or diesize are valid inputs and not +crazy big so the loop takes forever!

+
+
+

2.3.2. Rolling with advantage

+

Now that we have the generic roller, we can start using it to do a more complex roll.

+
# in mygame/evadventure/rules.py 
+
+# ... 
+
+class EvAdventureRollEngine:
+
+    def roll(roll_string):
+        # ... 
+    
+    def roll_with_advantage_or_disadvantage(self, advantage=False, disadvantage=False):
+        
+        if not (advantage or disadvantage) or (advantage and disadvantage):
+            # normal roll - advantage/disadvantage not set or they cancel 
+            # each other out 
+            return self.roll("1d20")
+        elif advantage:
+             # highest of two d20 rolls
+             return max(self.roll("1d20"), self.roll("1d20"))
+        else:
+             # disadvantage - lowest of two d20 rolls 
+             return min(self.roll("1d20"), self.roll("1d20"))
+
+
+

The min() and max() functions are standard Python fare for getting the biggest/smallest +of two arguments.

+
+
+

2.3.3. Saving throws

+

We want the saving throw to itself figure out if it succeeded or not. This means it needs to know +the Ability bonus (like STR +1). It would be convenient if we could just pass the entity +doing the saving throw to this method, tell it what type of save was needed, and then +have it figure things out:

+
result, quality = dice.saving_throw(character, Ability.STR)
+
+
+

The return will be a boolean True/False if they pass, as well as a quality that tells us if +a perfect fail/success was rolled or not.

+

To make the saving throw method this clever, we need to think some more about how we want to store our +data on the character.

+

For our purposes it sounds reasonable that we will be using Attributes for storing +the Ability scores. To make it easy, we will name them the same as the +Enum values we set up in the previous lesson. So if we have +an enum STR = "strength", we want to store the Ability on the character as an Attribute strength.

+

From the Attribute documentation, we can see that we can use AttributeProperty to make it so the +Attribute is available as character.strength, and this is what we will do.

+

So, in short, we’ll create the saving throws method with the assumption that we will be able to do +character.strength, character.constitution, character.charisma etc to get the relevant Abilities.

+
# in mygame/evadventure/rules.py 
+# ...
+from .enums import Ability
+
+class EvAdventureRollEngine: 
+
+    def roll(...)
+        # ...
+   
+    def roll_with_advantage_or_disadvantage(...)
+        # ...
+       
+    def saving_throw(self, character, bonus_type=Ability.STR, target=15, 
+                     advantage=False, disadvantage=False):
+        """ 
+        Do a saving throw, trying to beat a target.
+       
+        Args:
+           character (Character): A character (assumed to have Ability bonuses
+               stored on itself as Attributes).
+           bonus_type (Ability): A valid Ability bonus enum.
+           target (int): The target number to beat. Always 15 in Knave.
+           advantage (bool): If character has advantage on this roll.
+           disadvantage (bool): If character has disadvantage on this roll.
+          
+        Returns:
+            tuple: A tuple (bool, Ability), showing if the throw succeeded and 
+                the quality is one of None or Ability.CRITICAL_FAILURE/SUCCESS
+               
+        """
+                    
+        # make a roll 
+        dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage)
+       
+        # figure out if we had critical failure/success
+        quality = None
+        if dice_roll == 1:
+            quality = Ability.CRITICAL_FAILURE
+        elif dice_roll == 20:
+            quality = Ability.CRITICAL_SUCCESS 
+
+        # figure out bonus
+        bonus = getattr(character, bonus_type.value, 1) 
+
+        # return a tuple (bool, quality)
+        return (dice_roll + bonus) > target, quality
+
+
+

The getattr(obj, attrname, default) function is a very useful Python tool for getting an attribute +off an object and getting a default value if the attribute is not defined.

+
+
+

2.3.4. Opposed saving throw

+

With the building pieces we already created, this method is simple. Remember that the defense you have +to beat is always the relevant bonus + 10 in Knave. So if the enemy defends with STR +3, you must +roll higher than 13.

+
# in mygame/evadventure/rules.py 
+
+from .enums import Ability
+
+class EvAdventureRollEngine:
+    
+    def roll(...):
+        # ... 
+
+    def roll_with_advantage_or_disadvantage(...):
+        # ... 
+
+    def saving_throw(...):
+        # ... 
+
+    def opposed_saving_throw(self, attacker, defender, 
+                             attack_type=Ability.STR, defense_type=Ability.ARMOR,
+                             advantage=False, disadvantage=False):
+        defender_defense = getattr(defender, defense_type.value, 1) + 10 
+        result, quality = self.saving_throw(attacker, bonus_type=attack_type,
+                                            target=defender_defense, 
+                                            advantage=advantage, disadvantage=disadvantage)
+        
+        return result, quality 
+
+
+
+
+

2.3.5. Morale check

+

We will make the assumption that the morale value is available from the creature simply as +monster.morale - we need to remember to make this so later!

+

In Knave, a creature have roll with 2d6 equal or under its morale to not flee or surrender +when things go south. The standard morale value is 9.

+
# in mygame/evadventure/rules.py 
+
+class EvAdventureRollEngine:
+
+    # ...
+    
+    def morale_check(self, defender): 
+        return self.roll("2d6") <= getattr(defender, "morale", 9)
+    
+
+
+
+
+

2.3.6. Roll for Healing

+

To be able to handle healing, we need to make some more assumptions about how we store +health on game entities. We will need hp_max (the total amount of available HP) and hp +(the current health value). We again assume these will be available as obj.hp and obj.hp_max.

+

According to the rules, after consuming a ration and having a full night’s sleep, a character regains +1d8 + CON HP.

+
# in mygame/evadventure/rules.py 
+
+from .enums import Ability
+
+class EvAdventureRollEngine: 
+
+    # ... 
+    
+    def heal_from_rest(self, character): 
+        """ 
+        A night's rest retains 1d8 + CON HP  
+        
+        """
+        con_bonus = getattr(character, Ability.CON.value, 1)
+        character.heal(self.roll("1d8") + con_bonus)
+
+
+

We make another assumption here - that character.heal() is a thing. We tell this function how +much the character should heal, and it will do so, making sure to not heal more than its max +number of HPs

+
+

Knowing what is available on the character and what rule rolls we need is a bit of a chicken-and-egg +problem. We will make sure to implement the matching Character class next lesson.

+
+
+
+

2.3.7. Rolling on a table

+

We occasionally need to roll on a ‘table’ - a selection of choices. There are two main table-types +we need to support:

+

Simply one element per row of the table (same odds to get each result).

+ + + + + + + + + + + + + + + +

Result

item1

item2

item3

item4

+

This we will simply represent as a plain list

+
["item1", "item2", "item3", "item4"]
+
+
+

Ranges per item (varying odds per result):

+ + + + + + + + + + + + + + + + + + + + +

Range

Result

1-5

item1

6-15

item2

16-19

item3

20

item4

+

This we will represent as a list of tuples:

+
[("1-5", "item1"), ("6-15", "item2"), ("16-19", "item4"), ("20", "item5")]
+
+
+

We also need to know what die to roll to get a result on the table (it may not always +be obvious, and in some games you could be asked to roll a lower dice to only get +early table results, for example).

+
# in mygame/evadventure/rules.py 
+
+from random import randint, choice
+
+class EvAdventureRollEngine:
+    
+    # ... 
+
+    def roll_random_table(self, dieroll, table_choices): 
+        """ 
+        Args: 
+             dieroll (str): A die roll string, like "1d20".
+             table_choices (iterable): A list of either single elements or 
+                of tuples.
+        Returns: 
+            Any: A random result from the given list of choices.
+            
+        Raises:
+            RuntimeError: If rolling dice giving results outside the table.
+            
+        """
+        roll_result = self.roll(dieroll) 
+        
+        if isinstance(table_choices[0], (tuple, list)):
+            # the first element is a tuple/list; treat as on the form [("1-5", "item"),...]
+            for (valrange, choice) in table_choices:
+                minval, *maxval = valrange.split("-", 1)
+                minval = abs(int(minval))
+                maxval = abs(int(maxval[0]) if maxval else minval)
+                
+                if minval <= roll_result <= maxval:
+                    return choice 
+                
+            # if we get here we must have set a dieroll producing a value 
+            # outside of the table boundaries - raise error
+            raise RuntimeError("roll_random_table: Invalid die roll")
+        else:
+            # a simple regular list
+            roll_result = max(1, min(len(table_choices), roll_result))
+            return table_choices[roll_result - 1]
+
+
+

Check that you understand what this does.

+

This may be confusing:

+
minval, *maxval = valrange.split("-", 1)
+minval = abs(int(minval))
+maxval = abs(int(maxval[0]) if maxval else minval)
+
+
+

If valrange is the string 1-5, then valrange.split("-", 1) would result in a tuple ("1", "5"). +But if the string was in fact just "20" (possible for a single entry in an RPG table), this would +lead to an error since it would only split out a single element - and we expected two.

+

By using *maxval (with the *), maxval is told to expect 0 or more elements in a tuple. +So the result for 1-5 will be ("1", ("5",)) and for 20 it will become ("20", ()). In the line

+
maxval = abs(int(maxval[0]) if maxval else minval)
+
+
+

we check if maxval actually has a value ("5",) or if its empty (). The result is either +"5" or the value of minval.

+
+
+

2.3.8. Roll for death

+

While original Knave suggests hitting 0 HP means insta-death, we will grab the optional “death table” from the “prettified” Knave’s optional rules to make it a little less punishing. We also changed the result of 2 to ‘dead’ since we don’t simulate ‘dismemberment’ in this tutorial:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Roll

Result

-1d4 Loss of Ability

1-2

dead

-

3

weakened

STR

4

unsteady

DEX

5

sickly

CON

6

addled

INT

7

rattled

WIS

8

disfigured

CHA

+

All the non-dead values map to a loss of 1d4 in one of the six Abilities (but you get HP back). We need to map back to this from the above table. One also cannot have less than -10 Ability bonus, if you do, you die too.

+
# in mygame/evadventure/rules.py 
+
+death_table = (
+    ("1-2", "dead"),
+    ("3", "strength"),
+    ("4", "dexterity"),
+    ("5", "constitution"),
+    ("6", "intelligence"),
+    ("7", "wisdom"),
+    ("8", "charisma"),
+)
+    
+    
+class EvAdventureRollEngine:
+    
+    # ... 
+
+    def roll_random_table(...)
+        # ... 
+        
+    def roll_death(self, character): 
+        ability_name = self.roll_random_table("1d8", death_table)
+
+        if ability_name == "dead":
+            # TODO - kill the character! 
+            pass 
+        else: 
+            loss = self.roll("1d4")
+            
+            current_ability = getattr(character, ability_name)
+            current_ability -= loss
+            
+            if current_ability < -10: 
+                # TODO - kill the character!
+                pass 
+            else:
+                # refresh 1d4 health, but suffer 1d4 ability loss
+                self.heal(character, self.roll("1d4"))
+                setattr(character, ability_name, current_ability)
+                
+                character.msg(
+                    "You survive your brush with death, and while you recover "
+                    f"some health, you permanently lose {loss} {ability_name} instead."
+                )
+                
+dice = EvAdventureRollEngine()
+
+
+

Here we roll on the ‘death table’ from the rules to see what happens. We give the character +a message if they survive, to let them know what happened.

+

We don’t yet know what ‘killing the character’ technically means, so we mark this as TODO and return to it in a later lesson. We just know that we need to do something here to kill off the character!

+
+
+
+

2.4. Testing

+
+

Make a new module mygame/evadventure/tests/test_rules.py

+
+

Testing the rules module will also showcase some very useful tools when testing.

+
# mygame/evadventure/tests/test_rules.py 
+
+from unittest.mock import patch 
+from evennia.utils.test_resources import BaseEvenniaTest
+from .. import rules 
+
+class TestEvAdventureRuleEngine(BaseEvenniaTest):
+   
+    def setUp(self):
+        """Called before every test method"""
+        super().setUp()
+        self.roll_engine = rules.EvAdventureRollEngine()
+    
+    @patch("evadventure.rules.randint")
+    def test_roll(self, mock_randint):
+        mock_randint.return_value = 4 
+        self.assertEqual(self.roll_engine.roll("1d6"), 4)     
+        self.assertEqual(self.roll_engine.roll("2d6"), 2 * 4)     
+        
+    # test of the other rule methods below ...
+
+
+

As before, run the specific test with

+
evennia test --settings settings.py .evadventure.tests.test_rules
+
+
+
+

2.4.1. Mocking and patching

+ +

The setUp method is a special method of the testing class. It will be run before every +test method. We use super().setUp() to make sure the parent class’ version of this method +always fire. Then we create a fresh EvAdventureRollEngine we can test with.

+

In our test, we import patch from the unittest.mock library. This is a very useful tool for testing. +Normally the randint function we imported in rules will return a random value. That’s very hard to test for, since the value will be different every test.

+

With @patch (this is called a decorator), we temporarily replace rules.randint with a ‘mock’ - a dummy entity. This mock is passed into the testing method. We then take this mock_randint and set .return_value = 4 on it.

+

Adding return_value to the mock means that every time this mock is called, it will return 4. For the duration of the test we can now check with self.assertEqual that our roll method always returns a result as-if the random result was 4.

+

There are many resources for understanding mock, refer to +them for further help.

+
+

The EvAdventureRollEngine have many methods to test. We leave this as an extra exercise!

+
+
+
+
+

2.5. Summary

+

This concludes all the core rule mechanics of Knave - the rules used during play. We noticed here that we are going to soon need to establish how our Character actually stores data. So we will address that next.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Shops.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Shops.html new file mode 100644 index 0000000000..d71ecddd5d --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Shops.html @@ -0,0 +1,141 @@ + + + + + + + + + 15. In-game Shops — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

15. In-game Shops

+
+

Warning

+

This part of the Beginner tutorial is still being developed.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Utilities.html b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Utilities.html new file mode 100644 index 0000000000..962536eeaa --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Utilities.html @@ -0,0 +1,424 @@ + + + + + + + + + 1. Code structure and Utilities — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

1. Code structure and Utilities

+

In this lesson we will set up the file structure for EvAdventure. We will make some +utilities that will be useful later. We will also learn how to write tests.

+
+

1.1. Folder structure

+ +

Create a new folder under your mygame folder, named evadventure. Inside it, create +another folder tests/ and make sure to put empty __init__.py files in both. This turns both +folders into packages Python understands to import from.

+
mygame/
+   commands/
+   evadventure/         <---
+      __init__.py       <---
+      tests/            <---
+          __init__.py   <---
+   __init__.py
+   README.md
+   server/
+   typeclasses/
+   web/
+   world/
+
+
+
+

Importing anything from inside this folder from anywhere else under mygame will be done by

+
# from anywhere in mygame/
+from evadventure.yourmodulename import whatever 
+
+
+

This is the ‘absolute path` type of import.

+

Between two modules both in evadventure/, you can use a ‘relative’ import with .:

+
# from a module inside mygame/evadventure
+from .yourmodulename import whatever
+
+
+

From e.g. inside mygame/evadventure/tests/ you can import from one level above using ..:

+
# from mygame/evadventure/tests/ 
+from ..yourmodulename import whatever
+
+
+
+
+

1.2. Enums

+ +

Create a new file mygame/evadventure/enums.py.

+

An enum (enumeration) is a way to establish constants in Python. Best is to show an example:

+
# in a file mygame/evadventure/enums.py
+
+from enum import Enum
+
+class Ability(Enum): 
+
+    STR = "strength"
+
+
+
+

You access an enum like this:

+
# from another module in mygame/evadventure
+
+from .enums import Ability 
+
+Ability.STR   # the enum itself 
+Ability.STR.value  # this is the string "strength"
+
+
+
+

Having enums is recommended practice. With them set up, it means we can make sure to refer to the same thing every time. Having all enums in one place also means you have a good overview of the constants you are dealing with.

+

The alternative would be to for example pass around a string "constitution". If you mis-spell this ("consitution"), you would not necessarily know it right away - the error would happen later when the string is not recognized. If you make a typo getting Ability.COM instead of Ability.CON, Python will immediately raise an error since this enum is not recognized.

+

With enums you can also do nice direct comparisons like if ability is Ability.WIS: <do stuff>.

+

Note that the Ability.STR enum does not have the actual value of e.g. your Strength. It’s just a fixed label for the Strength ability.

+

Here is the enum.py module needed for Knave. It covers the basic aspects of rule systems we need to track (check out the Knave rules. If you use another rule system you’ll likely gradually expand on your enums as you figure out what you’ll need).

+
# mygame/evadventure/enums.py
+
+class Ability(Enum):
+    """
+    The six base ability-bonuses and other 
+    abilities
+
+    """
+
+    STR = "strength"
+    DEX = "dexterity"
+    CON = "constitution"
+    INT = "intelligence"
+    WIS = "wisdom"
+    CHA = "charisma"
+     
+    ARMOR = "armor"
+    
+    CRITICAL_FAILURE = "critical_failure"
+    CRITICAL_SUCCESS = "critical_success"
+    
+    ALLEGIANCE_HOSTILE = "hostile"
+    ALLEGIANCE_NEUTRAL = "neutral"
+    ALLEGIANCE_FRIENDLY = "friendly"
+
+
+ABILITY_REVERSE_MAP =  {
+    "str": Ability.STR, 
+    "dex": Ability.DEX,
+    "con": Ability.CON,
+    "int": Ability.INT,
+    "wis": Ability.WIS,
+    "cha": Ability.CHA 
+}
+
+
+
+

Here the Ability class holds basic properties of a character sheet.

+

The ABILITY_REVERSE_MAP is a convenient map to go the other way - if you in some command were to enter the string ‘cha’, we could use this mapping to directly convert your input to the correct Ability:

+
ability = ABILITY_REVERSE_MAP.get(your_input)
+
+
+
+
+

1.3. Utility module

+
+

Create a new module mygame/evadventure/utils.py

+
+ +

This is for general functions we may need from all over. In this case we only picture one utility, a function that produces a pretty display of any object we pass to it.

+

This is an example of the string we want to see:

+
Chipped Sword 
+Value: ~10 coins [wielded in Weapon hand]
+ 
+A simple sword used by mercenaries all over 
+the world.
+ 
+Slots: 1, Used from: weapon hand
+Quality: 3, Uses: None
+Attacks using strength against armor.
+Damage roll: 1d6
+
+
+

Here’s the start of how the function could look:

+
# in mygame/evadventure/utils.py
+
+_OBJ_STATS = """
+|c{key}|n
+Value: ~|y{value}|n coins{carried}
+
+{desc}
+
+Slots: |w{size}|n, Used from: |w{use_slot_name}|n
+Quality: |w{quality}|n, Uses: |w{uses}|n
+Attacks using |w{attack_type_name}|n against |w{defense_type_name}|n
+Damage roll: |w{damage_roll}|n
+""".strip()
+
+
+def get_obj_stats(obj, owner=None): 
+    """ 
+    Get a string of stats about the object.
+    
+    Args:
+        obj (Object): The object to get stats for.
+        owner (Object): The one currently owning/carrying `obj`, if any. Can be 
+            used to show e.g. where they are wielding it.
+    Returns:
+        str: A nice info string to display about the object.
+     
+    """
+    return _OBJ_STATS.format(
+        key=obj.key, 
+        value=10, 
+        carried="[Not carried]", 
+        desc=obj.db.desc, 
+        size=1,
+        quality=3,
+        uses="infinite",
+        use_slot_name="backpack",
+        attack_type_name="strength",
+        defense_type_name="armor",
+        damage_roll="1d6"
+    )
+
+
+

Here we set up the string template with place holders for where every piece of info should go. Study this string so you understand what it does. The |c, |y, |w and |n markers are Evennia color markup for making the text cyan, yellow, white and neutral-color respectively.

+

We can guess some things, such that obj.key is the name of the object, and that obj.db.desc will hold its description (this is how it is in default Evennia).

+

But so far we have not established how to get any of the other properties like size or attack_type. So we just set them to dummy values. We’ll need to get back to this when we have more code in place!

+
+
+

1.4. Testing

+
+

create a new module mygame/evadventure/tests/test_utils.py

+
+

How do you know if you made a typo in the code above? You could manually test it by reloading your Evennia server and do the following from in-game:

+
py from evadventure.utils import get_obj_stats;print(get_obj_stats(self))
+
+
+

You should get back a nice string about yourself! If that works, great! But you’ll need to remember doing that test when you change this code later.

+ +

A unit test allows you to set up automated testing of code. Once you’ve written your test you can run it over and over and make sure later changes to your code didn’t break things.

+

In this particular case, we expect to later have to update the test when get_obj_stats becomes more complete and returns more reasonable data.

+

Evennia comes with extensive functionality to help you test your code. Here’s a module for +testing get_obj_stats.

+
# mygame/evadventure/tests/test_utils.py
+
+from evennia.utils import create 
+from evennia.utils.test_resources import EvenniaTest 
+
+from ..import utils
+
+class TestUtils(EvenniaTest):
+    def test_get_obj_stats(self):
+        # make a simple object to test with 
+        obj = create.create_object(
+            key="testobj", 
+            attributes=(("desc", "A test object"),)
+        ) 
+        # run it through the function 
+        result = utils.get_obj_stats(obj)
+        # check that the result is what we expected
+        self.assertEqual(
+            result, 
+            """ 
+|ctestobj|n
+Value: ~|y10|n coins[not carried]
+
+A test object
+
+Slots: |w1|n, Used from: |wbackpack|n
+Quality: |w3|n, Uses: |winfinite|n
+Attacks using |wstrength|n against |warmor|n
+Damage roll: |w1d6|n
+""".strip()
+)
+
+
+
+

What happens here is that we create a new test-class TestUtils that inherits from EvenniaTest. This inheritance is what makes this a testing class.

+
+

Important

+

It’s useful for any game dev to know how to effectively test their code. So we’ll try to include a Testing section at the end of each of the implementation lessons to follow. Writing tests for your code is optional but highly recommended. It can feel a little cumbersome or time-consuming at first … but you’ll thank yourself later.

+
+

We can have any number of methods on this class. To have a method recognized as one containing code to test, its name must start with test_. We have one - test_get_obj_stats.

+

In this method we create a dummy obj and gives it a key “testobj”. Note how we add the desc Attribute directly in the create_object call by specifying the attribute as a tuple (name, value)!

+

We then get the result of passing this dummy-object through get_obj_stats we imported earlier.

+

The assertEqual method is available on all testing classes and checks that the result is equal to the string we specify. If they are the same, the test passes, otherwise it fails and we need to investigate what went wrong.

+
+

1.4.1. Running your test

+

To run your test you need to stand inside your mygame folder and execute the following command:

+
evennia test --settings settings.py evadventure.tests
+
+
+

This will run all your evadventure tests (if you had more of them). To only run your utility tests you could do

+
evennia test --settings settings.py evadventure.tests.test_utils
+
+
+

If all goes well, you should get an OK back. Otherwise you need to check the failure, maybe your return string doesn’t quite match what you expected.

+
+

Hint: The example unit test code above contains a deliberate error in capitalization. See if you can interpret the error and fix it!

+
+
+
+
+

1.5. Summary

+

It’s very important to understand how you import code between modules in Python, so if this is still confusing to you, it’s worth to read up on this more.

+

That said, many newcomers are confused with how to begin, so by creating the folder structure, some small modules and even making your first unit test, you are off to a great start!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part4/Beginner-Tutorial-Part4-Overview.html b/docs/latest/Howtos/Beginner-Tutorial/Part4/Beginner-Tutorial-Part4-Overview.html new file mode 100644 index 0000000000..e2f8e7ea34 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part4/Beginner-Tutorial-Part4-Overview.html @@ -0,0 +1,170 @@ + + + + + + + + + Part 4: Using What We Created — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Part 4: Using What We Created

+ +

We now have the code underpinnings of everything we need. We have also tested the various components and has a simple tech-demo to show it all works together. But there is no real coherence to it at this point - we need to actually make a world. In part four we will expand our tech demo into a more full-fledged (if small) game by use of batchcommand and batchcode processors.

+
+

Lessons

+

TODO

+ +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part5/Add-a-simple-new-web-page.html b/docs/latest/Howtos/Beginner-Tutorial/Part5/Add-a-simple-new-web-page.html new file mode 100644 index 0000000000..44fdcbef2f --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part5/Add-a-simple-new-web-page.html @@ -0,0 +1,250 @@ + + + + + + + + + 1. Add a simple new web page — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

1. Add a simple new web page

+

Evennia leverages Django which is a web development framework. +Huge professional websites are made in Django and there is extensive documentation (and books) on it. +You are encouraged to at least look at the Django basic tutorials. Here we will just give a brief +introduction for how things hang together, to get you started.

+

We assume you have installed and set up Evennia to run. A webserver and website comes along with the +default Evennia install out of the box. You can view the default website by pointing your web browser +to http://localhost:4001. You will see a generic welcome page with some game statistics and a link +to the Evennia web client.

+

In this tutorial, we will add a new page that you can visit at http://localhost:4001/story.

+
+

1.1. Create the view

+

A django “view” is a normal Python function that django calls to render the HTML page you will see +in the web browser. Django can do all sorts of cool stuff to a page by using the view function — like +adding dynamic content or making changes to a page on the fly — but, here, we will just have it spit +back raw HTML.

+

Open mygame/web/website folder and create a new module file there named story.py. (You could also +put it in its own folder if you wanted to be neat but, if you do, don’t forget to add an empty +__init__.py file in the new folfder. Adding the __init__.py file tells Python that modules can be +imported from the new folder. For this tutorial, here’s what the example contents of your new story.py +module should look like:

+
# in mygame/web/website/story.py
+
+from django.shortcuts import render
+
+def storypage(request):
+    return render(request, "story.html")
+
+
+

The above view takes advantage of a shortcut provided for use by Django: render. The render shortcut +gives the template information from the request. For instance, it might provide the game name, and then +renders it.

+
+
+

1.2. The HTML page

+

Next, we need to find the location where Evennia (and Django) looks for HTML files, which are referred +to as templates in Django’s parlance. You can specify such locations in your settings (see the +TEMPLATES variable in default_settings.py for more info) but, here we’ll use an existing one.

+

Navigate to mygame/web/templates/website/ and create a new file there called story.html. This +is not an HTML tutorial, so this file’s content will be simple:

+
{% extends "base.html" %}
+{% block content %}
+<div class="row">
+  <div class="col">
+    <h1>A story about a tree</h1>
+    <p>
+        This is a story about a tree, a classic tale ...
+    </p>
+  </div>
+</div>
+{% endblock %}
+
+
+

As shown above, Django will allow us to extend our base styles easily because we’ve used the +render shortcut. If you’d prefer to not take advantage of Evennia’s base styles, you might +instead do something like this:

+
<html>
+  <body>
+    <h1>A story about a tree</h1>
+    <p>
+    This is a story about a tree, a classic tale ...
+  </body>
+</html>
+
+
+
+
+

1.3. The URL

+

When you enter the address http://localhost:4001/story in your web browser, Django will parse the +stub following the port — here, /story — to find out to which page you would like displayed. How +does Django know what HTML file /story should link to? You inform Django about what address stub +patterns correspond to what files in the file mygame/web/website/urls.py. Open it in your editor now.

+

Django looks for the variable urlpatterns in this file. You will want to add your new story pattern +and corresponding path to urlpatterns list — which is then, in turn, merged with the default +urlpatterns. Here’s how it could look:

+
"""
+This reroutes from an URL to a python view-function/class.
+The main web/urls.py includes these routes for all urls (the root of the url)
+so it can reroute to all website pages.
+"""
+from django.urls import path
+
+from web.website import story
+
+from evennia.web.website.urls import urlpatterns as evennia_website_urlpatterns
+
+# add patterns here
+urlpatterns = [
+    # path("url-pattern", imported_python_view),
+    path(r"story", story.storypage, name="Story"),
+]
+
+# read by Django
+urlpatterns = urlpatterns + evennia_website_urlpatterns
+
+
+

The above code imports our story.py Python view module from where we created it earlier — in +mygame/web/website/ — and then add the corresponding path instance. The first argument to +path is the pattern of the URL that we want to find ("story") as a regular expression, and +then the view function from story.py that we want to call.

+

That should be it. Reload Evennia — evennia reload — and you should now be able to navigate +your browser to the http://localhost:4001/story location and view your new story page as +rendered by Python!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Beginner-Tutorial/Part5/Beginner-Tutorial-Part5-Overview.html b/docs/latest/Howtos/Beginner-Tutorial/Part5/Beginner-Tutorial-Part5-Overview.html new file mode 100644 index 0000000000..9796cf6664 --- /dev/null +++ b/docs/latest/Howtos/Beginner-Tutorial/Part5/Beginner-Tutorial-Part5-Overview.html @@ -0,0 +1,177 @@ + + + + + + + + + Part 5: Showing the World — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Part 5: Showing the World

+ +

You have a working game! In part five we will look at the web-components of Evennia and how to modify them +to fit your game. We will also look at hosting your game and if you feel up to it we’ll also go through how +to bring your game online so you can invite your first players.

+
+

Lessons

+

TODO

+ +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Evennia-for-Diku-Users.html b/docs/latest/Howtos/Evennia-for-Diku-Users.html new file mode 100644 index 0000000000..9ae6a8d965 --- /dev/null +++ b/docs/latest/Howtos/Evennia-for-Diku-Users.html @@ -0,0 +1,319 @@ + + + + + + + + + Evennia for Diku Users — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia for Diku Users

+

Evennia represents a learning curve for those who used to code on +Diku type MUDs. While coding in Python is easy if you +already know C, the main effort is to get rid of old C programming habits. Trying to code Python the +way you code C will not only look ugly, it will lead to less optimal and harder to maintain code. +Reading Evennia example code is a good way to get a feel for how different problems are approached +in Python.

+

Overall, Python offers an extensive library of resources, safe memory management and excellent +handling of errors. While Python code does not run as fast as raw C code does, the difference is not +all that important for a text-based game. The main advantages of Python are an extremely fast +development cycle and easy ways to create game systems. Doing the same with C can take many times +more code and be harder to make stable and maintainable.

+
+

Core Differences

+
    +
  • As mentioned, the main difference between Evennia and a Diku-derived codebase is that Evennia is +written purely in Python. Since Python is an interpreted language there is no compile stage. It is +modified and extended by the server loading Python modules at run-time. It also runs on all computer +platforms Python runs on (which is basically everywhere).

  • +
  • Vanilla Diku type engines save their data in custom flat file type storage solutions. By +contrast, Evennia stores all game data in one of several supported SQL databases. Whereas flat files +have the advantage of being easier to implement, they (normally) lack many expected safety features +and ways to effectively extract subsets of the stored data. For example, if the server loses power +while writing to a flatfile it may become corrupt and the data lost. A proper database solution is +not susceptible to this - at no point is the data in a state where it cannot be recovered. Databases +are also highly optimized for querying large data sets efficiently.

  • +
+
+
+

Some Familiar Things

+

Diku expresses the character object referenced normally by:

+

struct char ch* then all character-related fields can be accessed by ch->. In Evennia, one must +pay attention to what object you are using, and when you are accessing another through back- +handling, that you are accessing the right object. In Diku C, accessing character object is normally +done by:

+
/* creating pointer of both character and room struct */
+
+void(struct char ch*, struct room room*){
+    int dam;
+    if (ROOM_FLAGGED(room, ROOM_LAVA)){
+        dam = 100;
+        ch->damage_taken = dam;
+    }
+}
+
+
+

As an example for creating Commands in Evennia via the from evennia import Command the character +object that calls the command is denoted by a class property as self.caller. In this example +self.caller is essentially the ‘object’ that has called the Command, but most of the time it is an +Account object. For a more familiar Diku feel, create a variable that becomes the account object as:

+
#mygame/commands/command.py
+
+from evennia import Command
+
+class CmdMyCmd(Command):
+    """
+    This is a Command Evennia Object
+    """
+
+    [...]
+
+    def func(self):
+        ch = self.caller
+        # then you can access the account object directly by using the familiar ch.
+        ch.msg("...")
+        account_name = ch.name
+        race = ch.db.race
+
+
+
+

As mentioned above, care must be taken what specific object you are working with. If focused on a +room object and you need to access the account object:

+
#mygame/typeclasses/room.py
+
+from evennia import DefaultRoom
+
+class MyRoom(DefaultRoom):
+    [...]
+
+    def is_account_object(self, object):
+        # a test to see if object is an account
+        [...]
+
+    def myMethod(self):
+        #self.caller would not make any sense, since self refers to the
+        # object of 'DefaultRoom', you must find the character obj first:
+        for ch in self.contents:
+            if self.is_account_object(ch):
+                # now you can access the account object with ch:
+                account_name = ch.name
+                race = ch.db.race
+
+
+
+
+

Emulating Evennia to Look and Feel Like A Diku/ROM

+

To emulate a Diku Mud on Evennia some work has to be done before hand. If there is anything that all +coders and builders remember from Diku/Rom days is the presence of VNUMs. Essentially all data was +saved in flat files and indexed by VNUMs for easy access. Evennia has the ability to emulate VNUMS +to the extent of categorising rooms/mobs/objs/trigger/zones[…] into vnum ranges.

+

Evennia has objects that are called Scripts. As defined, they are the ‘out of game’ instances that +exist within the mud, but never directly interacted with. Scripts can be used for timers, mob AI, +and even stand alone databases.

+

Because of their wonderful structure all mob, room, zone, triggers, etc… data can be saved in +independently created global scripts.

+

Here is a sample mob file from a Diku Derived flat file.

+
#0
+mob0~
+mob0~
+mob0
+~
+   Mob0
+~
+10 0 0 0 0 0 0 0 0 E
+1 20 9 0d0+10 1d2+0
+10 100
+8 8 0
+E
+#1
+Puff dragon fractal~
+Puff~
+Puff the Fractal Dragon is here, contemplating a higher reality.
+~
+   Is that some type of differential curve involving some strange, and unknown
+calculus that she seems to be made out of?
+~
+516106 0 0 0 2128 0 0 0 1000 E
+34 9 -10 6d6+340 5d5+5
+340 115600
+8 8 2
+BareHandAttack: 12
+E
+T 95
+
+
+

Each line represents something that the MUD reads in and does something with it. This isn’t easy to +read, but let’s see if we can emulate this as a dictionary to be stored on a database script created +in Evennia.

+

First, let’s create a global script that does absolutely nothing and isn’t attached to anything. You +can either create this directly in-game with the @py command or create it in another file to do some +checks and balances if for whatever reason the script needs to be created again. It +can be done like so:

+
from evennia import create_script
+
+mob_db = create_script("typeclasses.scripts.DefaultScript", key="mobdb",
+                       persistent=True, obj=None)
+mob_db.db.vnums = {}
+
+
+

Just by creating a simple script object and assigning it a ‘vnums’ attribute as a type dictionary. +Next we have to create the mob layout…

+
# vnum : mob_data
+
+mob_vnum_1 = {
+            'key' : 'puff',
+            'sdesc' : 'puff the fractal dragon',
+            'ldesc' : 'Puff the Fractal Dragon is here, ' \
+                      'contemplating a higher reality.',
+            'ddesc' : ' Is that some type of differential curve ' \
+                      'involving some strange, and unknown calculus ' \
+                      'that she seems to be made out of?',
+            [...]
+        }
+
+# Then saving it to the data, assuming you have the script obj stored in a variable.
+mob_db.db.vnums[1] = mob_vnum_1
+
+
+

This is a very ‘caveman’ example, but it gets the idea across. You can use the keys in the +mob_db.vnums to act as the mob vnum while the rest contains the data.

+

Much simpler to read and edit. If you plan on taking this route, you must keep in mind that by +default evennia ‘looks’ at different properties when using the look command for instance. If you +create an instance of this mob and make its self.key = 1, by default evennia will say:

+

Here is : 1

+

You must restructure all default commands so that the mud looks at different properties defined on +your mob.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Evennia-for-MUSH-Users.html b/docs/latest/Howtos/Evennia-for-MUSH-Users.html new file mode 100644 index 0000000000..80273aa4e7 --- /dev/null +++ b/docs/latest/Howtos/Evennia-for-MUSH-Users.html @@ -0,0 +1,342 @@ + + + + + + + + + Evennia for MUSH Users — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia for MUSH Users

+

This page is adopted from an article originally posted for the MUSH community here on +musoapbox.net.

+

MUSHes are text multiplayer games traditionally used for +heavily roleplay-focused game styles. They are often (but not always) utilizing game masters and +human oversight over code automation. MUSHes are traditionally built on the TinyMUSH-family of game +servers, like PennMUSH, TinyMUSH, TinyMUX and RhostMUSH. Also their siblings +MUCK and MOO are +often mentioned together with MUSH since they all inherit from the same +TinyMUD base. A major feature is the +ability to modify and program the game world from inside the game by using a custom scripting +language. We will refer to this online scripting as softcode here.

+

Evennia works quite differently from a MUSH both in its overall design and under the hood. The same +things are achievable, just in a different way. Here are some fundamental differences to keep in +mind if you are coming from the MUSH world.

+
+

Developers vs Players

+

In MUSH, users tend to code and expand all aspects of the game from inside it using softcode. A MUSH +can thus be said to be managed solely by Players with different levels of access. Evennia on the +other hand, differentiates between the role of the Player and the Developer.

+
    +
  • An Evennia Developer works in Python from outside the game, in what MUSH would consider +“hardcode”. Developers implement larger-scale code changes and can fundamentally change how the game +works. They then load their changes into the running Evennia server. Such changes will usually not +drop any connected players.

  • +
  • An Evennia Player operates from inside the game. Some staff-level players are likely to double +as developers. Depending on access level, players can modify and expand the game’s world by digging +new rooms, creating new objects, alias commands, customize their experience and so on. Trusted staff +may get access to Python via the @py command, but this would be a security risk for normal Players +to use. So the Player usually operates by making use of the tools prepared for them by the +Developer - tools that can be as rigid or flexible as the developer desires.

  • +
+
+
+

Collaborating on a game - Python vs Softcode

+

For a Player, collaborating on a game need not be too different between MUSH and Evennia. The +building and description of the game world can still happen mostly in-game using build commands, +using text tags and inline functions to prettify and customize the +experience. Evennia offers external ways to build a world but those are optional. There is also +nothing in principle stopping a Developer from offering a softcode-like language to Players if +that is deemed necessary.

+

For Developers of the game, the difference is larger: Code is mainly written outside the game in +Python modules rather than in-game on the command line. Python is a very popular and well-supported +language with tons of documentation and help to be found. The Python standard library is also a +great help for not having to reinvent the wheel. But that said, while Python is considered one of +the easier languages to learn and use it is undoubtedly very different from MUSH softcode.

+

While softcode allows collaboration in-game, Evennia’s external coding instead opens up the +possibility for collaboration using professional version control tools and bug tracking using +websites like github (or bitbucket for a free private repo). Source code can be written in proper +text editors and IDEs with refactoring, syntax highlighting and all other conveniences. In short, +collaborative development of an Evennia game is done in the same way most professional collaborative +development is done in the world, meaning all the best tools can be used.

+
+
+

@parent vs @typeclass and @spawn

+

Inheritance works differently in Python than in softcode. Evennia has no concept of a “master +object” that other objects inherit from. There is in fact no reason at all to introduce “virtual +objects” in the game world - code and data are kept separate from one another.

+

In Python (which is an object oriented +language) one instead creates classes - these are like blueprints from which you spawn any number +of object instances. Evennia also adds the extra feature that every instance is persistent in the +database (this means no SQL is ever needed). To take one example, a unique character in Evennia is +an instances of the class Character.

+

One parallel to MUSH’s @parent command may be Evennia’s @typeclass command, which changes which +class an already existing object is an instance of. This way you can literally turn a Character +into a Flowerpot on the spot.

+

if you are new to object oriented design it’s important to note that all object instances of a class +does not have to be identical. If they did, all Characters would be named the same. Evennia allows +to customize individual objects in many different ways. One way is through Attributes, which are +database-bound properties that can be linked to any object. For example, you could have an Orc +class that defines all the stuff an Orc should be able to do (probably in turn inheriting from some +Monster class shared by all monsters). Setting different Attributes on different instances +(different strength, equipment, looks etc) would make each Orc unique despite all sharing the same +class.

+

The @spawn command allows one to conveniently choose between different “sets” of Attributes to +put on each new Orc (like the “warrior” set or “shaman” set) . Such sets can even inherit one +another which is again somewhat remniscent at least of the effect of @parent and the object- +based inheritance of MUSH.

+

There are other differences for sure, but that should give some feel for things. Enough with the +theory. Let’s get down to more practical matters next. To install, see the +Getting Started instructions.

+
+
+

A first step making things more familiar

+

We will here give two examples of customizing Evennia to be more familiar to a MUSH Player.

+
+

Activating a multi-descer

+

By default Evennia’s desc command updates your description and that’s it. There is a more feature- +rich optional “multi-descer” in evennia/contrib/multidesc.py though. This alternative allows for +managing and combining a multitude of keyed descriptions.

+

To activate the multi-descer, cd to your game folder and into the commands sub-folder. There +you’ll find the file default_cmdsets.py. In Python lingo all *.py files are called modules. +Open the module in a text editor. We won’t go into Evennia in-game Commands and Command sets +further here, but suffice to say Evennia allows you to change which commands (or versions of +commands) are available to the player from moment to moment depending on circumstance.

+

Add two new lines to the module as seen below:

+
# the file mygame/commands/default_cmdsets.py
+# [...] 
+
+from evennia.contrib import multidescer   # <- added now
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    """
+    The CharacterCmdSet contains general in-game commands like look,
+    get etc available on in-game Character objects. It is merged with
+    the AccountCmdSet when an Account puppets a Character.
+    """
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+        """
+        Populates the cmdset
+        """
+        super().at_cmdset_creation()
+        #
+        # any commands you add below will overload the default ones.
+        #
+        self.add(multidescer.CmdMultiDesc())      # <- added now 
+# [...]
+
+
+

Note that Python cares about indentation, so make sure to indent with the same number of spaces as +shown above!

+

So what happens above? We import the +module +evennia/contrib/multidescer.py at the top. Once imported we can access stuff inside that module +using full stop (.). The multidescer is defined as a class CmdMultiDesc (we could find this out +by opening said module in a text editor). At the bottom we create a new instance of this class and +add it to the CharacterCmdSet class. For the sake of this tutorial we only need to know that +CharacterCmdSet contains all commands that should be be available to the Character by default.

+

This whole thing will be triggered when the command set is first created, which happens on server +start. So we need to reload Evennia with @reload - no one will be disconnected by doing this. If +all went well you should now be able to use desc (or +desc) and find that you have more +possibilities:

+
> help +desc                  # get help on the command
+> +desc eyes = His eyes are blue. 
+> +desc basic = A big guy.
+> +desc/set basic + + eyes    # we add an extra space between
+> look me
+A big guy. His eyes are blue.
+
+
+

If there are errors, a traceback will show in the server log - several lines of text showing +where the error occurred. Find where the error is by locating the line number related to the +default_cmdsets.py file (it’s the only one you’ve changed so far). Most likely you mis-spelled +something or missed the indentation. Fix it and either @reload again or run evennia start as +needed.

+
+
+

Customizing the multidescer syntax

+

As seen above the multidescer uses syntax like this (where |/ are Evennia’s tags for line breaks) +:

+
> +desc/set basic + |/|/ + cape + footwear + |/|/ + attitude 
+
+
+

This use of + was prescribed by the Developer that coded this +desc command. What if the +Player doesn’t like this syntax though? Do players need to pester the dev to change it? Not +necessarily. While Evennia does not allow the player to build their own multi-descer on the command +line, it does allow for re-mapping the command syntax to one they prefer. This is done using the +nick command.

+

Here’s a nick that changes how to input the command above:

+
> nick setdesc $1 $2 $3 $4 = +desc/set $1 + |/|/ + $2 + $3 + |/|/ + $4
+
+
+

The string on the left will be matched against your input and if matching, it will be replaced with +the string on the right. The $-type tags will store space-separated arguments and put them into +the replacement. The nick allows shell-like wildcards, so you +can use *, ?, [...], [!...] etc to match parts of the input.

+

The same description as before can now be set as

+
> setdesc basic cape footwear attitude 
+
+
+

With the nick functionality players can mitigate a lot of syntax dislikes even without the +developer changing the underlying Python code.

+
+
+
+

Next steps

+

If you are a Developer and are interested in making a more MUSH-like Evennia game, a good start is +to look into the Evennia Tutorial for a first MUSH-like game. +That steps through building a simple little game from scratch and helps to acquaint you with the +various corners of Evennia. There is also the [Tutorial for running roleplaying sessions](Evennia- +for-roleplaying-sessions) that can be of interest.

+

An important aspect of making things more familiar for Players is adding new and tweaking existing +commands. How this is done is covered by the [Tutorial on adding new commands](Adding-Command- +Tutorial). You may also find it useful to shop through the evennia/contrib/ folder. The +Tutorial world is a small single-player quest you can try (it’s not very MUSH- +like but it does show many Evennia concepts in action). Beyond that there are many more tutorials +to try out. If you feel you want a more visual overview you can also look at +Evennia in pictures.

+

… And of course, if you need further help you can always drop into the Evennia +chatroom +or post a question in our forum/mailing list!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Evennia-for-roleplaying-sessions.html b/docs/latest/Howtos/Evennia-for-roleplaying-sessions.html new file mode 100644 index 0000000000..8593862af0 --- /dev/null +++ b/docs/latest/Howtos/Evennia-for-roleplaying-sessions.html @@ -0,0 +1,809 @@ + + + + + + + + + Evennia for roleplaying sessions — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia for roleplaying sessions

+

This tutorial will explain how to set up a realtime or play-by-post tabletop style game using a +fresh Evennia server.

+

The scenario is thus: You and a bunch of friends want to play a tabletop role playing game online. +One of you will be the game master and you are all okay with playing using written text. You want +both the ability to role play in real-time (when people happen to be online at the same time) as +well as the ability for people to post when they can and catch up on what happened since they were +last online.

+

This is the functionality we will be needing and using:

+
    +
  • The ability to make one of you the GM (game master), with special abilities.

  • +
  • A Character sheet that players can create, view and fill in. It can also be locked so only the +GM can modify it.

  • +
  • A dice roller mechanism, for whatever type of dice the RPG rules require.

  • +
  • Rooms, to give a sense of location and to compartmentalize play going on- This means both +Character movements from location to location and GM explicitly moving them around.

  • +
  • Channels, for easily sending text to all subscribing accounts, regardless of location.

  • +
  • Account-to-Account messaging capability, including sending to multiple recipients +simultaneously, regardless of location.

  • +
+

We will find most of these things are already part of vanilla Evennia, but that we can expand on the +defaults for our particular use-case. Below we will flesh out these components from start to finish.

+
+

Starting out

+

We will assume you start from scratch. You need Evennia installed, as per the Setup Quickstart +instructions. Initialize a new game directory with evennia init <gamedirname>. In this tutorial we assume your game dir is simply named mygame. You can use the default database and keep all other settings to default for now. Familiarize yourself with the +mygame folder before continuing. You might want to browse the Beginner Tutorial tutorial, just to see roughly where things are modified.

+
+
+

The Game Master role

+

In brief:

+
    +
  • Simplest way: Being an admin, just give one account Admins permission using the standard perm command.

  • +
  • Better but more work: Make a custom command to set/unset the above, while tweaking the Character to show your renewed GM status to the other accounts.

  • +
+
+

The permission hierarchy

+

Evennia has the following permission hierarchy out of the box: Players, Helpers, Builders, Admins and finally Developers. We could change these but then we’d need to update our Default commands to use the changes. We want to keep this simple, so instead we map our different roles on top of this permission ladder.

+
    +
  1. Players is the permission set on normal players. This is the default for anyone creating a new +account on the server.

  2. +
  3. Helpers are like Players except they also have the ability to create/edit new help entries. +This could be granted to players who are willing to help with writing lore or custom logs for +everyone.

  4. +
  5. Builders is not used in our case since the GM should be the only world-builder.

  6. +
  7. Admins is the permission level the GM should have. Admins can do everything builders can +(create/describe rooms etc) but also kick accounts, rename them and things like that.

  8. +
  9. Developers-level permission are the server administrators, the ones with the ability to +restart/shutdown the server as well as changing the permission levels.

  10. +
+
+

The superuser is not part of the hierarchy and actually completely bypasses it. We’ll assume server admin(s) will “just” be Developers.

+
+
+
+

How to grant permissions

+

Only Developers can (by default) change permission level. Only they have access to the @perm +command:

+
> perm Yvonne
+Permissions on Yvonne: accounts
+
+> perm Yvonne = Admins
+> perm Yvonne
+Permissions on Yvonne: accounts, admins
+
+> perm/del Yvonne = Admins
+> perm Yvonne
+Permissions on Yvonne: accounts
+
+
+

There is no need to remove the basic Players permission when adding the higher permission: the +highest will be used. Permission level names are not case sensitive. You can also use both plural +and singular, so “Admins” gives the same powers as “Admin”.

+
+
+

Optional: Making a GM-granting command

+

Use of perm works out of the box, but it’s really the bare minimum. Would it not be nice if other +accounts could tell at a glance who the GM is? Also, we shouldn’t really need to remember that the +permission level is called “Admins”. It would be easier if we could just do @gm <account> and +@notgm <account> and at the same time change something make the new GM status apparent.

+

So let’s make this possible. This is what we’ll do:

+
    +
  1. We’ll customize the default Character class. If an object of this class has a particular flag, +its name will have the string(GM) added to the end.

  2. +
  3. We’ll add a new command, for the server admin to assign the GM-flag properly.

  4. +
+
+

Character modification

+

Let’s first start by customizing the Character. We recommend you browse the beginning of the +Account page to make sure you know how Evennia differentiates between the OOC “Account +objects” (not to be confused with the Accounts permission, which is just a string specifying your +access) and the IC “Character objects”.

+

Open mygame/typeclasses/characters.py and modify the default Character class:

+
# in mygame/typeclasses/characters.py
+
+# [...]
+
+class Character(DefaultCharacter):
+    # [...]
+    def get_display_name(self, looker, **kwargs):
+        """
+        This method customizes how character names are displayed. We assume
+        only permissions of types "Developers" and "Admins" require
+        special attention.
+        """
+        name = self.key
+        selfaccount = self.account     # will be None if we are not puppeted
+        lookaccount = looker.account   #              - " -
+
+        if selfaccount and selfaccount.db.is_gm:
+           # A GM. Show name as name(GM)
+           name = f"{name}(GM)"
+
+        if lookaccount and \
+          (lookaccount.permissions.get("Developers") or lookaccount.db.is_gm):
+            # Developers/GMs see name(#dbref) or name(GM)(#dbref)
+            name = f"{name}(#{self.id})"
+
+        return name
+
+
+

Above, we change how the Character’s name is displayed: If the account controlling this Character is +a GM, we attach the string (GM) to the Character’s name so everyone can tell who’s the boss. If we +ourselves are Developers or GM’s we will see database ids attached to Characters names, which can +help if doing database searches against Characters of exactly the same name. We base the “gm- +ingness” on having an flag (an Attribute) named is_gm. We’ll make sure new GM’s +actually get this flag below.

+
+

Extra exercise: This will only show the (GM) text on Characters puppeted by a GM account, +that is, it will show only to those in the same location. If we wanted it to also pop up in, say, +who listings and channels, we’d need to make a similar change to the Account typeclass in +mygame/typeclasses/accounts.py. We leave this as an exercise to the reader.

+
+
+
+

New @gm/@ungm command

+

We will describe in some detail how to create and add an Evennia command here with the +hope that we don’t need to be as detailed when adding commands in the future. We will build on +Evennia’s default “mux-like” commands here.

+

Open mygame/commands/command.py and add a new Command class at the bottom:

+
# in mygame/commands/command.py
+
+from evennia import default_cmds
+
+# [...]
+
+import evennia
+
+class CmdMakeGM(default_cmds.MuxCommand):
+    """
+    Change an account's GM status
+
+    Usage:
+      @gm <account>
+      @ungm <account>
+
+    """
+    # note using the key without @ means both @gm !gm etc will work
+    key = "gm"
+    aliases = "ungm"
+    locks = "cmd:perm(Developers)"
+    help_category = "RP"
+
+    def func(self):
+        "Implement the command"
+        caller = self.caller
+
+        if not self.args:
+            caller.msg("Usage: @gm account or @ungm account")
+            return
+
+        accountlist = evennia.search_account(self.args) # returns a list
+        if not accountlist:
+            caller.msg(f"Could not find account '{self.args}'")
+            return
+        elif len(accountlist) > 1:
+            caller.msg(f"Multiple matches for '{self.args}': {accountlist}")
+            return
+        else:
+            account = accountlist[0]
+
+        if self.cmdstring == "gm":
+            # turn someone into a GM
+            if account.permissions.get("Admins"):
+                caller.msg(f"Account {account} is already a GM.")
+            else:
+                account.permissions.add("Admins")
+                caller.msg(f"Account {account} is now a GM.")
+                account.msg(f"You are now a GM (changed by {caller}).")
+                account.character.db.is_gm = True
+        else:
+            # @ungm was entered - revoke GM status from someone
+            if not account.permissions.get("Admins"):
+                caller.msg(f"Account {account} is not a GM.")
+            else:
+                account.permissions.remove("Admins")
+                caller.msg(f"Account {account} is no longer a GM.")
+                account.msg(f"You are no longer a GM (changed by {caller}).")
+                del account.character.db.is_gm
+
+
+
+

All the command does is to locate the account target and assign it the Admins permission if we +used gm or revoke it if using the ungm alias. We also set/unset the is_gm Attribute that is +expected by our new Character.get_display_name method from earlier.

+
+

We could have made this into two separate commands or opted for a syntax like gm/revoke <accountname>. Instead we examine how this command was called (stored in self.cmdstring) in order to act accordingly. Either way works, practicality and coding style decides which to go with.

+
+

To actually make this command available (only to Developers, due to the lock on it), we add it to the default Account command set. Open the file mygame/commands/default_cmdsets.py and find the AccountCmdSet class:

+
# mygame/commands/default_cmdsets.py
+
+# [...]
+from commands.command import CmdMakeGM
+
+class AccountCmdSet(default_cmds.AccountCmdSet):
+    # [...]
+    def at_cmdset_creation(self):
+        # [...]
+        self.add(CmdMakeGM())
+
+
+
+

Finally, issue the reload command to update the server to your changes. Developer-level players +(or the superuser) should now have the gm/ungm command available.

+
+
+
+
+

Character sheet

+

In brief:

+
    +
  • Use Evennia’s EvTable/EvForm to build a Character sheet

  • +
  • Tie individual sheets to a given Character.

  • +
  • Add new commands to modify the Character sheet, both by Accounts and GMs.

  • +
  • Make the Character sheet lockable by a GM, so the Player can no longer modify it.

  • +
+
+

Building a Character sheet

+

There are many ways to build a Character sheet in text, from manually pasting strings together to more automated ways. Exactly what is the best/easiest way depends on the sheet one tries to create. We will here show two examples using the EvTable and EvForm utilities.Later we will create Commands to edit and display the output from those utilities.

+
+

Note that these docs don’t show the color. see the text tag documentation for how to add color to the tables and forms.

+
+
+

Making a sheet with EvTable

+

EvTable is a text-table generator. It helps with displaying text in ordered rows and columns. This is an example of using it in code:

+
# this can be tried out in a Python shell like iPython
+
+from evennia.utils import evtable
+
+# we hardcode these for now, we'll get them as input later
+STR, CON, DEX, INT, WIS, CHA = 12, 13, 8, 10, 9, 13
+
+table = evtable.EvTable("Attr", "Value",
+                        table = [
+                           ["STR", "CON", "DEX", "INT", "WIS", "CHA"],
+                           [STR, CON, DEX, INT, WIS, CHA]
+                        ], align='r', border="incols")
+
+
+

Above, we create a two-column table by supplying the two columns directly. We also tell the table to be right-aligned and to use the “incols” border type (borders drawns only in between columns). The EvTable class takes a lot of arguments for customizing its look, you can see some of the possible keyword arguments here. Once you have the table you could also retroactively add new columns and rows to it with table.add_row() and table.add_column(): if necessary the table will expand with empty rows/columns to always remain rectangular.

+

The result from printing the above table will be

+
table_string = str(table)
+
+print(table_string)
+
+ Attr | Value
+~~~~~~+~~~~~~~
+  STR |    12
+  CON |    13
+  DEX |     8
+  INT |    10
+  WIS |     9
+  CHA |    13
+
+
+

This is a minimalistic but effective Character sheet. By combining the table_string with other +strings one could build up a reasonably full graphical representation of a Character. For more +advanced layouts we’ll look into EvForm next.

+
+
+

Making a sheet with EvForm

+

EvForm allows the creation of a two-dimensional “graphic” made by text characters. On this surface, one marks and tags rectangular regions (“cells”) to be filled with content. This content can be either normal strings or EvTable instances (see the previous section, one such instance would be the table variable in that example).

+

In the case of a Character sheet, these cells would be comparable to a line or box where you could +enter the name of your character or their strength score. EvMenu also easily allows to update the +content of those fields in code (it use EvTables so you rebuild the table first before re-sending it +to EvForm).

+

The drawback of EvForm is that its shape is static; if you try to put more text in a region than it +was sized for, the text will be cropped. Similarly, if you try to put an EvTable instance in a field +too small for it, the EvTable will do its best to try to resize to fit, but will eventually resort +to cropping its data or even give an error if too small to fit any data.

+

An EvForm is defined in a Python module. Create a new file mygame/world/charsheetform.py and +modify it thus:

+
#coding=utf-8
+
+# in mygame/world/charsheetform.py
+
+FORMCHAR = "x"
+TABLECHAR = "c"
+
+FORM = """
+.--------------------------------------.
+|                                      |
+| Name: xxxxxxxxxxxxxx1xxxxxxxxxxxxxxx |
+|       xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
+|                                      |
+ >------------------------------------<
+|                                      |
+| ccccccccccc  Advantages:             |
+| ccccccccccc   xxxxxxxxxxxxxxxxxxxxxx |
+| ccccccccccc   xxxxxxxxxx3xxxxxxxxxxx |
+| ccccccccccc   xxxxxxxxxxxxxxxxxxxxxx |
+| ccccc2ccccc  Disadvantages:          |
+| ccccccccccc   xxxxxxxxxxxxxxxxxxxxxx |
+| ccccccccccc   xxxxxxxxxx4xxxxxxxxxxx |
+| ccccccccccc   xxxxxxxxxxxxxxxxxxxxxx |
+|                                      |
++--------------------------------------+
+"""
+
+
+

The #coding statement (which must be put on the very first line to work) tells Python to use the +utf-8 encoding for the file. Using the FORMCHAR and TABLECHAR we define what single-character we +want to use to “mark” the regions of the character sheet holding cells and tables respectively. +Within each block (which must be separated from one another by at least one non-marking character) we embed identifiers 1-4 to identify each block. The identifier could be any single character except for the FORMCHAR and TABLECHAR

+
+

You can still use FORMCHAR and TABLECHAR elsewhere in your sheet, but not in a way that it would identify a cell/table. The smallest identifiable cell/table area is 3 characters wide including the identifier (for example x2x).

+
+

Now we will map content to this form.

+
# again, this can be tested in a Python shell
+
+# hard-code this info here, later we'll ask the
+# account for this info. We will re-use the 'table'
+# variable from the EvTable example.
+
+NAME = "John, the wise old admin with a chip on his shoulder"
+ADVANTAGES = "Language-wiz, Intimidation, Firebreathing"
+DISADVANTAGES = "Bad body odor, Poor eyesight, Troubled history"
+
+from evennia.utils import evform
+
+# load the form from the module
+form = evform.EvForm("world/charsheetform.py")
+
+# map the data to the form
+form.map(cells={"1":NAME, "3": ADVANTAGES, "4": DISADVANTAGES},
+         tables={"2":table})
+
+
+

We create some RP-sounding input and re-use the table variable from the previous EvTable +example.

+
+

Note, that if you didn’t want to create the form in a separate module you could also load it directly into the EvForm call like this: EvForm(form={"FORMCHAR":"x", "TABLECHAR":"c", "FORM": formstring}) where FORM specifies the form as a string in the same way as listed in the module above. Note however that the very first line of the FORM string is ignored, so start with a \n.

+
+

We then map those to the cells of the form:

+
print(form)
+
+
+
.--------------------------------------.
+|                                      |
+| Name: John, the wise old admin with |
+|        a chip on his shoulder        |
+|                                      |
+ >------------------------------------<
+|                                      |
+|  Attr|Value  Advantages:             |
+| ~~~~~+~~~~~   Language-wiz,          |
+|   STR|   12   Intimidation,          |
+|   CON|   13   Firebreathing          |
+|   DEX|    8  Disadvantages:          |
+|   INT|   10   Bad body odor, Poor    |
+|   WIS|    9   eyesight, Troubled     |
+|   CHA|   13   history                |
+|                                      |
++--------------------------------------+
+
+
+

As seen, the texts and tables have been slotted into the text areas and line breaks have been added where needed. We chose to just enter the Advantages/Disadvantages as plain strings here, meaning long names ended up split between rows. If we wanted more control over the display we could have inserted \n line breaks after each line or used a borderless EvTable to display those as well.

+
+
+
+

Tie a Character sheet to a Character

+

We will assume we go with the EvForm example above. We now need to attach this to a Character so it can be modified. For this we will modify our Character class a little more:

+
# mygame/typeclasses/character.py
+
+from evennia.utils import evform, evtable
+
+[...]
+
+class Character(DefaultCharacter):
+    [...]
+    def at_object_creation(self):
+        "called only once, when object is first created"
+        # we will use this to stop account from changing sheet
+        self.db.sheet_locked = False
+        # we store these so we can build these on demand
+        self.db.chardata  = {"str": 0,
+                             "con": 0,
+                             "dex": 0,
+                             "int": 0,
+                             "wis": 0,
+                             "cha": 0,
+                             "advantages": "",
+                             "disadvantages": ""}
+        self.db.charsheet = evform.EvForm("world/charsheetform.py")
+        self.update_charsheet()
+
+    def update_charsheet(self):
+        """
+        Call this to update the sheet after any of the ingoing data
+        has changed.
+        """
+        data = self.db.chardata
+        table = evtable.EvTable("Attr", "Value",
+                        table = [
+                           ["STR", "CON", "DEX", "INT", "WIS", "CHA"],
+                           [data["str"], data["con"], data["dex"],
+                            data["int"], data["wis"], data["cha"]]],
+                           align='r', border="incols")
+        self.db.charsheet.map(tables={"2": table},
+                              cells={"1":self.key,
+                                     "3":data["advantages"],
+                                     "4":data["disadvantages"]})
+
+
+
+

Use reload to make this change available to all newly created Characters. Already existing +Characters will not have the charsheet defined, since at_object_creation is only called once. +The easiest to force an existing Character to re-fire its at_object_creation is to use the +typeclass command in-game:

+
typeclass/force <Character Name>
+
+
+
+
+

Command for Account to change Character sheet

+

We will add a command to edit the sections of our Character sheet. Open +mygame/commands/command.py.

+
# at the end of mygame/commands/command.py
+
+ALLOWED_ATTRS = ("str", "con", "dex", "int", "wis", "cha")
+ALLOWED_FIELDNAMES = ALLOWED_ATTRS + \
+                     ("name", "advantages", "disadvantages")
+
+def _validate_fieldname(caller, fieldname):
+    "Helper function to validate field names."
+    if fieldname not in ALLOWED_FIELDNAMES:
+        list_of_fieldnames = ", ".join(ALLOWED_FIELDNAMES)
+        err = f"Allowed field names: {list_of_fieldnames}"
+        caller.msg(err)
+        return False
+    if fieldname in ALLOWED_ATTRS and not value.isdigit():
+        caller.msg(f"{fieldname} must receive a number.")
+        return False
+    return True
+
+class CmdSheet(MuxCommand):
+    """
+    Edit a field on the character sheet
+
+    Usage:
+      @sheet field value
+
+    Examples:
+      @sheet name Ulrik the Warrior
+      @sheet dex 12
+      @sheet advantages Super strength, Night vision
+
+    If given without arguments, will view the current character sheet.
+
+    Allowed field names are:
+       name,
+       str, con, dex, int, wis, cha,
+       advantages, disadvantages
+
+    """
+
+    key = "sheet"
+    aliases = "editsheet"
+    locks = "cmd: perm(Players)"
+    help_category = "RP"
+
+    def func(self):
+        caller = self.caller
+        if not self.args or len(self.args) < 2:
+            # not enough arguments. Display the sheet
+            if sheet:
+                caller.msg(caller.db.charsheet)
+            else:
+                caller.msg("You have no character sheet.")
+            return
+
+        # if caller.db.sheet_locked:
+            caller.msg("Your character sheet is locked.")
+            return
+
+        # split input by whitespace, once
+        fieldname, value = self.args.split(None, 1)
+        fieldname = fieldname.lower() # ignore case
+
+        if not _validate_fieldnames(caller, fieldname):
+            return
+        if fieldname == "name":
+            self.key = value
+        else:
+            caller.chardata[fieldname] = value
+        caller.update_charsheet()
+        caller.msg(f"{fieldname} was set to {value}.")
+
+
+
+

Most of this command is error-checking to make sure the right type of data was input. Note how the sheet_locked Attribute is checked and will return if not set.

+

This command you import into mygame/commands/default_cmdsets.py and add to the CharacterCmdSet, in the same way the @gm command was added to the AccountCmdSet earlier.

+
+
+

Commands for GM to change Character sheet

+

Game masters use basically the same input as Players do to edit a character sheet, except they can do it on other players than themselves. They are also not stopped by any sheet_locked flags.

+
# continuing in mygame/commands/command.py
+
+class CmdGMsheet(MuxCommand):
+    """
+    GM-modification of char sheets
+
+    Usage:
+      @gmsheet character [= fieldname value]
+
+    Switches:
+      lock - lock the character sheet so the account
+             can no longer edit it (GM's still can)
+      unlock - unlock character sheet for Account
+             editing.
+
+    Examples:
+      @gmsheet Tom
+      @gmsheet Anna = str 12
+      @gmsheet/lock Tom
+
+    """
+    key = "gmsheet"
+    locks = "cmd: perm(Admins)"
+    help_category = "RP"
+
+    def func(self):
+        caller = self.caller
+        if not self.args:
+            caller.msg("Usage: @gmsheet character [= fieldname value]")
+
+        if self.rhs:
+            # rhs (right-hand-side) is set only if a '='
+            # was given.
+            if len(self.rhs) < 2:
+                caller.msg("You must specify both a fieldname and value.")
+                return
+            fieldname, value = self.rhs.split(None, 1)
+            fieldname = fieldname.lower()
+            if not _validate_fieldname(caller, fieldname):
+                return
+            charname = self.lhs
+        else:
+            # no '=', so we must be aiming to look at a charsheet
+            fieldname, value = None, None
+            charname = self.args.strip()
+
+        character = caller.search(charname, global_search=True)
+        if not character:
+            return
+
+        if "lock" in self.switches:
+            if character.db.sheet_locked:
+                caller.msg("The character sheet is already locked.")
+            else:
+                character.db.sheet_locked = True
+                caller.msg(f"{character.key} can no longer edit their character sheet.")
+        elif "unlock" in self.switches:
+            if not character.db.sheet_locked:
+                caller.msg("The character sheet is already unlocked.")
+            else:
+                character.db.sheet_locked = False
+                caller.msg(f"{character.key} can now edit their character sheet.")
+
+        if fieldname:
+            if fieldname == "name":
+                character.key = value
+            else:
+                character.db.chardata[fieldname] = value
+            character.update_charsheet()
+            caller.msg(f"You set {character.key}'s {fieldname} to {value}.")
+        else:
+            # just display
+            caller.msg(character.db.charsheet)
+
+
+

The gmsheet command takes an additional argument to specify which Character’s character sheet to edit. It also takes /lock and /unlock switches to block the Player from tweaking their sheet.

+

Before this can be used, it should be added to the default CharacterCmdSet in the same way as the normal sheet. Due to the lock set on it, this command will only be available to Admins (i.e. GMs) or higher permission levels.

+
+
+
+

Dice roller

+

Evennia’s contrib folder already comes with a full dice roller. To add it to the game, simply import contrib.dice.CmdDice into mygame/commands/default_cmdsets.py and add CmdDice to the CharacterCmdset as done with other commands in this tutorial. After a @reload you will be able +to roll dice using normal RPG-style format:

+
roll 2d6 + 3
+7
+
+
+

Use help dice to see what syntax is supported or look at evennia/contrib/dice.py to see how it’s implemented.

+
+
+

Rooms

+

Evennia comes with rooms out of the box, so no extra work needed. A GM will automatically have all needed building commands available. A fuller go-through is found in the Building tutorial. +Here are some useful highlights:

+
    +
  • dig roomname;alias = exit_there;alias, exit_back;alias - this is the basic command for digging a new room. You can specify any exit-names and just enter the name of that exit to go there.

  • +
  • tunnel direction = roomname - this is a specialized command that only accepts directions in the cardinal directions (n,ne,e,se,s,sw,w,nw) as well as in/out and up/down. It also automatically builds “matching” exits back in the opposite direction.

  • +
  • create/drop objectname - this creates and drops a new simple object in the current location.

  • +
  • desc obj - change the look-description of the object.

  • +
  • tel object = location - teleport an object to a named location.

  • +
  • search objectname - locate an object in the database.

  • +
+
+

TODO: Describe how to add a logging room, that logs says and poses to a log file that people can access after the fact.

+
+
+
+

Channels

+

Evennia comes with Channels in-built and they are described fully in the documentation. For brevity, here are the relevant commands for normal use:

+
    +
  • channel/create = new_channel;alias;alias = short description - Creates a new channel.

  • +
  • channel/sub channel - subscribe to a channel.

  • +
  • channel/unsub channel - unsubscribel from a channel.

  • +
  • channels lists all available channels, including your subscriptions and any aliases you have set up for them.

  • +
+

You can read channel history: if you for example are chatting on the public channel you can do +public/history to see the 20 last posts to that channel or public/history 32 to view twenty +posts backwards, starting with the 32nd from the end.

+
+
+

PMs

+

To send PMs to one another, players can use the page (or tell) command:

+
page recipient = message
+page recipient, recipient, ... = message
+
+
+

Players can use page alone to see the latest messages. This also works if they were not online +when the message was sent.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Howto-Add-Object-Weight.html b/docs/latest/Howtos/Howto-Add-Object-Weight.html new file mode 100644 index 0000000000..8ad790b333 --- /dev/null +++ b/docs/latest/Howtos/Howto-Add-Object-Weight.html @@ -0,0 +1,253 @@ + + + + + + + + + Give objects weight — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Give objects weight

+

All in-game objets you can touch usually has some weight. What weight does varies from game to game. Commonly it limits how much you can carry. A heavy stone may also hurt you more than a ballon, if it falls on you. If you want to get fancy, a pressure plate may only trigger if the one stepping on it is heavy enough.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
# inside your mygame/typeclasses/objects.py
+
+from evennia import DefaultObject 
+from evennia import AttributeProperty 
+
+class ObjectParent: 
+
+    weight = AttributeProperty(default=1, autocreate=False)
+
+    @property 
+    def total_weight(self):
+        return self.weight + sum(obj.total_weight for obj in self.contents) 
+
+
+class Object(ObjectParent, DefaultObject):
+    # ...
+
+
+ +
    +
  • Line 6: We use the ObjectParent mixin. Since this mixin is used for Characters, Exits and Rooms as well as for Object, it means all of those will automatically also have weight!

  • +
  • Line 8: We use an AttributeProperty to set up the ‘default’ weight of 1 (whatever that is). Setting autocreate=False means no actual Attribute will be created until the weight is actually changed from the default of 1. See the AttributeProperty documentation for caveats with this.

  • +
  • Line 10 and 11: Using the @property decorator on total_weight means that we will be able to call obj.total_weight instead of obj.total_weight() later.

  • +
  • Line 12: We sum up all weights from everything “in” this object, by looping over self.contents. Since all objects will have weight now, this should always work!

  • +
+

Let’s check out the weight of some trusty boxes

+
> create/drop box1
+> py self.search("box1").weight
+1 
+> py self.search("box1").total_weight
+1 
+
+
+

Let’s put another box into the first one.

+
> create/drop box2 
+> py self.search("box2").total_weight
+1 
+> py self.search("box2").location = self.search("box1")
+> py self.search(box1).total_weight 
+2
+
+
+
+

Limit inventory by weight carried

+

To limit how much you can carry, you first need to know your own strength

+
# in mygame/typeclasses/characters.py 
+
+from evennia import AttributeProperty
+
+# ... 
+
+class Character(ObjectParent, DefaultCharacter): 
+
+    carrying_capacity = AttributeProperty(10, autocreate=False)
+
+    @property
+    def carried_weight(self):
+        return self.total_weight - self.weight
+
+
+
+

Here we make sure to add another AttributeProperty telling us how much to carry. In a real game, this may be based on how strong the Character is. When we consider how much weight we already carry, we should not include our own weight, so we subtract that.

+

To honor this limit, we’ll need to override the default get command.

+ +
# in mygame/commands/command.py 
+
+# ... 
+from evennia import default_cmds 
+
+# ... 
+
+class WeightAwareCmdGet(default_cmds.CmdGet):
+
+    def func(self):
+        caller = self.caller 
+        if not self.args: 
+            caller.msg("Get what?")
+            return 
+
+        obj = caller.search(self.args)
+
+        if (obj.weight + caller.carried_weight 
+                > caller.carrying_capacity):
+            caller.msg("You can't carry that much!")
+            return 
+        super().func()
+
+
+

Here we add an extra check for the weight of the thing we are trying to pick up, then we call the normal CmdGet with super().func().

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Howto-Command-Cooldown.html b/docs/latest/Howtos/Howto-Command-Cooldown.html new file mode 100644 index 0000000000..c3f14f75e9 --- /dev/null +++ b/docs/latest/Howtos/Howto-Command-Cooldown.html @@ -0,0 +1,289 @@ + + + + + + + + + Adding Command Cooldowns — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Adding Command Cooldowns

+
> hit goblin with sword 
+You strike goblin with the sword. It dodges! 
+> hit goblin with sword 
+You are off-balance and can't attack again yet.
+
+
+

Some types of games want to limit how often a command can be run. If a +character casts the spell Firestorm, you might not want them to spam that +command over and over. In an advanced combat system, a massive swing may +offer a chance of lots of damage at the cost of not being able to re-do it for +a while.

+

Such effects are called command cooldowns.

+ +

This howto exemplifies a very resource-efficient way to do cooldowns. A more +‘active’ way is to use asynchronous delays as in the Command-Duration howto suggests. The two howto’s might be useful to combine if you want to echo some message to the user after the cooldown ends.

+
+

An efficient cooldown

+

The idea is that when a Command runs, we store the time it runs. When it next runs, we check again the current time. The command is only allowed to run if enough time passed since now and the previous run. This is a very efficient implementation that only checks on-demand.

+
# in, say, mygame/commands/spells.py
+
+import time
+from evennia import default_cmds
+
+class CmdSpellFirestorm(default_cmds.MuxCommand):
+    """
+    Spell - Firestorm
+
+    Usage:
+      cast firestorm <target>
+
+    This will unleash a storm of flame. You can only release one
+    firestorm every five minutes (assuming you have the mana).
+    """
+    key = "cast firestorm"
+    rate_of_fire = 60 * 2  # 2 minutes
+
+    def func(self):
+        "Implement the spell"
+
+        now = time.time()
+        last_cast = caller.db.firestorm_last_cast  # could be None
+        if last_cast and (now - last_cast < self.rate_of_fire):
+            message = "You cannot cast this spell again yet."
+            self.caller.msg(message)
+            return
+
+        # [the spell effect is implemented]
+
+        # if the spell was successfully cast, store the casting time
+        self.caller.db.firestorm_last_cast = now
+
+
+

We specify rate_of_fire and then just check for an Attribute firestorm_last_cast on the caller. It is either None (because the spell was never cast before) or an timestamp representing the last time the spell was cast.

+
+

Non-Persistent cooldown

+

The above implementation will survive a reload. If you don’t want that, you can just switch to let firestorm_last_cast be a NAtrribute instead. For example:

+
        last_cast = caller.ndb.firestorm_last_cast
+        # ... 
+        self.caller.ndb.firestorm_last_cast = now 
+
+
+

That is, use .ndb instead of .db. Since a NAttributes are purely in-memory, they can be faster to read and write to than an Attribute. So this can be more optimal if your intervals are short and need to change often. The drawback is that they’ll reset if the server reloads.

+
+
+
+

Make a cooldown-aware command parent

+

If you have many different spells or other commands with cooldowns, you don’t +want to have to add this code every time. Instead you can make a “cooldown +command mixin” class. A mixin is a class that you can ‘add’ to another class +(via multiple inheritance) to give it some special ability. Here’s an example +with persistent storage:

+
# in, for example, mygame/commands/mixins.py
+
+import time
+
+class CooldownCommandMixin:
+
+    rate_of_fire = 60
+    cooldown_storage_key = "last_used"
+    cooldown_storage_category = "cmd_cooldowns"
+
+    def check_cooldown(self):
+        last_time = self.caller.attributes.get(
+            key=self.cooldown_storage_key,
+            category=self.cooldown_storage_category)
+        )
+        return (time.time() - last_time) < self.rate_of_fire
+
+    def update_cooldown(self):
+        self.caller.attribute.add(
+            key=self.cooldown_storage_key,
+            value=time.time(),
+            category=self.cooldown_storage_category
+
+        )
+
+
+

This is meant to be mixed into a Command, so we assume self.caller exists. +We allow for setting what Attribute key/category to use to store the cooldown.

+

It also uses an Attribute-category to make sure what it stores is not mixed up +with other Attributes on the caller.

+

Here’s how it’s used:

+
# in, say, mygame/commands/spells.py
+
+from evennia import default_cmds
+from .mixins import CooldownCommandMixin
+
+
+class CmdSpellFirestorm(
+        CooldownCommandMixin, default_cmds.MuxCommand):
+    key = "cast firestorm"
+
+    cooldown_storage_key = "firestorm_last_cast"
+    rate_of_fire = 60 * 2
+
+    def func(self):
+
+        if not self.check_cooldown():
+            self.caller.msg("You cannot cast this spell again yet.")
+            return
+
+        # [the spell effect happens]
+
+        self.update_cooldown()
+
+
+
+

So the same as before, we have just hidden away the cooldown checks and you can +reuse this mixin for all your cooldowns.

+
+

Command crossover

+

This example of cooldown-checking also works between commands. For example, +you can have all fire-related spells store the cooldown with the same +cooldown_storage_key (like fire_spell_last_used). That would mean casting +of Firestorm would block all other fire-related spells for a while.

+

Similarly, when you take that big sword swing, other types of attacks could +be blocked before you can recover your balance.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Howto-Command-Duration.html b/docs/latest/Howtos/Howto-Command-Duration.html new file mode 100644 index 0000000000..48889bd7f4 --- /dev/null +++ b/docs/latest/Howtos/Howto-Command-Duration.html @@ -0,0 +1,553 @@ + + + + + + + + + Commands that take time to finish — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Commands that take time to finish

+
> craft fine sword 
+You start crafting a fine sword. 
+> north 
+You are too focused on your crafting, and can't move!
+You create the blade of the sword. 
+You create the pommel of the sword. 
+You finish crafting a Fine Sword.
+
+
+

In some types of games a command should not start and finish immediately.

+

Loading a crossbow might take a bit of time to do - time you don’t have when +the enemy comes rushing at you. Crafting that armour will not be immediate +either. For some types of games the very act of moving or changing pose all +comes with a certain time associated with it.

+

There are two main suitable ways to introduce a ‘delay’ in a Command’s execution:

+
    +
  • Using yield in the Command’s func method.

  • +
  • Using the evennia.utils.delay utility function.

  • +
+

We’ll simplify both below.

+
+

Pause commands with yield

+

The yield keyword is a reserved word in Python. It’s used to create generators, which are interesting in their own right. For the purpose of this howto though, we just need to know that Evennia will use it to ‘pause’ the execution of the command for a certain time.

+ +
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
class CmdTest(Command):
+
+    """
+    A test command just to test waiting.
+
+    Usage:
+        test
+
+    """
+
+    key = "test"
+
+    def func(self):
+        self.msg("Before ten seconds...")
+        yield 10
+        self.msg("Afterwards.")
+
+
+
    +
  • Line 15 : This is the important line. The yield 10 tells Evennia to “pause” the command +and to wait for 10 seconds to execute the rest. If you add this command and +run it, you’ll see the first message, then, after a pause of ten seconds, the +next message. You can use yield several times in your command.

  • +
+

This syntax will not “freeze” all commands. While the command is “pausing”, you can execute other commands (or even call the same command again). And other players aren’t frozen either.

+
+

Using yield is non-persistent. If you reload the game while a command is “paused”, that pause state is lost and it will not resume after the server has reloaded.

+
+
+
+

Pause commands with utils.delay

+

The yield syntax is easy to read, easy to understand, easy to use. But it’s non-persistent and not that flexible if you want more advanced options.

+

The evennia.utils.delay represents is a more powerful way to introduce delays. Unlike yield, it
+can be made persistent and also works outside of Command.func. It’s however a little more cumbersome to write since unlike yield it will not actually stop at the line it’s called.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
from evennia import default_cmds, utils
+    
+class CmdEcho(default_cmds.MuxCommand):
+    """
+    Wait for an echo
+    
+    Usage: 
+      echo <string>
+    
+    Calls and waits for an echo.
+    """
+    key = "echo"
+    
+    def echo(self):
+        "Called after 10 seconds."
+        shout = self.args
+        self.caller.msg(
+            "You hear an echo: "
+            f"{shout.upper()} ... "
+            f"{shout.capitalize()} ... "
+            f"{shout.lower()}"
+        )
+    
+    def func(self):
+        """
+         This is called at the initial shout.            
+        """
+        self.caller.msg(f"You shout '{self.args}' and wait for an echo ...")
+        # this waits non-blocking for 10 seconds, then calls self.echo
+        utils.delay(10, self.echo) # call echo after 10 seconds
+    
+
+
+

Import this new echo command into the default command set and reload the server. You will find that it will take 10 seconds before you see your shout coming back.

+
    +
  • Line 14: We add a new method echo. This is a callback - a method/function we will call after a certain time.

  • +
  • Line 30: Here we use utils.delay to tell Evennia “Please wait for 10 seconds, then call “self.echo”. Note how we pass self.echo and not self.echo()! If we did the latter, echo would fire immediately. Instead we let Evennia do this call for us ten seconds later.

  • +
+

You will also find that this is a non-blocking effect; you can issue other commands in the interim and the game will go on as usual. The echo will come back to you in its own time.

+

The call signature for utils.delay is:

+
utils.delay(timedelay, callback, persistent=False, *args, **kwargs) 
+
+
+ +

If you set persistent=True, this delay will survive a reload. If you pass *args and/or **kwargs, they will be passed on into the callback. So this way you can pass more complex arguments to the delayed function.

+

It’s important to remember that the delay() call will not “pause” at that point when it is +called (the way yield does in the previous section). The lines after the delay() call will +actually execute right away. What you must do is to tell it which function to call after the time +has passed (its “callback”). This may sound strange at first, but it is normal practice in +asynchronous systems. You can also link such calls together:

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
from evennia import default_cmds, utils
+    
+class CmdEcho(default_cmds.MuxCommand):
+    """
+    waits for an echo
+    
+    Usage: 
+      echo <string>
+    
+    Calls and waits for an echo
+    """
+    key = "echo"
+    
+    def func(self):
+        "This sets off a chain of delayed calls"
+        self.caller.msg(f"You shout '{self.args}', waiting for an echo ...")
+
+        # wait 2 seconds before calling self.echo1
+        utils.delay(2, self.echo1)
+    
+    # callback chain, started above
+    def echo1(self):
+        "First echo"
+        self.caller.msg(f"... {self.args.upper()}")
+        # wait 2 seconds for the next one
+        utils.delay(2, self.echo2)
+
+    def echo2(self):
+        "Second echo"
+        self.caller.msg(f"... {self.args.capitalize()}")
+        # wait another 2 seconds
+        utils.delay(2, callback=self.echo3)
+
+    def echo3(self):
+        "Last echo"
+        self.caller.msg(f"... {self.args.lower()} ...")
+
+
+

The above version will have the echoes arrive one after another, each separated by a two second +delay.

+
    +
  • Line 19: This sets off the chain, telling Evennia to wait 2 seconds before calling self.echo1.

  • +
  • Line 22: This is called after 2 seconds. It tells Evennia to wait another 2 seconds before calling self.echo2.

  • +
  • Line 28: This is called after yet another 2 seonds (4s total). It tells Evennia to wait another 2 seconds before calling, self.echo3.

  • +
  • Line34 Called after another 2 seconds (6s total). This ends the delay-chain.

  • +
+
> echo Hello!
+... HELLO!
+... Hello!
+... hello! ...
+
+
+
+

Warning

+

What about time.sleep?

+

You may be aware of the time.sleep function coming with Python. Doing `time.sleep(10) pauses Python for 10 seconds. Do not use this, it will not work with Evennia. If you use it, you will block the entire server (everyone!) for ten seconds!

+

If you want specifics, utils.delay is a thin wrapper around a Twisted Deferred. This is an asynchronous concept.

+
+
+
+

Making a blocking command

+

Both yield or utils.delay() pauses the command but allows the user to use other commands while the first one waits to finish.

+

In some cases you want to instead have that command ‘block’ other commands from running. An example is crafting a helmet: most likely you should not be able to start crafting a shield at the same time. Or even walk out of the smithy.

+

The simplest way of implementing blocking is to use the technique covered in the How to implement a Command Cooldown tutorial. In that tutorial we cooldowns are implemented by comparing the current time with the last time the command was used. This is the best approach if you can get away with it. It could work well for our crafting example … if you don’t want to automatically update the player on their progress.

+

In short: +- If you are fine with the player making an active input to check their status, compare timestamps as done in the Command-cooldown tutorial. On-demand is by far the most efficent. +- If you want Evennia to tell the user their status without them taking a further action, you need to use yield , delay (or some other active time-keeping method).

+

Here is an example where we will use utils.delay to tell the player when the cooldown has passed:

+
from evennia import utils, default_cmds
+    
+class CmdBigSwing(default_cmds.MuxCommand):
+    """
+    swing your weapon in a big way
+
+    Usage:
+      swing <target>
+    
+    Makes a mighty swing. Doing so will make you vulnerable
+    to counter-attacks before you can recover. 
+    """
+    key = "bigswing"
+    locks = "cmd:all()"
+    
+    def func(self):
+        "Makes the swing" 
+
+        if self.caller.ndb.off_balance:
+            # we are still off-balance.
+            self.caller.msg("You are off balance and need time to recover!")
+            return      
+      
+        # [attack/hit code goes here ...]
+        self.caller.msg("You swing big! You are off balance now.")   
+
+        # set the off-balance flag
+        self.caller.ndb.off_balance = True
+            
+        # wait 8 seconds before we can recover. During this time 
+        # we won't be able to swing again due to the check at the top.        
+        utils.delay(8, self.recover)
+    
+    def recover(self):
+        "This will be called after 8 secs"
+        del self.caller.ndb.off_balance            
+        self.caller.msg("You regain your balance.")
+
+
+

Note how, after the cooldown, the user will get a message telling them they are now ready for +another swing.

+

By storing the off_balance flag on the character (rather than on, say, the Command instance +itself) it can be accessed by other Commands too. Other attacks may also not work when you are off balance. You could also have an enemy Command check your off_balance status to gain bonuses, to take another example.

+
+
+

Make a Command possible to Abort

+

One can imagine that you will want to abort a long-running command before it has a time to finish. +If you are in the middle of crafting your armor you will probably want to stop doing that when a +monster enters your smithy.

+

You can implement this in the same way as you do the “blocking” command above, just in reverse. +Below is an example of a crafting command that can be aborted by starting a fight:

+
from evennia import utils, default_cmds
+    
+class CmdCraftArmour(default_cmds.MuxCommand):
+    """
+    Craft armour
+    
+    Usage:
+       craft <name of armour>
+    
+    This will craft a suit of armour, assuming you
+    have all the components and tools. Doing some
+    other action (such as attacking someone) will 
+    abort the crafting process. 
+    """
+    key = "craft"
+    locks = "cmd:all()"
+    
+    def func(self):
+        "starts crafting"
+
+        if self.caller.ndb.is_crafting:
+            self.caller.msg("You are already crafting!")
+            return 
+        if self._is_fighting():
+            self.caller.msg("You can't start to craft "
+                            "in the middle of a fight!")
+            return
+            
+        # [Crafting code, checking of components, skills etc]          
+
+        # Start crafting
+        self.caller.ndb.is_crafting = True
+        self.caller.msg("You start crafting ...")
+        utils.delay(60, self.step1)
+    
+    def _is_fighting(self):
+        "checks if we are in a fight."
+        if self.caller.ndb.is_fighting:                
+            del self.caller.ndb.is_crafting 
+            return True
+      
+    def step1(self):
+        "first step of armour construction"
+        if self._is_fighting(): 
+            return
+        self.msg("You create the first part of the armour.")
+        utils.delay(60, callback=self.step2)
+
+    def step2(self):
+        "second step of armour construction"
+        if self._is_fighting(): 
+            return
+        self.msg("You create the second part of the armour.")            
+        utils.delay(60, step3)
+
+    def step3(self):
+        "last step of armour construction"
+        if self._is_fighting():
+            return          
+    
+        # [code for creating the armour object etc]
+
+        del self.caller.ndb.is_crafting
+        self.msg("You finalize your armour.")
+    
+    
+# example of a command that aborts crafting
+    
+class CmdAttack(default_cmds.MuxCommand):
+    """
+    attack someone
+    
+    Usage:
+        attack <target>
+    
+    Try to cause harm to someone. This will abort
+    eventual crafting you may be currently doing. 
+    """
+    key = "attack"
+    aliases = ["hit", "stab"]
+    locks = "cmd:all()"
+    
+    def func(self):
+        "Implements the command"
+
+        self.caller.ndb.is_fighting = True
+    
+        # [...]
+
+
+

The above code creates a delayed crafting command that will gradually create the armour. If the +attack command is issued during this process it will set a flag that causes the crafting to be +quietly canceled next time it tries to update.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Howto-Command-Prompt.html b/docs/latest/Howtos/Howto-Command-Prompt.html new file mode 100644 index 0000000000..1280a5a7e5 --- /dev/null +++ b/docs/latest/Howtos/Howto-Command-Prompt.html @@ -0,0 +1,251 @@ + + + + + + + + + Adding a Command Prompt — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Adding a Command Prompt

+

A prompt is quite common in MUDs:

+
HP: 5, MP: 2, SP: 8
+> 
+
+
+

The prompt display useful details about your character that you are likely to want to keep tabs on at all times. It could be health, magical power, gold and current location. It might also show things like in-game time, weather and so on.

+

Traditionally, the prompt (changed or not) was returned with every reply from the server and just displayed on its own line. Many modern MUD clients (including Evennia’s own webclient) allows for identifying the prompt and have it appear in a fixed location that gets updated in-place (usually just above the input line).

+
+

A fixed-location prompt

+

A prompt is sent using the prompt keyword to the msg() method on objects. The prompt will be +sent without any line breaks.

+
self.msg(prompt="HP: 5, MP: 2, SP: 8")
+
+
+

You can combine the sending of normal text with the sending (updating of the prompt):

+
self.msg("This is a text", prompt="This is a prompt")
+
+
+

You can update the prompt on demand, this is normally done using OOB-tracking of the relevant +Attributes (like the character’s health). You could also make sure that attacking commands update +the prompt when they cause a change in health, for example.

+

Here is a simple example of the prompt sent/updated from a command class:

+
    from evennia import Command
+
+    class CmdDiagnose(Command):
+        """
+        see how hurt your are
+
+        Usage: 
+          diagnose [target]
+
+        This will give an estimate of the target's health. Also
+        the target's prompt will be updated. 
+        """ 
+        key = "diagnose"
+        
+        def func(self):
+            if not self.args:
+                target = self.caller
+            else:
+                target = self.search(self.args)
+                if not target:
+                    return
+            # try to get health, mana and stamina
+            hp = target.db.hp
+            mp = target.db.mp
+            sp = target.db.sp
+
+            if None in (hp, mp, sp):
+                # Attributes not defined          
+                self.caller.msg("Not a valid target!")
+                return 
+             
+            text = f"You diagnose {target} as having {hp} health, {mp} mana and {sp} stamina."
+            prompt = f"{hp} HP, {mp} MP, {sp} SP"
+            self.caller.msg(text, prompt=prompt)
+
+
+
+
+

A prompt with every command

+

The prompt sent as described above uses a standard telnet instruction (the Evennia web client gets a special flag). Most MUD telnet clients will understand and allow users to catch this and keep the prompt in place until it updates. So in principle you’d not need to update the prompt every command.

+

However, with a varying user base it can be unclear which clients are used and which skill level the users have. So sending a prompt with every command is a safe catch-all. You don’t need to manually go in and edit every command you have though. Instead you edit the base command class for your custom commands (like MuxCommand in your mygame/commands/command.py folder) and overload the at_post_cmd() hook. This hook is always called after the main func() method of the Command.

+
from evennia import default_cmds
+
+class MuxCommand(default_cmds.MuxCommand):
+    # ...
+    def at_post_cmd(self):
+        "called after self.func()."
+        caller = self.caller        
+        prompt = f"{caller.db.hp} HP, {caller.db.mp} MP, {caller.db.sp} SP"
+        caller.msg(prompt=prompt)
+
+
+
+
+

Modifying default commands

+

If you want to add something small like this to Evennia’s default commands without modifying them directly the easiest way is to just wrap those with a multiple inheritance to your own base class:

+
# in (for example) mygame/commands/mycommands.py
+
+from evennia import default_cmds
+# our custom MuxCommand with at_post_cmd hook
+from commands.command import MuxCommand
+
+# overloading the look command
+class CmdLook(default_cmds.CmdLook, MuxCommand):
+    pass
+
+
+

The result of this is that the hooks from your custom MuxCommand will be mixed into the default +CmdLook through multiple inheritance. Next you just add this to your default command set:

+
# in mygame/commands/default_cmdsets.py
+
+from evennia import default_cmds
+from commands import mycommands
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(mycommands.CmdLook())
+
+
+

This will automatically replace the default look command in your game with your own version.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Howto-Default-Exit-Errors.html b/docs/latest/Howtos/Howto-Default-Exit-Errors.html new file mode 100644 index 0000000000..de7a3d390d --- /dev/null +++ b/docs/latest/Howtos/Howto-Default-Exit-Errors.html @@ -0,0 +1,246 @@ + + + + + + + + + Return custom errors on missing Exits — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Return custom errors on missing Exits

+
> north
+Ouch! You bump into a wall!
+> out 
+ But you are already outside ...? 
+
+
+

Evennia allows for exits to have any name. The command “kitchen” is a valid exit name as well as “jump out the window” or “north”. An exit actually consists of two parts: an Exit Object and +an Exit Command stored on said exit object. The command has the same key and aliases as the +exit-object, which is why you can see the exit in the room and just write its name to traverse it.

+

So if you try to enter the name of a non-existing exit, Evennia treats is the same way as if you were trying to use a non-existing command:

+
 > jump out the window
+ Command 'jump out the window' is not available. Type "help" for help.
+
+
+

Many games don’t need this type of freedom. They define only the cardinal directions as valid exit names ( Evennia’s tunnel command also offers this functionality). In this case, the error starts to look less logical:

+
 > west
+ Command 'west' is not available. Maybe you meant "set" or "reset"?
+
+
+

Since we for our particular game know that west is an exit direction, it would be better if the error message just told us that we couldn’t go there.

+
 > west 
+ You cannot move west.
+
+
+

The way to do this is to give Evennia an alternative Command to use when no Exit-Command is found in the room. See Adding Commands for more info about the process of adding new Commands to Evennia.

+

In this example we will just echo an error message, but you could do everything (maybe you lose health if you bump into a wall?)

+
# for example in a file mygame/commands/movecommands.py
+
+from evennia import default_cmds, CmdSet
+
+class CmdExitError(default_cmds.MuxCommand):
+    """Parent class for all exit-errors."""
+    locks = "cmd:all()"
+    arg_regex = r"\s|$"
+    auto_help = False
+    def func(self):
+        """Returns error based on key"""
+        self.caller.msg(f"You cannot move {self.key}.")
+
+class CmdExitErrorNorth(CmdExitError):
+    key = "north"
+    aliases = ["n"]
+
+class CmdExitErrorEast(CmdExitError):
+    key = "east"
+    aliases = ["e"]
+
+class CmdExitErrorSouth(CmdExitError):
+    key = "south"
+    aliases = ["s"]
+
+class CmdExitErrorWest(CmdExitError):
+    key = "west"
+    aliases = ["w"]
+
+# you could add each command on its own to the default cmdset,
+# but putting them all in a cmdset here allows you to
+# just add this and makes it easier to expand with more 
+# exit-errors in the future
+
+class MovementFailCmdSet(CmdSet):
+    def at_cmdset_creation(self): 
+        self.add(CmdExitErrorNorth())
+        self.add(CmdExitErrorEast())
+        self.add(CmdExitErrorWest())
+        self.add(CmdExitErrorSouth()) 
+
+
+

We pack our commands in a new little cmdset; if we add this to our CharacterCmdSet, we can just add more errors to MovementFailCmdSet later without having to change code in two places.

+
# in mygame/commands/default_cmdsets.py
+
+from commands import movecommands
+
+# [...]
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # [...]
+    def at_cmdset_creation(self):
+        # [...]
+        # this adds all the commands at once
+        self.add(movecommands.MovementFailCmdSet)
+
+
+

reload the server. What happens henceforth is that if you are in a room with an Exitobject (let’s say it’s “north”), the proper Exit-command will overload your error command (also named “north”). But if you enter a direction without having a matching exit for it, you will fall back to your default error commands:

+
 > east
+ You cannot move east.
+
+
+

Further expansions by the exit system (including manipulating the way the Exit command itself is created) can be done by modifying the Exit typeclass directly.

+
+

Why not a single command?

+

So why didn’t we create a single error command above? Something like this:

+
class CmdExitError(default_cmds.MuxCommand):
+   "Handles all exit-errors."
+   key = "error_cmd"
+   aliases = ["north", "n", 
+              "east", "e",
+              "south", "s",
+              "west", "w"]
+    #[...]
+
+
+

This would not work the way we want. Understanding why is important.

+

Evennia’s command system compares commands by key and/or aliases. If any key or alias match, the two commands are considered identical. When the cmdsets merge, priority will then decide which of these ‘identical’ commandss replace which.

+

So the above example would work fine as long as there were no Exits at all in the room. But when we enter a room with an exit “north”, its Exit-command (which has a higher priority) will override the single CmdExitError with its alias ‘north’. So the CmdExitError will be gone and while “north” will work, we’ll again get the normal “Command not recognized” error for the other directions.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Howto-Game-Time.html b/docs/latest/Howtos/Howto-Game-Time.html new file mode 100644 index 0000000000..a45b50e32e --- /dev/null +++ b/docs/latest/Howtos/Howto-Game-Time.html @@ -0,0 +1,386 @@ + + + + + + + + + Changing game calendar and time speed — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Changing game calendar and time speed

+

A lot of games use a separate time system we refer to as game time. This runs in parallel to what we usually think of as real time. The game time might run at a different speed, use different +names for its time units or might even use a completely custom calendar. You don’t need to rely on a game time system at all. But if you do, Evennia offers basic tools to handle these various situations. This tutorial will walk you through these features.

+
+

A game time with a standard calendar

+

Many games let their in-game time run faster or slower than real time, but still use our normal +real-world calendar. This is common both for games set in present day as well as for games in +historical or futuristic settings. Using a standard calendar has some advantages:

+
    +
  • Handling repetitive actions is much easier, since converting from the real time experience to the +in-game perceived one is easy.

  • +
  • The intricacies of the real world calendar, with leap years and months of different length etc are +automatically handled by the system.

  • +
+

Evennia’s game time features assume a standard calendar (see the relevant section below for a custom calendar).

+
+

Setting up game time for a standard calendar

+

All is done through the settings. Here are the settings you should use if you want a game time with +a standard calendar:

+
# in a file settings.py in mygame/server/conf
+# The time factor dictates if the game world runs faster (timefactor>1)
+# or slower (timefactor<1) than the real world.
+TIME_FACTOR = 2.0
+
+# The starting point of your game time (the epoch), in seconds.
+# In Python a value of 0 means Jan 1 1970 (use negatives for earlier
+# start date). This will affect the returns from the utils.gametime
+# module.
+TIME_GAME_EPOCH = None
+
+
+

By default, the game time runs twice as fast as the real time. You can set the time factor to be 1 (the game time would run exactly at the same speed than the real time) or lower (the game time will be slower than the real time). Most games choose to have the game time spinning faster (you will find some games that have a time factor of 60, meaning the game time runs sixty times as fast as the real time, a minute in real time would be an hour in game time).

+

The epoch is a slightly more complex setting. It should contain a number of seconds that would +indicate the time your game started. As indicated, an epoch of 0 would mean January 1st, 1970. If +you want to set your time in the future, you just need to find the starting point in seconds. There +are several ways to do this in Python, this method will show you how to do it in local time:

+
# We're looking for the number of seconds representing
+# January 1st, 2020
+from datetime import datetime
+import time
+start = datetime(2020, 1, 1)
+time.mktime(start.timetuple())
+
+
+

This should return a huge number - the number of seconds since Jan 1 1970. Copy that directly into your settings (editing server/conf/settings.py):

+
# in a file settings.py in mygame/server/conf
+TIME_GAME_EPOCH = 1577865600
+
+
+

Reload the game with @reload, and then use the @time command. You should see something like +this:

+
+----------------------------+-------------------------------------+
+| Server time                |                                     |
++~~~~~~~~~~~~~~~~~~~~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
+| Current uptime             | 20 seconds                          |
+| Total runtime              | 1 day, 1 hour, 55 minutes           |
+| First start                | 2017-02-12 15:47:50.565000          |
+| Current time               | 2017-02-13 17:43:10.760000          |
++----------------------------+-------------------------------------+
+| In-Game time               | Real time x 2                       |
++~~~~~~~~~~~~~~~~~~~~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
+| Epoch (from settings)      | 2020-01-01 00:00:00                 |
+| Total time passed:         | 1 day, 17 hours, 34 minutes         |
+| Current time               | 2020-01-02 17:34:55.430000          |
++----------------------------+-------------------------------------+
+
+
+

The line that is most relevant here is the game time epoch. You see it shown at 2020-01-01. From +this point forward, the game time keeps increasing. If you keep typing @time, you’ll see the game +time updated correctly… and going (by default) twice as fast as the real time.

+
+ +
+

A game time with a custom calendar

+

Using a custom calendar to handle game time is sometimes needed if you want to place your game in a fictional universe. For instance you may want to create the Shire calendar which Tolkien described having 12 months, each which 30 days. That would give only 360 days per year (presumably hobbits weren’t really fond of the hassle of following the astronomical calendar). Another example would be creating a planet in a different solar system with, say, days 29 hours long and months of only 18 days.

+

Evennia handles custom calendars through an optional contrib module, called custom_gametime. +Contrary to the normal gametime module described above it is not active by default.

+
+
+

Setting up the custom calendar

+

In our first example of the Shire calendar, used by hobbits in books by Tolkien, we don’t really need the notion of weeks… but we need the notion of months having 30 days, not 28.

+

The custom calendar is defined by adding the TIME_UNITS setting to your settings file. It’s a dictionary containing as keys the name of the units, and as value the number of seconds (the smallest unit for us) in this unit. Its keys must be picked among the following: “sec”, “min”, “hour”, “day”, “week”, “month” and “year” but you don’t have to include them all. Here is the configuration for the Shire calendar:

+
# in a file settings.py in mygame/server/conf
+TIME_UNITS = {"sec": 1,
+              "min": 60,
+              "hour": 60 * 60,
+              "day": 60 * 60 * 24,
+              "month": 60 * 60 * 24 * 30,
+              "year": 60 * 60 * 24 * 30 * 12 }
+
+
+

We give each unit we want as keys. Values represent the number of seconds in that unit. Hour is set to 60 * 60 (that is, 3600 seconds per hour). Notice that we don’t specify the week unit in this configuration: instead, we skip from days to months directly.

+

In order for this setting to work properly, remember all units have to be multiples of the previous units. If you create “day”, it needs to be multiple of hours, for instance.

+

So for our example, our settings may look like this:

+
# in a file settings.py in mygame/server/conf
+# Time factor
+TIME_FACTOR = 4
+
+# Game time epoch
+TIME_GAME_EPOCH = 0
+
+# Units
+TIME_UNITS = {
+        "sec": 1,
+        "min": 60,
+        "hour": 60 * 60,
+        "day": 60 * 60 * 24,
+        "month": 60 * 60 * 24 * 30,
+        "year": 60 * 60 * 24 * 30 * 12,
+}
+
+
+

Notice we have set a time epoch of 0. Using a custom calendar, we will come up with a nice display of time on our own. In our case the game time starts at year 0, month 1, day 1, and at midnight.

+
+

Year, hour, minute and sec starts from 0, month, week and day starts from 1, this makes them +behave consistently with the standard time.

+
+

Note that while we use “month”, “week” etc in the settings, your game may not use those terms in- game, instead referring to them as “cycles”, “moons”, “sand falls” etc. This is just a matter of you +displaying them differently. See next section.

+
+

A command to display the current game time

+

As pointed out earlier, the @time command is meant to be used with a standard calendar, not a custom one. We can easily create a new command though. We’ll call it time, as is often the case +on other MU*. Here’s an example of how we could write it (for the example, you can create a file +gametime.py in your commands directory and paste this code in it):

+
# in a file mygame/commands/gametime.py
+
+from evennia.contrib.base_systems import custom_gametime
+
+from commands.command import Command
+
+class CmdTime(Command):
+
+    """
+    Display the time.
+
+    Syntax:
+        time
+
+    """
+
+    key = "time"
+    locks = "cmd:all()"
+
+    def func(self):
+        """Execute the time command."""
+        # Get the absolute game time
+        year, month, day, hour, mins, secs = custom_gametime.custom_gametime(absolute=True)
+        time_string = f"We are in year {year}, day {day}, month {month}."
+        time_string += f"\nIt's {hour:02}:{mins:02}:{secs:02}."
+        self.msg(time_string)
+
+
+

Don’t forget to add it in your CharacterCmdSet to see this command:

+
# in mygame/commands/default_cmdset.py
+
+from commands.gametime import CmdTime   # <-- Add
+
+# ...
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    """
+    The `CharacterCmdSet` contains general in-game commands like `look`,
+    `get`, etc available on in-game Character objects. It is merged with
+    the `AccountCmdSet` when an Account puppets a Character.
+    """
+    key = "DefaultCharacter"
+
+    def at_cmdset_creation(self):
+        """
+        Populates the cmdset
+        """
+        super().at_cmdset_creation()
+        # ...
+        self.add(CmdTime())   # <- Add
+
+
+

Reload your game with the @reload command. You should now see the time command. If you enter it, you might see something like:

+
We are in year 0, day 0, month 0.
+It's 00:52:17.
+
+
+

You could display it a bit more prettily with names for months and perhaps even days, if you want. +And if “months” are called “moons” in your game, this is where you’d add that.

+
+
+
+ +
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Howtos-Overview.html b/docs/latest/Howtos/Howtos-Overview.html new file mode 100644 index 0000000000..1dff2a1185 --- /dev/null +++ b/docs/latest/Howtos/Howtos-Overview.html @@ -0,0 +1,240 @@ + + + + + + + + + Tutorials and How-To’s — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Tutorials and How-To’s

+

Below you will find a variety of tutorials and how-to’s for Evennia. The Beginner Tutorial is the recommended starting point.

+
+

Note

+

Want more details about something? See the documentation for Evennia’s core Components and important Concepts.

+
+
+

Beginner Tutorial

+

Recommended starting point! This will take you from absolute beginner to making +a small – but full – game with Evennia. Other tutorials and how-to’s tend to assume you are already familiar with the concepts explained in the Beginner Tutorial.

+
+

Note

+

Part 3 and onwards are still under development.

+
+ +
+
+
+
+

How-To’s

+ +
+
+

Systems

+ +
+
+

Website Tutorials

+

Some of these will likely move into the Beginner tutorial later.

+ +
+
+

Deep Dives

+ +
+
+

Old Tutorials

+

These will be replaced by the Beginner Tutorial, but remain here until that is complete.

+ +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Implementing-a-game-rule-system.html b/docs/latest/Howtos/Implementing-a-game-rule-system.html new file mode 100644 index 0000000000..abb1eaf681 --- /dev/null +++ b/docs/latest/Howtos/Implementing-a-game-rule-system.html @@ -0,0 +1,372 @@ + + + + + + + + + Implementing a game rule system — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Implementing a game rule system

+

The simplest way to create an online roleplaying game (at least from a code perspective) is to +simply grab a paperback RPG rule book, get a staff of game masters together and start to run scenes +with whomever logs in. Game masters can roll their dice in front of their computers and tell the +players the results. This is only one step away from a traditional tabletop game and puts heavy +demands on the staff - it is unlikely staff will be able to keep up around the clock even if they +are very dedicated.

+

Many games, even the most roleplay-dedicated, thus tend to allow for players to mediate themselves +to some extent. A common way to do this is to introduce coded systems - that is, to let the +computer do some of the heavy lifting. A basic thing is to add an online dice-roller so everyone can +make rolls and make sure no one is cheating. Somewhere at this level you find the most bare-bones +roleplaying MUSHes.

+

The advantage of a coded system is that as long as the rules are fair the computer is too - it makes +no judgement calls and holds no personal grudges (and cannot be accused of holding any). Also, the +computer doesn’t need to sleep and can always be online regardless of when a player logs on. The +drawback is that a coded system is not flexible and won’t adapt to the unprogrammed actions human +players may come up with in role play. For this reason many roleplay-heavy MUDs do a hybrid +variation - they use coded systems for things like combat and skill progression but leave role play +to be mostly freeform, overseen by staff game masters.

+

Finally, on the other end of the scale are less- or no-roleplay games, where game mechanics (and +thus player fairness) is the most important aspect. In such games the only events with in-game value +are those resulting from code. Such games are very common and include everything from hack-and-slash +MUDs to various tactical simulations.

+

So your first decision needs to be just what type of system you are aiming for. This page will try +to give some ideas for how to organize the “coded” part of your system, however big that may be.

+
+

Overall system infrastructure

+

We strongly recommend that you code your rule system as stand-alone as possible. That is, don’t +spread your skill check code, race bonus calculation, die modifiers or what have you all over your +game.

+
    +
  • Put everything you would need to look up in a rule book into a module in mygame/world. Hide away +as much as you can. Think of it as a black box (or maybe the code representation of an all-knowing +game master). The rest of your game will ask this black box questions and get answers back. Exactly +how it arrives at those results should not need to be known outside the box. Doing it this way +makes it easier to change and update things in one place later.

  • +
  • Store only the minimum stuff you need with each game object. That is, if your Characters need +values for Health, a list of skills etc, store those things on the Character - don’t store how to +roll or change them.

  • +
  • Next is to determine just how you want to store things on your Objects and Characters. You can +choose to either store things as individual Attributes, like character.db.STR=34 and +character.db.Hunting_skill=20. But you could also use some custom storage method, like a dictionary character.db.skills = {"Hunting":34, "Fishing":20, ...}. A much more fancy solution is to look at the Trait handler contrib. Finally you could even go with a custom django model. Which is the better depends on your game and the complexity of your system.

  • +
  • Make a clear API into your rules. That is, make methods/functions that you feed with, say, your Character and which skill you want to check. That is, you want something similar to this:

    +
        from world import rules
    +    result = rules.roll_skill(character, "hunting")
    +    result = rules.roll_challenge(character1, character2, "swords")
    +
    +
    +
  • +
+

You might need to make these functions more or less complex depending on your game. For example the properties of the room might matter to the outcome of a roll (if the room is dark, burning etc). Establishing just what you need to send into your game mechanic module is a great way to also get a feel for what you need to add to your engine.

+
+
+

Coded systems

+

Inspired by tabletop role playing games, most game systems mimic some sort of die mechanic. To this end Evennia offers a full dice roller contribution. For custom implementations, Python offers many ways to randomize a result using its in-built random module. No matter how it’s implemented, we will in this text refer to the action of determining an outcome as a “roll”.

+

In a freeform system, the result of the roll is just compared with values and people (or the game +master) just agree on what it means. In a coded system the result now needs to be processed somehow. There are many things that may happen as a result of rule enforcement:

+
    +
  • Health may be added or deducted. This can effect the character in various ways.

  • +
  • Experience may need to be added, and if a level-based system is used, the player might need to be informed they have increased a level.

  • +
  • Room-wide effects need to be reported to the room, possibly affecting everyone in the room.

  • +
+

There are also a slew of other things that fall under “Coded systems”, including things like +weather, NPC artificial intelligence and game economy. Basically everything about the world that a Game master would control in a tabletop role playing game can be mimicked to some level by coded systems.

+
+
+

Example of Rule module

+

Here is a simple example of a rule module. This is what we assume about our simple example game:

+
    +
  • Characters have only four numerical values:

    +
      +
    • Their level, which starts at 1.

    • +
    • A skill combat, which determines how good they are at hitting things. Starts between 5 and 10.

    • +
    • Their Strength, STR, which determine how much damage they do. Starts between 1 and 10.

    • +
    • Their Health points, HP, which starts at 100.

    • +
    +
  • +
  • When a Character reaches HP = 0, they are presumed “defeated”. Their HP is reset and they get a failure message (as a stand-in for death code).

  • +
  • Abilities are stored as simple Attributes on the Character.

  • +
  • “Rolls” are done by rolling a 100-sided die. If the result is below the combat value, it’s a success and damage is rolled. Damage is rolled as a six-sided die + the value of STR (for this example we ignore weapons and assume STR is all that matters).

  • +
  • Every successful attack roll gives 1-3 experience points (XP). Every time the number of XP reaches (level + 1) ** 2, the Character levels up. When leveling up, the Character’s combat value goes up by 2 points and STR by one (this is a stand-in for a real progression system).

  • +
+
+

Character

+

The Character typeclass is simple. It goes in mygame/typeclasses/characters.py. There is already an empty Character class there that Evennia will look to and use.

+
from random import randint
+from evennia import DefaultCharacter
+
+class Character(DefaultCharacter):
+    """
+    Custom rule-restricted character. We randomize
+    the initial skill and ability values bettween 1-10.
+    """
+    def at_object_creation(self):
+        "Called only when first created"
+        self.db.level = 1
+        self.db.HP = 100
+        self.db.XP = 0
+        self.db.STR = randint(1, 10)
+        self.db.combat = randint(5, 10)
+
+
+

@reload the server to load up the new code. Doing examine self will however not show the new +Attributes on yourself. This is because the at_object_creation hook is only called on new +Characters. Your Character was already created and will thus not have them. To force a reload, use +the following command:

+
@typeclass/force/reset self
+
+
+

The examine self command will now show the new Attributes.

+
+
+

Rule module

+

This is a module mygame/world/rules.py.

+
from random import randint
+
+def roll_hit():
+    "Roll 1d100"
+    return randint(1, 100)
+
+def roll_dmg():
+    "Roll 1d6"
+    return randint(1, 6)
+
+def check_defeat(character):
+    "Checks if a character is 'defeated'."
+    if character.db.HP <= 0:
+       character.msg("You fall down, defeated!")
+       character.db.HP = 100   # reset
+
+def add_XP(character, amount):
+    "Add XP to character, tracking level increases."
+    character.db.XP += amount
+    if character.db.XP >= (character.db.level + 1) ** 2:
+        character.db.level += 1
+        character.db.STR += 1
+        character.db.combat += 2
+        character.msg(f"You are now level {character.db.level}!")
+
+def skill_combat(*args):
+    """
+    This determines outcome of combat. The one who
+    rolls under their combat skill AND higher than
+    their opponent's roll hits.
+    """
+    char1, char2 = args
+    roll1, roll2 = roll_hit(), roll_hit()
+    failtext_template = "You are hit by {attacker} for {dmg} damage!"
+    wintext_template = "You hit {target} for {dmg} damage!"
+    xp_gain = randint(1, 3)
+    if char1.db.combat >= roll1 > roll2:
+        # char 1 hits
+        dmg = roll_dmg() + char1.db.STR
+        char1.msg(wintext_template.format(target=char2, dmg=dmg))
+        add_XP(char1, xp_gain)
+        char2.msg(failtext_template.format(attacker=char1, dmg=dmg))
+        char2.db.HP -= dmg
+        check_defeat(char2)
+    elif char2.db.combat >= roll2 > roll1:
+        # char 2 hits
+        dmg = roll_dmg() + char2.db.STR
+        char1.msg(failtext_template.format(attacker=char2, dmg=dmg))
+        char1.db.HP -= dmg
+        check_defeat(char1)
+        char2.msg(wintext_template.format(target=char1, dmg=dmg))
+        add_XP(char2, xp_gain)
+    else:
+        # a draw
+        drawtext = "Neither of you can find an opening."
+        char1.msg(drawtext)
+        char2.msg(drawtext)
+
+SKILLS = {"combat": skill_combat}
+
+def roll_challenge(character1, character2, skillname):
+    """
+    Determine the outcome of a skill challenge between
+    two characters based on the skillname given.
+    """
+    if skillname in SKILLS:
+        SKILLS[skillname](character1, character2)
+    else:
+        raise RunTimeError(f"Skillname {skillname} not found.")
+
+
+

These few functions implement the entirety of our simple rule system. We have a function to check +the “defeat” condition and reset the HP back to 100 again. We define a generic “skill” function. +Multiple skills could all be added with the same signature; our SKILLS dictionary makes it easy to +look up the skills regardless of what their actual functions are called. Finally, the access +function roll_challenge just picks the skill and gets the result.

+

In this example, the skill function actually does a lot - it not only rolls results, it also informs +everyone of their results via character.msg() calls.

+

Here is an example of usage in a game command:

+
from evennia import Command
+from world import rules
+
+class CmdAttack(Command):
+    """
+    attack an opponent
+
+    Usage:
+      attack <target>
+
+    This will attack a target in the same room, dealing
+    damage with your bare hands.
+    """
+    def func(self):
+        "Implementing combat"
+
+        caller = self.caller
+        if not self.args:
+            caller.msg("You need to pick a target to attack.")
+            return
+
+        target = caller.search(self.args)
+        if target:
+            rules.roll_challenge(caller, target, "combat")
+
+
+

Note how simple the command becomes and how generic you can make it. It becomes simple to offer any +number of Combat commands by just extending this functionality - you can easily roll challenges and +pick different skills to check. And if you ever decided to, say, change how to determine hit chance, +you don’t have to change every command, but need only change the single roll_hit function inside +your rules module.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Turn-based-Combat-System.html b/docs/latest/Howtos/Turn-based-Combat-System.html new file mode 100644 index 0000000000..4b92ef18bb --- /dev/null +++ b/docs/latest/Howtos/Turn-based-Combat-System.html @@ -0,0 +1,575 @@ + + + + + + + + + Turn based Combat System — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Turn based Combat System

+

This tutorial gives an example of a full, if simplified, combat system for Evennia. It was inspired +by the discussions held on the mailing +list.

+
+

Overview of combat system concepts

+

Most MUDs will use some sort of combat system. There are several main variations:

+
    +
  • Freeform - the simplest form of combat to implement, common to MUSH-style roleplaying games. This means the system only supplies dice rollers or maybe commands to compare skills and spit out the result. Dice rolls are done to resolve combat according to the rules of the game and to direct the scene. A game master may be required to resolve rule disputes.

  • +
  • Twitch - This is the traditional MUD hack&slash style combat. In a twitch system there is often no difference between your normal “move-around-and-explore mode” and the “combat mode”. You enter an attack command and the system will calculate if the attack hits and how much damage was caused. Normally attack commands have some sort of timeout or notion of recovery/balance to reduce the advantage of spamming or client scripting. Whereas the simplest systems just means entering kill <target> over and over, more sophisticated twitch systems include anything from defensive stances to tactical positioning.

  • +
  • Turn-based - a turn based system means that the system pauses to make sure all combatants can choose their actions before continuing. In some systems, such entered actions happen immediately (like twitch-based) whereas in others the resolution happens simultaneously at the end of the turn. The disadvantage of a turn-based system is that the game must switch to a “combat mode” and one also needs to take special care of how to handle new combatants and the passage of time. The advantage is that success is not dependent on typing speed or of setting up quick client macros. This potentially allows for emoting as part of combat which is an advantage for roleplay-heavy games.

  • +
+

To implement a freeform combat system all you need is a dice roller and a roleplaying rulebook. See contrib/dice.py for an example dice roller. To implement at twitch-based system you basically need a few combat commands, possibly ones with a cooldown. You also need a game rule module that makes use of it. We will focus on the turn-based variety here.

+
+
+

Tutorial overview

+

This tutorial will implement the slightly more complex turn-based combat system. Our example has the following properties:

+
    +
  • Combat is initiated with attack <target>, this initiates the combat mode.

  • +
  • Characters may join an ongoing battle using attack <target> against a character already in +combat.

  • +
  • Each turn every combating character will get to enter two commands, their internal order matters and they are compared one-to-one in the order given by each combatant. Use of say and pose is free.

  • +
  • The commands are (in our example) simple; they can either hit <target>, feint <target> or parry <target>. They can also defend, a generic passive defense. Finally they may choose to disengage/flee.

  • +
  • When attacking we use a classic [rock-paper-scissors](https://en.wikipedia.org/wiki/Rock-paper- scissors) mechanic to determine success: hit defeats feint, which defeats parry which defeats hit. defend is a general passive action that has a percentage chance to win against hit (only).

  • +
  • disengage/flee must be entered two times in a row and will only succeed if there is no hit against them in that time. If so they will leave combat mode.

  • +
  • Once every player has entered two commands, all commands are resolved in order and the result is reported. A new turn then begins.

  • +
  • If players are too slow the turn will time out and any unset commands will be set to defend.

  • +
+

For creating the combat system we will need the following components:

+
    +
  • A combat handler. This is the main mechanic of the system. This is a Script object created for each combat. It is not assigned to a specific object but is shared by the combating characters and handles all the combat information. Since Scripts are database entities it also means that the combat will not be affected by a server reload.

  • +
  • A combat command set with the relevant commands needed for combat, such as the various attack/defend options and the flee/disengage command to leave the combat mode.

  • +
  • A rule resolution system. The basics of making such a module is described in the rule system tutorial. We will only sketch such a module here for our end-turn combat resolution.

  • +
  • An attack command for initiating the combat mode. This is added to the default command set. It will create the combat handler and add the character(s) to it. It will also assign the combat command set to the characters.

  • +
+
+
+

The combat handler

+

The combat handler is implemented as a stand-alone Script. This Script is created when the first Character decides to attack another and is deleted when no one is fighting any more. Each handler represents one instance of combat and one combat only. Each instance of combat can hold any number of characters but each character can only be part of one combat at a time (a player would +need to disengage from the first combat before they could join another).

+

The reason we don’t store this Script “on” any specific character is because any character may leave the combat at any time. Instead the script holds references to all characters involved in the combat. Vice-versa, all characters holds a back-reference to the current combat handler. While we don’t use this very much here this might allow the combat commands on the characters to access and update the combat handler state directly.

+

Note: Another way to implement a combat handler would be to use a normal Python object and handle time-keeping with the TickerHandler. This would require either adding custom hook methods on the character or to implement a custom child of the TickerHandler class to track turns. Whereas the TickerHandler is easy to use, a Script offers more power in this case.

+

Here is a basic combat handler. Assuming our game folder is named mygame, we store it in +mygame/typeclasses/combat_handler.py:

+
# mygame/typeclasses/combat_handler.py
+
+import random
+from evennia import DefaultScript
+from world.rules import resolve_combat
+
+class CombatHandler(DefaultScript):
+    """
+    This implements the combat handler.
+    """
+
+    # standard Script hooks 
+
+    def at_script_creation(self):
+        "Called when script is first created"
+
+        self.key = f"combat_handler_{random.randint(1, 1000)}"
+        self.desc = "handles combat"
+        self.interval = 60 * 2  # two minute timeout
+        self.start_delay = True
+        self.persistent = True   
+
+        # store all combatants
+        self.db.characters = {}
+        # store all actions for each turn
+        self.db.turn_actions = {}
+        # number of actions entered per combatant
+        self.db.action_count = {}
+
+    def _init_character(self, character):
+        """
+        This initializes handler back-reference 
+        and combat cmdset on a character
+        """
+        character.ndb.combat_handler = self
+        character.cmdset.add("commands.combat.CombatCmdSet")
+
+    def _cleanup_character(self, character):
+        """
+        Remove character from handler and clean 
+        it of the back-reference and cmdset
+        """
+        dbref = character.id 
+        del self.db.characters[dbref]
+        del self.db.turn_actions[dbref]
+        del self.db.action_count[dbref]        
+        del character.ndb.combat_handler
+        character.cmdset.delete("commands.combat.CombatCmdSet")
+
+    def at_start(self):
+        """
+        This is called on first start but also when the script is restarted
+        after a server reboot. We need to re-assign this combat handler to 
+        all characters as well as re-assign the cmdset.
+        """
+        for character in self.db.characters.values():
+            self._init_character(character)
+
+    def at_stop(self):
+        "Called just before the script is stopped/destroyed."
+        for character in list(self.db.characters.values()):
+            # note: the list() call above disconnects list from database
+            self._cleanup_character(character)
+
+    def at_repeat(self):
+        """
+        This is called every self.interval seconds (turn timeout) or 
+        when force_repeat is called (because everyone has entered their 
+        commands). We know this by checking the existence of the
+        `normal_turn_end` NAttribute, set just before calling 
+        force_repeat.
+        
+        """
+        if self.ndb.normal_turn_end:
+            # we get here because the turn ended normally
+            # (force_repeat was called) - no msg output
+            del self.ndb.normal_turn_end
+        else:        
+            # turn timeout
+            self.msg_all("Turn timer timed out. Continuing.")
+        self.end_turn()
+
+    # Combat-handler methods
+
+    def add_character(self, character):
+        "Add combatant to handler"
+        dbref = character.id
+        self.db.characters[dbref] = character        
+        self.db.action_count[dbref] = 0
+        self.db.turn_actions[dbref] = [("defend", character, None),
+                                       ("defend", character, None)]
+        # set up back-reference
+        self._init_character(character)
+       
+    def remove_character(self, character):
+        "Remove combatant from handler"
+        if character.id in self.db.characters:
+            self._cleanup_character(character)
+        if not self.db.characters:
+            # if no more characters in battle, kill this handler
+            self.stop()
+
+    def msg_all(self, message):
+        "Send message to all combatants"
+        for character in self.db.characters.values():
+            character.msg(message)
+
+    def add_action(self, action, character, target):
+        """
+        Called by combat commands to register an action with the handler.
+
+         action - string identifying the action, like "hit" or "parry"
+         character - the character performing the action
+         target - the target character or None
+
+        actions are stored in a dictionary keyed to each character, each
+        of which holds a list of max 2 actions. An action is stored as
+        a tuple (character, action, target). 
+        """
+        dbref = character.id
+        count = self.db.action_count[dbref]
+        if 0 <= count <= 1: # only allow 2 actions            
+            self.db.turn_actions[dbref][count] = (action, character, target)
+        else:        
+            # report if we already used too many actions
+            return False
+        self.db.action_count[dbref] += 1
+        return True
+
+    def check_end_turn(self):
+        """
+        Called by the command to eventually trigger 
+        the resolution of the turn. We check if everyone
+        has added all their actions; if so we call force the
+        script to repeat immediately (which will call
+        `self.at_repeat()` while resetting all timers). 
+        """
+        if all(count > 1 for count in self.db.action_count.values()):
+            self.ndb.normal_turn_end = True
+            self.force_repeat() 
+
+    def end_turn(self):
+        """
+        This resolves all actions by calling the rules module. 
+        It then resets everything and starts the next turn. It
+        is called by at_repeat().
+        """        
+        resolve_combat(self, self.db.turn_actions)
+
+        if len(self.db.characters) < 2:
+            # less than 2 characters in battle, kill this handler
+            self.msg_all("Combat has ended")
+            self.stop()
+        else:
+            # reset counters before next turn
+            for character in self.db.characters.values():
+                self.db.characters[character.id] = character
+                self.db.action_count[character.id] = 0
+                self.db.turn_actions[character.id] = [("defend", character, None),
+                                                  ("defend", character, None)]
+            self.msg_all("Next turn begins ...")
+
+
+

This implements all the useful properties of our combat handler. This Script will survive a reboot +and will automatically re-assert itself when it comes back online. Even the current state of the +combat should be unaffected since it is saved in Attributes at every turn. An important part to note +is the use of the Script’s standard at_repeat hook and the force_repeat method to end each turn. +This allows for everything to go through the same mechanisms with minimal repetition of code.

+

What is not present in this handler is a way for players to view the actions they set or to change +their actions once they have been added (but before the last one has added theirs). We leave this as an exercise.

+
+
+

Combat commands

+

Our combat commands - the commands that are to be available to us during the combat - are (in our example) very simple. In a full implementation the commands available might be determined by the weapon(s) held by the player or by which skills they know.

+

We create them in mygame/commands/combat.py.

+
# mygame/commands/combat.py
+
+from evennia import Command
+
+class CmdHit(Command):
+    """
+    hit an enemy
+
+    Usage:
+      hit <target>
+
+    Strikes the given enemy with your current weapon.
+    """
+    key = "hit"
+    aliases = ["strike", "slash"]
+    help_category = "combat"
+
+    def func(self):
+        "Implements the command"
+        if not self.args:
+            self.caller.msg("Usage: hit <target>")
+            return 
+        target = self.caller.search(self.args)
+        if not target:
+            return
+        ok = self.caller.ndb.combat_handler.add_action("hit", 
+                                                       self.caller, 
+                                                       target) 
+        if ok:
+            self.caller.msg("You add 'hit' to the combat queue")
+        else:
+            self.caller.msg("You can only queue two actions per turn!")
+ 
+        # tell the handler to check if turn is over
+        self.caller.ndb.combat_handler.check_end_turn()
+
+
+

The other commands CmdParry, CmdFeint, CmdDefend and CmdDisengage look basically the same. We should also add a custom help command to list all the available combat commands and what they do.

+

We just need to put them all in a cmdset. We do this at the end of the same module:

+
# mygame/commands/combat.py
+
+from evennia import CmdSet
+from evennia import default_cmds
+
+class CombatCmdSet(CmdSet):
+    key = "combat_cmdset"
+    mergetype = "Replace"
+    priority = 10 
+    no_exits = True
+
+    def at_cmdset_creation(self):
+        self.add(CmdHit())
+        self.add(CmdParry())
+        self.add(CmdFeint())
+        self.add(CmdDefend())
+        self.add(CmdDisengage())    
+        self.add(CmdHelp())
+        self.add(default_cmds.CmdPose())
+        self.add(default_cmds.CmdSay())
+
+
+
+
+

Rules module

+

A general way to implement a rule module is found in the [rule system tutorial](Implementing-a-game- rule-system). Proper resolution would likely require us to change our Characters to store things like strength, weapon skills and so on. So for this example we will settle for a very simplistic rock-paper-scissors kind of setup with some randomness thrown in. We will not deal with damage here but just announce the results of each turn. In a real system the Character objects would hold stats to affect their skills, their chosen weapon affect the choices, they would be able to lose health etc.

+

Within each turn, there are “sub-turns”, each consisting of one action per character. The actions within each sub-turn happens simultaneously and only once they have all been resolved we move on to the next sub-turn (or end the full turn).

+

Note: In our simple example the sub-turns don’t affect each other (except for disengage/flee), nor do any effects carry over between turns. The real power of a turn-based system would be to add +real tactical possibilities here though; For example if your hit got parried you could be out of +balance and your next action would be at a disadvantage. A successful feint would open up for a +subsequent attack and so on …

+

Our rock-paper-scissor setup works like this:

+
    +
  • hit beats feint and flee/disengage. It has a random chance to fail against defend.

  • +
  • parry beats hit.

  • +
  • feint beats parry and is then counted as a hit.

  • +
  • defend does nothing but has a chance to beat hit.

  • +
  • flee/disengage must succeed two times in a row (i.e. not beaten by a hit once during the turn). If so the character leaves combat.

  • +
+
# mygame/world/rules.py
+
+import random
+
+
+# messages 
+
+def resolve_combat(combat_handler, actiondict):
+    """
+    This is called by the combat handler
+    actiondict is a dictionary with a list of two actions
+    for each character:
+    {char.id:[(action1, char, target), (action2, char, target)], ...}
+    """
+    flee = {}  # track number of flee commands per character
+    for isub in range(2):
+        # loop over sub-turns
+        messages = []
+        for subturn in (sub[isub] for sub in actiondict.values()):
+            # for each character, resolve the sub-turn
+            action, char, target = subturn
+            if target:
+                taction, tchar, ttarget = actiondict[target.id][isub]
+            if action == "hit":
+                if taction == "parry" and ttarget == char:
+                    messages.append(
+                        f"{char} tries to hit {tchar}, but {tchar} parries the attack!"
+                    )
+                elif taction == "defend" and random.random() < 0.5:
+                    messages.append(
+                        f"{tchar} defends against the attack by {char}."
+                    )
+                elif taction == "flee":
+                    flee[tchar] = -2
+                    messages.append(
+                        f"{char} stops {tchar} from disengaging, with a hit!"
+                    )
+                else:
+                    messages.append(
+                        f"{char} hits {tchar}, bypassing their {taction}!"
+                    )
+            elif action == "parry":
+                if taction == "hit":
+                    messages.append(f"{char} parries the attack by {tchar}.")
+                elif taction == "feint":
+                    messages.append(
+                        f"{char} tries to parry, but {tchar} feints and hits!"
+                    )
+                else:
+                    messages.append(f"{char} parries to no avail.")
+            elif action == "feint":
+                if taction == "parry":
+                    messages.append(
+                        f"{char} feints past {tchar}'s parry, landing a hit!"
+                    )
+                elif taction == "hit":
+                    messages.append(f"{char} feints but is defeated by {tchar}'s hit!")
+                else:
+                    messages.append(f"{char} feints to no avail.")
+            elif action == "defend":
+                messages.append(f"{char} defends.")
+            elif action == "flee":
+                if char in flee:
+                    flee[char] += 1
+                else:
+                    flee[char] = 1
+                    messages.append(
+                        f"{char} tries to disengage (two subsequent turns needed)"
+                    )
+
+        # echo results of each subturn
+        combat_handler.msg_all("\n".join(messages))
+
+    # at the end of both sub-turns, test if anyone fled
+    for (char, fleevalue) in flee.items():
+        if fleevalue == 2:
+            combat_handler.msg_all(f"{char} withdraws from combat.")
+            combat_handler.remove_character(char)
+
+
+

To make it simple (and to save space), this example rule module actually resolves each interchange twice - first when it gets to each character and then again when handling the target. Also, since we use the combat handler’s msg_all method here, the system will get pretty spammy. To clean it up, one could imagine tracking all the possible interactions to make sure each pair is only handled and reported once.

+
+
+

Combat initiator command

+

This is the last component we need, a command to initiate combat. This will tie everything together. We store this with the other combat commands.

+
# mygame/commands/combat.py
+
+from evennia import create_script
+
+
+class CmdAttack(Command):
+    """
+    initiates combat
+
+    Usage:
+      attack <target>
+
+    This will initiate combat with <target>. If <target is
+    already in combat, you will join the combat. 
+    """
+    key = "attack"
+    help_category = "General"
+
+    def func(self):
+        "Handle command"
+        if not self.args:
+            self.caller.msg("Usage: attack <target>")
+            return
+        target = self.caller.search(self.args)
+        if not target:
+            return
+        # set up combat
+        if target.ndb.combat_handler:
+            # target is already in combat - join it            
+            target.ndb.combat_handler.add_character(self.caller)
+            target.ndb.combat_handler.msg_all(f"{self.caller} joins combat!")
+        else:
+            # create a new combat handler
+            chandler = create_script("combat_handler.CombatHandler")
+            chandler.add_character(self.caller)
+            chandler.add_character(target)
+            self.caller.msg(f"You attack {target}! You are in combat.")
+            target.msg(f"{self.caller} attacks you! You are in combat.")       
+
+
+

The attack command will not go into the combat cmdset but rather into the default cmdset. See e.g. the Adding Command Tutorial if you are unsure about how to do this.

+
+
+

Expanding the example

+

At this point you should have a simple but flexible turn-based combat system. We have taken several shortcuts and simplifications in this example. The output to the players is likely too verbose during combat and too limited when it comes to informing about things surrounding it. Methods for changing your commands or list them, view who is in combat etc is likely needed - this will require play testing for each game and style. There is also currently no information displayed for other people happening to be in the same room as the combat - some less detailed information should probably be echoed to the room to +show others what’s going on.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Building-a-Mech.html b/docs/latest/Howtos/Tutorial-Building-a-Mech.html new file mode 100644 index 0000000000..b7753ab43d --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Building-a-Mech.html @@ -0,0 +1,324 @@ + + + + + + + + + Building a giant mech — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Building a giant mech

+

Let us create a functioning giant mech in Evennia. Everyone likes a giant mech, right? Start in-game as a character with build privileges (or the superuser).

+
create/drop Giant Mech ; mech
+
+
+

Boom. We created a Giant Mech Object and dropped it in the room. We also gave it an alias mech. +Let’s describe it.

+
desc mech = This is a huge mech. It has missiles and stuff.
+
+
+

Next we define who can “puppet” the mech object.

+
lock mech = puppet:all()
+
+
+

This makes it so that everyone can control the mech. More mechs to the people! (Note that whereas Evennia’s default commands may look vaguely MUX-like, you can change the syntax to look like whatever interface style you prefer.)

+

Before we continue, let’s make a brief detour. Evennia is very flexible about its objects and even more flexible about using and adding commands to those objects. Here are some ground rules well worth remembering for the remainder of this article:

+
    +
  • The Account represents the real person logging in and has no game-world existence.

  • +
  • Any Object can be puppeted by an Account (with proper permissions).

  • +
  • Characters, Rooms, and Exits are just children of normal Objects.

  • +
  • Any Object can be inside another (except if it creates a loop).

  • +
  • Any Object can store custom sets of commands on it. Those commands can:

    +
      +
    • be made available to the puppeteer (Account),

    • +
    • be made available to anyone in the same location as the Object, and

    • +
    • be made available to anyone “inside” the Object

    • +
    • Also Accounts can store commands on themselves. Account commands are always available unless commands on a puppeted Object explicitly override them.

    • +
    +
  • +
+

In Evennia, using the ic command will allow you to puppet a given Object (assuming you have puppet-access to do so). As mentioned above, the bog-standard Character class is in fact like any Object: it is auto-puppeted when logging in and just has a command set on it containing the normal in-game commands, like look, inventory, get and so on.

+
ic mech
+
+
+

You just jumped out of your Character and are now the mech! If people look at you in-game, they +will look at a mech. The problem at this point is that the mech Object has no commands of its own. +The usual things like look, inventory and get sat on the Character object, remember? So at the +moment the mech is not quite as cool as it could be.

+
ic <Your old Character>
+
+
+

You just jumped back to puppeting your normal, mundane Character again. All is well.

+
+

Where did that ic command come from, if the mech had no commands on it? The +answer is that it came from the Account’s command set. This is important. Without the Account being the one with the ic command, we would not have been able to get back out of our mech again.

+
+
+

Make a Mech that can shoot

+

Let us make the mech a little more interesting. In our favorite text editor, we will create some new +mech-suitable commands. In Evennia, commands are defined as Python classes.

+
# in a new file mygame/commands/mechcommands.py
+
+from evennia import Command
+
+class CmdShoot(Command):
+    """
+    Firing the mech’s gun
+
+    Usage:
+      shoot [target]
+
+    This will fire your mech’s main gun. If no
+    target is given, you will shoot in the air.
+    """
+    key = "shoot"
+    aliases = ["fire", "fire!"]
+
+    def func(self):
+        "This actually does the shooting"
+
+        caller = self.caller
+        location = caller.location
+
+        if not self.args:
+            # no argument given to command - shoot in the air
+            message = "BOOM! The mech fires its gun in the air!"
+            location.msg_contents(message)
+            return
+
+        # we have an argument, search for target
+        target = caller.search(self.args.strip())
+        if target:
+            location.msg_contents(
+                f"BOOM! The mech fires its gun at {target.key}"
+            )
+
+class CmdLaunch(Command):
+    # make your own 'launch'-command here as an exercise!
+    # (it's very similar to the 'shoot' command above).
+
+
+
+

This is saved as a normal Python module (let’s call it mechcommands.py), in a place Evennia looks for such modules (mygame/commands/). This command will trigger when the player gives the command “shoot”, “fire,” or even “fire!” with an exclamation mark. The mech can shoot in the air or at a target if you give one. In a real game the gun would probably be given a chance to hit and give +damage to the target, but this is enough for now.

+

We also make a second command for launching missiles (CmdLaunch). To save space we won’t describe it here; it looks the same except it returns a text about the missiles being fired and has different key and aliases. We leave that up to you to create as an exercise. You could have it print "WOOSH! The mech launches missiles against <target>!, for example.

+

Now we shove our commands into a command set. A Command Set (CmdSet) is a container holding any number of commands. The command set is what we will store on the mech.

+
# in the same file mygame/commands/mechcommands.py
+
+from evennia import CmdSet
+from evennia import default_cmds
+
+class MechCmdSet(CmdSet):
+    """
+    This allows mechs to do do mech stuff.
+    """
+    key = "mechcmdset"
+
+    def at_cmdset_creation(self):
+        "Called once, when cmdset is first created"
+        self.add(CmdShoot())
+        self.add(CmdLaunch())
+
+
+

This simply groups all the commands we want. We add our new shoot/launch commands. Let’s head back into the game. For testing we will manually attach our new CmdSet to the mech.

+
py self.search("mech").cmdset.add("commands.mechcommands.MechCmdSet")
+
+
+

This is a little Python snippet that searches for the mech in our current location and attaches our new MechCmdSet to it. What we add is actually the Python path to our cmdset class. Evennia will import and initialize it behind the scenes.

+
ic mech
+
+
+

We are back as the mech! Let’s do some shooting!

+
fire!
+BOOM! The mech fires its gun in the air!
+
+
+

There we go, one functioning mech. Try your own launch command and see that it works too. We can not only walk around as the mech — since the CharacterCmdSet is included in our MechCmdSet, the mech can also do everything a Character could do, like look around, pick up stuff, and have an inventory. We could now shoot the gun at a target or try the missile launch command. Once you have your own mech, what else do you need?

+
+

You’ll find that the mech’s commands are available to you by just standing in the same +location (not just by puppeting it). We’ll solve this with a lock in the next section.

+
+
+
+

Making an army of Mechs

+

What we’ve done so far is just to make a normal Object, describe it and put some commands on it. +This is great for testing. The way we added it, the MechCmdSet will even go away if we reload the +server. Now we want to make the mech an actual object “type” so we can create mechs without those extra steps. For this we need to create a new Typeclass.

+

A Typeclass is a near-normal Python class that stores its existence to the database +behind the scenes. A Typeclass is created in a normal Python source file:

+
# in the new file mygame/typeclasses/mech.py
+
+from typeclasses.objects import Object
+from commands.mechcommands import MechCmdSet
+from evennia import default_cmds
+
+class Mech(Object):
+    """
+    This typeclass describes an armed Mech.
+    """
+    def at_object_creation(self):
+        "This is called only when object is first created"
+        self.cmdset.add_default(default_cmds.CharacterCmdSet)
+        self.cmdset.add(MechCmdSet, persistent=True)
+        self.locks.add("puppet:all();call:false()")
+        self.db.desc = "This is a huge mech. It has missiles and stuff."
+
+
+

For convenience we include the full contents of the default CharacterCmdSet in there. This will make a Character’s normal commands available to the mech. We also add the mech-commands from before, making sure they are stored persistently in the database. The locks specify that anyone can puppet the meck and no-one can “call” the mech’s Commands from ‘outside’ it - you have to puppet it to be able to shoot.

+

That’s it. When Objects of this type are created, they will always start out with the mech’s command set and the correct lock. We set a default description, but you would probably change this with desc to individualize your mechs as you build them.

+

Back in the game, just exit the old mech (@ic back to your old character) then do

+
create/drop The Bigger Mech ; bigmech : mech.Mech
+
+
+

We create a new, bigger mech with an alias bigmech. Note how we give the python-path to our +Typeclass at the end — this tells Evennia to create the new object based on that class (we don’t +have to give the full path in our game dir typeclasses.mech.Mech because Evennia knows to look in the typeclasses folder already). A shining new mech will appear in the room! Just use

+
ic bigmech
+
+
+

to take it on a test drive.

+
+

Future Mechs

+

Having you puppet the mech-object directly is just one way to implement a giant mech in Evennia.

+

For example, you could instead picture a mech as a “vehicle” that you “enter” as your normal +Character (since any Object can move inside another). In that case the “insides” of the mech Object could be the “cockpit”. The cockpit would have the MechCommandSet stored on itself and all the shooting goodness would be made available to you only when you enter it.

+

To expand on this you could add more commands to the mech and remove others. Maybe the mech shouldn’t work just like a Character after all.

+

Maybe it makes loud noises every time it passes from room to room. Maybe it cannot pick up things without crushing them. Maybe it needs fuel, ammo and repairs. Maybe you’ll lock it down so it can only be puppeted by emo teenagers.

+

And of course you could put more guns on it. And make it fly.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Building-a-Train.html b/docs/latest/Howtos/Tutorial-Building-a-Train.html new file mode 100644 index 0000000000..ce06389ac0 --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Building-a-Train.html @@ -0,0 +1,485 @@ + + + + + + + + + Building a train that moves — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Building a train that moves

+
+

TODO: This should be updated for latest Evennia use.

+
+

Vehicles are things that you can enter and then move around in your game world. Here we’ll explain how to create a train, but this can be equally applied to create other kind of vehicles +(cars, planes, boats, spaceships, submarines, …).

+

Objects in Evennia have an interesting property: you can put any object inside another object. This is most obvious in rooms: a room in Evennia is just like any other game object (except rooms tend to not themselves be inside anything else).

+

Our train will be similar: it will be an object that other objects can get inside. We then simply +move the Train, which brings along everyone inside it.

+
+

Creating our train object

+

The first step we need to do is create our train object, including a new typeclass. To do this, +create a new file, for instance in mygame/typeclasses/train.py with the following content:

+
# in mygame/typeclasses/train.py
+
+from evennia import DefaultObject
+
+class TrainObject(DefaultObject):
+
+    def at_object_creation(self):
+        # We'll add in code here later.
+        pass
+
+
+
+

Now we can create our train in our game:

+
create/drop train:train.TrainObject
+
+
+

Now this is just an object that doesn’t do much yet… but we can already force our way inside it +and back (assuming we created it in limbo).

+
tel train 
+tel limbo
+
+
+
+
+

Entering and leaving the train

+

Using the telcommand like shown above is obviously not what we want. @tel is an admin command and normal players will thus never be able to enter the train!

+

It is also not really a good idea to use Exits to get in and out of the train - Exits are (at least by default) objects too. They point to a specific destination. If we put an Exit in this room leading inside the train it would stay here when the train moved away (still leading into the train like a magic portal!). In the same way, if we put an Exit object inside the train, it would always point back to this room, regardless of where the Train has moved.

+

Now, one could define custom Exit types that move with the train or change their destination in the right way - but this seems to be a pretty cumbersome solution.

+

What we will do instead is to create some new commands: one for entering the train and +another for leaving it again. These will be stored on the train object and will thus be made +available to whomever is either inside it or in the same room as the train.

+

Let’s create a new command module as mygame/commands/train.py:

+
# mygame/commands/train.py
+
+from evennia import Command, CmdSet
+
+class CmdEnterTrain(Command):
+    """
+    entering the train
+    
+    Usage:
+      enter train
+
+    This will be available to players in the same location
+    as the train and allows them to embark. 
+    """
+
+    key = "enter train"
+
+    def func(self):
+        train = self.obj
+        self.caller.msg("You board the train.")
+        self.caller.move_to(train, move_type="board")
+
+
+class CmdLeaveTrain(Command):
+    """
+    leaving the train 
+ 
+    Usage:
+      leave train
+
+    This will be available to everyone inside the 
+    train. It allows them to exit to the train's
+    current location. 
+    """
+
+    key = "leave train"
+
+    def func(self):
+        train = self.obj
+        parent = train.location
+        self.caller.move_to(parent, move_type="disembark")
+
+
+class CmdSetTrain(CmdSet):
+
+    def at_cmdset_creation(self):
+        self.add(CmdEnterTrain())
+        self.add(CmdLeaveTrain())
+
+
+

Note that while this seems like a lot of text, the majority of lines here are taken up by +documentation.

+

These commands are work in a pretty straightforward way: CmdEnterTrain moves the location of the player to inside the train and CmdLeaveTrain does the opposite: it moves the player back to the +current location of the train (back outside to its current location). We stacked them in a cmdset CmdSetTrain so they can be used.

+

To make the commands work we need to add this cmdset to our train typeclass:

+
# file mygame/typeclasses/train.py
+
+from commands.train import CmdSetTrain
+from typeclasses.objects import Object
+
+class TrainObject(Object):
+
+    def at_object_creation(self):        
+        self.cmdset.add_default(CmdSetTrain)
+
+
+
+

If we now reload our game and reset our train, those commands should work and we can now enter and leave the train:

+
reload
+typeclass/force/reset train = train.TrainObject
+enter train
+leave train
+
+
+

Note the switches used with the typeclass command: The /force switch is necessary to assign our object the same typeclass we already have. The /reset re-triggers the typeclass’ at_object_creation() hook (which is otherwise only called the very first an instance is created). +As seen above, when this hook is called on our train, our new cmdset will be loaded.

+
+
+

Locking down the commands

+

If you have played around a bit, you’ve probably figured out that you can use leave train when +outside the train and enter train when inside. This doesn’t make any sense … so let’s go ahead +and fix that. We need to tell Evennia that you can not enter the train when you’re already inside +or leave the train when you’re outside. One solution to this is locks: we will lock down the commands so that they can only be called if the player is at the correct location.

+

Since we didn’t set a lock property on the Command, it defaults to cmd:all(). This means that everyone can use the command as long as they are in the same room or inside the train.

+

First of all we need to create a new lock function. Evennia comes with many lock functions built-in +already, but none that we can use for locking a command in this particular case. Create a new entry in mygame/server/conf/lockfuncs.py:

+

+# file mygame/server/conf/lockfuncs.py
+
+def cmdinside(accessing_obj, accessed_obj, *args, **kwargs):
+    """
+    Usage: cmdinside() 
+    Used to lock commands and only allows access if the command
+    is defined on an object which accessing_obj is inside of.     
+    """
+    return accessed_obj.obj == accessing_obj.location
+
+
+
+

If you didn’t know, Evennia is by default set up to use all functions in this module as lock +functions (there is a setting variable that points to it).

+

Our new lock function, cmdinside, is to be used by Commands. The accessed_obj is the Command object (in our case this will be CmdEnterTrain and CmdLeaveTrain) — Every command has an obj property: this is the the object on which the command “sits”. Since we added those commands to our train object, the .obj property will be set to the train object. Conversely, accessing_obj is the object that called the command: in our case it’s the Character trying to enter or leave the train.

+

What this function does is to check that the player’s location is the same as the train object. If +it is, it means the player is inside the train. Otherwise it means the player is somewhere else and +the check will fail.

+

The next step is to actually use this new lock function to create a lock of type cmd:

+
# file commands/train.py
+...
+class CmdEnterTrain(Command):
+    key = "enter train"
+    locks = "cmd:not cmdinside()"
+    # ...
+
+class CmdLeaveTrain(Command):
+    key = "leave train"
+    locks = "cmd:cmdinside()"
+    # ...
+
+
+

Notice how we use the not here so that we can use the same cmdinside to check if we are inside +and outside, without having to create two separate lock functions. After a @reload our commands +should be locked down appropriately and you should only be able to use them at the right places.

+
+

Note: If you’re logged in as the super user (user #1) then this lock will not work: the super +user ignores lock functions. In order to use this functionality you need to @quell first.

+
+
+
+

Making our train move

+

Now that we can enter and leave the train correctly, it’s time to make it move. There are different +things we need to consider for this:

+
    +
  • Who can control your vehicle? The first player to enter it, only players that have a certain “drive” skill, automatically?

  • +
  • Where should it go? Can the player steer the vehicle to go somewhere else or will it always follow the same route?

  • +
+

For our example train we’re going to go with automatic movement through a predefined route (its track). The train will stop for a bit at the start and end of the route to allow players to enter and leave it.

+

Go ahead and create some rooms for our train. Make a list of the room ids along the route (using the xe command).

+
> dig/tel South station
+> ex              # note the id of the station
+> tunnel/tel n = Following a railroad
+> ex              # note the id of the track
+> tunnel/tel n = Following a railroad
+> ...
+> tunnel/tel n = North Station
+
+
+

Put the train onto the tracks:

+
tel south station
+tel train = here
+
+
+

Next we will tell the train how to move and which route to take.

+
# file typeclasses/train.py
+
+from evennia import DefaultObject, search_object
+
+from commands.train import CmdSetTrain
+
+class TrainObject(DefaultObject):
+
+    def at_object_creation(self):
+        self.cmdset.add_default(CmdSetTrain)
+        self.db.driving = False
+        # The direction our train is driving (1 for forward, -1 for backwards)
+        self.db.direction = 1
+        # The rooms our train will pass through (change to fit your game)
+        self.db.rooms = ["#2", "#47", "#50", "#53", "#56", "#59"]
+
+    def start_driving(self):
+        self.db.driving = True
+
+    def stop_driving(self):
+        self.db.driving = False
+
+    def goto_next_room(self):
+        currentroom = self.location.dbref
+        idx = self.db.rooms.index(currentroom) + self.db.direction
+
+        if idx < 0 or idx >= len(self.db.rooms):
+            # We reached the end of our path
+            self.stop_driving()
+            # Reverse the direction of the train
+            self.db.direction *= -1
+        else:
+            roomref = self.db.rooms[idx]
+            room = search_object(roomref)[0]
+            self.move_to(room)
+            self.msg_contents(f"The train is moving forward to {room.name}.")
+
+
+

We added a lot of code here. Since we changed the at_object_creation to add in variables we will have to reset our train object like earlier (using the @typeclass/force/reset command).

+

We are keeping track of a few different things now: whether the train is moving or standing still, +which direction the train is heading to and what rooms the train will pass through.

+

We also added some methods: one to start moving the train, another to stop and a third that actually moves the train to the next room in the list. Or makes it stop driving if it reaches the last stop.

+

Let’s try it out, using py to call the new train functionality:

+
> reload
+> typeclass/force/reset train = train.TrainObject
+> enter train
+> py here.goto_next_room()
+
+
+

You should see the train moving forward one step along the rail road.

+
+
+

Adding in scripts

+

If we wanted full control of the train we could now just add a command to step it along the track when desired. We want the train to move on its own though, without us having to force it by manually calling the goto_next_room method.

+

To do this we will create two scripts: one script that runs when the train has stopped at +a station and is responsible for starting the train again after a while. The other script will take +care of the driving.

+

Let’s make a new file in mygame/typeclasses/trainscript.py

+
# file mygame/typeclasses/trainscript.py
+
+from evennia import DefaultScript
+
+class TrainStoppedScript(DefaultScript):
+
+    def at_script_creation(self):
+        self.key = "trainstopped"
+        self.interval = 30
+        self.persistent = True
+        self.repeats = 1
+        self.start_delay = True
+
+    def at_repeat(self):
+        self.obj.start_driving()        
+
+    def at_stop(self):
+        self.obj.scripts.add(TrainDrivingScript)
+
+
+class TrainDrivingScript(DefaultScript):
+
+    def at_script_creation(self):
+        self.key = "traindriving"
+        self.interval = 1
+        self.persistent = True
+
+    def is_valid(self):
+        return self.obj.db.driving
+
+    def at_repeat(self):
+        if not self.obj.db.driving:
+            self.stop()
+        else:
+            self.obj.goto_next_room()
+
+    def at_stop(self):
+        self.obj.scripts.add(TrainStoppedScript)
+
+
+

Those scripts work as a state system: when the train is stopped, it waits for 30 seconds and then +starts again. When the train is driving, it moves to the next room every second. The train is always +in one of those two states - both scripts take care of adding the other one once they are done.

+

As a last step we need to link the stopped-state script to our train, reload the game and reset our +train again., and we’re ready to ride it around!

+
# file typeclasses/train.py
+
+from typeclasses.trainscript import TrainStoppedScript
+
+class TrainObject(DefaultObject):
+
+    def at_object_creation(self):
+        # ...
+        self.scripts.add(TrainStoppedScript)
+
+
+
> reload
+> typeclass/force/reset train = train.TrainObject
+> enter train
+
+# output:
+< The train is moving forward to Following a railroad.
+< The train is moving forward to Following a railroad.
+< The train is moving forward to Following a railroad.
+...
+< The train is moving forward to Following a railroad.
+< The train is moving forward to North station.
+
+leave train
+
+
+

Our train will stop 30 seconds at each end station and then turn around to go back to the other end.

+
+
+

Expanding

+

This train is very basic and still has some flaws. Some more things to do:

+
    +
  • Make it look like a train.

  • +
  • Make it impossible to exit and enter the train mid-ride. This could be made by having the enter/exit commands check so the train is not moving before allowing the caller to proceed.

  • +
  • Have train conductor commands that can override the automatic start/stop.

  • +
  • Allow for in-between stops between the start- and end station

  • +
  • Have a rail road track instead of hard-coding the rooms in the train object. This could for example be a custom Exit only traversable by trains. The train will follow the track. Some track segments can split to lead to two different rooms and a player can switch the direction to which room it goes.

  • +
  • Create another kind of vehicle!

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Coordinates.html b/docs/latest/Howtos/Tutorial-Coordinates.html new file mode 100644 index 0000000000..9e1cded6b7 --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Coordinates.html @@ -0,0 +1,443 @@ + + + + + + + + + Adding room coordinates to your game — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Adding room coordinates to your game

+ +

This tutorial is moderately difficult in content. You might want to be familiar and at ease with some Python concepts (like properties) and possibly Django concepts (like queries), although this tutorial will try to walk you through the process and give enough explanations each time. If you don’t feel very confident with math, don’t hesitate to pause, go to the example section, which shows a tiny map, and try to walk around the code or read the explanation.

+

Evennia doesn’t have a coordinate system by default. Rooms and other objects are linked by location and content:

+
    +
  • An object can be in a location, that is, another object. Like an exit in a room.

  • +
  • An object can access its content. A room can see what objects uses it as location (that would +include exits, rooms, characters and so on).

  • +
+

This system allows for a lot of flexibility and, fortunately, can be extended by other systems. +Here, I offer you a way to add coordinates to every room in a way most compliant with Evennia +design. This will also show you how to use coordinates, find rooms around a given point for +instance.

+
+

Coordinates as tags

+

The first concept might be the most surprising at first glance: we will create coordinates as +tags.

+

So, why not attributes, wouldn’t that be easier? It would. We could just do something like room.db.x = 3. The advantage of using tags is that it will be easy and effective to search. Although this might not seem like a huge advantage right now, with a database of thousands of rooms, it might make a difference, particularly if you have a lot of things based on coordinates.

+

Rather than giving you a step-by-step process, We’ll show you the code. Notice that we use +properties to easily access and update coordinates. This is a Pythonic approach. Here’s our first +Room class, that you can modify in typeclasses/rooms.py:

+
# in typeclasses/rooms.py
+
+from evennia import DefaultRoom
+
+class Room(DefaultRoom):
+    """
+    Rooms are like any Object, except their location is None
+    (which is default). They also use basetype_setup() to
+    add locks so they cannot be puppeted or picked up.
+    (to change that, use at_object_creation instead)
+
+    See examples/object.py for a list of
+    properties and methods available on all Objects.
+    """
+    
+    @property
+    def x(self):
+        """Return the X coordinate or None."""
+        x = self.tags.get(category="coordx")
+        return int(x) if isinstance(x, str) else None
+
+    @x.setter
+    def x(self, x):
+        """Change the X coordinate."""
+        old = self.tags.get(category="coordx")
+        if old is not None:
+            self.tags.remove(old, category="coordx")
+        if x is not None:
+            self.tags.add(str(x), category="coordx")
+
+    @property
+    def y(self):
+        """Return the Y coordinate or None."""
+        y = self.tags.get(category="coordy")
+        return int(y) if isinstance(y, str) else None
+    
+    @y.setter
+    def y(self, y):
+        """Change the Y coordinate."""
+        old = self.tags.get(category="coordy")
+        if old is not None:
+            self.tags.remove(old, category="coordy")
+        if y is not None:
+            self.tags.add(str(y), category="coordy")
+
+    @property
+    def z(self):
+        """Return the Z coordinate or None."""
+        z = self.tags.get(category="coordz")
+        return int(z) if isinstance(z, str) else None
+    
+    @z.setter
+    def z(self, z):
+        """Change the Z coordinate."""
+        old = self.tags.get(category="coordz")
+        if old is not None:
+            self.tags.remove(old, category="coordz")
+        if z is not None:
+            self.tags.add(str(z), category="coordz")
+
+
+

If you aren’t familiar with the concept of properties in Python, I encourage you to read a good +tutorial on the subject. [This article on Python properties](https://www.programiz.com/python- +programming/property) +is well-explained and should help you understand the idea.

+

Let’s look at our properties for x. First of all is the read property.

+
    @property
+    def x(self):
+        """Return the X coordinate or None."""
+        x = self.tags.get(category="coordx")
+        return int(x) if isinstance(x, str) else None
+
+
+

What it does is pretty simple:

+
    +
  1. It gets the tag of category "coordx". It’s the tag category where we store our X coordinate. +The tags.get method will return None if the tag can’t be found.

  2. +
  3. We convert the value to an integer, if it’s a str. Remember that tags can only contain str, +so we’ll need to convert it.

  4. +
+

So can Tags contain values? Well, technically, they can’t: they’re either here or not. But using tag categories, as we have done, we get a tag, knowing only its category. That’s the basic approach to coordinates in this tutorial.

+

Now, let’s look at the method that will be called when we wish to set x in our room:

+
    @x.setter
+    def x(self, x):
+        """Change the X coordinate."""
+        old = self.tags.get(category="coordx")
+        if old is not None:
+            self.tags.remove(old, category="coordx")
+        if x is not None:
+            self.tags.add(str(x), category="coordx")
+
+
+
    +
  1. First, we remove the old X coordinate, if it exists. Otherwise, we’d end up with two tags in our +room with “coordx” as their category, which wouldn’t do at all.

  2. +
  3. Then we add the new tag, giving it the proper category.

  4. +
+

If you add this code and reload your game, once you’re logged in with a character in a room as its +location, you can play around:

+
py here.x
+py here.x = 0
+py here.y = 3
+py here.z = -2
+py here.z = None
+
+
+
+
+

Some additional searches

+

Having coordinates is useful for several reasons:

+
    +
  1. It can help in shaping a truly logical world, in its geography, at least.

  2. +
  3. It can allow to look for specific rooms at given coordinates.

  4. +
  5. It can be good in order to quickly find the rooms around a location.

  6. +
  7. It can even be great in path-finding (finding the shortest path between two rooms).

  8. +
+

So far, our coordinate system can help with 1., but not much else. Here are some methods that we +could add to the Room typeclass. These methods will just be search methods. Notice that they are +class methods, since we want to get rooms.

+
+

Finding one room

+

First, a simple one: how to find a room at a given coordinate? Say, what is the room at X=0, Y=0, +Z=0?

+
class Room(DefaultRoom):
+    # ...
+    @classmethod
+    def get_room_at(cls, x, y, z):
+        """
+        Return the room at the given location or None if not found.
+
+        Args:
+            x (int): the X coord.
+            y (int): the Y coord.
+            z (int): the Z coord.
+
+        Return:
+            The room at this location (Room) or None if not found.
+
+        """
+        rooms = cls.objects.filter(
+                db_tags__db_key=str(x), db_tags__db_category="coordx").filter(
+                db_tags__db_key=str(y), db_tags__db_category="coordy").filter(
+                db_tags__db_key=str(z), db_tags__db_category="coordz")
+        if rooms:
+            return rooms[0]
+
+        return None
+
+
+

This solution includes some Django queries. Basically, what we do is reach for the object manager and search for objects with the matching tags. Again, don’t spend too much time worrying about the mechanism, the method is quite easy to use:

+
Room.get_room_at(5, 2, -3)
+
+
+

Notice that this is a class method: you will call it from Room (the class), not an instance. Though you still can:

+
py here.get_room_at(3, 8, 0)
+
+
+
+
+

Finding several rooms

+

Here’s another useful method that allows us to look for rooms around a given coordinate. This is more advanced search and doing some calculation, beware! Look at the following section if you’re +lost.

+
from math import sqrt
+
+class Room(DefaultRoom):
+
+    # ...
+
+    @classmethod
+    def get_rooms_around(cls, x, y, z, distance):
+        """
+        Return the list of rooms around the given coordinates.
+
+        This method returns a list of tuples (distance, room) that
+        can easily be browsed.  This list is sorted by distance (the
+        closest room to the specified position is always at the top
+        of the list).
+
+        Args:
+            x (int): the X coord.
+            y (int): the Y coord.
+            z (int): the Z coord.
+            distance (int): the maximum distance to the specified position.
+
+        Returns:
+            A list of tuples containing the distance to the specified
+            position and the room at this distance.  Several rooms
+            can be at equal distance from the position.
+
+        """
+        # Performs a quick search to only get rooms in a square
+        x_r = list(reversed([str(x - i) for i in range(0, distance + 1)]))
+        x_r += [str(x + i) for i in range(1, distance + 1)]
+        y_r = list(reversed([str(y - i) for i in range(0, distance + 1)]))
+        y_r += [str(y + i) for i in range(1, distance + 1)]
+        z_r = list(reversed([str(z - i) for i in range(0, distance + 1)]))
+        z_r += [str(z + i) for i in range(1, distance + 1)]
+        wide = cls.objects.filter(
+                db_tags__db_key__in=x_r, db_tags__db_category="coordx").filter(
+                db_tags__db_key__in=y_r, db_tags__db_category="coordy").filter(
+                db_tags__db_key__in=z_r, db_tags__db_category="coordz")
+
+        # We now need to filter down this list to find out whether
+        # these rooms are really close enough, and at what distance
+        # In short: we change the square to a circle.
+        rooms = []
+        for room in wide:
+            x2 = int(room.tags.get(category="coordx"))
+            y2 = int(room.tags.get(category="coordy"))
+            z2 = int(room.tags.get(category="coordz"))
+            distance_to_room = sqrt(
+                    (x2 - x) ** 2 + (y2 - y) ** 2 + (z2 - z) ** 2)
+            if distance_to_room <= distance:
+                rooms.append((distance_to_room, room))
+
+        # Finally sort the rooms by distance
+        rooms.sort(key=lambda tup: tup[0])
+        return rooms
+
+
+

This gets more serious.

+
    +
  1. We have specified coordinates as parameters. We determine a broad range using the distance. +That is, for each coordinate, we create a list of possible matches. See the example below.

  2. +
  3. We then search for the rooms within this broader range. It gives us a square around our location. Some rooms are definitely outside the range. Again, see the example below to follow the logic.

  4. +
  5. We filter down the list and sort it by distance from the specified coordinates.

  6. +
+

Notice that we only search starting at step 2. Thus, the Django search doesn’t look and cache all +objects, just a wider range than what would be really necessary. This method returns a circle of +coordinates around a specified point. Django looks for a square. What wouldn’t fit in the circle +is removed at step 3, which is the only part that includes systematic calculation. This method is +optimized to be quick and efficient.

+
+
+

An example

+

An example might help. Consider this very simple map (a textual description follows):

+
4 A B C D
+3 E F G H
+2 I J K L
+1 M N O P
+  1 2 3 4
+
+
+

The X coordinates are given below. The Y coordinates are given on the left. This is a simple square with 16 rooms: 4 on each line, 4 lines of them. All the rooms are identified by letters in this example: the first line at the top has rooms A to D, the second E to H, the third I to L and the fourth M to P. The bottom-left room, X=1 and Y=1, is M. The upper-right room X=4 and Y=4 is D. +So let’s say we want to find all the neighbors, distance 1, from the room J. J is at X=2, Y=2.

+

So we use:

+
Room.get_rooms_around(x=2, y=2, z=0, distance=1)
+# we'll assume a z coordinate of 0 for simplicity
+
+
+
    +
  1. First, this method gets all the rooms in a square around J. So it gets E F G, I J K, M N O. If you want, draw the square around these coordinates to see what’s happening.

  2. +
  3. Next, we browse over this list and check the real distance between J (X=2, Y=2) and the room. The four corners of the square are not in this circle. For instance, the distance between J and M is not 1. If you draw a circle of center J and radius 1, you’ll notice that the four corners of our square (E, G, M and O) are not in this circle. So we remove them. 3. We sort by distance from J.

  4. +
+

So in the end we might obtain something like this:

+
[
+    (0, J), # yes, J is part of this circle after all, with a distance of 0
+    (1, F),
+    (1, I),
+    (1, K),
+    (1, N),
+]
+
+
+

You can try with more examples if you want to see this in action.

+
+
+
+

To conclude

+

You can also use this system to map other objects, not just rooms. You can easily remove the +Z coordinate too, if you simply need X and Y.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Displaying-Room-Map.html b/docs/latest/Howtos/Tutorial-Displaying-Room-Map.html new file mode 100644 index 0000000000..4686eaa082 --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Displaying-Room-Map.html @@ -0,0 +1,557 @@ + + + + + + + + + Show a dynamic map of rooms — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Show a dynamic map of rooms

+ +

An often desired feature in a MUD is to show an in-game map to help navigation.

+
Forest path
+
+         [.]   [.]
+[.][.][@][.][.][.]
+         [.]   [.][.][.]
+
+The trees are looming over the narrow forest path.
+
+Exits: East, West
+
+
+
+

The Grid of Rooms

+

There are at least two requirements needed for this tutorial to work.

+
    +
  1. The structure of your mud has to follow a logical layout. Evennia supports the layout of your world to be ‘logically’ impossible with rooms looping to themselves or exits leading to the other side of the map. Exits can also be named anything, from “jumping out the window” to “into the fifth dimension”. This tutorial assumes you can only move in the cardinal directions (N, E, S and W).

  2. +
  3. Rooms must be connected and linked together for the map to be generated correctly. Vanilla Evennia comes with a admin command tunnel that allows a user to create rooms in the cardinal directions, but additional work is needed to assure that rooms are connected. For example, if you tunnel east and then immediately do tunnel west you’ll find that you have created two completely stand-alone rooms. So care is needed if you want to create a “logical” layout. In this tutorial we assume you have such a grid of rooms that we can generate the map from.

  4. +
+
+
+

Concept

+

Before getting into the code, it is beneficial to understand and conceptualize how this is going to work. The idea is analogous to a worm that starts at your current position. It chooses a direction and ‘walks’ outward from it, mapping its route as it goes. Once it has traveled a pre-set distance it stops and starts over in another direction. An important note is that we want a system which is easily callable and not too complicated. Therefore we will wrap this entire code into a custom Python class (not a typeclass as this doesn’t use any core objects from evennia itself). We are going to create something that displays like this when you type ‘look’:

+
Hallway
+
+      [.]   [.]
+      [@][.][.][.][.]
+      [.]   [.]   [.]
+
+The distant echoes of the forgotten
+wail throughout the empty halls.
+
+Exits: North, East, South
+
+
+

Your current location is defined by [@] while the [.]s are other rooms that the “worm” has seen +since departing from your location.

+
+
+

Setting up the Map Display

+

First we must define the components for displaying the map. For the “worm” to know what symbol to draw on the map we will have it check an Attribute on the room it visits called sector_type. For this tutorial we understand two symbols - a normal room and the room with us in it. We also define a fallback symbol for rooms without said Attribute - that way the map will still work even if we didn’t prepare the room correctly. Assuming your game folder is named mygame, we create this code in mygame/world/map.py.

+
# in mygame/world/map.py
+
+# the symbol is identified with a key "sector_type" on the
+# Room. Keys None and "you" must always exist.
+SYMBOLS = { None : ' . ', # for rooms without sector_type Attribute
+            'you' : '[@]',
+            'SECT_INSIDE': '[.]' }
+
+
+

Since trying to access an unset Attribute returns None, this means rooms without the sector_type +Atttribute will show as .. Next we start building the custom class Map. It will hold all +methods we need.

+
# in mygame/world/map.py
+
+class Map(object):
+
+    def __init__(self, caller, max_width=9, max_length=9):
+        self.caller = caller
+        self.max_width = max_width
+        self.max_length = max_length
+        self.worm_has_mapped = {}
+        self.curX = None
+        self.curY = None
+
+
+
    +
  • self.caller is normally your Character object, the one using the map.

  • +
  • self.max_width/length determine the max width and length of the map that will be generated. Note that it’s important that these variables are set to odd numbers to make sure the display area has a center point.

  • +
  • self.worm_has_mapped is building off the worm analogy above. This dictionary will store all rooms the “worm” has mapped as well as its relative position within the grid. This is the most important variable as it acts as a ‘checker’ and ‘address book’ that is able to tell us where the worm has been and what it has mapped so far.

  • +
  • self.curX/Y are coordinates representing the worm’s current location on the grid.

  • +
+

Before any sort of mapping can actually be done we need to create an empty display area and do some sanity checks on it by using the following methods.

+
# in mygame/world/map.py
+
+class Map(object):
+    # [... continued]
+
+    def create_grid(self):
+        # This method simply creates an empty grid/display area
+        # with the specified variables from __init__(self):
+        board = []
+        for row in range(self.max_width):
+            board.append([])
+            for column in range(self.max_length):
+                board[row].append('   ')
+        return board
+
+    def check_grid(self):
+        # this method simply checks the grid to make sure
+        # that both max_l and max_w are odd numbers.
+        return True if self.max_length % 2 != 0 or self.max_width % 2 != 0\
+            else False
+
+
+

Before we can set our worm on its way, we need to know some of the computer science behind all this called ‘Graph Traversing’. In Pseudo code what we are trying to accomplish is this:

+
# pseudo code
+
+def draw_room_on_map(room, max_distance):
+    self.draw(room)
+
+    if max_distance == 0:
+        return
+
+    for exit in room.exits:
+        if self.has_drawn(exit.destination):
+            # skip drawing if we already visited the destination
+            continue
+        else:
+            # first time here!
+            self.draw_room_on_map(exit.destination, max_distance - 1)
+
+
+

The beauty of Python is that our actual code of doing this doesn’t differ much if at all from this +Pseudo code example.

+
    +
  • max_distance is a variable indicating to our Worm how many rooms AWAY from your current location will it map. Obviously the larger the number the more time it will take if your current location has many many rooms around you.

  • +
+

The first hurdle here is what value to use for ‘max_distance’. There is no reason for the worm to travel further than what is actually displayed to you. For example, if your current location is placed in the center of a display area of size max_length = max_width = 9, then the worm need only +go 4 spaces in either direction:

+
[.][.][.][.][@][.][.][.][.]
+ 4  3  2  1  0  1  2  3  4
+
+
+

The max_distance can be set dynamically based on the size of the display area. As your width/length changes it becomes a simple algebraic linear relationship which is simply max_distance = (min(max_width, max_length) -1) / 2.

+
+
+

Building the Mapper

+

Now we can start to fill our Map object with some methods. We are still missing a few methods that are very important:

+
    +
  • self.draw(self, room) - responsible for actually drawing room to grid.

  • +
  • self.has_drawn(self, room) - checks to see if the room has been mapped and worm has already been here.

  • +
  • self.median(self, number) - a simple utility method that finds the median (middle point) from 0, n

  • +
  • self.update_pos(self, room, exit_name) - updates the worm’s physical position by reassigning self.curX/Y accordingly.

  • +
  • self.start_loc_on_grid(self) - the very first initial draw on the grid representing your location in the middle of the grid.

  • +
  • self.show_map - after everything is done convert the map into a readable string

  • +
  • self.draw_room_on_map(self, room, max_distance) - the main method that ties it all together.

  • +
+

Now that we know which methods we need, let’s refine our initial __init__(self) to pass some +conditional statements and set it up to start building the display.

+
#mygame/world/map.py
+
+class Map(object):
+
+    def __init__(self, caller, max_width=9, max_length=9):
+        self.caller = caller
+        self.max_width = max_width
+        self.max_length = max_length
+        self.worm_has_mapped = {}
+        self.curX = None
+        self.curY = None
+
+        if self.check_grid():
+            # we have to store the grid into a variable
+            self.grid = self.create_grid()
+            # we use the algebraic relationship
+            self.draw_room_on_map(caller.location,
+                                  ((min(max_width, max_length) -1 ) / 2)
+
+
+
+

Here we check to see if the parameters for the grid are okay, then we create an empty canvas and map our initial location as the first room!

+

As mentioned above, the code for the self.draw_room_on_map() is not much different than the Pseudo code. The method is shown below:

+
# in mygame/world/map.py, in the Map class
+
+def draw_room_on_map(self, room, max_distance):
+    self.draw(room)
+
+    if max_distance == 0:
+        return
+
+    for exit in room.exits:
+        if exit.name not in ("north", "east", "west", "south"):
+            # we only map in the cardinal directions. Mapping up/down would be
+            # an interesting learning project for someone who wanted to try it.
+            continue
+        if self.has_drawn(exit.destination):
+            # we've been to the destination already, skip ahead.
+            continue
+
+        self.update_pos(room, exit.name.lower())
+        self.draw_room_on_map(exit.destination, max_distance - 1)
+
+
+

The first thing the “worm” does is to draw your current location in self.draw. Lets define that…

+
#in mygame/word/map.py, in the Map class
+
+def draw(self, room):
+    # draw initial ch location on map first!
+    if room == self.caller.location:
+        self.start_loc_on_grid()
+        self.worm_has_mapped[room] = [self.curX, self.curY]
+    else:
+        # map all other rooms
+        self.worm_has_mapped[room] = [self.curX, self.curY]
+        # this will use the sector_type Attribute or None if not set.
+        self.grid[self.curX][self.curY] = SYMBOLS[room.db.sector_type]
+
+
+

In self.start_loc_on_grid():

+
def median(self, num):
+    lst = sorted(range(0, num))
+    n = len(lst)
+    m = n -1
+    return (lst[n//2] + lst[m//2]) / 2.0
+
+def start_loc_on_grid(self):
+    x = self.median(self.max_width)
+    y = self.median(self.max_length)
+    # x and y are floats by default, can't index lists with float types
+    x, y = int(x), int(y)
+
+    self.grid[x][y] = SYMBOLS['you']
+    self.curX, self.curY = x, y # updating worms current location
+
+
+

After the system has drawn the current map it checks to see if the max_distance is 0 (since this +is the inital start phase it is not). Now we handle the iteration once we have each individual exit +in the room. The first thing it does is check if the room the Worm is in has been mapped already… +lets define that…

+
def has_drawn(self, room):
+    return True if room in self.worm_has_mapped.keys() else False
+
+
+

If has_drawn returns False that means the worm has found a room that hasn’t been mapped yet. It +will then ‘move’ there. The self.curX/Y sort of lags behind, so we have to make sure to track the +position of the worm; we do this in self.update_pos() below.

+
def update_pos(self, room, exit_name):
+    # this ensures the coordinates stays up to date
+    # to where the worm is currently at.
+    self.curX, self.curY = \
+      self.worm_has_mapped[room][0], self.worm_has_mapped[room][1]
+
+    # now we have to actually move the pointer
+    # variables depending on which 'exit' it found
+    if exit_name == 'east':
+        self.curY += 1
+    elif exit_name == 'west':
+        self.curY -= 1
+    elif exit_name == 'north':
+        self.curX -= 1
+    elif exit_name == 'south':
+        self.curX += 1
+
+
+

Once the system updates the position of the worm it feeds the new room back into the original +draw_room_on_map() and starts the process all over again…

+

That is essentially the entire thing. The final method is to bring it all together and make a nice +presentational string out of it using the self.show_map() method.

+
def show_map(self):
+    map_string = ""
+    for row in self.grid:
+        map_string += " ".join(row)
+        map_string += "\n"
+
+    return map_string
+
+
+
+
+

Using the Map

+

In order for the map to get triggered we store it on the Room typeclass. If we put it in +return_appearance we will get the map back every time we look at the room.

+
+

return_appearance is a default Evennia hook available on all objects; it is called e.g. by the +look command to get the description of something (the room in this case).

+
+
# in mygame/typeclasses/rooms.py
+
+from evennia import DefaultRoom
+from world.map import Map
+
+class Room(DefaultRoom):
+
+    def return_appearance(self, looker):
+        # [...]
+        string = f"{Map(looker).show_map()}\n"
+        # Add all the normal stuff like room description,
+        # contents, exits etc.
+        string += "\n" + super().return_appearance(looker)
+        return string
+
+
+

Obviously this method of generating maps doesn’t take into account of any doors or exits that are hidden… etc… but hopefully it serves as a good base to start with. Like previously mentioned, it is very important to have a solid foundation on rooms before implementing this. You can try this on vanilla evennia by using @tunnel and essentially you can just create a long straight/edgy non- looping rooms that will show on your in-game map.

+

The above example will display the map above the room description. You could also use an EvTable to place description and map next to each other. Some other things you can do is to have a Command that displays with a larger radius, maybe with a legend and other features.

+

Below is the whole map.py for your reference. You need to update your Room typeclass (see above) to actually call it. Remember that to see different symbols for a location you also need to set the sector_type Attribute on the room to one of the keys in the SYMBOLS dictionary. So in this example, to make a room be mapped as [.] you would set the room’s sector_type to "SECT_INSIDE". Try it out with @set here/sector_type = "SECT_INSIDE". If you wanted all new rooms to have a given sector symbol, you could change the default in the SYMBOLS dictionary below, or you could add the Attribute in the Room’s at_object_creation method.

+
# mygame/world/map.py
+
+# These are keys set with the Attribute sector_type on the room.
+# The keys None and "you" must always exist.
+SYMBOLS = { None : ' . ',  # for rooms without a sector_type attr
+            'you' : '[@]',
+            'SECT_INSIDE': '[.]' }
+
+class Map(object):
+
+    def __init__(self, caller, max_width=9, max_length=9):
+        self.caller = caller
+        self.max_width = max_width
+        self.max_length = max_length
+        self.worm_has_mapped = {}
+        self.curX = None
+        self.curY = None
+
+        if self.check_grid():
+            # we actually have to store the grid into a variable
+            self.grid = self.create_grid()
+            self.draw_room_on_map(caller.location,
+                                 ((min(max_width, max_length) -1 ) / 2))
+
+    def update_pos(self, room, exit_name):
+        # this ensures the pointer variables always
+        # stays up to date to where the worm is currently at.
+        self.curX, self.curY = \
+           self.worm_has_mapped[room][0], self.worm_has_mapped[room][1]
+
+        # now we have to actually move the pointer
+        # variables depending on which 'exit' it found
+        if exit_name == 'east':
+            self.curY += 1
+        elif exit_name == 'west':
+            self.curY -= 1
+        elif exit_name == 'north':
+            self.curX -= 1
+        elif exit_name == 'south':
+            self.curX += 1
+
+    def draw_room_on_map(self, room, max_distance):
+        self.draw(room)
+
+        if max_distance == 0:
+            return
+
+        for exit in room.exits:
+            if exit.name not in ("north", "east", "west", "south"):
+                # we only map in the cardinal directions. Mapping up/down would be
+                # an interesting learning project for someone who wanted to try it.
+                continue
+            if self.has_drawn(exit.destination):
+                # we've been to the destination already, skip ahead.
+                continue
+
+            self.update_pos(room, exit.name.lower())
+            self.draw_room_on_map(exit.destination, max_distance - 1)
+
+    def draw(self, room):
+        # draw initial caller location on map first!
+        if room == self.caller.location:
+            self.start_loc_on_grid()
+            self.worm_has_mapped[room] = [self.curX, self.curY]
+        else:
+            # map all other rooms
+            self.worm_has_mapped[room] = [self.curX, self.curY]
+            # this will use the sector_type Attribute or None if not set.
+            self.grid[self.curX][self.curY] = SYMBOLS[room.db.sector_type]
+
+    def median(self, num):
+        lst = sorted(range(0, num))
+        n = len(lst)
+        m = n -1
+        return (lst[n//2] + lst[m//2]) / 2.0
+
+    def start_loc_on_grid(self):
+        x = self.median(self.max_width)
+        y = self.median(self.max_length)
+        # x and y are floats by default, can't index lists with float types
+        x, y = int(x), int(y)
+
+        self.grid[x][y] = SYMBOLS['you']
+        self.curX, self.curY = x, y # updating worms current location
+
+
+    def has_drawn(self, room):
+        return True if room in self.worm_has_mapped.keys() else False
+
+
+    def create_grid(self):
+        # This method simply creates an empty grid
+        # with the specified variables from __init__(self):
+        board = []
+        for row in range(self.max_width):
+            board.append([])
+            for column in range(self.max_length):
+                board[row].append('   ')
+        return board
+
+    def check_grid(self):
+        # this method simply checks the grid to make sure
+        # both max_l and max_w are odd numbers
+        return True if self.max_length % 2 != 0 or \
+                    self.max_width % 2 != 0 else False
+
+    def show_map(self):
+        map_string = ""
+        for row in self.grid:
+            map_string += " ".join(row)
+            map_string += "\n"
+
+        return map_string
+
+
+
+
+

Final Comments

+

The Dynamic map could be expanded with further capabilities. For example, it could mark exits or +allow NE, SE etc directions as well. It could have colors for different terrain types. One could +also look into up/down directions and figure out how to display that in a good way.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-NPC-Listening.html b/docs/latest/Howtos/Tutorial-NPC-Listening.html new file mode 100644 index 0000000000..9bfcbc0ba5 --- /dev/null +++ b/docs/latest/Howtos/Tutorial-NPC-Listening.html @@ -0,0 +1,257 @@ + + + + + + + + + NPCs that listen to what is said — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

NPCs that listen to what is said

+
> say hi 
+You say, "hi"
+The troll under the bridge answers, "well, well. Hello."
+
+
+

This howto explains how to make an NPC that reacts to characters speaking in their current location. The principle applies to other situations, such as enemies joining a fight or reacting to a character drawing a weapon.

+
# mygame/typeclasses/npc.py
+
+from characters import Character
+
+class Npc(Character):
+    """
+    A NPC typeclass which extends the character class.
+    """
+    def at_heard_say(self, message, from_obj):
+        """
+        A simple listener and response. This makes it easy to change for
+        subclasses of NPCs reacting differently to says.       
+
+        """ 
+        # message will be on the form `<Person> says, "say_text"`
+        # we want to get only say_text without the quotes and any spaces
+        message = message.split('says, ')[1].strip(' "')
+
+        # we'll make use of this in .msg() below
+        return f"{from_obj} said: '{message}'"
+
+
+

We add a simple method at_heard_say that formats what it hears. We assume that the message that enters it is on the form Someone says, "Hello", and we make sure to only get Hello in that example.

+

We are not actually calling at_heard_say yet. We’ll handle that next.

+

When someone in the room speaks to this NPC, its msg method will be called. We will modify the +NPCs .msg method to catch says so the NPC can respond.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
# mygame/typeclasses/npc.py
+
+from characters import Character
+class Npc(Character):
+
+    # [at_heard_say() goes here]
+
+    def msg(self, text=None, from_obj=None, **kwargs):
+        "Custom msg() method reacting to say."
+
+        if from_obj != self:
+            # make sure to not repeat what we ourselves said or we'll create a loop
+            try:
+                # if text comes from a say, `text` is `('say_text', {'type': 'say'})`
+                say_text, is_say = text[0], text[1]['type'] == 'say'
+            except Exception:
+                is_say = False
+            if is_say:
+                # First get the response (if any)
+                response = self.at_heard_say(say_text, from_obj)
+                # If there is a response
+                if response != None:
+                    # speak ourselves, using the return
+                    self.execute_cmd(f"say {response}")   
+    
+        # this is needed if anyone ever puppets this NPC - without it you would never
+        # get any feedback from the server (not even the results of look)
+        super().msg(text=text, from_obj=from_obj, **kwargs) 
+
+
+

So if the NPC gets a say and that say is not coming from the NPC itself, it will echo it using the +at_heard_say hook. Some things of note in the above example:

+
    +
  • Line 15 The text input can be on many different forms depending on where this msg is called from. If you look at the code of the ‘say’ command you’d find that it will call .msg with ("Hello", {"type": "say"}). We use this knowledge to figure out if this comes from a say or not.

  • +
  • Line 24: We use execute_cmd to fire the NPCs own say command back. This works because the NPC is actually a child of DefaultCharacter - so it has the CharacterCmdSet on it! Normally you should use execute_cmd only sparingly; it’s usually more efficient to call the actual code used by the Command directly. For this tutorial, invoking the command is shorter to write while making sure all hooks are called

  • +
  • Line26: Note the comments about super at the end. This will trigger the ‘default’ msg (in the parent class) as well. It’s not really necessary as long as no one puppets the NPC (by @ic <npcname>) but it’s wise to keep in there since the puppeting player will be totally blind if msg() is never returning anything to them!

  • +
+

Now that’s done, let’s create an NPC and see what it has to say for itself.

+
reload
+create/drop Guild Master:npc.Npc
+
+
+

(you could also give the path as typeclasses.npc.Npc, but Evennia will look into the typeclasses +folder automatically so this is a little shorter).

+
> say hi
+You say, "hi"
+Guild Master says, "Anna said: 'hi'"
+
+
+
+

Assorted notes

+

There are many ways to implement this kind of functionality. An alternative example to overriding +msg would be to modify the at_say hook on the Character instead. It could detect that it’s +sending to an NPC and call the at_heard_say hook directly.

+

While the tutorial solution has the advantage of being contained only within the NPC class, +combining this with using the Character class gives more direct control over how the NPC will react. Which way to go depends on the design requirements of your particular game.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-NPC-Merchants.html b/docs/latest/Howtos/Tutorial-NPC-Merchants.html new file mode 100644 index 0000000000..1b4a335c15 --- /dev/null +++ b/docs/latest/Howtos/Tutorial-NPC-Merchants.html @@ -0,0 +1,384 @@ + + + + + + + + + NPC merchants — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

NPC merchants

+
*** Welcome to ye Old Sword shop! ***
+   Things for sale (choose 1-3 to inspect, quit to exit):
+_________________________________________________________
+1. A rusty sword (5 gold)
+2. A sword with a leather handle (10 gold)
+3. Excalibur (100 gold)
+
+
+

This will introduce an NPC able to sell things. In practice this means that when you interact with them you’ll get shown a menu of choices. Evennia provides the EvMenu utility to easily create in-game menus.

+

We will store all the merchant’s wares in their inventory. This means that they may stand in an actual shop room, at a market or wander the road. We will also use ‘gold’ as an example currency.
+To enter the shop, you’ll just need to stand in the same room and use the buy/shop command.

+
+

Making the merchant class

+

The merchant will respond to you giving the shop or buy command in their presence.

+
# in for example mygame/typeclasses/merchants.py 
+
+from typeclasses.objects import Object
+from evennia import Command, CmdSet, EvMenu
+
+class CmdOpenShop(Command): 
+    """
+    Open the shop! 
+
+    Usage:
+        shop/buy 
+
+    """
+    key = "shop"
+    aliases = ["buy"]
+
+    def func(self):
+        # this will sit on the Merchant, which is self.obj. 
+        # the self.caller is the player wanting to buy stuff.    
+        self.obj.open_shop(self.caller)
+        
+
+class MerchantCmdSet(CmdSet):
+    def at_cmdset_creation(self):
+        self.add(CmdOpenShop())
+
+
+class NPCMerchant(Object):
+
+     def at_object_creation(self):
+         self.cmdset.add_default(MerchantCmdSet)
+
+     def open_shop(self, shopper):
+         menunodes = {}  # TODO! 
+         shopname = self.db.shopname or "The shop"
+         EvMenu(shopper, menunodes, startnode="shopfront", 
+                shopname=shopname, shopkeeper=self, wares=self.contents)
+
+
+
+

We could also have put the commands in a separate module, but for compactness, we put it all with the merchant typeclass.

+

Note that we make the merchant an Object! Since we don’t give them any other commands, it makes little sense to let them be a Character.

+

We make a very simple shop/buy Command and make sure to add it on the merchant in its own cmdset.

+

We initialize EvMenu on the shopper but we haven’t created any menunodes yet, so this will not actually do much at this point. It’s important that we we pass shopname, shopkeeper and wares into the menu, it means they will be made available as properties on the EvMenu instance - we will be able to access them from inside the menu.

+
+
+

Coding the shopping menu

+

EvMenu splits the menu into nodes represented by Python functions. Each node represents a stop in the menu where the user has to make a choice.

+

For simplicity, we’ll code the shop interface above the NPCMerchant class in the same module.

+

The start node of the shop named “ye Old Sword shop!” will look like this if there are only 3 wares to sell:

+
*** Welcome to ye Old Sword shop! ***
+   Things for sale (choose 1-3 to inspect, quit to exit):
+_________________________________________________________
+1. A rusty sword (5 gold)
+2. A sword with a leather handle (10 gold)
+3. Excalibur (100 gold)
+
+
+
# in mygame/typeclasses/merchants.py
+
+# top of module, above NPCMerchant class.
+
+def node_shopfront(caller, raw_string, **kwargs):
+    "This is the top-menu screen."
+
+    # made available since we passed them to EvMenu on start 
+    menu = caller.ndb._evmenu
+    shopname = menu.shopname
+    shopkeeper = menu.shopkeeper 
+    wares = menu.wares
+
+    text = f"*** Welcome to {shopname}! ***\n"
+    if wares:
+        text += f"   Things for sale (choose 1-{len(wares)} to inspect); quit to exit:"
+    else:
+        text += "   There is nothing for sale; quit to exit."
+
+    options = []
+    for ware in wares:
+        # add an option for every ware in store
+        gold_val = ware.db.gold_value or 1
+        options.append({"desc": f"{ware.key} ({gold_val} gold)",
+                        "goto": ("inspect_and_buy", 
+                                 {"selected_ware": ware})
+                       })
+                       
+    return text, options
+
+
+

Inside the node we can access the menu on the caller as caller.ndb._evmenu. The extra keywords we passed into EvMenu are available on this menu instance. Armed with this we can easily present a shop interface. Each option will become a numbered choice on this screen.

+

Note how we pass the ware with each option and label it selected_ware. This will be accessible in the next node’s **kwargs argument

+

If a player choose one of the wares, they should be able to inspect it. Here’s how it should look if they selected 1 in ye Old Sword shop:

+
You inspect A rusty sword:
+
+This is an old weapon maybe once used by soldiers in some
+long forgotten army. It is rusty and in bad condition.
+__________________________________________________________
+1. Buy A rusty sword (5 gold)
+2. Look for something else.
+
+
+

If you buy, you’ll see

+
You pay 5 gold and purchase A rusty sword!
+
+
+

or

+
You cannot afford 5 gold for A rusty sword!
+
+
+

Either way you should end up back at the top level of the shopping menu again and can continue browsing or quit the menu with quit.

+

Here’s how it looks in code:

+
# in mygame/typeclasses/merchants.py 
+
+# right after the other node
+
+def _buy_item(caller, raw_string, **kwargs):
+    "Called if buyer chooses to buy"
+    selected_ware = kwargs["selected_ware"]
+    value = selected_ware.db.gold_value or 1
+    wealth = caller.db.gold or 0
+
+    if wealth >= value:
+        rtext = f"You pay {value} gold and purchase {ware.key}!"
+        caller.db.gold -= value
+        move_to(caller, quiet=True, move_type="buy")
+    else:
+        rtext = f"You cannot afford {value} gold for {ware.key}!"
+    caller.msg(rtext)
+    # no matter what, we return to the top level of the shop
+    return "shopfront"
+
+def node_inspect_and_buy(caller, raw_string, **kwargs):
+    "Sets up the buy menu screen."
+
+    # passed from the option we chose 
+    selected_ware = kwargs["selected_ware"]
+
+    value = selected_ware.db.gold_value or 1
+    text = f"You inspect {ware.key}:\n\n{ware.db.desc}"
+    gold_val = ware.db.gold_value or 1
+
+    options = ({
+        "desc": f"Buy {ware.key} for {gold_val} gold",
+        "goto": (_buy_item, kwargs)
+    }, {
+        "desc": "Look for something else",
+        "goto": "shopfront",
+    })
+    return text, options
+
+
+

In this node we grab the selected_ware from kwargs - this we pased along from the option on the previous node. We display its description and value. If the user buys, we reroute through the _buy_item helper function (this is not a node, it’s just a callable that must return the name of the next node to go to.). In _buy_item we check if the buyer can affort the ware, and if it can we move it to their inventory. Either way, this method returns shop_front as the next node.

+

We have been referring to two nodes here: "shopfront" and "inspect_and_buy" , we should map them to the code in the menu. Scroll down to the NPCMerchant class in the same module and find that unfinished open_shop method again:

+
# in /mygame/typeclasses/merchants.py
+
+def node_shopfront(caller, raw_string, **kwargs):
+    # ... 
+
+def _buy_item(caller, raw_string, **kwargs):
+    # ...
+
+def node_inspect_and_buy(caller, raw_string, **kwargs):
+    # ... 
+
+class NPCMerchant(Object):
+
+     # ...
+
+     def open_shop(self, shopper):
+         menunodes = {
+             "shopfront": node_shopfront,
+             "inspect_and_buy": node_inspect_and_buy
+         }
+         shopname = self.db.shopname or "The shop"
+         EvMenu(shopper, menunodes, startnode="shopfront", 
+                shopname=shopname, shopkeeper=self, wares=self.contents)
+
+
+
+

We now added the nodes to the Evmenu under their right labels. The merchant is now ready!

+
+
+

The shop is open for business!

+

Make sure to reload.

+

Let’s try it out by creating the merchant and a few wares in-game. Remember that we also must create some gold get this economy going.

+
> set self/gold = 8
+
+> create/drop Stan S. Stanman;stan:typeclasses.merchants.NPCMerchant
+> set stan/shopname = Stan's previously owned vessles
+
+> create/drop A proud vessel;ship 
+> set ship/desc = The thing has holes in it.
+> set ship/gold_value = 5
+
+> create/drop A classic speedster;rowboat 
+> set rowboat/gold_value = 2
+> set rowboat/desc = It's not going anywhere fast.
+
+
+

Note that a builder without any access to Python code can now set up a personalized merchant with just in-game commands. With the shop all set up, we just need to be in the same room to start consuming!

+
> buy
+*** Welcome to Stan's previously owned vessels! ***
+   Things for sale (choose 1-3 to inspect, quit to exit):
+_________________________________________________________
+1. A proud vessel (5 gold)
+2. A classic speedster (2 gold)
+
+> 1 
+
+You inspect A proud vessel:
+
+The thing has holes in it.
+__________________________________________________________
+1. Buy A proud vessel (5 gold)
+2. Look for something else.
+
+> 1
+You pay 5 gold and purchase A proud vessel!
+
+*** Welcome to Stan's previously owned vessels! ***
+   Things for sale (choose 1-3 to inspect, quit to exit):
+_________________________________________________________
+1. A classic speedster (2 gold)
+
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-NPC-Reacting.html b/docs/latest/Howtos/Tutorial-NPC-Reacting.html new file mode 100644 index 0000000000..8841facb11 --- /dev/null +++ b/docs/latest/Howtos/Tutorial-NPC-Reacting.html @@ -0,0 +1,212 @@ + + + + + + + + + NPCs reacting to your presence — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

NPCs reacting to your presence

+
> north 
+------------------------------------
+Meadow
+You are standing in a green meadow. 
+A bandit is here. 
+------------------------------------
+Bandit gives you a menacing look!
+
+
+

This tutorial shows the implementation of an NPC object that responds to characters entering their +location.

+

What we will need is the following:

+
    +
  • An NPC typeclass that can react when someone enters.

  • +
  • A custom Room typeclass that can tell the NPC that someone entered.

  • +
  • We will also tweak our default Character typeclass a little.

  • +
+
# in mygame/typeclasses/npcs.py  (for example)
+
+from typeclasses.characters import Character
+
+class NPC(Character):
+    """
+    A NPC typeclass which extends the character class.
+    """
+    def at_char_entered(self, character):
+        """
+        A simple is_aggressive check.
+        Can be expanded upon later.
+        """
+        if self.db.is_aggressive:
+            self.execute_cmd(f"say Graaah! Die, {character}!")
+        else:
+            self.execute_cmd(f"say Greetings, {character}!")
+
+
+

Here we make a simple method on the NPC˙. We expect it to be called when a (player-)character enters the room. We don’t actually set the is_aggressive Attribute beforehand; if it’s not set, the NPC is simply non-hostile.

+

Whenever something enters the Room, its at_object_receive hook will be called. So we should override it.

+
# in mygame/typeclasses/rooms.py
+
+from evennia import utils
+
+# ... 
+
+class Room(ObjectParent, DefaultRoom):
+
+    # ... 
+    
+    def at_object_receive(self, arriving_obj, source_location):
+        if arriving_obj.account: 
+            # this has an active acccount - a player character
+            for item in self.contents:
+                # get all npcs in the room and inform them
+                if  utils.inherits_from(item, "typeclasses.npcs.NPC"):
+                    self.at_char_entered(arriving_obj)
+
+
+
+ +

A currently puppeted Character will have an .account attached to it. We use that to know that the thing arriving is a Character. We then use Evennia’s utils.inherits_from helper utility to get every NPC in the room can each of their newly created at_char_entered method.

+

Make sure to reload.

+

Let’s create an NPC and make it aggressive. For the sake of this example, let’s assume your name is “Anna” and that there is a room to the north of your current location.

+
> create/drop Orc:typeclasses.npcs.NPC
+> north 
+> south 
+Orc says, Greetings, Anna!
+
+
+

Now let’s turn the orc aggressive.

+
> set orc/is_aggressive = True 
+> north 
+> south 
+Orc says, Graah! Die, Anna!
+
+
+

That’s one easily aggravated Orc!

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Parsing-Commands.html b/docs/latest/Howtos/Tutorial-Parsing-Commands.html new file mode 100644 index 0000000000..ce2e7408bf --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Parsing-Commands.html @@ -0,0 +1,851 @@ + + + + + + + + + Parsing command arguments, theory and best practices — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Parsing command arguments, theory and best practices

+

This tutorial will elaborate on the many ways one can parse command arguments. The first step after +adding a command usually is to parse its arguments. There are lots of +ways to do it, but some are indeed better than others and this tutorial will try to present them.

+

If you’re a Python beginner, this tutorial might help you a lot. If you’re already familiar with +Python syntax, this tutorial might still contain useful information. There are still a lot of +things I find in the standard library that come as a surprise, though they were there all along. +This might be true for others.

+

In this tutorial we will:

+
    +
  • Parse arguments with numbers.

  • +
  • Parse arguments with delimiters.

  • +
  • Take a look at optional arguments.

  • +
  • Parse argument containing object names.

  • +
+
+

What are command arguments?

+

I’m going to talk about command arguments and parsing a lot in this tutorial. So let’s be sure we +talk about the same thing before going any further:

+
+

A command is an Evennia object that handles specific user input.

+
+

For instance, the default look is a command. After having created your Evennia game, and +connected to it, you should be able to type look to see what’s around. In this context, look is +a command.

+
+

Command arguments are additional text passed after the command.

+
+

Following the same example, you can type look self to look at yourself. In this context, self +is the text specified after look. " self" is the argument to the look command.

+

Part of our task as a game developer is to connect user inputs (mostly commands) with actions in the +game. And most of the time, entering commands is not enough, we have to rely on arguments for +specifying actions with more accuracy.

+

Take the say command. If you couldn’t specify what to say as a command argument (say hello!), +you would have trouble communicating with others in the game. One would need to create a different +command for every kind of word or sentence, which is, of course, not practical.

+

Last thing: what is parsing?

+
+

In our case, parsing is the process by which we convert command arguments into something we can work with.

+
+

We don’t usually use the command argument as is (which is just text, of type str in Python). We +need to extract useful information. We might want to ask the user for a number, or the name of +another character present in the same room. We’re going to see how to do all that now.

+
+
+

Working with strings

+

In object terms, when you write a command in Evennia (when you write the Python class), the +arguments are stored in the args attribute. Which is to say, inside your func method, you can +access the command arguments in self.args.

+
+

self.args

+

To begin with, look at this example:

+
class CmdTest(Command):
+
+    """
+    Test command.
+
+    Syntax:
+      test [argument]
+
+    Enter any argument after test.
+
+    """
+
+    key = "test"
+
+    def func(self):
+        self.msg(f"You have entered: {self.args}.")
+
+
+

If you add this command and test it, you will receive exactly what you have entered without any +parsing:

+
> test Whatever
+You have entered:  Whatever.
+> test
+You have entered: .
+
+
+
+

The lines starting with > indicate what you enter into your client. The other lines are what +you receive from the game server.

+
+

Notice two things here:

+
    +
  1. The left space between our command key (“test”, here) and our command argument is not removed. +That’s why there are two spaces in our output at line 2. Try entering something like “testok”.

  2. +
  3. Even if you don’t enter command arguments, the command will still be called with an empty string +in self.args.

  4. +
+

Perhaps a slight modification to our code would be appropriate to see what’s happening. We will +force Python to display the command arguments as a debug string using a little shortcut.

+
class CmdTest(Command):
+
+    """
+    Test command.
+
+    Syntax:
+      test [argument]
+
+    Enter any argument after test.
+
+    """
+
+    key = "test"
+
+    def func(self):
+        self.msg(f"You have entered: {self.args!r}.")
+
+
+

The only line we have changed is the last one, and we have added !r between our braces to tell +Python to print the debug version of the argument (the repr-ed version). Let’s see the result:

+
> test Whatever
+You have entered: ' Whatever'.
+> test
+You have entered: ''.
+> test And something with '?
+You have entered: " And something with '?".
+
+
+

This displays the string in a way you could see in the Python interpreter. It might be easier to +read… to debug, anyway.

+

I insist so much on that point because it’s crucial: the command argument is just a string (of type +str) and we will use this to parse it. What you will see is mostly not Evennia-specific, it’s +Python-specific and could be used in any other project where you have the same need.

+
+
+

Stripping

+

As you’ve seen, our command arguments are stored with the space. And the space between the command +and the arguments is often of no importance.

+
+

Why is it ever there?

+
+

Evennia will try its best to find a matching command. If the user enters your command key with +arguments (but omits the space), Evennia will still be able to find and call the command. You might +have seen what happened if the user entered testok. In this case, testok could very well be a +command (Evennia checks for that) but seeing none, and because there’s a test command, Evennia +calls it with the arguments "ok".

+

But most of the time, we don’t really care about this left space, so you will often see code to +remove it. There are different ways to do it in Python, but a command use case is the strip +method on str and its cousins, lstrip and rstrip.

+
    +
  • strip: removes one or more characters (either spaces or other characters) from both ends of the +string.

  • +
  • lstrip: same thing but only removes from the left end (left strip) of the string.

  • +
  • rstrip: same thing but only removes from the right end (right strip) of the string.

  • +
+

Some Python examples might help:

+
>>> '   this is '.strip() # remove spaces by default
+'this is'
+>>> "   What if I'm right?   ".lstrip() # strip spaces from the left
+"What if I'm right?   "
+>>> 'Looks good to me...'.strip('.') # removes '.'
+'Looks good to me'
+>>> '"Now, what is it?"'.strip('"?') # removes '"' and '?' from both ends
+'Now, what is it'
+
+
+

Usually, since we don’t need the space separator, but still want our command to work if there’s no +separator, we call lstrip on the command arguments:

+
class CmdTest(Command):
+
+    """
+    Test command.
+
+    Syntax:
+      test [argument]
+
+    Enter any argument after test.
+
+    """
+
+    key = "test"
+
+    def parse(self):
+        """Parse arguments, just strip them."""
+        self.args = self.args.lstrip()
+
+    def func(self):
+        self.msg(f"You have entered: {self.args!r}.")
+
+
+
+

We are now beginning to override the command’s parse method, which is typically useful just for +argument parsing. This method is executed before func and so self.args in func() will contain +our self.args.lstrip().

+
+

Let’s try it:

+
> test Whatever
+You have entered: 'Whatever'.
+> test
+You have entered: ''.
+> test And something with '?
+You have entered: "And something with '?".
+> test     And something with lots of spaces
+You have entered: 'And something with lots of spaces'.
+
+
+

Spaces at the end of the string are kept, but all spaces at the beginning are removed:

+
+

strip, lstrip and rstrip without arguments will strip spaces, line breaks and other common +separators. You can specify one or more characters as a parameter. If you specify more than one +character, all of them will be stripped from your original string.

+
+
+
+

Convert arguments to numbers

+

As pointed out, self.args is a string (of type str). What if we want the user to enter a +number?

+

Let’s take a very simple example: creating a command, roll, that allows to roll a six-sided die. +The player has to guess the number, specifying the number as argument. To win, the player has to +match the number with the die. Let’s see an example:

+
> roll 3
+You roll a die.  It lands on the number 4.
+You played 3, you have lost.
+> dice 1
+You roll a die.  It lands on the number 2.
+You played 1, you have lost.
+> dice 1
+You roll a die.  It lands on the number 1.
+You played 1, you have won!
+
+
+

If that’s your first command, it’s a good opportunity to try to write it. A command with a simple +and finite role always is a good starting choice. Here’s how we could (first) write it… but it +won’t work as is, I warn you:

+
from random import randint
+
+from evennia import Command
+
+class CmdRoll(Command):
+
+    """
+    Play random, enter a number and try your luck.
+
+    Usage:
+      roll <number>
+
+    Enter a valid number as argument.  A random die will be rolled and you
+    will win if you have specified the correct number.
+
+    Example:
+      roll 3
+
+    """
+
+    key = "roll"
+
+    def parse(self):
+        """Convert the argument to a number."""
+        self.args = self.args.lstrip()
+
+    def func(self):
+        # Roll a random die
+        figure = randint(1, 6) # return a pseudo-random number between 1 and 6, including both
+        self.msg(f"You roll a die.  It lands on the number {figure}.")
+
+        if self.args == figure: # THAT WILL BREAK!
+            self.msg(f"You played {self.args}, you have won!")
+        else:
+            self.msg(f"You played {self.args}, you have lost.")
+
+
+

If you try this code, Python will complain that you try to compare a number with a string: figure +is a number and self.args is a string and can’t be compared as-is in Python. Python doesn’t do +“implicit converting” as some languages do. By the way, this might be annoying sometimes, and other +times you will be glad it tries to encourage you to be explicit rather than implicit about what to +do. This is an ongoing debate between programmers. Let’s move on!

+

So we need to convert the command argument from a str into an int. There are a few ways to do +it. But the proper way is to try to convert and deal with the ValueError Python exception.

+

Converting a str into an int in Python is extremely simple: just use the int function, give it +the string and it returns an integer, if it could. If it can’t, it will raise ValueError. So +we’ll need to catch that. However, we also have to indicate to Evennia that, should the number be +invalid, no further parsing should be done. Here’s a new attempt at our command with this +converting:

+
from random import randint
+
+from evennia import Command, InterruptCommand
+
+class CmdRoll(Command):
+
+    """
+    Play random, enter a number and try your luck.
+
+    Usage:
+      roll <number>
+
+    Enter a valid number as argument.  A random die will be rolled and you
+    will win if you have specified the correct number.
+
+    Example:
+      roll 3
+
+    """
+
+    key = "roll"
+
+    def parse(self):
+        """Convert the argument to number if possible."""
+        args = self.args.lstrip()
+
+        # Convert to int if possible
+        # If not, raise InterruptCommand.  Evennia will catch this
+        # exception and not call the 'func' method.
+        try:
+            self.entered = int(args)
+        except ValueError:
+            self.msg(f"{args} is not a valid number.")
+            raise InterruptCommand
+
+    def func(self):
+        # Roll a random die
+        figure = randint(1, 6) # return a pseudo-random number between 1 and 6, including both
+        self.msg(f"You roll a die.  It lands on the number {figure}.")
+
+        if self.entered == figure:
+            self.msg(f"You played {self.entered}, you have won!")
+        else:
+            self.msg(f"You played {self.entered}, you have lost.")
+
+
+

Before enjoying the result, let’s examine the parse method a little more: what it does is try to +convert the entered argument from a str to an int. This might fail (if a user enters roll something). In such a case, Python raises a ValueError exception. We catch it in our +try/except block, send a message to the user and raise the InterruptCommand exception in +response to tell Evennia to not run func(), since we have no valid number to give it.

+

In the func method, instead of using self.args, we use self.entered which we have defined in +our parse method. You can expect that, if func() is run, then self.entered contains a valid +number.

+

If you try this command, it will work as expected this time: the number is converted as it should +and compared to the die roll. You might spend some minutes playing this game. Time out!

+

Something else we could want to address: in our small example, we only want the user to enter a +positive number between 1 and 6. And the user can enter roll 0 or roll -8 or roll 208 for +that matter, the game still works. It might be worth addressing. Again, you could write a +condition to do that, but since we’re catching an exception, we might end up with something cleaner +by grouping:

+
from random import randint
+
+from evennia import Command, InterruptCommand
+
+class CmdRoll(Command):
+
+    """
+    Play random, enter a number and try your luck.
+
+    Usage:
+      roll <number>
+
+    Enter a valid number as argument.  A random die will be rolled and you
+    will win if you have specified the correct number.
+
+    Example:
+      roll 3
+
+    """
+
+    key = "roll"
+
+    def parse(self):
+        """Convert the argument to number if possible."""
+        args = self.args.lstrip()
+
+        # Convert to int if possible
+        try:
+            self.entered = int(args)
+            if not 1 <= self.entered <= 6:
+                # self.entered is not between 1 and 6 (including both)
+                raise ValueError
+        except ValueError:
+            self.msg(f"{args} is not a valid number.")
+            raise InterruptCommand
+
+    def func(self):
+        # Roll a random die
+        figure = randint(1, 6) # return a pseudo-random number between 1 and 6, including both
+        self.msg(f"You roll a die.  It lands on the number {figure}.")
+
+        if self.entered == figure:
+            self.msg(f"You played {self.entered}, you have won!")
+        else:
+            self.msg(f"You played {self.entered}, you have lost.")
+
+
+

Using grouped exceptions like that makes our code easier to read, but if you feel more comfortable +checking, afterward, that the number the user entered is in the right range, you can do so in a +latter condition.

+
+

Notice that we have updated our parse method only in this last attempt, not our func() method +which remains the same. This is one goal of separating argument parsing from command processing, +these two actions are best kept isolated.

+
+
+
+

Working with several arguments

+

Often a command expects several arguments. So far, in our example with the “roll” command, we only +expect one argument: a number and just a number. What if we want the user to specify several +numbers? First the number of dice to roll, then the guess?

+
+

You won’t win often if you roll 5 dice but that’s for the example.

+
+

So we would like to interpret a command like this:

+
> roll 3 12
+
+
+

(To be understood: roll 3 dice, my guess is the total number will be 12.)

+

What we need is to cut our command argument, which is a str, break it at the space (we use the +space as a delimiter). Python provides the str.split method which we’ll use. Again, here are +some examples from the Python interpreter:

+
>>> args = "3 12"
+>>> args.split(" ")
+['3', '12']
+>>> args = "a command with several arguments"
+>>> args.split(" ")
+['a', 'command', 'with', 'several', 'arguments']
+>>>
+
+
+

As you can see, str.split will “convert” our strings into a list of strings. The specified +argument (" " in our case) is used as delimiter. So Python browses our original string. When it +sees a delimiter, it takes whatever is before this delimiter and append it to a list.

+

The point here is that str.split will be used to split our argument. But, as you can see from the +above output, we can never be sure of the length of the list at this point:

+
>>> args = "something"
+>>> args.split(" ")
+['something']
+>>> args = ""
+>>> args.split(" ")
+['']
+>>>
+
+
+

Again we could use a condition to check the number of split arguments, but Python offers a better +approach, making use of its exception mechanism. We’ll give a second argument to str.split, the +maximum number of splits to do. Let’s see an example, this feature might be confusing at first +glance:

+
>>> args = "that is something great"
+>>> args.split(" ", 1) # one split, that is a list with two elements (before, after)
+
+
+

[‘that’, ‘is something great’]

+
+
+
+
+

Read this example as many times as needed to understand it. The second argument we give to +str.split is not the length of the list that should be returned, but the number of times we have +to split. Therefore, we specify 1 here, but we get a list of two elements (before the separator, +after the separator).

+
+

What will happen if Python can’t split the number of times we ask?

+
+

It won’t:

+
>>> args = "whatever"
+>>> args.split(" ", 1) # there isn't even a space here...
+['whatever']
+>>>
+
+
+

This is one moment I would have hoped for an exception and didn’t get one. But there’s another way +which will raise an exception if there is an error: variable unpacking.

+

We won’t talk about this feature in details here. It would be complicated. But the code is really +straightforward to use. Let’s take our example of the roll command but let’s add a first argument: +the number of dice to roll.

+
from random import randint
+
+from evennia import Command, InterruptCommand
+
+class CmdRoll(Command):
+
+    """
+    Play random, enter a number and try your luck.
+
+    Specify two numbers separated by a space.  The first number is the
+    number of dice to roll (1, 2, 3) and the second is the expected sum
+    of the roll.
+
+    Usage:
+      roll <dice> <number>
+
+    For instance, to roll two 6-figure dice, enter 2 as first argument.
+    If you think the sum of these two dice roll will be 10, you could enter:
+
+        roll 2 10
+
+    """
+
+    key = "roll"
+
+    def parse(self):
+        """Split the arguments and convert them."""
+        args = self.args.lstrip()
+
+        # Split: we expect two arguments separated by a space
+        try:
+            number, guess = args.split(" ", 1)
+        except ValueError:
+            self.msg("Invalid usage.  Enter two numbers separated by a space.")
+            raise InterruptCommand
+
+        # Convert the entered number (first argument)
+        try:
+            self.number = int(number)
+            if self.number <= 0:
+                raise ValueError
+        except ValueError:
+            self.msg(f"{number} is not a valid number of dice.")
+            raise InterruptCommand
+
+        # Convert the entered guess (second argument)
+        try:
+            self.guess = int(guess)
+            if not 1 <= self.guess <= self.number * 6:
+                raise ValueError
+        except ValueError:
+            self.msg(f"{self.guess} is not a valid guess.")
+            raise InterruptCommand
+
+    def func(self):
+        # Roll a random die X times (X being self.number)
+        figure = 0
+        for _ in range(self.number):
+            figure += randint(1, 6)
+
+        self.msg(f"You roll {self.number} dice and obtain the sum {figure}.")
+
+        if self.guess == figure:
+            self.msg(f"You played {self.guess}, you have won!")
+        else:
+            self.msg(f"You played {self.guess}, you have lost.")
+
+
+

The beginning of the parse() method is what interests us most:

+
try:
+    number, guess = args.split(" ", 1)
+except ValueError:
+    self.msg("Invalid usage.  Enter two numbers separated by a space.")
+    raise InterruptCommand
+
+
+

We split the argument using str.split but we capture the result in two variables. Python is smart +enough to know that we want what’s left of the space in the first variable, what’s right of the +space in the second variable. If there is not even a space in the string, Python will raise a +ValueError exception.

+

This code is much easier to read than browsing through the returned strings of str.split. We can +convert both variables the way we did previously. Actually there are not so many changes in this +version and the previous one, most of it is due to name changes for clarity.

+
+

Splitting a string with a maximum of splits is a common occurrence while parsing command +arguments. You can also see the str.rspli8t method that does the same thing but from the right of +the string. Therefore, it will attempt to find delimiters at the end of the string and work toward +the beginning of it.

+
+

We have used a space as a delimiter. This is absolutely not necessary. You might remember that +most default Evennia commands can take an = sign as a delimiter. Now you know how to parse them +as well:

+
>>> cmd_key = "tel"
+>>> cmd_args = "book = chest"
+>>> left, right = cmd_args.split("=") # mighht raise ValueError!
+>>> left
+'book '
+>>> right
+' chest'
+>>>
+
+
+
+
+

Optional arguments

+

Sometimes, you’ll come across commands that have optional arguments. These arguments are not +necessary but they can be set if more information is needed. I will not provide the entire command +code here but just enough code to show the mechanism in Python:

+

Again, we’ll use str.split, knowing that we might not have any delimiter at all. For instance, +the player could enter the “tel” command like this:

+
> tel book
+> tell book = chest
+
+
+

The equal sign is optional along with whatever is specified after it. A possible solution in our +parse method would be:

+
    def parse(self):
+        args = self.args.lstrip()
+
+        # = is optional
+        try:
+            obj, destination = args.split("=", 1)
+        except ValueError:
+            obj = args
+            destination = None
+
+
+

This code would place everything the user entered in obj if she didn’t specify any equal sign. +Otherwise, what’s before the equal sign will go in obj, what’s after the equal sign will go in +destination. This makes for quick testing after that, more robust code with less conditions that +might too easily break your code if you’re not careful.

+
+

Again, here we specified a maximum numbers of splits. If the users enters:

+
+
> tel book = chest = chair
+
+
+

Then destination will contain: " chest = chair". This is often desired, but it’s up to you to +set parsing however you like.

+
+
+
+

Evennia searches

+

After this quick tour of some str methods, we’ll take a look at some Evennia-specific features +that you won’t find in standard Python.

+

One very common task is to convert a str into an Evennia object. Take the previous example: +having "book" in a variable is great, but we would prefer to know what the user is talking +about… what is this "book"?

+

To get an object from a string, we perform an Evennia search. Evennia provides a search method on +all typeclassed objects (you will most likely use the one on characters or accounts). This method +supports a very wide array of arguments and has its own tutorial. +Some examples of useful cases follow:

+
+

Local searches

+

When an account or a character enters a command, the account or character is found in the caller +attribute. Therefore, self.caller will contain an account or a character (or a session if that’s +a session command, though that’s not as frequent). The search method will be available on this +caller.

+

Let’s take the same example of our little “tel” command. The user can specify an object as +argument:

+
    def parse(self):
+        name = self.args.lstrip()
+
+
+

We then need to “convert” this string into an Evennia object. The Evennia object will be searched +in the caller’s location and its contents by default (that is to say, if the command has been +entered by a character, it will search the object in the character’s room and the character’s +inventory).

+
    def parse(self):
+        name = self.args.lstrip()
+
+        self.obj = self.caller.search(name)
+
+
+

We specify only one argument to the search method here: the string to search. If Evennia finds a +match, it will return it and we keep it in the obj attribute. If it can’t find anything, it will +return None so we need to check for that:

+
    def parse(self):
+        name = self.args.lstrip()
+
+        self.obj = self.caller.search(name)
+        if self.obj is None:
+            # A proper error message has already been sent to the caller
+            raise InterruptCommand
+
+
+

That’s it. After this condition, you know that whatever is in self.obj is a valid Evennia object +(another character, an object, an exit…).

+
+
+

Quiet searches

+

By default, Evennia will handle the case when more than one match is found in the search. The user +will be asked to narrow down and re-enter the command. You can, however, ask to be returned the +list of matches and handle this list yourself:

+
    def parse(self):
+        name = self.args.lstrip()
+
+        objs = self.caller.search(name, quiet=True)
+        if not objs:
+            # This is an empty list, so no match
+            self.msg(f"No {name!r} was found.")
+            raise InterruptCommand
+        
+        self.obj = objs[0] # Take the first match even if there are several
+
+
+

All we have changed to obtain a list is a keyword argument in the search method: quiet. If set +to True, then errors are ignored and a list is always returned, so we need to handle it as such. +Notice in this example, self.obj will contain a valid object too, but if several matches are +found, self.obj will contain the first one, even if more matches are available.

+
+
+

Global searches

+

By default, Evennia will perform a local search, that is, a search limited by the location in which +the caller is. If you want to perform a global search (search in the entire database), just set the +global_search keyword argument to True:

+
    def parse(self):
+        name = self.args.lstrip()
+        self.obj = self.caller.search(name, global_search=True)
+
+
+
+
+
+

Conclusion

+

Parsing command arguments is vital for most game designers. If you design “intelligent” commands, +users should be able to guess how to use them without reading the help, or with a very quick peek at +said help. Good commands are intuitive to users. Better commands do what they’re told to do. For +game designers working on MUDs, commands are the main entry point for users into your game. This is +no trivial. If commands execute correctly (if their argument is parsed, if they don’t behave in +unexpected ways and report back the right errors), you will have happier players that might stay +longer on your game. I hope this tutorial gave you some pointers on ways to improve your command +parsing. There are, of course, other ways you will discover, or ways you are already using in your +code.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Persistent-Handler.html b/docs/latest/Howtos/Tutorial-Persistent-Handler.html new file mode 100644 index 0000000000..22c596e920 --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Persistent-Handler.html @@ -0,0 +1,359 @@ + + + + + + + + + Making a Persistent object Handler — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Making a Persistent object Handler

+

A handler is a convenient way to group functionality on an object. This allows you to logically +group all actions related to that thing in one place. This tutorial expemplifies how to make your +own handlers and make sure data you store in them survives a reload.

+

For example, when you do obj.attributes.get("key") or obj.tags.add('tagname') you are evoking +handlers stored as .attributes and tags on the obj. On these handlers are methods (get() +and add() in this example).

+
+

Base Handler example

+

Here is a base way to set up an on-object handler:

+

+from evennia import DefaultObject, create_object
+from evennia.utils.utils import lazy_property
+
+class NameChanger:
+    def __init__(self, obj):
+        self.obj = obj
+
+    def add_to_key(self, suffix):
+        self.obj.key = f"self.obj.key_{suffix}"
+
+# make a test object
+class MyObject(DefaultObject):
+    @lazy_property:
+    def namechange(self):
+       return NameChanger(self)
+
+
+obj = create_object(MyObject, key="test")
+print(obj.key)
+>>> "test"
+obj.namechange.add_to_key("extra")
+print(obj.key)
+>>> "test_extra"
+
+
+

What happens here is that we make a new class NameChanger. We use the +@lazy_property decorator to set it up - this means the handler will not be +actually created until someone really wants to use it, by accessing +obj.namechange later. The decorated namechange method returns the handler +and makes sure to initialize it with self - this becomes the obj inside the +handler!

+

We then make a silly method add_to_key that uses the handler to manipulate the +key of the object. In this example, the handler is pretty pointless, but +grouping functionality this way can both make for an easy-to-remember API and +can also allow you cache data for easy access - this is how the +AttributeHandler (.attributes) and TagHandler (.tags) works.

+
+
+

Persistent storage of data in handler

+

Let’s say we want to track ‘quests’ in our handler. A ‘quest’ is a regular class +that represents the quest. Let’s make it simple as an example:

+
# for example in mygame/world/quests.py
+
+
+class Quest:
+
+    key = "The quest for the red key"
+
+    def __init__(self):
+        self.current_step = "start"
+
+    def check_progress(self):
+        # uses self.current_step to check
+        # progress of this quest
+        getattr(self, f"step_{self.current_step}")()
+
+    def step_start(self):
+        # check here if quest-step is complete
+        self.current_step = "find_the_red_key"
+    def step_find_the_red_key(self):
+        # check if step is complete
+        self.current_step = "hand_in_quest"
+    def step_hand_in_quest(self):
+        # check if handed in quest to quest giver
+        self.current_step = None  # finished
+
+
+
+

We expect the dev to make subclasses of this to implement different quests. Exactly how this works +doesn’t matter, the key is that we want to track self.current_step - a property that should +survive a server reload. But so far there is no way for Quest to accomplish this, it’s just a +normal Python class with no connection to the database.

+
+

Handler with save/load capability

+

Let’s make a QuestHandler that manages a character’s quests.

+
# for example in the same mygame/world/quests.py
+
+
+class QuestHandler:
+    def __init__(self, obj):
+        self.obj = obj
+        self.do_save = False
+        self._load()
+
+    def _load(self):
+        self.storage = self.obj.attributes.get(
+            "quest_storage", default={}, category="quests")
+
+    def _save(self):
+        self.obj.attributes.add(
+            "quest_storage", self.storage, category="quests")
+        self._load()  # important
+        self.do_save = False
+
+    def add(self, questclass):
+        self.storage[questclass.key] = questclass(self.obj)
+        self._save()
+
+    def check_progress(self):
+            quest.check_progress()
+        if self.do_save:
+            # .do_save is set on handler by Quest if it wants to save progress
+            self._save()
+
+
+
+

The handler is just a normal Python class and has no database-storage on its own. But it has a link +to .obj, which is assumed to be a full typeclased entity, on which we can create +persistent Attributes to store things however we like!

+

We make two helper methods _load and +_save that handles local fetches and saves storage to an Attribute on the object. To avoid +saving more than necessary, we have a property do_save. This we will set in Quest below.

+
+

Note that once we _save the data, we need to call _load again. This is to make sure the version we store on the handler is properly de-serialized. If you get an error about data being bytes, you probably missed this step.

+
+
+
+

Make quests storable

+

The handler will save all Quest objects as a dict in an Attribute on obj. We are not done yet +though, the Quest object needs access to the obj too - not only will this is important to figure +out if the quest is complete (the Quest must be able to check the quester’s inventory to see if +they have the red key, for example), it also allows the Quest to tell the handler when its state +changed and it should be saved.

+

We change the Quest such:

+
from evennia.utils import dbserialize
+
+
+class Quest:
+
+    def __init__(self, obj):
+        self.obj = obj
+        self._current_step = "start"
+
+    def __serialize_dbobjs__(self):
+        self.obj = dbserialize.dbserialize(self.obj)
+
+    def __deserialize_dbobjs__(self):
+        if isinstance(self.obj, bytes):
+            self.obj = dbserialize.dbunserialize(self.obj)
+
+    @property
+    def questhandler(self):
+        return self.obj.quests
+
+    @property
+    def current_step(self):
+        return self._current_step
+
+    @current_step.setter
+    def current_step(self, value):
+        self._current_step = value
+        self.questhandler.do_save = True  # this triggers save in handler!
+
+    # [same as before]
+
+
+
+

The Quest.__init__ now takes obj as argument, to match what we pass to it in +QuestHandler.add. We want to monitor the changing of current_step, so we +make it into a property. When we edit that value, we set the do_save flag on +the handler, which means it will save the status to database once it has checked +progress on all its quests. The Quest.questhandler property allows to easily +get back to the handler (and the object on which it sits).

+

The __serialize__dbobjs__ and __deserialize_dbobjs__ methods are needed +because Attributes can’t store ‘hidden’ database objects (the Quest.obj +property. The methods help Evennia serialize/deserialize Quest propertly when +the handler saves it. For more information, see Storing Single +objects in the Attributes

+
+
+

Tying it all together

+

The final thing we need to do is to add the quest-handler to the character:

+
# in mygame/typeclasses/characters.py
+
+from evennia import DefaultCharacter
+from evennia.utils.utils import lazy_property
+from .world.quests import QuestHandler  # as an example
+
+
+class Character(DefaultCharacter):
+    # ...
+    @lazy_property
+    def quests(self):
+        return QuestHandler(self)
+
+
+
+

You can now make your Quest classes to describe your quests and add them to +characters with

+
character.quests.add(FindTheRedKey)
+
+
+

and can later do

+
character.quests.check_progress()
+
+
+

and be sure that quest data is not lost between reloads.

+

You can find a full-fledged quest-handler example as EvAdventure +quests contrib in the Evennia +repository.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Understanding-Color-Tags.html b/docs/latest/Howtos/Tutorial-Understanding-Color-Tags.html new file mode 100644 index 0000000000..5688b3324a --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Understanding-Color-Tags.html @@ -0,0 +1,298 @@ + + + + + + + + + Understanding Color Tags — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Understanding Color Tags

+

This tutorial aims at dispelling confusions regarding the use of color tags within Evennia.

+

Correct understanding of this topic requires having read the Evennia’s color tags. Here we’ll explain by examples the reasons behind the unexpected (or apparently incoherent) behaviors of some color tags, as mentioned en passant in the Colors page.

+

All you’ll need for this tutorial is access to a running instance of Evennia via a color-enabled +client. The examples provided are just commands that you can type in your client.

+
+

Evennia, ANSI and Xterm256

+

All modern MUD clients support colors; nevertheless, the standards to which all clients abide dates +back to old day of terminals, and when it comes to colors we are dealing with ANSI and Xterm256 +standards.

+

Evennia handles transparently, behind the scenes, all the code required to enforce these +standards—so, if a user connects with a client which doesn’t support colors, or supports only ANSI +(16 colors), Evennia will take all due steps to ensure that the output will be adjusted to look +right at the client side.

+

As for you, the developer, all you need to care about is knowing how to correctly use the color tags +within your MUD. Most likely, you’ll be adding colors to help pages, descriptions, automatically +generated text, etc.

+

You are free to mix together ANSI and Xterm256 color tags, but you should be aware of a few +pitfalls. ANSI and Xterm256 coexist without conflicts in Evennia, but in many ways they don’t «see» +each other: ANSI-specific color tags will have no effect on Xterm-defined colors, as we shall see +here.

+
+
+

ANSI

+

ANSI has a set of 16 colors, to be more precise: ANSI has 8 basic colors which come in dark and +bright flavours—with dark being normal. The colors are: red, green, yellow, blue, magenta, +cyan, white and black. White in its dark version is usually referred to as gray, and black in its +bright version as darkgray. Here, for sake of simplicity they’ll be referred to as dark and bright: +bright/dark black, bright/dark white.

+

The default colors of MUD clients is normal (dark) white on normal black (ie: gray on black).

+

It’s important to grasp that in the ANSI standard bright colors apply only to text (foreground), not +to background. Evennia allows to bypass this limitation via Xterm256, but doing so will impact the +behavior of ANSI tags, as we shall see.

+

Also, it’s important to remember that the 16 ANSI colors are a convention, and the final user can +always customize their appearance—he might decide to have green show as red, and dark green as blue, +etc.

+
+
+

Xterm256

+

The 16 colors of ANSI should be more than enough to handle simple coloring of text. But when an +author wants to be sure that a given color will show as he intended it, she might choose to rely on +Xterm256 colors.

+

Xterm256 doesn’t rely on a palette of named colors, it instead represent colors by their values. So, +a red color could be |[500 (bright and pure red), or |[300 (darker red), and so on.

+
+
+

ANSI Color Tags in Evennia

+
+

NOTE: for ease of reading, the examples contain extra white spaces after the +color tags (eg: |g green |b blue ). This is done only so that it’s easier +to see the tags separated from their context; it wouldn’t be good practice +in real-life coding.

+
+

Let’s proceed by examples. In your MUD client type:

+
say Normal |* Negative
+
+
+

Evennia should output the word “Normal” normally (ie: gray on black) and “Negative” in reversed +colors (ie: black on gray).

+

This is pretty straight forward, the |* ANSI invert tag switches between foreground and +background—from now on, FG and BG shorthands will be used to refer to foreground and +background.

+

But take mental note of this: |* has switched dark white and dark black.

+

Now try this:

+
say |w Bright white FG |* Negative
+
+
+

You’ll notice that the word “Negative” is not black on white, it’s darkgray on gray. Why is this? +Shouldn’t it be black text on a white BG? Two things are happening here.

+

As mentioned, ANSI has 8 base colors, the dark ones. The bright ones are achieved by means of +highlighting the base/dark/normal colors, and they only apply to FG.

+

What happened here is that when we set the bright white FG with |w, Evennia translated this into +the ANSI sequence of Highlight On + White FG. In terms of Evennia’s color tags, it’s as if we typed:

+
say |h|!W Bright white FG |* Negative
+
+
+

Furthermore, the Highlight-On property (which only works for BG!) is preserved after the FG/BG +switch, this being the reason why we see black as darkgray: highlighting makes it bright black +(ie: darkgray).

+

As for the BG being also grey, that is normal—ie: you are seeing normal white (ie: dark white = +gray). Remember that since there are no bright BG colors, the ANSI |* tag will transpose any FG +color in its normal/dark version. So here the FG’s bright white became dark white in the BG! In +reality, it was always normal/dark white, except that in the FG is seen as bright because of the +highlight tag behind the scenes.

+

Let’s try the same thing with some color:

+
say |m |[G Bright Magenta on Dark Green |* Negative
+
+
+

Again, the BG stays dark because of ANSI rules, and the FG stays bright because of the implicit |h +in |m.

+

Now, let’s see what happens if we set a bright BG and then invert—yes, Evennia kindly allows us to +do it, even if it’s not within ANSI expectations.

+
say |[b Dark White on Bright Blue |* Negative
+
+
+

Before color inversion, the BG does show in bright blue, and after inversion (as expected) it’s +dark white (gray). The bright blue of the BG survived the inversion and gave us a bright blue FG. +This behavior is tricky though, and not as simple as it might look.

+

If the inversion were to be pure ANSI, the bright blue would have been accounted just as normal +blue, and should have converted to normal blue in the FG (after all, there was no highlighting on). +The fact is that in reality this color is not bright blue at all, it just an Xterm version of it!

+

To demonstrate this, type:

+
say |[b Dark White on Bright Blue |* Negative |H un-bright
+
+
+

The |H Highlight-Off tag should have turned dark blue the last word; but it didn’t because it +couldn’t: in order to enforce the non-ANSI bright BG Evennia turned to Xterm, and Xterm entities are +not affected by ANSI tags!

+

So, we are getting at the heart of all confusions and possible odd-behaviors pertaining color tags +in Evennia: apart from Evennia’s translations from- and to- ANSI/Xterm, the two systems are +independent and transparent to each other.

+

The bright blue of the previous example was just an Xterm representation of the ANSI standard blue. +Try to change the default settings of your client, so that blue shows as some other color, you’ll +then realize the difference when Evennia is sending a true ANSI color (which will show up according +to your settings) and when instead it’s sending an Xterm representation of that color (which will +show up always as defined by Evennia).

+

You’ll have to keep in mind that the presence of an Xterm BG or FG color might affect the way your +tags work on the text. For example:

+
say |[b Bright Blue BG |* Negative |!Y Dark Yellow |h not bright
+
+
+

Here the |h tag no longer affects the FG color. Even though it was changed via the |! tag, the +ANSI system is out-of-tune because of the intrusion of an Xterm color (bright blue BG, then moved to +FG with |*).

+

All unexpected ANSI behaviours are the result of mixing Xterm colors (either on purpose or either +via bright BG colors). The |n tag will restore things in place and ANSI tags will respond properly +again. So, at the end is just an issue of being mindful when using Xterm colors or bright BGs, and +avoid wild mixing them with ANSI tags without normalizing (|n) things again.

+

Try this:

+
say |[b Bright Blue BG |* Negative |!R Red FG
+
+
+

And then:

+
say |[B Dark Blue BG |* Negative |!R Red BG??
+
+
+

In this second example the |! changes the BG color instead of the FG! In fact, the odd behavior is +the one from the former example, non the latter. When you invert FG and BG with |* you actually +inverting their references. This is why the last example (which has a normal/dark BG!) allows |! +to change the BG color. In the first example, it’s again the presence of an Xterm color (bright blue +BG) which changes the default behavior.

+

Try this:

+

say Normal |* Negative |!R Red BG

+

This is the normal behavior, and as you can see it allows |! to change BG color after the +inversion of FG and BG.

+

As long as you have an understanding of how ANSI works, it should be easy to handle color tags +avoiding the pitfalls of Xterm-ANSI promisquity.

+

One last example:

+

say Normal |* Negative |* still Negative

+

Shows that |* only works once in a row and will not (and should not!) revert back if used again. +Nor it will have any effect until the |n tag is called to “reset” ANSI back to normal. This is how +it is meant to work.

+

ANSI operates according to a simple states-based mechanism, and it’s important to understand the positive effect of resetting with the |n tag, and not try to +push it over the limit, so to speak.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Using-Arxcode.html b/docs/latest/Howtos/Tutorial-Using-Arxcode.html new file mode 100644 index 0000000000..aa75362313 --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Using-Arxcode.html @@ -0,0 +1,361 @@ + + + + + + + + + Using the Arxcode game dir — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Using the Arxcode game dir

+
+

Warning

+

Arxcode is separately maintained.

+

While Arxcode uses Evennia, it is not part of Evennia itself; we include this documentation only as a service to users. Also, while Arxcode is still actively maintained (2022), these instructions are based on the Arx-code released as of Aug 12, 2018. They will probably not work 100% out of the box anymore.

+

Arxcode bugs should be directed to the Arxcode github issue tracker.

+
+

Arx - After the Reckoning is a big and very popular Evennia-based game. Arx is heavily roleplaying-centric, relying on game masters to drive the story. Technically it’s maybe best described as “a MUSH, but with more coded systems”. In August of 2018, the game’s developer, Tehom, generously released the source code of Arx on github. This is a treasure-trove for developers wanting to pick ideas or even get a starting game to build on.

+

It’s not too hard to run Arx from the sources (of course you’ll start with an empty database) but +since part of Arx has grown organically, it doesn’t follow standard Evennia paradigms everywhere. +This page covers one take on installing and setting things up while making your new Arx-based game better match with the vanilla Evennia install.

+
+

Installing Evennia

+

Firstly, set aside a folder/directory on your drive for everything to follow.

+

You need to start by installing Evennia by following most of the Git-installation instructions for your OS. The difference is that instead of cloning from upstream Evennia, you should do

+
git clone https://github.com/TehomCD/evennia.git
+
+
+

This is because Arx uses TehomCD’s older Evennia 0.8 fork, notably still using Python2. This detail is important if referring to newer Evennia documentation.

+

If you are new to Evennia it’s highly recommended that you run through the normal install instructions in full - including initializing and starting a new empty game and connecting to it. +That way you can be sure Evennia works correctly as a baseline.

+

After installing you should have a virtualenv running and you should have the following file structure in your set-aside folder:

+
muddev/
+   vienv/
+   evennia/
+   mygame/
+
+
+

Here mygame is the empty game you created during the Evennia install, with evennia --init. Go to +that and run evennia stop to make sure your empty game is not running. We’ll instead let Evenna +run Arx, so in principle you could erase mygame - but it could also be good to have a clean game +to compare to.

+
+
+

Installing Arxcode

+

cd to the root of your directory and clone the released source code from github:

+
git clone https://github.com/Arx-Game/arxcode.git myarx
+
+
+

A new folder myarx should appear next to the ones you already had. You could rename this to +something else if you want.

+

cd into myarx. If you wonder about the structure of the game dir, you can read more about it here.

+
+

Clean up settings

+

Arx has split evennia’s normal settings into base_settings.py and production_settings.py. It +also has its own solution for managing ‘secret’ parts of the settings file. We’ll keep most of Arx +way but we’ll remove the secret-handling and replace it with the normal Evennia method.

+

cd into myarx/server/conf/ and open the file settings.py in a text editor. The top part (within +"""...""") is just help text. Wipe everything underneath that and make it look like this instead +(don’t forget to save):

+
from base_settings import *
+
+TELNET_PORTS = [4000]
+SERVERNAME = "MyArx"
+GAME_SLOGAN = "The cool game"
+
+try:
+    from server.conf.secret_settings import *
+except ImportError:
+    print("secret_settings.py file not found or failed to import.")
+
+
+
+

Note: Indents and capitalization matter in Python. Make indents 4 spaces (not tabs) for your own sanity. If you want a starter on Python in Evennia, [you can look here](Beginner-Tutorial-Python-basic- introduction).

+
+

This will import Arx’ base settings and override them with the Evennia-default telnet port and give the game a name. The slogan changes the sub-text shown under the name of your game in the website header. You can tweak these to your own liking later.

+

Next, create a new, empty file secret_settings.py in the same location as the settings.py file. +This can just contain the following:

+
SECRET_KEY = "sefsefiwwj3 jnwidufhjw4545_oifej whewiu hwejfpoiwjrpw09&4er43233fwefwfw"
+
+
+
+

Replace the long random string with random ASCII characters of your own. The secret key should not be shared.

+

Next, open myarx/server/conf/base_settings.py in your text editor. We want to remove/comment out all mentions of the decouple package, which Evennia doesn’t use (we use private_settings.py to hide away settings that should not be shared).

+

Comment out from decouple import config by adding a # to the start of the line: # from decouple import config. Then search for config( in the file and comment out all lines where this is used. Many of these are specific to the server environment where the original Arx runs, so is not that relevant to us.

+
+
+

Install Arx dependencies

+

Arx has some further dependencies beyond vanilla Evennia. Start by cd:ing to the root of your +myarx folder.

+
+

If you run Linux or Mac: Edit myarx/requirements.txt and comment out the line +pypiwin32==219 - it’s only needed on Windows and will give an error on other platforms.

+
+

Make sure your virtualenv is active, then run

+
pip install -r requirements.txt
+
+
+

The needed Python packages will be installed for you.

+
+
+

Adding logs/ folder

+

The Arx repo does not contain the myarx/server/logs/ folder Evennia expects for storing server +logs. This is simple to add:

+
# linux/mac
+mkdir server/logs
+# windows
+mkdir server\logs
+
+
+
+
+

Setting up the database and starting

+

From the myarx folder, run

+
evennia migrate
+
+
+

This creates the database and will step through all database migrations needed.

+
evennia start
+
+
+

If all goes well Evennia will now start up, running Arx! You can connect to it on localhost (or 127.0.0.1 if your platform doesn’t alias localhost), port 4000 using a Telnet client. Alternatively, you can use your web browser to browse to http://localhost:4001 to see the game’s website and get to the web client.

+

When you log in you’ll get the standard Evennia greeting (since the database is empty), but you can +try help to see that it’s indeed Arx that is running.

+
+
+

Additional Setup Steps

+

The first time you start Evennia after creating the database with the evennia migrate step above, +it should create a few starting objects for you - your superuser account, which it will prompt you +to enter, a starting room (Limbo), and a character object for you. If for some reason this does not +occur, you may have to follow the steps below. For the first time Superuser login you may have to +run steps 7-8 and 10 to create and connect to your in-came Character.

+
    +
  1. Login to the game website with your Superuser account.

  2. +
  3. Press the Admin button to get into the (Django-) Admin Interface.

  4. +
  5. Navigate to the Accounts section.

  6. +
  7. Add a new Account named for the new staffer. Use a place holder password and dummy e-mail +address.

  8. +
  9. Flag account as Staff and apply the Admin permission group (This assumes you have already set up an Admin Group in Django).

  10. +
  11. Add Tags named player and developer.

  12. +
  13. Log into the game using the web client (or a third-party telnet client) using your superuser account. Move to where you want the new staffer character to appear.

  14. +
  15. In the game client, run @create/drop <staffername>:typeclasses.characters.Character, where <staffername> is usually the same name you used for the Staffer account you created in the Admin earlier (if you are creating a Character for your superuser, use your superuser account name). This creates a new in-game Character and places it in your current location.

  16. +
  17. Have the new Admin player log into the game.

  18. +
  19. Have the new Admin puppet the character with @ic StafferName.

  20. +
  21. Have the new Admin change their password - @password <old password> = <new password>.

  22. +
+

Now that you have a Character and an Account object, there’s a few additional things you may need to do in order for some commands to function properly. You can either execute these as in-game commands while ic (controlling your character object).

+
py from web.character.models import RosterEntry;RosterEntry.objects.create(player=self.player, character=self)
+
+py from world.dominion.models import PlayerOrNpc, AssetOwner;dompc = PlayerOrNpc.objects.create(player=self.player);AssetOwner.objects.create(player=dompc)
+
+
+

Those steps will give you ‘RosterEntry’, ‘PlayerOrNpc’, and ‘AssetOwner’ objects. RosterEntry +explicitly connects a character and account object together, even while offline, and contains +additional information about a character’s current presence in game (such as which ‘roster’ they’re +in, if you choose to use an active roster of characters). PlayerOrNpc are more character extensions, as well as support for npcs with no in-game presence and just represented by a name which can be offscreen members of a character’s family. It also allows for membership in Organizations. AssetOwner holds information about a character or organization’s money and resources.

+
+
+
+

Alternate Windows install guide

+

Contributed by Pax

+

If for some reason you cannot use the Windows Subsystem for Linux (which would use instructions identical to the ones above), it’s possible to get Evennia/Arx running under Anaconda for Windows. The process is a little bit trickier.

+

Make sure you have:

+ +

Set up a convenient repository place for things.

+
cd ~
+mkdir Source
+cd Source
+mkdir Arx
+cd Arx
+
+
+

Replace the SSH git clone links below with your own github forks. +If you don’t plan to change Evennia at all, you can use the +evennia/evennia.git repo instead of a forked one.

+
git clone git@github.com:<youruser>/evennia.git
+git clone git@github.com:<youruser>/arxcode.git
+
+
+

Evennia is a package itself, so we want to install it and all of its +prerequisites, after switching to the appropriately-tagged branch for +Arxcode.

+
cd evennia
+git checkout tags/v0.7 -b arx-master
+pip install -e .
+
+
+

Arx has some dependencies of its own, so now we’ll go install them +As it is not a package, we’ll use the normal requirements file.

+
cd ../arxcode
+pip install -r requirements.txt
+
+
+

The git repo doesn’t include the empty log directory and Evennia is unhappy if you +don’t have it, so while still in the arxcode directory…

+
mkdir server/logs
+
+
+

Now hit https://github.com/evennia/evennia/wiki/Arxcode-installing-help and +change the setup stuff as in the ‘Clean up settings’ section.

+

Then we will create our default database…

+
../evennia/bin/windows/evennia.bat migrate
+
+
+

…and do the first run. You need winpty because Windows does not have a TTY/PTY +by default, and so the Python console input commands (used for prompts on first +run) will fail and you will end up in an unhappy place. Future runs, you should +not need winpty.

+
winpty ../evennia/bin/windows/evennia.bat start
+
+
+

Once this is done, you should have your Evennia server running Arxcode up +on localhost at port 4000, and the webserver at http://localhost:4001/.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-Weather-Effects.html b/docs/latest/Howtos/Tutorial-Weather-Effects.html new file mode 100644 index 0000000000..eae08b519d --- /dev/null +++ b/docs/latest/Howtos/Tutorial-Weather-Effects.html @@ -0,0 +1,187 @@ + + + + + + + + + Adding Weather messages to a Room — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Adding Weather messages to a Room

+

This tutorial will have us create a simple weather system for our MUD. The way we want to use this is to have all outdoor rooms echo weather-related messages to the room at regular and semi-random intervals. Things like “Clouds gather above”, “It starts to rain” and so on.

+

One could imagine every outdoor room in the game having a script running on themselves that fires regularly. For this particular example it is however more efficient to do it another way, namely by using a “ticker-subscription” model.

+

The principle is simple: Instead of having each Object individually track the time, they instead subscribe to be called by a global ticker who handles time keeping. Not only does this centralize and organize much of the code in one place, it also has less computing overhead.

+

Evennia’s TickerHandler specifically offers such a subscription model. We will use it for our weather system.

+

We will create a new WeatherRoom typeclass that is aware of the day-night cycle.

+
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
    import random
+    from evennia import DefaultRoom, TICKER_HANDLER
+    
+    ECHOES = ["The sky is clear.", 
+              "Clouds gather overhead.",
+              "It's starting to drizzle.",
+              "A breeze of wind is felt.",
+              "The wind is picking up"] # etc  
+
+    class WeatherRoom(DefaultRoom):
+        "This room is ticked at regular intervals"        
+       
+        def at_object_creation(self):
+            "called only when the object is first created"
+            TICKER_HANDLER.add(60 * 60, self.at_weather_update)
+
+        def at_weather_update(self, *args, **kwargs):
+            "ticked at regular intervals"
+            echo = random.choice(ECHOES)
+            self.msg_contents(echo)
+
+
+

In the at_object_creation method, we simply added ourselves to the TickerHandler and tell it to +call at_weather_update every hour (60*60 seconds). During testing you might want to play with a +shorter time duration.

+

For this to work we also create a custom hook at_weather_update(*args, **kwargs), which is the +call sign required by TickerHandler hooks.

+

Henceforth the room will inform everyone inside it when the weather changes. This particular example +is of course very simplistic - the weather echoes are just randomly chosen and don’t care what +weather came before it. Expanding it to be more realistic is a useful exercise.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Tutorial-for-basic-MUSH-like-game.html b/docs/latest/Howtos/Tutorial-for-basic-MUSH-like-game.html new file mode 100644 index 0000000000..cfe3aa168d --- /dev/null +++ b/docs/latest/Howtos/Tutorial-for-basic-MUSH-like-game.html @@ -0,0 +1,770 @@ + + + + + + + + + Tutorial for basic MUSH like game — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Tutorial for basic MUSH like game

+

This tutorial lets you code a small but complete and functioning MUSH-like game in Evennia. A +MUSH is, for our purposes, a class of roleplay-centric games +focused on free form storytelling. Even if you are not interested in MUSH:es, this is still a good +first game-type to try since it’s not so code heavy. You will be able to use the same principles for +building other types of games.

+

The tutorial starts from scratch. If you did the First Steps Coding tutorial +already you should have some ideas about how to do some of the steps already.

+

The following are the (very simplistic and cut-down) features we will implement (this was taken from +a feature request from a MUSH user new to Evennia). A Character in this system should:

+
    +
  • Have a “Power” score from 1 to 10 that measures how strong they are (stand-in for the stat +system).

  • +
  • Have a command (e.g. +setpower 4) that sets their power (stand-in for character generation +code).

  • +
  • Have a command (e.g. +attack) that lets them roll their power and produce a “Combat Score” +between 1 and 10*Power, displaying the result and editing their object to record this number +(stand-in for +actions in the command code).

  • +
  • Have a command that displays everyone in the room and what their most recent “Combat Score” roll +was (stand-in for the combat code).

  • +
  • Have a command (e.g. +createNPC Jenkins) that creates an NPC with full abilities.

  • +
  • Have a command to control NPCs, such as +npc/cmd (name)=(command) (stand-in for the NPC +controlling code).

  • +
+

In this tutorial we will assume you are starting from an empty database without any previous +modifications.

+
+

Server Settings

+

To emulate a MUSH, the default MULTISESSION_MODE=0 is enough (one unique session per +account/character). This is the default so you don’t need to change anything. You will still be able +to puppet/unpuppet objects you have permission to, but there is no character selection out of the +box in this mode.

+

We will assume our game folder is called mygame henceforth. You should be fine with the default +SQLite3 database.

+
+
+

Creating the Character

+

First thing is to choose how our Character class works. We don’t need to define a special NPC object +– an NPC is after all just a Character without an Account currently controlling them.

+

Make your changes in the mygame/typeclasses/characters.py file:

+
# mygame/typeclasses/characters.py
+
+from evennia import DefaultCharacter
+
+class Character(DefaultCharacter):
+    """
+     [...]
+    """
+    def at_object_creation(self):
+        "This is called when object is first created, only."
+        self.db.power = 1
+        self.db.combat_score = 1
+
+
+

We defined two new Attributes power and combat_score and set them to default +values. Make sure to @reload the server if you had it already running (you need to reload every +time you update your python code, don’t worry, no accounts will be disconnected by the reload).

+

Note that only new characters will see your new Attributes (since the at_object_creation hook is +called when the object is first created, existing Characters won’t have it). To update yourself, +run

+
 @typeclass/force self
+
+
+

This resets your own typeclass (the /force switch is a safety measure to not do this +accidentally), this means that at_object_creation is re-run.

+
 examine self
+
+
+

Under the “Persistent attributes” heading you should now find the new Attributes power and score +set on yourself by at_object_creation. If you don’t, first make sure you @reloaded into the new +code, next look at your server log (in the terminal/console) to see if there were any syntax errors +in your code that may have stopped your new code from loading correctly.

+
+
+

Character Generation

+

We assume in this example that Accounts first connect into a “character generation area”. Evennia +also supports full OOC menu-driven character generation, but for this example, a simple start room +is enough. When in this room (or rooms) we allow character generation commands. In fact, character +generation commands will only be available in such rooms.

+

Note that this again is made so as to be easy to expand to a full-fledged game. With our simple +example, we could simply set an is_in_chargen flag on the account and have the +setpower command +check it. Using this method however will make it easy to add more functionality later.

+

What we need are the following:

+
    +
  • One character generation Command to set the “Power” on the Character.

  • +
  • A chargen CmdSet to hold this command. Lets call it ChargenCmdset.

  • +
  • A custom ChargenRoom type that makes this set of commands available to players in such rooms.

  • +
  • One such room to test things in.

  • +
+
+

The +setpower command

+

For this tutorial we will add all our new commands to mygame/commands/command.py but you could +split your commands into multiple module if you prefered.

+

For this tutorial character generation will only consist of one Command to set the +Character s “power” stat. It will be called on the following MUSH-like form:

+
 +setpower 4
+
+
+

Open command.py file. It contains documented empty templates for the base command and the +“MuxCommand” type used by default in Evennia. We will use the plain Command type here, the +MuxCommand class offers some extra features like stripping whitespace that may be useful - if so, +just import from that instead.

+

Add the following to the end of the command.py file:

+
# end of command.py
+from evennia import Command # just for clarity; already imported above
+
+class CmdSetPower(Command):
+    """
+    set the power of a character
+
+    Usage:
+      +setpower <1-10>
+
+    This sets the power of the current character. This can only be
+    used during character generation.
+    """
+
+    key = "+setpower"
+    help_category = "mush"
+
+    def func(self):
+        "This performs the actual command"
+        errmsg = "You must supply a number between 1 and 10."
+        if not self.args:
+            self.caller.msg(errmsg)
+            return
+        try:
+            power = int(self.args)
+        except ValueError:
+            self.caller.msg(errmsg)
+            return
+        if not (1 <= power <= 10):
+            self.caller.msg(errmsg)
+            return
+        # at this point the argument is tested as valid. Let's set it.
+        self.caller.db.power = power
+        self.caller.msg(f"Your Power was set to {power}.")
+
+
+

This is a pretty straightforward command. We do some error checking, then set the power on ourself. +We use a help_category of “mush” for all our commands, just so they are easy to find and separate +in the help list.

+

Save the file. We will now add it to a new CmdSet so it can be accessed (in a full +chargen system you would of course have more than one command here).

+

Open mygame/commands/default_cmdsets.py and import your command.py module at the top. We also +import the default CmdSet class for the next step:

+
from evennia import CmdSet
+from commands import command
+
+
+

Next scroll down and define a new command set (based on the base CmdSet class we just imported at +the end of this file, to hold only our chargen-specific command(s):

+
# end of default_cmdsets.py
+
+class ChargenCmdset(CmdSet):
+    """
+    This cmdset it used in character generation areas.
+    """
+    key = "Chargen"
+    def at_cmdset_creation(self):
+        "This is called at initialization"
+        self.add(command.CmdSetPower())
+
+
+

In the future you can add any number of commands to this cmdset, to expand your character generation +system as you desire. Now we need to actually put that cmdset on something so it’s made available to +users. We could put it directly on the Character, but that would make it available all the time. +It’s cleaner to put it on a room, so it’s only available when players are in that room.

+
+
+

Chargen areas

+

We will create a simple Room typeclass to act as a template for all our Chargen areas. Edit +mygame/typeclasses/rooms.py next:

+
from commands.default_cmdsets import ChargenCmdset
+
+# ...
+# down at the end of rooms.py
+
+class ChargenRoom(Room):
+    """
+    This room class is used by character-generation rooms. It makes
+    the ChargenCmdset available.
+    """
+    def at_object_creation(self):
+        "this is called only at first creation"
+        self.cmdset.add(ChargenCmdset, persistent=True)
+
+
+

Note how new rooms created with this typeclass will always start with ChargenCmdset on themselves. +Don’t forget the persistent=True keyword or you will lose the cmdset after a server reload. For +more information about Command Sets and Commands, see the respective +links.

+
+
+

Testing chargen

+

First, make sure you have @reloaded the server (or use evennia reload from the terminal) to have +your new python code added to the game. Check your terminal and fix any errors you see - the error +traceback lists exactly where the error is found - look line numbers in files you have changed.

+

We can’t test things unless we have some chargen areas to test. Log into the game (you should at +this point be using the new, custom Character class). Let’s dig a chargen area to test.

+
 @dig chargen:rooms.ChargenRoom = chargen,finish
+
+
+

If you read the help for @dig you will find that this will create a new room named chargen. The +part after the : is the python-path to the Typeclass you want to use. Since Evennia will +automatically try the typeclasses folder of our game directory, we just specify +rooms.ChargenRoom, meaning it will look inside the module rooms.py for a class named +ChargenRoom (which is what we created above). The names given after = are the names of exits to +and from the room from your current location. You could also append aliases to each one name, such +as chargen;character generation.

+

So in summary, this will create a new room of type ChargenRoom and open an exit chargen to it and +an exit back here named finish. If you see errors at this stage, you must fix them in your code. +@reload +between fixes. Don’t continue until the creation seems to have worked okay.

+
 chargen
+
+
+

This should bring you to the chargen room. Being in there you should now have the +setpower +command available, so test it out. When you leave (via the finish exit), the command will go away +and trying +setpower should now give you a command-not-found error. Use ex me (as a privileged +user) to check so the Power Attribute has been set correctly.

+

If things are not working, make sure your typeclasses and commands are free of bugs and that you +have entered the paths to the various command sets and commands correctly. Check the logs or command +line for tracebacks and errors.

+
+
+
+

Combat System

+

We will add our combat command to the default command set, meaning it will be available to everyone +at all times. The combat system consists of a +attack command to get how successful our attack is. +We also change the default look command to display the current combat score.

+
+

Attacking with the +attack command

+

Attacking in this simple system means rolling a random “combat score” influenced by the power stat +set during Character generation:

+
> +attack
+You +attack with a combat score of 12!
+
+
+

Go back to mygame/commands/command.py and add the command to the end like this:

+
import random
+
+# ...
+
+class CmdAttack(Command):
+    """
+    issues an attack
+
+    Usage:
+        +attack
+
+    This will calculate a new combat score based on your Power.
+    Your combat score is visible to everyone in the same location.
+    """
+    key = "+attack"
+    help_category = "mush"
+
+    def func(self):
+        "Calculate the random score between 1-10*Power"
+        caller = self.caller
+        power = caller.db.power
+        if not power:
+            # this can happen if caller is not of
+            # our custom Character typeclass
+            power = 1
+        combat_score = random.randint(1, 10 * power)
+        caller.db.combat_score = combat_score
+
+        # announce
+        message_template = "{attacker} +attack{s} with a combat score of {c_score}!"
+        caller.msg(message_template.format(
+            attacker="You",
+            s="",
+            c_score=combat_score,
+        ))
+        caller.location.msg_contents(message_template.format(
+            attacker=caller.key,
+            s="s",
+            c_score=combat_score,
+        ), exclude=caller)
+
+
+

What we do here is simply to generate a “combat score” using Python’s inbuilt random.randint() +function. We then store that and echo the result to everyone involved.

+

To make the +attack command available to you in game, go back to +mygame/commands/default_cmdsets.py and scroll down to the CharacterCmdSet class. At the correct +place add this line:

+
self.add(command.CmdAttack())
+
+
+

@reload Evennia and the +attack command should be available to you. Run it and use e.g. @ex to +make sure the combat_score attribute is saved correctly.

+
+
+

Have “look” show combat scores

+

Players should be able to view all current combat scores in the room. We could do this by simply +adding a second command named something like +combatscores, but we will instead let the default +look command do the heavy lifting for us and display our scores as part of its normal output, like +this:

+
>  look Tom
+Tom (combat score: 3)
+This is a great warrior.
+
+
+

We don’t actually have to modify the look command itself however. To understand why, take a look +at how the default look is actually defined. It sits in evennia/commands/default/general.py.

+

You will find that the actual return text is done by the look command calling a hook method +named return_appearance on the object looked at. All the look does is to echo whatever this hook +returns. So what we need to do is to edit our custom Character typeclass and overload its +return_appearance to return what we want (this is where the advantage of having a custom typeclass +comes into play for real).

+

Go back to your custom Character typeclass in mygame/typeclasses/characters.py. The default +implementation of return appearance is found in evennia.DefaultCharacter.

+

If you want to make bigger changes you could copy & paste the whole default thing into our overloading method. In our case the change is small though:

+
class Character(DefaultCharacter):
+    """
+     [...]
+    """
+    def at_object_creation(self):
+        "This is called when object is first created, only."
+        self.db.power = 1
+        self.db.combat_score = 1
+
+    def return_appearance(self, looker):
+        """
+        The return from this method is what
+        looker sees when looking at this object.
+        """
+        text = super().return_appearance(looker)
+        cscore = f" (combat score: {self.db.combat_score})"
+        if "\n" in text:
+            # text is multi-line, add score after first line
+            first_line, rest = text.split("\n", 1)
+            text = first_line + cscore + "\n" + rest
+        else:
+            # text is only one line; add score to end
+            text += cscore
+        return text
+
+
+

What we do is to simply let the default return_appearance do its thing (super will call the +parent’s version of the same method). We then split out the first line of this text, append our +combat_score and put it back together again.

+

@reload the server and you should be able to look at other Characters and see their current combat +scores.

+
+

Note: A potentially more useful way to do this would be to overload the entire return_appearance +of the Rooms of your mush and change how they list their contents; in that way one could see all +combat scores of all present Characters at the same time as looking at the room. We leave this as an +exercise.

+
+
+
+
+

NPC system

+

Here we will re-use the Character class by introducing a command that can create NPC objects. We +should also be able to set its Power and order it around.

+

There are a few ways to define the NPC class. We could in theory create a custom typeclass for it +and put a custom NPC-specific cmdset on all NPCs. This cmdset could hold all manipulation commands. +Since we expect NPC manipulation to be a common occurrence among the user base however, we will +instead put all relevant NPC commands in the default command set and limit eventual access with +Permissions and Locks.

+
+

Creating an NPC with +createNPC

+

We need a command for creating the NPC, this is a very straightforward command:

+
> +createnpc Anna
+You created the NPC 'Anna'.
+
+
+

At the end of command.py, create our new command:

+
from evennia import create_object
+
+class CmdCreateNPC(Command):
+    """
+    create a new npc
+
+    Usage:
+        +createNPC <name>
+
+    Creates a new, named NPC. The NPC will start with a Power of 1.
+    """
+    key = "+createnpc"
+    aliases = ["+createNPC"]
+    locks = "call:not perm(nonpcs)"
+    help_category = "mush"
+
+    def func(self):
+        "creates the object and names it"
+        caller = self.caller
+        if not self.args:
+            caller.msg("Usage: +createNPC <name>")
+            return
+        if not caller.location:
+            # may not create npc when OOC
+            caller.msg("You must have a location to create an npc.")
+            return
+        # make name always start with capital letter
+        name = self.args.strip().capitalize()
+        # create npc in caller's location
+        npc = create_object("characters.Character",
+                      key=name,
+                      location=caller.location,
+                      locks=f"edit:id({caller.id}) and perm(Builders);call:false()")
+        # announce
+        message_template = "{creator} created the NPC '{npc}'."
+        caller.msg(message_template.format(
+            creator="You",
+            npc=name,
+        ))
+        caller.location.msg_contents(message_template.format(
+            creator=caller.key,
+            npc=name,
+        ), exclude=caller)
+
+
+

Here we define a +createnpc (+createNPC works too) that is callable by everyone not having the +nonpcspermission” (in Evennia, a “permission” can just as well be used to +block access, it depends on the lock we define). We create the NPC object in the caller’s current +location, using our custom Character typeclass to do so.

+

We set an extra lock condition on the NPC, which we will use to check who may edit the NPC later – +we allow the creator to do so, and anyone with the Builders permission (or higher). See +Locks for more information about the lock system.

+

Note that we just give the object default permissions (by not specifying the permissions keyword +to the create_object() call). In some games one might want to give the NPC the same permissions +as the Character creating them, this might be a security risk though.

+

Add this command to your default cmdset the same way you did the +attack command earlier. +@reload and it will be available to test.

+
+
+

Editing the NPC with +editNPC

+

Since we re-used our custom character typeclass, our new NPC already has a Power value - it +defaults to 1. How do we change this?

+

There are a few ways we can do this. The easiest is to remember that the power attribute is just a +simple Attribute stored on the NPC object. So as a Builder or Admin we could set this +right away with the default @set command:

+
 @set mynpc/power = 6
+
+
+

The @set command is too generally powerful though, and thus only available to staff. We will add a +custom command that only changes the things we want players to be allowed to change. We could in +principle re-work our old +setpower command, but let’s try something more useful. Let’s make a ++editNPC command.

+
> +editNPC Anna/power = 10
+Set Anna's property 'power' to 10.
+
+
+

This is a slightly more complex command. It goes at the end of your command.py file as before.

+
class CmdEditNPC(Command):
+    """
+    edit an existing NPC
+
+    Usage:
+      +editnpc <name>[/<attribute> [= value]]
+
+    Examples:
+      +editnpc mynpc/power = 5
+      +editnpc mynpc/power    - displays power value
+      +editnpc mynpc          - shows all editable
+                                attributes and values
+
+    This command edits an existing NPC. You must have
+    permission to edit the NPC to use this.
+    """
+    key = "+editnpc"
+    aliases = ["+editNPC"]
+    locks = "cmd:not perm(nonpcs)"
+    help_category = "mush"
+
+    def parse(self):
+        "We need to do some parsing here"
+        args = self.args
+        propname, propval = None, None
+        if "=" in args:
+            args, propval = [part.strip() for part in args.rsplit("=", 1)]
+        if "/" in args:
+            args, propname = [part.strip() for part in args.rsplit("/", 1)]
+        # store, so we can access it below in func()
+        self.name = args
+        self.propname = propname
+        # a propval without a propname is meaningless
+        self.propval = propval if propname else None
+
+    def func(self):
+        "do the editing"
+
+        allowed_propnames = ("power", "attribute1", "attribute2")
+
+        caller = self.caller
+        if not self.args or not self.name:
+            caller.msg("Usage: +editnpc name[/propname][=propval]")
+            return
+        npc = caller.search(self.name)
+        if not npc:
+            return
+        if not npc.access(caller, "edit"):
+            caller.msg("You cannot change this NPC.")
+            return
+        if not self.propname:
+            # this means we just list the values
+            output = f"Properties of {npc.key}:"
+            for propname in allowed_propnames:
+                propvalue = npc.attributes.get(propname, default="N/A")
+                output += f"\n {propname} = {propvalue}"
+            caller.msg(output)
+        elif self.propname not in allowed_propnames:
+            caller.msg("You may only change %s." %
+                              ", ".join(allowed_propnames))
+        elif self.propval:
+            # assigning a new propvalue
+            # in this example, the properties are all integers...
+            intpropval = int(self.propval)
+            npc.attributes.add(self.propname, intpropval)
+            caller.msg("Set %s's property '%s' to %s" %
+                         (npc.key, self.propname, self.propval))
+        else:
+            # propname set, but not propval - show current value
+            caller.msg("%s has property %s = %s" %
+                         (npc.key, self.propname,
+                          npc.attributes.get(self.propname, default="N/A")))
+
+
+

This command example shows off the use of more advanced parsing but otherwise it’s mostly error +checking. It searches for the given npc in the same room, and checks so the caller actually has +permission to “edit” it before continuing. An account without the proper permission won’t even be +able to view the properties on the given NPC. It’s up to each game if this is the way it should be.

+

Add this to the default command set like before and you should be able to try it out.

+

Note: If you wanted a player to use this command to change an on-object property like the NPC’s +name (the key property), you’d need to modify the command since “key” is not an Attribute (it is +not retrievable via npc.attributes.get but directly via npc.key). We leave this as an optional +exercise.

+
+
+

Making the NPC do stuff - the +npc command

+

Finally, we will make a command to order our NPC around. For now, we will limit this command to only +be usable by those having the “edit” permission on the NPC. This can be changed if it’s possible for +anyone to use the NPC.

+

The NPC, since it inherited our Character typeclass has access to most commands a player does. What +it doesn’t have access to are Session and Player-based cmdsets (which means, among other things that +they cannot chat on channels, but they could do that if you just added those commands). This makes +the +npc command simple:

+
+npc Anna = say Hello!
+Anna says, 'Hello!'
+
+
+

Again, add to the end of your command.py module:

+
class CmdNPC(Command):
+    """
+    controls an NPC
+
+    Usage:
+        +npc <name> = <command>
+
+    This causes the npc to perform a command as itself. It will do so
+    with its own permissions and accesses.
+    """
+    key = "+npc"
+    locks = "call:not perm(nonpcs)"
+    help_category = "mush"
+
+    def parse(self):
+        "Simple split of the = sign"
+        name, cmdname = None, None
+        if "=" in self.args:
+            name, cmdname = [part.strip()
+                             for part in self.args.rsplit("=", 1)]
+        self.name, self.cmdname = name, cmdname
+
+    def func(self):
+        "Run the command"
+        caller = self.caller
+        if not self.cmdname:
+            caller.msg("Usage: +npc <name> = <command>")
+            return
+        npc = caller.search(self.name)
+        if not npc:
+            return
+        if not npc.access(caller, "edit"):
+            caller.msg("You may not order this NPC to do anything.")
+            return
+        # send the command order
+        npc.execute_cmd(self.cmdname)
+        caller.msg(f"You told {npc.key} to do '{self.cmdname}'.")
+
+
+

Note that if you give an erroneous command, you will not see any error message, since that error +will be returned to the npc object, not to you. If you want players to see this, you can give the +caller’s session ID to the execute_cmd call, like this:

+
npc.execute_cmd(self.cmdname, sessid=self.caller.sessid)
+
+
+

Another thing to remember is however that this is a very simplistic way to control NPCs. Evennia +supports full puppeting very easily. An Account (assuming the “puppet” permission was set correctly) +could simply do @ic mynpc and be able to play the game “as” that NPC. This is in fact just what +happens when an Account takes control of their normal Character as well.

+
+
+
+

Concluding remarks

+

This ends the tutorial. It looks like a lot of text but the amount of code you have to write is +actually relatively short. At this point you should have a basic skeleton of a game and a feel for +what is involved in coding your game.

+

From here on you could build a few more ChargenRooms and link that to a bigger grid. The +setpower +command can either be built upon or accompanied by many more to get a more elaborate character +generation.

+

The simple “Power” game mechanic should be easily expandable to something more full-fledged and +useful, same is true for the combat score principle. The +attack could be made to target a +specific player (or npc) and automatically compare their relevant attributes to determine a result.

+

To continue from here, you can take a look at the Tutorial World. For +more specific ideas, see the other tutorials and hints as well +as the Evennia Component overview.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Web-Add-a-wiki.html b/docs/latest/Howtos/Web-Add-a-wiki.html new file mode 100644 index 0000000000..1c6ef4d3f8 --- /dev/null +++ b/docs/latest/Howtos/Web-Add-a-wiki.html @@ -0,0 +1,333 @@ + + + + + + + + + Add a wiki on your website — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Add a wiki on your website

+
+

Warning

+

As of 2023, The django wiki only supports Django 4.0. Evennia requires Django 4.1+. While the django-wiki is still active and will hopefully be updated eventually, for now there is likely to be issues or trouble to install. This tutorial will probably not work out of the gate.

+
+
+

Note

+

Before doing this tutorial you will probably want to read the intro in Basic Web tutorial. Reading the three first parts of the Django tutorial might help as well.

+
+

This tutorial will provide a step-by-step process to installing a wiki on your website. +Fortunately, you don’t have to create the features manually, since it has been done by others, and we can integrate their work quite easily with Django. I have decided to focus on +the Django-wiki.

+

The Django-wiki offers a lot of features associated with wikis, is actively maintained (at this time, anyway), and isn’t too difficult to install in Evennia. You can see a demonstration of Django-wiki here.

+
+

Basic installation

+

You should begin by shutting down the Evennia server if it is running. We will run migrations and alter the virtual environment just a bit. Open a terminal and activate your Python environment, the one you use to run the evennia command.

+

If you used the default location from the Evennia installation instructions, it should be one of the following:

+
    +
  • On Linux:

    +
    source evenv/bin/activate
    +
    +
    +
  • +
  • Or Windows:

    +
    evenv\bin\activate
    +
    +
    +
  • +
+
+

Installing with pip

+

Install the wiki using pip:

+
pip install wiki
+
+
+

It might take some time, the Django-wiki having some dependencies.

+
+
+

Adding the wiki in the settings

+

You will need to add a few settings to have the wiki app on your website. Open your server/conf/settings.py file and add the following at the bottom (but before importing secret_settings). Here’s an example of a settings file with the Django-wiki added:

+
# Use the defaults from Evennia unless explicitly overridden
+from evennia.settings_default import *
+
+######################################################################
+# Evennia base server config
+######################################################################
+
+# This is the name of your game. Make it catchy!
+SERVERNAME = "demowiki"
+
+######################################################################
+# Django-wiki settings
+######################################################################
+INSTALLED_APPS += (
+    'django.contrib.humanize.apps.HumanizeConfig',
+    'django_nyt.apps.DjangoNytConfig',
+    'mptt',
+    'sorl.thumbnail',
+    'wiki.apps.WikiConfig',
+    'wiki.plugins.attachments.apps.AttachmentsConfig',
+    'wiki.plugins.notifications.apps.NotificationsConfig',
+    'wiki.plugins.images.apps.ImagesConfig',
+    'wiki.plugins.macros.apps.MacrosConfig',
+)
+
+# Disable wiki handling of login/signup
+WIKI_ACCOUNT_HANDLING = False
+WIKI_ACCOUNT_SIGNUP_ALLOWED = False
+
+######################################################################
+# Settings given in secret_settings.py override those in this file.
+######################################################################
+try:
+    from server.conf.secret_settings import *
+except ImportError:
+    print("secret_settings.py file not found or failed to import.")
+
+
+

Everything in the section “Django-wiki settings” is what you’ll need to include.

+
+
+

Adding the new URLs

+

Next you will need to add two URLs to the file web/urls.py. You’ll do that by modifying +urlpatterns to look something like this:

+
# add patterns
+urlpatterns = [
+    # website
+    path("", include("web.website.urls")),
+    # webclient
+    path("webclient/", include("web.webclient.urls")),
+    # web admin
+    path("admin/", include("web.admin.urls")),
+    # wiki
+    path("wiki/", include("wiki.urls")),
+    path("notifications/", include("django_nyt.urls")),
+]
+
+
+

The last two lines are what you’ll need to add.

+
+
+

Running migrations

+

Next you’ll need to run migrations, since the wiki app adds a few tables in our database:

+
evennia migrate
+
+
+
+
+

Initializing the wiki

+

Last step! Go ahead and start up your server again.

+
evennia start
+
+
+

Once that’s finished booting, go to your evennia website (e.g. http://localhost:4001 ) and log in with your superuser account, if you aren’t already. Then, go to your new wiki (e.g. http://localhost:4001/wiki ). It’ll prompt you to create a starting page - put whatever you want, you can change it later.

+

Congratulations! You’re all done!

+
+
+
+

Defining wiki permissions

+

A wiki is usually intended as a collaborative effort - but you probably still want to set some rules about who is allowed to do what. Who can create new articles? Edit them? Delete them? Etc.

+

The two simplest ways to do this are to use Django-wiki’s group-based permissions +system - or, since this is an Evennia site, to define your own custom permission rules tied to Evennia’s permissions system in your settings file.

+
+

Group permissions

+

The wiki itself controls reading/editing permissions per article. The creator of an article will always have read/write permissions on that article. Additionally, the article will have Group-based permissions and general permissions.

+

By default, Evennia’s permission groups won’t be recognized by the wiki, so you’ll have to create your own. Go to the Groups page of your game’s Django admin panel and add whichever permission groups you want for your wiki here.

+

Note: If you want to connect those groups to your game’s permission levels, you’ll need to modify the game to apply both to accounts.

+

Once you’ve added those groups, they’ll be usable in your wiki right away!

+
+
+

Settings permissions

+

Django-wiki also allows you to bypass its article-based permissions with custom site-wide permissions rules in your settings file. If you don’t want to use the Group system, or if you want a simple solution for connecting the Evennia permission levels to wiki access, this is the way to go.

+

Here’s an example of a basic set-up that would go in your settings.py file:

+
# In server/conf/settings.py
+# ...
+
+# Custom methods to link wiki permissions to game perms
+def is_superuser(article, user):
+    """Return True if user is a superuser, False otherwise."""
+    return not user.is_anonymous() and user.is_superuser
+
+def is_builder(article, user):
+    """Return True if user is a builder, False otherwise."""
+    return not user.is_anonymous() and user.locks.check_lockstring(user, "perm(Builders)")
+
+def is_player(article, user):
+    """Return True if user is a builder, False otherwise."""
+    return not user.is_anonymous() and user.locks.check_lockstring(user, "perm(Players)")
+
+# Create new users
+WIKI_CAN_ADMIN = is_superuser
+
+# Change the owner and group for an article
+WIKI_CAN_ASSIGN = is_superuser
+
+# Change the GROUP of an article, despite the name
+WIKI_CAN_ASSIGN_OWNER = is_superuser
+
+# Change read/write permissions on an article
+WIKI_CAN_CHANGE_PERMISSIONS = is_superuser
+
+# Mark an article as deleted
+WIKI_CAN_DELETE = is_builder
+
+# Lock or permanently delete an article
+WIKI_CAN_MODERATE = is_superuser
+
+# Create or edit any pages
+WIKI_CAN_WRITE = is_builder
+
+# Read any pages
+WIKI_CAN_READ = is_player
+
+# Completely disallow editing and article creation when not logged in
+WIKI_ANONYMOUS_WRITE = False
+
+
+

The permission functions can check anything you like on the accessing user, so long as the function returns either True (they’re allowed) or False (they’re not).

+

For a full list of possible settings, you can check out the django-wiki documentation.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Web-Changing-Webpage.html b/docs/latest/Howtos/Web-Changing-Webpage.html new file mode 100644 index 0000000000..a9923b0a71 --- /dev/null +++ b/docs/latest/Howtos/Web-Changing-Webpage.html @@ -0,0 +1,187 @@ + + + + + + + + + Changing the Game Website — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Changing the Game Website

+

Evennia uses the Django web framework as the basis of both its database configuration and the website it provides. While a full understanding of Django requires reading the Django documentation, we have provided this tutorial to get you running with the basics and how they pertain to Evennia. This text details getting everything set up. The Web-based Character view Tutorial gives a more explicit example of making a custom web page connected to your game, and you may want to read that after finishing this guide.

+
+

A Basic Overview

+

Django is a web framework. It gives you a set of development tools for building a website quickly and easily.

+

Django projects are split up into apps and these apps all contribute to one project. For instance, you might have an app for conducting polls, or an app for showing news posts or, like us, one for creating a web client.

+

Each of these applications has a urls.py file, which specifies what URLs are used by the app, a views.py file for the code that the URLs activate, a templates directory for displaying the results of that code in HTML for the user, and a static folder that holds assets like CSS, Javascript, and Image files (You may note your mygame/web folder does not have a static or template folder. This is intended and explained further below). Django applications may also have a models.py file for storing information in the database. We will not change any models here, take a look at the New Models page (as well as the Django docs on models) if you are interested.

+

There is also a root urls.py that determines the URL structure for the entire project. A starter urls.py is included in the default game template, and automatically imports all of Evennia’s default URLs for you. This is located in web/urls.py.

+
+
+

Changing the logo on the front page

+

Evennia’s default logo is a fun little googly-eyed snake wrapped around a gear globe. As cute as it is, it probably doesn’t represent your game. So one of the first things you may wish to do is replace it with a logo of your own.

+

Django web apps all have static assets: CSS files, Javascript files, and Image files. In order to make sure the final project has all the static files it needs, the system collects the files from every app’s static folder and places it in the STATIC_ROOT defined in settings.py. By default, the Evennia STATIC_ROOT is in web/static.

+

Because Django pulls files from all of those separate places and puts them in one folder, it’s possible for one file to overwrite another. We will use this to plug in our own files without having to change anything in the Evennia itself.

+

By default, Evennia is configured to pull files you put in the mygame/web/static/ after all other static files. That means that files under mygame/web/static/ folder will overwrite any previously loaded files having the same path under its static folder. This last part is important to repeat: To overload the static resource from a standard evennia/web/static folder you need to replicate the path of folders and file names under mygame/web/static/. Luckily your game dir’s folder already has a lot of pre-made structure, so it should be pretty clear: For exampl for overriding website things, you put it under mygame/web/static/website/. Webclient would be mygame/web/static/webclient and so on.

+

Let’s see how this works for our logo. The default web application is in the Evennia library itself, in evennia/web/. We can see that there is a static folder here. If we browse down, we’ll eventually find the full path to the Evennia logo file: evennia/web/static/website/images/evennia_logo.png.

+

Put your own logo in the equivalent place in your game folder: mygame/web/static/website/images/evennia_logo.png.

+

To get this file pulled in, just change to your own game directory and reload the server:

+
evennia reload
+
+
+

This will reload the configuration and bring in the new static file(s). If you didn’t want to reload the server you could instead use

+
evennia collectstatic
+
+
+

to only update the static files without any other changes.

+
+

Evennia will collect static files automatically during startup. So if evennia collectstatic reports finding 0 files to collect, make sure you didn’t start the engine at some point - if so the collector has already done its work! To make sure, connect to the website and check so the logo has actually changed to your own version.

+
+
+

The asset collector is actually collecting all data into one place, in the hidden directory mygame/server/.static/. It’s from here these files are actually served. Sometimes the static asset collector can get confused. If no matter what you do, your overridden files aren’t getting copied over the defaults, try emptyingmygame/server/.static/ and run evennia collectstatic anew.

+
+
+
+

Changing the Front Page’s Text

+

The default front page for Evennia contains information about the Evennia project. You’ll probably want to replace this information with information about your own project. Changing the page template is done in a similar way to changing static resources.

+

Like static files, Django looks through a series of template folders to find the file it wants. The difference is that Django does not copy all of the template files into one place, it just searches through the template folders until it finds a template that matches what it’s looking for. This means that when you edit a template, the changes are instant. You don’t have to reload the server or run any extra commands to see these changes - reloading the web page in your browser is enough.

+

To replace the index page’s text, we’ll need to find the template for it. We’ll go into more detail about how to determine which template is used for rendering a page in the Web-based Character view Tutorial. For now, you should know that the template we want to change is stored in evennia/web/website/templates/website/index.html.

+

To replace this template file, you will put your changed template inside mygame/web/templates/. In the same way as with static resources you must use replicate the same folder structure as in the main library. For example, to override the main index.html file found in evennia/web/templates/website/index.html, copy it mygame/web/templates/website/index.html and customize it as you like. Just reload your server to see your new version.

+
+
+

Further reading

+

For further hints on working with the web presence, you could now continue to the Web-based Character view Tutorial where you learn to make a web page that displays in-game character stats. You can also look at Django’s own tutorial to get more insight in how Django works and what possibilities exist.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Web-Character-Generation.html b/docs/latest/Howtos/Web-Character-Generation.html new file mode 100644 index 0000000000..e72583e81a --- /dev/null +++ b/docs/latest/Howtos/Web-Character-Generation.html @@ -0,0 +1,685 @@ + + + + + + + + + Web Character Generation — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Web Character Generation

+
+

Introduction

+

This tutorial will create a simple web-based interface for generating a new in-game Character. Accounts will need to have first logged into the website (with their AccountDB account). Once finishing character generation the Character will be created immediately and the Accounts can then log into the game and play immediately (the Character will not require staff approval or anything like that). This guide does not go over how to create an AccountDB on the website with the right permissions to transfer to their web-created characters.

+

It is probably most useful to set AUTO_CREATE_CHARACTER_WITH_ACCOUNT = False so that all player characters can be created through this.

+

You should have some familiarity with how Django sets up its Model Template View framework. You need to understand what is happening in the basic Web Character View tutorial. If you don’t understand the listed tutorial or have a grasp of Django basics, please look at the Django tutorial to get a taste of what Django does, before throwing Evennia into the mix (Evennia shares its API and attributes with the website interface). This guide will outline the format of the models, views, urls, and html templates needed.

+
+
+

Pictures

+

Here are some screenshots of the simple app we will be making.

+

Index page, with no character application yet done:

+
+

Index page, with no character application yet done.

+
+

Having clicked the “create” link you get to create your character (here we will only have name and background, you can add whatever is needed to fit your game):

+
+

Character creation.

+
+

Back to the index page. Having entered our character application (we called our character “TestApp”) you see it listed:

+
+

Having entered an application.

+
+

We can also view an already written character application by clicking on it - this brings us to the detail page:

+
+

Detail view of character application.

+
+
+
+

Installing an App

+

Assuming your game is named “mygame”, navigate to your mygame/ directory, and type:

+
cd web
+evennia startapp chargen
+
+
+

This will initialize a new Django app we choose to call “chargen” in mygame/web/. We put it under web/ to keep all web stuff together, but you can organize however you like. It is directory containing some basic starting things Django needs.

+

Next, navigate to mygame/server/conf/settings.py and add or edit the following line to make Evennia (and Django) aware of our new app:

+
INSTALLED_APPS += ('web.chargen',)
+
+
+

After this, we will get into defining our models (the description of the database storage), +views (the server-side website content generators), urls (how the web browser finds the pages) and templates (how the web page should be structured).

+
+

Installing - Checkpoint:

+
    +
  • you should have a folder named chargen or whatever you chose in your mygame/web/ directory

  • +
  • you should have your application name added to your INSTALLED_APPS in settings.py

  • +
+
+
+
+

Create Models

+

Models are created in mygame/web/chargen/models.py.

+

A Django database model is a Python class that describes the database storage of the +data you want to manage. Any data you choose to store is stored in the same database as the game and you have access to all the game’s objects here.

+

We need to define what a character application actually is. This will differ from game to game so for this tutorial we will define a simple character sheet with the following database fields:

+
    +
  • app_id (AutoField): Primary key for this character application sheet.

  • +
  • char_name (CharField): The new character’s name.

  • +
  • date_applied (DateTimeField): Date that this application was received.

  • +
  • background (TextField): Character story background.

  • +
  • account_id (IntegerField): Which account ID does this application belong to? This is an +AccountID from the AccountDB object.

  • +
  • submitted (BooleanField): True/False depending on if the application has been submitted yet.

  • +
+
+

Note: In a full-fledged game, you’d likely want them to be able to select races, skills, attributes and so on.

+
+

Our models.py file should look something like this:

+
# in mygame/web/chargen/models.py
+
+from django.db import models
+
+class CharApp(models.Model):
+    app_id = models.AutoField(primary_key=True)
+    char_name = models.CharField(max_length=80, verbose_name='Character Name')
+    date_applied = models.DateTimeField(verbose_name='Date Applied')
+    background = models.TextField(verbose_name='Background')
+    account_id = models.IntegerField(default=1, verbose_name='Account ID')
+    submitted = models.BooleanField(default=False)
+
+
+

You should consider how you are going to link your application to your account. For this tutorial, we are using the account_id attribute on our character application model in order to keep track of which characters are owned by which accounts. Since the account id is a primary key in Evennia, it is a good candidate, as you will never have two of the same IDs in Evennia. You can feel free to use anything else, but for the purposes of this guide, we are going to use account ID to join the character applications with the proper account.

+
+

Model - Checkpoint:

+
    +
  • you should have filled out mygame/web/chargen/models.py with the model class shown above (eventually adding fields matching what you need for your game).

  • +
+
+
+
+

Create Views

+

Views are server-side constructs that make dynamic data available to a web page. We are going to add them to mygame/web/chargen.views.py. Each view in our example represents the backbone of a +specific web page. We will use three views and three pages here:

+
    +
  • The index (managing index.html). This is what you see when you navigate to +http://yoursite.com/chargen.

  • +
  • The detail display sheet (manages detail.html). A page that passively displays the stats of a given Character.

  • +
  • Character creation sheet (manages create.html). This is the main form with fields to fill in.

  • +
+
+

Index view

+

Let’s get started with the index first.

+

We’ll want characters to be able to see their created characters so let’s

+
# file mygame/web/chargen.views.py
+
+from .models import CharApp
+
+def index(request):
+    current_user = request.user # current user logged in
+    p_id = current_user.id # the account id
+    # submitted Characters by this account
+    sub_apps = CharApp.objects.filter(account_id=p_id, submitted=True)
+    context = {'sub_apps': sub_apps}
+    # make the variables in 'context' available to the web page template
+    return render(request, 'chargen/index.html', context)
+
+
+
+
+

Detail view

+

Our detail page will have pertinent character application information our users can see. Since this is a basic demonstration, our detail page will only show two fields:

+
    +
  • Character name

  • +
  • Character background

  • +
+

We will use the account ID again just to double-check that whoever tries to check our character page is actually the account who owns the application.

+
# file mygame/web/chargen.views.py
+
+def detail(request, app_id):
+    app = CharApp.objects.get(app_id=app_id)
+    name = app.char_name
+    background = app.background
+    submitted = app.submitted
+    p_id = request.user.id
+    context = {'name': name, 'background': background,
+        'p_id': p_id, 'submitted': submitted}
+    return render(request, 'chargen/detail.html', context)
+
+
+
+
+
+

Creating view

+

Predictably, our create function will be the most complicated of the views, as it needs to accept information from the user, validate the information, and send the information to the server. Once the form content is validated will actually create a playable Character.

+

The form itself we will define first. In our simple example we are just looking for the Character’s name and background. This form we create in mygame/web/chargen/forms.py:

+
# file mygame/web/chargen/forms.py
+
+from django import forms
+
+class AppForm(forms.Form):
+    name = forms.CharField(label='Character Name', max_length=80)
+    background = forms.CharField(label='Background')
+
+
+

Now we make use of this form in our view.

+
# file mygame/web/chargen/views.py
+
+from web.chargen.models import CharApp
+from web.chargen.forms import AppForm
+from django.http import HttpResponseRedirect
+from datetime import datetime
+from evennia.objects.models import ObjectDB
+from django.conf import settings
+from evennia.utils import create
+
+def creating(request):
+    user = request.user
+    if request.method == 'POST':
+        form = AppForm(request.POST)
+        if form.is_valid():
+            name = form.cleaned_data['name']
+            background = form.cleaned_data['background']
+            applied_date = datetime.now()
+            submitted = True
+            if 'save' in request.POST:
+                submitted = False
+            app = CharApp(char_name=name, background=background,
+            date_applied=applied_date, account_id=user.id,
+            submitted=submitted)
+            app.save()
+            if submitted:
+                # Create the actual character object
+                typeclass = settings.BASE_CHARACTER_TYPECLASS
+                home = ObjectDB.objects.get_id(settings.GUEST_HOME)
+                # turn the permissionhandler to a string
+                perms = str(user.permissions)
+                # create the character
+                char = create.create_object(typeclass=typeclass, key=name,
+                    home=home, permissions=perms)
+                user.add_character(char)
+                # add the right locks for the character so the account can
+                #  puppet it
+                char.locks.add(" or ".join([
+                    f"puppet:id({char.id})",
+                    f"pid({user.id})",
+                    "perm(Developers)",
+                    "pperm(Developers)",
+                ]))
+                char.db.background = background # set the character background
+            return HttpResponseRedirect('/chargen')
+    else:
+        form = AppForm()
+    return render(request, 'chargen/create.html', {'form': form})
+
+
+
+

Note also that we basically create the character using the Evennia API, and we grab the proper permissions from the AccountDB object and copy them to the character object. We take the user permissions attribute and turn that list of strings into a string object in order for the create_object function to properly process the permissions.

+
+

Most importantly, the following attributes must be set on the created character object:

+
    +
  • Evennia permissions (copied from the AccountDB).

  • +
  • The right puppet locks so the Account can actually play as this Character later.

  • +
  • The relevant Character typeclass

  • +
  • Character name (key)

  • +
  • The Character’s home room location (#2 by default)

  • +
+

Other attributes are strictly speaking optional, such as the background attribute on our character. It may be a good idea to decompose this function and create a separate _create_character function in order to set up your character object the account owns. But with the Evennia API, setting custom attributes is as easy as doing it in the meat of your Evennia game directory.

+

After all of this, our views.py file should look like something like this:

+
# file mygame/web/chargen/views.py
+
+from django.shortcuts import render
+from web.chargen.models import CharApp
+from web.chargen.forms import AppForm
+from django.http import HttpResponseRedirect
+from datetime import datetime
+from evennia.objects.models import ObjectDB
+from django.conf import settings
+from evennia.utils import create
+
+def index(request):
+    current_user = request.user # current user logged in
+    p_id = current_user.id # the account id
+    # submitted apps under this account
+    sub_apps = CharApp.objects.filter(account_id=p_id, submitted=True)
+    context = {'sub_apps': sub_apps}
+    return render(request, 'chargen/index.html', context)
+
+def detail(request, app_id):
+    app = CharApp.objects.get(app_id=app_id)
+    name = app.char_name
+    background = app.background
+    submitted = app.submitted
+    p_id = request.user.id
+    context = {'name': name, 'background': background,
+        'p_id': p_id, 'submitted': submitted}
+    return render(request, 'chargen/detail.html', context)
+
+def creating(request):
+    user = request.user
+    if request.method == 'POST':
+        form = AppForm(request.POST)
+        if form.is_valid():
+            name = form.cleaned_data['name']
+            background = form.cleaned_data['background']
+            applied_date = datetime.now()
+            submitted = True
+            if 'save' in request.POST:
+                submitted = False
+            app = CharApp(char_name=name, background=background,
+            date_applied=applied_date, account_id=user.id,
+            submitted=submitted)
+            app.save()
+            if submitted:
+                # Create the actual character object
+                typeclass = settings.BASE_CHARACTER_TYPECLASS
+                home = ObjectDB.objects.get_id(settings.GUEST_HOME)
+                # turn the permissionhandler to a string
+                perms = str(user.permissions)
+                # create the character
+                char = create.create_object(typeclass=typeclass, key=name,
+                    home=home, permissions=perms)
+                user.add_character(char)
+                # add the right locks for the character so the account can
+                #  puppet it
+                char.locks.add(" or ".join([
+                    f"puppet:id({char.id})",
+                    f"pid({user.id})",
+                    "perm(Developers)",
+                    "pperm(Developers)",
+                ]))
+                char.db.background = background # set the character background
+            return HttpResponseRedirect('/chargen')
+    else:
+        form = AppForm()
+    return render(request, 'chargen/create.html', {'form': form})
+
+
+
+

Create Views - Checkpoint:

+
    +
  • you’ve defined a views.py that has an index, detail, and creating functions.

  • +
  • you’ve defined a forms.py with the AppForm class needed by the creating function of views.py.

  • +
  • your mygame/web/chargen directory should now have a views.py and forms.py file

  • +
+
+
+
+

Create URLs

+

URL patterns helps redirect requests from the web browser to the right views. These patterns are created in mygame/web/chargen/urls.py.

+
# file mygame/web/chargen/urls.py
+
+from django.urls import path
+from web.chargen import views
+
+urlpatterns = [
+    # url: /chargen/
+    path("", views.index, name='chargen-index'),
+    # url: /chargen/5/
+    path("<int:app_id>/", views.detail, name="chargen-detail"),
+    # url: /chargen/create
+    path("create/", views.creating, name='chargen-creating'),
+]
+
+
+

You could change the format as you desire. To make it more secure, you could remove app_id from the “detail” url, and instead just fetch the account’s applications using a unifying field like account_id to find all the character application objects to display.

+

To add this to our website, we must also update the main mygame/website/urls.py file; this will help tying our new chargen app in with the rest of the website. urlpatterns variable, and change it to include:

+
# in file mygame/website/urls.py
+
+from django.urls import path, include
+
+urlpatterns = [
+    # make all chargen endpoints available under /chargen url
+    path("chargen/", include("web.chargen.urls")
+]
+
+
+
+
+

URLs - Checkpoint:

+
    +
  • You’ve created a urls.py file in the mygame/web/chargen directory

  • +
  • You have edited the main mygame/web/urls.py file to include urls to the chargen directory.

  • +
+
+
+
+

HTML Templates

+

So we have our url patterns, views, and models defined. Now we must define our HTML templates that the actual user will see and interact with. For this tutorial we us the basic prosimii template that comes with Evennia.

+

Take note that we use user.is_authenticated to make sure that the user cannot create a character without logging in.

+

These files will all go into the /mygame/web/chargen/templates/chargen/ directory.

+
+

index.html

+

This HTML template should hold a list of all the applications the account currently has active. For this demonstration, we will only list the applications that the account has submitted. You could easily adjust this to include saved applications, or other types of applications if you have different kinds.

+

Please refer back to views.py to see where we define the variables these templates make use of.

+
<!-- file mygame/web/chargen/templates/chargen/index.html-->
+
+{% extends "base.html" %}
+{% block content %}
+{% if user.is_authenticated %}
+    <h1>Character Generation</h1>
+    {% if sub_apps %}
+        <ul>
+        {% for sub_app in sub_apps %}
+            <li><a href="/chargen/{{ sub_app.app_id }}/">{{ sub_app.char_name }}</a></li>
+        {% endfor %}
+        </ul>
+    {% else %}
+        <p>You haven't submitted any character applications.</p>
+    {% endif %}
+  {% else %}
+    <p>Please <a href="{% url 'login'%}">login</a>first.<a/></p>
+{% endif %}
+{% endblock %}
+
+
+
+
+

detail.html

+

This page should show a detailed character sheet of their application. This will only show their name and character background. You will likely want to extend this to show many more fields for your game. In a full-fledged character generation, you may want to extend the boolean attribute of submitted to allow accounts to save character applications and submit them later.

+
<!-- file mygame/web/chargen/templates/chargen/detail.html-->
+
+{% extends "base.html" %}
+{% block content %}
+<h1>Character Information</h1>
+{% if user.is_authenticated %}
+    {% if user.id == p_id %}
+        <h2>{{name}}</h2>
+        <h2>Background</h2>
+        <p>{{background}}</p>
+        <p>Submitted: {{submitted}}</p>
+    {% else %}
+        <p>You didn't submit this character.</p>
+    {% endif %}
+{% else %}
+<p>You aren't logged in.</p>
+{% endif %}
+{% endblock %}
+
+
+
+
+

create.html

+

Our create HTML template will use the Django form we defined back in views.py/forms.py to drive the majority of the application process. There will be a form input for every field we defined in forms.py, which is handy. We have used POST as our method because we are sending information to the server that will update the database. As an alternative, GET would be much less secure. You can read up on documentation elsewhere on the web for GET vs. POST.

+
<!-- file mygame/web/chargen/templates/chargen/create.html-->
+
+{% extends "base.html" %}
+{% block content %}
+<h1>Character Creation</h1>
+{% if user.is_authenticated %}
+<form action="/chargen/create/" method="post">
+    {% csrf_token %}
+    {{ form }}
+    <input type="submit" name="submit" value="Submit"/>
+</form>
+{% else %}
+<p>You aren't logged in.</p>
+{% endif %}
+{% endblock %}
+
+
+
+
+

Templates - Checkpoint:

+
    +
  • Create a index.html, detail.html and create.html template in your mygame/web/chargen/templates/chargen directory

  • +
+
+
+
+

Activating your new character generation

+

After finishing this tutorial you should have edited or created the following files:

+
mygame/web/website/urls.py
+mygame/web/chargen/models.py
+mygame/web/chargen/views.py
+mygame/web/chargen/urls.py
+mygame/web/chargen/templates/chargen/index.html
+mygame/web/chargen/templates/chargen/create.html
+mygame/web/chargen/templates/chargen/detail.html
+
+
+

Once you have all these files stand in your mygame/folder and run:

+
evennia makemigrations
+evennia migrate
+
+
+

This will create and update the models. If you see any errors at this stage, read the traceback carefully, it should be relatively easy to figure out where the error is.

+

Login to the website (you need to have previously registered an Player account with the game to do this). Next you navigate to http://yourwebsite.com/chargen (if you are running locally this will be something like http://localhost:4001/chargen and you will see your new app in action.

+

This should hopefully give you a good starting point in figuring out how you’d like to approach your own web generation. The main difficulties are in setting the appropriate settings on your newly created character object. Thankfully, the Evennia API makes this easy.

+
+
+

Adding a no CAPCHA reCAPCHA on your character generation

+

As sad as it is, if your server is open to the web, bots might come to visit and take advantage of your open form to create hundreds, thousands, millions of characters if you give them the opportunity. This section shows you how to use the No CAPCHA +reCAPCHA designed by Google. Not only is it easy to use, it is user-friendly… for humans. A simple checkbox to check, except if Google has some suspicion, in which case you will have a more difficult test with an image and the usual text inside. It’s worth pointing out that, as long as Google doesn’t suspect you of being a robot, this is quite useful, not only for common users, but to screen-reader users, to which reading inside of an image is pretty difficult, if not impossible. And to top it all, it will be so easy to add in your website.

+
+

Step 1: Obtain a SiteKey and secret from Google

+

The first thing is to ask Google for a way to safely authenticate your website to their service. To do it, we need to create a site key and a secret. Go to https://www.google.com/recaptcha/admin to create such a site key. It’s quite easy when you have a Google account.

+

When you have created your site key, save it safely. Also copy your secret key as well. You should find both information on the web page. Both would contain a lot of letters and figures.

+
+
+

Step 2: installing and configuring the dedicated Django app

+

Since Evennia runs on Django, the easiest way to add our CAPCHA and perform the proper check is to install the dedicated Django app. Quite easy:

+
pip install django-nocaptcha-recaptcha
+
+
+

And add it to the installed apps in your settings. In your mygame/server/conf/settings.py, you might have something like this:

+
# ...
+INSTALLED_APPS += (
+    'web.chargen',
+    'nocaptcha_recaptcha',
+)
+
+
+

Don’t close the setting file just yet. We have to add in the site key and secret key. You can add them below:

+
# NoReCAPCHA site key
+NORECAPTCHA_SITE_KEY = "PASTE YOUR SITE KEY HERE"
+# NoReCAPCHA secret key
+NORECAPTCHA_SECRET_KEY = "PUT YOUR SECRET KEY HERE"
+
+
+
+
+

Step 3: Adding the CAPCHA to our form

+

Finally we have to add the CAPCHA to our form. It will be pretty easy too. First, open your web/chargen/forms.py file. We’re going to add a new field, but hopefully, all the hard work has been done for us. Update at your convenience, You might end up with something like this:

+
from django import forms
+from nocaptcha_recaptcha.fields import NoReCaptchaField
+
+class AppForm(forms.Form):
+    name = forms.CharField(label='Character Name', max_length=80)
+    background = forms.CharField(label='Background')
+    captcha = NoReCaptchaField()
+
+
+

As you see, we added a line of import (line 2) and a field in our form.

+

And lastly, we need to update our HTML file to add in the Google library. You can open +web/chargen/templates/chargen/create.html. There’s only one line to add:

+
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
+
+
+

And you should put it at the bottom of the page. Just before the closing body would be good, but for the time being, the base page doesn’t provide a footer block, so we’ll put it in the content block. Note that it’s not the best place, but it will work. In the end, your +web/chargen/templates/chargen/create.html file should look like this:

+
{% extends "base.html" %}
+{% block content %}
+<h1>Character Creation</h1>
+{% if user.is_authenticated %}
+<form action="/chargen/create/" method="post">
+    {% csrf_token %}
+    {{ form }}
+    <input type="submit" name="submit" value="Submit"/>
+</form>
+{% else %}
+<p>You aren't logged in.</p>
+{% endif %}
+<script src="https://www.google.com/recaptcha/api.js" async defer></script>
+{% endblock %}
+
+
+

Reload and open http://localhost:4001/chargen/create and you should see your beautiful CAPCHA just before the “submit” button. Try not to check the checkbox to see what happens. And do the same while checking the checkbox!

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Web-Character-View-Tutorial.html b/docs/latest/Howtos/Web-Character-View-Tutorial.html new file mode 100644 index 0000000000..2c535d9786 --- /dev/null +++ b/docs/latest/Howtos/Web-Character-View-Tutorial.html @@ -0,0 +1,285 @@ + + + + + + + + + Web Character View Tutorial — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Web Character View Tutorial

+

Before doing this tutorial you will probably want to read the intro in Changing The Web Page tutorial.

+

In this tutorial we will create a web page that displays the stats of a game character. For this, and all other pages we want to make specific to our game, we’ll need to create our own Django “app”. We’ll call our app character, since it will be dealing with character information. From your game dir, run

+
evennia startapp character
+
+
+

This will create a new directory named character inside mygame. To keep +things tidy, let’s move it into the web/ subdirectory.

+
mv character web  (linux/mac)
+move character web  (windows)
+
+
+

We put it in web/ to keep things tidy, but you could place it wherever you +like. It contains all basic files that a Django app needs.

+

Note that we will not edit all files in this new directory, many of the generated files are outside the scope of this tutorial.

+

In order for Django to find our new web app, we’ll need to add it to the INSTALLED_APPS setting. Evennia’s default installed apps are already set, so in server/conf/settings.py, we’ll just extend them:

+
INSTALLED_APPS += ('web.character',)
+
+
+
+

Note: That end comma is important. It makes sure that Python interprets the addition as a tuple instead of a string.

+
+

The first thing we need to do is to create a view and an URL pattern to point to it. A view is a +function that generates the web page that a visitor wants to see, while the URL pattern lets Django know what URL should trigger the view. The pattern may also provide some information of its own as we shall see.

+

Here is our character/urls.py file (Note: you may have to create this file if a blank one +wasn’t generated for you):

+
# URL patterns for the character app
+
+from django.urls import path
+from web.character.views import sheet
+
+urlpatterns = [
+    path("sheet/<int:object_id>", sheet, name="sheet")
+]
+
+
+

This file contains all of the URL patterns for the application. The url function in the +urlpatterns list are given three arguments. The first argument is a pattern-string used to +identify which URLs are valid. Patterns are specified as regular expressions. Regular expressions are used to match strings and are written in a special, very compact, syntax. A detailed description of regular expressions is beyond this tutorial but you can learn more about them here. For now, just accept that this regular expression requires that the visitor’s URL looks something like this:

+
sheet/123/
+
+
+

That is, sheet/ followed by a number, rather than some other possible URL pattern. We will interpret this number as object ID. Thanks to how the regular expression is formulated, the pattern recognizer stores the number in a variable called object_id. This will be passed to the view (see below). We add the imported view function (sheet) in the second argument. We also add the name keyword to identify the URL pattern itself. You should always name your URL patterns, this makes them easy to refer to in html templates using the {% url %} tag (but we won’t get more into that in this tutorial).

+
+

Security Note: Normally, users do not have the ability to see object IDs within the game (it’s restricted to superusers only). Exposing the game’s object IDs to the public like this enables griefers to perform what is known as an account enumeration attack in the efforts of hijacking your superuser account. Consider this: in every Evennia installation, there are two objects that we can always expect to exist and have the same object IDs– Limbo (#2) and the superuser you create in the beginning (#1). Thus, the griefer can get 50% of the information they need to hijack the admin account (the admin’s username) just by navigating to sheet/1!

+
+

Next we create views.py, the view file that urls.py refers to.

+
# Views for our character app
+
+from django.http import Http404
+from django.shortcuts import render
+from django.conf import settings
+
+from evennia.utils.search import object_search
+from evennia.utils.utils import inherits_from
+
+def sheet(request, object_id):
+    object_id = '#' + object_id
+    try:
+        character = object_search(object_id)[0]
+    except IndexError:
+        raise Http404("I couldn't find a character with that ID.")
+    if not inherits_from(character, settings.BASE_CHARACTER_TYPECLASS):
+        raise Http404("I couldn't find a character with that ID. "
+                      "Found something else instead.")
+    return render(request, 'character/sheet.html', {'character': character})
+
+
+

As explained earlier, the URL pattern parser in urls.py parses the URL and passes object_id to our view function sheet. We do a database search for the object using this number. We also make sure such an object exists and that it is actually a Character. The view function is also handed a request object. This gives us information about the request, such as if a logged-in user viewed it - we won’t use that information here but it is good to keep in mind.

+

On the last line, we call the render function. Apart from the request object, the render +function takes a path to an html template and a dictionary with extra data you want to pass into said template. As extra data we pass the Character object we just found. In the template it will be available as the variable “character”.

+

The html template is created as templates/character/sheet.html under your character app folder. You may have to manually create both template and its subfolder character. Here’s the template to create:

+
{% extends "base.html" %}
+{% block content %}
+
+    <h1>{{ character.name }}</h1>
+
+    <p>{{ character.db.desc }}</p>
+
+    <h2>Stats</h2>
+    <table>
+      <thead>
+        <tr>
+          <th>Stat</th>
+          <th>Value</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <td>Strength</td>
+          <td>{{ character.db.str }}</td>
+        </tr>
+        <tr>
+          <td>Intelligence</td>
+          <td>{{ character.db.int }}</td>
+        </tr>
+        <tr>
+          <td>Speed</td>
+          <td>{{ character.db.spd }}</td>
+        </tr>
+      </tbody>
+    </table>
+
+    <h2>Skills</h2>
+    <ul>
+      {% for skill in character.db.skills %}
+        <li>{{ skill }}</li>
+      {% empty %}
+        <li>This character has no skills yet.</li>
+      {% endfor %}
+    </ul>
+
+    {% if character.db.approved %}
+      <p class="success">This character has been approved!</p>
+    {% else %}
+      <p class="warning">This character has not yet been approved!</p>
+    {% endif %}
+{% endblock %}
+
+
+

In Django templates, {% ... %} denotes special in-template “functions” that Django understands. The {{ ... }} blocks work as “slots”. They are replaced with whatever value the code inside the block returns.

+

The first line, {% extends "base.html" %}, tells Django that this template extends the base template that Evennia is using. The base template is provided by the theme. Evennia comes with the open-source third-party theme prosimii. You can find it and its base.html in +evennia/web/templates/prosimii. Like other templates, these can be overwritten.

+

The next line is {% block content %}. The base.html file has blocks, which are placeholders +that templates can extend. The main block, and the one we use, is named content.

+

We can access the character variable anywhere in the template because we passed it in the render call at the end of view.py. That means we also have access to the Character’s db attributes, much like you would in normal Python code. You don’t have the ability to call functions with arguments in the template– in fact, if you need to do any complicated logic, you should do it in view.py and pass the results as more variables to the template. But you still have a great deal of flexibility in how you display the data.

+

We can do a little bit of logic here as well. We use the {% for %} ... {% endfor %} and {% if %} ... {% else %} ... {% endif %} structures to change how the template renders depending on how many skills the user has, or if the user is approved (assuming your game has an approval system).

+

The last file we need to edit is the master URLs file. This is needed in order to smoothly integrate the URLs from your new character app with the URLs from Evennia’s existing pages. Find the file web/website/urls.py and update its patterns list as follows:

+
# web/website/urls.py
+
+urlpatterns = [
+    # ...
+    path("character/", include('web.character.urls'))
+   ]
+
+
+

Now reload the server with evennia reload and visit the page in your browser. If you haven’t +changed your defaults, you should be able to find the sheet for character #1 at +http://localhost:4001/character/sheet/1/

+

Try updating the stats in-game and refresh the page in your browser. The results should show immediately.

+

As an optional final step, you can also change your character typeclass to have a method called ‘get_absolute_url’.

+
# typeclasses/characters.py
+
+    # inside Character
+    def get_absolute_url(self):
+        from django.urls import reverse
+        return reverse('character:sheet', kwargs={'object_id':self.id})
+
+
+

Doing so will give you a ‘view on site’ button in the top right of the Django Admin Objects +changepage that links to your new character sheet, and allow you to get the link to a character’s page by using {{ object.get_absolute_url }} in any template where you have a given object.

+

Now that you’ve made a basic page and app with Django, you may want to read the full Django tutorial to get a better idea of what it can do. You can find Django’s tutorial +here.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Web-Extending-the-REST-API.html b/docs/latest/Howtos/Web-Extending-the-REST-API.html new file mode 100644 index 0000000000..8fd5b646cc --- /dev/null +++ b/docs/latest/Howtos/Web-Extending-the-REST-API.html @@ -0,0 +1,537 @@ + + + + + + + + + Extending the REST API — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Extending the REST API

+ +

By default, the Evennia REST API provides endpoints for the standard entities. One such endpoint is /api/characters/, returning information about Characters. In this tutorial, we’ll extend it by adding an inventory action to the /characters endpoint, showing all objects being worn and carried by a character.

+
+

Creating your own viewset

+ +

The first thing you’ll need to do is define your own views.py module.

+

Create a blank file: mygame/web/api/views.py

+

The default REST API endpoints are controlled by classes in evennia/web/api/views.py - you could copy that entire file and use it, but we’re going to focus on changing the minimum.

+

To start, we’ll reimplement the default CharacterViewSet that handles requests from the characters/ endpoint. This is a child of the objects endpoint that can only access characters.

+
# in mygame/web/api/views.py
+
+# we'll need these from django's rest framework to make our view work
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from rest_framework import status
+
+# this implements all the basic Evennia Object endpoint logic, so we're inheriting from it
+from evennia.web.api.views import ObjectDBViewSet
+
+# and we need this to filter our character view
+from evennia.objects.objects import DefaultCharacter
+
+# our own custom view
+class CharacterViewSet(ObjectDBViewSet):
+    """
+    A customized Character view that adds an inventory detail
+    """
+    queryset = DefaultCharacter.objects.all_family()
+
+
+
+
+

Setting up the urls

+

Now that we have a viewset of our own, we can create our own urls module and change the characters endpoint path to point to ours.

+ +

The API routing is more complicated than the website or webclient routing, so you need to copy the entire module from evennia into your game instead of patching on changes. Copy the file from evennia/web/api/urls.py to your folder, mygame/web/api/urls.py and open it in your editor.

+

Import your new views module, then find and update the characters path to use your own viewset.

+
# mygame/web/api/urls.py
+
+from django.urls import path
+from django.views.generic import TemplateView
+from rest_framework.schemas import get_schema_view
+
+from evennia.web.api.root import APIRootRouter
+from evennia.web.api import views
+
+from . import views as my_views # <--- NEW
+
+app_name = "api"
+
+router = APIRootRouter()
+router.trailing_slash = "/?"
+router.register(r"accounts", views.AccountDBViewSet, basename="account")
+router.register(r"objects", views.ObjectDBViewSet, basename="object")
+router.register(r"characters", my_views.CharacterViewSet, basename="character") # <--- MODIFIED
+router.register(r"exits", views.ExitViewSet, basename="exit")
+router.register(r"rooms", views.RoomViewSet, basename="room")
+router.register(r"scripts", views.ScriptDBViewSet, basename="script")
+router.register(r"helpentries", views.HelpViewSet, basename="helpentry")
+
+urlpatterns = router.urls
+
+urlpatterns += [
+    # openapi schema
+    path(
+        "openapi",
+        get_schema_view(title="Evennia API", description="Evennia OpenAPI Schema", version="1.0"),
+        name="openapi",
+    ),
+    # redoc auto-doc (based on openapi schema)
+    path(
+        "redoc/",
+        TemplateView.as_view(
+            template_name="rest_framework/redoc.html", extra_context={"schema_url": "api:openapi"}
+        ),
+        name="redoc",
+    ),
+]
+
+
+

We’ve almost got it pointing at our new view now. The last step is to add your own API urls - web.api.urls - to your web root url module. Otherwise it will continue pointing to the default API router and we’ll never see our changes.

+

Open mygame/web/urls.py in your editor and add a new path for “api/”, pointing to web.api.urls. The final file should look something like this:

+
# mygame/web/urls.py
+
+from django.urls import path, include
+
+# default evennia patterns
+from evennia.web.urls import urlpatterns as evennia_default_urlpatterns
+
+# add patterns
+urlpatterns = [
+    # website
+    path("", include("web.website.urls")),
+    # webclient
+    path("webclient/", include("web.webclient.urls")),
+    # web admin
+    path("admin/", include("web.admin.urls")),
+        
+    # the new API path
+    path("api/", include("web.api.urls")),
+]
+
+# 'urlpatterns' must be named such for django to find it.
+urlpatterns = urlpatterns + evennia_default_urlpatterns
+
+
+

Restart your evennia game - evennia reboot from the command line for a full restart of the game AND portal - and try to get /api/characters/ again. If it works exactly like before, you’re ready to move on to the next step!

+
+
+

Adding a new detail

+

Head back over to your character view class - it’s time to start adding our inventory.

+

The usual “page” in a REST API is called an endpoint and is what you typically access. e.g. /api/characters/ is the “characters” endpoint, and /api/characters/:id is the endpoint for individual characters.

+ +

However, an endpoint can also have one or more detail views, which function like a sub-point. We’ll be adding inventory as a detail to our character endpoint, which will look like /api/characters/:id/inventory

+

With the django REST framework, adding a new detail is as simple as adding a decorated method to the view set class - the @action decorator. Since checking your inventory is just data retrieval, we’ll only want to permit the GET method, and we’re adding this action as an API detail, so our decorator will look like this:

+
@action(detail=True, methods=["get"])
+
+
+
+

There are situations where you might want a detail or endpoint that isn’t just data retrieval: for example, buy or sell on an auction-house listing. In those cases, you would use put or post instead. For further reading on what you can do with @action and ViewSets, visit the django REST framework documentation

+
+

When adding a function as a detail action, the name of our function will be the same as the detail. Since we want an inventory action we’ll define an inventory function.

+
"""
+mygame/web/api/views.py
+
+Customized views for the REST API
+"""
+# we'll need these from django's rest framework to make our view work
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from rest_framework import status
+
+# this implements all the basic Evennia Object endpoint logic, so we're inheriting from it
+from evennia.web.api.views import ObjectDBViewSet
+
+# and we need this to filter our character view
+from evennia.objects.objects import DefaultCharacter
+
+# our own custom view
+class CharacterViewSet(ObjectDBViewSet):
+    """
+    A customized Character view that adds an inventory detail
+    """
+    queryset = DefaultCharacter.objects.all_family()
+
+    # !! NEW
+    @action(detail=True, methods=["get"])
+    def inventory(self, request, pk=None):
+        return Response("your inventory", status=status.HTTP_200_OK )
+
+
+

Get your character’s ID - it’s the same as your dbref but without the # - and then evennia reboot again. Now you should be able to call your new characters action: /api/characters/1/inventory (assuming you’re looking at character #1) and it’ll return the string “your inventory”

+
+
+

Creating a Serializer

+

A simple string isn’t very useful, though. What we want is the character’s actual inventory - and for that, we need to set up our own serializer.

+ +

Generally speaking, a serializer turns a set of data into a specially formatted string that can be sent in a data stream - usually JSON. Django REST serializers are special classes and functions which take python objects and convert them into API-ready formats. So, just like for the viewset, django and evennia have done a lot of the heavy lifting for us already.

+

Instead of writing our own serializer, we’ll inherit from evennia’s pre-existing serializers and extend them for our own purpose. To do that, create a new file mygame/web/api/serializers.py and start by adding in the imports you’ll need.

+
# the base serializing library for the framework
+from rest_framework import serializers
+
+# the handy classes Evennia already prepared for us
+from evennia.web.api.serializers import TypeclassSerializerMixin, SimpleObjectDBSerializer
+
+# and the DefaultObject typeclass, for the necessary db model information
+from evennia.objects.objects import DefaultObject
+
+
+

Next, we’ll be defining our own serializer class. Since it’s for retrieving inventory data, we’ll name it appropriately.

+
class InventorySerializer(TypeclassSerializerMixin, serializers.ModelSerializer):
+    """
+    Serializing an inventory
+    """
+    
+    # these define the groups of items
+    worn = serializers.SerializerMethodField()
+    carried = serializers.SerializerMethodField()
+    
+    class Meta:
+        model = DefaultObject
+        fields = [
+            "id", # required field
+            # add these to match the properties you defined
+            "worn",
+            "carried",
+        ]
+        read_only_fields = ["id"]
+
+
+

The Meta class defines which fields will be used in the final serialized string. The id field is from the base ModelSerializer, but you’ll notice that the two others - worn and carried - are defined as properties to SerializerMethodField. That tells the framework to look for matching method names in the form get_X when serializing.

+

Which is why our next step is to add those methods! We defined the properties worn and carried, so the methods we’ll add are get_worn and get_carried. They’ll be static methods - that is, they don’t include self - since they don’t need to reference the serializer class itself.

+
    # these methods filter the character's contents based on the `worn` attribute
+    def get_worn(character):
+        """
+        Serializes only worn objects in the target's inventory.
+        """
+        worn = [obj for obj in character.contents if obj.db.worn]
+        return SimpleObjectDBSerializer(worn, many=True).data
+    
+    def get_carried(character):
+        """
+        Serializes only non-worn objects in the target's inventory.
+        """
+        carried = [obj for obj in character.contents if not obj.db.worn]
+        return SimpleObjectDBSerializer(carried, many=True).data
+
+
+

For this guide, we’re assuming that whether an object is being worn or not is stored in the worn db attribute and filtering based on that attribute. This can easily be done differently to match your own game’s mechanics: filtering based on a tag, calling a custom method on your character that returns the right list, etc.

+

If you want to add in more details - grouping carried items by typing, or dividing up armor vs weapons, you’d just need to add or change the properties, fields, and methods.

+
+

Remember: worn = serializers.SerializerMethodField() is how the API knows to use get_worn, and Meta.fields is the list of fields that will actually make it into the final JSON.

+
+

Your final file should look like this:

+
# mygame/web/api/serializers.py
+
+# the base serializing library for the framework
+from rest_framework import serializers
+
+# the handy classes Evennia already prepared for us
+from evennia.web.api.serializers import TypeclassSerializerMixin, SimpleObjectDBSerializer
+
+# and the DefaultObject typeclass, for the necessary db model information
+from evennia.objects.objects import DefaultObject
+
+class InventorySerializer(TypeclassSerializerMixin, serializers.ModelSerializer):
+    """
+    Serializing an inventory
+    """
+    
+    # these define the groups of items
+    worn = serializers.SerializerMethodField()
+    carried = serializers.SerializerMethodField()
+    
+    class Meta:
+        model = DefaultObject
+        fields = [
+            "id", # required field
+            # add these to match the properties you defined
+            "worn",
+            "carried",
+        ]
+        read_only_fields = ["id"]
+
+    # these methods filter the character's contents based on the `worn` attribute
+    def get_worn(character):
+        """
+        Serializes only worn objects in the target's inventory.
+        """
+        worn = [obj for obj in character.contents if obj.db.worn]
+        return SimpleObjectDBSerializer(worn, many=True).data
+    
+    def get_carried(character):
+        """
+        Serializes only non-worn objects in the target's inventory.
+        """
+        carried = [obj for obj in character.contents if not obj.db.worn]
+        return SimpleObjectDBSerializer(carried, many=True).data
+
+
+
+
+

Using your serializer

+

Now let’s go back to our views file, mygame/web/api/views.py. Add our new serializer with the rest of the imports:

+
from .serializers import InventorySerializer
+
+
+

Then, update our inventory detail to use our serializer.

+
    @action(detail=True, methods=["get"])
+    def inventory(self, request, pk=None):
+        obj = self.get_object()
+        return Response( InventorySerializer(obj).data, status=status.HTTP_200_OK )
+
+
+

Your views file should now look like this:

+
"""
+mygame/web/api/views.py
+
+Customized views for the REST API
+"""
+# we'll need these from django's rest framework to make our view work
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from rest_framework import status
+
+# this implements all the basic Evennia Object endpoint logic, so we're inheriting from it
+from evennia.web.api.views import ObjectDBViewSet
+
+# and we need this to filter our character view
+from evennia.objects.objects import DefaultCharacter
+
+from .serializers import InventorySerializer # <--- NEW
+
+# our own custom view
+class CharacterViewSet(ObjectDBViewSet):
+    """
+    A customized Character view that adds an inventory detail
+    """
+    queryset = DefaultCharacter.objects.all_family()
+
+    @action(detail=True, methods=["get"])
+    def inventory(self, request, pk=None):
+        return Response( InventorySerializer(obj).data, status=status.HTTP_200_OK ) # <--- MODIFIED
+
+
+

That’ll use our new serializer to get our character’s inventory. Except… not quite.

+

Go ahead and try it: evennia reboot and then /api/characters/1/inventory like before. Instead of returning the string “your inventory”, you should get an error saying you don’t have permission. Don’t worry - that means it’s successfully referencing the new serializer. We just haven’t given it permission to access the objects yet.

+
+
+

Customizing API permissions

+

Evennia comes with its own custom API permissions class, connecting the API permissions to the in-game permission hierarchy and locks system. Since we’re trying to access the object’s data now, we need to pass the has_object_permission check as well as the general permission check - and that default permission class hardcodes actions into the object permission checks.

+

Since we’ve added a new action - inventory - to our characters endpoint, we need to use our own custom permissions on our characters endpoint as well. Create one more module file: mygame/web/api/permissions.py

+

Like with the previous classes, we’ll be inheriting from the original and extending it to take advantage of all the work Evennia already does for us.

+
# mygame/web/api/permissions.py
+
+from evennia.web.api.permissions import EvenniaPermission
+
+class CharacterPermission(EvenniaPermission):
+    
+    def has_object_permission(self, request, view, obj):
+        """
+        Checks object-level permissions after has_permission
+        """
+        # our new permission check
+        if view.action == "inventory":
+            return self.check_locks(obj, request.user, self.view_locks)
+
+        # if it's not an inventory action, run through all the default checks
+        return super().has_object_permission(request, view, obj)
+
+
+

That’s the whole permission class! For our final step, we need to use it in our characters view by importing it and setting the permission_classes property.

+

Once you’ve done that, your final views.py should look like this:

+
"""
+mygame/web/api/views.py
+
+Customized views for the REST API
+"""
+# we'll need these from django's rest framework to make our view work
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from rest_framework import status
+
+# this implements all the basic Evennia Object endpoint logic, so we're inheriting from it
+from evennia.web.api.views import ObjectDBViewSet
+
+# and we need this to filter our character view
+from evennia.objects.objects import DefaultCharacter
+
+from .serializers import InventorySerializer
+from .permissions import CharacterPermission # <--- NEW
+
+# our own custom view
+class CharacterViewSet(ObjectDBViewSet):
+    """
+    A customized Character view that adds an inventory detail
+    """
+    permission_classes = [CharacterPermission] # <--- NEW
+    queryset = DefaultCharacter.objects.all_family()
+
+    @action(detail=True, methods=["get"])
+    def inventory(self, request, pk=None):
+        obj = self.get_object()
+        return Response( InventorySerializer(obj).data, status=status.HTTP_200_OK )
+
+
+

One last evennia reboot - now you should be able to get /api/characters/1/inventory and see everything your character has, neatly divided into “worn” and “carried”.

+
+
+

Next Steps

+ +

That’s it! You’ve learned how to customize your own REST endpoint for Evennia, add new endpoint details, and serialize data from your game’s objects for the REST API. With those tools, you can take any in-game data you want and make it available - or even modifiable - with the API.

+

If you want a challenge, try taking what you learned and implementing a new desc detail that will let you GET the existing character desc or PUT a new desc. (Tip: check out how evennia’s REST permissions module works, and the set_attribute methods in the default evennia REST API views.)

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Web-Help-System-Tutorial.html b/docs/latest/Howtos/Web-Help-System-Tutorial.html new file mode 100644 index 0000000000..8f340e6306 --- /dev/null +++ b/docs/latest/Howtos/Web-Help-System-Tutorial.html @@ -0,0 +1,538 @@ + + + + + + + + + Web Help System Tutorial — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Web Help System Tutorial

+

Before doing this tutorial you will probably want to read the intro in the Changing the Web page tutorial. Reading the three first parts of the Django tutorial might help as well.

+

This tutorial will show you how to access the help system through your website. Both help commands and regular help entries will be visible, depending on the logged-in user or an anonymous character.

+

This tutorial will show you how to:

+
    +
  • Create a new page to add to your website.

  • +
  • Take advantage of a basic view and basic templates.

  • +
  • Access the help system on your website.

  • +
  • Identify whether the viewer of this page is logged-in and, if so, to what account.

  • +
+
+

Creating our app

+

The first step is to create our new Django app. An app in Django can contain pages and mechanisms: your website may contain different apps. Actually, the website provided out-of-the-box by Evennia has already three apps: a “webclient” app, to handle the entire webclient, a “website” app to contain your basic pages, and a third app provided by Django to create a simple admin interface. So we’ll create another app in parallel, giving it a clear name to represent our help system.

+

From your game directory, use the following commands:

+
cd web
+evennia startapp help_system
+
+
+

This creates a new folder help_system in your mygame/ folder. To keep things +tidy, let’s move it to the web/ folder:

+
mv help_system web  (linux)
+move help_system web  (windows)
+
+
+
+

Note: calling the app “help” would have been more explicit, but this name is already used by Django.

+
+

We put the new app under web/t o keep all web-related things together, but you can organize however you like. Here’s how the structure looks:

+
mygame/
+    ...
+    web/
+        help_system/
+        ...
+
+
+

The “web/help_system” directory contains files created by Django. We’ll use some of them, but if you want to learn more about them all, you should read the Django tutorial.

+

There is a last thing to be done: your folder has been added, but Django doesn’t know about it, it doesn’t know it’s a new app. We need to tell it, and we do so by editing a simple setting. Open your “server/conf/settings.py” file and add, or edit, these lines:

+
# Web configuration
+INSTALLED_APPS += (
+        "web.help_system",
+)
+
+
+

You can start Evennia if you want, and go to your website, probably at http://localhost:4001 . You won’t see anything different though: we added the app but it’s fairly empty.

+
+
+

Our new page

+

At this point, our new app contains mostly empty files that you can explore. In order to create a page for our help system, we need to add:

+
    +
  • A view, dealing with the logic of our page.

  • +
  • A template to display our new page.

  • +
  • A new URL pointing to our page.

  • +
+
+

We could get away by creating just a view and a new URL, but that’s not a recommended way to work with your website. Building on templates is so much more convenient.

+
+
+

Create a view

+

A view in Django is a simple Python function placed in the “views.py” file in your app. It will +handle the behavior that is triggered when a user asks for this information by entering a URL (the connection between views and URLs will be discussed later).

+

So let’s create our view. You can open the “web/help_system/views.py” file and paste the following lines:

+
from django.shortcuts import render
+
+def index(request):
+    """The 'index' view."""
+    return render(request, "help_system/index.html")
+
+
+

Our view handles all code logic. This time, there’s not much: when this function is called, it will render the template we will now create. But that’s where we will do most of our work afterward.

+
+
+

Create a template

+

The render function called into our view asks the template help_system/index.html. The templates of our apps are stored in the app directory, “templates” sub-directory. Django may have created the “templates” folder already. If not, create it yourself. In it, create another folder “help_system”, and inside of this folder, create a file named “index.html”. Wow, that’s some hierarchy. Your directory structure (starting from web) should look like this:

+
web/
+    help_system/
+        ...
+        templates/
+            help_system/
+                index.html
+
+
+

Open the “index.html” file and paste in the following lines:

+
{% extends "base.html" %}
+{% block titleblock %}Help index{% endblock %}
+{% block content %}
+<h2>Help index</h2>
+{% endblock %}
+
+
+

Here’s a little explanation line by line of what this template does:

+
    +
  1. It loads the “base.html” template. This describes the basic structure of all your pages, with a menu at the top and a footer, and perhaps other information like images and things to be present on each page. You can create templates that do not inherit from “base.html”, but you should have a good reason for doing so.

  2. +
  3. The “base.html” template defines all the structure of the page. What is left is to override some sections of our pages. These sections are called blocks. On line 2, we override the block named “blocktitle”, which contains the title of our page.

  4. +
  5. Same thing here, we override the block named “content”, which contains the main content of our web page. This block is bigger, so we define it on several lines.

  6. +
  7. This is perfectly normal HTML code to display a level-2 heading.

  8. +
  9. And finally we close the block named “content”.

  10. +
+
+
+

Create a new URL

+

Last step to add our page: we need to add a URL leading to it… otherwise users won’t be able to access it. The URLs of our apps are stored in the app’s directory “urls.py” file.

+

Open the web/help_system/urls.py file (you might have to create it) and make it look like this:

+
# URL patterns for the help_system app
+
+from django.urls import path
+from .views import index
+
+urlpatterns = [
+    path('', index)
+]
+
+
+

The urlpatterns variable is what Django/Evennia looks for to figure out how to direct a user entering an URL in their browser to the view-code you have written.

+

Last we need to tie this into the main namespace for your game. Edit the file mygame/web/urls.py. In it you will find the urlpatterns list again. Add a new path to the end of the list.

+
# mygame/web/urls.py
+# [...]
+
+# add patterns
+urlpatterns = [
+    # website
+    path("", include("web.website.urls")),
+    # webclient
+    path("webclient/", include("web.webclient.urls")),
+    # web admin
+    path("admin/", include("web.admin.urls")),
+
+    # my help system
+    path('help/', include('web.help_system.urls'))   # <--- NEW
+]
+
+# [...]
+
+
+

When a user will ask for a specific URL on your site, Django will:

+
    +
  1. Read the list of custom patterns defined in “web/urls.py”. There’s one pattern here, which describes to Django that all URLs beginning by ‘help/’ should be sent to the ‘help_system’ app. The ‘help/’ part is removed.

  2. +
  3. Then Django will check the “web.help_system/urls.py” file. It contains only one URL, which is empty (^$).

  4. +
+

In other words, if the URL is ‘/help/’, then Django will execute our defined view.

+
+
+

Let’s see it work

+

You can now reload or start Evennia. Open a tab in your browser and go to http://localhost:4001/help/ . If everything goes well, you should see your new page… which isn’t empty since Evennia uses our “base.html” template. In the content of our page, there’s only a heading that reads “help index”. Notice that the title of our page is “mygame - Help index” (“mygame” is replaced by the name of your game).

+

From now on, it will be easier to move forward and add features.

+
+
+

A brief reminder

+

We’ll be trying the following things:

+
    +
  • Have the help of commands and help entries accessed online.

  • +
  • Have various commands and help entries depending on whether the user is logged in or not.

  • +
+

In terms of pages, we’ll have:

+
    +
  • One to display the list of help topics.

  • +
  • One to display the content of a help topic.

  • +
+

The first one would link to the second.

+
+

Should we create two URLs?

+
+

The answer is… maybe. It depends on what you want to do. We have our help index accessible through the “/help/” URL. We could have the detail of a help entry accessible through “/help/desc” (to see the detail of the “desc” command). The problem is that our commands or help topics may contain special characters that aren’t to be present in URLs. There are different ways around this problem. I have decided to use a GET variable here, which would create URLs like this:

+
/help?name=desc
+
+
+

If you use this system, you don’t have to add a new URL: GET and POST variables are accessible through our requests and we’ll see how soon enough.

+
+
+
+

Handling logged-in users

+

One of our requirements is to have a help system tailored to our accounts. If an account with admin access logs in, the page should display a lot of commands that aren’t accessible to common users. And perhaps even some additional help topics.

+

Fortunately, it’s fairly easy to get the logged in account in our view (remember that we’ll do most of our coding there). The request object, passed to our function, contains a user attribute. This attribute will always be there: we cannot test whether it’s None or not, for instance. But when the request comes from a user that isn’t logged in, the user attribute will contain an anonymous Django user. We then can use the is_anonymous method to see whether the user is logged-in or not. Last gift by Evennia, if the user is logged in, request.user contains a reference to an account object, which will help us a lot in coupling the game and online system.

+

So we might end up with something like:

+
def index(request):
+    """The 'index' view."""
+    user = request.user
+    if not user.is_anonymous() and user.character:
+        character = user.character
+
+
+
+

Note: this code works when your MULTISESSION_MODE is set to 0 or 1. When it’s above, you would have something like:

+
+
def index(request):
+    """The 'index' view."""
+    user = request.user
+    if not user.is_anonymous() and user.characters:
+        character = user.characters[0]
+
+
+

In this second case, it will select the first character of the account.

+

But what if the user’s not logged in? Again, we have different solutions. One of the most simple is to create a character that will behave as our default character for the help system. You can create it through your game: connect to it and enter:

+
@charcreate anonymous
+
+
+

The system should answer:

+
    Created new character anonymous. Use @ic anonymous to enter the game as this character.
+
+
+

So in our view, we could have something like this:

+
from typeclasses.characters import Character
+
+def index(request):
+    """The 'index' view."""
+    user = request.user
+    if not user.is_anonymous() and user.character:
+        character = user.character
+    else:
+        character = Character.objects.get(db_key="anonymous")
+
+
+

This time, we have a valid character no matter what: remember to adapt this code if you’re running in multisession mode above 1.

+
+
+

The full system

+

What we’re going to do is to browse through all commands and help entries, and list all the commands that can be seen by this character (either our ‘anonymous’ character, or our logged-in character).

+

The code is longer, but it presents the entire concept in our view. Edit the “web/help_system/views.py” file and paste into it:

+
from django.http import Http404
+from django.shortcuts import render
+from evennia.help.models import HelpEntry
+
+from typeclasses.characters import Character
+
+def index(request):
+    """The 'index' view."""
+    user = request.user
+    if not user.is_anonymous() and user.character:
+        character = user.character
+    else:
+        character = Character.objects.get(db_key="anonymous")
+
+    # Get the categories and topics accessible to this character
+    categories, topics = _get_topics(character)
+
+    # If we have the 'name' in our GET variable
+    topic = request.GET.get("name")
+    if topic:
+        if topic not in topics:
+            raise Http404("This help topic doesn't exist.")
+
+        topic = topics[topic]
+        context = {
+                "character": character,
+                "topic": topic,
+        }
+        return render(request, "help_system/detail.html", context)
+    else:
+        context = {
+                "character": character,
+                "categories": categories,
+        }
+        return render(request, "help_system/index.html", context)
+
+def _get_topics(character):
+    """Return the categories and topics for this character."""
+    cmdset = character.cmdset.all()[0]
+    commands = cmdset.commands
+    entries = [entry for entry in HelpEntry.objects.all()]
+    categories = {}
+    topics = {}
+
+    # Browse commands
+    for command in commands:
+        if not command.auto_help or not command.access(character):
+            continue
+
+        # Create the template for a command
+        template = {
+                "name": command.key,
+                "category": command.help_category,
+                "content": command.get_help(character, cmdset),
+        }
+
+        category = command.help_category
+        if category not in categories:
+            categories[category] = []
+        categories[category].append(template)
+        topics[command.key] = template
+
+    # Browse through the help entries
+    for entry in entries:
+        if not entry.access(character, 'view', default=True):
+            continue
+
+        # Create the template for an entry
+        template = {
+                "name": entry.key,
+                "category": entry.help_category,
+                "content": entry.entrytext,
+        }
+
+        category = entry.help_category
+        if category not in categories:
+            categories[category] = []
+        categories[category].append(template)
+        topics[entry.key] = template
+
+    # Sort categories
+    for entries in categories.values():
+        entries.sort(key=lambda c: c["name"])
+
+    categories = list(sorted(categories.items()))
+    return categories, topics
+
+
+

That’s a bit more complicated here, but all in all, it can be divided in small chunks:

+
    +
  • The index function is our view:

    +
      +
    • It begins by getting the character as we saw in the previous section.

    • +
    • It gets the help topics (commands and help entries) accessible to this character. It’s another function that handles that part.

    • +
    • If there’s a GET variable “name” in our URL (like “/help?name=drop”), it will retrieve it. If it’s not a valid topic’s name, it returns a 404. Otherwise, it renders the template called “detail.html”, to display the detail of our topic.

    • +
    • If there’s no GET variable “name”, render “index.html”, to display the list of topics.

    • +
    +
  • +
  • The _get_topics is a private function. Its sole mission is to retrieve the commands a character can execute, and the help entries this same character can see. This code is more Evennia-specific than Django-specific, it will not be detailed in this tutorial. Just notice that all help topics are stored in a dictionary. This is to simplify our job when displaying them in our templates.

  • +
+

Notice that, in both cases when we asked to render a template, we passed to render a third argument which is the dictionary of variables used in our templates. We can pass variables this way, and we will use them in our templates.

+
+

The index template

+

Let’s look at our full “index” template. You can open the “web/help_system/templates/help_sstem/index.html” file and paste the following into it:

+
{% extends "base.html" %}
+{% block titleblock %}Help index{% endblock %}
+{% block content %}
+<h2>Help index</h2>
+{% if categories %}
+    {% for category, topics in categories %}
+        <h2>{{ category|capfirst }}</h2>
+        <table>
+        <tr>
+        {% for topic in topics %}
+            {% if forloop.counter|divisibleby:"5" %}
+                </tr>
+                <tr>
+            {% endif %}
+            <td><a href="{% url 'help_system:index' %}?name={{ topic.name|urlencode }}">
+            {{ topic.name }}</td>
+        {% endfor %}
+        </tr>
+        </table>
+    {% endfor %}
+{% endif %}
+{% endblock %}
+
+
+

This template is definitely more detailed. What it does is:

+
    +
  1. Browse through all categories.

  2. +
  3. For all categories, display a level-2 heading with the name of the category.

  4. +
  5. All topics in a category (remember, they can be either commands or help entries) are displayed in a table. The trickier part may be that, when the loop is above 5, it will create a new line. The table will have 5 columns at the most per row.

  6. +
  7. For every cell in the table, we create a link redirecting to the detail page (see below). The URL would look something like “help?name=say”. We use urlencode to ensure special characters are properly escaped.

  8. +
+
+
+

The detail template

+

It’s now time to show the detail of a topic (command or help entry). You can create the file “web/help_system/templates/help_system/detail.html”. You can paste into it the following code:

+
{% extends "base.html" %}
+{% block titleblock %}Help for {{ topic.name }}{% endblock %}
+{% block content %}
+<h2>{{ topic.name|capfirst }} help topic</h2>
+<p>Category: {{ topic.category|capfirst }}</p>
+{{ topic.content|linebreaks }}
+{% endblock %}
+
+
+

This template is much easier to read. Some filters might be unknown to you, but they are just used to format here.

+
+
+

Put it all together

+

Remember to reload or start Evennia, and then go to http://localhost:4001/help. You should see the list of commands and topics accessible by all characters. Try to login (click the “login” link in the menu of your website) and go to the same page again. You should now see a more detailed list of commands and help entries. Click on one to see its detail.

+
+
+
+

To improve this feature

+

As always, a tutorial is here to help you feel comfortable adding new features and code by yourself. Here are some ideas of things to improve this little feature:

+
    +
  • Links at the bottom of the detail template to go back to the index might be useful.

  • +
  • A link in the main menu to link to this page would be great… for the time being you have to enter the URL, users won’t guess it’s there.

  • +
  • Colors aren’t handled at this point, which isn’t exactly surprising. You could add it though.

  • +
  • Linking help entries between one another won’t be simple, but it would be great. For instance, if you see a help entry about how to use several commands, it would be great if these commands were themselves links to display their details.

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Howtos/Web-Tweeting-Game-Stats.html b/docs/latest/Howtos/Web-Tweeting-Game-Stats.html new file mode 100644 index 0000000000..fa70510f04 --- /dev/null +++ b/docs/latest/Howtos/Web-Tweeting-Game-Stats.html @@ -0,0 +1,212 @@ + + + + + + + + + Automatically Tweet game stats — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Automatically Tweet game stats

+

This tutorial will create a simple script that will send a tweet to your already configured twitter account. Please see: How to connect Evennia to Twitter if you haven’t already done so.

+

The script could be expanded to cover a variety of statistics you might wish to tweet about +regularly, from player deaths to how much currency is in the economy etc.

+
# evennia/typeclasses/tweet_stats.py
+
+import twitter
+from random import randint
+from django.conf import settings
+from evennia import ObjectDB
+from evennia.prototypes import prototypes
+from evennia import logger
+from evennia import DefaultScript
+
+class TweetStats(DefaultScript):
+    """
+    This implements the tweeting of stats to a registered twitter account
+    """
+
+    # standard Script hooks 
+
+    def at_script_creation(self):
+        "Called when script is first created"
+
+        self.key = "tweet_stats"
+        self.desc = "Tweets interesting stats about the game"
+        self.interval = 86400  # 1 day timeout
+        self.start_delay = False
+        
+    def at_repeat(self):
+        """
+        This is called every self.interval seconds to 
+        tweet interesting stats about the game.
+        """
+        
+        api = twitter.Api(consumer_key='consumer_key',
+          consumer_secret='consumer_secret',
+          access_token_key='access_token_key',
+          access_token_secret='access_token_secret')
+        
+        # Game Chars, Rooms, Objects taken from `stats` command
+        nobjs = ObjectDB.objects.count()
+        base_char_typeclass = settings.BASE_CHARACTER_TYPECLASS
+        nchars = (              
+            ObjectDB.objects
+           .filter(db_typeclass_path=base_char_typeclass)
+           .count()
+        )
+        nrooms =(
+            ObjectDB.objects
+            .filter(db_location__isnull=True)
+            .exclude(db_typeclass_path=base_char_typeclass)
+            .count()
+        )
+        nexits = (
+            ObjectDB.objects
+            .filter(db_location__isnull=False,
+                    db_destination__isnull=False)
+            .count()
+        )
+        nother = nobjs - nchars - nrooms - nexits
+        tweet = f"Chars: {ncars}, Rooms: {nrooms}, Objects: {nother}"
+
+        # post the tweet 
+        try:
+            response = api.PostUpdate(tweet)
+        except:
+            logger.log_trace(f"Tweet Error: When attempting to tweet {tweet}")
+
+
+

In the at_script_creation method, we configure the script to fire immediately (useful for testing) +and setup the delay (1 day) as well as script information seen when you use @scripts

+

In the at_repeat method (which is called immediately and then at interval seconds later) we setup +the Twitter API (just like in the initial configuration of twitter). We then show the number of Player Characters, Rooms and Other/Objects.

+

The Scripts docs will show you how to add it as a Global script, however, for testing +it may be useful to start/stop it quickly from within the game. Assuming that you create the file +as mygame/typeclasses/tweet_stats.py it can be started by using the following command

+
script Here = tweet_stats.TweetStats
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Licensing.html b/docs/latest/Licensing.html new file mode 100644 index 0000000000..9c15230957 --- /dev/null +++ b/docs/latest/Licensing.html @@ -0,0 +1,148 @@ + + + + + + + + + Licensing Q&A — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Licensing Q&A

+

Evennia is licensed under the very friendly BSD (3-clause) license. You can find the license as LICENSE.txt in the Evennia repository’s root. It’s not long!

+

Q: When creating a game using Evennia, what does the license permit me to do with it?

+

A: It’s your own game world to do with as you please! Keep it to yourself or re-distribute it +under another license of your choice - or sell it and become filthy rich for all we care.

+

Q: I have modified the Evennia library itself, what does the license say about that?

+

A: Our license allows you to do whatever you want with your modified Evennia, including +re-distributing or selling it, as long as you include our license and copyright info found in +LICENSE.txt along with your distribution.

+

… Of course, if you fix bugs or add some new snazzy feature we softly nudge you to make those +changes available so they can be added to the core Evennia package for everyone’s benefit. The +license doesn’t require you to do it, but that doesn’t mean we won’t still greatly appreciate it +if you do!

+

Q: Can I re-distribute the Evennia server package along with my custom game implementation?

+

A: Sure. As long as the text in LICENSE.txt is included.

+

Q: What about Contributions?

+

The contributions in evennia/evennia/contrib are considered to be released under the same license +as Evennia itself, unless the individual contributor has specifically defined otherwise.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Links.html b/docs/latest/Links.html new file mode 100644 index 0000000000..2b50ea5fef --- /dev/null +++ b/docs/latest/Links.html @@ -0,0 +1,306 @@ + + + + + + + + + Links — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ + + + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Channels-to-Discord.html b/docs/latest/Setup/Channels-to-Discord.html new file mode 100644 index 0000000000..ba27e424df --- /dev/null +++ b/docs/latest/Setup/Channels-to-Discord.html @@ -0,0 +1,302 @@ + + + + + + + + + Connect Evennia channels to Discord — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Connect Evennia channels to Discord

+

Discord is a popular chat service, especially for game +communities. If you have a discord server for your game, you can connect it +to your in-game channels to communicate between in-game and out.

+
+

Configuring Discord

+

The first thing you’ll need is to set up a Discord bot to connect to your game. +Go to the bot applications page and make a new application. You’ll need the +“MESSAGE CONTENT” toggle flipped On, and to add your bot token to your settings.

+
# mygame/server/conf/secret_settings.py
+DISCORD_BOT_TOKEN = '<your Discord bot token>'
+
+
+

You will also need the pyopenssl module, if it isn’t already installed. +Install it into your Evennia python environment with

+
pip install pyopenssl
+
+
+

Lastly, enable Discord in your settings

+
DISCORD_ENABLED = True
+
+
+

Start/reload Evennia and log in as a privileged user. You should now have a new +command available: discord2chan. Enter help discord2chan for an explanation +of its options.

+

Adding a new channel link is done with the following command:

+
 discord2chan <evennia_channel> = <discord_channel_id>
+
+
+

The evennia_channel argument must be the name of an existing Evennia channel, +and discord_channel_id is the full numeric ID of the Discord channel.

+
+

Your bot needs to be added to the correct Discord server with access to the +channel in order to send or receive messages. This command does NOT verify that +your bot has Discord permissions!

+
+
+
+

Step-By-Step Discord Setup

+

This section will walk through the entire process of setting up a Discord +connection to your Evennia game, step by step. If you’ve completed any of the +steps already, feel free to skip to the next.

+
+

Creating a Discord Bot Application

+
+

You will need an active Discord account and admin access to a Discord server +in order to connect Evennia to it. This assumes you already do.

+
+

Make sure you’re logged in on the Discord website, then visit +https://discord.com/developers/applications. Click the “New Application” +button in the upper right corner, then enter the name for your new app - the +name of your Evennia game is a good option.

+

You’ll next be brought to the settings page for the new application. Click “Bot” +on the sidebar menu, then “Build-a-Bot” to create your bot account.

+

Save the displayed token! This will be the ONLY time that Discord will allow +you to see that token - if you lose it, you will have to reset it. This token is +how your bot confirms its identity, so it’s very important.

+

Next, add this token to your secret settings.

+
# file: mygame/server/conf/secret_settings.py
+
+DISCORD_BOT_TOKEN = '<token>'
+
+
+

Once that is saved, scroll down the Bot page a little more and find the toggle for +“Message Content Intent”. You’ll need this to be toggled to ON, or you bot won’t +be able to read anyone’s messages.

+

Finally, you can add any additional settings to your new bot account: a display image, +display nickname, bio, etc. You can come back and change these at any time, so +don’t worry about it too much now.

+
+
+

Adding your bot to your server

+

While still in your new application, click “OAuth2” on the side menu, then “URL +Generator”. On this page, you’ll generate an invite URL for your app, then visit +that URL to add it to your server.

+

In the top box, find the checkbox for bot and check it: this will make a second +permissions box appear. In that box, you’ll want to check off at least the +following boxes:

+
    +
  • Read Messages/View Channels (in “General Permissions”)

  • +
  • Send Messages (in “Text Permissions”)

  • +
+

Lastly, scroll down to the bottom of the page and copy the resulting URL. It should +look something like this:

+
https://discord.com/api/oauth2/authorize?client_id=55555555555555555&permissions=3072&scope=bot
+
+
+

Visit that link, select the server for your Evennia connection, and confirm.

+

After the bot is added to your server, you can fine-tune the permissions further +through the usual Discord server administration.

+
+
+

Activating Discord in Evennia

+

You’ll need to do two additional things with your Evennia game before it can connect +to Discord.

+

First, install pyopenssl to your virtual environment, if you haven’t already.

+
pip install pyopenssl
+
+
+

Second, enable the Discord integration in your settings file.

+
# file: server/conf/settings.py
+DISCORD_ENABLED = True
+
+
+

Start or reload your game to apply the changed settings, then log in as an account +with at least Developer permissions and initialize the bot account on Evennia with +the discord2chan command. You should receive a message that the bot was created, and +that there are no active connections to Discord.

+
+
+

Connecting an Evennia channel to a Discord channel

+

You will need the name of your Evennia channel, and the channel ID for your Discord +channel. The channel ID is the last part of the URL when you visit a channel.

+

e.g. if the url is https://discord.com/channels/55555555555555555/12345678901234567890 +then your channel ID is 12345678901234567890

+

Link the two channels with the following command:

+
discord2chan <evennia channel> = <discord channel id>
+
+
+

The two channels should now relay to each other. Confirm this works by posting a +message on the evennia channel, and another on the Discord channel - they should +both show up on the other end.

+
+

If you don’t see any messages coming to or from Discord, make sure that your bot +has permission to read and send messages and that your application has the +“Message Content Intents” flag set.

+
+
+
+

Further Customization

+

The help file for discord2chan has more information on how to use the command to +customize your relayed messages.

+

For anything more complex, however, you can create your own child class of +DiscordBot and add it to your settings.

+
# file: mygame/server/conf/settings.py
+# EXAMPLE
+DISCORD_BOT_CLASS = 'accounts.bots.DiscordBot'
+
+
+
+

If you had already set up a Discord relay and are changing this, make sure you +either delete the old bot account in Evennia or change its typeclass or it won’t +take effect.

+
+

The core DiscordBot account class has several useful hooks already set up for +processing and relaying channel messages between Discord and Evennia channels, +along with the (unused by default) direct_msg hook for processing DMs sent to +the bot on Discord.

+

Only messages and server updates are processed by default, but the Discord custom +protocol passes all other unprocessed dispatch data on to the Evennia bot account +so you can add additional handling yourself. However, this integration is not a full library +and does not document the full range of possible Discord events.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Channels-to-Grapevine.html b/docs/latest/Setup/Channels-to-Grapevine.html new file mode 100644 index 0000000000..d1db8aa266 --- /dev/null +++ b/docs/latest/Setup/Channels-to-Grapevine.html @@ -0,0 +1,201 @@ + + + + + + + + + Connect Evennia channels to Grapevine — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Connect Evennia channels to Grapevine

+

Grapevine is a new chat network for MU**** games. By +connecting an in-game channel to the grapevine network, players on your game +can chat with players in other games, also non-Evennia ones.

+
+

Configuring Grapevine

+

To use Grapevine, you first need the pyopenssl module. Install it into your +Evennia python environment with

+
pip install pyopenssl
+
+
+

To configure Grapevine, you’ll need to activate it in your settings file.

+
    GRAPEVINE_ENABLED = True
+
+
+

Next, register an account at https://grapevine.haus. When you have logged in, +go to your Settings/Profile and to the Games sub menu. Here you register your +new game by filling in its information. At the end of registration you are going +to get a Client ID and a Client Secret. These should not be shared.

+

Open/create the file mygame/server/conf/secret_settings.py and add the following:

+
  GRAPEVINE_CLIENT_ID = "<client ID>"
+  GRAPEVINE_CLIENT_SECRET = "<client_secret>"
+
+
+

You can also customize the Grapevine channels you are allowed to connect to. This +is added to the GRAPEVINE_CHANNELS setting. You can see which channels are available +by going to the Grapevine online chat here: https://grapevine.haus/chat.

+

Start/reload Evennia and log in as a privileged user. You should now have a new +command available: @grapevine2chan. This command is called like this:

+
 @grapevine2chan[/switches] <evennia_channel> = <grapevine_channel>
+
+
+

Here, the evennia_channel must be the name of an existing Evennia channel and +grapevine_channel one of the supported channels in GRAPEVINE_CHANNELS.

+
+

At the time of writing, the Grapevine network only has two channels: +testing and gossip. Evennia defaults to allowing connecting to both. Use +testing for trying your connection.

+
+
+
+

Setting up Grapevine, step by step

+

You can connect Grapevine to any Evennia channel (so you could connect it to +the default public channel if you like), but for testing, let’s set up a +new channel gw.

+
 @ccreate gw = This is connected to an gw channel!
+
+
+

You will automatically join the new channel.

+

Next we will create a connection to the Grapevine network.

+
 @grapevine2chan gw = gossip
+
+
+

Evennia will now create a new connection and connect it to Grapevine. Connect +to https://grapevine.haus/chat to check.

+

Write something in the Evennia channel gw and check so a message appears in +the Grapevine chat. Write a reply in the chat and the grapevine bot should echo +it to your channel in-game.

+

Your Evennia gamers can now chat with users on external Grapevine channels!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Channels-to-IRC.html b/docs/latest/Setup/Channels-to-IRC.html new file mode 100644 index 0000000000..40d58f139b --- /dev/null +++ b/docs/latest/Setup/Channels-to-IRC.html @@ -0,0 +1,217 @@ + + + + + + + + + Connect Evennia channels to IRC — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Connect Evennia channels to IRC

+

IRC (Internet Relay Chat) is a long standing +chat protocol used by many open-source projects for communicating in real time. By connecting one of +Evennia’s Channels to an IRC channel you can communicate also with people not on +an mud themselves. You can also use IRC if you are only running your Evennia MUD locally on your +computer (your game doesn’t need to be open to the public)! All you need is an internet connection. +For IRC operation you also need twisted.words. +This is available simply as a package python-twisted-words in many Linux distros, or directly +downloadable from the link.

+
+

Configuring IRC

+

To configure IRC, you’ll need to activate it in your settings file.

+
    IRC_ENABLED = True
+
+
+

Start Evennia and log in as a privileged user. You should now have a new command available: +@irc2chan. This command is called like this:

+
 @irc2chan[/switches] <evennia_channel> = <ircnetwork> <port> <#irchannel> <botname>
+
+
+

If you already know how IRC works, this should be pretty self-evident to use. Read the help entry +for more features.

+
+
+

Setting up IRC, step by step

+

You can connect IRC to any Evennia channel (so you could connect it to the default public channel +if you like), but for testing, let’s set up a new channel irc.

+
 @ccreate irc = This is connected to an irc channel!
+
+
+

You will automatically join the new channel.

+

Next we will create a connection to an external IRC network and channel. There are many, many IRC +nets. Here is a list of some of the biggest +ones, the one you choose is not really very important unless you want to connect to a particular +channel (also make sure that the network allows for “bots” to connect).

+

For testing, we choose the Freenode network, irc.freenode.net. We will connect to a test +channel, let’s call it #myevennia-test (an IRC channel always begins with #). It’s best if you +pick an obscure channel name that didn’t exist previously - if it didn’t exist it will be created +for you.

+
+

Don’t connect to #evennia for testing and debugging, that is Evennia’s official chat channel! +You are welcome to connect your game to #evennia once you have everything working though - it +can be a good way to get help and ideas. But if you do, please do so with an in-game channel open +only to your game admins and developers).

+
+

The port needed depends on the network. For Freenode this is 6667.

+

What will happen is that your Evennia server will connect to this IRC channel as a normal user. This +“user” (or “bot”) needs a name, which you must also supply. Let’s call it “mud-bot”.

+

To test that the bot connects correctly you also want to log onto this channel with a separate, +third-party IRC client. There are hundreds of such clients available. If you use Firefox, the +Chatzilla plugin is good and easy. Freenode also offers its own web-based chat page. Once you +have connected to a network, the command to join is usually /join #channelname (don’t forget the +#).

+

Next we connect Evennia with the IRC channel.

+
 @irc2chan irc = irc.freenode.net 6667 #myevennia-test mud-bot
+
+
+

Evennia will now create a new IRC bot mud-bot and connect it to the IRC network and the channel +#myevennia. If you are connected to the IRC channel you will soon see the user mud-bot connect.

+

Write something in the Evennia channel irc.

+
 irc Hello, World!
+[irc] Anna: Hello, World!
+
+
+

If you are viewing your IRC channel with a separate IRC client you should see your text appearing +there, spoken by the bot:

+
mud-bot> [irc] Anna: Hello, World!
+
+
+

Write Hello! in your IRC client window and it will appear in your normal channel, marked with the +name of the IRC channel you used (#evennia here).

+
[irc] Anna@#myevennia-test: Hello!
+
+
+

Your Evennia gamers can now chat with users on external IRC channels!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Channels-to-RSS.html b/docs/latest/Setup/Channels-to-RSS.html new file mode 100644 index 0000000000..e89bb2eb19 --- /dev/null +++ b/docs/latest/Setup/Channels-to-RSS.html @@ -0,0 +1,180 @@ + + + + + + + + + Connect Evennia channels to RSS — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Connect Evennia channels to RSS

+

RSS is a format for easily tracking updates on websites. The +principle is simple - whenever a site is updated, a small text file is updated. An RSS reader can +then regularly go online, check this file for updates and let the user know what’s new.

+

Evennia allows for connecting any number of RSS feeds to any number of in-game channels. Updates to the feed will be conveniently echoed to the channel. There are many potential uses for this: For example the MUD might use a separate website to host its forums. Through RSS, the players can then be notified when new posts are made. Another example is to let everyone know you updated your dev blog. Admins might also want to track the latest Evennia updates through our own RSS feed here.

+
+

Configuring RSS

+

To use RSS, you first need to install the feedparser python +module.

+
pip install feedparser
+
+
+

Next you activate RSS support in your config file by settting RSS_ENABLED=True.

+

Start/reload Evennia as a privileged user. You should now have a new command available, @rss2chan:

+
 @rss2chan <evennia_channel> = <rss_url>
+
+
+
+
+

Setting up RSS, step by step

+

You can connect RSS to any Evennia channel, but for testing, let’s set up a new channel “rss”.

+
 @ccreate rss = RSS feeds are echoed to this channel!
+
+
+

Let’s connect Evennia’s code-update feed to this channel. The RSS url for evennia updates is +https://github.com/evennia/evennia/commits/main.atom, so let’s add that:

+
 @rss2chan rss = https://github.com/evennia/evennia/commits/main.atom
+
+
+

That’s it, really. New Evennia updates will now show up as a one-line title and link in the channel. +Give the @rss2chan command on its own to show all connections. To remove a feed from a channel, +you specify the connection again (use the command to see it in the list) but add the /delete +switch:

+
 @rss2chan/delete rss = https://github.com/evennia/evennia/commits/main.atom
+
+
+

You can connect any number of RSS feeds to a channel this way. You could also connect them to the +same channels as Channels-to-IRC to have the feed echo to external chat channels as well.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Channels-to-Twitter.html b/docs/latest/Setup/Channels-to-Twitter.html new file mode 100644 index 0000000000..67c93cad2c --- /dev/null +++ b/docs/latest/Setup/Channels-to-Twitter.html @@ -0,0 +1,233 @@ + + + + + + + + + Connect Evennia to Twitter — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Connect Evennia to Twitter

+

Twitter is an online social networking service that enables users to send and read short messages called “tweets”. Following is a short tutorial explaining how to enable users to send tweets from inside Evennia.

+
+

Configuring Twitter

+

You must first have a Twitter account. Log in and register an App at the Twitter Dev Site. Make sure you enable access to “write” tweets!

+

To tweet from Evennia you will need both the “API Token” and the “API secret” strings as well as the “Access Token” and “Access Secret” strings.

+

Twitter changed their requirements to require a Mobile number on the Twitter account to register new apps with write access. If you’re unable to do this, please see this Dev post which describes how to get around it.

+

To use Twitter you must install the Twitter Python module:

+
pip install python-twitter
+
+
+
+
+

Setting up Twitter, step by step

+
+

A basic tweet command

+

Evennia doesn’t have a tweet command out of the box so you need to write your own little Command in order to tweet. If you are unsure about how commands work and how to add them, it can be an idea to go through the Adding a Command Tutorial before continuing.

+

You can create the command in a separate command module (something like mygame/commands/tweet.py) or together with your other custom commands, as you prefer. +This is how it can look:

+
# in mygame/commands.tweet.py, for example
+
+import twitter
+from evennia import Command
+
+# here you insert your unique App tokens
+# from the Twitter dev site
+TWITTER_API = twitter.Api(consumer_key='api_key',
+                          consumer_secret='api_secret',
+                          access_token_key='access_token_key',
+                          access_token_secret='access_token_secret')
+
+class CmdTweet(Command):
+    """
+    Tweet a message
+
+    Usage: 
+      tweet <message>
+
+    This will send a Twitter tweet to a pre-configured Twitter account.
+    A tweet has a maximum length of 280 characters. 
+    """
+
+    key = "tweet"
+    locks = "cmd:pperm(tweet) or pperm(Developers)"
+    help_category = "Comms"
+
+    def func(self):
+        "This performs the tweet"
+ 
+        caller = self.caller
+        tweet = self.args
+
+        if not tweet:
+            caller.msg("Usage: tweet <message>")      
+            return
+ 
+        tlen = len(tweet)
+        if tlen > 280:
+            caller.msg(f"Your tweet was {tlen} chars long (max 280).")
+            return
+
+        # post the tweet        
+        TWITTER_API.PostUpdate(tweet)
+
+        caller.msg(f"You tweeted:\n{tweet}")
+
+
+

Be sure to substitute your own actual API/Access keys and secrets in the appropriate places.

+

We default to limiting tweet access to players with Developers-level access or to those players that have the permission “tweet”

+

To allow allow individual characters to tweet, set the tweet permission with

+
perm/player playername = tweet
+
+
+

You may change the lock as you feel is appropriate. Change the overall permission to Players if you want everyone to be able to tweet.

+

Now add this command to your default command set (e.g in mygame/commands/defalt_cmdsets.py) and reload the server. From now on those with access can simply use tweet <message> to see the tweet posted from the game’s Twitter account.

+
+
+

Next Steps

+

This shows only a basic tweet setup, other things to do could be:

+
    +
  • Auto-Adding the character name to the tweet

  • +
  • More error-checking of postings

  • +
  • Changing locks to make tweeting open to more people

  • +
  • Echo your tweets to an in-game channel

  • +
+

Rather than using an explicit command you can set up a Script to send automatic tweets, for example to post updated game stats. See the Tweeting Game Stats tutorial for help.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Choosing-a-Database.html b/docs/latest/Setup/Choosing-a-Database.html new file mode 100644 index 0000000000..1139fe6158 --- /dev/null +++ b/docs/latest/Setup/Choosing-a-Database.html @@ -0,0 +1,398 @@ + + + + + + + + + Choosing a database — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Choosing a database

+

This page gives an overview of the supported SQL databases as well as instructions on install:

+
    +
  • SQLite3 (default)

  • +
  • PostgreSQL

  • +
  • MySQL / MariaDB

  • +
+

Since Evennia uses Django, most of our notes are based off of what we know from the community and their documentation. While the information below may be useful, you can always find the most up-to-date and “correct” information at Django’s Notes about supported Databases page.

+
+

SQLite3 (default)

+

SQLite3 is a light weight single-file database. It is our default database and Evennia will set this up for you automatically if you give no other options.

+

SQLite stores the database in a single file (mygame/server/evennia.db3). This means it’s very easy to reset this database - just delete (or move) that evennia.db3 file and run evennia migrate again! No server process is needed and the administrative overhead and resource consumption is tiny. It is also very fast since it’s run in-memory. For the vast majority of Evennia installs it will probably be all that’s ever needed.

+

SQLite will generally be much faster than MySQL/PostgreSQL but its performance comes with two drawbacks:

+
    +
  • SQLite ignores length constraints by design; it is possible to store very large strings and numbers in fields that technically should not accept them. This is not something you will notice; your game will read and write them and function normally, but this can create some data migration problems requiring careful thought if you do need to change databases later.

  • +
  • SQLite can scale well to storage of millions of objects, but if you end up with a thundering herd of users trying to access your MUD and web site at the same time, or you find yourself writing long- running functions to update large numbers of objects on a live game, either will yield errors and interference. SQLite does not work reliably with multiple concurrent threads or processes accessing its records. This has to do with file-locking clashes of the database file. So for a production server making heavy use of process- or thread pools, a proper database is a more appropriate choice.

  • +
+
+

Install of SQlite3

+

This is installed and configured as part of Evennia. The database file is created as mygame/server/evennia.db3 when you run

+
evennia migrate
+
+
+

without changing any database options. An optional requirement is the sqlite3 client program - this is required if you want to inspect the database data manually. A shortcut for using it with the evennia database is evennia dbshell. Linux users should look for the sqlite3 package for their distro while Mac/Windows should get the sqlite-tools package from this page.

+

To inspect the default Evennia database (once it’s been created), go to your game dir and do

+
    sqlite3 server/evennia.db3
+    # or
+    evennia dbshell
+
+
+

This will bring you into the sqlite command line. Use .help for instructions and .quit to exit. +See here for a cheat-sheet of commands.

+
+
+

Resetting SQLite3

+

If you want to reset your SQLite3 database, see here.

+
+
+
+

PostgreSQL

+

PostgreSQL is an open-source database engine, recommended by Django. While not as fast as SQLite for normal usage, it will scale better than SQLite, especially if your game has an very large database and/or extensive web presence through a separate server process.

+
+

Install and initial setup of PostgreSQL

+

First, install the posgresql server. Version 9.6 is tested with Evennia. Packages are readily available for all distributions. You need to also get the psql client (this is called postgresql- client on debian-derived systems). Windows/Mac users can find what they need on the postgresql download page. You should be setting up a password for your database-superuser (always called postgres) when you install.

+

For interaction with Evennia you need to also install psycopg2 to your Evennia install +(pip install psycopg2-binary in your virtualenv). This acts as the python bridge to the database server.

+

Next, start the postgres client:

+
    psql -U postgres --password
+
+
+
+

Warning

+

With the --password argument, Postgres should prompt you for a password. If it won’t, replace that with -p yourpassword instead. Do not use the -p argument unless you have to since the resulting command, and your password, will be logged in the shell history.

+
+

This will open a console to the postgres service using the psql client.

+

On the psql command line:

+
CREATE USER evennia WITH PASSWORD 'somepassword';
+CREATE DATABASE evennia;
+
+-- Postgres-specific optimizations
+-- https://docs.djangoproject.com/en/dev/ref/databases/#optimizing-postgresql-s-configuration
+ALTER ROLE evennia SET client_encoding TO 'utf8';
+ALTER ROLE evennia SET default_transaction_isolation TO 'read committed';
+ALTER ROLE evennia SET timezone TO 'UTC';
+
+GRANT ALL PRIVILEGES ON DATABASE evennia TO evennia;
+-- For Postgres 10+
+ALTER DATABASE evennia owner to evennia;
+
+-- Other useful commands:
+--  \l       (list all databases and permissions)
+--  \q       (exit)
+
+
+

Here is a cheat-sheet for psql commands.

+

We create a database user ‘evennia’ and a new database named evennia (you can call them whatever you want though). We then grant the ‘evennia’ user full privileges to the new database so it can read/write etc to it. If you in the future wanted to completely wipe the database, an easy way to do is to log in as the postgres superuser again, then do DROP DATABASE evennia;, then CREATE and GRANT steps above again to recreate the database and grant privileges.

+
+
+

Evennia PostgreSQL configuration

+

Edit `mygame/server/conf/secret_settings.py and add the following section:

+
#
+# PostgreSQL Database Configuration
+#
+DATABASES = {
+        'default': {
+            'ENGINE': 'django.db.backends.postgresql_psycopg2',
+            'NAME': 'evennia',
+            'USER': 'evennia',
+            'PASSWORD': 'somepassword',
+            'HOST': 'localhost',
+            'PORT': ''    # use default
+        }}
+
+
+

If you used some other name for the database and user, enter those instead. Run

+
evennia migrate
+
+
+

to populate your database. Should you ever want to inspect the database directly you can from now on also use

+
evennia dbshell
+
+
+

as a shortcut to get into the postgres command line for the right database and user.

+

With the database setup you should now be able to start start Evennia normally with your new database.

+
+
+

Resetting PostgreSQL

+

If you want to reset your PostgreSQL datbase, see here

+
+
+

Advanced PostgreSQL Usage (Remote Server)

+
+

Warning

+

The example below is for a server within a private network that is not open to +the Internet. Be sure to understand the details before making any changes to +an Internet-accessible server.

+
+

The above discussion is for hosting a local server. In certain configurations it may make sense host the database on a server remote to the one Evennia is running on. One example case is where code development may be done on multiple machines by multiple users. In this configuration, a local data base (such as SQLite3) is not feasible since all the machines and developers do not have access to the file.

+

Choose a remote machine to host the database and PostgreSQl server. Follow the instructions above on that server to set up the database. Depending on distribution, PostgreSQL will only accept connections on the local machine (localhost). In order to enable remote access, two files need to be changed.

+

First, determine which cluster is running your database. Use pg_lscluster:

+
$ pg_lsclusters
+Ver Cluster Port Status Owner    Data directory              Log file
+12  main    5432 online postgres /var/lib/postgresql/12/main /var/log/postgresql/postgresql-12-main.log
+
+
+

Next, edit the database’s postgresql.conf. This is found on Ubuntu systems in /etc/postgresql/<ver>/<cluster>, where <ver> and <cluster> are what are reported in the pg_lscluster output. So, for the above example, the file is /etc/postgresql/12/main/postgresql.conf.

+

In this file, look for the line with listen_addresses. For example:

+
listen_address = 'localhost'    # What IP address(es) to listen on;
+                                # comma-separated list of addresses;
+                                # defaults to 'localhost'; use '*' for all
+
+
+
+

Warning

+

Misconfiguring the wrong cluster may cause problems +with existing clusters.

+
+

Also, note the line with port = and keep the port number in mind.

+

Set listen_addresses to '*'. This permits postgresql to accept connections +on any interface.

+
+

Warning

+

Setting listen_addresses to '*' opens a port on all interfaces. If your +server has access to the Internet, ensure your firewall is configured +appropriately to limit access to this port as necessary. (You may also list +explicit addresses and subnets to listen. See the postgresql documentation +for more details.)

+
+

Finally, modify the pg_hba.conf (in the same directory as postgresql.conf). Look for a line with:

+
# IPv4 local connections:
+host    all             all             127.0.0.1/32            md5
+
+
+

Add a line with:

+
host    all             all             0.0.0.0/0               md5
+
+
+
+

Warning

+

This permits incoming connections from all IPs. See +the PosgreSQL documentation on how to limit this.

+
+

Now, restart your cluster:

+
$ pg_ctlcluster 12 main restart
+
+
+

Finally, update the database settings in your Evennia secret_settings.py (as described above modifying SERVER and PORT to match your server.

+

Now your Evennia installation should be able to connect and talk with a remote server.

+
+
+
+

MySQL / MariaDB

+

MySQL is a commonly used proprietary database system, on par with PostgreSQL. There is an open-source alternative called MariaDB that mimics all functionality and command syntax of the former. So this section covers both.

+
+

Installing and initial setup of MySQL/MariaDB

+

First, install and setup MariaDB or MySQL for your specific server. Linux users should look for the mysql-server or mariadb-server packages for their respective distributions. Windows/Mac users will find what they need from the MySQL downloads or MariaDB downloads pages. You also need the respective database clients (mysql, mariadb-client), so you can setup the database itself. When you install the server you should usually be asked to set up the database root user and password.

+

Finally, you will also need a Python interface to allow Evennia to talk to the database. Django recommends the mysqlclient one. Install this into the evennia virtualenv with pip install mysqlclient.

+

Start the database client (this is named the same for both mysql and mariadb):

+
mysql -u root -p
+
+
+

You should get to enter your database root password (set this up when you installed the database server).

+

Inside the database client interface:

+
CREATE USER 'evennia'@'localhost' IDENTIFIED BY 'somepassword';
+CREATE DATABASE evennia;
+ALTER DATABASE `evennia` CHARACTER SET utf8; -- note that it's `evennia` with back-ticks, not
+quotes!
+GRANT ALL PRIVILEGES ON evennia.* TO 'evennia'@'localhost';
+FLUSH PRIVILEGES;
+-- use 'exit' to quit client
+
+
+

Here is a mysql command cheat sheet.

+

Above we created a new local user and database (we called both ‘evennia’ here, you can name them what you prefer). We set the character set to utf8 to avoid an issue with prefix character length that can pop up on some installs otherwise. Next we grant the ‘evennia’ user all privileges on the evennia database and make sure the privileges are applied. Exiting the client brings us back to the normal terminal/console.

+
+

If you are not using MySQL for anything else you might consider granting the ‘evennia’ user full privileges with GRANT ALL PRIVILEGES ON *.* TO 'evennia'@'localhost';. If you do, it means you can use evennia dbshell later to connect to mysql, drop your database and re-create it as a way of easy reset. Without this extra privilege you will be able to drop the database but not re create it without first switching to the database-root user.

+
+
+
+

Add MySQL/MariaDB configuration to Evennia

+

To tell Evennia to use your new database you need to edit mygame/server/conf/settings.py (or secret_settings.py if you don’t want your db info passed around on git repositories).

+
+

The Django documentation suggests using an external db.cnf or other external conf- formatted file. Evennia users have however found that this leads to problems (see e.g. issue #1184). To avoid trouble we recommend you simply put the configuration in your settings as below.

+
+
    #
+    # MySQL Database Configuration
+    #
+    DATABASES = {
+       'default': {
+           'ENGINE': 'django.db.backends.mysql',
+           'NAME': 'evennia',
+           'USER': 'evennia',
+           'PASSWORD': 'somepassword',
+           'HOST': 'localhost',  # or an IP Address that your DB is hosted on
+           'PORT': '', # use default port
+       }
+    }
+
+
+

The mysql backend is used by MariaDB as well.

+

Change this to fit your database setup. Next, run:

+
evennia migrate
+
+
+

to populate your database. Should you ever want to inspect the database directly you can from now on also use

+
evennia dbshell
+
+
+

as a shortcut to get into the postgres command line for the right database and user.

+

With the database setup you should now be able to start start Evennia normally with your new database.

+
+
+

Resetting MySQL/MariaDB

+

If you want to reset your MySQL/MariaDB datbase, see here.

+
+
+
+

Other databases

+

No testing has been performed with Oracle, but it is also supported through Django. There are community maintained drivers for MS SQL and possibly a few others. If you try other databases out, consider contributing to this page with instructions.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Client-Support-Grid.html b/docs/latest/Setup/Client-Support-Grid.html new file mode 100644 index 0000000000..f0d838101f --- /dev/null +++ b/docs/latest/Setup/Client-Support-Grid.html @@ -0,0 +1,300 @@ + + + + + + + + + Client Support Grid — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Client Support Grid

+

This grid tries to gather info about different MU clients when used with Evennia. +If you want to report a problem, update an entry or add a client, make a +new documentation issue for it. Everyone’s encouraged to report their findings.

+
+

Client Grid

+

Legend:

+
    +
  • Name: The name of the client. Also note if it’s OS-specific.

  • +
  • Version: Which version or range of client versions were tested.

  • +
  • Comments: Any quirks on using this client with Evennia should be added here.

  • +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Name

Version tested

Comments

Evennia Webclient

1.0+

Evennia-specific

tintin++

2.0+

No MXP support

tinyfugue

5.0+

No UTF-8 support

MUSHclient (Win)

4.94

NAWS reports full text area

Zmud (Win)

7.21

UNTESTED

Cmud (Win)

v3

UNTESTED

Potato

2.0.0b16

No MXP, MCCP support. Win 32bit does not understand

“localhost”, must use 127.0.0.1.

Mudlet

3.4+

No known issues. Some older versions showed <> as html

under MXP.

SimpleMU (Win)

full

Discontinued. NAWS reports pixel size.

Atlantis (Mac)

0.9.9.4

No known issues.

GMUD

0.0.1

Can’t handle any telnet handshakes. Not recommended.

BeipMU (Win)

3.0.255

No MXP support. Best to enable “MUD prompt handling”, disable

“Handle HTML tags”.

MudRammer (IOS)

1.8.7

Bad Telnet Protocol compliance: displays spurious characters.

MUDMaster

1.3.1

UNTESTED

BlowTorch (Andr)

1.1.3

Telnet NOP displays as spurious character.

Mukluk (Andr)

2015.11.20

Telnet NOP displays as spurious character. Has UTF-8/Emoji

support.

Gnome-MUD (Unix)

0.11.2

Telnet handshake errors. First (only) attempt at logging in

fails.

Spyrit

0.4

No MXP, OOB support.

JamochaMUD

5.2

Does not support ANSI within MXP text.

DuckClient (Chrome)

4.2

No MXP support. Displays Telnet Go-Ahead and

WILL SUPPRESS-GO-AHEAD as ù character. Also seems to run

the version command on connection, which will not work in

MULTISESSION_MODES above 1.

KildClient

2.11.1

No known issues.

+
+
+

Workarounds for client issues:

+
+

Issue: Telnet NOP displays as spurious character.

+

Known clients:

+
    +
  • BlowTorch (Andr)

  • +
  • Mukluk (Andr)

  • +
+

Workaround:

+
    +
  • In-game: Use @option NOPKEEPALIVE=off for the session, or use the /save +parameter to disable it for that Evennia account permanently.

  • +
  • Client-side: Set a gag-type trigger on the NOP character to make it invisible to the client.

  • +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Config-Apache-Proxy.html b/docs/latest/Setup/Config-Apache-Proxy.html new file mode 100644 index 0000000000..b6b1acabda --- /dev/null +++ b/docs/latest/Setup/Config-Apache-Proxy.html @@ -0,0 +1,321 @@ + + + + + + + + + Configuring an Apache Proxy — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Configuring an Apache Proxy

+

Evennia has its own webserver. This should usually not be replaced. But another reason for wanting to use an external webserver like Apache would be to act as a proxy in front of the Evennia webserver. Getting this working with TLS (encryption) requires some extra work covered at the end of this page.

+
+

Warning

+

Possibly outdated +The Apache instructions below might be outdated. If something is not working right, or you use Evennia with a different server, please let us know.

+
+
+

Running Apache as a proxy in front of Evennia

+

Below are steps to run Evennia using a front-end proxy (Apache HTTP), mod_proxy_http, +mod_proxy_wstunnel, and mod_ssl. mod_proxy_http and mod_proxy_wstunnel will simply be +referred to as mod_proxy below.

+
+

Install mod_ssl

+
    +
  • Fedora/RHEL - Apache HTTP Server and mod_ssl are available in the standard package repositories for Fedora and RHEL:

    +
    $ dnf install httpd mod_ssl
    +or
    +$ yum install httpd mod_ssl
    +
    +
    +
    +
  • +
  • Ubuntu/Debian - Apache HTTP Server and mod_ssljkl are installed together in the apache2 package and available in the standard package repositories for Ubuntu and Debian. mod_ssl needs to be enabled after installation:

    +
    $ apt-get update
    +$ apt-get install apache2 
    +$ a2enmod ssl
    +
    +
    +
    +
  • +
+
+
+

TLS proxy+websocket configuration

+

Below is a sample configuration for Evennia with a TLS-enabled http and websocket proxy.

+
+

Apache HTTP Server Configuration

+
<VirtualHost *:80>
+  # Always redirect to https/443
+  ServerName mud.example.com
+  Redirect / https://mud.example.com
+</VirtualHost>
+
+<VirtualHost *:443>
+  ServerName mud.example.com
+  
+  SSLEngine On
+  
+  # Location of certificate and key
+  SSLCertificateFile /etc/pki/tls/certs/mud.example.com.crt
+  SSLCertificateKeyFile /etc/pki/tls/private/mud.example.com.key
+  
+  # Use a tool https://www.ssllabs.com/ssltest/ to scan your set after setting up.
+  SSLProtocol TLSv1.2
+  SSLCipherSuite HIGH:!eNULL:!NULL:!aNULL
+  
+  # Proxy all websocket traffic to port 4002 in Evennia
+  ProxyPass /ws ws://127.0.0.1:4002/
+  ProxyPassReverse /ws ws://127.0.0.1:4002/
+  
+  # Proxy all HTTP traffic to port 4001 in Evennia
+  ProxyPass / http://127.0.0.1:4001/
+  ProxyPassReverse / http://127.0.0.1:4001/
+  
+  # Configure separate logging for this Evennia proxy
+  ErrorLog logs/evennia_error.log
+  CustomLog logs/evennia_access.log combined
+</VirtualHost>
+
+
+
+
+

Evennia secure websocket configuration

+

There is a slight trick in setting up Evennia so websocket traffic is handled correctly by the +proxy. You must set the WEBSOCKET_CLIENT_URL setting in your mymud/server/conf/settings.py file:

+
WEBSOCKET_CLIENT_URL = "wss://external.example.com/ws"
+
+
+

The setting above is what the client’s browser will actually use. Note the use of wss:// is because our client will be communicating over an encrypted connection (“wss” indicates websocket over SSL/TLS). Also, especially note the additional path /ws at the end of the URL. This is how +Apache HTTP Server identifies that a particular request should be proxied to Evennia’s websocket +port but this should be applicable also to other types of proxies (like nginx).

+
+
+
+
+

Run Apache instead of the Evennia webserver

+
+

Warning

+

This is not supported, nor recommended. +This is covered because it has been asked about. The webclient would not work. It would also run out-of-process, leading to race conditions. This is not directly supported, so if you try this you are on your own.

+
+
+

Install mod_wsgi

+
    +
  • Fedora/RHEL - Apache HTTP Server and mod_wsgi are available in the standard package +repositories for Fedora and RHEL:

    +
    $ dnf install httpd mod_wsgi
    +or
    +$ yum install httpd mod_wsgi
    +
    +
    +
  • +
  • Ubuntu/Debian - Apache HTTP Server and mod_wsgi are available in the standard package +repositories for Ubuntu and Debian:

    +
    $ apt-get update
    +$ apt-get install apache2 libapache2-mod-wsgi
    +
    +
    +
  • +
+
+
+

Copy and modify the VHOST

+

After mod_wsgi is installed, copy the evennia/web/utils/evennia_wsgi_apache.conf file to your +apache2 vhosts/sites folder. On Debian/Ubuntu, this is /etc/apache2/sites-enabled/. Make your +modifications after copying the file there.

+

Read the comments and change the paths to point to the appropriate locations within your setup.

+
+
+

Restart/Reload Apache

+

You’ll then want to reload or restart apache2 after changing the configurations.

+
    +
  • Fedora/RHEL/Ubuntu

    +
    $ systemctl restart httpd
    +
    +
    +
  • +
  • Ubuntu/Debian

    +
    $ systemctl restart apache2
    +
    +
    +
  • +
+

With any luck, you’ll be able to point your browser at your domain or subdomain that you set up in +your vhost and see the nifty default Evennia webpage. If not, read the hopefully informative error +message and work from there. Questions may be directed to our Evennia Community +site.

+
+
+

A note on code reloading

+

If your mod_wsgi is set up to run on daemon mode (as will be the case by default on Debian and +Ubuntu), you may tell mod_wsgi to reload by using the touch command on +evennia/game/web/utils/apache_wsgi.conf. When mod_wsgi sees that the file modification time has +changed, it will force a code reload. Any modifications to the code will not be propagated to the +live instance of your site until reloaded.

+

If you are not running in daemon mode or want to force the issue, simply restart or reload apache2 +to apply your changes.

+
+
+

Further notes and hints:

+

If you get strange (and usually uninformative) Permission denied errors from Apache, make sure +that your evennia directory is located in a place the webserver may actually access. For example, +some Linux distributions may default to very restrictive access permissions on a user’s /home +directory.

+

One user commented that they had to add the following to their Apache config to get things to work. +Not confirmed, but worth trying if there are trouble.

+
<Directory "/home/<yourname>/evennia/game/web">
+                Options +ExecCGI
+                Allow from all
+</Directory>
+
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Config-HAProxy.html b/docs/latest/Setup/Config-HAProxy.html new file mode 100644 index 0000000000..09ef216791 --- /dev/null +++ b/docs/latest/Setup/Config-HAProxy.html @@ -0,0 +1,367 @@ + + + + + + + + + Configuring HAProxy — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Configuring HAProxy

+

A modern public-facing website should these days be served via encrypted +connections. So https: rather than http: for the website and +wss: rather than vs ws: for websocket connections used by webclient.

+

The reason is security - not only does it make sure a user ends up at the right +site (rather than a spoof that hijacked the original’s address), it stops an +evil middleman from snooping on data (like passwords) being sent across the +wire.

+

Evennia itself does not implement https/wss connections. This is something best +handled by dedicated tools able to keep up-to-date with the latest security +practices.

+

So what we’ll do is install proxy between Evennia and the outgoing ports of +your server. Essentially, Evennia will think it’s only running locally (on +localhost, IP 127.0.0.1) while the proxy will transparently map that to the +“real” outgoing ports and handle HTTPS/WSS for us.

+
         Evennia
+            |
+(inside-only local IP/ports serving HTTP/WS)
+            |
+          Proxy
+            |
+(outside-visible public IP/ports serving HTTPS/WSS)
+            |
+         Firewall
+            |
+         Internet
+
+
+

These instructions assume you run a server with Unix/Linux (very common if you +use remote hosting) and that you have root access to that server.

+

The pieces we’ll need:

+
    +
  • HAProxy - an open-source proxy program that is +easy to set up and use.

  • +
  • LetsEncrypt for providing the User +Certificate needed to establish an encrypted connection. In particular we’ll +use the excellent Certbot program, +which automates the whole certificate setup process with LetsEncrypt.

  • +
  • cron - this comes with all Linux/Unix systems and allows to automate tasks +in the OS.

  • +
+

Before starting you also need the following information and setup:

+
    +
  • (optional) The host name of your game. This is +something you must previously have purchased from a domain registrar and set +up with DNS to point to the IP of your server. For the benefit of this +manual, we’ll assume your host name is my.awesomegame.com.

  • +
  • If you don’t have a domain name or haven’t set it up yet, you must at least +know the IP address of your server. Find this with ifconfig or similar from +inside the server. If you use a hosting service like DigitalOcean you can also +find the droplet’s IP address in the control panel. Use this as the host name +everywhere.

  • +
  • You must open port 80 in your firewall. This is used by Certbot below to +auto-renew certificates. So you can’t really run another webserver alongside +this setup without tweaking.

  • +
  • You must open port 443 (HTTPS) in your firewall. This will be the external +webserver port.

  • +
  • Make sure port 4001 (internal webserver port) is not open in your firewall +(it usually will be closed by default unless you explicitly opened it +previously).

  • +
  • Open port 4002 in firewall (we’ll use the same number for both internal- +and external ports, the proxy will only show the safe one serving wss).

  • +
+
+

Getting certificates

+

Certificates guarantee that you are you. Easiest is to get this with +Letsencrypt and the +Certbot program. Certbot has a lot of +install instructions for various operating systems. Here’s for Debian/Ubuntu:

+
sudo apt install certbot
+
+
+

Make sure to stop Evennia and that no port-80 using service is running, then

+
sudo certbot certonly --standalone
+
+
+

You will get some questions you need to answer, such as an email to send +certificate errors to and the host name (or IP, supposedly) to use with this +certificate. After this, the certificates will end up in +/etc/letsencrypt/live/<yourhostname>/*pem (example from Ubuntu). The +critical files for our purposes are fullchain.pem and privkey.pem.

+

Certbot sets up a cron-job/systemd job to regularly renew the certificate. To +check this works, try

+
sudo certbot renew --dry-run
+
+
+
+

The certificate is only valid for 3 months at a time, so make sure this test +works (it requires port 80 to be open). Look up Certbot’s page for more help.

+

We are not quite done. HAProxy expects these two files to be one file. More +specifically we are going to

+
    +
  1. copy privkey.pem and copy it to a new file named <yourhostname>.pem (like +my.awesomegame.com.pem)

  2. +
  3. Append the contents of fullchain.pem to the end of this new file. No empty +lines are needed.

  4. +
+

We could do this by copy&pasting in a text editor, but here’s how to do it with +shell commands (replace the example paths with your own):

+
cd /etc/letsencrypt/live/my.awesomegame.com/
+sudo cp privkey.pem my.awesomegame.com.pem
+sudo cat fullchain.pem >> my.awesomegame.com.pem
+
+
+

The new my.awesomegame.com.pem file (or whatever you named it) is what we will +point to in the HAProxy config below.

+

There is a problem here though - Certbot will (re)generate fullchain.pem for +us automatically a few days before before the 3-month certificate runs out. +But HAProxy will not see this because it is looking at the combined file that +will still have the old fullchain.pem appended to it.

+

We’ll set up an automated task to rebuild the .pem file regularly by +using the cron program of Unix/Linux.

+
crontab -e
+
+
+

An editor will open to the crontab file. Add the following at the bottom (all +on one line, and change the paths to your own!):

+
0 5 * * * cd /etc/letsencrypt/live/my.awesomegame.com/ &&
+    cp privkey.pem my.awesomegame.com.pem &&
+    cat fullchain.pem >> my.awesomegame.com.pem
+
+
+

Save and close the editor. Every night at 05:00 (5 AM), the +my.awesomegame.com.pem will now be rebuilt for you. Since Certbot updates +the fullchain.pem file a few days before the certificate runs out, this should +be enough time to make sure HaProxy never sees an outdated certificate.

+
+
+

Installing and configuring HAProxy

+

Installing HaProxy is usually as simple as:

+
# Debian derivatives (Ubuntu, Mint etc)
+sudo apt install haproxy
+
+# Redhat derivatives (dnf instead of yum for very recent Fedora distros)
+sudo yum install haproxy
+
+
+

Configuration of HAProxy is done in a single file. This can be located wherever +you like, for now put in your game dir and name it haproxy.cfg.

+

Here is an example tested on Centos7 and Ubuntu. Make sure to change the file to +put in your own values.

+

We use the my.awesomegame.com example here and here are the ports

+
    +
  • 443 is the standard SSL port

  • +
  • 4001 is the standard Evennia webserver port (firewall closed!)

  • +
  • 4002 is the default Evennia websocket port (we use the same number for +the outgoing wss port, so this should be open in firewall).

  • +
+
# base stuff to set up haproxy
+global
+    log /dev/log local0
+    chroot /var/lib/haproxy
+    maxconn  4000
+    user  haproxy
+    tune.ssl.default-dh-param 2048
+    ## uncomment this when everything works
+    # daemon
+defaults
+    mode http
+    option forwardfor
+
+# Evennia Specifics
+listen evennia-https-website
+    bind my.awesomegame.com:443 ssl no-sslv3 no-tlsv10 crt /etc/letsencrypt/live/my.awesomegame.com>/my.awesomegame.com.pem
+    server localhost 127.0.0.1:4001
+    timeout client 10m
+    timeout server 10m
+    timeout connect 5m
+
+listen evennia-secure-websocket
+    bind my.awesomegame.com:4002 ssl no-sslv3 no-tlsv10 crt /etc/letsencrypt/live/my.awesomegame.com/my.awesomegame.com.pem
+    server localhost 127.0.0.1:4002
+    timeout client 10m
+    timeout server 10m
+    timeout connect 5m
+
+
+
+
+

Putting it all together

+

Get back to the Evennia game dir and edit mygame/server/conf/settings.py. Add:

+
WEBSERVER_INTERFACES = ['127.0.0.1']
+WEBSOCKET_CLIENT_INTERFACE = '127.0.0.1'
+
+
+

and

+
WEBSOCKET_CLIENT_URL="wss://my.awesomegame.com:4002/"
+
+
+

Make sure to reboot (stop + start) evennia completely:

+
evennia reboot
+
+
+

Finally you start the proxy:

+
sudo haproxy -f /path/to/the/above/haproxy.cfg
+
+
+
+

Make sure you can connect to your game from your browser and that you end up +with an https:// page and can use the websocket webclient.

+

Once everything works you may want to start the proxy automatically and in the +background. Stop the proxy with Ctrl-C and make sure to uncomment the line # daemon in the config file.

+

If you have no other proxies running on your server, you can copy your +haproxy.conf file to the system-wide settings:

+
sudo cp /path/to/the/above/haproxy.cfg /etc/haproxy/
+
+
+

The proxy will now start on reload and you can control it with

+
sudo service haproxy start|stop|restart|status
+
+
+

If you don’t want to copy stuff into /etc/ you can also run the haproxy purely +out of your current location by running it with cron on server restart. Open +the crontab again:

+
sudo crontab -e
+
+
+

Add a new line to the end of the file:

+
@reboot haproxy -f /path/to/the/above/haproxy.cfg
+
+
+

Save the file and haproxy should start up automatically when you reboot the +server. Next just restart the proxy manually a last time - with daemon +uncommented in the config file, it will now start as a background process.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Config-Nginx.html b/docs/latest/Setup/Config-Nginx.html new file mode 100644 index 0000000000..95a4d44e04 --- /dev/null +++ b/docs/latest/Setup/Config-Nginx.html @@ -0,0 +1,240 @@ + + + + + + + + + Configuring NGINX for Evennia with SSL — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Configuring NGINX for Evennia with SSL

+

Nginx is a proxy server; you can put it between Evennia and the outside world to serve your game over encrypted connections. Another alternative is HAProxy.

+
+

This is NOT a full set-up guide! It assumes you know how to get your own Letsencrypt certificates, that you already have nginx installed, and that you are familiar with Nginx configuration files. If you don’t already use nginx, you are probably better off using the guide for using HAProxy instead.

+
+
+

SSL on the website and websocket

+

Both the website and the websocket should be accessed through your normal HTTPS port, so they should be defined together.

+

For nginx, here is an example configuration, using Evennia’s default ports:

+
server {
+	server_name example.com;
+
+	listen [::]:443 ssl;
+	listen 443 ssl;
+	ssl_certificate	 /path/to/your/cert/file;
+	ssl_certificate_key /path/to/your/cert/key;
+
+	location /ws {
+		# The websocket connection
+		proxy_pass http://localhost:4002;
+		proxy_http_version 1.1;
+		# allows the handshake to upgrade the connection
+		proxy_set_header Upgrade $http_upgrade;
+		proxy_set_header Connection "Upgrade";
+		# forwards the connection IP
+		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+		proxy_set_header X-Real-IP $remote_addr;
+		proxy_set_header Host $host;
+	}
+
+	location / {
+		# The main website
+		proxy_pass http://localhost:4001;
+		proxy_http_version 1.1;
+		# forwards the connection IP
+		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+		proxy_set_header X-Real-IP $remote_addr;
+		proxy_set_header Host $http_host;
+		proxy_set_header X-Forwarded-Proto $scheme;
+	}
+}
+
+
+

This proxies the websocket connection through the /ws location, and the root location to the website.

+

For Evennia, here is an example settings configuration that would go with the above nginx configuration, to go in your production server’s server/conf/secret_settings.py

+
+

The secret_settings.py file is not included in git commits and is to be used for secret stuff. Putting your production-only settings in this file allows you to continue using default access points for local development, making your life easier.

+
+
SERVER_HOSTNAME = "example.com"
+# Set the FULL URI for the websocket, including the scheme
+WEBSOCKET_CLIENT_URL = "wss://example.com/ws"
+# Turn off all external connections
+LOCKDOWN_MODE = True
+
+
+

This makes sure that evennia uses the correct URI for websocket connections. Setting LOCKDOWN_MODE on will also prevents any external connections directly to Evennia’s ports, limiting it to connections through the nginx proxies.

+
+
+

Telnet SSL

+
+

This will proxy ALL telnet access through nginx! If you want players to connect directly to Evennia’s telnet ports instead of going through nginx, leave LOCKDOWN_MODE off and use a different SSL implementation, such as activating Evennia’s internal telnet SSL port (see settings.SSL_ENABLED and settings.SSL_PORTS in default settings file).

+
+

If you’ve only used nginx for websites, telnet is slightly more complicated. You need to set up stream parameters in your primary configuration file - e.g. /etc/nginx/nginx.conf - which default installations typically will not include.

+

We chose to parallel the http structure for stream, adding conf files to streams-available and having them symlinked in streams-enabled, the same as other sites.

+
stream {
+	include /etc/nginx/conf.streams.d/*.conf;
+	include /etc/nginx/streams-enabled/*;
+}
+
+
+

Then of course you need to create the required folders in the same location as your other nginx configurations:

+
$ sudo mkdir conf.streams.d streams-available streams-enabled
+
+
+

An example configuration file for the telnet connection - using an arbitrary external port of 4040 - would then be:

+
server {
+	listen [::]:4040 ssl;
+	listen 4040 ssl;
+
+	ssl_certificate  /path/to/your/cert/file;
+	ssl_certificate_key  /path/to/your/cert/key;
+
+	# connect to Evennia's internal NON-SSL telnet port
+	proxy_pass localhost:4000;
+	# forwards the connection IP - requires --with-stream-realip-module
+	set_real_ip_from $realip_remote_addr:$realip_remote_port
+}
+
+
+

Players can now connect with telnet+SSL to your server at example.com:4040 - but not to the internal connection of 4000.

+
+

IMPORTANT: With this configuration, the default front page will be WRONG. You will need to change the index.html template and update the telnet section (NOT the telnet ssl section!) to display the correct information.

+
+
+
+

Don’t Forget!

+

certbot will automatically renew your certificates for you, but nginx won’t see them without reloading. Make sure to set up a monthly cron job to reload your nginx service to avoid service interruptions due to expired certificates.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Evennia-Game-Index.html b/docs/latest/Setup/Evennia-Game-Index.html new file mode 100644 index 0000000000..2bdf13c22e --- /dev/null +++ b/docs/latest/Setup/Evennia-Game-Index.html @@ -0,0 +1,210 @@ + + + + + + + + + Evennia Game Index — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia Game Index

+

The Evennia game index is a list of games built or +being built with Evennia. Anyone is allowed to add their game to the index

+
    +
  • also if you have just started development and don’t yet accept external +players. It’s a chance for us to know you are out there and for you to make us +intrigued about or excited for your upcoming game!

  • +
+

All we ask is that you check so your game-name does not collide with one +already in the list - be nice!

+
+

Connect with the wizard

+

From your game dir, run

+
evennia connections 
+
+
+

This will start the Evennia Connection wizard. From the menu, select to add +your game to the Evennia Game Index. Follow the prompts and don’t forget to +save your new settings in the end. Use quit at any time if you change your +mind.

+
+

The wizard will create a new file mygame/server/conf/connection_settings.py +with the settings you chose. This is imported from the end of your main +settings file and will thus override it. You can edit this new file if you +want, but remember that if you run the wizard again, your changes may get +over-written.

+
+
+
+

Manual Settings

+

If you don’t want to use the wizard (maybe because you already have the client installed from an +earlier version), you can also configure your index entry in your settings file +(mygame/server/conf/settings.py). Add the following:

+
GAME_INDEX_ENABLED = True 
+
+GAME_INDEX_LISTING = {
+    # required 
+    'game_status': 'pre-alpha',            # pre-alpha, alpha, beta, launched
+    'listing_contact': "dummy@dummy.com",  # not publicly shown.
+    'short_description': 'Short blurb',    
+
+    # optional 
+    'long_description':
+        "Longer description that can use Markdown like *bold*, _italic_"
+        "and [linkname](https://link.com). Use \n for line breaks."
+    'telnet_hostname': 'dummy.com',            
+    'telnet_port': '1234',                     
+    'web_client_url': 'dummy.com/webclient',   
+    'game_website': 'dummy.com',              
+    # 'game_name': 'MyGame',  # set only if different than settings.SERVERNAME
+}
+
+
+

Of these, the game_status, short_description and listing_contact are +required. The listing_contact is not publicly visible and is only meant as a +last resort if we need to get in touch with you over any listing issue/bug (so +far this has never happened).

+

If game_name is not set, the settings.SERVERNAME will be used. Use empty strings +('') for optional fields you don’t want to specify at this time.

+
+
+

Non-public games

+

If you don’t specify neither telnet_hostname + port nor +web_client_url, the Game index will list your game as Not yet public. +Non-public games are moved to the bottom of the index since there is no way +for people to try them out. But it’s a good way to show you are out there, even +if you are not ready for players yet.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Installation-Android.html b/docs/latest/Setup/Installation-Android.html new file mode 100644 index 0000000000..1998827e7e --- /dev/null +++ b/docs/latest/Setup/Installation-Android.html @@ -0,0 +1,266 @@ + + + + + + + + + Installing on Android — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Installing on Android

+

This page describes how to install and run the Evennia server on an Android phone. This will involve installing a slew of third-party programs from the Google Play store, so make sure you are okay with this before starting.

+
+

Warning

+

Android installation is experimental and not tested with later versions of Android. +Report your findings.

+
+
+

Install Termux

+

The first thing to do is install a terminal emulator that allows a “full” version of linux to be run. Note that Android is essentially running on top of linux so if you have a rooted phone, you may be able to skip this step. You don’t require a rooted phone to install Evennia though.

+

Assuming we do not have root, we will install Termux. Termux provides a base installation of Linux essentials, including apt and Python, and makes them available under a writeable directory. It also gives us a terminal where we can enter commands. By default, Android doesn’t give you permissions to the root folder, so Termux pretends that its own installation directory is the root directory.

+

Termux will set up a base system for us on first launch, but we will need to install some prerequisites for Evennia. Commands you should run in Termux will look like this:

+
$ cat file.txt
+
+
+

The $ symbol is your prompt - do not include it when running commands.

+
+
+

Prerequisites

+

To install some of the libraries Evennia requires, namely Pillow and Twisted, we have to first +install some packages they depend on. In Termux, run the following

+
$ pkg install -y clang git zlib ndk-sysroot libjpeg-turbo libcrypt python
+
+
+

Termux ships with Python 3, perfect. Python 3 has venv (virtualenv) and pip (Python’s module +installer) built-in.

+

So, let’s set up our virtualenv. This keeps the Python packages we install separate from the system +versions.

+
$ cd
+$ python3 -m venv evenv
+
+
+

This will create a new folder, called evenv, containing the new python executable. +Next, let’s activate our new virtualenv. Every time you want to work on Evennia, you need to run the +following command:

+
$ source evenv/bin/activate
+
+
+

Your prompt will change to look like this:

+
(evenv) $
+
+
+

Update the updaters and installers in the venv: pip, setuptools and wheel.

+
python3 -m pip install --upgrade pip setuptools wheel
+
+
+
+

Installing Evennia

+

Now that we have everything in place, we’re ready to download and install Evennia itself.

+

Mysterious incantations

+
export LDFLAGS="-L/data/data/com.termux/files/usr/lib/"
+export CFLAGS="-I/data/data/com.termux/files/usr/include/"
+
+
+

(these tell clang, the C compiler, where to find the bits for zlib when building Pillow)

+

Install the latest Evennia in a way that lets you edit the source

+
(evenv) $ pip install --upgrade -e 'git+https://github.com/evennia/evennia#egg=evennia'
+
+
+

This step will possibly take quite a while - we are downloading Evennia and are then installing it, +building all of the requirements for Evennia to run. If you run into trouble on this step, please +see Troubleshooting.

+

You can go to the dir where Evennia is installed with cd $VIRTUAL_ENV/src/evennia. git grep (something) can be handy, as can git diff

+
+
+

Final steps

+

At this point, Evennia is installed on your phone! You can now continue with the original +Setup Quickstart instruction, we repeat them here for clarity.

+

To start a new game:

+
(evenv) $ evennia --init mygame
+(evenv) $ ls
+mygame evenv
+
+
+

To start the game for the first time:

+
(evenv) $ cd mygame
+(evenv) $ evennia migrate
+(evenv) $ evennia start
+
+
+

Your game should now be running! Open a web browser at http://localhost:4001 or point a telnet +client to localhost:4000 and log in with the user you created.

+
+
+
+

Running Evennia

+

When you wish to run Evennia, get into your Termux console and make sure you have activated your +virtualenv as well as are in your game’s directory. You can then run evennia start as normal.

+
$ cd ~ && source evenv/bin/activate
+(evenv) $ cd mygame
+(evenv) $ evennia start
+
+
+

You may wish to look at the Linux Instructions for more.

+
+
+

Caveats

+
    +
  • Android’s os module doesn’t support certain functions - in particular getloadavg. Thusly, running +the command @server in-game will throw an exception. So far, there is no fix for this problem.

  • +
  • As you might expect, performance is not amazing.

  • +
  • Android is fairly aggressive about memory handling, and you may find that your server process is +killed if your phone is heavily taxed. Termux seems to keep a notification up to discourage this.

  • +
+
+
+

Troubleshooting

+

As time goes by and errors are reported, this section will be added to.

+

Some steps to try anyway:

+
    +
  • Make sure your packages are up-to-date, try running pkg update && pkg upgrade -y

  • +
  • Make sure you’ve installed the clang package. If not, try pkg install clang -y

  • +
  • Make sure you’re in the right directory. `cd ~/mygame

  • +
  • Make sure you’ve sourced your virtualenv. type cd && source evenv/bin/activate

  • +
  • See if a shell will start: cd ~/mygame ; evennia shell

  • +
  • Look at the log files in ~/mygame/server/logs/

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Installation-Docker.html b/docs/latest/Setup/Installation-Docker.html new file mode 100644 index 0000000000..c0074cfa34 --- /dev/null +++ b/docs/latest/Setup/Installation-Docker.html @@ -0,0 +1,349 @@ + + + + + + + + + Installing with Docker — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Installing with Docker

+

Evennia releases docker images as part of regular commits and releases. This makes running an Evennia-based game in a Docker container easy.

+

First, install the docker program so you can run the Evennia container. You can get it freely from docker.com. Linux users can likely also get it through their normal package manager.

+

To fetch the latest evennia docker image, run:

+
docker pull evennia/evennia
+
+
+

This will get the latest stable image.

+
docker pull evennia/evennia:develop 
+
+
+

gets the image based off Evennia’s unstable develop branch.

+

Next, cd to a place where your game dir is, or where you want to create it. Then run:

+
docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4002:4002 --rm -v $PWD:/usr/src/game --user $UID:$GID evennia/evennia
+
+
+

Having run this (see next section for a description of what’s what), you will be at a prompt inside +the docker container:

+
evennia|docker /usr/src/game $
+
+
+

This is a normal shell prompt. We are in the /usr/src/game location inside the docker container. If you had anything in the folder you started from, you should see it here (with ls) since we mounted the current directory to usr/src/game (with -v above). You have the evennia command available and can now proceed to create a new game as per the normal game setup instructions (no virtualenv needed).

+

You can run Evennia from inside this container if you want to, it’s like you are root in a little +isolated Linux environment. To exit the container and all processes in there, press Ctrl-D. If you +created a new game folder, you will find that it has appeared on-disk.

+
+

The game folder or any new files that you created from inside the container will appear as owned by root. If you want to edit the files outside of the container you should change the ownership. On Linux/Mac you do this with sudo chown myname:myname -R mygame, where you replace myname with your username and mygame with whatever your game folder is named.

+
+

Below is an explanation of the docker run command we used:

+
    +
  • docker run ... evennia/evennia tells us that we want to run a new container based on the evennia/evennia docker image. Everything in between are options for this. The evennia/evennia is the name of our official docker image on the dockerhub repository. If you didn’t do docker pull evennia/evennia first, the image will be downloaded when running this, otherwise your already downloaded version will be used. It contains everything needed to run Evennia.

  • +
  • -it has to do with creating an interactive session inside the container we start.

  • +
  • --rm will make sure to delete the container when it shuts down. This is nice to keep things tidy +on your drive.

  • +
  • -p 4000:4000 -p 4001:4001 -p 4002:4002 means that we map ports 4000, 4001 and 4002 from inside the docker container to same-numbered ports on our host machine. These are ports for telnet, webserver and websockets. This is what allows your Evennia server to be accessed from outside the container (such as by your MUD client)!

  • +
  • -v $PWD:/usr/src/game mounts the current directory (outside the container) to the path /usr/src/game inside the container. This means that when you edit that path in the container you will actually be modifying the “real” place on your hard drive. If you didn’t do this, any changes would only exist inside the container and be gone if we create a new one. Note that in linux a shortcut for the current directory is $PWD. If you don’t have this for your OS, you can replace it with the full path to the current on-disk directory (like C:/Development/evennia/game or wherever you want your evennia files to appear).

  • +
  • --user $UID:$GID ensures the container’s modifications to $PWD are done with you user and group IDs instead of root’s IDs (root is the user running evennia inside the container). This avoids having stale .pid files in your filesystem between container reboots which you have to force delete with sudo rm server/*.pid before each boot.

  • +
+
+

Running your game as a docker image

+

If you run the docker command given in the previous section from your game dir you can then easily start Evennia and have a running server without any further fuss.

+

But apart from ease of install, the primary benefit to running an Evennia-based game in a container is to simplify its deployment into a public production environment. Most cloud-based hosting +providers these days support the ability to run container-based applications. This makes deploying +or updating your game as simple as building a new container image locally, pushing it to your Docker Hub account, and then pulling from Docker Hub into your AWS/Azure/other docker-enabled hosting account. The container eliminates the need to install Python, set up a virtualenv, or run pip to install dependencies.

+
+

Start Evennia and run through docker

+

For remote or automated deployment you may want to start Evennia immediately as soon as the docker container comes up. If you already have a game folder with a database set up you can also start the docker container and pass commands directly to it. The command you pass will be the main process to run in the container. From your game dir, run for example this command:

+
docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4002:4002 --rm -v $PWD:/usr/src/game evennia/evennia evennia start -l
+
+
+

This will start Evennia as the foreground process, echoing the log to the terminal. Closing the +terminal will kill the server. Note that you must use a foreground command like evennia start -l +or evennia ipstart to start the server - otherwise the foreground process will finish immediately +and the container go down.

+
+
+
+

Create your own game image

+

These steps assume that you have created or otherwise obtained a game directory already. First, cd to your game dir and create a new empty text file named Dockerfile. Save the following two lines into it:

+
FROM evennia/evennia:latest
+
+ENTRYPOINT evennia start -l
+
+
+

These are instructions for building a new docker image. This one is based on the official +evennia/evennia image, but also makes sure to start evennia when it runs (so we don’t need to +enter it and run commands).

+

To build the image:

+
    docker build -t mydhaccount/mygame .
+
+
+

(don’t forget the period at the end, it will use the Dockerfile from the current location). Here mydhaccount is the name of your dockerhub account. If you don’t have a dockerhub account you can build the image locally only (name the container whatever you like in that case, like just mygame).

+

Docker images are stored centrally on your computer. You can see which ones you have available locally with docker images. Once built, you have a couple of options to run your game.

+
+

Run container from your game image for development

+

To run the container based on your game image locally for development, mount the local game directory as before:

+
docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4002:4002 -v $PWD:/usr/src/game --user $UID:$GID
+mydhaccount/mygame
+
+
+

Evennia will start and you’ll get output in the terminal, perfect for development. You should be +able to connect to the game with your clients normally.

+
+
+

Deploy game image for production

+

Each time you rebuild the docker image as per the above instructions, the latest copy of your game +directory is actually copied inside the image (at /usr/src/game/). If you don’t mount your on-disk +folder there, the internal one will be used. So for deploying evennia on a server, omit the -v +option and just give the following command:

+
docker run -it --rm -d -p 4000:4000 -p 4001:4001 -p 4002:4002 --user $UID:$GID mydhaccount/mygame
+
+
+

Your game will be downloaded from your docker-hub account and a new container will be built using the image and started on the server! If your server environment forces you to use different ports, you can just map the normal ports differently in the command above.

+

Above we added the -d option, which starts the container in daemon mode - you won’t see any +return in the console. You can see it running with docker ps:

+
$ docker ps
+
+CONTAINER ID     IMAGE       COMMAND                  CREATED              ...
+f6d4ca9b2b22     mygame      "/bin/sh -c 'evenn..."   About a minute ago   ...
+
+
+

Note the container ID, this is how you manage the container as it runs.

+
   docker logs f6d4ca9b2b22      
+
+
+

Looks at the STDOUT output of the container (i.e. the normal server log)

+
   docker logs -f f6d4ca9b2b22   
+
+
+

Tail the log (so it updates to your screen ‘live’).

+
   docker pause f6d4ca9b2b22     
+
+
+

Suspend the state of the container.

+
   docker unpause f6d4ca9b2b22   
+
+
+

Un-suspend it again after a pause. It will pick up exactly where it were.

+
   docker stop f6d4ca9b2b22      
+
+
+

Stop the container. To get it up again you need to use docker run, specifying ports etc. A new +container will get a new container id to reference.

+
+
+
+

How it Works

+

The evennia/evennia docker image holds the evennia library and all of its dependencies. It also has an ONBUILD directive which is triggered during builds of images derived from it. This ONBUILD directive handles setting up a volume and copying your game directory code into the proper location within the container.

+

In most cases, the Dockerfile for an Evennia-based game will only need the FROM evennia/evennia:latest directive, and optionally a MAINTAINER directive if you plan to publish your image on Docker Hub and would like to provide contact info.

+

For more information on Dockerfile directives, see the Dockerfile Reference.

+

For more information on volumes and Docker containers, see the Docker site’s Manage data in +containers page.

+
+

What if I Don’t Want “LATEST”?

+

A new evennia/evennia image is built automatically whenever there is a new commit to the main branch of Evennia. It is possible to create your own custom evennia base docker image based on any arbitrary commit.

+
    +
  1. Use git tools to checkout the commit that you want to base your image upon. (In the example +below, we’re checking out commit a8oc3d5b.)

  2. +
+
git checkout -b my-stable-branch a8oc3d5b 
+
+
+
    +
  1. Change your working directory to the evennia directory containing Dockerfile. Note that +Dockerfile has changed over time, so if you are going far back in the commit history you might +want to bring a copy of the latest Dockerfile with you and use that instead of whatever version +was used at the time.

  2. +
  3. Use the docker build command to build the image based off of the currently checked out commit. +The example below assumes your docker account is mydhaccount.

  4. +
+
docker build -t mydhaccount/evennia .
+
+
+
    +
  1. Now you have a base evennia docker image built off of a specific commit. To use this image to +build your game, you would modify FROM directive in the Dockerfile for your game directory +to be:

  2. +
+
FROM mydhacct/evennia:latest
+
+
+

Note: From this point, you can also use the docker tag command to set a specific tag on your image and/or upload it into Docker Hub under your account. +5. At this point, build your game using the same docker build command as usual. Change your +working directory to be your game directory and run

+
docker build -t mydhaccountt/mygame .
+
+
+
+
+
+

Additional Creature Comforts

+

The Docker ecosystem includes a tool called docker-compose, which can orchestrate complex multi- container applications, or in our case, store the default port and terminal parameters that we want specified every time we run our container. A sample docker-compose.yml file to run a containerized Evennia game in development might look like this:

+
version: '2'
+
+services:
+  evennia:
+    image: mydhacct/mygame
+    stdin_open: true
+    tty: true
+    ports:
+      - "4001-4002:4001-4002"
+      - "4000:4000"
+    volumes: 
+      - .:/usr/src/game
+
+
+

With this file in the game directory next to the Dockerfile, starting the container is as simple as

+
docker-compose up
+
+
+

For more information about docker-compose, see Getting Started with docker- +compose.

+
+

Note that with this setup you lose the --user $UID option. The problem is that the variable UID is not available inside the configuration file docker-compose.yml. A workaround is to hardcode your user and group id. In a terminal run echo  $UID:$GID and if for example you get 1000:1000 you can add to docker-compose.yml a line user: 1000:1000 just below the image: ... line.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Installation-Git.html b/docs/latest/Setup/Installation-Git.html new file mode 100644 index 0000000000..33284a14c7 --- /dev/null +++ b/docs/latest/Setup/Installation-Git.html @@ -0,0 +1,306 @@ + + + + + + + + + Installing with GIT — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Installing with GIT

+

This installs and runs Evennia from its sources. This is required if you want to contribute to Evennia itself or have an easier time exploring the code. See the basic Installation for +a quick installation of the library. See the troubleshooting if you run +into trouble.

+
+

Important

+

If you are converting an existing game from a previous version, see here.

+
+
+

Summary

+

For the impatient. If you have trouble with a step, you should jump on to the +more detailed instructions for your platform.

+
    +
  1. Install Python and GIT. Start a Console/Terminal.

  2. +
  3. cd to some place you want to do your development (like a folder +/home/anna/muddev/ on Linux or a folder in your personal user directory on Windows).

  4. +
  5. git clone https://github.com/evennia/evennia.git (a new folder evennia is created)

  6. +
  7. python3.11 -m venv evenv (a new folder evenv is created)

  8. +
  9. source evenv/bin/activate (Linux, Mac), evenv\Scripts\activate (Windows)

  10. +
  11. pip install -e evennia

  12. +
  13. evennia --init mygame

  14. +
  15. cd mygame

  16. +
  17. evennia migrate

  18. +
  19. evennia start (make sure to make a superuser when asked)

  20. +
+

Evennia should now be running and you can connect to it by pointing a web browser to +http://localhost:4001 or a MUD telnet client to localhost:4000 (use 127.0.0.1 if your OS does +not recognize localhost).

+
+
+

Virtualenv

+

A Python virtual environment allows you to install Evennia and all its dependenceis in its own little isolated folder, separate from the rest of the system. This also means you can install without any extra permissions - it all goes into a folder on your drive.

+

It’s optional to use a virtualenv, but it’s highly recommended. Not only is this common Python praxis, it will make your life easier and avoid clashes with other Python programs you may have.

+

Python supports virtualenv natively:

+ +
python3.11 -m venv evenv   (linux/mac)
+python -m venv evenv       (Windows)
+
+
+

This will create a new folder evenv in your current directory. +Activate it like this:

+
source evenv/bin/activate (Linux, Mac)
+
+evenv\Scripts\activate    (Windows Console)
+
+.\evenv\scripts\activate  (Windows PS Shell, 
+                           Git Bash etc)
+
+
+

The text (evenv) should appear next to your prompt to show that the virtual +environment is enabled. You do not need to actually be in or near the evenv folder for +the environment to be active.

+
+

Important

+

Remember that you need to (re-)activate the virtualenv like this every time you +start a new terminal/console (or restart your computer). Until you do, the evennia command will not be available.

+
+
+
+

Linux Install

+

For Debian-derived systems (like Ubuntu, Mint etc), start a terminal and +install the requirements:

+
sudo apt-get update
+sudo apt-get install python3.11 python3.11-venv python3.11-dev gcc
+
+
+

You should make sure to not be root after this step, running as root is a +security risk. Now create a folder where you want to do all your Evennia +development:

+
mkdir muddev
+cd muddev
+
+
+

Next we fetch Evennia itself:

+
git clone https://github.com/evennia/evennia.git
+
+
+

A new folder evennia will appear containing the Evennia library. This only +contains the source code though, it is not installed yet.

+

At this point it’s now optional but recommended that you initialize and activate a virtualenv.

+

Next, install Evennia (system-wide, or into your active virtualenv). Make sure you are standing +at the top of your mud directory tree (so you see the evennia/ folder, and likely the evenv virtualenv folder) and do

+ +
pip install -e evennia
+
+
+

Test that you can run the evennia command.

+

Next you can continue initializing your game from the regular Installation instructions.

+
+
+

Mac Install

+

The Evennia server is a terminal program. Open the terminal e.g. from +Applications->Utilities->Terminal. Here is an introduction to the Mac terminal if you are unsure how it works.

+
    +
  • Python should already be installed but you must make sure it’s a high enough version - go for 3.11. (This discusses how you may upgrade it).

  • +
  • GIT can be obtained with git-osx-installer or via MacPorts as described here.

  • +
  • If you run into issues with installing Twisted later you may need to install gcc and the Python headers.

  • +
+

After this point you should not need sudo or any higher privileges to install anything.

+

Now create a folder where you want to do all your Evennia development:

+
mkdir muddev
+cd muddev
+
+
+

Next we fetch Evennia itself:

+
git clone https://github.com/evennia/evennia.git
+
+
+

A new folder evennia will appear containing the Evennia library. This only contains the source code though, it is not installed yet.

+

At this point it’s now optional but recommended that you initialize and activate a virtualenv.

+

Next, install Evennia (system-wide, or into your active virtualenv). Make sure you are standing +at the top of your mud directory tree (so you see the evennia/, and likely the evenv virtualenv +folder) and do

+
pip install --upgrade pip   # Old pip versions may be an issue on Mac.
+pip install --upgrade setuptools   # Ditto concerning Mac issues.
+pip install -e evennia
+
+
+

Test that you can run the evennia command.

+

Next you can continue initializing your game from the regular Installation instructions.

+
+
+

Windows Install

+
+

If you are running Windows10+, consider using the Windows Subsystem for Linux > (WSL) instead. Just set up WSL with an Ubuntu image and follow the Linux install instructions above.

+
+

The Evennia server itself is a command line program. In the Windows launch menu, start All Programs -> Accessories -> command prompt and you will get the Windows command line interface. Here is one of many tutorials on using the Windows command line if you are unfamiliar with it.

+
    +
  • Install Python from the Python homepage. You will need to be a Windows Administrator to install packages. Get Python 3.11, 64-bit version. Use the default settings; make sure the py launcher gets installed.

  • +
  • You need to also get GIT and install it. You can use the default install options but when you get asked to “Adjust your PATH environment”, you should select the second option “Use Git from the Windows Command Prompt”, which gives you more freedom as to where you can use the program.

  • +
  • If you run Python 3.11: You must also install the Windows SDK. Download and run the linked installer. Click the Individual Components tab at the top. Search and checkmark the latest Windows 10 SDK (also for older and newer Windows versions). Click Install. If you later have issues with installing Evennia due to a failure to build the “Twisted wheels”, this is where you are missing things. If you have trouble, use Python 3.10 for now (2022)

  • +
  • You may need the pypiwin32 Python headers. Install these only if you have issues.

  • +
+

You can install Evennia wherever you want. cd to that location and create a +new folder for all your Evennia development (let’s call it muddev).

+
mkdir muddev
+cd muddev
+
+
+
+

If cd isn’t working you can use pushd instead to force the directory change.

+
+

Next we fetch Evennia itself:

+
git clone https://github.com/evennia/evennia.git
+
+
+

A new folder evennia will appear containing the Evennia library. This only +contains the source code though, it is not installed yet.

+

At this point it’s optional but recommended that you initialize and activate a virtualenv.

+

Next, install Evennia (system wide, or into the virtualenv). Make sure you are standing +at the top of your mud directory tree (so you see evennia, and likely the evenv virtualenv folder when running the dir command). Then do:

+
pip install -e evennia
+
+
+

Test that you can run the evennia command everywhere while your virtualenv (evenv) is active.

+

Next you can continue initializing your game from the regular Installation instructions.

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Installation-Non-Interactive.html b/docs/latest/Setup/Installation-Non-Interactive.html new file mode 100644 index 0000000000..e7a8095569 --- /dev/null +++ b/docs/latest/Setup/Installation-Non-Interactive.html @@ -0,0 +1,147 @@ + + + + + + + + + Non-interactive setup — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Non-interactive setup

+

The first time you run evennia start (just after having created the database), you will be asked +to interactively insert the superuser username, email and password. If you are deploying Evennia +as part of an automatic build script, you don’t want to enter this information manually.

+

You can have the superuser be created automatically by passing environment variables to your +build script:

+
    +
  • EVENNIA_SUPERUSER_USERNAME

  • +
  • EVENNIA_SUPERUSER_PASSWORD

  • +
  • EVENNIA_SUPERUSER_EMAIL is optional. If not given, empty string is used.

  • +
+

These envvars will only be used on the very first server start and then ignored. For example:

+
EVENNIA_SUPERUSER_USERNAME=myname EVENNIA_SUPERUSER_PASSWORD=mypwd evennia start
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Installation-Troubleshooting.html b/docs/latest/Setup/Installation-Troubleshooting.html new file mode 100644 index 0000000000..2a5f35f7ff --- /dev/null +++ b/docs/latest/Setup/Installation-Troubleshooting.html @@ -0,0 +1,255 @@ + + + + + + + + + Installation Troubleshooting — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Installation Troubleshooting

+

If you have an issue not covered here, please report it so it can be fixed or a workaround found!

+

The server logs are in mygame/server/logs/. To easily view server logs in the terminal, +you can run evennia -l, or start/reload the server with evennia start -l or evennia reload -l.

+
+

Check your Requirements

+

Any system that supports Python3.10+ should work.

+
    +
  • Linux/Unix

  • +
  • Windows (Win7, Win8, Win10, Win11)

  • +
  • Mac OSX (>10.5 recommended)

  • +
  • Python (3.10 and 3.11 are tested. 3.11 is recommended)

  • +
  • Twisted (v22.3+)

    +
      +
    • ZopeInterface (v3.0+) - usually included in Twisted packages

    • +
    • Linux/Mac users may need the gcc and python-dev packages or equivalent.

    • +
    • Windows users need MS Visual C++ and maybe pypiwin32.

    • +
    +
  • +
  • Django (v4.2+), be warned that latest dev version is usually untested with Evennia.

  • +
  • GIT - version control software used if you want to install the sources +(but also useful to track your own code)

    + +
  • +
+
+
+

Confusion of location (GIT installation)

+

When doing the Git installation, some may be confused and install Evennia in the wrong location. After following the instructions (and using a virtualenv), the folder structure should look like this:

+
muddev/
+    evenv/
+    evennia/
+    mygame/
+
+
+

The evennia code itself is found inside evennia/evennia/ (so two levels down). Your settings file +is mygame/server/conf/settings.py and the parent setting file is evennia/evennia/settings_default.py.

+
+
+

Virtualenv setup fails

+

When doing the python3.11 -m venv evenv step, some users report getting an error; something like:

+
Error: Command '['evenv', '-Im', 'ensurepip', '--upgrade', '--default-pip']' 
+returned non-zero exit status 1
+
+
+

You can solve this by installing the python3.11-venv package or equivalent for your OS. Alternatively you can bootstrap it in this way:

+
python3.11 -m --without-pip evenv
+
+
+

This should set up the virtualenv without pip. Activate the new virtualenv and then install pip from within it:

+
python -m ensurepip --upgrade
+
+
+

If that fails, a worse alternative to try is

+
curl https://bootstrap.pypa.io/get-pip.py | python3.10    (linux/unix/WSL only)
+
+
+

Either way, you should now be able to continue with the installation.

+
+
+

Localhost not found

+

If localhost doesn’t work when trying to connect to your local game, try 127.0.0.1, which is the same thing.

+
+
+

Linux Troubleshooting

+
    +
  • If you get an error when installing Evennia (especially with lines mentioning +failing to include Python.h) then try sudo apt-get install python3-setuptools python3-dev. Once installed, run pip install -e evennia again.

  • +
  • When doing a git install, some not-updated Linux distributions may give errors +about a too-old setuptools or missing functools. If so, update your environment +with pip install --upgrade pip wheel setuptools. Then try pip install -e evennia again.

  • +
  • One user reported a rare issue on Ubuntu 16 is an install error on installing Twisted; Command "python setup.py egg_info" failed with error code 1 in /tmp/pip-build-vnIFTg/twisted/ with errors like distutils.errors.DistutilsError: Could not find suitable distribution for Requirement.parse('incremental>=16.10.1'). This appears possible to solve by simply updating Ubuntu with sudo apt-get update && sudo apt-get dist-upgrade.

  • +
  • Users of Fedora (notably Fedora 24) has reported a gcc error saying the directory +/usr/lib/rpm/redhat/redhat-hardened-cc1 is missing, despite gcc itself being installed. The +confirmed work-around seems to be to install the redhat-rpm-config package with e.g. sudo dnf install redhat-rpm-config.

  • +
  • Some users trying to set up a virtualenv on an NTFS filesystem find that it fails due to issues +with symlinks not being supported. Answer is to not use NTFS (seriously, why would you do that to yourself?)

  • +
+
+
+

Mac Troubleshooting

+
    +
  • Some Mac users have reported not being able to connect to localhost (i.e. your own computer). If so, try to connect to 127.0.0.1 instead, which is the same thing. Use port 4000 from mud clients and port 4001 from the web browser as usual.

  • +
  • If you get a MemoryError when starting Evennia, or when looking at the log, this may be due to an sqlite versioning issue. A user in our forums found a working solution for this. Here is another variation to solve it.

  • +
+
+
+

Windows Troubleshooting

+
    +
  • If you install with pip install evennia and find that the evennia command is not available, run py -m evennia once. This should add the evennia binary to your environment. If this fails, make sure you are using a virtualenv. Worst case, you can keep using py -m evennia in the places where the evennia command is used.

  • +
  • Install Python from the Python homepage. You will need to be a Windows Administrator to install packages.

  • +
  • When installing Python, make sure to check-mark all install options, especially the one about making Python available on the path (you may have to scroll to see it). This allows you to +just write python in any console without first finding where the python program actually sits on your hard drive.

  • +
  • If you get a command not found when trying to run the evennia program after installation, try closing the Console and starting it again (remember to re-activate the virtualenv if you use one!). Sometimes Windows is not updating its environment properly and evennia will be available only in the new console.

  • +
  • If you installed Python but the python command is not available (even in a new console), then +you might have missed installing Python on the path. In the Windows Python installer you get a list of options for what to install. Most or all options are pre-checked except this one, and you may even have to scroll down to see it. Reinstall Python and make sure it’s checked.

  • +
  • If your MUD client cannot connect to localhost:4000, try the equivalent 127.0.0.1:4000 +instead. Some MUD clients on Windows does not appear to understand the alias localhost.

  • +
  • Some Windows users get an error installing the Twisted ‘wheel’. A wheel is a pre-compiled binary +package for Python. A common reason for this error is that you are using a 32-bit version of Python, but Twisted has not yet uploaded the latest 32-bit wheel. Easiest way to fix this is to install a slightly older Twisted version. So if, say, version 22.1 failed, install 22.0 manually with pip install twisted==22.0. Alternatively you could check that you are using the 64-bit version of Python and uninstall any 32bit one. If so, you must then deactivate the virtualenv, delete the evenv folder and recreate it anew with your new Python.

  • +
  • If you’ve done a git installation, and your server won’t start with an error message like AttributeError: module 'evennia' has no attribute '_init', it may be a python path issue. In a terminal, cd to (your python directory)\site-packages and run the command echo "C:\absolute\path\to\evennia" > local-vendors.pth. Open the created file in your favorite IDE and make sure it is saved with UTF-8 encoding and not UTF-8 with BOM.

  • +
  • If your server won’t start, with no error messages (and no log files at all when starting from +scratch), try to start with evennia ipstart instead. If you then see an error about system cannot find the path specified, it may be that the file evennia\evennia\server\twistd.bat has the wrong path to the twistd executable. This file is auto-generated, so try to delete it and then run evennia start to rebuild it and see if it works. If it still doesn’t work you need to open it in a text editor like Notepad. It’s just one line containing the path to the twistd.exe executable as determined by Evennia. If you installed Twisted in a non-standard location this might be wrong and you should update the line to the real location.

  • +
  • Some users have reported issues with Windows WSL and anti-virus software during Evennia +development. Timeout errors and the inability to run evennia connections may be due to your anti-virus software interfering. Try disabling or changing your anti-virus software settings.

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Installation-Upgrade.html b/docs/latest/Setup/Installation-Upgrade.html new file mode 100644 index 0000000000..200eb2c6b2 --- /dev/null +++ b/docs/latest/Setup/Installation-Upgrade.html @@ -0,0 +1,181 @@ + + + + + + + + + Upgrading an existing installation — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Upgrading an existing installation

+

This is relevant to you already having code in an older Evennia version. If you are new, or don’t have much code yet, it may be easier to just start fresh with the Installation instructions and copy over things manually.

+
+

Evennia v0.9.5 to 1.0+

+
+

Upgrading the Evennia library

+

Prior to 1.0, all Evennia installs were Git-installs. These instructions assume that you already have a cloned evennia repo, and use a virtualenv (best practices).

+
    +
  • Make sure to stop Evennia 0.9.5 entirely with evennia stop from your game dir.

  • +
  • deactivate to leave your active virtualenv.

  • +
  • Delete the old virtualenv evenv folder, or rename it (in case you want to keep using 0.9.5 for a while).

  • +
  • cd into your evennia/ root folder (you want to be where you see the docs/ and bin/ directories as well as a nested evennia/ folder)

  • +
  • git pull

  • +
  • git checkout main (instead of master which was used for 0.9.5)

  • +
+

From here on, proceed with the Git Installation, except skip cloning Evennia (since you already have the repo). Note that you can also follow the normal pip install if you don’t need or want to use git to track bleeding edge changes nor want to be able to help contribute to Evennia itself.

+
+
+

Upgrading your game dir

+

If you don’t have anything you want to keep in your existing game dir, you can just start a new one using the normal install instructions. If you want to keep/convert your existing game dir, continue below.

+
    +
  • First, make a backup of your exising game dir! If you use version control, make sure to commit your current state.

  • +
  • cd to your existing 0.9.5-based game folder (like mygame).

  • +
  • If you have changed mygame/web, rename the folder to web_0.9.5. If you didn’t change anything (or don’t have anything you want to keep), you can delete it entirely.

  • +
  • Copy evennia/evennia/game_template/web to mygame/ (e.g. using cp -Rf or a file manager). This new web folder replaces the old one and has a very different structure.

  • +
  • It’s possible you need to replace/comment out import and calls to the deprecated django.conf.urls. The new way to call it is available here.

  • +
  • Run evennia migrate - note that it’s normal to see some warnings here, don’t run makemigrations even if the system asks you to.

  • +
  • Run evennia start

  • +
+

If you made extensive work in your game dir, you may well find that you need to do some (hopefully minor) changes to your code before it will start with Evennia 1.0. Some important points:

+
    +
  • The evennia/contrib/ folder changed structure - there are now categorized sub-folders, so you have to update your imports.

  • +
  • Any web changes need to be moved back from your backup into the new structure of web/ manually.

  • +
  • See the Evennia 1.0 Changelog for all changes.

  • +
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Installation.html b/docs/latest/Setup/Installation.html new file mode 100644 index 0000000000..5d0f892887 --- /dev/null +++ b/docs/latest/Setup/Installation.html @@ -0,0 +1,276 @@ + + + + + + + + + Installation — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Installation

+
+

Important

+

If you are converting an existing game from a previous Evennia version, see here.

+
+

The fastest way to install Evennia is to use the pip installer that comes with Python (read on). +You can also clone Evennia from github or use docker. Some users have also experimented with installing Evennia on Android.

+
+

Requirements

+ +
    +
  • Evennia requires Python 3.10 or 3.11 (recommended). Any OS that supports Python should work.

    +
      +
    • Windows: In the installer, make sure to select add python to path. If you have multiple versions of Python installed, use py command instead of python to have Windows automatically use the latest.

    • +
    +
  • +
  • Don’t install Evennia as administrator or superuser.

  • +
  • If you run into trouble, see installation troubleshooting.

  • +
+
+
+

Install with pip

+
+

Important

+

You are recommended to setup a light-weight Python virtualenv to install Evennia in. Using a virtualenv is standard practice in Python and allows you to install what you want in isolation from other programs. The virtualenv system is a part of Python and will make your life easier!

+
+

Evennia is managed from the terminal (console/Command Prompt on Windows). Once you have Python installed—and after activating your virtualenv if you are using one—install Evennia with:

+
pip install evennia
+
+
+

Optional: If you use a contrib that warns you that it needs additional packages, you can install all extra dependencies with:

+
pip install evennia[extra]
+
+
+

To update Evennia later, do the following:

+
pip install --upgrade evennia
+
+
+
+

Note

+

Windows users only - +You now must run python -m evennia once. This should permanently make the evennia command available in your environment.

+
+

Once installed, make sure the evennia command works. Use evennia -h for usage help. If you are using a virtualenv, make sure it is active whenever you need to use the evennia command later.

+
+
+

Initialize a New Game

+

We will create a new “game dir” in which to create your game. Here, and in the rest of the Evennia documentation, we refer to this game dir as mygame, but you should, of course, name your game whatever you like. To create the new mygame folder—or whatever you choose—in your current location:

+ +
evennia --init mygame
+
+
+

The resultant folder contains all the empty templates and default settings needed to start the Evennia server.

+
+
+

Start the New Game

+

First, create the default database (Sqlite3):

+
cd mygame
+evennia migrate
+
+
+

The resulting database file is created in mygame/server/evennia.db3. If you ever want to start from a fresh database, just delete this file and re-run the evennia migrate command.

+

Next, start the Evennia server with:

+
evennia start
+
+
+

When prompted, enter a username and password for the in-game “god” or “superuser.” Providing an email address is optional.

+
+

You can also automate creation of the superuser.

+
+

If all went well, your new Evennia server is now up and running! To play your new—albeit empty—game, point a legacy MUD/telnet client to localhost:4000 or a web browser to http://localhost:4001. You may log in as a new account or use the superuser account you created above.

+
+
+

Restarting and Stopping

+

You can restart the server (without disconnecting players) by issuing:

+
evennia restart
+
+
+

And, to do a full stop and restart (with disconnecting players) use:

+
evennia reboot
+
+
+

A full stop of the server (use evennia start to restart) is achieved with:

+
evennia stop
+
+
+

See the Server start-stop-reload documentation page for details.

+
+
+

View Server Logs

+

Log files are located in mygame/server/logs. You can tail the logging in real-time with:

+
evennia --log
+
+
+

or just:

+
evennia -l
+
+
+

Press Ctrl-C (Cmd-C for Mac) to stop viewing the live log.

+

You may also begin viewing the real-time log immediately by adding -l/--log to evennia commands, such as when starting the server:

+
evennia start -l
+
+
+
+
+

Server Configuration

+

Your server’s configuration file is mygame/server/settings.py. It’s empty by default. Copy and paste only the settings you want/need from the default settings file to your server’s settings.py. See the Settings documentation for more information before configuring your server at this time.

+
+
+

Register with the Evennia Game Index (optional)

+

To let the world know that you are working on a new Evennia-based game, you may register your server with the Evennia game index by issuing:

+
evennia connections 
+
+
+

Then, just follow the prompts. You don’t have to be open for players to do this — simply mark your game as closed and “pre-alpha.”

+

See here for more instructions and please check out the index beforehand to make sure you don’t pick a game name that is already taken — be nice!

+
+
+

Next Steps

+

You are good to go!

+

Next, why not head over to the Starting Tutorial to learn how to begin making your new game!

+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Online-Setup.html b/docs/latest/Setup/Online-Setup.html new file mode 100644 index 0000000000..0db7cf51fc --- /dev/null +++ b/docs/latest/Setup/Online-Setup.html @@ -0,0 +1,553 @@ + + + + + + + + + Online Setup — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Online Setup

+

Evennia development can be made without any Internet connection beyond fetching updates. However, at some point, you are likely to want to make your game visible online, either as part of opening it to the public or to allow other developers or beta testers access to it.

+
+

Connecting to Evennia over the Internet

+

Accessing your Evennia server from the outside is not hard on its own. Any issues are usually due to the various security measures of your computer, network, or hosting service. These will generally (and correctly) block outside access to servers on your machine unless you tell them otherwise.

+

We will start by showing how to host your server on your own local computer. Even if you plan to +host your “real” game on a remote host later, setting it up locally is useful practice. We cover +remote hosting later in this document.

+

Out of the box, Evennia uses three ports for outward communication. If your computer has a firewall, these should be open for in/out communication (and only these, other ports used by Evennia are internal to your computer only).

+
    +
  • 4000, telnet, for traditional mud clients

  • +
  • 4001, HTTP, for the website

  • +
  • 4002, websocket, for the web client

  • +
+

Evennia will by default accept incoming connections on all interfaces (0.0.0.0), so in principle anyone knowing the ports to use and has the IP address to your machine should be able to connect to your game.

+ +
    +
  • Make sure Evennia is installed and that you have activated the virtualenv. Start the server with evennia start --log. The --log (or -l) will make sure that the logs are echoed to the terminal.

  • +
  • Make sure you can connect with your web browser to http://localhost:4001 or, alternatively, http://127.0.0.1:4001 which is the same thing. You should get your Evennia web site and be able to play the game in the web client. Also check so that you can connect with a mud client to host localhost, port 4000 or host 127.0.0.1, port 4000.

  • +
  • Google for “my ip” or use any online service to figure out what your “outward-facing” IP address is. For our purposes, let’s say your outward-facing IP is 203.0.113.0.

  • +
  • Next try your outward-facing IP by opening http://203.0.113.0:4001 in a browser. If this works, that’s it! Also try telnet, with the server set to 203.0.113.0 and port 4000. However, most likely it will not work. If so, read on.

  • +
  • If your computer has a firewall, it may be blocking the ports we need (it may also block telnet overall). If so, you need to open the outward-facing ports to in/out communication. See the manual/instructions for your firewall software on how to do this. To test you could also temporarily turn off your firewall entirely to see if that was indeed the problem.

  • +
  • Another common problem for not being able to connect is that you are using a hardware router (like a wifi router). The router sits ‘between’ your computer and the Internet. So the IP you find with Google is the router’s IP, not that of your computer. To resolve this you need to configure your router to forward data it gets on its ports to the IP and ports of your computer sitting in your private network. How to do this depends on the make of your router; you usually configure it using a normal web browser. In the router interface, look for “Port forwarding” or maybe “Virtual server”. If that doesn’t work, try to temporarily wire your computer directly to the Internet outlet (assuming your computer has the ports for it). You’ll need to check for your IP again. If that works, you know the problem is the router.

  • +
+
+

Note

+

If you need to reconfigure a router, the router’s Internet-facing ports do not have to have to have the same numbers as your computer’s (and Evennia’s) ports! For example, you might want to connect Evennia’s outgoing port 4001 to an outgoing router port 80 - this is the port HTTP requests use and web browsers automatically look for - if you do that you could go to http://203.0.113.0 without having to add the port at the end. This would collide with any other web services you are running through this router though.

+
+
+

Settings example

+

You can connect Evennia to the Internet without any changes to your settings. The default settings are easy to use but are not necessarily the safest. You can customize your online presence in your settings file. To have Evennia recognize changed port settings, you have to do a full evennia reboot to also restart the Portal and not just the Server component.

+

Below is an example of a simple set of settings, mostly using the defaults. Evennia will require access to five computer ports, of which three (only) should be open to the outside world. Below we +continue to assume that our server address is 203.0.113.0.

+
# in mygame/server/conf/settings.py
+
+SERVERNAME = "MyGame"
+
+# open to the internet: 4000, 4001, 4002
+# closed to the internet (internal use): 4005, 4006
+TELNET_PORTS = [4000]
+WEBSOCKET_CLIENT_PORT = 4002
+WEBSERVER_PORTS = [(4001, 4005)]
+AMP_PORT = 4006
+
+# This needs to be set to your website address for django or you'll receive a
+# CSRF error when trying to log on to the web portal
+CSRF_TRUSTED_ORIGINS = ['https://mymudgame.com']
+
+# Optional - security measures limiting interface access
+# (don't set these before you know things work without them)
+TELNET_INTERFACES = ['203.0.113.0']
+WEBSOCKET_CLIENT_INTERFACE = '203.0.113.0'
+ALLOWED_HOSTS = [".mymudgame.com"]
+
+# uncomment if you want to lock the server down for maintenance.
+# LOCKDOWN_MODE = True
+
+
+
+

Read on for a description of the individual settings.

+
+
+

Telnet

+
# Required. Change to whichever outgoing Telnet port(s)
+# you are allowed to use on your host.
+TELNET_PORTS = [4000]
+# Optional for security. Restrict which telnet
+# interfaces we should accept. Should be set to your
+# outward-facing IP address(es). Default is ´0.0.0.0´
+# which accepts all interfaces.
+TELNET_INTERFACES = ['0.0.0.0']
+
+
+

The TELNET_* settings are the most important ones for getting a traditional base game going. Which IP addresses you have available depends on your server hosting solution (see the next sections). Some hosts will restrict which ports you are allowed you use so make sure to check.

+
+
+

Web server

+
# Required. This is a list of tuples
+# (outgoing_port, internal_port). Only the outgoing
+# port should be open to the world!
+# set outgoing port to 80 if you want to run Evennia
+# as the only web server on your machine (if available).
+WEBSERVER_PORTS = [(4001, 4005)]
+# Optional for security. Change this to the IP your
+# server can be reached at (normally the same
+# as TELNET_INTERFACES)
+WEBSERVER_INTERFACES = ['0.0.0.0']
+# Optional for security. Protects against
+# man-in-the-middle attacks. Change  it to your server's
+# IP address or URL when you run a production server.
+ALLOWED_HOSTS = ['*']
+
+
+

The web server is always configured with two ports at a time. The outgoing port (4001 by +default) is the port external connections can use. If you don’t want users to have to specify the +port when they connect, you should set this to 80 - this however only works if you are not running +any other web server on the machine.

+

The internal port (4005 by default) is used internally by Evennia to communicate between the +Server and the Portal. It should not be available to the outside world. You usually only need to +change the outgoing port unless the default internal port is clashing with some other program.

+
+
+

Web client

+
# Required. Change this to the main IP address of your server.
+WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0'
+# Optional and needed only if using a proxy or similar. Change
+# to the IP or address where the client can reach
+# your server. The ws:// part is then required. If not given, the client
+# will use its host location.
+WEBSOCKET_CLIENT_URL = ""
+# Required. Change to a free port for the websocket client to reach
+# the server on. This will be automatically appended
+# to WEBSOCKET_CLIENT_URL by the web client.
+WEBSOCKET_CLIENT_PORT = 4002
+
+
+

The websocket-based web client needs to be able to call back to the server, and these settings must be changed for it to find where to look. If it cannot find the server you will get an warning in your browser’s Console (in the dev tools of the browser), and the client will revert to the AJAX- +based of the client instead, which tends to be slower.

+
+
+

Other ports

+
# Optional public facing. Only allows SSL connections (off by default).
+SSL_PORTS = [4003]
+SSL_INTERFACES = ['0.0.0.0']
+# Optional public facing. Only if you allow SSH connections (off by default).
+SSH_PORTS = [4004]
+SSH_INTERFACES = ['0.0.0.0']
+# Required private. You should only change this if there is a clash
+# with other services on your host. Should NOT be open to the
+# outside world.
+AMP_PORT = 4006
+
+
+

The AMP_PORT is required to work, since this is the internal port linking Evennia’s Server and Portal components together. The other ports are encrypted ports that may be useful for custom protocols but are otherwise not used.

+
+
+

Lockdown mode

+

When you test things out and check configurations you may not want players to drop in on you. +Similarly, if you are doing maintenance on a live game you may want to take it offline for a while +to fix eventual problems without risking people connecting. To do this, stop the server with +evennia stop and add LOCKDOWN_MODE = True to your settings file. When you start the server +again, your game will only be accessible from localhost.

+
+
+

Registering with the Evennia game directory

+

Once your game is online you should make sure to register it with the Evennia Game Index. Registering with the index will help people find your server, drum up interest for your game and also shows people that Evennia is being used. You can do this even if you are just starting development - if you don’t give any telnet/web address it will appear as Not yet public and just be a teaser. If so, pick pre-alpha as the development status.

+

To register, stand in your game dir, run

+
evennia connections
+
+
+

and follow the instructions. See the Game index page for more details.

+
+
+
+

SSL and HTTPS

+

SSL can be very useful for web clients. It will protect the credentials and gameplay of your users +over a web client if they are in a public place, and your websocket can also be switched to WSS for the same benefit. SSL certificates used to cost money on a yearly basis, but there is now a program that issues them for free with assisted setup to make the entire process less painful.

+

Options that may be useful in combination with an SSL proxy:

+
# See above for the section on Lockdown Mode.
+# Useful for a proxy on the public interface connecting to Evennia on localhost.
+LOCKDOWN_MODE = True
+
+# Have clients communicate via wss after connecting with https to port 4001.
+# Without this, you may get DOMException errors when the browser tries
+# to create an insecure websocket from a secure webpage.
+WEBSOCKET_CLIENT_URL = "wss://fqdn:4002"
+
+
+
+

Let’s Encrypt

+

Let’s Encrypt is a certificate authority offering free certificates to secure a website with HTTPS. To get started issuing a certificate for your web server using Let’s Encrypt, see these links:

+ +

Also, on Freenode visit the #letsencrypt channel for assistance from the community. For an additional resource, Let’s Encrypt has a very active community forum.

+

A blog where someone sets up Let’s Encrypt

+

The only process missing from all of the above documentation is how to pass verification. This is how Let’s Encrypt verifies that you have control over your domain (not necessarily ownership, it’s Domain Validation (DV)). This can be done either with configuring a certain path on your web server or through a TXT record in your DNS. Which one you will want to do is a personal preference, but can also be based on your hosting choice. In a controlled/cPanel environment, you will most likely have to use DNS verification.

+
+
+

Relevant SSL Proxy Setup Information

+ +
+
+
+

Hosting Evennia from your own computer

+

What we showed above is by far the simplest and probably cheapest option: Run Evennia on your own home computer. Moreover, since Evennia is its own web server, you don’t need to install anything extra to have a website.

+

Advantages

+
    +
  • Free (except for internet costs and the electrical bill).

  • +
  • Full control over the server and hardware (it sits right there!).

  • +
  • Easy to set up.

  • +
  • Suitable for quick setups - e.g. to briefly show off results to your collaborators.

  • +
+

Disadvantages

+
    +
  • You need a good internet connection, ideally without any upload/download limits/costs.

  • +
  • If you want to run a full game this way, your computer needs to always be on. It could be noisy, +and as mentioned, the electrical bill must be considered.

  • +
  • No support or safety - if your house burns down, so will your game. Also, you are yourself +responsible for doing regular backups.

  • +
  • Potentially not as easy if you don’t know how to open ports in your firewall or router.

  • +
  • Home IP numbers are often dynamically allocated, so for permanent online time you need to set up a DNS to always re-point to the right place (see below). - You are personally responsible for any use/misuse of your internet connection– though unlikely (but not impossible) if running your server somehow causes issues for other customers on the network, goes against your ISP’s terms of service (many ISPs insist on upselling you to a business- tier connection) or you are the subject of legal action by a copyright holder, you may find your main internet connection terminated as a consequence.

  • +
+
+

Setting up your own machine as a server

+

The first section of this page describes how to do this and allow users to connect to the IP address of your machine/router.

+

A complication with using a specific IP address like this is that your home IP might not remain the +same. Many ISPs (Internet Service Providers) allocates a dynamic IP to you which could change at +any time. When that happens, that IP you told people to go to will be worthless. Also, that long +string of numbers is not very pretty, is it? It’s hard to remember and not easy to use in marketing +your game. What you need is to alias it to a more sensible domain name - an alias that follows you +around also when the IP changes.

+
    +
  1. To set up a domain name alias, we recommend starting with a free domain name from +FreeDNS. Once you register there (it’s free) you have access to tens +of thousands domain names that people have “donated” to allow you to use for your own sub domain. +For example, strangled.net is one of those available domains. So tying our IP address to +strangled.net using the subdomain evennia would mean that one could henceforth direct people to +http://evennia.strangled.net:4001 for their gaming needs - far easier to remember!

  2. +
  3. So how do we make this new, nice domain name follow us also if our IP changes? For this we need +to set up a little program on our computer. It will check whenever our ISP decides to change our IP +and tell FreeDNS that. There are many alternatives to be found from FreeDNS:s homepage, one that +works on multiple platforms is inadyn. Get it from their page or, +in Linux, through something like apt-get install inadyn.

  4. +
  5. Next, you login to your account on FreeDNS and go to the +Dynamic page. You should have a list of your subdomains. Click +the Direct URL link and you’ll get a page with a text message. Ignore that and look at the URL of +the page. It should be ending in a lot of random letters. Everything after the question mark is your +unique “hash”. Copy this string.

  6. +
  7. You now start inadyn with the following command (Linux):

    +

    inadyn --dyndns_system default@freedns.afraid.org -a <my.domain>,<hash> &

    +
  8. +
+

where <my.domain> would be evennia.strangled.net and <hash> the string of numbers we copied +from FreeDNS. The & means we run in the background (might not be valid in other operating +systems). inadyn will henceforth check for changes every 60 seconds. You should put the inadyn +command string in a startup script somewhere so it kicks into gear whenever your computer starts.

+
+
+
+

Hosting Evennia on a remote server

+

Your normal “web hotel” will probably not be enough to run Evennia. A web hotel is normally aimed at +a very specific usage - delivering web pages, at the most with some dynamic content. The “Python +scripts” they refer to on their home pages are usually only intended to be CGI-like scripts launched +by their webserver. Even if they allow you shell access (so you can install the Evennia dependencies +in the first place), resource usage will likely be very restricted. Running a full-fledged game +server like Evennia will probably be shunned upon or be outright impossible. If you are unsure, +contact your web hotel and ask about their policy on you running third-party servers that will want +to open custom ports.

+

The options you probably need to look for are shell account services, VPS:es or Cloud +services. A “Shell account” service means that you get a shell account on a server and can log in +like any normal user. By contrast, a VPS (Virtual Private Server) service usually means that you +get root access, but in a virtual machine. There are also Cloud-type services which allows for +starting up multiple virtual machines and pay for what resources you use.

+

Advantages

+
    +
  • Shell accounts/VPS/clouds offer more flexibility than your average web hotel - it’s the ability to +log onto a shared computer away from home.

  • +
  • Usually runs a Linux flavor, making it easy to install Evennia.

  • +
  • Support. You don’t need to maintain the server hardware. If your house burns down, at least your +game stays online. Many services guarantee a certain level of up-time and also do regular backups +for you. Make sure to check, some offer lower rates in exchange for you yourself being fully +responsible for your data/backups.

  • +
  • Usually offers a fixed domain name, so no need to mess with IP addresses.

  • +
  • May have the ability to easily deploy docker versions of evennia +and/or your game.

  • +
+

Disadvantages

+
    +
  • Might be pretty expensive (more so than a web hotel). Note that Evennia will normally need at +least 100MB RAM and likely much more for a large production game.

  • +
  • Linux flavors might feel unfamiliar to users not used to ssh/PuTTy and the Linux command line.

  • +
  • You are probably sharing the server with many others, so you are not completely in charge. CPU +usage might be limited. Also, if the server people decides to take the server down for maintenance, +you have no choice but to sit it out (but you’ll hopefully be warned ahead of time).

  • +
+
+

Installing Evennia on a remote server

+

Firstly, if you are familiar with server infrastructure, consider using [Docker](Running-Evennia-in- +Docker) to deploy your game to the remote server; it will likely ease installation and deployment. +Docker images may be a little confusing if you are completely new to them though.

+

If not using docker, and assuming you know how to connect to your account over ssh/PuTTy, you should +be able to follow the Setup Quickstart instructions normally. You only need Python +and GIT pre-installed; these should both be available on any servers (if not you should be able to +easily ask for them to be installed). On a VPS or Cloud service you can install them yourself as +needed.

+

If virtualenv is not available and you can’t get it, you can download it (it’s just a single file) +from the virtualenv pypi. Using virtualenv you can +install everything without actually needing to have further root access. Ports might be an issue, +so make sure you know which ports are available to use and reconfigure Evennia accordingly.

+
+
+
+

Hosting options and suggestions

+

To find commercial solutions, browse the web for “shell access”, “VPS” or “Cloud services” in your +region. You may find useful offers for “low cost” VPS hosting on Low End Box. The associated +Low End Talk forum can be useful for health checking the many small businesses that offer +“value” hosting, and occasionally for technical suggestions.

+

There are all sorts of services available. Below are some international suggestions offered by +Evennia users:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Hosting name

Type

Lowest price

Comments

silvren.com

Shell account

Free for MU*

Private hobby provider so don’t assume backups or expect immediate support. To ask for an account, connect with a MUD client to rostdev.mushpark.com, port 4201 and ask for “Jarin”.

Digital Ocean

VPS

$5/month

You can get a $50 credit if you use the referral link https://m.do.co/c/8f64fec2670c - if you do, once you’ve had it long enough to have paid $25 we will get that as a referral bonus to help Evennia development.

Amazon Web services

Cloud

~$5/month / on-demand

Free Tier first 12 months. Regions available around the globe.

Amazon Lightsail

Cloud

$5/month

Free first month. AWS’s “fixed cost” offering.

Azure App Services

Cloud

Free

Free tier with limited regions for hobbyists.

Huawei Cloud

Cloud

on demand

Similar to Amazon. Free 12-month tier with limited regions.

Heficed

VPS & Cloud

$6/month

$6/month for a 1GB ram server.

Scaleway

Cloud

€3/month / on-demand

EU based (Paris, Amsterdam). Smallest option provides 2GB RAM.

Prgmr

VPS

$5/month

1 month free with a year prepay. You likely want some experience with servers with this option as they don’t have a lot of support.

Linode

Cloud

$5/month / on-demand

Multiple regions. Smallest option provides 1GB RAM

Genesis MUD hosting

Shell account

$8/month

Dedicated MUD host with very limited memory offerings. May run very old Python versions. Evennia needs at least the “Deluxe” package (50MB RAM) and probably a lot higher for a production game. While it’s sometimes mentioned in a MUD context, this host is not recommended for Evennia.

+

Please help us expand this list.

+
+

Cloud9

+

If you are interested in running Evennia in the online dev environment Cloud9, you +can spin it up through their normal online setup using the Evennia Linux install instructions. The +one extra thing you will have to do is update mygame/server/conf/settings.py and add +WEBSERVER_PORTS = [(8080, 4001)]. This will then let you access the web server and do everything +else as normal.

+

Note that, as of December 2017, Cloud9 was re-released by Amazon as a service within their AWS cloud +service offering. New customers entitled to the 1 year AWS “free tier” may find it provides +sufficient resources to operate a Cloud9 development environment without charge. +https://aws.amazon.com/cloud9/

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Running-Evennia.html b/docs/latest/Setup/Running-Evennia.html new file mode 100644 index 0000000000..c139dfa8fc --- /dev/null +++ b/docs/latest/Setup/Running-Evennia.html @@ -0,0 +1,310 @@ + + + + + + + + + Start Stop Reload — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Start Stop Reload

+

You control Evennia from your game folder (we refer to it as mygame/ here), using the evennia +program. If the evennia program is not available on the command line you must first install +Evennia as described on the Installation page.

+ +

Below are described the various management options. Run

+
evennia -h
+
+
+

to give you a brief help and

+
evennia menu
+
+
+

to give you a menu with options.

+
+

Starting Evennia

+

Evennia consists of two components, the Evennia Portal and Server. Briefly, the Server is what is running the mud. It handles all game-specific things but doesn’t care exactly how players connect, only that they have. The Portal is a gateay to which players connect. It knows everything about telnet, ssh, webclient protocols etc but very little about the game. Both are required for a functioning game.

+
 evennia start
+
+
+

The above command will start the Portal, which in turn will boot up the Server. The command will print a summary of the process and unless there is an error you will see no further output. Both components will instead log to log files in mygame/server/logs/. For convenience you can follow those logs directly in your terminal by attaching -l to commands:

+
 evennia -l
+
+
+

Will start following the logs of an already running server. When starting Evennia you can also do

+
 evennia start -l
+
+
+
+

To stop viewing the log files, press Ctrl-C (Cmd-C on Mac).

+
+
+
+

Reloading

+

The act of reloading means the Portal will tell the Server to shut down and then boot it back up again. Everyone will get a message and the game will be briefly paused for all accounts as the server reboots. Since they are connected to the Portal, their connections are not lost.

+

Reloading is as close to a “warm reboot” you can get. It reinitializes all code of Evennia, but doesn’t kill “persistent” Scripts. It also calls at_server_reload() hooks on all objects so you can save eventual temporary properties you want.

+

From in-game the reload command is used. You can also reload the server from outside the game:

+
 evennia reload
+
+
+

Sometimes reloading from “the outside” is necessary in case you have added some sort of bug that blocks in-game input.

+
+
+

Stopping

+

A full shutdown closes Evennia completely, both Server and Portal. All accounts will be booted and +systems saved and turned off cleanly.

+

From inside the game you initiate a shutdown with the shutdown command. From command line you do

+
 evennia stop
+
+
+

You will see messages of both Server and Portal closing down. All accounts will see the shutdown +message and then be disconnected.

+
+
+

Foreground mode

+

Normally, Evennia runs as a ‘daemon’, in the background. If you want you can start either of the +processes (but not both) as foreground processes in interactive mode. This means they will log +directly to the terminal (rather than to log files that we then echo to the terminal) and you can +kill the process (not just the log-file view) with Ctrl-C.

+
evennia istart
+
+
+

will start/restart the Server in interactive mode. This is required if you want to run a +debugger. Next time you evennia reload the server, it will return to normal mode.

+
evennia ipstart
+
+
+

will start the Portal in interactive mode.

+

If you do Ctrl-C/Cmd-C in foreground mode, the component will stop. You’ll need to run evennia start to get the game going again.

+
+
+

Resetting

+

Resetting is the equivalent of a “cold reboot” - the Server will shut down and then restarted +again, but will behave as if it was fully shut down. As opposed to a “real” shutdown, no accounts will be disconnected during a reset. A reset will however purge all non-persistent scripts and will call at_server_shutdown() hooks. It can be a good way to clean unsafe scripts during development, for example.

+

From in-game the reset command is used. From the terminal:

+
evennia reset
+
+
+
+
+

Rebooting

+

This will shut down both Server and Portal, which means all connected players will lose their +connection. It can only be initiated from the terminal:

+
evennia reboot
+
+
+

This is identical to doing these two commands:

+
 evennia stop
+ evennia start
+
+
+
+
+

Status and info

+

To check basic Evennia settings, such as which ports and services are active, this will repeat the +initial return given when starting the server:

+
evennia info
+
+
+

You can also get a briefer run-status from both components with this command

+
evennia status
+
+
+

This can be useful for automating checks to make sure the game is running and is responding.

+
+
+

Killing (Linux/Mac only)

+

In the extreme case that neither of the server processes locks up and does not respond to commands, +you can send them kill-signals to force them to shut down. To kill only the Server:

+
evennia skill
+
+
+

To kill both Server and Portal:

+
evennia kill
+
+
+

Note that this functionality is not supported on Windows.

+
+
+

Django options

+

The evennia program will also pass-through options used by the django-admin. These operate on the database in various ways.

+
 evennia migrate # migrate the database
+ evennia shell   # launch an interactive, django-aware python shell
+ evennia dbshell # launch the database shell
+
+
+

For (many) more options, see the django-admin docs.

+
+
+

Advanced handling of Evennia processes

+

If you should need to manually manage Evennia’s processors (or view them in a task manager program +such as Linux’ top or the more advanced htop), you will find the following processes to be +related to Evennia:

+
    +
  • 1 x twistd ... evennia/server/portal/portal.py - this is the Portal process.

  • +
  • 3 x twistd ... server.py - One of these processes manages Evennia’s Server component, the main game. The other processes (with the same name but different process id) handle’s Evennia’s internal web server threads. You can look at mygame/server/server.pid to determine which is the main process.

  • +
+
+

Syntax errors during live development

+

During development, you will usually modify code and then reload the server to see your changes. +This is done by Evennia re-importing your custom modules from disk. Usually bugs in a module will +just have you see a traceback in the game, in the log or on the command line. For some really +serious syntax errors though, your module might not even be recognized as valid Python. Evennia may then fail to restart correctly.

+

From inside the game you see a text about the Server restarting followed by an ever growing list of +“…”. Usually this only lasts a very short time (up to a few seconds). If it seems to go on, it +means the Portal is still running (you are still connected to the game) but the Server-component of +Evennia failed to restart (that is, it remains in a shut-down state). Look at your log files or +terminal to see what the problem is - you will usually see a clear traceback showing what went +wrong.

+

Fix your bug then run

+
evennia start
+
+
+

Assuming the bug was fixed, this will start the Server manually (while not restarting the Portal). +In-game you should now get the message that the Server has successfully restarted.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Security-Practices.html b/docs/latest/Setup/Security-Practices.html new file mode 100644 index 0000000000..c6ef4c8cee --- /dev/null +++ b/docs/latest/Setup/Security-Practices.html @@ -0,0 +1,250 @@ + + + + + + + + + Security Hints and Practices — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Security Hints and Practices

+

Hackers these days aren’t discriminating, and their backgrounds range from bored teenagers to international intelligence agencies. Their scripts and bots endlessly crawl the web, looking for vulnerable systems they can break into. Who owns the system is irrelevant– it doesn’t matter if it belongs to you or the Pentagon, the goal is to take advantage of poorly-secured systems and see what resources can be controlled or stolen from them.

+

If you’re considering deploying to a cloud-based host, you have a vested interest in securing your applications– you likely have a credit card on file that your host can freely bill. Hackers pegging your CPU to mine cryptocurrency or saturating your network connection to participate in a botnet or send spam can run up your hosting bill, get your service suspended or get your address/site +blacklisted by ISPs. It can be a difficult legal or political battle to undo this damage after the +fact.

+

As a developer about to expose a web application to the threat landscape of the modern internet, +here are a few tips to consider to increase the security of your Evennia install.

+
+

Know your logs

+

In case of emergency, check your logs! By default they are located in the server/logs/ folder. +Here are some of the more important ones and why you should care:

+
    +
  • http_requests.log will show you what HTTP requests have been made against Evennia’s built-in webserver (TwistedWeb). This is a good way to see if people are innocuously browsing your site or trying to break it through code injection.

  • +
  • portal.log will show you various networking-related information. This is a good place to check for odd or unusual types or amounts of connections to your game, or other networking-related issues– like when users are reporting an inability to connect.

  • +
  • server.log is the MUX administrator’s best friend. Here is where you’ll find information pertaining to who’s trying to break into your system by guessing at passwords, who created what objects, and more. If your game fails to start or crashes and you can’t tell why, this is the first place you should look for answers. Security-related events are prefixed with an [SS] so when there’s a problem you might want to pay special attention to those.

  • +
+
+
+

Disable development/debugging options

+

There are a few Evennia/Django options that are set when you first create your game to make it more obvious to you where problems arise. These options should be disabled before you push your game into production– leaving them on can expose variables or code someone with malicious intent can easily abuse to compromise your environment.

+

In server/conf/settings.py:

+
# Disable Django's debug mode
+DEBUG = False
+# Disable the in-game equivalent
+IN_GAME_ERRORS = False
+# If you've registered a domain name, force Django to check host headers. Otherwise leave this as-is.
+# Note the leading period-- it is not a typo!
+ALLOWED_HOSTS = ['.example.com']
+
+
+
+
+

Handle user-uploaded images with care

+

If you decide to allow users to upload their own images to be served from your site, special care must be taken. Django will read the file headers to confirm it’s an image (as opposed to a document or zip archive), but code can be injected into an image file after the headers that can be interpreted as HTML and/or give an attacker a web shell through which they can access +other filesystem resources.

+

Django has a more comprehensive overview of how to handle user-uploaded files, but +in short you should take care to do one of two things:

+
    +
  • Serve all user-uploaded assets from a separate domain or CDN (not a subdomain of the one you already have!). For example, you may be browsing reddit.com but note that all the user-submitted images are being served from the redd.it domain. There are both security and performance benefits to this (webservers tend to load local resources one-by-one, whereas they will request external resources in bulk).

  • +
  • If you don’t want to pay for a second domain, don’t understand what any of this means or can’t be bothered with additional infrastructure, then simply reprocess user images upon receipt using an image library. Convert them to a different format, for example. Destroy the originals!

  • +
+
+
+

Disable the web interface (if you only want telnet)

+

The web interface allows visitors to see an informational page as well as log into a browser-based telnet client with which to access Evennia. It also provides authentication endpoints against which an attacker can attempt to validate stolen lists of credentials to see which ones might be shared by your users. Django’s security is robust, but if you don’t want/need these features and fully intend +to force your users to use traditional clients to access your game, you might consider disabling +either/both to minimize your attack surface.

+

In server/conf/settings.py:

+
# Disable the Javascript webclient
+WEBCLIENT_ENABLED = False
+# Disable the website altogether
+WEBSERVER_ENABLED = False
+
+
+
+
+

Change your ssh port

+

Automated attacks will often target port 22 seeing as how it’s the standard port for SSH traffic. Also, many public wifi hotspots block ssh traffic over port 22 so you might not be able to access your server from these locations if you like to work remotely or don’t have a home internet connection.

+

If you don’t intend on running a website or securing it with TLS, you can mitigate both problems by changing the port used for ssh to 443, which most/all hotspot providers assume is HTTPS traffic and allows through.

+

(Ubuntu) In /etc/ssh/sshd_config, change the following variable:

+
# What ports, IPs and protocols we listen for
+Port 443
+
+
+

Save, close, then run the following command:

+
sudo service ssh restart
+
+
+
+
+

Set up a firewall

+

Ubuntu users can make use of the simple ufw utility. Anybody else can use iptables.

+
# Install ufw (if not already)
+sudo apt-get install ufw
+
+
+

UFW’s default policy is to deny everything. We must specify what we want to allow through our firewall.

+
# Allow terminal connections to your game
+sudo ufw allow 4000/tcp
+# Allow browser connections to your website
+sudo ufw allow 4001/tcp
+
+
+

Use ONE of the next two commands depending on which port your ssh daemon is listening on:

+
sudo ufw allow 22/tcp
+sudo ufw allow 443/tcp
+
+
+

Finally:

+
sudo ufw enable
+
+
+

Now the only ports open will be your administrative ssh port (whichever you chose), and Evennia on 4000-4001.

+
+
+

Use an external webserver / proxy

+

There are some benefits to deploying a proxy in front of your Evennia server; notably it means you can serve Evennia website and webclient data from an HTTPS: url (with encryption). Any proxy can be used, for example:

+
-[HaProxy](./Config-HAProxy.md)
+-[Apache as a proxy](./Config-Apache-Proxy.md)
+- Nginx 
+- etc.
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Settings-Default.html b/docs/latest/Setup/Settings-Default.html new file mode 100644 index 0000000000..f29452d757 --- /dev/null +++ b/docs/latest/Setup/Settings-Default.html @@ -0,0 +1,1414 @@ + + + + + + + + + Evennia Default settings file — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Evennia Default settings file

+

Master file is located at evennia/evennia/settings_default.py. Read +its comments to see what each setting does and copy only what you want +to change into mygame/server/conf/settings.py.

+

Example of accessing settings:

+
from django.conf import settings
+
+if settings.SERVERNAME == "Evennia":
+    print("Yay!")
+
+
+
+
"""
+Master configuration file for Evennia.
+
+NOTE: NO MODIFICATIONS SHOULD BE MADE TO THIS FILE!
+
+All settings changes should be done by copy-pasting the variable and
+its value to <gamedir>/server/conf/settings.py.
+
+Hint: Don't copy&paste over more from this file than you actually want
+to change.  Anything you don't copy&paste will thus retain its default
+value - which may change as Evennia is developed. This way you can
+always be sure of what you have changed and what is default behaviour.
+
+"""
+import os
+import sys
+
+from django.contrib.messages import constants as messages
+from django.urls import reverse_lazy
+
+######################################################################
+# Evennia base server config
+######################################################################
+
+# This is the name of your game. Make it catchy!
+SERVERNAME = "Evennia"
+# Short one-sentence blurb describing your game. Shown under the title
+# on the website and could be used in online listings of your game etc.
+GAME_SLOGAN = "The Python MUD/MU* creation system"
+# The url address to your server, like mymudgame.com. This should be the publicly
+# visible location. This is used e.g. on the web site to show how you connect to the
+# game over telnet. Default is localhost (only on your machine).
+SERVER_HOSTNAME = "localhost"
+# Lockdown mode will cut off the game from any external connections
+# and only allow connections from localhost. Requires a cold reboot.
+LOCKDOWN_MODE = False
+# Controls whether new account registration is available.
+# Set to False to lock down the registration page and the create account command.
+NEW_ACCOUNT_REGISTRATION_ENABLED = True
+# Activate telnet service
+TELNET_ENABLED = True
+# A list of ports the Evennia telnet server listens on Can be one or many.
+TELNET_PORTS = [4000]
+# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
+TELNET_INTERFACES = ["0.0.0.0"]
+# Activate Telnet+SSL protocol (SecureSocketLibrary) for supporting clients
+SSL_ENABLED = False
+# Ports to use for Telnet+SSL
+SSL_PORTS = [4003]
+# Telnet+SSL Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
+SSL_INTERFACES = ["0.0.0.0"]
+# Telnet+SSL certificate issuers. Don't change unless you have issues, e.g. CN may need to be
+# changed to your server's hostname.
+SSL_CERTIFICATE_ISSUER = {
+    "C": "EV",
+    "ST": "Evennia",
+    "L": "Evennia",
+    "O": "Evennia Security",
+    "OU": "Evennia Department",
+    "CN": "evennia",
+}
+# OOB (out-of-band) telnet communication allows Evennia to communicate
+# special commands and data with enabled Telnet clients. This is used
+# to create custom client interfaces over a telnet connection. To make
+# full use of OOB, you need to prepare functions to handle the data
+# server-side (see INPUT_FUNC_MODULES). TELNET_ENABLED is required for this
+# to work.
+TELNET_OOB_ENABLED = False
+# Activate SSH protocol communication (SecureShell)
+SSH_ENABLED = False
+# Ports to use for SSH
+SSH_PORTS = [4004]
+# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
+SSH_INTERFACES = ["0.0.0.0"]
+# Start the evennia django+twisted webserver so you can
+# browse the evennia website and the admin interface
+# (Obs - further web configuration can be found below
+# in the section  'Config for Django web features')
+WEBSERVER_ENABLED = True
+# This is a security setting protecting against host poisoning
+# attacks.  It defaults to allowing all. In production, make
+# sure to change this to your actual host addresses/IPs.
+ALLOWED_HOSTS = ["*"]
+# The webserver sits behind a Portal proxy. This is a list
+# of tuples (proxyport,serverport) used. The proxyports are what
+# the Portal proxy presents to the world. The serverports are
+# the internal ports the proxy uses to forward data to the Server-side
+# webserver (these should not be publicly open)
+WEBSERVER_PORTS = [(4001, 4005)]
+# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
+WEBSERVER_INTERFACES = ["0.0.0.0"]
+# IP addresses that may talk to the server in a reverse proxy configuration,
+# like NginX or Varnish. These can be either specific IPv4 or IPv6 addresses,
+# or subnets in CIDR format - like 192.168.0.0/24 or 2001:db8::/32.
+UPSTREAM_IPS = ["127.0.0.1"]
+# The webserver uses threadpool for handling requests. This will scale
+# with server load. Set the minimum and maximum number of threads it
+# may use as (min, max) (must be > 0)
+WEBSERVER_THREADPOOL_LIMITS = (1, 20)
+# Start the evennia webclient. This requires the webserver to be running and
+# offers the fallback ajax-based webclient backbone for browsers not supporting
+# the websocket one.
+WEBCLIENT_ENABLED = True
+# Activate Websocket support for modern browsers. If this is on, the
+# default webclient will use this and only use the ajax version if the browser
+# is too old to support websockets. Requires WEBCLIENT_ENABLED.
+WEBSOCKET_CLIENT_ENABLED = True
+# Server-side websocket port to open for the webclient. Note that this value will
+# be dynamically encoded in the webclient html page to allow the webclient to call
+# home. If the external encoded value needs to be different than this, due to
+# working through a proxy or docker port-remapping, the environment variable
+# WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the
+# front-facing client's sake.
+WEBSOCKET_CLIENT_PORT = 4002
+# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
+WEBSOCKET_CLIENT_INTERFACE = "0.0.0.0"
+# Actual URL for webclient component to reach the websocket. You only need
+# to set this if you know you need it, like using some sort of proxy setup.
+# If given it must be on the form "ws[s]://hostname[:port]". If left at None,
+# the client will itself figure out this url based on the server's hostname.
+# e.g. ws://external.example.com or wss://external.example.com:443
+WEBSOCKET_CLIENT_URL = None
+# This determine's whether Evennia's custom admin page is used, or if the
+# standard Django admin is used.
+EVENNIA_ADMIN = True
+# The Server opens an AMP port so that the portal can
+# communicate with it. This is an internal functionality of Evennia, usually
+# operating between two processes on the same machine. You usually don't need to
+# change this unless you cannot use the default AMP port/host for
+# whatever reason.
+AMP_ENABLED = True
+AMP_HOST = "localhost"
+AMP_PORT = 4006
+AMP_INTERFACE = "127.0.0.1"
+
+
+# Path to the lib directory containing the bulk of the codebase's code.
+EVENNIA_DIR = os.path.dirname(os.path.abspath(__file__))
+# Path to the game directory (containing the server/conf/settings.py file)
+# This is dynamically created- there is generally no need to change this!
+if EVENNIA_DIR.lower() == os.getcwd().lower() or (
+    sys.argv[1] == "test" if len(sys.argv) > 1 else False
+):
+    # unittesting mode
+    GAME_DIR = os.getcwd()
+else:
+    # Fallback location (will be replaced by the actual game dir at runtime)
+    GAME_DIR = os.path.join(EVENNIA_DIR, "game_template")
+    for i in range(10):
+        gpath = os.getcwd()
+        if "server" in os.listdir(gpath):
+            if os.path.isfile(os.path.join("server", "conf", "settings.py")):
+                GAME_DIR = gpath
+                break
+        os.chdir(os.pardir)
+# Place to put log files, how often to rotate the log and how big each log file
+# may become before rotating.
+LOG_DIR = os.path.join(GAME_DIR, "server", "logs")
+SERVER_LOG_FILE = os.path.join(LOG_DIR, "server.log")
+SERVER_LOG_DAY_ROTATION = 7
+SERVER_LOG_MAX_SIZE = 1000000
+PORTAL_LOG_FILE = os.path.join(LOG_DIR, "portal.log")
+PORTAL_LOG_DAY_ROTATION = 7
+PORTAL_LOG_MAX_SIZE = 1000000
+# The http log is usually only for debugging since it's very spammy
+HTTP_LOG_FILE = os.path.join(LOG_DIR, "http_requests.log")
+# if this is set to the empty string, lockwarnings will be turned off.
+LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, "lockwarnings.log")
+# Number of lines to append to rotating channel logs when they rotate
+CHANNEL_LOG_NUM_TAIL_LINES = 20
+# Max size (in bytes) of channel log files before they rotate.
+# Minimum is 1000 (1kB) but should usually be larger.
+CHANNEL_LOG_ROTATE_SIZE = 1000000
+# Unused by default, but used by e.g. the MapSystem contrib. A place for storing
+# semi-permanent data and avoid it being rebuilt over and over. It is created
+# on-demand only.
+CACHE_DIR = os.path.join(GAME_DIR, "server", ".cache")
+# Local time zone for this installation. All choices can be found here:
+# http://www.postgresql.org/docs/8.0/interactive/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE
+TIME_ZONE = "UTC"
+# Activate time zone in datetimes
+USE_TZ = True
+# Authentication backends. This is the code used to authenticate a user.
+AUTHENTICATION_BACKENDS = ["evennia.web.utils.backends.CaseInsensitiveModelBackend"]
+# Language code for this installation. All choices can be found here:
+# http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
+LANGUAGE_CODE = "en-us"
+# How long time (in seconds) a user may idle before being logged
+# out. This can be set as big as desired. A user may avoid being
+# thrown off by sending the empty system command 'idle' to the server
+# at regular intervals. Set <=0 to deactivate idle timeout completely.
+IDLE_TIMEOUT = -1
+# The idle command can be sent to keep your session active without actually
+# having to spam normal commands regularly. It gives no feedback, only updates
+# the idle timer. Note that "idle" will *always* work, even if a different
+# command-name is given here; this is because the webclient needs a default
+# to send to avoid proxy timeouts.
+IDLE_COMMAND = "idle"
+# The set of encodings tried. An Account object may set an attribute "encoding" on
+# itself to match the client used. If not set, or wrong encoding is
+# given, this list is tried, in order, aborting on the first match.
+# Add sets for languages/regions your accounts are likely to use.
+# (see http://en.wikipedia.org/wiki/Character_encoding)
+# Telnet default encoding, unless specified by the client, will be ENCODINGS[0].
+ENCODINGS = ["utf-8", "latin-1", "ISO-8859-1"]
+# Regular expression applied to all output to a given session in order
+# to strip away characters (usually various forms of decorations) for the benefit
+# of users with screen readers. Note that ANSI/MXP doesn't need to
+# be stripped this way, that is handled automatically.
+SCREENREADER_REGEX_STRIP = r"\+-+|\+$|\+~|--+|~~+|==+"
+# MXP support means the ability to show clickable links in the client. Clicking
+# the link will execute a game command. It's a way to add mouse input to the game.
+MXP_ENABLED = True
+# If this is set, MXP can only be sent by the server and not added from the
+# client side. Disabling this is a potential security risk because it could
+# allow malevolent players to lure others to execute commands they did not
+# intend to.
+MXP_OUTGOING_ONLY = True
+# Database objects are cached in what is known as the idmapper. The idmapper
+# caching results in a massive speedup of the server (since it dramatically
+# limits the number of database accesses needed) and also allows for
+# storing temporary data on objects. It is however also the main memory
+# consumer of Evennia. With this setting the cache can be capped and
+# flushed when it reaches a certain size. Minimum is 50 MB but it is
+# not recommended to set this to less than 100 MB for a distribution
+# system.
+# Empirically, N_objects_in_cache ~ ((RMEM - 35) / 0.0157):
+#  mem(MB)   |  objs in cache   ||   mem(MB)   |   objs in cache
+#      50    |       ~1000      ||      800    |     ~49 000
+#     100    |       ~4000      ||     1200    |     ~75 000
+#     200    |      ~10 000     ||     1600    |    ~100 000
+#     500    |      ~30 000     ||     2000    |    ~125 000
+# Note that the estimated memory usage is not exact (and the cap is only
+# checked every 5 minutes), so err on the side of caution if
+# running on a server with limited memory. Also note that Python
+# will not necessarily return the memory to the OS when the idmapper
+# flashes (the memory will be freed and made available to the Python
+# process only). How many objects need to be in memory at any given
+# time depends very much on your game so some experimentation may
+# be necessary (use @server to see how many objects are in the idmapper
+# cache at any time). Setting this to None disables the cache cap.
+IDMAPPER_CACHE_MAXSIZE = 200  # (MB)
+# This determines how many connections per second the Portal should
+# accept, as a DoS countermeasure. If the rate exceeds this number, incoming
+# connections will be queued to this rate, so none will be lost.
+# Must be set to a value > 0.
+MAX_CONNECTION_RATE = 2
+# Determine how many commands per second a given Session is allowed
+# to send to the Portal via a connected protocol. Too high rate will
+# drop the command and echo a warning. Note that this will also cap
+# OOB messages so don't set it too low if you expect a lot of events
+# from the client! To turn the limiter off, set to <= 0.
+MAX_COMMAND_RATE = 80
+# The warning to echo back to users if they send commands too fast
+COMMAND_RATE_WARNING = "You entered commands too fast. Wait a moment and try again."
+# custom, extra commands to add to the `evennia` launcher. This is a dict
+# of {'cmdname': 'path.to.callable', ...}, where the callable will be passed
+# any extra args given on the command line. For example `evennia cmdname foo bar`.
+EXTRA_LAUNCHER_COMMANDS = {}
+
+# Determine how large of a string can be sent to the server in number
+# of characters. If they attempt to enter a string over this character
+# limit, we stop them and send a message. To make unlimited, set to
+# 0 or less.
+MAX_CHAR_LIMIT = 6000
+# The warning to echo back to users if they enter a very large string
+MAX_CHAR_LIMIT_WARNING = (
+    "You entered a string that was too long. Please break it up into multiple parts."
+)
+# If this is true, errors and tracebacks from the engine will be
+# echoed as text in-game as well as to the log. This can speed up
+# debugging. OBS: Showing full tracebacks to regular users could be a
+# security problem -turn this off in a production game!
+IN_GAME_ERRORS = True
+# Broadcast "Server restart"-like messages to all sessions.
+BROADCAST_SERVER_RESTART_MESSAGES = True
+
+######################################################################
+# Evennia Database config
+######################################################################
+
+# Database config syntax:
+# ENGINE - path to the the database backend. Possible choices are:
+#            'django.db.backends.sqlite3', (default)
+#            'django.db.backends.mysql',
+#            'django.db.backends.postgresql',
+#            'django.db.backends.oracle' (untested).
+# NAME - database name, or path to the db file for sqlite3
+# USER - db admin (unused in sqlite3)
+# PASSWORD - db admin password (unused in sqlite3)
+# HOST - empty string is localhost (unused in sqlite3)
+# PORT - empty string defaults to localhost (unused in sqlite3)
+DATABASES = {
+    "default": {
+        "ENGINE": "django.db.backends.sqlite3",
+        "NAME": os.getenv("TEST_DB_PATH", os.path.join(GAME_DIR, "server", "evennia.db3")),
+        "USER": "",
+        "PASSWORD": "",
+        "HOST": "",
+        "PORT": "",
+    }
+}
+# How long the django-database connection should be kept open, in seconds.
+# If you get errors about the database having gone away after long idle
+# periods, shorten this value (e.g. MySQL defaults to a timeout of 8 hrs)
+CONN_MAX_AGE = 3600 * 7
+# When removing or renaming models, such models stored in Attributes may
+# become orphaned and will return as None. If the change is a rename (that
+# is, there is a 1:1 pk mapping between the old and the new), the unserializer
+# can convert old to new when retrieving them. This is a list of tuples
+# (old_natural_key, new_natural_key). Note that Django ContentTypes'
+# natural_keys are themselves tuples (appname, modelname). Creation-dates will
+# not be checked for models specified here. If new_natural_key does not exist,
+# `None` will be returned and stored back as if no replacement was set.
+ATTRIBUTE_STORED_MODEL_RENAME = [
+    (("players", "playerdb"), ("accounts", "accountdb")),
+    (("typeclasses", "defaultplayer"), ("typeclasses", "defaultaccount")),
+]
+# Default type of autofield (required by Django), which defines the type of
+# primary key fields for all tables. This type is guaranteed to be at least a
+# 64-bit integer.
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+######################################################################
+# Evennia webclient options
+######################################################################
+
+# default webclient options (without user changing it)
+WEBCLIENT_OPTIONS = {
+    # Gags prompts in output window and puts them on the input bar
+    "gagprompt": True,
+    # Shows help files in a new popup window instead of in-pane
+    "helppopup": False,
+    # Shows notifications of new messages as popup windows
+    "notification_popup": False,
+    # Plays a sound for notifications of new messages
+    "notification_sound": False,
+}
+
+######################################################################
+# Evennia pluggable modules
+######################################################################
+# Plugin modules extend Evennia in various ways. In the cases with no
+# existing default, there are examples of many of these modules
+# in contrib/examples.
+
+# The command parser module to use. See the default module for which
+# functions it must implement
+COMMAND_PARSER = "evennia.commands.cmdparser.cmdparser"
+# On a multi-match when search objects or commands, the user has the
+# ability to search again with an index marker that differentiates
+# the results. If multiple "box" objects
+# are found, they can by default be separated as 1-box, 2-box. Below you
+# can change the regular expression used. The regex must have one
+# have two capturing groups (?P<number>...) and (?P<name>...) - the default
+# parser expects this. It should also involve a number starting from 1.
+# When changing this you must also update SEARCH_MULTIMATCH_TEMPLATE
+# to properly describe the syntax.
+SEARCH_MULTIMATCH_REGEX = r"(?P<name>[^-]*)-(?P<number>[0-9]+)(?P<args>.*)"
+# To display multimatch errors in various listings we must display
+# the syntax in a way that matches what SEARCH_MULTIMATCH_REGEX understand.
+# The template will be populated with data and expects the following markup:
+# {number} - the order of the multimatch, starting from 1; {name} - the
+# name (key) of the multimatched entity; {aliases} - eventual
+# aliases for the entity; {info} - extra info like #dbrefs for staff. Don't
+# forget a line break if you want one match per line.
+SEARCH_MULTIMATCH_TEMPLATE = " {name}-{number}{aliases}{info}\n"
+# The handler that outputs errors when using any API-level search
+# (not manager methods). This function should correctly report errors
+# both for command- and object-searches. This allows full control
+# over the error output (it uses SEARCH_MULTIMATCH_TEMPLATE by default).
+SEARCH_AT_RESULT = "evennia.utils.utils.at_search_result"
+# Single characters to ignore at the beginning of a command. When set, e.g.
+# cmd, @cmd and +cmd will all find a command "cmd" or one named "@cmd" etc. If
+# you have defined two different commands cmd and @cmd you can still enter
+# @cmd to exactly target the second one. Single-character commands consisting
+# of only a prefix character will not be stripped. Set to the empty
+# string ("") to turn off prefix ignore.
+CMD_IGNORE_PREFIXES = "@&/+"
+# The module holding text strings for the connection screen.
+# This module should contain one or more variables
+# with strings defining the look of the screen.
+CONNECTION_SCREEN_MODULE = "server.conf.connection_screens"
+# Delay to use before sending the evennia.syscmdkeys.CMD_LOGINSTART Command
+# when a new session connects (this defaults the unloggedin-look for showing
+# the connection screen). The delay is useful mainly for telnet, to allow
+# client/server to establish client capabilities like color/mxp etc before
+# sending any text. A value of 0.3 should be enough. While a good idea, it may
+# cause issues with menu-logins and autoconnects since the menu will not have
+# started when the autoconnects starts sending menu commands.
+DELAY_CMD_LOGINSTART = 0.3
+# A module that must exist - this holds the instructions Evennia will use to
+# first prepare the database for use (create user #1 and Limbo etc). Only override if
+# you really know what # you are doing. If replacing, it must contain a function
+# handle_setup(stepname=None). The function will start being called with no argument
+# and is expected to maintain a named sequence of steps. Once each step is completed, it
+# should be saved with ServerConfig.objects.conf('last_initial_setup_step', stepname)
+# on a crash, the system will continue by calling handle_setup with the last completed
+# step. The last step in the sequence must be named 'done'. Once this key is saved,
+# initialization will not run again.
+INITIAL_SETUP_MODULE = "evennia.server.initial_setup"
+# An optional module that, if existing, must hold a function
+# named at_initial_setup(). This hook method can be used to customize
+# the server's initial setup sequence (the very first startup of the system).
+# The check will fail quietly if module doesn't exist or fails to load.
+AT_INITIAL_SETUP_HOOK_MODULE = "server.conf.at_initial_setup"
+# Module(s) containing custom at_server_init(), at_server_start(),
+# at_server_reload() and at_server_stop() methods. These methods will be called
+# every time the server starts, reloads and resets/stops
+# respectively. Can be given as a single path or a list of paths. If a list,
+# each module's hooks will be called in list order.
+AT_SERVER_STARTSTOP_MODULE = "server.conf.at_server_startstop"
+# List of one or more module paths to modules containing a function start_
+# plugin_services(application). This module will be called with the main
+# Evennia Server application when the Server is initiated.
+# It will be called last in the startup sequence.
+SERVER_SERVICES_PLUGIN_MODULES = ["server.conf.server_services_plugins"]
+# List of one or more module paths to modules containing a function
+# start_plugin_services(application). This module will be called with the
+# main Evennia Portal application when the Portal is initiated.
+# It will be called last in the startup sequence.
+PORTAL_SERVICES_PLUGIN_MODULES = ["server.conf.portal_services_plugins"]
+# Module holding MSSP meta data. This is used by MUD-crawlers to determine
+# what type of game you are running, how many accounts you have etc.
+MSSP_META_MODULE = "server.conf.mssp"
+# Module for web plugins.
+WEB_PLUGINS_MODULE = "server.conf.web_plugins"
+# Tuple of modules implementing lock functions. All callable functions
+# inside these modules will be available as lock functions.
+LOCK_FUNC_MODULES = ("evennia.locks.lockfuncs", "server.conf.lockfuncs")
+# Module holding handlers for managing incoming data from the client. These
+# will be loaded in order, meaning functions in later modules may overload
+# previous ones if having the same name.
+INPUT_FUNC_MODULES = ["evennia.server.inputfuncs", "server.conf.inputfuncs"]
+# Modules that contain prototypes for use with the spawner mechanism.
+PROTOTYPE_MODULES = ["world.prototypes"]
+# Modules containining Prototype functions able to be embedded in prototype
+# definitions from in-game.
+PROT_FUNC_MODULES = ["evennia.prototypes.protfuncs"]
+# Module holding settings/actions for the dummyrunner program (see the
+# dummyrunner for more information)
+DUMMYRUNNER_SETTINGS_MODULE = "evennia.server.profiling.dummyrunner_settings"
+# Mapping to extend Evennia's normal ANSI color tags. The mapping is a list of
+# tuples mapping the exact tag (not a regex!) to the ANSI convertion, like
+# `(r"%c%r", ansi.ANSI_RED)` (the evennia.utils.ansi module contains all
+# ANSI escape sequences). Default is to use `|` and `|[` -prefixes.
+COLOR_ANSI_EXTRA_MAP = []
+# Extend the available regexes for adding XTERM256 colors in-game. This is given
+# as a list of regexes, where each regex must contain three anonymous groups for
+# holding integers 0-5 for the red, green and blue components Default is
+# is r'\|([0-5])([0-5])([0-5])', which allows e.g. |500 for red.
+# XTERM256 foreground color replacement
+COLOR_XTERM256_EXTRA_FG = []
+# XTERM256 background color replacement. Default is \|\[([0-5])([0-5])([0-5])'
+COLOR_XTERM256_EXTRA_BG = []
+# Extend the available regexes for adding XTERM256 grayscale values in-game. Given
+# as a list of regexes, where each regex must contain one anonymous group containing
+# a single letter a-z to mark the level from white to black. Default is r'\|=([a-z])',
+# which allows e.g. |=k for a medium gray.
+# XTERM256 grayscale foreground
+COLOR_XTERM256_EXTRA_GFG = []
+# XTERM256 grayscale background. Default is \|\[=([a-z])'
+COLOR_XTERM256_EXTRA_GBG = []
+# ANSI does not support bright backgrounds, so Evennia fakes this by mapping it to
+# XTERM256 backgrounds where supported. This is a list of tuples that maps the wanted
+# ansi tag (not a regex!) to a valid XTERM256 background tag, such as `(r'{[r', r'{[500')`.
+COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP = []
+# If set True, the above color settings *replace* the default |-style color markdown
+# rather than extend it.
+COLOR_NO_DEFAULT = False
+
+
+######################################################################
+# Default command sets and commands
+######################################################################
+
+# Command set used on session before account has logged in
+CMDSET_UNLOGGEDIN = "commands.default_cmdsets.UnloggedinCmdSet"
+# (Note that changing these three following cmdset paths will only affect NEW
+# created characters/objects, not those already in play. So if you want to
+# change this and have it apply to every object, it's recommended you do it
+# before having created a lot of objects (or simply reset the database after
+# the change for simplicity)).
+# Command set used on the logged-in session
+CMDSET_SESSION = "commands.default_cmdsets.SessionCmdSet"
+# Default set for logged in account with characters (fallback)
+CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet"
+# Command set for accounts without a character (ooc)
+CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet"
+
+# Location to search for cmdsets if full path not given
+CMDSET_PATHS = ["commands", "evennia", "evennia.contrib"]
+# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your
+# default cmdsets, you will also need to copy CMDSET_FALLBACKS after your change in your
+# settings file for it to detect the change.
+CMDSET_FALLBACKS = {
+    CMDSET_CHARACTER: "evennia.commands.default.cmdset_character.CharacterCmdSet",
+    CMDSET_ACCOUNT: "evennia.commands.default.cmdset_account.AccountCmdSet",
+    CMDSET_SESSION: "evennia.commands.default.cmdset_session.SessionCmdSet",
+    CMDSET_UNLOGGEDIN: "evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet",
+}
+# Parent class for all default commands. Changing this class will
+# modify all default commands, so do so carefully.
+COMMAND_DEFAULT_CLASS = "evennia.commands.default.muxcommand.MuxCommand"
+# Command.arg_regex is a regular expression desribing how the arguments
+# to the command must be structured for the command to match a given user
+# input. By default the command-name should end with a space or / (since the
+# default commands uses MuxCommand and /switches). Note that the extra \n
+# is necessary for use with batchprocessor.
+COMMAND_DEFAULT_ARG_REGEX = r"^[ /]|\n|$"
+# By default, Command.msg will only send data to the Session calling
+# the Command in the first place. If set, Command.msg will instead return
+# data to all Sessions connected to the Account/Character associated with
+# calling the Command. This may be more intuitive for users in certain
+# multisession modes.
+COMMAND_DEFAULT_MSG_ALL_SESSIONS = False
+# The default lockstring of a command.
+COMMAND_DEFAULT_LOCKS = ""
+
+######################################################################
+# Typeclasses and other paths
+######################################################################
+
+# These are paths that will be prefixed to the paths given if the
+# immediately entered path fail to find a typeclass. It allows for
+# shorter input strings. They must either base off the game directory
+# or start from the evennia library.
+TYPECLASS_PATHS = [
+    "typeclasses",
+    "evennia",
+    "evennia.contrib",
+    "evennia.contrib.game_systems",
+    "evennia.contrib.base_systems",
+    "evennia.contrib.full_systems",
+    "evennia.contrib.tutorials",
+    "evennia.contrib.utils",
+]
+
+# Typeclass for account objects (linked to a character) (fallback)
+BASE_ACCOUNT_TYPECLASS = "typeclasses.accounts.Account"
+# Typeclass and base for all objects (fallback)
+BASE_OBJECT_TYPECLASS = "typeclasses.objects.Object"
+# Typeclass for character objects linked to an account (fallback)
+BASE_CHARACTER_TYPECLASS = "typeclasses.characters.Character"
+# Typeclass for rooms (fallback)
+BASE_ROOM_TYPECLASS = "typeclasses.rooms.Room"
+# Typeclass for Exit objects (fallback).
+BASE_EXIT_TYPECLASS = "typeclasses.exits.Exit"
+# Typeclass for Channel (fallback).
+BASE_CHANNEL_TYPECLASS = "typeclasses.channels.Channel"
+# Typeclass for Scripts (fallback). You usually don't need to change this
+# but create custom variations of scripts on a per-case basis instead.
+BASE_SCRIPT_TYPECLASS = "typeclasses.scripts.Script"
+# The default home location used for all objects. This is used as a
+# fallback if an object's normal home location is deleted. Default
+# is Limbo (#2).
+DEFAULT_HOME = "#2"
+# The start position for new characters. Default is Limbo (#2).
+START_LOCATION = "#2"
+# Lookups of Attributes, Tags, Nicks, Aliases can be aggressively
+# cached to avoid repeated database hits. This often gives noticeable
+# performance gains since they are called so often. Drawback is that
+# if you are accessing the database from multiple processes (such as
+# from a website -not- running Evennia's own webserver) data may go
+# out of sync between the processes. Keep on unless you face such
+# issues.
+TYPECLASS_AGGRESSIVE_CACHE = True
+# These are fallbacks for BASE typeclasses failing to load. Usually needed only
+# during doc building. The system expects these to *always* load correctly, so
+# only modify if you are making fundamental changes to how objects/accounts
+# work and know what you are doing
+FALLBACK_ACCOUNT_TYPECLASS = "evennia.accounts.accounts.DefaultAccount"
+FALLBACK_OBJECT_TYPECLASS = "evennia.objects.objects.DefaultObject"
+FALLBACK_CHARACTER_TYPECLASS = "evennia.objects.objects.DefaultCharacter"
+FALLBACK_ROOM_TYPECLASS = "evennia.objects.objects.DefaultRoom"
+FALLBACK_EXIT_TYPECLASS = "evennia.objects.objects.DefaultExit"
+FALLBACK_CHANNEL_TYPECLASS = "evennia.comms.comms.DefaultChannel"
+FALLBACK_SCRIPT_TYPECLASS = "evennia.scripts.scripts.DefaultScript"
+
+
+######################################################################
+# Options and validators
+######################################################################
+
+# Options available on Accounts. Each such option is described by a
+# class available from evennia.OPTION_CLASSES, in turn making use
+# of validators from evennia.VALIDATOR_FUNCS to validate input when
+# the user changes an option. The options are accessed through the
+# `Account.options` handler.
+
+# ("Description", 'Option Class name in evennia.OPTION_CLASS_MODULES', 'Default Value')
+
+OPTIONS_ACCOUNT_DEFAULT = {
+    "border_color": ("Headers, footers, table borders, etc.", "Color", "n"),
+    "header_star_color": ("* inside Header lines.", "Color", "n"),
+    "header_text_color": ("Text inside Header lines.", "Color", "w"),
+    "header_fill": ("Fill for Header lines.", "Text", "="),
+    "separator_star_color": ("* inside Separator lines.", "Color", "n"),
+    "separator_text_color": ("Text inside Separator lines.", "Color", "w"),
+    "separator_fill": ("Fill for Separator Lines.", "Text", "-"),
+    "footer_star_color": ("* inside Footer lines.", "Color", "n"),
+    "footer_text_color": ("Text inside Footer Lines.", "Color", "n"),
+    "footer_fill": ("Fill for Footer Lines.", "Text", "="),
+    "column_names_color": ("Table column header text.", "Color", "w"),
+    "timezone": ("Timezone for dates.", "Timezone", "UTC"),
+}
+# Modules holding Option classes, responsible for serializing the option and
+# calling validator functions on it. Same-named functions in modules added
+# later in this list will override those added earlier.
+OPTION_CLASS_MODULES = ["evennia.utils.optionclasses"]
+# Module holding validator functions. These are used as a resource for
+# validating options, but can also be used as input validators in general.
+# Same-named functions in modules added later in this list will override those
+# added earlier.
+VALIDATOR_FUNC_MODULES = ["evennia.utils.validatorfuncs"]
+
+######################################################################
+# Batch processors
+######################################################################
+
+# Python path to a directory to be searched for batch scripts
+# for the batch processors (.ev and/or .py files).
+BASE_BATCHPROCESS_PATHS = [
+    "world",
+    "evennia.contrib",
+    "evennia.contrib.tutorials",
+]
+
+######################################################################
+# Game Time setup
+######################################################################
+
+# You don't actually have to use this, but it affects the routines in
+# evennia.utils.gametime.py and allows for a convenient measure to
+# determine the current in-game time. You can of course interpret
+# "week", "month" etc as your own in-game time units as desired.
+
+# The time factor dictates if the game world runs faster (timefactor>1)
+# or slower (timefactor<1) than the real world.
+TIME_FACTOR = 2.0
+# The starting point of your game time (the epoch), in seconds.
+# In Python a value of 0 means Jan 1 1970 (use negatives for earlier
+# start date). This will affect the returns from the utils.gametime
+# module. If None, the server's first start-time is used as the epoch.
+TIME_GAME_EPOCH = None
+# Normally, game time will only increase when the server runs. If this is True,
+# game time will not pause when the server reloads or goes offline. This setting
+# together with a time factor of 1 should keep the game in sync with
+# the real time (add a different epoch to shift time)
+TIME_IGNORE_DOWNTIMES = False
+
+######################################################################
+# Help system
+######################################################################
+# Help output from CmdHelp are wrapped in an EvMore call
+# (excluding webclient with separate help popups). If continuous scroll
+# is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT
+HELP_MORE_ENABLED = True
+# The help category of a command if not specified.
+COMMAND_DEFAULT_HELP_CATEGORY = "general"
+# The help category of a db or file-based help entry if not specified
+DEFAULT_HELP_CATEGORY = "general"
+# File-based help entries. These are modules containing dicts defining help
+# entries. They can be used together with in-database entries created in-game.
+FILE_HELP_ENTRY_MODULES = ["world.help_entries"]
+# if topics listed in help should be clickable
+# clickable links only work on clients that support MXP.
+HELP_CLICKABLE_TOPICS = True
+# The Lunr search engine (used by help) excludes 'common' words from its search.
+# This is not so good when those words are names of commands, like who or say;
+# so we need to make sure to tell Lunr to not filter them out by adding them here
+# (many are auto-added out of the box, this extends the list).
+LUNR_STOP_WORD_FILTER_EXCEPTIONS = []
+
+######################################################################
+# FuncParser
+#
+# Strings parsed with the FuncParser can contain 'callables' on the
+# form $funcname(args,kwargs), which will lead to actual Python functions
+# being executed.
+######################################################################
+# This changes the start-symbol for the funcparser callable. Note that
+# this will make a lot of documentation invalid and there may also be
+# other unexpected side effects, so change with caution.
+FUNCPARSER_START_CHAR = "$"
+# The symbol to use to escape Func
+FUNCPARSER_ESCAPE_CHAR = "\\"
+# This is the global max nesting-level for nesting functions in
+# the funcparser. This protects against infinite loops.
+FUNCPARSER_MAX_NESTING = 20
+# Activate funcparser for all outgoing strings. The current Session
+# will be passed into the parser (used to be called inlinefuncs)
+FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED = False
+# Only functions defined globally (and not starting with '_') in
+# these modules will be considered valid inlinefuncs. The list
+# is loaded from left-to-right, same-named functions will overload
+FUNCPARSER_OUTGOING_MESSAGES_MODULES = ["evennia.utils.funcparser", "server.conf.inlinefuncs"]
+# Prototype values are also parsed with FuncParser. These modules
+# define which $func callables are available to use in prototypes.
+FUNCPARSER_PROTOTYPE_PARSING_MODULES = [
+    "evennia.prototypes.protfuncs",
+    "server.conf.prototypefuncs",
+]
+
+######################################################################
+# Global Scripts
+######################################################################
+
+# Global scripts started here will be available through
+# 'evennia.GLOBAL_SCRIPTS.key'. The scripts will survive a reload and be
+# recreated automatically if deleted. Each entry must have the script keys,
+# whereas all other fields in the specification are optional. If 'typeclass' is
+# not given, BASE_SCRIPT_TYPECLASS will be assumed.  Note that if you change
+# typeclass for the same key, a new Script will replace the old one on
+# `evennia.GLOBAL_SCRIPTS`.
+GLOBAL_SCRIPTS = {
+    # 'key': {'typeclass': 'typeclass.path.here',
+    #         'repeats': -1, 'interval': 50, 'desc': 'Example script'},
+}
+
+######################################################################
+# Default Account setup and access
+######################################################################
+
+# Different Multisession modes allow a player (=account) to connect to the
+# game simultaneously with multiple clients (=sessions).
+#  0 - single session per account (if reconnecting, disconnect old session)
+#  1 - multiple sessions per account, all sessions share output
+#  2 - multiple sessions per account, one session allowed per puppet
+#  3 - multiple sessions per account, multiple sessions per puppet (share output)
+#      session getting the same data.
+MULTISESSION_MODE = 0
+# Whether we should create a character with the same name as the account when
+# a new account is created. Together with AUTO_PUPPET_ON_LOGIN, this mimics
+# a legacy MUD, where there is no difference between account and character.
+AUTO_CREATE_CHARACTER_WITH_ACCOUNT = True
+# Whether an account should auto-puppet the last puppeted puppet when logging in. This
+# will only work if the session/puppet combination can be determined (usually
+# MULTISESSION_MODE 0 or 1), otherwise, the player will end up OOC. Use
+# MULTISESSION_MODE=0, AUTO_CREATE_CHARACTER_WITH_ACCOUNT=True and this value to
+# mimic a legacy mud with minimal difference between Account and Character. Disable
+# this and AUTO_PUPPET to get a chargen/character select screen on login.
+AUTO_PUPPET_ON_LOGIN = True
+# How many *different* characters an account can puppet *at the same time*. A value
+# above 1 only makes a difference together with MULTISESSION_MODE > 1.
+MAX_NR_SIMULTANEOUS_PUPPETS = 1
+# The maximum number of characters allowed by be created by the default ooc
+# char-creation command. This can be seen as how big of a 'stable' of characters
+# an account can have (not how many you can puppet at the same time). Set to
+# None for no limit.
+MAX_NR_CHARACTERS = 1
+# The access hierarchy, in climbing order. A higher permission in the
+# hierarchy includes access of all levels below it. Used by the perm()/pperm()
+# lock functions, which accepts both plural and singular (Admin & Admins)
+PERMISSION_HIERARCHY = [
+    "Guest",  # note-only used if GUEST_ENABLED=True
+    "Player",
+    "Helper",
+    "Builder",
+    "Admin",
+    "Developer",
+]
+# The default permission given to all new accounts
+PERMISSION_ACCOUNT_DEFAULT = "Player"
+# Default sizes for client window (in number of characters), if client
+# is not supplying this on its own
+CLIENT_DEFAULT_WIDTH = 78
+# telnet standard height is 24; does anyone use such low-res displays anymore?
+CLIENT_DEFAULT_HEIGHT = 45
+# Set rate limits per-IP on account creations and login attempts. Set limits
+# to None to disable.
+CREATION_THROTTLE_LIMIT = 2
+CREATION_THROTTLE_TIMEOUT = 10 * 60
+LOGIN_THROTTLE_LIMIT = 5
+LOGIN_THROTTLE_TIMEOUT = 5 * 60
+# Certain characters, like html tags, line breaks and tabs are stripped
+# from user input for commands using the `evennia.utils.strip_unsafe_input` helper
+# since they can be exploitative. This list defines Account-level permissions
+# (and higher) that bypass this stripping. It is used as a fallback if a
+# specific list of perms are not given to the helper function.
+INPUT_CLEANUP_BYPASS_PERMISSIONS = ["Builder"]
+
+
+######################################################################
+# Guest accounts
+######################################################################
+
+# This enables guest logins, by default via "connect guest". Note that
+# you need to edit your login screen to inform about this possibility.
+GUEST_ENABLED = False
+# Typeclass for guest account objects (linked to a character)
+BASE_GUEST_TYPECLASS = "typeclasses.accounts.Guest"
+# The permission given to guests
+PERMISSION_GUEST_DEFAULT = "Guests"
+# The default home location used for guests.
+GUEST_HOME = DEFAULT_HOME
+# The start position used for guest characters.
+GUEST_START_LOCATION = START_LOCATION
+# The naming convention used for creating new guest
+# accounts/characters. The size of this list also determines how many
+# guests may be on the game at once. The default is a maximum of nine
+# guests, named Guest1 through Guest9.
+GUEST_LIST = ["Guest" + str(s + 1) for s in range(9)]
+
+######################################################################
+# In-game Channels created from server start
+######################################################################
+
+# The mudinfo channel is a read-only channel used by Evennia to replay status
+# messages, connection info etc to staff. The superuser will automatically be
+# subscribed to this channel. If set to None, the channel is disabled and
+# status messages will only be logged (not recommended).
+CHANNEL_MUDINFO = {
+    "key": "MudInfo",
+    "aliases": "",
+    "desc": "Connection log",
+    "locks": "control:perm(Developer);listen:perm(Admin);send:false()",
+}
+# Optional channel (same form as CHANNEL_MUDINFO) that will receive connection
+# messages like ("<account> has (dis)connected"). While the MudInfo channel
+# will also receieve this info, this channel is meant for non-staffers. If
+# None, this information will only be logged.
+CHANNEL_CONNECTINFO = None
+# New accounts will auto-sub to the default channels given below (but they can
+# unsub at any time). Traditionally, at least 'public' should exist. Entries
+# will be (re)created on the next reload, but removing or updating a same-key
+# channel from this list will NOT automatically change/remove it in the game,
+# that needs to be done manually. Note: To create other, non-auto-subbed
+# channels, create them manually in server/conf/at_initial_setup.py.
+DEFAULT_CHANNELS = [
+    {
+        "key": "Public",
+        "aliases": ("pub",),
+        "desc": "Public discussion",
+        "locks": "control:perm(Admin);listen:all();send:all()",
+    }
+]
+
+######################################################################
+# External Connections
+######################################################################
+
+# Note: You do *not* have to make your MUD open to
+# the public to use the external connections, they
+# operate as long as you have an internet connection,
+# just like stand-alone chat clients.
+
+# The Evennia Game Index is a dynamic listing of Evennia games. You can add your game
+# to this list also if it is in closed pre-alpha development.
+GAME_INDEX_ENABLED = False
+# This dict
+GAME_INDEX_LISTING = {
+    "game_name": "Mygame",  # usually SERVERNAME
+    "game_status": "pre-alpha",  # pre-alpha, alpha, beta or launched
+    "short_description": "",  # could be GAME_SLOGAN
+    "long_description": "",
+    "listing_contact": "",  # email
+    "telnet_hostname": "",  # mygame.com
+    "telnet_port": "",  # 1234
+    "game_website": "",  # http://mygame.com
+    "web_client_url": "",  # http://mygame.com/webclient
+}
+# Evennia can connect to external IRC channels and
+# echo what is said on the channel to IRC and vice
+# versa. Obs - make sure the IRC network allows bots.
+# When enabled, command @irc2chan will be available in-game
+# IRC requires that you have twisted.words installed.
+IRC_ENABLED = False
+# RSS allows to connect RSS feeds (from forum updates, blogs etc) to
+# an in-game channel. The channel will be updated when the rss feed
+# updates. Use @rss2chan in game to connect if this setting is
+# active. OBS: RSS support requires the python-feedparser package to
+# be installed (through package manager or from the website
+# http://code.google.com/p/feedparser/)
+RSS_ENABLED = False
+RSS_UPDATE_INTERVAL = 60 * 10  # 10 minutes
+# Grapevine (grapevine.haus) is a network for listing MUDs as well as allow
+# users of said MUDs to communicate with each other on shared channels. To use,
+# your game must first be registered by logging in and creating a game entry at
+# https://grapevine.haus. Evennia links grapevine channels to in-game channels
+# with the @grapevine2chan command, available once this flag is set
+# Grapevine requires installing the pyopenssl library (pip install pyopenssl)
+GRAPEVINE_ENABLED = False
+# Grapevine channels to allow connection to. See https://grapevine.haus/chat
+# for the available channels. Only channels in this list can be linked to in-game
+# channels later.
+GRAPEVINE_CHANNELS = ["gossip", "testing"]
+# Grapevine authentication. Register your game at https://grapevine.haus to get
+# them. These are secret and should thus be overridden in secret_settings file
+GRAPEVINE_CLIENT_ID = ""
+GRAPEVINE_CLIENT_SECRET = ""
+# Discord (discord.com) is a popular communication service for many, especially
+# for game communities. Evennia's channels can be connected to Discord channels
+# and relay messages between Evennia and Discord. To use, you will need to create
+# your own Discord application and bot.
+# Discord also requires installing the pyopenssl library.
+# Full step-by-step instructions are available in the official Evennia documentation.
+DISCORD_ENABLED = False
+# The Intents bitmask required by Discord bots to request particular API permissions.
+# By default, this includes the basic guild status and message read/write flags.
+DISCORD_BOT_INTENTS = 105985
+# The authentication token for the Discord bot. This should be kept secret and
+# put in your secret_settings file.
+DISCORD_BOT_TOKEN = None
+# The account typeclass which the Evennia-side Discord relay bot will use.
+DISCORD_BOT_CLASS = "evennia.accounts.bots.DiscordBot"
+
+######################################################################
+# Django web features
+######################################################################
+
+# While DEBUG is False, show a regular server error page on the web
+# stuff, email the traceback to the people in the ADMINS tuple
+# below. If True, show a detailed traceback for the web
+# browser to display. Note however that this will leak memory when
+# active, so make sure to turn it off for a production server!
+DEBUG = False
+# Emails are sent to these people if the above DEBUG value is False. If you'd
+# rather prefer nobody receives emails, leave this commented out or empty.
+ADMINS = ()  # 'Your Name', 'your_email@domain.com'),)
+# These guys get broken link notifications when SEND_BROKEN_LINK_EMAILS is True.
+MANAGERS = ADMINS
+# This is a public point of contact for players or the public to contact
+# a staff member or administrator of the site. It is publicly posted.
+STAFF_CONTACT_EMAIL = None
+# If using Sites/Pages from the web admin, this value must be set to the
+# database-id of the Site (domain) we want to use with this game's Pages.
+SITE_ID = 1
+# The age for sessions.
+# Default: 1209600 (2 weeks, in seconds)
+SESSION_COOKIE_AGE = 1209600
+# Session cookie domain
+# Default: None
+SESSION_COOKIE_DOMAIN = None
+# The name of the cookie to use for sessions.
+# Default: 'sessionid'
+SESSION_COOKIE_NAME = "sessionid"
+# Should the session expire when the browser closes?
+# Default: False
+SESSION_EXPIRE_AT_BROWSER_CLOSE = False
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = False
+
+# Where to find locales (no need to change this, most likely)
+LOCALE_PATHS = [os.path.join(EVENNIA_DIR, "locale/")]
+# How to display time stamps in e.g. the admin
+SHORT_DATETIME_FORMAT = "Y-m-d H:i:s.u"
+DATETIME_FORMAT = "Y-m-d H:i:s"  # ISO 8601 but without T and timezone
+# This should be turned off unless you want to do tests with Django's
+# development webserver (normally Evennia runs its own server)
+SERVE_MEDIA = False
+# The master urlconf file that contains all of the sub-branches to the
+# applications. Change this to add your own URLs to the website.
+ROOT_URLCONF = "web.urls"
+# Where users are redirected after logging in via contrib.auth.login.
+LOGIN_REDIRECT_URL = "/"
+# Where to redirect users when using the @login_required decorator.
+LOGIN_URL = reverse_lazy("login")
+# Where to redirect users who wish to logout.
+LOGOUT_URL = reverse_lazy("logout")
+# URL that handles the media served from MEDIA_ROOT.
+# Example: "http://media.lawrence.com"
+MEDIA_URL = "/media/"
+# Absolute path to the directory that holds file uploads from web apps.
+MEDIA_ROOT = os.path.join(GAME_DIR, "server", ".media")
+# URL prefix for admin media -- CSS, JavaScript and images. Make sure
+# to use a trailing slash. Admin-related files are searched under STATIC_URL/admin.
+STATIC_URL = "/static/"
+# Absolute path to directory where the static data will be gathered into to be
+# served by webserver.
+STATIC_ROOT = os.path.join(GAME_DIR, "server", ".static")
+# Location of static data to overload the defaults from
+# evennia/web/static.
+STATICFILES_DIRS = [os.path.join(GAME_DIR, "web", "static")]
+# Patterns of files in the static directories. Used here to make sure that
+# its readme file is preserved but unused.
+STATICFILES_IGNORE_PATTERNS = ["README.md"]
+# The name of the currently selected web template. This corresponds to the
+# directory names shown in the templates directory.
+WEBSITE_TEMPLATE = "website"
+WEBCLIENT_TEMPLATE = "webclient"
+# We setup the location of the website template as well as the admin site.
+TEMPLATES = [
+    {
+        "BACKEND": "django.template.backends.django.DjangoTemplates",
+        "DIRS": [
+            os.path.join(GAME_DIR, "web", "templates"),
+            os.path.join(GAME_DIR, "web", "templates", WEBSITE_TEMPLATE),
+            os.path.join(GAME_DIR, "web", "templates", WEBCLIENT_TEMPLATE),
+            os.path.join(EVENNIA_DIR, "web", "templates"),
+            os.path.join(EVENNIA_DIR, "web", "templates", WEBSITE_TEMPLATE),
+            os.path.join(EVENNIA_DIR, "web", "templates", WEBCLIENT_TEMPLATE),
+        ],
+        "APP_DIRS": True,
+        "OPTIONS": {
+            "context_processors": [
+                "django.template.context_processors.i18n",
+                "django.template.context_processors.request",
+                "django.contrib.auth.context_processors.auth",
+                "django.template.context_processors.media",
+                "django.template.context_processors.debug",
+                "django.contrib.messages.context_processors.messages",
+                "sekizai.context_processors.sekizai",
+                "evennia.web.utils.general_context.general_context",
+            ],
+            # While true, show "pretty" error messages for template syntax errors.
+            "debug": DEBUG,
+        },
+    }
+]
+# Django cache settings
+# https://docs.djangoproject.com/en/4.1/topics/cache/#setting-up-the-cache
+CACHES = {
+    "default": {
+        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+    },
+    "throttle": {
+        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+        "TIMEOUT": 60 * 5,
+        "OPTIONS": {"MAX_ENTRIES": 2000},
+    },
+}
+# MiddleWare are semi-transparent extensions to Django's functionality.
+# see http://www.djangoproject.com/documentation/middleware/ for a more detailed
+# explanation.
+MIDDLEWARE = [
+    "django.middleware.common.CommonMiddleware",
+    "django.contrib.sessions.middleware.SessionMiddleware",
+    "django.contrib.messages.middleware.MessageMiddleware",  # 1.4?
+    "django.contrib.auth.middleware.AuthenticationMiddleware",
+    "django.middleware.csrf.CsrfViewMiddleware",
+    "django.contrib.admindocs.middleware.XViewMiddleware",
+    "django.contrib.flatpages.middleware.FlatpageFallbackMiddleware",
+    "evennia.web.utils.middleware.OriginIpMiddleware",
+    "evennia.web.utils.middleware.SharedLoginMiddleware",
+]
+
+# A list of Django apps (see INSTALLED_APPS) that will be listed first (if present)
+# in the Django web Admin page.
+DJANGO_ADMIN_APP_ORDER = [
+    "accounts",
+    "objects",
+    "scripts",
+    "comms",
+    "help",
+    "typeclasses",
+    "server",
+    "sites",
+    "flatpages",
+    "auth",
+]
+
+# The following apps will be excluded from the Django web Admin page.
+DJANGO_ADMIN_APP_EXCLUDE = list()
+
+######################################################################
+# Evennia components
+######################################################################
+
+# Global and Evennia-specific apps. This ties everything together so we can
+# refer to app models and perform DB syncs.
+INSTALLED_APPS = [
+    "django.contrib.auth",
+    "django.contrib.contenttypes",
+    "django.contrib.sessions",
+    "django.contrib.admindocs",
+    "django.contrib.flatpages",
+    "django.contrib.sites",
+    "django.contrib.staticfiles",
+    "evennia.web.utils.adminsite.EvenniaAdminApp",  # replaces django.contrib.admin
+    "django.contrib.messages",
+    "rest_framework",
+    "django_filters",
+    "sekizai",
+    "evennia.utils.idmapper",
+    "evennia.server",
+    "evennia.typeclasses",
+    "evennia.accounts",
+    "evennia.objects",
+    "evennia.comms",
+    "evennia.help",
+    "evennia.scripts",
+    "evennia.web",
+]
+# The user profile extends the User object with more functionality;
+# This should usually not be changed.
+AUTH_USER_MODEL = "accounts.AccountDB"
+
+# Password validation plugins
+# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
+AUTH_PASSWORD_VALIDATORS = [
+    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
+    {
+        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+        "OPTIONS": {"min_length": 8},
+    },
+    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
+    {"NAME": "evennia.server.validators.EvenniaPasswordValidator"},
+]
+
+# Username validation plugins
+AUTH_USERNAME_VALIDATORS = [
+    {"NAME": "django.contrib.auth.validators.ASCIIUsernameValidator"},
+    {
+        "NAME": "django.core.validators.MinLengthValidator",
+        "OPTIONS": {"limit_value": 3},
+    },
+    {
+        "NAME": "django.core.validators.MaxLengthValidator",
+        "OPTIONS": {"limit_value": 30},
+    },
+    {"NAME": "evennia.server.validators.EvenniaUsernameAvailabilityValidator"},
+]
+
+# Use a custom test runner that just tests Evennia-specific apps.
+TEST_RUNNER = "evennia.server.tests.testrunner.EvenniaTestSuiteRunner"
+
+# Messages and Bootstrap don't classify events the same way; this setting maps
+# messages.error() to Bootstrap 'danger' classes.
+MESSAGE_TAGS = {messages.ERROR: "danger"}
+
+# Django REST Framework settings
+REST_FRAMEWORK = {
+    # django_filters allows you to specify search fields for models in an API View
+    "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
+    # whether to paginate results and how many per page
+    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
+    "PAGE_SIZE": 25,
+    # require logged in users to call API so that access checks can work on them
+    "DEFAULT_PERMISSION_CLASSES": [
+        "rest_framework.permissions.IsAuthenticated",
+    ],
+    # These are the different ways people can authenticate for API requests - via
+    # session or with user/password. Other ways are possible, such as via tokens
+    # or oauth, but require additional dependencies.
+    "DEFAULT_AUTHENTICATION_CLASSES": [
+        "rest_framework.authentication.BasicAuthentication",
+        "rest_framework.authentication.SessionAuthentication",
+    ],
+    # default permission checks used by the EvenniaPermission class
+    "DEFAULT_CREATE_PERMISSION": "builder",
+    "DEFAULT_LIST_PERMISSION": "builder",
+    "DEFAULT_VIEW_LOCKS": ["examine"],
+    "DEFAULT_DESTROY_LOCKS": ["delete"],
+    "DEFAULT_UPDATE_LOCKS": ["control", "edit"],
+    # No throttle class set by default. Setting one also requires a cache backend to be specified.
+}
+
+# To enable the REST api, turn this to True
+REST_API_ENABLED = False
+
+######################################################################
+# Networking Replaceables
+######################################################################
+# This allows for replacing the very core of the infrastructure holding Evennia
+# together with your own variations. You should usually never have to touch
+# this, and if so, you really need to know what you are doing.
+
+# The primary Twisted Services used to start up Evennia.
+EVENNIA_SERVER_SERVICE_CLASS = "evennia.server.service.EvenniaServerService"
+EVENNIA_PORTAL_SERVICE_CLASS = "evennia.server.portal.service.EvenniaPortalService"
+
+# The Base Session Class is used as a parent class for all Protocols such as
+# Telnet and SSH.) Changing this could be really dangerous. It will cascade
+# to tons of classes. You generally shouldn't need to touch protocols.
+BASE_SESSION_CLASS = "evennia.server.session.Session"
+
+# Telnet Protocol inherits from whatever above BASE_SESSION_CLASS is specified.
+# It is used for all telnet connections, and is also inherited by the SSL Protocol
+# (which is just TLS + Telnet).
+TELNET_PROTOCOL_CLASS = "evennia.server.portal.telnet.TelnetProtocol"
+SSL_PROTOCOL_CLASS = "evennia.server.portal.ssl.SSLProtocol"
+
+# Websocket Client Protocol. This inherits from BASE_SESSION_CLASS. It is used
+# for all webclient connections.
+WEBSOCKET_PROTOCOL_CLASS = "evennia.server.portal.webclient.WebSocketClient"
+
+# Ajax Web Client classes. Evennia uses AJAX as a fallback for the webclient by
+# default. AJAX may in general be more useful for mobile clients as it's
+# resilient to IP address changes.
+
+# The Ajax Client Class is used to manage all AJAX sessions.
+AJAX_CLIENT_CLASS = "evennia.server.portal.webclient_ajax.AjaxWebClient"
+
+# Ajax Protocol Class is used for all AJAX client connections.
+AJAX_PROTOCOL_CLASS = "evennia.server.portal.webclient_ajax.AjaxWebClientSession"
+
+# Protocol for the SSH interface. This inherits from BASE_SESSION_CLASS.
+SSH_PROTOCOL_CLASS = "evennia.server.portal.ssh.SshProtocol"
+
+# Server-side session class used. This will inherit from BASE_SESSION_CLASS.
+# This one isn't as dangerous to replace.
+SERVER_SESSION_CLASS = "evennia.server.serversession.ServerSession"
+
+# The Server SessionHandler manages all ServerSessions, handling logins,
+# ensuring the login process happens smoothly, handling expected and
+# unexpected disconnects. You shouldn't need to touch it, but you can.
+# Replace it to implement altered game logic.
+SERVER_SESSION_HANDLER_CLASS = "evennia.server.sessionhandler.ServerSessionHandler"
+
+# The Portal SessionHandler manages all incoming connections regardless of
+# the protocol in use. It is responsible for keeping them going and informing
+# the Server Session Handler of the connections and synchronizing them across the
+# AMP connection. You shouldn't ever need to change this. But you can.
+PORTAL_SESSION_HANDLER_CLASS = "evennia.server.portal.portalsessionhandler.PortalSessionHandler"
+
+
+# These are members / properties / attributes kept on both Server and
+# Portal Sessions. They are sync'd at various points, such as logins and
+# reloads. If you add to this, you may need to adjust the class __init__
+# so the additions have somewhere to go. These must be simple things that
+# can be pickled - stuff you could serialize to JSON is best.
+SESSION_SYNC_ATTRS = (
+    "protocol_key",
+    "address",
+    "suid",
+    "sessid",
+    "uid",
+    "csessid",
+    "uname",
+    "logged_in",
+    "puid",
+    "conn_time",
+    "cmd_last",
+    "cmd_last_visible",
+    "cmd_total",
+    "protocol_flags",
+    "server_data",
+    "cmdset_storage_string",
+)
+
+# The following are used for the communications between the Portal and Server.
+# Very dragons territory.
+AMP_SERVER_PROTOCOL_CLASS = "evennia.server.portal.amp_server.AMPServerProtocol"
+AMP_CLIENT_PROTOCOL_CLASS = "evennia.server.amp_client.AMPServerClientProtocol"
+
+# don't change this manually, it can be checked from code to know if
+# being run from a unit test (set by the evennia.utils.test_resources.BaseEvenniaTest
+# and BaseEvenniaTestCase unit testing parents)
+TEST_ENVIRONMENT = False
+
+######################################################################
+# Django extensions
+######################################################################
+
+# Django extesions are useful third-party tools that are not
+# always included in the default django distro.
+try:
+    import django_extensions  # noqa
+
+    INSTALLED_APPS += ["django_extensions"]
+except ImportError:
+    # Django extensions are not installed in all distros.
+    pass
+
+#######################################################################
+# SECRET_KEY
+#######################################################################
+# This is the signing key for the cookies generated by Evennia's
+# web interface.
+#
+# It is a fallback for the SECRET_KEY setting in settings.py, which
+# is randomly seeded when settings.py is first created. If copying
+# from here, make sure to change it!
+SECRET_KEY = "changeme!(*#&*($&*(#*(&SDFKJJKLS*(@#KJAS"
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Settings.html b/docs/latest/Setup/Settings.html new file mode 100644 index 0000000000..021a30be4f --- /dev/null +++ b/docs/latest/Setup/Settings.html @@ -0,0 +1,220 @@ + + + + + + + + + Changing Game Settings — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Changing Game Settings

+

Evennia runs out of the box without any changes to its settings. But there are several important +ways to customize the server and expand it with your own plugins.

+

All game-specific settings are located in the mygame/server/conf/ directory.

+
+

Settings file

+

The “Settings” file referenced throughout the documentation is the file +mygame/server/conf/settings.py.

+

Your new settings.py is relatively bare out of the box. Evennia’s core settings file is +the Settings-Default file and is considerably more extensive. It is also +heavily documented and up-to-date, so you should refer to this file directly for the available settings.

+

Since mygame/server/conf/settings.py is a normal Python module, it simply imports +evennia/settings_default.py into itself at the top.

+

This means that if any setting you want to change were to depend on some other default setting, +you might need to copy & paste both in order to change them and get the effect you want (for most +commonly changed settings, this is not something you need to worry about).

+

You should never edit evennia/settings_default.py. Rather you should copy&paste the select +variables you want to change into your settings.py and edit them there. This will overload the +previously imported defaults.

+
+

Warning

+

Don’t copy everything! +It may be tempting to copy everything from settings_default.py into your own settings file just to have it all in one place. Don’t do this. By copying only what you need, you can easier track what you changed.

+
+

In code, the settings is accessed through

+
    from django.conf import settings
+     # or (shorter):
+    from evennia import settings
+     # example:
+    servername = settings.SERVER_NAME
+
+
+

Each setting appears as a property on the imported settings object. You can also explore all possible options with evennia.settings_full (this also includes advanced Django defaults that are not touched in default Evennia).

+
+

When importing settings into your code like this, it will be read +only. You cannot edit your settings from your code! The only way to change an Evennia setting is +to edit mygame/server/conf/settings.py directly. You will also need to restart the server +(possibly also the Portal) before a changed setting becomes available.

+
+
+
+

Other files in the server/conf directory

+

Apart from the main settings.py file,

+
    +
  • at_initial_setup.py - this allows you to add a custom startup method to be called (only) the +very first time Evennia starts (at the same time as user #1 and Limbo is created). It can be made to +start your own global scripts or set up other system/world-related things your game needs to have +running from the start.

  • +
  • at_server_startstop.py - this module contains two functions that Evennia will call every time +the Server starts and stops respectively - this includes stopping due to reloading and resetting as +well as shutting down completely. It’s a useful place to put custom startup code for handlers and +other things that must run in your game but which has no database persistence.

  • +
  • connection_screens.py - all global string variables in this module are interpreted by Evennia as +a greeting screen to show when an Account first connects. If more than one string variable is +present in the module a random one will be picked.

  • +
  • inlinefuncs.py - this is where you can define custom FuncParser functions.

  • +
  • inputfuncs.py - this is where you define custom Input functions to handle data +from the client.

  • +
  • lockfuncs.py - this is one of many possible modules to hold your own “safe” lock functions to +make available to Evennia’s Locks.

  • +
  • mssp.py - this holds meta information about your game. It is used by MUD search engines (which +you often have to register with) in order to display what kind of game you are running along with +statistics such as number of online accounts and online status.

  • +
  • oobfuncs.py - in here you can define custom OOB functions.

  • +
  • portal_services_plugin.py - this allows for adding your own custom services/protocols to the +Portal. It must define one particular function that will be called by Evennia at startup. There can +be any number of service plugin modules, all will be imported and used if defined. More info can be +found here.

  • +
  • server_services_plugin.py - this is equivalent to the previous one, but used for adding new +services to the Server instead. More info can be found +here.

  • +
+

Some other Evennia systems can be customized by plugin modules but has no explicit template in +conf/:

+
    +
  • cmdparser.py - a custom module can be used to totally replace Evennia’s default command parser. All this does is to split the incoming string into “command name” and “the rest”. It also handles things like error messages for no-matches and multiple-matches among other things that makes this more complex than it sounds. The default parser is very generic, so you are most often best served by modifying things further down the line (on the command parse level) than here.

  • +
  • at_search.py - this allows for replacing the way Evennia handles search results. It allows to change how errors are echoed and how multi-matches are resolved and reported (like how the default understands that “2-ball” should match the second “ball” object if there are two of them in the room).

  • +
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Setup-Overview.html b/docs/latest/Setup/Setup-Overview.html new file mode 100644 index 0000000000..5bcb0fe4d2 --- /dev/null +++ b/docs/latest/Setup/Setup-Overview.html @@ -0,0 +1,256 @@ + + + + + + + + + Server Setup and Life — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Server Setup and Life

+

This sums up all steps of maintaining your Evennia game from first installation to production release.

+
+

Installation and running

+ +
+
+

Configuration

+ +
+
+

Going Online

+ +
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Setup/Updating-Evennia.html b/docs/latest/Setup/Updating-Evennia.html new file mode 100644 index 0000000000..f5acb07ff4 --- /dev/null +++ b/docs/latest/Setup/Updating-Evennia.html @@ -0,0 +1,244 @@ + + + + + + + + + Updating Evennia — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

Updating Evennia

+

When Evennia is updated to a new version you will usually see it announced in the Discussion forum and in the dev blog. You can also see the changes on github or through one of our other linked pages.

+
+

If you installed with pip

+

If you followed the normal install instructions, here’s what you do to upgrade:

+
    +
  1. Read the changelog to see what changed and if it means you need to make any changes to your game code.

  2. +
  3. If you use a virtualenv, make sure it’s active.

  4. +
  5. cd to your game dir (e.g. mygame)

  6. +
  7. evennia stop

  8. +
  9. pip install --upgrade evennia

  10. +
  11. cd tor your game dir

  12. +
  13. evennia migrate - this is safe to do, but can be skipped unless the release announcement/changelog specifically tells you to do so. Ignore any warnings about running makemigrations, it should not be done!

  14. +
  15. evennia start

  16. +
+
+
+

If you installed with git

+

This applies if you followed the git-install instructions. Before Evennia 1.0, this was the only way to install Evennia.

+

At any time, development is either happening in the main branch (latest stable) or develop (experimental). Which one is active and ‘latest’ at a given time depends - after a release, main will see most updates, close to a new release, develop will usually be the fastest changing.

+
    +
  1. Read the changelog to see what changed and if it means you need to make any changes to your game code.

  2. +
  3. If you use a virtualenv, make sure it’s active.

  4. +
  5. cd to your game dir (e.g. mygame)

  6. +
  7. evennia stop

  8. +
  9. cd to the evennia repo folder you cloned during the git installation process.

  10. +
  11. git pull

  12. +
  13. pip install --upgrade -e . (remember the . at the end!)

  14. +
  15. cd back to your game dir

  16. +
  17. evennia migrate - this is safe to do, but can be skipped unless the release announcement/changelog specifically tells you to do so. Ignore any warnings about running makemigrations, it should not be done!

  18. +
  19. evennia start

  20. +
+
+
+

If you installed with docker

+

If you followed the [docker installation instructions] you need to pull the latest docker image for the branch you want:

+
    +
  • docker pull evennia/evennia (main branch)

  • +
  • docker pull evennia/evennia:develop (experimental develop branch)

  • +
+

Then restart your containers.

+
+
+

Resetting your database

+

Should you ever want to start over completely from scratch, there is no need to re-download Evennia. You just need to clear your database.

+

First:

+
    +
  1. cd to your game dir (e.g. mygame)

  2. +
  3. evennia stop

  4. +
+
+

SQLite3 (default)

+ +
    +
  1. delete the file mygame/server/evennia.db3

  2. +
  3. evennia migrate

  4. +
  5. evennia start

  6. +
+
+
+

PostgreSQL

+
    +
  1. evennia dbshell (opens the psql client interface)

    +
    psql> DROP DATABASE evennia;
    +psql> exit
    +
    +
    +
  2. +
  3. You should now follow the PostgreSQL install instructions to create a new evennia database.

  4. +
  5. evennia migrate

  6. +
  7. evennia start

  8. +
+
+
+

MySQL/MariaDB

+
    +
  1. evennia dbshell (opens the mysql client interface)

    +
    mysql> DROP DATABASE evennia;
    +mysql> exit
    +
    +
    +
  2. +
  3. You should now follow the MySQL install instructions to create a new evennia database.

  4. +
  5. evennia migrate

  6. +
  7. evennia start

  8. +
+
+
+

What are database migrations?

+

If and when an Evennia update modifies the database schema (that is, the under-the-hood details as to how data is stored in the database), you must update your existing database correspondingly to match the change. If you don’t, the updated Evennia will complain that it cannot read the database properly. Whereas schema changes should become more and more rare as Evennia matures, it may still happen from time to time.

+

One way one could handle this is to apply the changes manually to your database using the database’s command line. This often means adding/removing new tables or fields as well as possibly convert existing data to match what the new Evennia version expects. It should be quite obvious that this quickly becomes cumbersome and error-prone. If your database doesn’t contain anything critical yet it’s probably easiest to simply reset it and start over rather than to bother converting.

+

Enter migrations. Migrations keeps track of changes in the database schema and applies them automatically for you. Basically, whenever the schema changes we distribute small files called “migrations” with the source. Those tell the system exactly how to implement the change so you don’t have to do so manually. When a migration has been added we will tell you so on Evennia’s mailing lists and in commit messages - you then just run evennia migrate to be up-to-date again.

+
+
+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/Unimplemented.html b/docs/latest/Unimplemented.html new file mode 100644 index 0000000000..45f6c47651 --- /dev/null +++ b/docs/latest/Unimplemented.html @@ -0,0 +1,139 @@ + + + + + + + + + 1. Unimplemented — Evennia latest documentation + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

1. Unimplemented

+

Sorry, but this page has not been written yet.

+

Go back or return to the front page.

+
+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_images/Dungeon_Merchant_Camp.jpg b/docs/latest/_images/Dungeon_Merchant_Camp.jpg new file mode 100644 index 0000000000..10f9088d4d Binary files /dev/null and b/docs/latest/_images/Dungeon_Merchant_Camp.jpg differ diff --git a/docs/latest/_images/fork_button.png b/docs/latest/_images/fork_button.png new file mode 100644 index 0000000000..c1e48a0087 Binary files /dev/null and b/docs/latest/_images/fork_button.png differ diff --git a/docs/latest/_modules/django/conf.html b/docs/latest/_modules/django/conf.html new file mode 100644 index 0000000000..60bb34274f --- /dev/null +++ b/docs/latest/_modules/django/conf.html @@ -0,0 +1,506 @@ + + + + + + + + django.conf — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for django.conf

+"""
+Settings and configuration for Django.
+
+Read values from the module specified by the DJANGO_SETTINGS_MODULE environment
+variable, and then from django.conf.global_settings; see the global_settings.py
+for a list of all possible variables.
+"""
+
+import importlib
+import os
+import time
+import traceback
+import warnings
+from pathlib import Path
+
+import django
+from django.conf import global_settings
+from django.core.exceptions import ImproperlyConfigured
+from django.utils.deprecation import RemovedInDjango50Warning, RemovedInDjango51Warning
+from django.utils.functional import LazyObject, empty
+
+ENVIRONMENT_VARIABLE = "DJANGO_SETTINGS_MODULE"
+DEFAULT_STORAGE_ALIAS = "default"
+STATICFILES_STORAGE_ALIAS = "staticfiles"
+
+# RemovedInDjango50Warning
+USE_DEPRECATED_PYTZ_DEPRECATED_MSG = (
+    "The USE_DEPRECATED_PYTZ setting, and support for pytz timezones is "
+    "deprecated in favor of the stdlib zoneinfo module. Please update your "
+    "code to use zoneinfo and remove the USE_DEPRECATED_PYTZ setting."
+)
+
+USE_L10N_DEPRECATED_MSG = (
+    "The USE_L10N setting is deprecated. Starting with Django 5.0, localized "
+    "formatting of data will always be enabled. For example Django will "
+    "display numbers and dates using the format of the current locale."
+)
+
+CSRF_COOKIE_MASKED_DEPRECATED_MSG = (
+    "The CSRF_COOKIE_MASKED transitional setting is deprecated. Support for "
+    "it will be removed in Django 5.0."
+)
+
+DEFAULT_FILE_STORAGE_DEPRECATED_MSG = (
+    "The DEFAULT_FILE_STORAGE setting is deprecated. Use STORAGES instead."
+)
+
+STATICFILES_STORAGE_DEPRECATED_MSG = (
+    "The STATICFILES_STORAGE setting is deprecated. Use STORAGES instead."
+)
+
+
+class SettingsReference(str):
+    """
+    String subclass which references a current settings value. It's treated as
+    the value in memory but serializes to a settings.NAME attribute reference.
+    """
+
+    def __new__(self, value, setting_name):
+        return str.__new__(self, value)
+
+    def __init__(self, value, setting_name):
+        self.setting_name = setting_name
+
+
+class LazySettings(LazyObject):
+    """
+    A lazy proxy for either global Django settings or a custom settings object.
+    The user can manually configure settings prior to using them. Otherwise,
+    Django uses the settings module pointed to by DJANGO_SETTINGS_MODULE.
+    """
+
+    def _setup(self, name=None):
+        """
+        Load the settings module pointed to by the environment variable. This
+        is used the first time settings are needed, if the user hasn't
+        configured settings manually.
+        """
+        settings_module = os.environ.get(ENVIRONMENT_VARIABLE)
+        if not settings_module:
+            desc = ("setting %s" % name) if name else "settings"
+            raise ImproperlyConfigured(
+                "Requested %s, but settings are not configured. "
+                "You must either define the environment variable %s "
+                "or call settings.configure() before accessing settings."
+                % (desc, ENVIRONMENT_VARIABLE)
+            )
+
+        self._wrapped = Settings(settings_module)
+
+    def __repr__(self):
+        # Hardcode the class name as otherwise it yields 'Settings'.
+        if self._wrapped is empty:
+            return "<LazySettings [Unevaluated]>"
+        return '<LazySettings "%(settings_module)s">' % {
+            "settings_module": self._wrapped.SETTINGS_MODULE,
+        }
+
+    def __getattr__(self, name):
+        """Return the value of a setting and cache it in self.__dict__."""
+        if (_wrapped := self._wrapped) is empty:
+            self._setup(name)
+            _wrapped = self._wrapped
+        val = getattr(_wrapped, name)
+
+        # Special case some settings which require further modification.
+        # This is done here for performance reasons so the modified value is cached.
+        if name in {"MEDIA_URL", "STATIC_URL"} and val is not None:
+            val = self._add_script_prefix(val)
+        elif name == "SECRET_KEY" and not val:
+            raise ImproperlyConfigured("The SECRET_KEY setting must not be empty.")
+
+        self.__dict__[name] = val
+        return val
+
+    def __setattr__(self, name, value):
+        """
+        Set the value of setting. Clear all cached values if _wrapped changes
+        (@override_settings does this) or clear single values when set.
+        """
+        if name == "_wrapped":
+            self.__dict__.clear()
+        else:
+            self.__dict__.pop(name, None)
+        super().__setattr__(name, value)
+
+    def __delattr__(self, name):
+        """Delete a setting and clear it from cache if needed."""
+        super().__delattr__(name)
+        self.__dict__.pop(name, None)
+
+    def configure(self, default_settings=global_settings, **options):
+        """
+        Called to manually configure the settings. The 'default_settings'
+        parameter sets where to retrieve any unspecified values from (its
+        argument must support attribute access (__getattr__)).
+        """
+        if self._wrapped is not empty:
+            raise RuntimeError("Settings already configured.")
+        holder = UserSettingsHolder(default_settings)
+        for name, value in options.items():
+            if not name.isupper():
+                raise TypeError("Setting %r must be uppercase." % name)
+            setattr(holder, name, value)
+        self._wrapped = holder
+
+    @staticmethod
+    def _add_script_prefix(value):
+        """
+        Add SCRIPT_NAME prefix to relative paths.
+
+        Useful when the app is being served at a subpath and manually prefixing
+        subpath to STATIC_URL and MEDIA_URL in settings is inconvenient.
+        """
+        # Don't apply prefix to absolute paths and URLs.
+        if value.startswith(("http://", "https://", "/")):
+            return value
+        from django.urls import get_script_prefix
+
+        return "%s%s" % (get_script_prefix(), value)
+
+    @property
+    def configured(self):
+        """Return True if the settings have already been configured."""
+        return self._wrapped is not empty
+
+    def _show_deprecation_warning(self, message, category):
+        stack = traceback.extract_stack()
+        # Show a warning if the setting is used outside of Django.
+        # Stack index: -1 this line, -2 the property, -3 the
+        # LazyObject __getattribute__(), -4 the caller.
+        filename, _, _, _ = stack[-4]
+        if not filename.startswith(os.path.dirname(django.__file__)):
+            warnings.warn(message, category, stacklevel=2)
+
+    @property
+    def USE_L10N(self):
+        self._show_deprecation_warning(
+            USE_L10N_DEPRECATED_MSG, RemovedInDjango50Warning
+        )
+        return self.__getattr__("USE_L10N")
+
+    # RemovedInDjango50Warning.
+    @property
+    def _USE_L10N_INTERNAL(self):
+        # Special hook to avoid checking a traceback in internal use on hot
+        # paths.
+        return self.__getattr__("USE_L10N")
+
+    # RemovedInDjango51Warning.
+    @property
+    def DEFAULT_FILE_STORAGE(self):
+        self._show_deprecation_warning(
+            DEFAULT_FILE_STORAGE_DEPRECATED_MSG, RemovedInDjango51Warning
+        )
+        return self.__getattr__("DEFAULT_FILE_STORAGE")
+
+    # RemovedInDjango51Warning.
+    @property
+    def STATICFILES_STORAGE(self):
+        self._show_deprecation_warning(
+            STATICFILES_STORAGE_DEPRECATED_MSG, RemovedInDjango51Warning
+        )
+        return self.__getattr__("STATICFILES_STORAGE")
+
+
+class Settings:
+    def __init__(self, settings_module):
+        # update this dict from global settings (but only for ALL_CAPS settings)
+        for setting in dir(global_settings):
+            if setting.isupper():
+                setattr(self, setting, getattr(global_settings, setting))
+
+        # store the settings module in case someone later cares
+        self.SETTINGS_MODULE = settings_module
+
+        mod = importlib.import_module(self.SETTINGS_MODULE)
+
+        tuple_settings = (
+            "ALLOWED_HOSTS",
+            "INSTALLED_APPS",
+            "TEMPLATE_DIRS",
+            "LOCALE_PATHS",
+            "SECRET_KEY_FALLBACKS",
+        )
+        self._explicit_settings = set()
+        for setting in dir(mod):
+            if setting.isupper():
+                setting_value = getattr(mod, setting)
+
+                if setting in tuple_settings and not isinstance(
+                    setting_value, (list, tuple)
+                ):
+                    raise ImproperlyConfigured(
+                        "The %s setting must be a list or a tuple." % setting
+                    )
+                setattr(self, setting, setting_value)
+                self._explicit_settings.add(setting)
+
+        if self.USE_TZ is False and not self.is_overridden("USE_TZ"):
+            warnings.warn(
+                "The default value of USE_TZ will change from False to True "
+                "in Django 5.0. Set USE_TZ to False in your project settings "
+                "if you want to keep the current default behavior.",
+                category=RemovedInDjango50Warning,
+            )
+
+        if self.is_overridden("USE_DEPRECATED_PYTZ"):
+            warnings.warn(USE_DEPRECATED_PYTZ_DEPRECATED_MSG, RemovedInDjango50Warning)
+
+        if self.is_overridden("CSRF_COOKIE_MASKED"):
+            warnings.warn(CSRF_COOKIE_MASKED_DEPRECATED_MSG, RemovedInDjango50Warning)
+
+        if hasattr(time, "tzset") and self.TIME_ZONE:
+            # When we can, attempt to validate the timezone. If we can't find
+            # this file, no check happens and it's harmless.
+            zoneinfo_root = Path("/usr/share/zoneinfo")
+            zone_info_file = zoneinfo_root.joinpath(*self.TIME_ZONE.split("/"))
+            if zoneinfo_root.exists() and not zone_info_file.exists():
+                raise ValueError("Incorrect timezone setting: %s" % self.TIME_ZONE)
+            # Move the time zone info into os.environ. See ticket #2315 for why
+            # we don't do this unconditionally (breaks Windows).
+            os.environ["TZ"] = self.TIME_ZONE
+            time.tzset()
+
+        if self.is_overridden("USE_L10N"):
+            warnings.warn(USE_L10N_DEPRECATED_MSG, RemovedInDjango50Warning)
+
+        if self.is_overridden("DEFAULT_FILE_STORAGE"):
+            if self.is_overridden("STORAGES"):
+                raise ImproperlyConfigured(
+                    "DEFAULT_FILE_STORAGE/STORAGES are mutually exclusive."
+                )
+            self.STORAGES = {
+                **self.STORAGES,
+                DEFAULT_STORAGE_ALIAS: {"BACKEND": self.DEFAULT_FILE_STORAGE},
+            }
+            warnings.warn(DEFAULT_FILE_STORAGE_DEPRECATED_MSG, RemovedInDjango51Warning)
+
+        if self.is_overridden("STATICFILES_STORAGE"):
+            if self.is_overridden("STORAGES"):
+                raise ImproperlyConfigured(
+                    "STATICFILES_STORAGE/STORAGES are mutually exclusive."
+                )
+            self.STORAGES = {
+                **self.STORAGES,
+                STATICFILES_STORAGE_ALIAS: {"BACKEND": self.STATICFILES_STORAGE},
+            }
+            warnings.warn(STATICFILES_STORAGE_DEPRECATED_MSG, RemovedInDjango51Warning)
+        # RemovedInDjango51Warning.
+        if self.is_overridden("STORAGES"):
+            setattr(
+                self,
+                "DEFAULT_FILE_STORAGE",
+                self.STORAGES.get(DEFAULT_STORAGE_ALIAS, {}).get("BACKEND"),
+            )
+            setattr(
+                self,
+                "STATICFILES_STORAGE",
+                self.STORAGES.get(STATICFILES_STORAGE_ALIAS, {}).get("BACKEND"),
+            )
+
+    def is_overridden(self, setting):
+        return setting in self._explicit_settings
+
+    def __repr__(self):
+        return '<%(cls)s "%(settings_module)s">' % {
+            "cls": self.__class__.__name__,
+            "settings_module": self.SETTINGS_MODULE,
+        }
+
+
+class UserSettingsHolder:
+    """Holder for user configured settings."""
+
+    # SETTINGS_MODULE doesn't make much sense in the manually configured
+    # (standalone) case.
+    SETTINGS_MODULE = None
+
+    def __init__(self, default_settings):
+        """
+        Requests for configuration variables not in this class are satisfied
+        from the module specified in default_settings (if possible).
+        """
+        self.__dict__["_deleted"] = set()
+        self.default_settings = default_settings
+
+    def __getattr__(self, name):
+        if not name.isupper() or name in self._deleted:
+            raise AttributeError
+        return getattr(self.default_settings, name)
+
+    def __setattr__(self, name, value):
+        self._deleted.discard(name)
+        if name == "USE_L10N":
+            warnings.warn(USE_L10N_DEPRECATED_MSG, RemovedInDjango50Warning)
+        if name == "CSRF_COOKIE_MASKED":
+            warnings.warn(CSRF_COOKIE_MASKED_DEPRECATED_MSG, RemovedInDjango50Warning)
+        if name == "DEFAULT_FILE_STORAGE":
+            self.STORAGES[DEFAULT_STORAGE_ALIAS] = {
+                "BACKEND": self.DEFAULT_FILE_STORAGE
+            }
+            warnings.warn(DEFAULT_FILE_STORAGE_DEPRECATED_MSG, RemovedInDjango51Warning)
+        if name == "STATICFILES_STORAGE":
+            self.STORAGES[STATICFILES_STORAGE_ALIAS] = {
+                "BACKEND": self.STATICFILES_STORAGE
+            }
+            warnings.warn(STATICFILES_STORAGE_DEPRECATED_MSG, RemovedInDjango51Warning)
+        super().__setattr__(name, value)
+        if name == "USE_DEPRECATED_PYTZ":
+            warnings.warn(USE_DEPRECATED_PYTZ_DEPRECATED_MSG, RemovedInDjango50Warning)
+        # RemovedInDjango51Warning.
+        if name == "STORAGES":
+            if default_file_storage := self.STORAGES.get(DEFAULT_STORAGE_ALIAS):
+                super().__setattr__(
+                    "DEFAULT_FILE_STORAGE", default_file_storage.get("BACKEND")
+                )
+            else:
+                self.STORAGES.setdefault(
+                    DEFAULT_STORAGE_ALIAS,
+                    {"BACKEND": "django.core.files.storage.FileSystemStorage"},
+                )
+            if staticfiles_storage := self.STORAGES.get(STATICFILES_STORAGE_ALIAS):
+                super().__setattr__(
+                    "STATICFILES_STORAGE", staticfiles_storage.get("BACKEND")
+                )
+            else:
+                self.STORAGES.setdefault(
+                    STATICFILES_STORAGE_ALIAS,
+                    {
+                        "BACKEND": (
+                            "django.contrib.staticfiles.storage.StaticFilesStorage"
+                        ),
+                    },
+                )
+
+    def __delattr__(self, name):
+        self._deleted.add(name)
+        if hasattr(self, name):
+            super().__delattr__(name)
+
+    def __dir__(self):
+        return sorted(
+            s
+            for s in [*self.__dict__, *dir(self.default_settings)]
+            if s not in self._deleted
+        )
+
+    def is_overridden(self, setting):
+        deleted = setting in self._deleted
+        set_locally = setting in self.__dict__
+        set_on_default = getattr(
+            self.default_settings, "is_overridden", lambda s: False
+        )(setting)
+        return deleted or set_locally or set_on_default
+
+    def __repr__(self):
+        return "<%(cls)s>" % {
+            "cls": self.__class__.__name__,
+        }
+
+
+settings = LazySettings()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/django/db/models/fields/related_descriptors.html b/docs/latest/_modules/django/db/models/fields/related_descriptors.html new file mode 100644 index 0000000000..f58f975c73 --- /dev/null +++ b/docs/latest/_modules/django/db/models/fields/related_descriptors.html @@ -0,0 +1,1609 @@ + + + + + + + + django.db.models.fields.related_descriptors — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for django.db.models.fields.related_descriptors

+"""
+Accessors for related objects.
+
+When a field defines a relation between two models, each model class provides
+an attribute to access related instances of the other model class (unless the
+reverse accessor has been disabled with related_name='+').
+
+Accessors are implemented as descriptors in order to customize access and
+assignment. This module defines the descriptor classes.
+
+Forward accessors follow foreign keys. Reverse accessors trace them back. For
+example, with the following models::
+
+    class Parent(Model):
+        pass
+
+    class Child(Model):
+        parent = ForeignKey(Parent, related_name='children')
+
+ ``child.parent`` is a forward many-to-one relation. ``parent.children`` is a
+reverse many-to-one relation.
+
+There are three types of relations (many-to-one, one-to-one, and many-to-many)
+and two directions (forward and reverse) for a total of six combinations.
+
+1. Related instance on the forward side of a many-to-one relation:
+   ``ForwardManyToOneDescriptor``.
+
+   Uniqueness of foreign key values is irrelevant to accessing the related
+   instance, making the many-to-one and one-to-one cases identical as far as
+   the descriptor is concerned. The constraint is checked upstream (unicity
+   validation in forms) or downstream (unique indexes in the database).
+
+2. Related instance on the forward side of a one-to-one
+   relation: ``ForwardOneToOneDescriptor``.
+
+   It avoids querying the database when accessing the parent link field in
+   a multi-table inheritance scenario.
+
+3. Related instance on the reverse side of a one-to-one relation:
+   ``ReverseOneToOneDescriptor``.
+
+   One-to-one relations are asymmetrical, despite the apparent symmetry of the
+   name, because they're implemented in the database with a foreign key from
+   one table to another. As a consequence ``ReverseOneToOneDescriptor`` is
+   slightly different from ``ForwardManyToOneDescriptor``.
+
+4. Related objects manager for related instances on the reverse side of a
+   many-to-one relation: ``ReverseManyToOneDescriptor``.
+
+   Unlike the previous two classes, this one provides access to a collection
+   of objects. It returns a manager rather than an instance.
+
+5. Related objects manager for related instances on the forward or reverse
+   sides of a many-to-many relation: ``ManyToManyDescriptor``.
+
+   Many-to-many relations are symmetrical. The syntax of Django models
+   requires declaring them on one side but that's an implementation detail.
+   They could be declared on the other side without any change in behavior.
+   Therefore the forward and reverse descriptors can be the same.
+
+   If you're looking for ``ForwardManyToManyDescriptor`` or
+   ``ReverseManyToManyDescriptor``, use ``ManyToManyDescriptor`` instead.
+"""
+
+from asgiref.sync import sync_to_async
+
+from django.core.exceptions import FieldError
+from django.db import (
+    DEFAULT_DB_ALIAS,
+    NotSupportedError,
+    connections,
+    router,
+    transaction,
+)
+from django.db.models import Q, Window, signals
+from django.db.models.functions import RowNumber
+from django.db.models.lookups import GreaterThan, LessThanOrEqual
+from django.db.models.query import QuerySet
+from django.db.models.query_utils import DeferredAttribute
+from django.db.models.utils import AltersData, resolve_callables
+from django.utils.functional import cached_property
+
+
+class ForeignKeyDeferredAttribute(DeferredAttribute):
+    def __set__(self, instance, value):
+        if instance.__dict__.get(self.field.attname) != value and self.field.is_cached(
+            instance
+        ):
+            self.field.delete_cached_value(instance)
+        instance.__dict__[self.field.attname] = value
+
+
+def _filter_prefetch_queryset(queryset, field_name, instances):
+    predicate = Q(**{f"{field_name}__in": instances})
+    db = queryset._db or DEFAULT_DB_ALIAS
+    if queryset.query.is_sliced:
+        if not connections[db].features.supports_over_clause:
+            raise NotSupportedError(
+                "Prefetching from a limited queryset is only supported on backends "
+                "that support window functions."
+            )
+        low_mark, high_mark = queryset.query.low_mark, queryset.query.high_mark
+        order_by = [
+            expr for expr, _ in queryset.query.get_compiler(using=db).get_order_by()
+        ]
+        window = Window(RowNumber(), partition_by=field_name, order_by=order_by)
+        predicate &= GreaterThan(window, low_mark)
+        if high_mark is not None:
+            predicate &= LessThanOrEqual(window, high_mark)
+        queryset.query.clear_limits()
+    return queryset.filter(predicate)
+
+
+class ForwardManyToOneDescriptor:
+    """
+    Accessor to the related object on the forward side of a many-to-one or
+    one-to-one (via ForwardOneToOneDescriptor subclass) relation.
+
+    In the example::
+
+        class Child(Model):
+            parent = ForeignKey(Parent, related_name='children')
+
+    ``Child.parent`` is a ``ForwardManyToOneDescriptor`` instance.
+    """
+
+    def __init__(self, field_with_rel):
+        self.field = field_with_rel
+
+    @cached_property
+    def RelatedObjectDoesNotExist(self):
+        # The exception can't be created at initialization time since the
+        # related model might not be resolved yet; `self.field.model` might
+        # still be a string model reference.
+        return type(
+            "RelatedObjectDoesNotExist",
+            (self.field.remote_field.model.DoesNotExist, AttributeError),
+            {
+                "__module__": self.field.model.__module__,
+                "__qualname__": "%s.%s.RelatedObjectDoesNotExist"
+                % (
+                    self.field.model.__qualname__,
+                    self.field.name,
+                ),
+            },
+        )
+
+    def is_cached(self, instance):
+        return self.field.is_cached(instance)
+
+    def get_queryset(self, **hints):
+        return self.field.remote_field.model._base_manager.db_manager(hints=hints).all()
+
+    def get_prefetch_queryset(self, instances, queryset=None):
+        if queryset is None:
+            queryset = self.get_queryset()
+        queryset._add_hints(instance=instances[0])
+
+        rel_obj_attr = self.field.get_foreign_related_value
+        instance_attr = self.field.get_local_related_value
+        instances_dict = {instance_attr(inst): inst for inst in instances}
+        related_field = self.field.foreign_related_fields[0]
+        remote_field = self.field.remote_field
+
+        # FIXME: This will need to be revisited when we introduce support for
+        # composite fields. In the meantime we take this practical approach to
+        # solve a regression on 1.6 when the reverse manager in hidden
+        # (related_name ends with a '+'). Refs #21410.
+        # The check for len(...) == 1 is a special case that allows the query
+        # to be join-less and smaller. Refs #21760.
+        if remote_field.is_hidden() or len(self.field.foreign_related_fields) == 1:
+            query = {
+                "%s__in"
+                % related_field.name: {instance_attr(inst)[0] for inst in instances}
+            }
+        else:
+            query = {"%s__in" % self.field.related_query_name(): instances}
+        queryset = queryset.filter(**query)
+
+        # Since we're going to assign directly in the cache,
+        # we must manage the reverse relation cache manually.
+        if not remote_field.multiple:
+            for rel_obj in queryset:
+                instance = instances_dict[rel_obj_attr(rel_obj)]
+                remote_field.set_cached_value(rel_obj, instance)
+        return (
+            queryset,
+            rel_obj_attr,
+            instance_attr,
+            True,
+            self.field.get_cache_name(),
+            False,
+        )
+
+    def get_object(self, instance):
+        qs = self.get_queryset(instance=instance)
+        # Assuming the database enforces foreign keys, this won't fail.
+        return qs.get(self.field.get_reverse_related_filter(instance))
+
+    def __get__(self, instance, cls=None):
+        """
+        Get the related instance through the forward relation.
+
+        With the example above, when getting ``child.parent``:
+
+        - ``self`` is the descriptor managing the ``parent`` attribute
+        - ``instance`` is the ``child`` instance
+        - ``cls`` is the ``Child`` class (we don't need it)
+        """
+        if instance is None:
+            return self
+
+        # The related instance is loaded from the database and then cached
+        # by the field on the model instance state. It can also be pre-cached
+        # by the reverse accessor (ReverseOneToOneDescriptor).
+        try:
+            rel_obj = self.field.get_cached_value(instance)
+        except KeyError:
+            has_value = None not in self.field.get_local_related_value(instance)
+            ancestor_link = (
+                instance._meta.get_ancestor_link(self.field.model)
+                if has_value
+                else None
+            )
+            if ancestor_link and ancestor_link.is_cached(instance):
+                # An ancestor link will exist if this field is defined on a
+                # multi-table inheritance parent of the instance's class.
+                ancestor = ancestor_link.get_cached_value(instance)
+                # The value might be cached on an ancestor if the instance
+                # originated from walking down the inheritance chain.
+                rel_obj = self.field.get_cached_value(ancestor, default=None)
+            else:
+                rel_obj = None
+            if rel_obj is None and has_value:
+                rel_obj = self.get_object(instance)
+                remote_field = self.field.remote_field
+                # If this is a one-to-one relation, set the reverse accessor
+                # cache on the related object to the current instance to avoid
+                # an extra SQL query if it's accessed later on.
+                if not remote_field.multiple:
+                    remote_field.set_cached_value(rel_obj, instance)
+            self.field.set_cached_value(instance, rel_obj)
+
+        if rel_obj is None and not self.field.null:
+            raise self.RelatedObjectDoesNotExist(
+                "%s has no %s." % (self.field.model.__name__, self.field.name)
+            )
+        else:
+            return rel_obj
+
+    def __set__(self, instance, value):
+        """
+        Set the related instance through the forward relation.
+
+        With the example above, when setting ``child.parent = parent``:
+
+        - ``self`` is the descriptor managing the ``parent`` attribute
+        - ``instance`` is the ``child`` instance
+        - ``value`` is the ``parent`` instance on the right of the equal sign
+        """
+        # An object must be an instance of the related class.
+        if value is not None and not isinstance(
+            value, self.field.remote_field.model._meta.concrete_model
+        ):
+            raise ValueError(
+                'Cannot assign "%r": "%s.%s" must be a "%s" instance.'
+                % (
+                    value,
+                    instance._meta.object_name,
+                    self.field.name,
+                    self.field.remote_field.model._meta.object_name,
+                )
+            )
+        elif value is not None:
+            if instance._state.db is None:
+                instance._state.db = router.db_for_write(
+                    instance.__class__, instance=value
+                )
+            if value._state.db is None:
+                value._state.db = router.db_for_write(
+                    value.__class__, instance=instance
+                )
+            if not router.allow_relation(value, instance):
+                raise ValueError(
+                    'Cannot assign "%r": the current database router prevents this '
+                    "relation." % value
+                )
+
+        remote_field = self.field.remote_field
+        # If we're setting the value of a OneToOneField to None, we need to clear
+        # out the cache on any old related object. Otherwise, deleting the
+        # previously-related object will also cause this object to be deleted,
+        # which is wrong.
+        if value is None:
+            # Look up the previously-related object, which may still be available
+            # since we've not yet cleared out the related field.
+            # Use the cache directly, instead of the accessor; if we haven't
+            # populated the cache, then we don't care - we're only accessing
+            # the object to invalidate the accessor cache, so there's no
+            # need to populate the cache just to expire it again.
+            related = self.field.get_cached_value(instance, default=None)
+
+            # If we've got an old related object, we need to clear out its
+            # cache. This cache also might not exist if the related object
+            # hasn't been accessed yet.
+            if related is not None:
+                remote_field.set_cached_value(related, None)
+
+            for lh_field, rh_field in self.field.related_fields:
+                setattr(instance, lh_field.attname, None)
+
+        # Set the values of the related field.
+        else:
+            for lh_field, rh_field in self.field.related_fields:
+                setattr(instance, lh_field.attname, getattr(value, rh_field.attname))
+
+        # Set the related instance cache used by __get__ to avoid an SQL query
+        # when accessing the attribute we just set.
+        self.field.set_cached_value(instance, value)
+
+        # If this is a one-to-one relation, set the reverse accessor cache on
+        # the related object to the current instance to avoid an extra SQL
+        # query if it's accessed later on.
+        if value is not None and not remote_field.multiple:
+            remote_field.set_cached_value(value, instance)
+
+    def __reduce__(self):
+        """
+        Pickling should return the instance attached by self.field on the
+        model, not a new copy of that descriptor. Use getattr() to retrieve
+        the instance directly from the model.
+        """
+        return getattr, (self.field.model, self.field.name)
+
+
+class ForwardOneToOneDescriptor(ForwardManyToOneDescriptor):
+    """
+    Accessor to the related object on the forward side of a one-to-one relation.
+
+    In the example::
+
+        class Restaurant(Model):
+            place = OneToOneField(Place, related_name='restaurant')
+
+    ``Restaurant.place`` is a ``ForwardOneToOneDescriptor`` instance.
+    """
+
+    def get_object(self, instance):
+        if self.field.remote_field.parent_link:
+            deferred = instance.get_deferred_fields()
+            # Because it's a parent link, all the data is available in the
+            # instance, so populate the parent model with this data.
+            rel_model = self.field.remote_field.model
+            fields = [field.attname for field in rel_model._meta.concrete_fields]
+
+            # If any of the related model's fields are deferred, fallback to
+            # fetching all fields from the related model. This avoids a query
+            # on the related model for every deferred field.
+            if not any(field in fields for field in deferred):
+                kwargs = {field: getattr(instance, field) for field in fields}
+                obj = rel_model(**kwargs)
+                obj._state.adding = instance._state.adding
+                obj._state.db = instance._state.db
+                return obj
+        return super().get_object(instance)
+
+    def __set__(self, instance, value):
+        super().__set__(instance, value)
+        # If the primary key is a link to a parent model and a parent instance
+        # is being set, update the value of the inherited pk(s).
+        if self.field.primary_key and self.field.remote_field.parent_link:
+            opts = instance._meta
+            # Inherited primary key fields from this object's base classes.
+            inherited_pk_fields = [
+                field
+                for field in opts.concrete_fields
+                if field.primary_key and field.remote_field
+            ]
+            for field in inherited_pk_fields:
+                rel_model_pk_name = field.remote_field.model._meta.pk.attname
+                raw_value = (
+                    getattr(value, rel_model_pk_name) if value is not None else None
+                )
+                setattr(instance, rel_model_pk_name, raw_value)
+
+
+class ReverseOneToOneDescriptor:
+    """
+    Accessor to the related object on the reverse side of a one-to-one
+    relation.
+
+    In the example::
+
+        class Restaurant(Model):
+            place = OneToOneField(Place, related_name='restaurant')
+
+    ``Place.restaurant`` is a ``ReverseOneToOneDescriptor`` instance.
+    """
+
+    def __init__(self, related):
+        # Following the example above, `related` is an instance of OneToOneRel
+        # which represents the reverse restaurant field (place.restaurant).
+        self.related = related
+
+    @cached_property
+    def RelatedObjectDoesNotExist(self):
+        # The exception isn't created at initialization time for the sake of
+        # consistency with `ForwardManyToOneDescriptor`.
+        return type(
+            "RelatedObjectDoesNotExist",
+            (self.related.related_model.DoesNotExist, AttributeError),
+            {
+                "__module__": self.related.model.__module__,
+                "__qualname__": "%s.%s.RelatedObjectDoesNotExist"
+                % (
+                    self.related.model.__qualname__,
+                    self.related.name,
+                ),
+            },
+        )
+
+    def is_cached(self, instance):
+        return self.related.is_cached(instance)
+
+    def get_queryset(self, **hints):
+        return self.related.related_model._base_manager.db_manager(hints=hints).all()
+
+    def get_prefetch_queryset(self, instances, queryset=None):
+        if queryset is None:
+            queryset = self.get_queryset()
+        queryset._add_hints(instance=instances[0])
+
+        rel_obj_attr = self.related.field.get_local_related_value
+        instance_attr = self.related.field.get_foreign_related_value
+        instances_dict = {instance_attr(inst): inst for inst in instances}
+        query = {"%s__in" % self.related.field.name: instances}
+        queryset = queryset.filter(**query)
+
+        # Since we're going to assign directly in the cache,
+        # we must manage the reverse relation cache manually.
+        for rel_obj in queryset:
+            instance = instances_dict[rel_obj_attr(rel_obj)]
+            self.related.field.set_cached_value(rel_obj, instance)
+        return (
+            queryset,
+            rel_obj_attr,
+            instance_attr,
+            True,
+            self.related.get_cache_name(),
+            False,
+        )
+
+    def __get__(self, instance, cls=None):
+        """
+        Get the related instance through the reverse relation.
+
+        With the example above, when getting ``place.restaurant``:
+
+        - ``self`` is the descriptor managing the ``restaurant`` attribute
+        - ``instance`` is the ``place`` instance
+        - ``cls`` is the ``Place`` class (unused)
+
+        Keep in mind that ``Restaurant`` holds the foreign key to ``Place``.
+        """
+        if instance is None:
+            return self
+
+        # The related instance is loaded from the database and then cached
+        # by the field on the model instance state. It can also be pre-cached
+        # by the forward accessor (ForwardManyToOneDescriptor).
+        try:
+            rel_obj = self.related.get_cached_value(instance)
+        except KeyError:
+            related_pk = instance.pk
+            if related_pk is None:
+                rel_obj = None
+            else:
+                filter_args = self.related.field.get_forward_related_filter(instance)
+                try:
+                    rel_obj = self.get_queryset(instance=instance).get(**filter_args)
+                except self.related.related_model.DoesNotExist:
+                    rel_obj = None
+                else:
+                    # Set the forward accessor cache on the related object to
+                    # the current instance to avoid an extra SQL query if it's
+                    # accessed later on.
+                    self.related.field.set_cached_value(rel_obj, instance)
+            self.related.set_cached_value(instance, rel_obj)
+
+        if rel_obj is None:
+            raise self.RelatedObjectDoesNotExist(
+                "%s has no %s."
+                % (instance.__class__.__name__, self.related.get_accessor_name())
+            )
+        else:
+            return rel_obj
+
+    def __set__(self, instance, value):
+        """
+        Set the related instance through the reverse relation.
+
+        With the example above, when setting ``place.restaurant = restaurant``:
+
+        - ``self`` is the descriptor managing the ``restaurant`` attribute
+        - ``instance`` is the ``place`` instance
+        - ``value`` is the ``restaurant`` instance on the right of the equal sign
+
+        Keep in mind that ``Restaurant`` holds the foreign key to ``Place``.
+        """
+        # The similarity of the code below to the code in
+        # ForwardManyToOneDescriptor is annoying, but there's a bunch
+        # of small differences that would make a common base class convoluted.
+
+        if value is None:
+            # Update the cached related instance (if any) & clear the cache.
+            # Following the example above, this would be the cached
+            # ``restaurant`` instance (if any).
+            rel_obj = self.related.get_cached_value(instance, default=None)
+            if rel_obj is not None:
+                # Remove the ``restaurant`` instance from the ``place``
+                # instance cache.
+                self.related.delete_cached_value(instance)
+                # Set the ``place`` field on the ``restaurant``
+                # instance to None.
+                setattr(rel_obj, self.related.field.name, None)
+        elif not isinstance(value, self.related.related_model):
+            # An object must be an instance of the related class.
+            raise ValueError(
+                'Cannot assign "%r": "%s.%s" must be a "%s" instance.'
+                % (
+                    value,
+                    instance._meta.object_name,
+                    self.related.get_accessor_name(),
+                    self.related.related_model._meta.object_name,
+                )
+            )
+        else:
+            if instance._state.db is None:
+                instance._state.db = router.db_for_write(
+                    instance.__class__, instance=value
+                )
+            if value._state.db is None:
+                value._state.db = router.db_for_write(
+                    value.__class__, instance=instance
+                )
+            if not router.allow_relation(value, instance):
+                raise ValueError(
+                    'Cannot assign "%r": the current database router prevents this '
+                    "relation." % value
+                )
+
+            related_pk = tuple(
+                getattr(instance, field.attname)
+                for field in self.related.field.foreign_related_fields
+            )
+            # Set the value of the related field to the value of the related
+            # object's related field.
+            for index, field in enumerate(self.related.field.local_related_fields):
+                setattr(value, field.attname, related_pk[index])
+
+            # Set the related instance cache used by __get__ to avoid an SQL query
+            # when accessing the attribute we just set.
+            self.related.set_cached_value(instance, value)
+
+            # Set the forward accessor cache on the related object to the current
+            # instance to avoid an extra SQL query if it's accessed later on.
+            self.related.field.set_cached_value(value, instance)
+
+    def __reduce__(self):
+        # Same purpose as ForwardManyToOneDescriptor.__reduce__().
+        return getattr, (self.related.model, self.related.name)
+
+
+class ReverseManyToOneDescriptor:
+    """
+    Accessor to the related objects manager on the reverse side of a
+    many-to-one relation.
+
+    In the example::
+
+        class Child(Model):
+            parent = ForeignKey(Parent, related_name='children')
+
+    ``Parent.children`` is a ``ReverseManyToOneDescriptor`` instance.
+
+    Most of the implementation is delegated to a dynamically defined manager
+    class built by ``create_forward_many_to_many_manager()`` defined below.
+    """
+
+    def __init__(self, rel):
+        self.rel = rel
+        self.field = rel.field
+
+    @cached_property
+    def related_manager_cls(self):
+        related_model = self.rel.related_model
+
+        return create_reverse_many_to_one_manager(
+            related_model._default_manager.__class__,
+            self.rel,
+        )
+
+    def __get__(self, instance, cls=None):
+        """
+        Get the related objects through the reverse relation.
+
+        With the example above, when getting ``parent.children``:
+
+        - ``self`` is the descriptor managing the ``children`` attribute
+        - ``instance`` is the ``parent`` instance
+        - ``cls`` is the ``Parent`` class (unused)
+        """
+        if instance is None:
+            return self
+
+        return self.related_manager_cls(instance)
+
+    def _get_set_deprecation_msg_params(self):
+        return (
+            "reverse side of a related set",
+            self.rel.get_accessor_name(),
+        )
+
+    def __set__(self, instance, value):
+        raise TypeError(
+            "Direct assignment to the %s is prohibited. Use %s.set() instead."
+            % self._get_set_deprecation_msg_params(),
+        )
+
+
+def create_reverse_many_to_one_manager(superclass, rel):
+    """
+    Create a manager for the reverse side of a many-to-one relation.
+
+    This manager subclasses another manager, generally the default manager of
+    the related model, and adds behaviors specific to many-to-one relations.
+    """
+
+    class RelatedManager(superclass, AltersData):
+        def __init__(self, instance):
+            super().__init__()
+
+            self.instance = instance
+            self.model = rel.related_model
+            self.field = rel.field
+
+            self.core_filters = {self.field.name: instance}
+
+        def __call__(self, *, manager):
+            manager = getattr(self.model, manager)
+            manager_class = create_reverse_many_to_one_manager(manager.__class__, rel)
+            return manager_class(self.instance)
+
+        do_not_call_in_templates = True
+
+        def _check_fk_val(self):
+            for field in self.field.foreign_related_fields:
+                if getattr(self.instance, field.attname) is None:
+                    raise ValueError(
+                        f'"{self.instance!r}" needs to have a value for field '
+                        f'"{field.attname}" before this relationship can be used.'
+                    )
+
+        def _apply_rel_filters(self, queryset):
+            """
+            Filter the queryset for the instance this manager is bound to.
+            """
+            db = self._db or router.db_for_read(self.model, instance=self.instance)
+            empty_strings_as_null = connections[
+                db
+            ].features.interprets_empty_strings_as_nulls
+            queryset._add_hints(instance=self.instance)
+            if self._db:
+                queryset = queryset.using(self._db)
+            queryset._defer_next_filter = True
+            queryset = queryset.filter(**self.core_filters)
+            for field in self.field.foreign_related_fields:
+                val = getattr(self.instance, field.attname)
+                if val is None or (val == "" and empty_strings_as_null):
+                    return queryset.none()
+            if self.field.many_to_one:
+                # Guard against field-like objects such as GenericRelation
+                # that abuse create_reverse_many_to_one_manager() with reverse
+                # one-to-many relationships instead and break known related
+                # objects assignment.
+                try:
+                    target_field = self.field.target_field
+                except FieldError:
+                    # The relationship has multiple target fields. Use a tuple
+                    # for related object id.
+                    rel_obj_id = tuple(
+                        [
+                            getattr(self.instance, target_field.attname)
+                            for target_field in self.field.path_infos[-1].target_fields
+                        ]
+                    )
+                else:
+                    rel_obj_id = getattr(self.instance, target_field.attname)
+                queryset._known_related_objects = {
+                    self.field: {rel_obj_id: self.instance}
+                }
+            return queryset
+
+        def _remove_prefetched_objects(self):
+            try:
+                self.instance._prefetched_objects_cache.pop(
+                    self.field.remote_field.get_cache_name()
+                )
+            except (AttributeError, KeyError):
+                pass  # nothing to clear from cache
+
+        def get_queryset(self):
+            # Even if this relation is not to pk, we require still pk value.
+            # The wish is that the instance has been already saved to DB,
+            # although having a pk value isn't a guarantee of that.
+            if self.instance.pk is None:
+                raise ValueError(
+                    f"{self.instance.__class__.__name__!r} instance needs to have a "
+                    f"primary key value before this relationship can be used."
+                )
+            try:
+                return self.instance._prefetched_objects_cache[
+                    self.field.remote_field.get_cache_name()
+                ]
+            except (AttributeError, KeyError):
+                queryset = super().get_queryset()
+                return self._apply_rel_filters(queryset)
+
+        def get_prefetch_queryset(self, instances, queryset=None):
+            if queryset is None:
+                queryset = super().get_queryset()
+
+            queryset._add_hints(instance=instances[0])
+            queryset = queryset.using(queryset._db or self._db)
+
+            rel_obj_attr = self.field.get_local_related_value
+            instance_attr = self.field.get_foreign_related_value
+            instances_dict = {instance_attr(inst): inst for inst in instances}
+            queryset = _filter_prefetch_queryset(queryset, self.field.name, instances)
+
+            # Since we just bypassed this class' get_queryset(), we must manage
+            # the reverse relation manually.
+            for rel_obj in queryset:
+                if not self.field.is_cached(rel_obj):
+                    instance = instances_dict[rel_obj_attr(rel_obj)]
+                    setattr(rel_obj, self.field.name, instance)
+            cache_name = self.field.remote_field.get_cache_name()
+            return queryset, rel_obj_attr, instance_attr, False, cache_name, False
+
+        def add(self, *objs, bulk=True):
+            self._check_fk_val()
+            self._remove_prefetched_objects()
+            db = router.db_for_write(self.model, instance=self.instance)
+
+            def check_and_update_obj(obj):
+                if not isinstance(obj, self.model):
+                    raise TypeError(
+                        "'%s' instance expected, got %r"
+                        % (
+                            self.model._meta.object_name,
+                            obj,
+                        )
+                    )
+                setattr(obj, self.field.name, self.instance)
+
+            if bulk:
+                pks = []
+                for obj in objs:
+                    check_and_update_obj(obj)
+                    if obj._state.adding or obj._state.db != db:
+                        raise ValueError(
+                            "%r instance isn't saved. Use bulk=False or save "
+                            "the object first." % obj
+                        )
+                    pks.append(obj.pk)
+                self.model._base_manager.using(db).filter(pk__in=pks).update(
+                    **{
+                        self.field.name: self.instance,
+                    }
+                )
+            else:
+                with transaction.atomic(using=db, savepoint=False):
+                    for obj in objs:
+                        check_and_update_obj(obj)
+                        obj.save()
+
+        add.alters_data = True
+
+        async def aadd(self, *objs, bulk=True):
+            return await sync_to_async(self.add)(*objs, bulk=bulk)
+
+        aadd.alters_data = True
+
+        def create(self, **kwargs):
+            self._check_fk_val()
+            kwargs[self.field.name] = self.instance
+            db = router.db_for_write(self.model, instance=self.instance)
+            return super(RelatedManager, self.db_manager(db)).create(**kwargs)
+
+        create.alters_data = True
+
+        async def acreate(self, **kwargs):
+            return await sync_to_async(self.create)(**kwargs)
+
+        acreate.alters_data = True
+
+        def get_or_create(self, **kwargs):
+            self._check_fk_val()
+            kwargs[self.field.name] = self.instance
+            db = router.db_for_write(self.model, instance=self.instance)
+            return super(RelatedManager, self.db_manager(db)).get_or_create(**kwargs)
+
+        get_or_create.alters_data = True
+
+        async def aget_or_create(self, **kwargs):
+            return await sync_to_async(self.get_or_create)(**kwargs)
+
+        aget_or_create.alters_data = True
+
+        def update_or_create(self, **kwargs):
+            self._check_fk_val()
+            kwargs[self.field.name] = self.instance
+            db = router.db_for_write(self.model, instance=self.instance)
+            return super(RelatedManager, self.db_manager(db)).update_or_create(**kwargs)
+
+        update_or_create.alters_data = True
+
+        async def aupdate_or_create(self, **kwargs):
+            return await sync_to_async(self.update_or_create)(**kwargs)
+
+        aupdate_or_create.alters_data = True
+
+        # remove() and clear() are only provided if the ForeignKey can have a
+        # value of null.
+        if rel.field.null:
+
+            def remove(self, *objs, bulk=True):
+                if not objs:
+                    return
+                self._check_fk_val()
+                val = self.field.get_foreign_related_value(self.instance)
+                old_ids = set()
+                for obj in objs:
+                    if not isinstance(obj, self.model):
+                        raise TypeError(
+                            "'%s' instance expected, got %r"
+                            % (
+                                self.model._meta.object_name,
+                                obj,
+                            )
+                        )
+                    # Is obj actually part of this descriptor set?
+                    if self.field.get_local_related_value(obj) == val:
+                        old_ids.add(obj.pk)
+                    else:
+                        raise self.field.remote_field.model.DoesNotExist(
+                            "%r is not related to %r." % (obj, self.instance)
+                        )
+                self._clear(self.filter(pk__in=old_ids), bulk)
+
+            remove.alters_data = True
+
+            async def aremove(self, *objs, bulk=True):
+                return await sync_to_async(self.remove)(*objs, bulk=bulk)
+
+            aremove.alters_data = True
+
+            def clear(self, *, bulk=True):
+                self._check_fk_val()
+                self._clear(self, bulk)
+
+            clear.alters_data = True
+
+            async def aclear(self, *, bulk=True):
+                return await sync_to_async(self.clear)(bulk=bulk)
+
+            aclear.alters_data = True
+
+            def _clear(self, queryset, bulk):
+                self._remove_prefetched_objects()
+                db = router.db_for_write(self.model, instance=self.instance)
+                queryset = queryset.using(db)
+                if bulk:
+                    # `QuerySet.update()` is intrinsically atomic.
+                    queryset.update(**{self.field.name: None})
+                else:
+                    with transaction.atomic(using=db, savepoint=False):
+                        for obj in queryset:
+                            setattr(obj, self.field.name, None)
+                            obj.save(update_fields=[self.field.name])
+
+            _clear.alters_data = True
+
+        def set(self, objs, *, bulk=True, clear=False):
+            self._check_fk_val()
+            # Force evaluation of `objs` in case it's a queryset whose value
+            # could be affected by `manager.clear()`. Refs #19816.
+            objs = tuple(objs)
+
+            if self.field.null:
+                db = router.db_for_write(self.model, instance=self.instance)
+                with transaction.atomic(using=db, savepoint=False):
+                    if clear:
+                        self.clear(bulk=bulk)
+                        self.add(*objs, bulk=bulk)
+                    else:
+                        old_objs = set(self.using(db).all())
+                        new_objs = []
+                        for obj in objs:
+                            if obj in old_objs:
+                                old_objs.remove(obj)
+                            else:
+                                new_objs.append(obj)
+
+                        self.remove(*old_objs, bulk=bulk)
+                        self.add(*new_objs, bulk=bulk)
+            else:
+                self.add(*objs, bulk=bulk)
+
+        set.alters_data = True
+
+        async def aset(self, objs, *, bulk=True, clear=False):
+            return await sync_to_async(self.set)(objs=objs, bulk=bulk, clear=clear)
+
+        aset.alters_data = True
+
+    return RelatedManager
+
+
+class ManyToManyDescriptor(ReverseManyToOneDescriptor):
+    """
+    Accessor to the related objects manager on the forward and reverse sides of
+    a many-to-many relation.
+
+    In the example::
+
+        class Pizza(Model):
+            toppings = ManyToManyField(Topping, related_name='pizzas')
+
+    ``Pizza.toppings`` and ``Topping.pizzas`` are ``ManyToManyDescriptor``
+    instances.
+
+    Most of the implementation is delegated to a dynamically defined manager
+    class built by ``create_forward_many_to_many_manager()`` defined below.
+    """
+
+    def __init__(self, rel, reverse=False):
+        super().__init__(rel)
+
+        self.reverse = reverse
+
+    @property
+    def through(self):
+        # through is provided so that you have easy access to the through
+        # model (Book.authors.through) for inlines, etc. This is done as
+        # a property to ensure that the fully resolved value is returned.
+        return self.rel.through
+
+    @cached_property
+    def related_manager_cls(self):
+        related_model = self.rel.related_model if self.reverse else self.rel.model
+
+        return create_forward_many_to_many_manager(
+            related_model._default_manager.__class__,
+            self.rel,
+            reverse=self.reverse,
+        )
+
+    def _get_set_deprecation_msg_params(self):
+        return (
+            "%s side of a many-to-many set"
+            % ("reverse" if self.reverse else "forward"),
+            self.rel.get_accessor_name() if self.reverse else self.field.name,
+        )
+
+
+def create_forward_many_to_many_manager(superclass, rel, reverse):
+    """
+    Create a manager for the either side of a many-to-many relation.
+
+    This manager subclasses another manager, generally the default manager of
+    the related model, and adds behaviors specific to many-to-many relations.
+    """
+
+    class ManyRelatedManager(superclass, AltersData):
+        def __init__(self, instance=None):
+            super().__init__()
+
+            self.instance = instance
+
+            if not reverse:
+                self.model = rel.model
+                self.query_field_name = rel.field.related_query_name()
+                self.prefetch_cache_name = rel.field.name
+                self.source_field_name = rel.field.m2m_field_name()
+                self.target_field_name = rel.field.m2m_reverse_field_name()
+                self.symmetrical = rel.symmetrical
+            else:
+                self.model = rel.related_model
+                self.query_field_name = rel.field.name
+                self.prefetch_cache_name = rel.field.related_query_name()
+                self.source_field_name = rel.field.m2m_reverse_field_name()
+                self.target_field_name = rel.field.m2m_field_name()
+                self.symmetrical = False
+
+            self.through = rel.through
+            self.reverse = reverse
+
+            self.source_field = self.through._meta.get_field(self.source_field_name)
+            self.target_field = self.through._meta.get_field(self.target_field_name)
+
+            self.core_filters = {}
+            self.pk_field_names = {}
+            for lh_field, rh_field in self.source_field.related_fields:
+                core_filter_key = "%s__%s" % (self.query_field_name, rh_field.name)
+                self.core_filters[core_filter_key] = getattr(instance, rh_field.attname)
+                self.pk_field_names[lh_field.name] = rh_field.name
+
+            self.related_val = self.source_field.get_foreign_related_value(instance)
+            if None in self.related_val:
+                raise ValueError(
+                    '"%r" needs to have a value for field "%s" before '
+                    "this many-to-many relationship can be used."
+                    % (instance, self.pk_field_names[self.source_field_name])
+                )
+            # Even if this relation is not to pk, we require still pk value.
+            # The wish is that the instance has been already saved to DB,
+            # although having a pk value isn't a guarantee of that.
+            if instance.pk is None:
+                raise ValueError(
+                    "%r instance needs to have a primary key value before "
+                    "a many-to-many relationship can be used."
+                    % instance.__class__.__name__
+                )
+
+        def __call__(self, *, manager):
+            manager = getattr(self.model, manager)
+            manager_class = create_forward_many_to_many_manager(
+                manager.__class__, rel, reverse
+            )
+            return manager_class(instance=self.instance)
+
+        do_not_call_in_templates = True
+
+        def _build_remove_filters(self, removed_vals):
+            filters = Q.create([(self.source_field_name, self.related_val)])
+            # No need to add a subquery condition if removed_vals is a QuerySet without
+            # filters.
+            removed_vals_filters = (
+                not isinstance(removed_vals, QuerySet) or removed_vals._has_filters()
+            )
+            if removed_vals_filters:
+                filters &= Q.create([(f"{self.target_field_name}__in", removed_vals)])
+            if self.symmetrical:
+                symmetrical_filters = Q.create(
+                    [(self.target_field_name, self.related_val)]
+                )
+                if removed_vals_filters:
+                    symmetrical_filters &= Q.create(
+                        [(f"{self.source_field_name}__in", removed_vals)]
+                    )
+                filters |= symmetrical_filters
+            return filters
+
+        def _apply_rel_filters(self, queryset):
+            """
+            Filter the queryset for the instance this manager is bound to.
+            """
+            queryset._add_hints(instance=self.instance)
+            if self._db:
+                queryset = queryset.using(self._db)
+            queryset._defer_next_filter = True
+            return queryset._next_is_sticky().filter(**self.core_filters)
+
+        def _remove_prefetched_objects(self):
+            try:
+                self.instance._prefetched_objects_cache.pop(self.prefetch_cache_name)
+            except (AttributeError, KeyError):
+                pass  # nothing to clear from cache
+
+        def get_queryset(self):
+            try:
+                return self.instance._prefetched_objects_cache[self.prefetch_cache_name]
+            except (AttributeError, KeyError):
+                queryset = super().get_queryset()
+                return self._apply_rel_filters(queryset)
+
+        def get_prefetch_queryset(self, instances, queryset=None):
+            if queryset is None:
+                queryset = super().get_queryset()
+
+            queryset._add_hints(instance=instances[0])
+            queryset = queryset.using(queryset._db or self._db)
+            queryset = _filter_prefetch_queryset(
+                queryset._next_is_sticky(), self.query_field_name, instances
+            )
+
+            # M2M: need to annotate the query in order to get the primary model
+            # that the secondary model was actually related to. We know that
+            # there will already be a join on the join table, so we can just add
+            # the select.
+
+            # For non-autocreated 'through' models, can't assume we are
+            # dealing with PK values.
+            fk = self.through._meta.get_field(self.source_field_name)
+            join_table = fk.model._meta.db_table
+            connection = connections[queryset.db]
+            qn = connection.ops.quote_name
+            queryset = queryset.extra(
+                select={
+                    "_prefetch_related_val_%s"
+                    % f.attname: "%s.%s"
+                    % (qn(join_table), qn(f.column))
+                    for f in fk.local_related_fields
+                }
+            )
+            return (
+                queryset,
+                lambda result: tuple(
+                    getattr(result, "_prefetch_related_val_%s" % f.attname)
+                    for f in fk.local_related_fields
+                ),
+                lambda inst: tuple(
+                    f.get_db_prep_value(getattr(inst, f.attname), connection)
+                    for f in fk.foreign_related_fields
+                ),
+                False,
+                self.prefetch_cache_name,
+                False,
+            )
+
+        def add(self, *objs, through_defaults=None):
+            self._remove_prefetched_objects()
+            db = router.db_for_write(self.through, instance=self.instance)
+            with transaction.atomic(using=db, savepoint=False):
+                self._add_items(
+                    self.source_field_name,
+                    self.target_field_name,
+                    *objs,
+                    through_defaults=through_defaults,
+                )
+                # If this is a symmetrical m2m relation to self, add the mirror
+                # entry in the m2m table.
+                if self.symmetrical:
+                    self._add_items(
+                        self.target_field_name,
+                        self.source_field_name,
+                        *objs,
+                        through_defaults=through_defaults,
+                    )
+
+        add.alters_data = True
+
+        async def aadd(self, *objs, through_defaults=None):
+            return await sync_to_async(self.add)(
+                *objs, through_defaults=through_defaults
+            )
+
+        aadd.alters_data = True
+
+        def remove(self, *objs):
+            self._remove_prefetched_objects()
+            self._remove_items(self.source_field_name, self.target_field_name, *objs)
+
+        remove.alters_data = True
+
+        async def aremove(self, *objs):
+            return await sync_to_async(self.remove)(*objs)
+
+        aremove.alters_data = True
+
+        def clear(self):
+            db = router.db_for_write(self.through, instance=self.instance)
+            with transaction.atomic(using=db, savepoint=False):
+                signals.m2m_changed.send(
+                    sender=self.through,
+                    action="pre_clear",
+                    instance=self.instance,
+                    reverse=self.reverse,
+                    model=self.model,
+                    pk_set=None,
+                    using=db,
+                )
+                self._remove_prefetched_objects()
+                filters = self._build_remove_filters(super().get_queryset().using(db))
+                self.through._default_manager.using(db).filter(filters).delete()
+
+                signals.m2m_changed.send(
+                    sender=self.through,
+                    action="post_clear",
+                    instance=self.instance,
+                    reverse=self.reverse,
+                    model=self.model,
+                    pk_set=None,
+                    using=db,
+                )
+
+        clear.alters_data = True
+
+        async def aclear(self):
+            return await sync_to_async(self.clear)()
+
+        aclear.alters_data = True
+
+        def set(self, objs, *, clear=False, through_defaults=None):
+            # Force evaluation of `objs` in case it's a queryset whose value
+            # could be affected by `manager.clear()`. Refs #19816.
+            objs = tuple(objs)
+
+            db = router.db_for_write(self.through, instance=self.instance)
+            with transaction.atomic(using=db, savepoint=False):
+                if clear:
+                    self.clear()
+                    self.add(*objs, through_defaults=through_defaults)
+                else:
+                    old_ids = set(
+                        self.using(db).values_list(
+                            self.target_field.target_field.attname, flat=True
+                        )
+                    )
+
+                    new_objs = []
+                    for obj in objs:
+                        fk_val = (
+                            self.target_field.get_foreign_related_value(obj)[0]
+                            if isinstance(obj, self.model)
+                            else self.target_field.get_prep_value(obj)
+                        )
+                        if fk_val in old_ids:
+                            old_ids.remove(fk_val)
+                        else:
+                            new_objs.append(obj)
+
+                    self.remove(*old_ids)
+                    self.add(*new_objs, through_defaults=through_defaults)
+
+        set.alters_data = True
+
+        async def aset(self, objs, *, clear=False, through_defaults=None):
+            return await sync_to_async(self.set)(
+                objs=objs, clear=clear, through_defaults=through_defaults
+            )
+
+        aset.alters_data = True
+
+        def create(self, *, through_defaults=None, **kwargs):
+            db = router.db_for_write(self.instance.__class__, instance=self.instance)
+            new_obj = super(ManyRelatedManager, self.db_manager(db)).create(**kwargs)
+            self.add(new_obj, through_defaults=through_defaults)
+            return new_obj
+
+        create.alters_data = True
+
+        async def acreate(self, *, through_defaults=None, **kwargs):
+            return await sync_to_async(self.create)(
+                through_defaults=through_defaults, **kwargs
+            )
+
+        acreate.alters_data = True
+
+        def get_or_create(self, *, through_defaults=None, **kwargs):
+            db = router.db_for_write(self.instance.__class__, instance=self.instance)
+            obj, created = super(ManyRelatedManager, self.db_manager(db)).get_or_create(
+                **kwargs
+            )
+            # We only need to add() if created because if we got an object back
+            # from get() then the relationship already exists.
+            if created:
+                self.add(obj, through_defaults=through_defaults)
+            return obj, created
+
+        get_or_create.alters_data = True
+
+        async def aget_or_create(self, *, through_defaults=None, **kwargs):
+            return await sync_to_async(self.get_or_create)(
+                through_defaults=through_defaults, **kwargs
+            )
+
+        aget_or_create.alters_data = True
+
+        def update_or_create(self, *, through_defaults=None, **kwargs):
+            db = router.db_for_write(self.instance.__class__, instance=self.instance)
+            obj, created = super(
+                ManyRelatedManager, self.db_manager(db)
+            ).update_or_create(**kwargs)
+            # We only need to add() if created because if we got an object back
+            # from get() then the relationship already exists.
+            if created:
+                self.add(obj, through_defaults=through_defaults)
+            return obj, created
+
+        update_or_create.alters_data = True
+
+        async def aupdate_or_create(self, *, through_defaults=None, **kwargs):
+            return await sync_to_async(self.update_or_create)(
+                through_defaults=through_defaults, **kwargs
+            )
+
+        aupdate_or_create.alters_data = True
+
+        def _get_target_ids(self, target_field_name, objs):
+            """
+            Return the set of ids of `objs` that the target field references.
+            """
+            from django.db.models import Model
+
+            target_ids = set()
+            target_field = self.through._meta.get_field(target_field_name)
+            for obj in objs:
+                if isinstance(obj, self.model):
+                    if not router.allow_relation(obj, self.instance):
+                        raise ValueError(
+                            'Cannot add "%r": instance is on database "%s", '
+                            'value is on database "%s"'
+                            % (obj, self.instance._state.db, obj._state.db)
+                        )
+                    target_id = target_field.get_foreign_related_value(obj)[0]
+                    if target_id is None:
+                        raise ValueError(
+                            'Cannot add "%r": the value for field "%s" is None'
+                            % (obj, target_field_name)
+                        )
+                    target_ids.add(target_id)
+                elif isinstance(obj, Model):
+                    raise TypeError(
+                        "'%s' instance expected, got %r"
+                        % (self.model._meta.object_name, obj)
+                    )
+                else:
+                    target_ids.add(target_field.get_prep_value(obj))
+            return target_ids
+
+        def _get_missing_target_ids(
+            self, source_field_name, target_field_name, db, target_ids
+        ):
+            """
+            Return the subset of ids of `objs` that aren't already assigned to
+            this relationship.
+            """
+            vals = (
+                self.through._default_manager.using(db)
+                .values_list(target_field_name, flat=True)
+                .filter(
+                    **{
+                        source_field_name: self.related_val[0],
+                        "%s__in" % target_field_name: target_ids,
+                    }
+                )
+            )
+            return target_ids.difference(vals)
+
+        def _get_add_plan(self, db, source_field_name):
+            """
+            Return a boolean triple of the way the add should be performed.
+
+            The first element is whether or not bulk_create(ignore_conflicts)
+            can be used, the second whether or not signals must be sent, and
+            the third element is whether or not the immediate bulk insertion
+            with conflicts ignored can be performed.
+            """
+            # Conflicts can be ignored when the intermediary model is
+            # auto-created as the only possible collision is on the
+            # (source_id, target_id) tuple. The same assertion doesn't hold for
+            # user-defined intermediary models as they could have other fields
+            # causing conflicts which must be surfaced.
+            can_ignore_conflicts = (
+                self.through._meta.auto_created is not False
+                and connections[db].features.supports_ignore_conflicts
+            )
+            # Don't send the signal when inserting duplicate data row
+            # for symmetrical reverse entries.
+            must_send_signals = (
+                self.reverse or source_field_name == self.source_field_name
+            ) and (signals.m2m_changed.has_listeners(self.through))
+            # Fast addition through bulk insertion can only be performed
+            # if no m2m_changed listeners are connected for self.through
+            # as they require the added set of ids to be provided via
+            # pk_set.
+            return (
+                can_ignore_conflicts,
+                must_send_signals,
+                (can_ignore_conflicts and not must_send_signals),
+            )
+
+        def _add_items(
+            self, source_field_name, target_field_name, *objs, through_defaults=None
+        ):
+            # source_field_name: the PK fieldname in join table for the source object
+            # target_field_name: the PK fieldname in join table for the target object
+            # *objs - objects to add. Either object instances, or primary keys
+            # of object instances.
+            if not objs:
+                return
+
+            through_defaults = dict(resolve_callables(through_defaults or {}))
+            target_ids = self._get_target_ids(target_field_name, objs)
+            db = router.db_for_write(self.through, instance=self.instance)
+            can_ignore_conflicts, must_send_signals, can_fast_add = self._get_add_plan(
+                db, source_field_name
+            )
+            if can_fast_add:
+                self.through._default_manager.using(db).bulk_create(
+                    [
+                        self.through(
+                            **{
+                                "%s_id" % source_field_name: self.related_val[0],
+                                "%s_id" % target_field_name: target_id,
+                            }
+                        )
+                        for target_id in target_ids
+                    ],
+                    ignore_conflicts=True,
+                )
+                return
+
+            missing_target_ids = self._get_missing_target_ids(
+                source_field_name, target_field_name, db, target_ids
+            )
+            with transaction.atomic(using=db, savepoint=False):
+                if must_send_signals:
+                    signals.m2m_changed.send(
+                        sender=self.through,
+                        action="pre_add",
+                        instance=self.instance,
+                        reverse=self.reverse,
+                        model=self.model,
+                        pk_set=missing_target_ids,
+                        using=db,
+                    )
+                # Add the ones that aren't there already.
+                self.through._default_manager.using(db).bulk_create(
+                    [
+                        self.through(
+                            **through_defaults,
+                            **{
+                                "%s_id" % source_field_name: self.related_val[0],
+                                "%s_id" % target_field_name: target_id,
+                            },
+                        )
+                        for target_id in missing_target_ids
+                    ],
+                    ignore_conflicts=can_ignore_conflicts,
+                )
+
+                if must_send_signals:
+                    signals.m2m_changed.send(
+                        sender=self.through,
+                        action="post_add",
+                        instance=self.instance,
+                        reverse=self.reverse,
+                        model=self.model,
+                        pk_set=missing_target_ids,
+                        using=db,
+                    )
+
+        def _remove_items(self, source_field_name, target_field_name, *objs):
+            # source_field_name: the PK colname in join table for the source object
+            # target_field_name: the PK colname in join table for the target object
+            # *objs - objects to remove. Either object instances, or primary
+            # keys of object instances.
+            if not objs:
+                return
+
+            # Check that all the objects are of the right type
+            old_ids = set()
+            for obj in objs:
+                if isinstance(obj, self.model):
+                    fk_val = self.target_field.get_foreign_related_value(obj)[0]
+                    old_ids.add(fk_val)
+                else:
+                    old_ids.add(obj)
+
+            db = router.db_for_write(self.through, instance=self.instance)
+            with transaction.atomic(using=db, savepoint=False):
+                # Send a signal to the other end if need be.
+                signals.m2m_changed.send(
+                    sender=self.through,
+                    action="pre_remove",
+                    instance=self.instance,
+                    reverse=self.reverse,
+                    model=self.model,
+                    pk_set=old_ids,
+                    using=db,
+                )
+                target_model_qs = super().get_queryset()
+                if target_model_qs._has_filters():
+                    old_vals = target_model_qs.using(db).filter(
+                        **{"%s__in" % self.target_field.target_field.attname: old_ids}
+                    )
+                else:
+                    old_vals = old_ids
+                filters = self._build_remove_filters(old_vals)
+                self.through._default_manager.using(db).filter(filters).delete()
+
+                signals.m2m_changed.send(
+                    sender=self.through,
+                    action="post_remove",
+                    instance=self.instance,
+                    reverse=self.reverse,
+                    model=self.model,
+                    pk_set=old_ids,
+                    using=db,
+                )
+
+    return ManyRelatedManager
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/django/db/models/manager.html b/docs/latest/_modules/django/db/models/manager.html new file mode 100644 index 0000000000..366a1d6c73 --- /dev/null +++ b/docs/latest/_modules/django/db/models/manager.html @@ -0,0 +1,316 @@ + + + + + + + + django.db.models.manager — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for django.db.models.manager

+import copy
+import inspect
+from functools import wraps
+from importlib import import_module
+
+from django.db import router
+from django.db.models.query import QuerySet
+
+
+class BaseManager:
+    # To retain order, track each time a Manager instance is created.
+    creation_counter = 0
+
+    # Set to True for the 'objects' managers that are automatically created.
+    auto_created = False
+
+    #: If set to True the manager will be serialized into migrations and will
+    #: thus be available in e.g. RunPython operations.
+    use_in_migrations = False
+
+    def __new__(cls, *args, **kwargs):
+        # Capture the arguments to make returning them trivial.
+        obj = super().__new__(cls)
+        obj._constructor_args = (args, kwargs)
+        return obj
+
+    def __init__(self):
+        super().__init__()
+        self._set_creation_counter()
+        self.model = None
+        self.name = None
+        self._db = None
+        self._hints = {}
+
+    def __str__(self):
+        """Return "app_label.model_label.manager_name"."""
+        return "%s.%s" % (self.model._meta.label, self.name)
+
+    def __class_getitem__(cls, *args, **kwargs):
+        return cls
+
+    def deconstruct(self):
+        """
+        Return a 5-tuple of the form (as_manager (True), manager_class,
+        queryset_class, args, kwargs).
+
+        Raise a ValueError if the manager is dynamically generated.
+        """
+        qs_class = self._queryset_class
+        if getattr(self, "_built_with_as_manager", False):
+            # using MyQuerySet.as_manager()
+            return (
+                True,  # as_manager
+                None,  # manager_class
+                "%s.%s" % (qs_class.__module__, qs_class.__name__),  # qs_class
+                None,  # args
+                None,  # kwargs
+            )
+        else:
+            module_name = self.__module__
+            name = self.__class__.__name__
+            # Make sure it's actually there and not an inner class
+            module = import_module(module_name)
+            if not hasattr(module, name):
+                raise ValueError(
+                    "Could not find manager %s in %s.\n"
+                    "Please note that you need to inherit from managers you "
+                    "dynamically generated with 'from_queryset()'."
+                    % (name, module_name)
+                )
+            return (
+                False,  # as_manager
+                "%s.%s" % (module_name, name),  # manager_class
+                None,  # qs_class
+                self._constructor_args[0],  # args
+                self._constructor_args[1],  # kwargs
+            )
+
+    def check(self, **kwargs):
+        return []
+
+    @classmethod
+    def _get_queryset_methods(cls, queryset_class):
+        def create_method(name, method):
+            @wraps(method)
+            def manager_method(self, *args, **kwargs):
+                return getattr(self.get_queryset(), name)(*args, **kwargs)
+
+            return manager_method
+
+        new_methods = {}
+        for name, method in inspect.getmembers(
+            queryset_class, predicate=inspect.isfunction
+        ):
+            # Only copy missing methods.
+            if hasattr(cls, name):
+                continue
+            # Only copy public methods or methods with the attribute
+            # queryset_only=False.
+            queryset_only = getattr(method, "queryset_only", None)
+            if queryset_only or (queryset_only is None and name.startswith("_")):
+                continue
+            # Copy the method onto the manager.
+            new_methods[name] = create_method(name, method)
+        return new_methods
+
+    @classmethod
+    def from_queryset(cls, queryset_class, class_name=None):
+        if class_name is None:
+            class_name = "%sFrom%s" % (cls.__name__, queryset_class.__name__)
+        return type(
+            class_name,
+            (cls,),
+            {
+                "_queryset_class": queryset_class,
+                **cls._get_queryset_methods(queryset_class),
+            },
+        )
+
+    def contribute_to_class(self, cls, name):
+        self.name = self.name or name
+        self.model = cls
+
+        setattr(cls, name, ManagerDescriptor(self))
+
+        cls._meta.add_manager(self)
+
+    def _set_creation_counter(self):
+        """
+        Set the creation counter value for this instance and increment the
+        class-level copy.
+        """
+        self.creation_counter = BaseManager.creation_counter
+        BaseManager.creation_counter += 1
+
+    def db_manager(self, using=None, hints=None):
+        obj = copy.copy(self)
+        obj._db = using or self._db
+        obj._hints = hints or self._hints
+        return obj
+
+    @property
+    def db(self):
+        return self._db or router.db_for_read(self.model, **self._hints)
+
+    #######################
+    # PROXIES TO QUERYSET #
+    #######################
+
+    def get_queryset(self):
+        """
+        Return a new QuerySet object. Subclasses can override this method to
+        customize the behavior of the Manager.
+        """
+        return self._queryset_class(model=self.model, using=self._db, hints=self._hints)
+
+    def all(self):
+        # We can't proxy this method through the `QuerySet` like we do for the
+        # rest of the `QuerySet` methods. This is because `QuerySet.all()`
+        # works by creating a "copy" of the current queryset and in making said
+        # copy, all the cached `prefetch_related` lookups are lost. See the
+        # implementation of `RelatedManager.get_queryset()` for a better
+        # understanding of how this comes into play.
+        return self.get_queryset()
+
+    def __eq__(self, other):
+        return (
+            isinstance(other, self.__class__)
+            and self._constructor_args == other._constructor_args
+        )
+
+    def __hash__(self):
+        return id(self)
+
+
+class Manager(BaseManager.from_queryset(QuerySet)):
+    pass
+
+
+class ManagerDescriptor:
+    def __init__(self, manager):
+        self.manager = manager
+
+    def __get__(self, instance, cls=None):
+        if instance is not None:
+            raise AttributeError(
+                "Manager isn't accessible via %s instances" % cls.__name__
+            )
+
+        if cls._meta.abstract:
+            raise AttributeError(
+                "Manager isn't available; %s is abstract" % (cls._meta.object_name,)
+            )
+
+        if cls._meta.swapped:
+            raise AttributeError(
+                "Manager isn't available; '%s' has been swapped for '%s'"
+                % (
+                    cls._meta.label,
+                    cls._meta.swapped,
+                )
+            )
+
+        return cls._meta.managers_map[self.manager.name]
+
+
+class EmptyManager(Manager):
+    def __init__(self, model):
+        super().__init__()
+        self.model = model
+
+    def get_queryset(self):
+        return super().get_queryset().none()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/django/db/models/query.html b/docs/latest/_modules/django/db/models/query.html new file mode 100644 index 0000000000..2f7c546ba0 --- /dev/null +++ b/docs/latest/_modules/django/db/models/query.html @@ -0,0 +1,2734 @@ + + + + + + + + django.db.models.query — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for django.db.models.query

+"""
+The main QuerySet implementation. This provides the public API for the ORM.
+"""
+
+import copy
+import operator
+import warnings
+from itertools import chain, islice
+
+from asgiref.sync import sync_to_async
+
+import django
+from django.conf import settings
+from django.core import exceptions
+from django.db import (
+    DJANGO_VERSION_PICKLE_KEY,
+    IntegrityError,
+    NotSupportedError,
+    connections,
+    router,
+    transaction,
+)
+from django.db.models import AutoField, DateField, DateTimeField, Field, sql
+from django.db.models.constants import LOOKUP_SEP, OnConflict
+from django.db.models.deletion import Collector
+from django.db.models.expressions import Case, F, Value, When
+from django.db.models.functions import Cast, Trunc
+from django.db.models.query_utils import FilteredRelation, Q
+from django.db.models.sql.constants import CURSOR, GET_ITERATOR_CHUNK_SIZE
+from django.db.models.utils import (
+    AltersData,
+    create_namedtuple_class,
+    resolve_callables,
+)
+from django.utils import timezone
+from django.utils.deprecation import RemovedInDjango50Warning
+from django.utils.functional import cached_property, partition
+
+# The maximum number of results to fetch in a get() query.
+MAX_GET_RESULTS = 21
+
+# The maximum number of items to display in a QuerySet.__repr__
+REPR_OUTPUT_SIZE = 20
+
+
+class BaseIterable:
+    def __init__(
+        self, queryset, chunked_fetch=False, chunk_size=GET_ITERATOR_CHUNK_SIZE
+    ):
+        self.queryset = queryset
+        self.chunked_fetch = chunked_fetch
+        self.chunk_size = chunk_size
+
+    async def _async_generator(self):
+        # Generators don't actually start running until the first time you call
+        # next() on them, so make the generator object in the async thread and
+        # then repeatedly dispatch to it in a sync thread.
+        sync_generator = self.__iter__()
+
+        def next_slice(gen):
+            return list(islice(gen, self.chunk_size))
+
+        while True:
+            chunk = await sync_to_async(next_slice)(sync_generator)
+            for item in chunk:
+                yield item
+            if len(chunk) < self.chunk_size:
+                break
+
+    # __aiter__() is a *synchronous* method that has to then return an
+    # *asynchronous* iterator/generator. Thus, nest an async generator inside
+    # it.
+    # This is a generic iterable converter for now, and is going to suffer a
+    # performance penalty on large sets of items due to the cost of crossing
+    # over the sync barrier for each chunk. Custom __aiter__() methods should
+    # be added to each Iterable subclass, but that needs some work in the
+    # Compiler first.
+    def __aiter__(self):
+        return self._async_generator()
+
+
+class ModelIterable(BaseIterable):
+    """Iterable that yields a model instance for each row."""
+
+    def __iter__(self):
+        queryset = self.queryset
+        db = queryset.db
+        compiler = queryset.query.get_compiler(using=db)
+        # Execute the query. This will also fill compiler.select, klass_info,
+        # and annotations.
+        results = compiler.execute_sql(
+            chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size
+        )
+        select, klass_info, annotation_col_map = (
+            compiler.select,
+            compiler.klass_info,
+            compiler.annotation_col_map,
+        )
+        model_cls = klass_info["model"]
+        select_fields = klass_info["select_fields"]
+        model_fields_start, model_fields_end = select_fields[0], select_fields[-1] + 1
+        init_list = [
+            f[0].target.attname for f in select[model_fields_start:model_fields_end]
+        ]
+        related_populators = get_related_populators(klass_info, select, db)
+        known_related_objects = [
+            (
+                field,
+                related_objs,
+                operator.attrgetter(
+                    *[
+                        field.attname
+                        if from_field == "self"
+                        else queryset.model._meta.get_field(from_field).attname
+                        for from_field in field.from_fields
+                    ]
+                ),
+            )
+            for field, related_objs in queryset._known_related_objects.items()
+        ]
+        for row in compiler.results_iter(results):
+            obj = model_cls.from_db(
+                db, init_list, row[model_fields_start:model_fields_end]
+            )
+            for rel_populator in related_populators:
+                rel_populator.populate(row, obj)
+            if annotation_col_map:
+                for attr_name, col_pos in annotation_col_map.items():
+                    setattr(obj, attr_name, row[col_pos])
+
+            # Add the known related objects to the model.
+            for field, rel_objs, rel_getter in known_related_objects:
+                # Avoid overwriting objects loaded by, e.g., select_related().
+                if field.is_cached(obj):
+                    continue
+                rel_obj_id = rel_getter(obj)
+                try:
+                    rel_obj = rel_objs[rel_obj_id]
+                except KeyError:
+                    pass  # May happen in qs1 | qs2 scenarios.
+                else:
+                    setattr(obj, field.name, rel_obj)
+
+            yield obj
+
+
+class RawModelIterable(BaseIterable):
+    """
+    Iterable that yields a model instance for each row from a raw queryset.
+    """
+
+    def __iter__(self):
+        # Cache some things for performance reasons outside the loop.
+        db = self.queryset.db
+        query = self.queryset.query
+        connection = connections[db]
+        compiler = connection.ops.compiler("SQLCompiler")(query, connection, db)
+        query_iterator = iter(query)
+
+        try:
+            (
+                model_init_names,
+                model_init_pos,
+                annotation_fields,
+            ) = self.queryset.resolve_model_init_order()
+            model_cls = self.queryset.model
+            if model_cls._meta.pk.attname not in model_init_names:
+                raise exceptions.FieldDoesNotExist(
+                    "Raw query must include the primary key"
+                )
+            fields = [self.queryset.model_fields.get(c) for c in self.queryset.columns]
+            converters = compiler.get_converters(
+                [f.get_col(f.model._meta.db_table) if f else None for f in fields]
+            )
+            if converters:
+                query_iterator = compiler.apply_converters(query_iterator, converters)
+            for values in query_iterator:
+                # Associate fields to values
+                model_init_values = [values[pos] for pos in model_init_pos]
+                instance = model_cls.from_db(db, model_init_names, model_init_values)
+                if annotation_fields:
+                    for column, pos in annotation_fields:
+                        setattr(instance, column, values[pos])
+                yield instance
+        finally:
+            # Done iterating the Query. If it has its own cursor, close it.
+            if hasattr(query, "cursor") and query.cursor:
+                query.cursor.close()
+
+
+class ValuesIterable(BaseIterable):
+    """
+    Iterable returned by QuerySet.values() that yields a dict for each row.
+    """
+
+    def __iter__(self):
+        queryset = self.queryset
+        query = queryset.query
+        compiler = query.get_compiler(queryset.db)
+
+        # extra(select=...) cols are always at the start of the row.
+        names = [
+            *query.extra_select,
+            *query.values_select,
+            *query.annotation_select,
+        ]
+        indexes = range(len(names))
+        for row in compiler.results_iter(
+            chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size
+        ):
+            yield {names[i]: row[i] for i in indexes}
+
+
+class ValuesListIterable(BaseIterable):
+    """
+    Iterable returned by QuerySet.values_list(flat=False) that yields a tuple
+    for each row.
+    """
+
+    def __iter__(self):
+        queryset = self.queryset
+        query = queryset.query
+        compiler = query.get_compiler(queryset.db)
+
+        if queryset._fields:
+            # extra(select=...) cols are always at the start of the row.
+            names = [
+                *query.extra_select,
+                *query.values_select,
+                *query.annotation_select,
+            ]
+            fields = [
+                *queryset._fields,
+                *(f for f in query.annotation_select if f not in queryset._fields),
+            ]
+            if fields != names:
+                # Reorder according to fields.
+                index_map = {name: idx for idx, name in enumerate(names)}
+                rowfactory = operator.itemgetter(*[index_map[f] for f in fields])
+                return map(
+                    rowfactory,
+                    compiler.results_iter(
+                        chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size
+                    ),
+                )
+        return compiler.results_iter(
+            tuple_expected=True,
+            chunked_fetch=self.chunked_fetch,
+            chunk_size=self.chunk_size,
+        )
+
+
+class NamedValuesListIterable(ValuesListIterable):
+    """
+    Iterable returned by QuerySet.values_list(named=True) that yields a
+    namedtuple for each row.
+    """
+
+    def __iter__(self):
+        queryset = self.queryset
+        if queryset._fields:
+            names = queryset._fields
+        else:
+            query = queryset.query
+            names = [
+                *query.extra_select,
+                *query.values_select,
+                *query.annotation_select,
+            ]
+        tuple_class = create_namedtuple_class(*names)
+        new = tuple.__new__
+        for row in super().__iter__():
+            yield new(tuple_class, row)
+
+
+class FlatValuesListIterable(BaseIterable):
+    """
+    Iterable returned by QuerySet.values_list(flat=True) that yields single
+    values.
+    """
+
+    def __iter__(self):
+        queryset = self.queryset
+        compiler = queryset.query.get_compiler(queryset.db)
+        for row in compiler.results_iter(
+            chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size
+        ):
+            yield row[0]
+
+
+class QuerySet(AltersData):
+    """Represent a lazy database lookup for a set of objects."""
+
+    def __init__(self, model=None, query=None, using=None, hints=None):
+        self.model = model
+        self._db = using
+        self._hints = hints or {}
+        self._query = query or sql.Query(self.model)
+        self._result_cache = None
+        self._sticky_filter = False
+        self._for_write = False
+        self._prefetch_related_lookups = ()
+        self._prefetch_done = False
+        self._known_related_objects = {}  # {rel_field: {pk: rel_obj}}
+        self._iterable_class = ModelIterable
+        self._fields = None
+        self._defer_next_filter = False
+        self._deferred_filter = None
+
+    @property
+    def query(self):
+        if self._deferred_filter:
+            negate, args, kwargs = self._deferred_filter
+            self._filter_or_exclude_inplace(negate, args, kwargs)
+            self._deferred_filter = None
+        return self._query
+
+    @query.setter
+    def query(self, value):
+        if value.values_select:
+            self._iterable_class = ValuesIterable
+        self._query = value
+
+    def as_manager(cls):
+        # Address the circular dependency between `Queryset` and `Manager`.
+        from django.db.models.manager import Manager
+
+        manager = Manager.from_queryset(cls)()
+        manager._built_with_as_manager = True
+        return manager
+
+    as_manager.queryset_only = True
+    as_manager = classmethod(as_manager)
+
+    ########################
+    # PYTHON MAGIC METHODS #
+    ########################
+
+    def __deepcopy__(self, memo):
+        """Don't populate the QuerySet's cache."""
+        obj = self.__class__()
+        for k, v in self.__dict__.items():
+            if k == "_result_cache":
+                obj.__dict__[k] = None
+            else:
+                obj.__dict__[k] = copy.deepcopy(v, memo)
+        return obj
+
+    def __getstate__(self):
+        # Force the cache to be fully populated.
+        self._fetch_all()
+        return {**self.__dict__, DJANGO_VERSION_PICKLE_KEY: django.__version__}
+
+    def __setstate__(self, state):
+        pickled_version = state.get(DJANGO_VERSION_PICKLE_KEY)
+        if pickled_version:
+            if pickled_version != django.__version__:
+                warnings.warn(
+                    "Pickled queryset instance's Django version %s does not "
+                    "match the current version %s."
+                    % (pickled_version, django.__version__),
+                    RuntimeWarning,
+                    stacklevel=2,
+                )
+        else:
+            warnings.warn(
+                "Pickled queryset instance's Django version is not specified.",
+                RuntimeWarning,
+                stacklevel=2,
+            )
+        self.__dict__.update(state)
+
+    def __repr__(self):
+        data = list(self[: REPR_OUTPUT_SIZE + 1])
+        if len(data) > REPR_OUTPUT_SIZE:
+            data[-1] = "...(remaining elements truncated)..."
+        return "<%s %r>" % (self.__class__.__name__, data)
+
+    def __len__(self):
+        self._fetch_all()
+        return len(self._result_cache)
+
+    def __iter__(self):
+        """
+        The queryset iterator protocol uses three nested iterators in the
+        default case:
+            1. sql.compiler.execute_sql()
+               - Returns 100 rows at time (constants.GET_ITERATOR_CHUNK_SIZE)
+                 using cursor.fetchmany(). This part is responsible for
+                 doing some column masking, and returning the rows in chunks.
+            2. sql.compiler.results_iter()
+               - Returns one row at time. At this point the rows are still just
+                 tuples. In some cases the return values are converted to
+                 Python values at this location.
+            3. self.iterator()
+               - Responsible for turning the rows into model objects.
+        """
+        self._fetch_all()
+        return iter(self._result_cache)
+
+    def __aiter__(self):
+        # Remember, __aiter__ itself is synchronous, it's the thing it returns
+        # that is async!
+        async def generator():
+            await sync_to_async(self._fetch_all)()
+            for item in self._result_cache:
+                yield item
+
+        return generator()
+
+    def __bool__(self):
+        self._fetch_all()
+        return bool(self._result_cache)
+
+    def __getitem__(self, k):
+        """Retrieve an item or slice from the set of results."""
+        if not isinstance(k, (int, slice)):
+            raise TypeError(
+                "QuerySet indices must be integers or slices, not %s."
+                % type(k).__name__
+            )
+        if (isinstance(k, int) and k < 0) or (
+            isinstance(k, slice)
+            and (
+                (k.start is not None and k.start < 0)
+                or (k.stop is not None and k.stop < 0)
+            )
+        ):
+            raise ValueError("Negative indexing is not supported.")
+
+        if self._result_cache is not None:
+            return self._result_cache[k]
+
+        if isinstance(k, slice):
+            qs = self._chain()
+            if k.start is not None:
+                start = int(k.start)
+            else:
+                start = None
+            if k.stop is not None:
+                stop = int(k.stop)
+            else:
+                stop = None
+            qs.query.set_limits(start, stop)
+            return list(qs)[:: k.step] if k.step else qs
+
+        qs = self._chain()
+        qs.query.set_limits(k, k + 1)
+        qs._fetch_all()
+        return qs._result_cache[0]
+
+    def __class_getitem__(cls, *args, **kwargs):
+        return cls
+
+    def __and__(self, other):
+        self._check_operator_queryset(other, "&")
+        self._merge_sanity_check(other)
+        if isinstance(other, EmptyQuerySet):
+            return other
+        if isinstance(self, EmptyQuerySet):
+            return self
+        combined = self._chain()
+        combined._merge_known_related_objects(other)
+        combined.query.combine(other.query, sql.AND)
+        return combined
+
+    def __or__(self, other):
+        self._check_operator_queryset(other, "|")
+        self._merge_sanity_check(other)
+        if isinstance(self, EmptyQuerySet):
+            return other
+        if isinstance(other, EmptyQuerySet):
+            return self
+        query = (
+            self
+            if self.query.can_filter()
+            else self.model._base_manager.filter(pk__in=self.values("pk"))
+        )
+        combined = query._chain()
+        combined._merge_known_related_objects(other)
+        if not other.query.can_filter():
+            other = other.model._base_manager.filter(pk__in=other.values("pk"))
+        combined.query.combine(other.query, sql.OR)
+        return combined
+
+    def __xor__(self, other):
+        self._check_operator_queryset(other, "^")
+        self._merge_sanity_check(other)
+        if isinstance(self, EmptyQuerySet):
+            return other
+        if isinstance(other, EmptyQuerySet):
+            return self
+        query = (
+            self
+            if self.query.can_filter()
+            else self.model._base_manager.filter(pk__in=self.values("pk"))
+        )
+        combined = query._chain()
+        combined._merge_known_related_objects(other)
+        if not other.query.can_filter():
+            other = other.model._base_manager.filter(pk__in=other.values("pk"))
+        combined.query.combine(other.query, sql.XOR)
+        return combined
+
+    ####################################
+    # METHODS THAT DO DATABASE QUERIES #
+    ####################################
+
+    def _iterator(self, use_chunked_fetch, chunk_size):
+        iterable = self._iterable_class(
+            self,
+            chunked_fetch=use_chunked_fetch,
+            chunk_size=chunk_size or 2000,
+        )
+        if not self._prefetch_related_lookups or chunk_size is None:
+            yield from iterable
+            return
+
+        iterator = iter(iterable)
+        while results := list(islice(iterator, chunk_size)):
+            prefetch_related_objects(results, *self._prefetch_related_lookups)
+            yield from results
+
+    def iterator(self, chunk_size=None):
+        """
+        An iterator over the results from applying this QuerySet to the
+        database. chunk_size must be provided for QuerySets that prefetch
+        related objects. Otherwise, a default chunk_size of 2000 is supplied.
+        """
+        if chunk_size is None:
+            if self._prefetch_related_lookups:
+                # When the deprecation ends, replace with:
+                # raise ValueError(
+                #     'chunk_size must be provided when using '
+                #     'QuerySet.iterator() after prefetch_related().'
+                # )
+                warnings.warn(
+                    "Using QuerySet.iterator() after prefetch_related() "
+                    "without specifying chunk_size is deprecated.",
+                    category=RemovedInDjango50Warning,
+                    stacklevel=2,
+                )
+        elif chunk_size <= 0:
+            raise ValueError("Chunk size must be strictly positive.")
+        use_chunked_fetch = not connections[self.db].settings_dict.get(
+            "DISABLE_SERVER_SIDE_CURSORS"
+        )
+        return self._iterator(use_chunked_fetch, chunk_size)
+
+    async def aiterator(self, chunk_size=2000):
+        """
+        An asynchronous iterator over the results from applying this QuerySet
+        to the database.
+        """
+        if self._prefetch_related_lookups:
+            raise NotSupportedError(
+                "Using QuerySet.aiterator() after prefetch_related() is not supported."
+            )
+        if chunk_size <= 0:
+            raise ValueError("Chunk size must be strictly positive.")
+        use_chunked_fetch = not connections[self.db].settings_dict.get(
+            "DISABLE_SERVER_SIDE_CURSORS"
+        )
+        async for item in self._iterable_class(
+            self, chunked_fetch=use_chunked_fetch, chunk_size=chunk_size
+        ):
+            yield item
+
+    def aggregate(self, *args, **kwargs):
+        """
+        Return a dictionary containing the calculations (aggregation)
+        over the current queryset.
+
+        If args is present the expression is passed as a kwarg using
+        the Aggregate object's default alias.
+        """
+        if self.query.distinct_fields:
+            raise NotImplementedError("aggregate() + distinct(fields) not implemented.")
+        self._validate_values_are_expressions(
+            (*args, *kwargs.values()), method_name="aggregate"
+        )
+        for arg in args:
+            # The default_alias property raises TypeError if default_alias
+            # can't be set automatically or AttributeError if it isn't an
+            # attribute.
+            try:
+                arg.default_alias
+            except (AttributeError, TypeError):
+                raise TypeError("Complex aggregates require an alias")
+            kwargs[arg.default_alias] = arg
+
+        return self.query.chain().get_aggregation(self.db, kwargs)
+
+    async def aaggregate(self, *args, **kwargs):
+        return await sync_to_async(self.aggregate)(*args, **kwargs)
+
+    def count(self):
+        """
+        Perform a SELECT COUNT() and return the number of records as an
+        integer.
+
+        If the QuerySet is already fully cached, return the length of the
+        cached results set to avoid multiple SELECT COUNT(*) calls.
+        """
+        if self._result_cache is not None:
+            return len(self._result_cache)
+
+        return self.query.get_count(using=self.db)
+
+    async def acount(self):
+        return await sync_to_async(self.count)()
+
+    def get(self, *args, **kwargs):
+        """
+        Perform the query and return a single object matching the given
+        keyword arguments.
+        """
+        if self.query.combinator and (args or kwargs):
+            raise NotSupportedError(
+                "Calling QuerySet.get(...) with filters after %s() is not "
+                "supported." % self.query.combinator
+            )
+        clone = self._chain() if self.query.combinator else self.filter(*args, **kwargs)
+        if self.query.can_filter() and not self.query.distinct_fields:
+            clone = clone.order_by()
+        limit = None
+        if (
+            not clone.query.select_for_update
+            or connections[clone.db].features.supports_select_for_update_with_limit
+        ):
+            limit = MAX_GET_RESULTS
+            clone.query.set_limits(high=limit)
+        num = len(clone)
+        if num == 1:
+            return clone._result_cache[0]
+        if not num:
+            raise self.model.DoesNotExist(
+                "%s matching query does not exist." % self.model._meta.object_name
+            )
+        raise self.model.MultipleObjectsReturned(
+            "get() returned more than one %s -- it returned %s!"
+            % (
+                self.model._meta.object_name,
+                num if not limit or num < limit else "more than %s" % (limit - 1),
+            )
+        )
+
+    async def aget(self, *args, **kwargs):
+        return await sync_to_async(self.get)(*args, **kwargs)
+
+    def create(self, **kwargs):
+        """
+        Create a new object with the given kwargs, saving it to the database
+        and returning the created object.
+        """
+        obj = self.model(**kwargs)
+        self._for_write = True
+        obj.save(force_insert=True, using=self.db)
+        return obj
+
+    async def acreate(self, **kwargs):
+        return await sync_to_async(self.create)(**kwargs)
+
+    def _prepare_for_bulk_create(self, objs):
+        for obj in objs:
+            if obj.pk is None:
+                # Populate new PK values.
+                obj.pk = obj._meta.pk.get_pk_value_on_save(obj)
+            obj._prepare_related_fields_for_save(operation_name="bulk_create")
+
+    def _check_bulk_create_options(
+        self, ignore_conflicts, update_conflicts, update_fields, unique_fields
+    ):
+        if ignore_conflicts and update_conflicts:
+            raise ValueError(
+                "ignore_conflicts and update_conflicts are mutually exclusive."
+            )
+        db_features = connections[self.db].features
+        if ignore_conflicts:
+            if not db_features.supports_ignore_conflicts:
+                raise NotSupportedError(
+                    "This database backend does not support ignoring conflicts."
+                )
+            return OnConflict.IGNORE
+        elif update_conflicts:
+            if not db_features.supports_update_conflicts:
+                raise NotSupportedError(
+                    "This database backend does not support updating conflicts."
+                )
+            if not update_fields:
+                raise ValueError(
+                    "Fields that will be updated when a row insertion fails "
+                    "on conflicts must be provided."
+                )
+            if unique_fields and not db_features.supports_update_conflicts_with_target:
+                raise NotSupportedError(
+                    "This database backend does not support updating "
+                    "conflicts with specifying unique fields that can trigger "
+                    "the upsert."
+                )
+            if not unique_fields and db_features.supports_update_conflicts_with_target:
+                raise ValueError(
+                    "Unique fields that can trigger the upsert must be provided."
+                )
+            # Updating primary keys and non-concrete fields is forbidden.
+            if any(not f.concrete or f.many_to_many for f in update_fields):
+                raise ValueError(
+                    "bulk_create() can only be used with concrete fields in "
+                    "update_fields."
+                )
+            if any(f.primary_key for f in update_fields):
+                raise ValueError(
+                    "bulk_create() cannot be used with primary keys in "
+                    "update_fields."
+                )
+            if unique_fields:
+                if any(not f.concrete or f.many_to_many for f in unique_fields):
+                    raise ValueError(
+                        "bulk_create() can only be used with concrete fields "
+                        "in unique_fields."
+                    )
+            return OnConflict.UPDATE
+        return None
+
+    def bulk_create(
+        self,
+        objs,
+        batch_size=None,
+        ignore_conflicts=False,
+        update_conflicts=False,
+        update_fields=None,
+        unique_fields=None,
+    ):
+        """
+        Insert each of the instances into the database. Do *not* call
+        save() on each of the instances, do not send any pre/post_save
+        signals, and do not set the primary key attribute if it is an
+        autoincrement field (except if features.can_return_rows_from_bulk_insert=True).
+        Multi-table models are not supported.
+        """
+        # When you bulk insert you don't get the primary keys back (if it's an
+        # autoincrement, except if can_return_rows_from_bulk_insert=True), so
+        # you can't insert into the child tables which references this. There
+        # are two workarounds:
+        # 1) This could be implemented if you didn't have an autoincrement pk
+        # 2) You could do it by doing O(n) normal inserts into the parent
+        #    tables to get the primary keys back and then doing a single bulk
+        #    insert into the childmost table.
+        # We currently set the primary keys on the objects when using
+        # PostgreSQL via the RETURNING ID clause. It should be possible for
+        # Oracle as well, but the semantics for extracting the primary keys is
+        # trickier so it's not done yet.
+        if batch_size is not None and batch_size <= 0:
+            raise ValueError("Batch size must be a positive integer.")
+        # Check that the parents share the same concrete model with the our
+        # model to detect the inheritance pattern ConcreteGrandParent ->
+        # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy
+        # would not identify that case as involving multiple tables.
+        for parent in self.model._meta.get_parent_list():
+            if parent._meta.concrete_model is not self.model._meta.concrete_model:
+                raise ValueError("Can't bulk create a multi-table inherited model")
+        if not objs:
+            return objs
+        opts = self.model._meta
+        if unique_fields:
+            # Primary key is allowed in unique_fields.
+            unique_fields = [
+                self.model._meta.get_field(opts.pk.name if name == "pk" else name)
+                for name in unique_fields
+            ]
+        if update_fields:
+            update_fields = [self.model._meta.get_field(name) for name in update_fields]
+        on_conflict = self._check_bulk_create_options(
+            ignore_conflicts,
+            update_conflicts,
+            update_fields,
+            unique_fields,
+        )
+        self._for_write = True
+        fields = opts.concrete_fields
+        objs = list(objs)
+        self._prepare_for_bulk_create(objs)
+        with transaction.atomic(using=self.db, savepoint=False):
+            objs_with_pk, objs_without_pk = partition(lambda o: o.pk is None, objs)
+            if objs_with_pk:
+                returned_columns = self._batched_insert(
+                    objs_with_pk,
+                    fields,
+                    batch_size,
+                    on_conflict=on_conflict,
+                    update_fields=update_fields,
+                    unique_fields=unique_fields,
+                )
+                for obj_with_pk, results in zip(objs_with_pk, returned_columns):
+                    for result, field in zip(results, opts.db_returning_fields):
+                        if field != opts.pk:
+                            setattr(obj_with_pk, field.attname, result)
+                for obj_with_pk in objs_with_pk:
+                    obj_with_pk._state.adding = False
+                    obj_with_pk._state.db = self.db
+            if objs_without_pk:
+                fields = [f for f in fields if not isinstance(f, AutoField)]
+                returned_columns = self._batched_insert(
+                    objs_without_pk,
+                    fields,
+                    batch_size,
+                    on_conflict=on_conflict,
+                    update_fields=update_fields,
+                    unique_fields=unique_fields,
+                )
+                connection = connections[self.db]
+                if (
+                    connection.features.can_return_rows_from_bulk_insert
+                    and on_conflict is None
+                ):
+                    assert len(returned_columns) == len(objs_without_pk)
+                for obj_without_pk, results in zip(objs_without_pk, returned_columns):
+                    for result, field in zip(results, opts.db_returning_fields):
+                        setattr(obj_without_pk, field.attname, result)
+                    obj_without_pk._state.adding = False
+                    obj_without_pk._state.db = self.db
+
+        return objs
+
+    async def abulk_create(
+        self,
+        objs,
+        batch_size=None,
+        ignore_conflicts=False,
+        update_conflicts=False,
+        update_fields=None,
+        unique_fields=None,
+    ):
+        return await sync_to_async(self.bulk_create)(
+            objs=objs,
+            batch_size=batch_size,
+            ignore_conflicts=ignore_conflicts,
+            update_conflicts=update_conflicts,
+            update_fields=update_fields,
+            unique_fields=unique_fields,
+        )
+
+    def bulk_update(self, objs, fields, batch_size=None):
+        """
+        Update the given fields in each of the given objects in the database.
+        """
+        if batch_size is not None and batch_size <= 0:
+            raise ValueError("Batch size must be a positive integer.")
+        if not fields:
+            raise ValueError("Field names must be given to bulk_update().")
+        objs = tuple(objs)
+        if any(obj.pk is None for obj in objs):
+            raise ValueError("All bulk_update() objects must have a primary key set.")
+        fields = [self.model._meta.get_field(name) for name in fields]
+        if any(not f.concrete or f.many_to_many for f in fields):
+            raise ValueError("bulk_update() can only be used with concrete fields.")
+        if any(f.primary_key for f in fields):
+            raise ValueError("bulk_update() cannot be used with primary key fields.")
+        if not objs:
+            return 0
+        for obj in objs:
+            obj._prepare_related_fields_for_save(
+                operation_name="bulk_update", fields=fields
+            )
+        # PK is used twice in the resulting update query, once in the filter
+        # and once in the WHEN. Each field will also have one CAST.
+        self._for_write = True
+        connection = connections[self.db]
+        max_batch_size = connection.ops.bulk_batch_size(["pk", "pk"] + fields, objs)
+        batch_size = min(batch_size, max_batch_size) if batch_size else max_batch_size
+        requires_casting = connection.features.requires_casted_case_in_updates
+        batches = (objs[i : i + batch_size] for i in range(0, len(objs), batch_size))
+        updates = []
+        for batch_objs in batches:
+            update_kwargs = {}
+            for field in fields:
+                when_statements = []
+                for obj in batch_objs:
+                    attr = getattr(obj, field.attname)
+                    if not hasattr(attr, "resolve_expression"):
+                        attr = Value(attr, output_field=field)
+                    when_statements.append(When(pk=obj.pk, then=attr))
+                case_statement = Case(*when_statements, output_field=field)
+                if requires_casting:
+                    case_statement = Cast(case_statement, output_field=field)
+                update_kwargs[field.attname] = case_statement
+            updates.append(([obj.pk for obj in batch_objs], update_kwargs))
+        rows_updated = 0
+        queryset = self.using(self.db)
+        with transaction.atomic(using=self.db, savepoint=False):
+            for pks, update_kwargs in updates:
+                rows_updated += queryset.filter(pk__in=pks).update(**update_kwargs)
+        return rows_updated
+
+    bulk_update.alters_data = True
+
+    async def abulk_update(self, objs, fields, batch_size=None):
+        return await sync_to_async(self.bulk_update)(
+            objs=objs,
+            fields=fields,
+            batch_size=batch_size,
+        )
+
+    abulk_update.alters_data = True
+
+    def get_or_create(self, defaults=None, **kwargs):
+        """
+        Look up an object with the given kwargs, creating one if necessary.
+        Return a tuple of (object, created), where created is a boolean
+        specifying whether an object was created.
+        """
+        # The get() needs to be targeted at the write database in order
+        # to avoid potential transaction consistency problems.
+        self._for_write = True
+        try:
+            return self.get(**kwargs), False
+        except self.model.DoesNotExist:
+            params = self._extract_model_params(defaults, **kwargs)
+            # Try to create an object using passed params.
+            try:
+                with transaction.atomic(using=self.db):
+                    params = dict(resolve_callables(params))
+                    return self.create(**params), True
+            except IntegrityError:
+                try:
+                    return self.get(**kwargs), False
+                except self.model.DoesNotExist:
+                    pass
+                raise
+
+    async def aget_or_create(self, defaults=None, **kwargs):
+        return await sync_to_async(self.get_or_create)(
+            defaults=defaults,
+            **kwargs,
+        )
+
+    def update_or_create(self, defaults=None, **kwargs):
+        """
+        Look up an object with the given kwargs, updating one with defaults
+        if it exists, otherwise create a new one.
+        Return a tuple (object, created), where created is a boolean
+        specifying whether an object was created.
+        """
+        defaults = defaults or {}
+        self._for_write = True
+        with transaction.atomic(using=self.db):
+            # Lock the row so that a concurrent update is blocked until
+            # update_or_create() has performed its save.
+            obj, created = self.select_for_update().get_or_create(defaults, **kwargs)
+            if created:
+                return obj, created
+            for k, v in resolve_callables(defaults):
+                setattr(obj, k, v)
+
+            update_fields = set(defaults)
+            concrete_field_names = self.model._meta._non_pk_concrete_field_names
+            # update_fields does not support non-concrete fields.
+            if concrete_field_names.issuperset(update_fields):
+                # Add fields which are set on pre_save(), e.g. auto_now fields.
+                # This is to maintain backward compatibility as these fields
+                # are not updated unless explicitly specified in the
+                # update_fields list.
+                for field in self.model._meta.local_concrete_fields:
+                    if not (
+                        field.primary_key or field.__class__.pre_save is Field.pre_save
+                    ):
+                        update_fields.add(field.name)
+                        if field.name != field.attname:
+                            update_fields.add(field.attname)
+                obj.save(using=self.db, update_fields=update_fields)
+            else:
+                obj.save(using=self.db)
+        return obj, False
+
+    async def aupdate_or_create(self, defaults=None, **kwargs):
+        return await sync_to_async(self.update_or_create)(
+            defaults=defaults,
+            **kwargs,
+        )
+
+    def _extract_model_params(self, defaults, **kwargs):
+        """
+        Prepare `params` for creating a model instance based on the given
+        kwargs; for use by get_or_create().
+        """
+        defaults = defaults or {}
+        params = {k: v for k, v in kwargs.items() if LOOKUP_SEP not in k}
+        params.update(defaults)
+        property_names = self.model._meta._property_names
+        invalid_params = []
+        for param in params:
+            try:
+                self.model._meta.get_field(param)
+            except exceptions.FieldDoesNotExist:
+                # It's okay to use a model's property if it has a setter.
+                if not (param in property_names and getattr(self.model, param).fset):
+                    invalid_params.append(param)
+        if invalid_params:
+            raise exceptions.FieldError(
+                "Invalid field name(s) for model %s: '%s'."
+                % (
+                    self.model._meta.object_name,
+                    "', '".join(sorted(invalid_params)),
+                )
+            )
+        return params
+
+    def _earliest(self, *fields):
+        """
+        Return the earliest object according to fields (if given) or by the
+        model's Meta.get_latest_by.
+        """
+        if fields:
+            order_by = fields
+        else:
+            order_by = getattr(self.model._meta, "get_latest_by")
+            if order_by and not isinstance(order_by, (tuple, list)):
+                order_by = (order_by,)
+        if order_by is None:
+            raise ValueError(
+                "earliest() and latest() require either fields as positional "
+                "arguments or 'get_latest_by' in the model's Meta."
+            )
+        obj = self._chain()
+        obj.query.set_limits(high=1)
+        obj.query.clear_ordering(force=True)
+        obj.query.add_ordering(*order_by)
+        return obj.get()
+
+    def earliest(self, *fields):
+        if self.query.is_sliced:
+            raise TypeError("Cannot change a query once a slice has been taken.")
+        return self._earliest(*fields)
+
+    async def aearliest(self, *fields):
+        return await sync_to_async(self.earliest)(*fields)
+
+    def latest(self, *fields):
+        """
+        Return the latest object according to fields (if given) or by the
+        model's Meta.get_latest_by.
+        """
+        if self.query.is_sliced:
+            raise TypeError("Cannot change a query once a slice has been taken.")
+        return self.reverse()._earliest(*fields)
+
+    async def alatest(self, *fields):
+        return await sync_to_async(self.latest)(*fields)
+
+    def first(self):
+        """Return the first object of a query or None if no match is found."""
+        if self.ordered:
+            queryset = self
+        else:
+            self._check_ordering_first_last_queryset_aggregation(method="first")
+            queryset = self.order_by("pk")
+        for obj in queryset[:1]:
+            return obj
+
+    async def afirst(self):
+        return await sync_to_async(self.first)()
+
+    def last(self):
+        """Return the last object of a query or None if no match is found."""
+        if self.ordered:
+            queryset = self.reverse()
+        else:
+            self._check_ordering_first_last_queryset_aggregation(method="last")
+            queryset = self.order_by("-pk")
+        for obj in queryset[:1]:
+            return obj
+
+    async def alast(self):
+        return await sync_to_async(self.last)()
+
+    def in_bulk(self, id_list=None, *, field_name="pk"):
+        """
+        Return a dictionary mapping each of the given IDs to the object with
+        that ID. If `id_list` isn't provided, evaluate the entire QuerySet.
+        """
+        if self.query.is_sliced:
+            raise TypeError("Cannot use 'limit' or 'offset' with in_bulk().")
+        opts = self.model._meta
+        unique_fields = [
+            constraint.fields[0]
+            for constraint in opts.total_unique_constraints
+            if len(constraint.fields) == 1
+        ]
+        if (
+            field_name != "pk"
+            and not opts.get_field(field_name).unique
+            and field_name not in unique_fields
+            and self.query.distinct_fields != (field_name,)
+        ):
+            raise ValueError(
+                "in_bulk()'s field_name must be a unique field but %r isn't."
+                % field_name
+            )
+        if id_list is not None:
+            if not id_list:
+                return {}
+            filter_key = "{}__in".format(field_name)
+            batch_size = connections[self.db].features.max_query_params
+            id_list = tuple(id_list)
+            # If the database has a limit on the number of query parameters
+            # (e.g. SQLite), retrieve objects in batches if necessary.
+            if batch_size and batch_size < len(id_list):
+                qs = ()
+                for offset in range(0, len(id_list), batch_size):
+                    batch = id_list[offset : offset + batch_size]
+                    qs += tuple(self.filter(**{filter_key: batch}).order_by())
+            else:
+                qs = self.filter(**{filter_key: id_list}).order_by()
+        else:
+            qs = self._chain()
+        return {getattr(obj, field_name): obj for obj in qs}
+
+    async def ain_bulk(self, id_list=None, *, field_name="pk"):
+        return await sync_to_async(self.in_bulk)(
+            id_list=id_list,
+            field_name=field_name,
+        )
+
+    def delete(self):
+        """Delete the records in the current QuerySet."""
+        self._not_support_combined_queries("delete")
+        if self.query.is_sliced:
+            raise TypeError("Cannot use 'limit' or 'offset' with delete().")
+        if self.query.distinct or self.query.distinct_fields:
+            raise TypeError("Cannot call delete() after .distinct().")
+        if self._fields is not None:
+            raise TypeError("Cannot call delete() after .values() or .values_list()")
+
+        del_query = self._chain()
+
+        # The delete is actually 2 queries - one to find related objects,
+        # and one to delete. Make sure that the discovery of related
+        # objects is performed on the same database as the deletion.
+        del_query._for_write = True
+
+        # Disable non-supported fields.
+        del_query.query.select_for_update = False
+        del_query.query.select_related = False
+        del_query.query.clear_ordering(force=True)
+
+        collector = Collector(using=del_query.db, origin=self)
+        collector.collect(del_query)
+        deleted, _rows_count = collector.delete()
+
+        # Clear the result cache, in case this QuerySet gets reused.
+        self._result_cache = None
+        return deleted, _rows_count
+
+    delete.alters_data = True
+    delete.queryset_only = True
+
+    async def adelete(self):
+        return await sync_to_async(self.delete)()
+
+    adelete.alters_data = True
+    adelete.queryset_only = True
+
+    def _raw_delete(self, using):
+        """
+        Delete objects found from the given queryset in single direct SQL
+        query. No signals are sent and there is no protection for cascades.
+        """
+        query = self.query.clone()
+        query.__class__ = sql.DeleteQuery
+        cursor = query.get_compiler(using).execute_sql(CURSOR)
+        if cursor:
+            with cursor:
+                return cursor.rowcount
+        return 0
+
+    _raw_delete.alters_data = True
+
+    def update(self, **kwargs):
+        """
+        Update all elements in the current QuerySet, setting all the given
+        fields to the appropriate values.
+        """
+        self._not_support_combined_queries("update")
+        if self.query.is_sliced:
+            raise TypeError("Cannot update a query once a slice has been taken.")
+        self._for_write = True
+        query = self.query.chain(sql.UpdateQuery)
+        query.add_update_values(kwargs)
+
+        # Inline annotations in order_by(), if possible.
+        new_order_by = []
+        for col in query.order_by:
+            if annotation := query.annotations.get(col):
+                if getattr(annotation, "contains_aggregate", False):
+                    raise exceptions.FieldError(
+                        f"Cannot update when ordering by an aggregate: {annotation}"
+                    )
+                new_order_by.append(annotation)
+            else:
+                new_order_by.append(col)
+        query.order_by = tuple(new_order_by)
+
+        # Clear any annotations so that they won't be present in subqueries.
+        query.annotations = {}
+        with transaction.mark_for_rollback_on_error(using=self.db):
+            rows = query.get_compiler(self.db).execute_sql(CURSOR)
+        self._result_cache = None
+        return rows
+
+    update.alters_data = True
+
+    async def aupdate(self, **kwargs):
+        return await sync_to_async(self.update)(**kwargs)
+
+    aupdate.alters_data = True
+
+    def _update(self, values):
+        """
+        A version of update() that accepts field objects instead of field names.
+        Used primarily for model saving and not intended for use by general
+        code (it requires too much poking around at model internals to be
+        useful at that level).
+        """
+        if self.query.is_sliced:
+            raise TypeError("Cannot update a query once a slice has been taken.")
+        query = self.query.chain(sql.UpdateQuery)
+        query.add_update_fields(values)
+        # Clear any annotations so that they won't be present in subqueries.
+        query.annotations = {}
+        self._result_cache = None
+        return query.get_compiler(self.db).execute_sql(CURSOR)
+
+    _update.alters_data = True
+    _update.queryset_only = False
+
+    def exists(self):
+        """
+        Return True if the QuerySet would have any results, False otherwise.
+        """
+        if self._result_cache is None:
+            return self.query.has_results(using=self.db)
+        return bool(self._result_cache)
+
+    async def aexists(self):
+        return await sync_to_async(self.exists)()
+
+    def contains(self, obj):
+        """
+        Return True if the QuerySet contains the provided obj,
+        False otherwise.
+        """
+        self._not_support_combined_queries("contains")
+        if self._fields is not None:
+            raise TypeError(
+                "Cannot call QuerySet.contains() after .values() or .values_list()."
+            )
+        try:
+            if obj._meta.concrete_model != self.model._meta.concrete_model:
+                return False
+        except AttributeError:
+            raise TypeError("'obj' must be a model instance.")
+        if obj.pk is None:
+            raise ValueError("QuerySet.contains() cannot be used on unsaved objects.")
+        if self._result_cache is not None:
+            return obj in self._result_cache
+        return self.filter(pk=obj.pk).exists()
+
+    async def acontains(self, obj):
+        return await sync_to_async(self.contains)(obj=obj)
+
+    def _prefetch_related_objects(self):
+        # This method can only be called once the result cache has been filled.
+        prefetch_related_objects(self._result_cache, *self._prefetch_related_lookups)
+        self._prefetch_done = True
+
+    def explain(self, *, format=None, **options):
+        """
+        Runs an EXPLAIN on the SQL query this QuerySet would perform, and
+        returns the results.
+        """
+        return self.query.explain(using=self.db, format=format, **options)
+
+    async def aexplain(self, *, format=None, **options):
+        return await sync_to_async(self.explain)(format=format, **options)
+
+    ##################################################
+    # PUBLIC METHODS THAT RETURN A QUERYSET SUBCLASS #
+    ##################################################
+
+    def raw(self, raw_query, params=(), translations=None, using=None):
+        if using is None:
+            using = self.db
+        qs = RawQuerySet(
+            raw_query,
+            model=self.model,
+            params=params,
+            translations=translations,
+            using=using,
+        )
+        qs._prefetch_related_lookups = self._prefetch_related_lookups[:]
+        return qs
+
+    def _values(self, *fields, **expressions):
+        clone = self._chain()
+        if expressions:
+            clone = clone.annotate(**expressions)
+        clone._fields = fields
+        clone.query.set_values(fields)
+        return clone
+
+    def values(self, *fields, **expressions):
+        fields += tuple(expressions)
+        clone = self._values(*fields, **expressions)
+        clone._iterable_class = ValuesIterable
+        return clone
+
+    def values_list(self, *fields, flat=False, named=False):
+        if flat and named:
+            raise TypeError("'flat' and 'named' can't be used together.")
+        if flat and len(fields) > 1:
+            raise TypeError(
+                "'flat' is not valid when values_list is called with more than one "
+                "field."
+            )
+
+        field_names = {f for f in fields if not hasattr(f, "resolve_expression")}
+        _fields = []
+        expressions = {}
+        counter = 1
+        for field in fields:
+            if hasattr(field, "resolve_expression"):
+                field_id_prefix = getattr(
+                    field, "default_alias", field.__class__.__name__.lower()
+                )
+                while True:
+                    field_id = field_id_prefix + str(counter)
+                    counter += 1
+                    if field_id not in field_names:
+                        break
+                expressions[field_id] = field
+                _fields.append(field_id)
+            else:
+                _fields.append(field)
+
+        clone = self._values(*_fields, **expressions)
+        clone._iterable_class = (
+            NamedValuesListIterable
+            if named
+            else FlatValuesListIterable
+            if flat
+            else ValuesListIterable
+        )
+        return clone
+
+    def dates(self, field_name, kind, order="ASC"):
+        """
+        Return a list of date objects representing all available dates for
+        the given field_name, scoped to 'kind'.
+        """
+        if kind not in ("year", "month", "week", "day"):
+            raise ValueError("'kind' must be one of 'year', 'month', 'week', or 'day'.")
+        if order not in ("ASC", "DESC"):
+            raise ValueError("'order' must be either 'ASC' or 'DESC'.")
+        return (
+            self.annotate(
+                datefield=Trunc(field_name, kind, output_field=DateField()),
+                plain_field=F(field_name),
+            )
+            .values_list("datefield", flat=True)
+            .distinct()
+            .filter(plain_field__isnull=False)
+            .order_by(("-" if order == "DESC" else "") + "datefield")
+        )
+
+    # RemovedInDjango50Warning: when the deprecation ends, remove is_dst
+    # argument.
+    def datetimes(
+        self, field_name, kind, order="ASC", tzinfo=None, is_dst=timezone.NOT_PASSED
+    ):
+        """
+        Return a list of datetime objects representing all available
+        datetimes for the given field_name, scoped to 'kind'.
+        """
+        if kind not in ("year", "month", "week", "day", "hour", "minute", "second"):
+            raise ValueError(
+                "'kind' must be one of 'year', 'month', 'week', 'day', "
+                "'hour', 'minute', or 'second'."
+            )
+        if order not in ("ASC", "DESC"):
+            raise ValueError("'order' must be either 'ASC' or 'DESC'.")
+        if settings.USE_TZ:
+            if tzinfo is None:
+                tzinfo = timezone.get_current_timezone()
+        else:
+            tzinfo = None
+        return (
+            self.annotate(
+                datetimefield=Trunc(
+                    field_name,
+                    kind,
+                    output_field=DateTimeField(),
+                    tzinfo=tzinfo,
+                    is_dst=is_dst,
+                ),
+                plain_field=F(field_name),
+            )
+            .values_list("datetimefield", flat=True)
+            .distinct()
+            .filter(plain_field__isnull=False)
+            .order_by(("-" if order == "DESC" else "") + "datetimefield")
+        )
+
+    def none(self):
+        """Return an empty QuerySet."""
+        clone = self._chain()
+        clone.query.set_empty()
+        return clone
+
+    ##################################################################
+    # PUBLIC METHODS THAT ALTER ATTRIBUTES AND RETURN A NEW QUERYSET #
+    ##################################################################
+
+    def all(self):
+        """
+        Return a new QuerySet that is a copy of the current one. This allows a
+        QuerySet to proxy for a model manager in some cases.
+        """
+        return self._chain()
+
+    def filter(self, *args, **kwargs):
+        """
+        Return a new QuerySet instance with the args ANDed to the existing
+        set.
+        """
+        self._not_support_combined_queries("filter")
+        return self._filter_or_exclude(False, args, kwargs)
+
+    def exclude(self, *args, **kwargs):
+        """
+        Return a new QuerySet instance with NOT (args) ANDed to the existing
+        set.
+        """
+        self._not_support_combined_queries("exclude")
+        return self._filter_or_exclude(True, args, kwargs)
+
+    def _filter_or_exclude(self, negate, args, kwargs):
+        if (args or kwargs) and self.query.is_sliced:
+            raise TypeError("Cannot filter a query once a slice has been taken.")
+        clone = self._chain()
+        if self._defer_next_filter:
+            self._defer_next_filter = False
+            clone._deferred_filter = negate, args, kwargs
+        else:
+            clone._filter_or_exclude_inplace(negate, args, kwargs)
+        return clone
+
+    def _filter_or_exclude_inplace(self, negate, args, kwargs):
+        if negate:
+            self._query.add_q(~Q(*args, **kwargs))
+        else:
+            self._query.add_q(Q(*args, **kwargs))
+
+    def complex_filter(self, filter_obj):
+        """
+        Return a new QuerySet instance with filter_obj added to the filters.
+
+        filter_obj can be a Q object or a dictionary of keyword lookup
+        arguments.
+
+        This exists to support framework features such as 'limit_choices_to',
+        and usually it will be more natural to use other methods.
+        """
+        if isinstance(filter_obj, Q):
+            clone = self._chain()
+            clone.query.add_q(filter_obj)
+            return clone
+        else:
+            return self._filter_or_exclude(False, args=(), kwargs=filter_obj)
+
+    def _combinator_query(self, combinator, *other_qs, all=False):
+        # Clone the query to inherit the select list and everything
+        clone = self._chain()
+        # Clear limits and ordering so they can be reapplied
+        clone.query.clear_ordering(force=True)
+        clone.query.clear_limits()
+        clone.query.combined_queries = (self.query,) + tuple(
+            qs.query for qs in other_qs
+        )
+        clone.query.combinator = combinator
+        clone.query.combinator_all = all
+        return clone
+
+    def union(self, *other_qs, all=False):
+        # If the query is an EmptyQuerySet, combine all nonempty querysets.
+        if isinstance(self, EmptyQuerySet):
+            qs = [q for q in other_qs if not isinstance(q, EmptyQuerySet)]
+            if not qs:
+                return self
+            if len(qs) == 1:
+                return qs[0]
+            return qs[0]._combinator_query("union", *qs[1:], all=all)
+        return self._combinator_query("union", *other_qs, all=all)
+
+    def intersection(self, *other_qs):
+        # If any query is an EmptyQuerySet, return it.
+        if isinstance(self, EmptyQuerySet):
+            return self
+        for other in other_qs:
+            if isinstance(other, EmptyQuerySet):
+                return other
+        return self._combinator_query("intersection", *other_qs)
+
+    def difference(self, *other_qs):
+        # If the query is an EmptyQuerySet, return it.
+        if isinstance(self, EmptyQuerySet):
+            return self
+        return self._combinator_query("difference", *other_qs)
+
+    def select_for_update(self, nowait=False, skip_locked=False, of=(), no_key=False):
+        """
+        Return a new QuerySet instance that will select objects with a
+        FOR UPDATE lock.
+        """
+        if nowait and skip_locked:
+            raise ValueError("The nowait option cannot be used with skip_locked.")
+        obj = self._chain()
+        obj._for_write = True
+        obj.query.select_for_update = True
+        obj.query.select_for_update_nowait = nowait
+        obj.query.select_for_update_skip_locked = skip_locked
+        obj.query.select_for_update_of = of
+        obj.query.select_for_no_key_update = no_key
+        return obj
+
+    def select_related(self, *fields):
+        """
+        Return a new QuerySet instance that will select related objects.
+
+        If fields are specified, they must be ForeignKey fields and only those
+        related objects are included in the selection.
+
+        If select_related(None) is called, clear the list.
+        """
+        self._not_support_combined_queries("select_related")
+        if self._fields is not None:
+            raise TypeError(
+                "Cannot call select_related() after .values() or .values_list()"
+            )
+
+        obj = self._chain()
+        if fields == (None,):
+            obj.query.select_related = False
+        elif fields:
+            obj.query.add_select_related(fields)
+        else:
+            obj.query.select_related = True
+        return obj
+
+    def prefetch_related(self, *lookups):
+        """
+        Return a new QuerySet instance that will prefetch the specified
+        Many-To-One and Many-To-Many related objects when the QuerySet is
+        evaluated.
+
+        When prefetch_related() is called more than once, append to the list of
+        prefetch lookups. If prefetch_related(None) is called, clear the list.
+        """
+        self._not_support_combined_queries("prefetch_related")
+        clone = self._chain()
+        if lookups == (None,):
+            clone._prefetch_related_lookups = ()
+        else:
+            for lookup in lookups:
+                if isinstance(lookup, Prefetch):
+                    lookup = lookup.prefetch_to
+                lookup = lookup.split(LOOKUP_SEP, 1)[0]
+                if lookup in self.query._filtered_relations:
+                    raise ValueError(
+                        "prefetch_related() is not supported with FilteredRelation."
+                    )
+            clone._prefetch_related_lookups = clone._prefetch_related_lookups + lookups
+        return clone
+
+    def annotate(self, *args, **kwargs):
+        """
+        Return a query set in which the returned objects have been annotated
+        with extra data or aggregations.
+        """
+        self._not_support_combined_queries("annotate")
+        return self._annotate(args, kwargs, select=True)
+
+    def alias(self, *args, **kwargs):
+        """
+        Return a query set with added aliases for extra data or aggregations.
+        """
+        self._not_support_combined_queries("alias")
+        return self._annotate(args, kwargs, select=False)
+
+    def _annotate(self, args, kwargs, select=True):
+        self._validate_values_are_expressions(
+            args + tuple(kwargs.values()), method_name="annotate"
+        )
+        annotations = {}
+        for arg in args:
+            # The default_alias property may raise a TypeError.
+            try:
+                if arg.default_alias in kwargs:
+                    raise ValueError(
+                        "The named annotation '%s' conflicts with the "
+                        "default name for another annotation." % arg.default_alias
+                    )
+            except TypeError:
+                raise TypeError("Complex annotations require an alias")
+            annotations[arg.default_alias] = arg
+        annotations.update(kwargs)
+
+        clone = self._chain()
+        names = self._fields
+        if names is None:
+            names = set(
+                chain.from_iterable(
+                    (field.name, field.attname)
+                    if hasattr(field, "attname")
+                    else (field.name,)
+                    for field in self.model._meta.get_fields()
+                )
+            )
+
+        for alias, annotation in annotations.items():
+            if alias in names:
+                raise ValueError(
+                    "The annotation '%s' conflicts with a field on "
+                    "the model." % alias
+                )
+            if isinstance(annotation, FilteredRelation):
+                clone.query.add_filtered_relation(annotation, alias)
+            else:
+                clone.query.add_annotation(
+                    annotation,
+                    alias,
+                    select=select,
+                )
+        for alias, annotation in clone.query.annotations.items():
+            if alias in annotations and annotation.contains_aggregate:
+                if clone._fields is None:
+                    clone.query.group_by = True
+                else:
+                    clone.query.set_group_by()
+                break
+
+        return clone
+
+    def order_by(self, *field_names):
+        """Return a new QuerySet instance with the ordering changed."""
+        if self.query.is_sliced:
+            raise TypeError("Cannot reorder a query once a slice has been taken.")
+        obj = self._chain()
+        obj.query.clear_ordering(force=True, clear_default=False)
+        obj.query.add_ordering(*field_names)
+        return obj
+
+    def distinct(self, *field_names):
+        """
+        Return a new QuerySet instance that will select only distinct results.
+        """
+        self._not_support_combined_queries("distinct")
+        if self.query.is_sliced:
+            raise TypeError(
+                "Cannot create distinct fields once a slice has been taken."
+            )
+        obj = self._chain()
+        obj.query.add_distinct_fields(*field_names)
+        return obj
+
+    def extra(
+        self,
+        select=None,
+        where=None,
+        params=None,
+        tables=None,
+        order_by=None,
+        select_params=None,
+    ):
+        """Add extra SQL fragments to the query."""
+        self._not_support_combined_queries("extra")
+        if self.query.is_sliced:
+            raise TypeError("Cannot change a query once a slice has been taken.")
+        clone = self._chain()
+        clone.query.add_extra(select, select_params, where, params, tables, order_by)
+        return clone
+
+    def reverse(self):
+        """Reverse the ordering of the QuerySet."""
+        if self.query.is_sliced:
+            raise TypeError("Cannot reverse a query once a slice has been taken.")
+        clone = self._chain()
+        clone.query.standard_ordering = not clone.query.standard_ordering
+        return clone
+
+    def defer(self, *fields):
+        """
+        Defer the loading of data for certain fields until they are accessed.
+        Add the set of deferred fields to any existing set of deferred fields.
+        The only exception to this is if None is passed in as the only
+        parameter, in which case removal all deferrals.
+        """
+        self._not_support_combined_queries("defer")
+        if self._fields is not None:
+            raise TypeError("Cannot call defer() after .values() or .values_list()")
+        clone = self._chain()
+        if fields == (None,):
+            clone.query.clear_deferred_loading()
+        else:
+            clone.query.add_deferred_loading(fields)
+        return clone
+
+    def only(self, *fields):
+        """
+        Essentially, the opposite of defer(). Only the fields passed into this
+        method and that are not already specified as deferred are loaded
+        immediately when the queryset is evaluated.
+        """
+        self._not_support_combined_queries("only")
+        if self._fields is not None:
+            raise TypeError("Cannot call only() after .values() or .values_list()")
+        if fields == (None,):
+            # Can only pass None to defer(), not only(), as the rest option.
+            # That won't stop people trying to do this, so let's be explicit.
+            raise TypeError("Cannot pass None as an argument to only().")
+        for field in fields:
+            field = field.split(LOOKUP_SEP, 1)[0]
+            if field in self.query._filtered_relations:
+                raise ValueError("only() is not supported with FilteredRelation.")
+        clone = self._chain()
+        clone.query.add_immediate_loading(fields)
+        return clone
+
+    def using(self, alias):
+        """Select which database this QuerySet should execute against."""
+        clone = self._chain()
+        clone._db = alias
+        return clone
+
+    ###################################
+    # PUBLIC INTROSPECTION ATTRIBUTES #
+    ###################################
+
+    @property
+    def ordered(self):
+        """
+        Return True if the QuerySet is ordered -- i.e. has an order_by()
+        clause or a default ordering on the model (or is empty).
+        """
+        if isinstance(self, EmptyQuerySet):
+            return True
+        if self.query.extra_order_by or self.query.order_by:
+            return True
+        elif (
+            self.query.default_ordering
+            and self.query.get_meta().ordering
+            and
+            # A default ordering doesn't affect GROUP BY queries.
+            not self.query.group_by
+        ):
+            return True
+        else:
+            return False
+
+    @property
+    def db(self):
+        """Return the database used if this query is executed now."""
+        if self._for_write:
+            return self._db or router.db_for_write(self.model, **self._hints)
+        return self._db or router.db_for_read(self.model, **self._hints)
+
+    ###################
+    # PRIVATE METHODS #
+    ###################
+
+    def _insert(
+        self,
+        objs,
+        fields,
+        returning_fields=None,
+        raw=False,
+        using=None,
+        on_conflict=None,
+        update_fields=None,
+        unique_fields=None,
+    ):
+        """
+        Insert a new record for the given model. This provides an interface to
+        the InsertQuery class and is how Model.save() is implemented.
+        """
+        self._for_write = True
+        if using is None:
+            using = self.db
+        query = sql.InsertQuery(
+            self.model,
+            on_conflict=on_conflict,
+            update_fields=update_fields,
+            unique_fields=unique_fields,
+        )
+        query.insert_values(fields, objs, raw=raw)
+        return query.get_compiler(using=using).execute_sql(returning_fields)
+
+    _insert.alters_data = True
+    _insert.queryset_only = False
+
+    def _batched_insert(
+        self,
+        objs,
+        fields,
+        batch_size,
+        on_conflict=None,
+        update_fields=None,
+        unique_fields=None,
+    ):
+        """
+        Helper method for bulk_create() to insert objs one batch at a time.
+        """
+        connection = connections[self.db]
+        ops = connection.ops
+        max_batch_size = max(ops.bulk_batch_size(fields, objs), 1)
+        batch_size = min(batch_size, max_batch_size) if batch_size else max_batch_size
+        inserted_rows = []
+        bulk_return = connection.features.can_return_rows_from_bulk_insert
+        for item in [objs[i : i + batch_size] for i in range(0, len(objs), batch_size)]:
+            if bulk_return and on_conflict is None:
+                inserted_rows.extend(
+                    self._insert(
+                        item,
+                        fields=fields,
+                        using=self.db,
+                        returning_fields=self.model._meta.db_returning_fields,
+                    )
+                )
+            else:
+                self._insert(
+                    item,
+                    fields=fields,
+                    using=self.db,
+                    on_conflict=on_conflict,
+                    update_fields=update_fields,
+                    unique_fields=unique_fields,
+                )
+        return inserted_rows
+
+    def _chain(self):
+        """
+        Return a copy of the current QuerySet that's ready for another
+        operation.
+        """
+        obj = self._clone()
+        if obj._sticky_filter:
+            obj.query.filter_is_sticky = True
+            obj._sticky_filter = False
+        return obj
+
+    def _clone(self):
+        """
+        Return a copy of the current QuerySet. A lightweight alternative
+        to deepcopy().
+        """
+        c = self.__class__(
+            model=self.model,
+            query=self.query.chain(),
+            using=self._db,
+            hints=self._hints,
+        )
+        c._sticky_filter = self._sticky_filter
+        c._for_write = self._for_write
+        c._prefetch_related_lookups = self._prefetch_related_lookups[:]
+        c._known_related_objects = self._known_related_objects
+        c._iterable_class = self._iterable_class
+        c._fields = self._fields
+        return c
+
+    def _fetch_all(self):
+        if self._result_cache is None:
+            self._result_cache = list(self._iterable_class(self))
+        if self._prefetch_related_lookups and not self._prefetch_done:
+            self._prefetch_related_objects()
+
+    def _next_is_sticky(self):
+        """
+        Indicate that the next filter call and the one following that should
+        be treated as a single filter. This is only important when it comes to
+        determining when to reuse tables for many-to-many filters. Required so
+        that we can filter naturally on the results of related managers.
+
+        This doesn't return a clone of the current QuerySet (it returns
+        "self"). The method is only used internally and should be immediately
+        followed by a filter() that does create a clone.
+        """
+        self._sticky_filter = True
+        return self
+
+    def _merge_sanity_check(self, other):
+        """Check that two QuerySet classes may be merged."""
+        if self._fields is not None and (
+            set(self.query.values_select) != set(other.query.values_select)
+            or set(self.query.extra_select) != set(other.query.extra_select)
+            or set(self.query.annotation_select) != set(other.query.annotation_select)
+        ):
+            raise TypeError(
+                "Merging '%s' classes must involve the same values in each case."
+                % self.__class__.__name__
+            )
+
+    def _merge_known_related_objects(self, other):
+        """
+        Keep track of all known related objects from either QuerySet instance.
+        """
+        for field, objects in other._known_related_objects.items():
+            self._known_related_objects.setdefault(field, {}).update(objects)
+
+    def resolve_expression(self, *args, **kwargs):
+        if self._fields and len(self._fields) > 1:
+            # values() queryset can only be used as nested queries
+            # if they are set up to select only a single field.
+            raise TypeError("Cannot use multi-field values as a filter value.")
+        query = self.query.resolve_expression(*args, **kwargs)
+        query._db = self._db
+        return query
+
+    resolve_expression.queryset_only = True
+
+    def _add_hints(self, **hints):
+        """
+        Update hinting information for use by routers. Add new key/values or
+        overwrite existing key/values.
+        """
+        self._hints.update(hints)
+
+    def _has_filters(self):
+        """
+        Check if this QuerySet has any filtering going on. This isn't
+        equivalent with checking if all objects are present in results, for
+        example, qs[1:]._has_filters() -> False.
+        """
+        return self.query.has_filters()
+
+    @staticmethod
+    def _validate_values_are_expressions(values, method_name):
+        invalid_args = sorted(
+            str(arg) for arg in values if not hasattr(arg, "resolve_expression")
+        )
+        if invalid_args:
+            raise TypeError(
+                "QuerySet.%s() received non-expression(s): %s."
+                % (
+                    method_name,
+                    ", ".join(invalid_args),
+                )
+            )
+
+    def _not_support_combined_queries(self, operation_name):
+        if self.query.combinator:
+            raise NotSupportedError(
+                "Calling QuerySet.%s() after %s() is not supported."
+                % (operation_name, self.query.combinator)
+            )
+
+    def _check_operator_queryset(self, other, operator_):
+        if self.query.combinator or other.query.combinator:
+            raise TypeError(f"Cannot use {operator_} operator with combined queryset.")
+
+    def _check_ordering_first_last_queryset_aggregation(self, method):
+        if isinstance(self.query.group_by, tuple) and not any(
+            col.output_field is self.model._meta.pk for col in self.query.group_by
+        ):
+            raise TypeError(
+                f"Cannot use QuerySet.{method}() on an unordered queryset performing "
+                f"aggregation. Add an ordering with order_by()."
+            )
+
+
+class InstanceCheckMeta(type):
+    def __instancecheck__(self, instance):
+        return isinstance(instance, QuerySet) and instance.query.is_empty()
+
+
+class EmptyQuerySet(metaclass=InstanceCheckMeta):
+    """
+    Marker class to checking if a queryset is empty by .none():
+        isinstance(qs.none(), EmptyQuerySet) -> True
+    """
+
+    def __init__(self, *args, **kwargs):
+        raise TypeError("EmptyQuerySet can't be instantiated")
+
+
+class RawQuerySet:
+    """
+    Provide an iterator which converts the results of raw SQL queries into
+    annotated model instances.
+    """
+
+    def __init__(
+        self,
+        raw_query,
+        model=None,
+        query=None,
+        params=(),
+        translations=None,
+        using=None,
+        hints=None,
+    ):
+        self.raw_query = raw_query
+        self.model = model
+        self._db = using
+        self._hints = hints or {}
+        self.query = query or sql.RawQuery(sql=raw_query, using=self.db, params=params)
+        self.params = params
+        self.translations = translations or {}
+        self._result_cache = None
+        self._prefetch_related_lookups = ()
+        self._prefetch_done = False
+
+    def resolve_model_init_order(self):
+        """Resolve the init field names and value positions."""
+        converter = connections[self.db].introspection.identifier_converter
+        model_init_fields = [
+            f for f in self.model._meta.fields if converter(f.column) in self.columns
+        ]
+        annotation_fields = [
+            (column, pos)
+            for pos, column in enumerate(self.columns)
+            if column not in self.model_fields
+        ]
+        model_init_order = [
+            self.columns.index(converter(f.column)) for f in model_init_fields
+        ]
+        model_init_names = [f.attname for f in model_init_fields]
+        return model_init_names, model_init_order, annotation_fields
+
+    def prefetch_related(self, *lookups):
+        """Same as QuerySet.prefetch_related()"""
+        clone = self._clone()
+        if lookups == (None,):
+            clone._prefetch_related_lookups = ()
+        else:
+            clone._prefetch_related_lookups = clone._prefetch_related_lookups + lookups
+        return clone
+
+    def _prefetch_related_objects(self):
+        prefetch_related_objects(self._result_cache, *self._prefetch_related_lookups)
+        self._prefetch_done = True
+
+    def _clone(self):
+        """Same as QuerySet._clone()"""
+        c = self.__class__(
+            self.raw_query,
+            model=self.model,
+            query=self.query,
+            params=self.params,
+            translations=self.translations,
+            using=self._db,
+            hints=self._hints,
+        )
+        c._prefetch_related_lookups = self._prefetch_related_lookups[:]
+        return c
+
+    def _fetch_all(self):
+        if self._result_cache is None:
+            self._result_cache = list(self.iterator())
+        if self._prefetch_related_lookups and not self._prefetch_done:
+            self._prefetch_related_objects()
+
+    def __len__(self):
+        self._fetch_all()
+        return len(self._result_cache)
+
+    def __bool__(self):
+        self._fetch_all()
+        return bool(self._result_cache)
+
+    def __iter__(self):
+        self._fetch_all()
+        return iter(self._result_cache)
+
+    def __aiter__(self):
+        # Remember, __aiter__ itself is synchronous, it's the thing it returns
+        # that is async!
+        async def generator():
+            await sync_to_async(self._fetch_all)()
+            for item in self._result_cache:
+                yield item
+
+        return generator()
+
+    def iterator(self):
+        yield from RawModelIterable(self)
+
+    def __repr__(self):
+        return "<%s: %s>" % (self.__class__.__name__, self.query)
+
+    def __getitem__(self, k):
+        return list(self)[k]
+
+    @property
+    def db(self):
+        """Return the database used if this query is executed now."""
+        return self._db or router.db_for_read(self.model, **self._hints)
+
+    def using(self, alias):
+        """Select the database this RawQuerySet should execute against."""
+        return RawQuerySet(
+            self.raw_query,
+            model=self.model,
+            query=self.query.chain(using=alias),
+            params=self.params,
+            translations=self.translations,
+            using=alias,
+        )
+
+    @cached_property
+    def columns(self):
+        """
+        A list of model field names in the order they'll appear in the
+        query results.
+        """
+        columns = self.query.get_columns()
+        # Adjust any column names which don't match field names
+        for query_name, model_name in self.translations.items():
+            # Ignore translations for nonexistent column names
+            try:
+                index = columns.index(query_name)
+            except ValueError:
+                pass
+            else:
+                columns[index] = model_name
+        return columns
+
+    @cached_property
+    def model_fields(self):
+        """A dict mapping column names to model field names."""
+        converter = connections[self.db].introspection.identifier_converter
+        model_fields = {}
+        for field in self.model._meta.fields:
+            name, column = field.get_attname_column()
+            model_fields[converter(column)] = field
+        return model_fields
+
+
+class Prefetch:
+    def __init__(self, lookup, queryset=None, to_attr=None):
+        # `prefetch_through` is the path we traverse to perform the prefetch.
+        self.prefetch_through = lookup
+        # `prefetch_to` is the path to the attribute that stores the result.
+        self.prefetch_to = lookup
+        if queryset is not None and (
+            isinstance(queryset, RawQuerySet)
+            or (
+                hasattr(queryset, "_iterable_class")
+                and not issubclass(queryset._iterable_class, ModelIterable)
+            )
+        ):
+            raise ValueError(
+                "Prefetch querysets cannot use raw(), values(), and values_list()."
+            )
+        if to_attr:
+            self.prefetch_to = LOOKUP_SEP.join(
+                lookup.split(LOOKUP_SEP)[:-1] + [to_attr]
+            )
+
+        self.queryset = queryset
+        self.to_attr = to_attr
+
+    def __getstate__(self):
+        obj_dict = self.__dict__.copy()
+        if self.queryset is not None:
+            queryset = self.queryset._chain()
+            # Prevent the QuerySet from being evaluated
+            queryset._result_cache = []
+            queryset._prefetch_done = True
+            obj_dict["queryset"] = queryset
+        return obj_dict
+
+    def add_prefix(self, prefix):
+        self.prefetch_through = prefix + LOOKUP_SEP + self.prefetch_through
+        self.prefetch_to = prefix + LOOKUP_SEP + self.prefetch_to
+
+    def get_current_prefetch_to(self, level):
+        return LOOKUP_SEP.join(self.prefetch_to.split(LOOKUP_SEP)[: level + 1])
+
+    def get_current_to_attr(self, level):
+        parts = self.prefetch_to.split(LOOKUP_SEP)
+        to_attr = parts[level]
+        as_attr = self.to_attr and level == len(parts) - 1
+        return to_attr, as_attr
+
+    def get_current_queryset(self, level):
+        if self.get_current_prefetch_to(level) == self.prefetch_to:
+            return self.queryset
+        return None
+
+    def __eq__(self, other):
+        if not isinstance(other, Prefetch):
+            return NotImplemented
+        return self.prefetch_to == other.prefetch_to
+
+    def __hash__(self):
+        return hash((self.__class__, self.prefetch_to))
+
+
+def normalize_prefetch_lookups(lookups, prefix=None):
+    """Normalize lookups into Prefetch objects."""
+    ret = []
+    for lookup in lookups:
+        if not isinstance(lookup, Prefetch):
+            lookup = Prefetch(lookup)
+        if prefix:
+            lookup.add_prefix(prefix)
+        ret.append(lookup)
+    return ret
+
+
+def prefetch_related_objects(model_instances, *related_lookups):
+    """
+    Populate prefetched object caches for a list of model instances based on
+    the lookups/Prefetch instances given.
+    """
+    if not model_instances:
+        return  # nothing to do
+
+    # We need to be able to dynamically add to the list of prefetch_related
+    # lookups that we look up (see below).  So we need some book keeping to
+    # ensure we don't do duplicate work.
+    done_queries = {}  # dictionary of things like 'foo__bar': [results]
+
+    auto_lookups = set()  # we add to this as we go through.
+    followed_descriptors = set()  # recursion protection
+
+    all_lookups = normalize_prefetch_lookups(reversed(related_lookups))
+    while all_lookups:
+        lookup = all_lookups.pop()
+        if lookup.prefetch_to in done_queries:
+            if lookup.queryset is not None:
+                raise ValueError(
+                    "'%s' lookup was already seen with a different queryset. "
+                    "You may need to adjust the ordering of your lookups."
+                    % lookup.prefetch_to
+                )
+
+            continue
+
+        # Top level, the list of objects to decorate is the result cache
+        # from the primary QuerySet. It won't be for deeper levels.
+        obj_list = model_instances
+
+        through_attrs = lookup.prefetch_through.split(LOOKUP_SEP)
+        for level, through_attr in enumerate(through_attrs):
+            # Prepare main instances
+            if not obj_list:
+                break
+
+            prefetch_to = lookup.get_current_prefetch_to(level)
+            if prefetch_to in done_queries:
+                # Skip any prefetching, and any object preparation
+                obj_list = done_queries[prefetch_to]
+                continue
+
+            # Prepare objects:
+            good_objects = True
+            for obj in obj_list:
+                # Since prefetching can re-use instances, it is possible to have
+                # the same instance multiple times in obj_list, so obj might
+                # already be prepared.
+                if not hasattr(obj, "_prefetched_objects_cache"):
+                    try:
+                        obj._prefetched_objects_cache = {}
+                    except (AttributeError, TypeError):
+                        # Must be an immutable object from
+                        # values_list(flat=True), for example (TypeError) or
+                        # a QuerySet subclass that isn't returning Model
+                        # instances (AttributeError), either in Django or a 3rd
+                        # party. prefetch_related() doesn't make sense, so quit.
+                        good_objects = False
+                        break
+            if not good_objects:
+                break
+
+            # Descend down tree
+
+            # We assume that objects retrieved are homogeneous (which is the premise
+            # of prefetch_related), so what applies to first object applies to all.
+            first_obj = obj_list[0]
+            to_attr = lookup.get_current_to_attr(level)[0]
+            prefetcher, descriptor, attr_found, is_fetched = get_prefetcher(
+                first_obj, through_attr, to_attr
+            )
+
+            if not attr_found:
+                raise AttributeError(
+                    "Cannot find '%s' on %s object, '%s' is an invalid "
+                    "parameter to prefetch_related()"
+                    % (
+                        through_attr,
+                        first_obj.__class__.__name__,
+                        lookup.prefetch_through,
+                    )
+                )
+
+            if level == len(through_attrs) - 1 and prefetcher is None:
+                # Last one, this *must* resolve to something that supports
+                # prefetching, otherwise there is no point adding it and the
+                # developer asking for it has made a mistake.
+                raise ValueError(
+                    "'%s' does not resolve to an item that supports "
+                    "prefetching - this is an invalid parameter to "
+                    "prefetch_related()." % lookup.prefetch_through
+                )
+
+            obj_to_fetch = None
+            if prefetcher is not None:
+                obj_to_fetch = [obj for obj in obj_list if not is_fetched(obj)]
+
+            if obj_to_fetch:
+                obj_list, additional_lookups = prefetch_one_level(
+                    obj_to_fetch,
+                    prefetcher,
+                    lookup,
+                    level,
+                )
+                # We need to ensure we don't keep adding lookups from the
+                # same relationships to stop infinite recursion. So, if we
+                # are already on an automatically added lookup, don't add
+                # the new lookups from relationships we've seen already.
+                if not (
+                    prefetch_to in done_queries
+                    and lookup in auto_lookups
+                    and descriptor in followed_descriptors
+                ):
+                    done_queries[prefetch_to] = obj_list
+                    new_lookups = normalize_prefetch_lookups(
+                        reversed(additional_lookups), prefetch_to
+                    )
+                    auto_lookups.update(new_lookups)
+                    all_lookups.extend(new_lookups)
+                followed_descriptors.add(descriptor)
+            else:
+                # Either a singly related object that has already been fetched
+                # (e.g. via select_related), or hopefully some other property
+                # that doesn't support prefetching but needs to be traversed.
+
+                # We replace the current list of parent objects with the list
+                # of related objects, filtering out empty or missing values so
+                # that we can continue with nullable or reverse relations.
+                new_obj_list = []
+                for obj in obj_list:
+                    if through_attr in getattr(obj, "_prefetched_objects_cache", ()):
+                        # If related objects have been prefetched, use the
+                        # cache rather than the object's through_attr.
+                        new_obj = list(obj._prefetched_objects_cache.get(through_attr))
+                    else:
+                        try:
+                            new_obj = getattr(obj, through_attr)
+                        except exceptions.ObjectDoesNotExist:
+                            continue
+                    if new_obj is None:
+                        continue
+                    # We special-case `list` rather than something more generic
+                    # like `Iterable` because we don't want to accidentally match
+                    # user models that define __iter__.
+                    if isinstance(new_obj, list):
+                        new_obj_list.extend(new_obj)
+                    else:
+                        new_obj_list.append(new_obj)
+                obj_list = new_obj_list
+
+
+def get_prefetcher(instance, through_attr, to_attr):
+    """
+    For the attribute 'through_attr' on the given instance, find
+    an object that has a get_prefetch_queryset().
+    Return a 4 tuple containing:
+    (the object with get_prefetch_queryset (or None),
+     the descriptor object representing this relationship (or None),
+     a boolean that is False if the attribute was not found at all,
+     a function that takes an instance and returns a boolean that is True if
+     the attribute has already been fetched for that instance)
+    """
+
+    def has_to_attr_attribute(instance):
+        return hasattr(instance, to_attr)
+
+    prefetcher = None
+    is_fetched = has_to_attr_attribute
+
+    # For singly related objects, we have to avoid getting the attribute
+    # from the object, as this will trigger the query. So we first try
+    # on the class, in order to get the descriptor object.
+    rel_obj_descriptor = getattr(instance.__class__, through_attr, None)
+    if rel_obj_descriptor is None:
+        attr_found = hasattr(instance, through_attr)
+    else:
+        attr_found = True
+        if rel_obj_descriptor:
+            # singly related object, descriptor object has the
+            # get_prefetch_queryset() method.
+            if hasattr(rel_obj_descriptor, "get_prefetch_queryset"):
+                prefetcher = rel_obj_descriptor
+                is_fetched = rel_obj_descriptor.is_cached
+            else:
+                # descriptor doesn't support prefetching, so we go ahead and get
+                # the attribute on the instance rather than the class to
+                # support many related managers
+                rel_obj = getattr(instance, through_attr)
+                if hasattr(rel_obj, "get_prefetch_queryset"):
+                    prefetcher = rel_obj
+                if through_attr != to_attr:
+                    # Special case cached_property instances because hasattr
+                    # triggers attribute computation and assignment.
+                    if isinstance(
+                        getattr(instance.__class__, to_attr, None), cached_property
+                    ):
+
+                        def has_cached_property(instance):
+                            return to_attr in instance.__dict__
+
+                        is_fetched = has_cached_property
+                else:
+
+                    def in_prefetched_cache(instance):
+                        return through_attr in instance._prefetched_objects_cache
+
+                    is_fetched = in_prefetched_cache
+    return prefetcher, rel_obj_descriptor, attr_found, is_fetched
+
+
+def prefetch_one_level(instances, prefetcher, lookup, level):
+    """
+    Helper function for prefetch_related_objects().
+
+    Run prefetches on all instances using the prefetcher object,
+    assigning results to relevant caches in instance.
+
+    Return the prefetched objects along with any additional prefetches that
+    must be done due to prefetch_related lookups found from default managers.
+    """
+    # prefetcher must have a method get_prefetch_queryset() which takes a list
+    # of instances, and returns a tuple:
+
+    # (queryset of instances of self.model that are related to passed in instances,
+    #  callable that gets value to be matched for returned instances,
+    #  callable that gets value to be matched for passed in instances,
+    #  boolean that is True for singly related objects,
+    #  cache or field name to assign to,
+    #  boolean that is True when the previous argument is a cache name vs a field name).
+
+    # The 'values to be matched' must be hashable as they will be used
+    # in a dictionary.
+
+    (
+        rel_qs,
+        rel_obj_attr,
+        instance_attr,
+        single,
+        cache_name,
+        is_descriptor,
+    ) = prefetcher.get_prefetch_queryset(instances, lookup.get_current_queryset(level))
+    # We have to handle the possibility that the QuerySet we just got back
+    # contains some prefetch_related lookups. We don't want to trigger the
+    # prefetch_related functionality by evaluating the query. Rather, we need
+    # to merge in the prefetch_related lookups.
+    # Copy the lookups in case it is a Prefetch object which could be reused
+    # later (happens in nested prefetch_related).
+    additional_lookups = [
+        copy.copy(additional_lookup)
+        for additional_lookup in getattr(rel_qs, "_prefetch_related_lookups", ())
+    ]
+    if additional_lookups:
+        # Don't need to clone because the manager should have given us a fresh
+        # instance, so we access an internal instead of using public interface
+        # for performance reasons.
+        rel_qs._prefetch_related_lookups = ()
+
+    all_related_objects = list(rel_qs)
+
+    rel_obj_cache = {}
+    for rel_obj in all_related_objects:
+        rel_attr_val = rel_obj_attr(rel_obj)
+        rel_obj_cache.setdefault(rel_attr_val, []).append(rel_obj)
+
+    to_attr, as_attr = lookup.get_current_to_attr(level)
+    # Make sure `to_attr` does not conflict with a field.
+    if as_attr and instances:
+        # We assume that objects retrieved are homogeneous (which is the premise
+        # of prefetch_related), so what applies to first object applies to all.
+        model = instances[0].__class__
+        try:
+            model._meta.get_field(to_attr)
+        except exceptions.FieldDoesNotExist:
+            pass
+        else:
+            msg = "to_attr={} conflicts with a field on the {} model."
+            raise ValueError(msg.format(to_attr, model.__name__))
+
+    # Whether or not we're prefetching the last part of the lookup.
+    leaf = len(lookup.prefetch_through.split(LOOKUP_SEP)) - 1 == level
+
+    for obj in instances:
+        instance_attr_val = instance_attr(obj)
+        vals = rel_obj_cache.get(instance_attr_val, [])
+
+        if single:
+            val = vals[0] if vals else None
+            if as_attr:
+                # A to_attr has been given for the prefetch.
+                setattr(obj, to_attr, val)
+            elif is_descriptor:
+                # cache_name points to a field name in obj.
+                # This field is a descriptor for a related object.
+                setattr(obj, cache_name, val)
+            else:
+                # No to_attr has been given for this prefetch operation and the
+                # cache_name does not point to a descriptor. Store the value of
+                # the field in the object's field cache.
+                obj._state.fields_cache[cache_name] = val
+        else:
+            if as_attr:
+                setattr(obj, to_attr, vals)
+            else:
+                manager = getattr(obj, to_attr)
+                if leaf and lookup.queryset is not None:
+                    qs = manager._apply_rel_filters(lookup.queryset)
+                else:
+                    qs = manager.get_queryset()
+                qs._result_cache = vals
+                # We don't want the individual qs doing prefetch_related now,
+                # since we have merged this into the current work.
+                qs._prefetch_done = True
+                obj._prefetched_objects_cache[cache_name] = qs
+    return all_related_objects, additional_lookups
+
+
+class RelatedPopulator:
+    """
+    RelatedPopulator is used for select_related() object instantiation.
+
+    The idea is that each select_related() model will be populated by a
+    different RelatedPopulator instance. The RelatedPopulator instances get
+    klass_info and select (computed in SQLCompiler) plus the used db as
+    input for initialization. That data is used to compute which columns
+    to use, how to instantiate the model, and how to populate the links
+    between the objects.
+
+    The actual creation of the objects is done in populate() method. This
+    method gets row and from_obj as input and populates the select_related()
+    model instance.
+    """
+
+    def __init__(self, klass_info, select, db):
+        self.db = db
+        # Pre-compute needed attributes. The attributes are:
+        #  - model_cls: the possibly deferred model class to instantiate
+        #  - either:
+        #    - cols_start, cols_end: usually the columns in the row are
+        #      in the same order model_cls.__init__ expects them, so we
+        #      can instantiate by model_cls(*row[cols_start:cols_end])
+        #    - reorder_for_init: When select_related descends to a child
+        #      class, then we want to reuse the already selected parent
+        #      data. However, in this case the parent data isn't necessarily
+        #      in the same order that Model.__init__ expects it to be, so
+        #      we have to reorder the parent data. The reorder_for_init
+        #      attribute contains a function used to reorder the field data
+        #      in the order __init__ expects it.
+        #  - pk_idx: the index of the primary key field in the reordered
+        #    model data. Used to check if a related object exists at all.
+        #  - init_list: the field attnames fetched from the database. For
+        #    deferred models this isn't the same as all attnames of the
+        #    model's fields.
+        #  - related_populators: a list of RelatedPopulator instances if
+        #    select_related() descends to related models from this model.
+        #  - local_setter, remote_setter: Methods to set cached values on
+        #    the object being populated and on the remote object. Usually
+        #    these are Field.set_cached_value() methods.
+        select_fields = klass_info["select_fields"]
+        from_parent = klass_info["from_parent"]
+        if not from_parent:
+            self.cols_start = select_fields[0]
+            self.cols_end = select_fields[-1] + 1
+            self.init_list = [
+                f[0].target.attname for f in select[self.cols_start : self.cols_end]
+            ]
+            self.reorder_for_init = None
+        else:
+            attname_indexes = {
+                select[idx][0].target.attname: idx for idx in select_fields
+            }
+            model_init_attnames = (
+                f.attname for f in klass_info["model"]._meta.concrete_fields
+            )
+            self.init_list = [
+                attname for attname in model_init_attnames if attname in attname_indexes
+            ]
+            self.reorder_for_init = operator.itemgetter(
+                *[attname_indexes[attname] for attname in self.init_list]
+            )
+
+        self.model_cls = klass_info["model"]
+        self.pk_idx = self.init_list.index(self.model_cls._meta.pk.attname)
+        self.related_populators = get_related_populators(klass_info, select, self.db)
+        self.local_setter = klass_info["local_setter"]
+        self.remote_setter = klass_info["remote_setter"]
+
+    def populate(self, row, from_obj):
+        if self.reorder_for_init:
+            obj_data = self.reorder_for_init(row)
+        else:
+            obj_data = row[self.cols_start : self.cols_end]
+        if obj_data[self.pk_idx] is None:
+            obj = None
+        else:
+            obj = self.model_cls.from_db(self.db, self.init_list, obj_data)
+            for rel_iter in self.related_populators:
+                rel_iter.populate(row, obj)
+        self.local_setter(from_obj, obj)
+        if obj is not None:
+            self.remote_setter(obj, from_obj)
+
+
+def get_related_populators(klass_info, select, db):
+    iterators = []
+    related_klass_infos = klass_info.get("related_klass_infos", [])
+    for rel_klass_info in related_klass_infos:
+        rel_cls = RelatedPopulator(rel_klass_info, select, db)
+        iterators.append(rel_cls)
+    return iterators
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/django/db/models/query_utils.html b/docs/latest/_modules/django/db/models/query_utils.html new file mode 100644 index 0000000000..659c2bb17f --- /dev/null +++ b/docs/latest/_modules/django/db/models/query_utils.html @@ -0,0 +1,538 @@ + + + + + + + + django.db.models.query_utils — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for django.db.models.query_utils

+"""
+Various data structures used in query construction.
+
+Factored out from django.db.models.query to avoid making the main module very
+large and/or so that they can be used by other modules without getting into
+circular import difficulties.
+"""
+import functools
+import inspect
+import logging
+from collections import namedtuple
+
+from django.core.exceptions import FieldError
+from django.db import DEFAULT_DB_ALIAS, DatabaseError, connections
+from django.db.models.constants import LOOKUP_SEP
+from django.utils import tree
+
+logger = logging.getLogger("django.db.models")
+
+# PathInfo is used when converting lookups (fk__somecol). The contents
+# describe the relation in Model terms (model Options and Fields for both
+# sides of the relation. The join_field is the field backing the relation.
+PathInfo = namedtuple(
+    "PathInfo",
+    "from_opts to_opts target_fields join_field m2m direct filtered_relation",
+)
+
+
+def subclasses(cls):
+    yield cls
+    for subclass in cls.__subclasses__():
+        yield from subclasses(subclass)
+
+
+class Q(tree.Node):
+    """
+    Encapsulate filters as objects that can then be combined logically (using
+    `&` and `|`).
+    """
+
+    # Connection types
+    AND = "AND"
+    OR = "OR"
+    XOR = "XOR"
+    default = AND
+    conditional = True
+
+    def __init__(self, *args, _connector=None, _negated=False, **kwargs):
+        super().__init__(
+            children=[*args, *sorted(kwargs.items())],
+            connector=_connector,
+            negated=_negated,
+        )
+
+    def _combine(self, other, conn):
+        if getattr(other, "conditional", False) is False:
+            raise TypeError(other)
+        if not self:
+            return other.copy()
+        if not other and isinstance(other, Q):
+            return self.copy()
+
+        obj = self.create(connector=conn)
+        obj.add(self, conn)
+        obj.add(other, conn)
+        return obj
+
+    def __or__(self, other):
+        return self._combine(other, self.OR)
+
+    def __and__(self, other):
+        return self._combine(other, self.AND)
+
+    def __xor__(self, other):
+        return self._combine(other, self.XOR)
+
+    def __invert__(self):
+        obj = self.copy()
+        obj.negate()
+        return obj
+
+    def resolve_expression(
+        self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False
+    ):
+        # We must promote any new joins to left outer joins so that when Q is
+        # used as an expression, rows aren't filtered due to joins.
+        clause, joins = query._add_q(
+            self,
+            reuse,
+            allow_joins=allow_joins,
+            split_subq=False,
+            check_filterable=False,
+            summarize=summarize,
+        )
+        query.promote_joins(joins)
+        return clause
+
+    def flatten(self):
+        """
+        Recursively yield this Q object and all subexpressions, in depth-first
+        order.
+        """
+        yield self
+        for child in self.children:
+            if isinstance(child, tuple):
+                # Use the lookup.
+                child = child[1]
+            if hasattr(child, "flatten"):
+                yield from child.flatten()
+            else:
+                yield child
+
+    def check(self, against, using=DEFAULT_DB_ALIAS):
+        """
+        Do a database query to check if the expressions of the Q instance
+        matches against the expressions.
+        """
+        # Avoid circular imports.
+        from django.db.models import BooleanField, Value
+        from django.db.models.functions import Coalesce
+        from django.db.models.sql import Query
+        from django.db.models.sql.constants import SINGLE
+
+        query = Query(None)
+        for name, value in against.items():
+            if not hasattr(value, "resolve_expression"):
+                value = Value(value)
+            query.add_annotation(value, name, select=False)
+        query.add_annotation(Value(1), "_check")
+        # This will raise a FieldError if a field is missing in "against".
+        if connections[using].features.supports_comparing_boolean_expr:
+            query.add_q(Q(Coalesce(self, True, output_field=BooleanField())))
+        else:
+            query.add_q(self)
+        compiler = query.get_compiler(using=using)
+        try:
+            return compiler.execute_sql(SINGLE) is not None
+        except DatabaseError as e:
+            logger.warning("Got a database error calling check() on %r: %s", self, e)
+            return True
+
+    def deconstruct(self):
+        path = "%s.%s" % (self.__class__.__module__, self.__class__.__name__)
+        if path.startswith("django.db.models.query_utils"):
+            path = path.replace("django.db.models.query_utils", "django.db.models")
+        args = tuple(self.children)
+        kwargs = {}
+        if self.connector != self.default:
+            kwargs["_connector"] = self.connector
+        if self.negated:
+            kwargs["_negated"] = True
+        return path, args, kwargs
+
+
+class DeferredAttribute:
+    """
+    A wrapper for a deferred-loading field. When the value is read from this
+    object the first time, the query is executed.
+    """
+
+    def __init__(self, field):
+        self.field = field
+
+    def __get__(self, instance, cls=None):
+        """
+        Retrieve and caches the value from the datastore on the first lookup.
+        Return the cached value.
+        """
+        if instance is None:
+            return self
+        data = instance.__dict__
+        field_name = self.field.attname
+        if field_name not in data:
+            # Let's see if the field is part of the parent chain. If so we
+            # might be able to reuse the already loaded value. Refs #18343.
+            val = self._check_parent_chain(instance)
+            if val is None:
+                instance.refresh_from_db(fields=[field_name])
+            else:
+                data[field_name] = val
+        return data[field_name]
+
+    def _check_parent_chain(self, instance):
+        """
+        Check if the field value can be fetched from a parent field already
+        loaded in the instance. This can be done if the to-be fetched
+        field is a primary key field.
+        """
+        opts = instance._meta
+        link_field = opts.get_ancestor_link(self.field.model)
+        if self.field.primary_key and self.field != link_field:
+            return getattr(instance, link_field.attname)
+        return None
+
+
+class class_or_instance_method:
+    """
+    Hook used in RegisterLookupMixin to return partial functions depending on
+    the caller type (instance or class of models.Field).
+    """
+
+    def __init__(self, class_method, instance_method):
+        self.class_method = class_method
+        self.instance_method = instance_method
+
+    def __get__(self, instance, owner):
+        if instance is None:
+            return functools.partial(self.class_method, owner)
+        return functools.partial(self.instance_method, instance)
+
+
+class RegisterLookupMixin:
+    def _get_lookup(self, lookup_name):
+        return self.get_lookups().get(lookup_name, None)
+
+    @functools.lru_cache(maxsize=None)
+    def get_class_lookups(cls):
+        class_lookups = [
+            parent.__dict__.get("class_lookups", {}) for parent in inspect.getmro(cls)
+        ]
+        return cls.merge_dicts(class_lookups)
+
+    def get_instance_lookups(self):
+        class_lookups = self.get_class_lookups()
+        if instance_lookups := getattr(self, "instance_lookups", None):
+            return {**class_lookups, **instance_lookups}
+        return class_lookups
+
+    get_lookups = class_or_instance_method(get_class_lookups, get_instance_lookups)
+    get_class_lookups = classmethod(get_class_lookups)
+
+    def get_lookup(self, lookup_name):
+        from django.db.models.lookups import Lookup
+
+        found = self._get_lookup(lookup_name)
+        if found is None and hasattr(self, "output_field"):
+            return self.output_field.get_lookup(lookup_name)
+        if found is not None and not issubclass(found, Lookup):
+            return None
+        return found
+
+    def get_transform(self, lookup_name):
+        from django.db.models.lookups import Transform
+
+        found = self._get_lookup(lookup_name)
+        if found is None and hasattr(self, "output_field"):
+            return self.output_field.get_transform(lookup_name)
+        if found is not None and not issubclass(found, Transform):
+            return None
+        return found
+
+    @staticmethod
+    def merge_dicts(dicts):
+        """
+        Merge dicts in reverse to preference the order of the original list. e.g.,
+        merge_dicts([a, b]) will preference the keys in 'a' over those in 'b'.
+        """
+        merged = {}
+        for d in reversed(dicts):
+            merged.update(d)
+        return merged
+
+    @classmethod
+    def _clear_cached_class_lookups(cls):
+        for subclass in subclasses(cls):
+            subclass.get_class_lookups.cache_clear()
+
+    def register_class_lookup(cls, lookup, lookup_name=None):
+        if lookup_name is None:
+            lookup_name = lookup.lookup_name
+        if "class_lookups" not in cls.__dict__:
+            cls.class_lookups = {}
+        cls.class_lookups[lookup_name] = lookup
+        cls._clear_cached_class_lookups()
+        return lookup
+
+    def register_instance_lookup(self, lookup, lookup_name=None):
+        if lookup_name is None:
+            lookup_name = lookup.lookup_name
+        if "instance_lookups" not in self.__dict__:
+            self.instance_lookups = {}
+        self.instance_lookups[lookup_name] = lookup
+        return lookup
+
+    register_lookup = class_or_instance_method(
+        register_class_lookup, register_instance_lookup
+    )
+    register_class_lookup = classmethod(register_class_lookup)
+
+    def _unregister_class_lookup(cls, lookup, lookup_name=None):
+        """
+        Remove given lookup from cls lookups. For use in tests only as it's
+        not thread-safe.
+        """
+        if lookup_name is None:
+            lookup_name = lookup.lookup_name
+        del cls.class_lookups[lookup_name]
+        cls._clear_cached_class_lookups()
+
+    def _unregister_instance_lookup(self, lookup, lookup_name=None):
+        """
+        Remove given lookup from instance lookups. For use in tests only as
+        it's not thread-safe.
+        """
+        if lookup_name is None:
+            lookup_name = lookup.lookup_name
+        del self.instance_lookups[lookup_name]
+
+    _unregister_lookup = class_or_instance_method(
+        _unregister_class_lookup, _unregister_instance_lookup
+    )
+    _unregister_class_lookup = classmethod(_unregister_class_lookup)
+
+
+def select_related_descend(field, restricted, requested, select_mask, reverse=False):
+    """
+    Return True if this field should be used to descend deeper for
+    select_related() purposes. Used by both the query construction code
+    (compiler.get_related_selections()) and the model instance creation code
+    (compiler.klass_info).
+
+    Arguments:
+     * field - the field to be checked
+     * restricted - a boolean field, indicating if the field list has been
+       manually restricted using a requested clause)
+     * requested - The select_related() dictionary.
+     * select_mask - the dictionary of selected fields.
+     * reverse - boolean, True if we are checking a reverse select related
+    """
+    if not field.remote_field:
+        return False
+    if field.remote_field.parent_link and not reverse:
+        return False
+    if restricted:
+        if reverse and field.related_query_name() not in requested:
+            return False
+        if not reverse and field.name not in requested:
+            return False
+    if not restricted and field.null:
+        return False
+    if (
+        restricted
+        and select_mask
+        and field.name in requested
+        and field not in select_mask
+    ):
+        raise FieldError(
+            f"Field {field.model._meta.object_name}.{field.name} cannot be both "
+            "deferred and traversed using select_related at the same time."
+        )
+    return True
+
+
+def refs_expression(lookup_parts, annotations):
+    """
+    Check if the lookup_parts contains references to the given annotations set.
+    Because the LOOKUP_SEP is contained in the default annotation names, check
+    each prefix of the lookup_parts for a match.
+    """
+    for n in range(1, len(lookup_parts) + 1):
+        level_n_lookup = LOOKUP_SEP.join(lookup_parts[0:n])
+        if annotations.get(level_n_lookup):
+            return level_n_lookup, lookup_parts[n:]
+    return None, ()
+
+
+def check_rel_lookup_compatibility(model, target_opts, field):
+    """
+    Check that self.model is compatible with target_opts. Compatibility
+    is OK if:
+      1) model and opts match (where proxy inheritance is removed)
+      2) model is parent of opts' model or the other way around
+    """
+
+    def check(opts):
+        return (
+            model._meta.concrete_model == opts.concrete_model
+            or opts.concrete_model in model._meta.get_parent_list()
+            or model in opts.get_parent_list()
+        )
+
+    # If the field is a primary key, then doing a query against the field's
+    # model is ok, too. Consider the case:
+    # class Restaurant(models.Model):
+    #     place = OneToOneField(Place, primary_key=True):
+    # Restaurant.objects.filter(pk__in=Restaurant.objects.all()).
+    # If we didn't have the primary key check, then pk__in (== place__in) would
+    # give Place's opts as the target opts, but Restaurant isn't compatible
+    # with that. This logic applies only to primary keys, as when doing __in=qs,
+    # we are going to turn this into __in=qs.values('pk') later on.
+    return check(target_opts) or (
+        getattr(field, "primary_key", False) and check(field.model._meta)
+    )
+
+
+class FilteredRelation:
+    """Specify custom filtering in the ON clause of SQL joins."""
+
+    def __init__(self, relation_name, *, condition=Q()):
+        if not relation_name:
+            raise ValueError("relation_name cannot be empty.")
+        self.relation_name = relation_name
+        self.alias = None
+        if not isinstance(condition, Q):
+            raise ValueError("condition argument must be a Q() instance.")
+        self.condition = condition
+        self.path = []
+
+    def __eq__(self, other):
+        if not isinstance(other, self.__class__):
+            return NotImplemented
+        return (
+            self.relation_name == other.relation_name
+            and self.alias == other.alias
+            and self.condition == other.condition
+        )
+
+    def clone(self):
+        clone = FilteredRelation(self.relation_name, condition=self.condition)
+        clone.alias = self.alias
+        clone.path = self.path[:]
+        return clone
+
+    def resolve_expression(self, *args, **kwargs):
+        """
+        QuerySet.annotate() only accepts expression-like arguments
+        (with a resolve_expression() method).
+        """
+        raise NotImplementedError("FilteredRelation.resolve_expression() is unused.")
+
+    def as_sql(self, compiler, connection):
+        # Resolve the condition in Join.filtered_relation.
+        query = compiler.query
+        where = query.build_filtered_relation_q(self.condition, reuse=set(self.path))
+        return compiler.compile(where)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/django/utils/functional.html b/docs/latest/_modules/django/utils/functional.html new file mode 100644 index 0000000000..6afdff3ab6 --- /dev/null +++ b/docs/latest/_modules/django/utils/functional.html @@ -0,0 +1,569 @@ + + + + + + + + django.utils.functional — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for django.utils.functional

+import copy
+import itertools
+import operator
+import warnings
+from functools import total_ordering, wraps
+
+
+class cached_property:
+    """
+    Decorator that converts a method with a single self argument into a
+    property cached on the instance.
+
+    A cached property can be made out of an existing method:
+    (e.g. ``url = cached_property(get_absolute_url)``).
+    """
+
+    name = None
+
+    @staticmethod
+    def func(instance):
+        raise TypeError(
+            "Cannot use cached_property instance without calling "
+            "__set_name__() on it."
+        )
+
+    def __init__(self, func, name=None):
+        from django.utils.deprecation import RemovedInDjango50Warning
+
+        if name is not None:
+            warnings.warn(
+                "The name argument is deprecated as it's unnecessary as of "
+                "Python 3.6.",
+                RemovedInDjango50Warning,
+                stacklevel=2,
+            )
+        self.real_func = func
+        self.__doc__ = getattr(func, "__doc__")
+
+    def __set_name__(self, owner, name):
+        if self.name is None:
+            self.name = name
+            self.func = self.real_func
+        elif name != self.name:
+            raise TypeError(
+                "Cannot assign the same cached_property to two different names "
+                "(%r and %r)." % (self.name, name)
+            )
+
+    def __get__(self, instance, cls=None):
+        """
+        Call the function and put the return value in instance.__dict__ so that
+        subsequent attribute access on the instance returns the cached value
+        instead of calling cached_property.__get__().
+        """
+        if instance is None:
+            return self
+        res = instance.__dict__[self.name] = self.func(instance)
+        return res
+
+
+class classproperty:
+    """
+    Decorator that converts a method with a single cls argument into a property
+    that can be accessed directly from the class.
+    """
+
+    def __init__(self, method=None):
+        self.fget = method
+
+    def __get__(self, instance, cls=None):
+        return self.fget(cls)
+
+    def getter(self, method):
+        self.fget = method
+        return self
+
+
+class Promise:
+    """
+    Base class for the proxy class created in the closure of the lazy function.
+    It's used to recognize promises in code.
+    """
+
+    pass
+
+
+def lazy(func, *resultclasses):
+    """
+    Turn any callable into a lazy evaluated callable. result classes or types
+    is required -- at least one is needed so that the automatic forcing of
+    the lazy evaluation code is triggered. Results are not memoized; the
+    function is evaluated on every access.
+    """
+
+    @total_ordering
+    class __proxy__(Promise):
+        """
+        Encapsulate a function call and act as a proxy for methods that are
+        called on the result of that function. The function is not evaluated
+        until one of the methods on the result is called.
+        """
+
+        __prepared = False
+
+        def __init__(self, args, kw):
+            self.__args = args
+            self.__kw = kw
+            if not self.__prepared:
+                self.__prepare_class__()
+            self.__class__.__prepared = True
+
+        def __reduce__(self):
+            return (
+                _lazy_proxy_unpickle,
+                (func, self.__args, self.__kw) + resultclasses,
+            )
+
+        def __repr__(self):
+            return repr(self.__cast())
+
+        @classmethod
+        def __prepare_class__(cls):
+            for resultclass in resultclasses:
+                for type_ in resultclass.mro():
+                    for method_name in type_.__dict__:
+                        # All __promise__ return the same wrapper method, they
+                        # look up the correct implementation when called.
+                        if hasattr(cls, method_name):
+                            continue
+                        meth = cls.__promise__(method_name)
+                        setattr(cls, method_name, meth)
+            cls._delegate_bytes = bytes in resultclasses
+            cls._delegate_text = str in resultclasses
+            if cls._delegate_bytes and cls._delegate_text:
+                raise ValueError(
+                    "Cannot call lazy() with both bytes and text return types."
+                )
+            if cls._delegate_text:
+                cls.__str__ = cls.__text_cast
+            elif cls._delegate_bytes:
+                cls.__bytes__ = cls.__bytes_cast
+
+        @classmethod
+        def __promise__(cls, method_name):
+            # Builds a wrapper around some magic method
+            def __wrapper__(self, *args, **kw):
+                # Automatically triggers the evaluation of a lazy value and
+                # applies the given magic method of the result type.
+                res = func(*self.__args, **self.__kw)
+                return getattr(res, method_name)(*args, **kw)
+
+            return __wrapper__
+
+        def __text_cast(self):
+            return func(*self.__args, **self.__kw)
+
+        def __bytes_cast(self):
+            return bytes(func(*self.__args, **self.__kw))
+
+        def __bytes_cast_encoded(self):
+            return func(*self.__args, **self.__kw).encode()
+
+        def __cast(self):
+            if self._delegate_bytes:
+                return self.__bytes_cast()
+            elif self._delegate_text:
+                return self.__text_cast()
+            else:
+                return func(*self.__args, **self.__kw)
+
+        def __str__(self):
+            # object defines __str__(), so __prepare_class__() won't overload
+            # a __str__() method from the proxied class.
+            return str(self.__cast())
+
+        def __eq__(self, other):
+            if isinstance(other, Promise):
+                other = other.__cast()
+            return self.__cast() == other
+
+        def __lt__(self, other):
+            if isinstance(other, Promise):
+                other = other.__cast()
+            return self.__cast() < other
+
+        def __hash__(self):
+            return hash(self.__cast())
+
+        def __mod__(self, rhs):
+            if self._delegate_text:
+                return str(self) % rhs
+            return self.__cast() % rhs
+
+        def __add__(self, other):
+            return self.__cast() + other
+
+        def __radd__(self, other):
+            return other + self.__cast()
+
+        def __deepcopy__(self, memo):
+            # Instances of this class are effectively immutable. It's just a
+            # collection of functions. So we don't need to do anything
+            # complicated for copying.
+            memo[id(self)] = self
+            return self
+
+    @wraps(func)
+    def __wrapper__(*args, **kw):
+        # Creates the proxy object, instead of the actual value.
+        return __proxy__(args, kw)
+
+    return __wrapper__
+
+
+def _lazy_proxy_unpickle(func, args, kwargs, *resultclasses):
+    return lazy(func, *resultclasses)(*args, **kwargs)
+
+
+def lazystr(text):
+    """
+    Shortcut for the common case of a lazy callable that returns str.
+    """
+    return lazy(str, str)(text)
+
+
+def keep_lazy(*resultclasses):
+    """
+    A decorator that allows a function to be called with one or more lazy
+    arguments. If none of the args are lazy, the function is evaluated
+    immediately, otherwise a __proxy__ is returned that will evaluate the
+    function when needed.
+    """
+    if not resultclasses:
+        raise TypeError("You must pass at least one argument to keep_lazy().")
+
+    def decorator(func):
+        lazy_func = lazy(func, *resultclasses)
+
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            if any(
+                isinstance(arg, Promise)
+                for arg in itertools.chain(args, kwargs.values())
+            ):
+                return lazy_func(*args, **kwargs)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
+def keep_lazy_text(func):
+    """
+    A decorator for functions that accept lazy arguments and return text.
+    """
+    return keep_lazy(str)(func)
+
+
+empty = object()
+
+
+def new_method_proxy(func):
+    def inner(self, *args):
+        if (_wrapped := self._wrapped) is empty:
+            self._setup()
+            _wrapped = self._wrapped
+        return func(_wrapped, *args)
+
+    inner._mask_wrapped = False
+    return inner
+
+
+class LazyObject:
+    """
+    A wrapper for another class that can be used to delay instantiation of the
+    wrapped class.
+
+    By subclassing, you have the opportunity to intercept and alter the
+    instantiation. If you don't need to do that, use SimpleLazyObject.
+    """
+
+    # Avoid infinite recursion when tracing __init__ (#19456).
+    _wrapped = None
+
+    def __init__(self):
+        # Note: if a subclass overrides __init__(), it will likely need to
+        # override __copy__() and __deepcopy__() as well.
+        self._wrapped = empty
+
+    def __getattribute__(self, name):
+        if name == "_wrapped":
+            # Avoid recursion when getting wrapped object.
+            return super().__getattribute__(name)
+        value = super().__getattribute__(name)
+        # If attribute is a proxy method, raise an AttributeError to call
+        # __getattr__() and use the wrapped object method.
+        if not getattr(value, "_mask_wrapped", True):
+            raise AttributeError
+        return value
+
+    __getattr__ = new_method_proxy(getattr)
+
+    def __setattr__(self, name, value):
+        if name == "_wrapped":
+            # Assign to __dict__ to avoid infinite __setattr__ loops.
+            self.__dict__["_wrapped"] = value
+        else:
+            if self._wrapped is empty:
+                self._setup()
+            setattr(self._wrapped, name, value)
+
+    def __delattr__(self, name):
+        if name == "_wrapped":
+            raise TypeError("can't delete _wrapped.")
+        if self._wrapped is empty:
+            self._setup()
+        delattr(self._wrapped, name)
+
+    def _setup(self):
+        """
+        Must be implemented by subclasses to initialize the wrapped object.
+        """
+        raise NotImplementedError(
+            "subclasses of LazyObject must provide a _setup() method"
+        )
+
+    # Because we have messed with __class__ below, we confuse pickle as to what
+    # class we are pickling. We're going to have to initialize the wrapped
+    # object to successfully pickle it, so we might as well just pickle the
+    # wrapped object since they're supposed to act the same way.
+    #
+    # Unfortunately, if we try to simply act like the wrapped object, the ruse
+    # will break down when pickle gets our id(). Thus we end up with pickle
+    # thinking, in effect, that we are a distinct object from the wrapped
+    # object, but with the same __dict__. This can cause problems (see #25389).
+    #
+    # So instead, we define our own __reduce__ method and custom unpickler. We
+    # pickle the wrapped object as the unpickler's argument, so that pickle
+    # will pickle it normally, and then the unpickler simply returns its
+    # argument.
+    def __reduce__(self):
+        if self._wrapped is empty:
+            self._setup()
+        return (unpickle_lazyobject, (self._wrapped,))
+
+    def __copy__(self):
+        if self._wrapped is empty:
+            # If uninitialized, copy the wrapper. Use type(self), not
+            # self.__class__, because the latter is proxied.
+            return type(self)()
+        else:
+            # If initialized, return a copy of the wrapped object.
+            return copy.copy(self._wrapped)
+
+    def __deepcopy__(self, memo):
+        if self._wrapped is empty:
+            # We have to use type(self), not self.__class__, because the
+            # latter is proxied.
+            result = type(self)()
+            memo[id(self)] = result
+            return result
+        return copy.deepcopy(self._wrapped, memo)
+
+    __bytes__ = new_method_proxy(bytes)
+    __str__ = new_method_proxy(str)
+    __bool__ = new_method_proxy(bool)
+
+    # Introspection support
+    __dir__ = new_method_proxy(dir)
+
+    # Need to pretend to be the wrapped class, for the sake of objects that
+    # care about this (especially in equality tests)
+    __class__ = property(new_method_proxy(operator.attrgetter("__class__")))
+    __eq__ = new_method_proxy(operator.eq)
+    __lt__ = new_method_proxy(operator.lt)
+    __gt__ = new_method_proxy(operator.gt)
+    __ne__ = new_method_proxy(operator.ne)
+    __hash__ = new_method_proxy(hash)
+
+    # List/Tuple/Dictionary methods support
+    __getitem__ = new_method_proxy(operator.getitem)
+    __setitem__ = new_method_proxy(operator.setitem)
+    __delitem__ = new_method_proxy(operator.delitem)
+    __iter__ = new_method_proxy(iter)
+    __len__ = new_method_proxy(len)
+    __contains__ = new_method_proxy(operator.contains)
+
+
+def unpickle_lazyobject(wrapped):
+    """
+    Used to unpickle lazy objects. Just return its argument, which will be the
+    wrapped object.
+    """
+    return wrapped
+
+
+class SimpleLazyObject(LazyObject):
+    """
+    A lazy object initialized from any function.
+
+    Designed for compound objects of unknown type. For builtins or objects of
+    known type, use django.utils.functional.lazy.
+    """
+
+    def __init__(self, func):
+        """
+        Pass in a callable that returns the object to be wrapped.
+
+        If copies are made of the resulting SimpleLazyObject, which can happen
+        in various circumstances within Django, then you must ensure that the
+        callable can be safely run more than once and will return the same
+        value.
+        """
+        self.__dict__["_setupfunc"] = func
+        super().__init__()
+
+    def _setup(self):
+        self._wrapped = self._setupfunc()
+
+    # Return a meaningful representation of the lazy object for debugging
+    # without evaluating the wrapped object.
+    def __repr__(self):
+        if self._wrapped is empty:
+            repr_attr = self._setupfunc
+        else:
+            repr_attr = self._wrapped
+        return "<%s: %r>" % (type(self).__name__, repr_attr)
+
+    def __copy__(self):
+        if self._wrapped is empty:
+            # If uninitialized, copy the wrapper. Use SimpleLazyObject, not
+            # self.__class__, because the latter is proxied.
+            return SimpleLazyObject(self._setupfunc)
+        else:
+            # If initialized, return a copy of the wrapped object.
+            return copy.copy(self._wrapped)
+
+    def __deepcopy__(self, memo):
+        if self._wrapped is empty:
+            # We have to use SimpleLazyObject, not self.__class__, because the
+            # latter is proxied.
+            result = SimpleLazyObject(self._setupfunc)
+            memo[id(self)] = result
+            return result
+        return copy.deepcopy(self._wrapped, memo)
+
+    __add__ = new_method_proxy(operator.add)
+
+    @new_method_proxy
+    def __radd__(self, other):
+        return other + self
+
+
+def partition(predicate, values):
+    """
+    Split the values into two sets, based on the return value of the function
+    (True/False). e.g.:
+
+        >>> partition(lambda x: x > 3, range(5))
+        [0, 1, 2, 3], [4]
+    """
+    results = ([], [])
+    for item in values:
+        results[predicate(item)].append(item)
+    return results
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia.html b/docs/latest/_modules/evennia.html new file mode 100644 index 0000000000..fd226822c1 --- /dev/null +++ b/docs/latest/_modules/evennia.html @@ -0,0 +1,602 @@ + + + + + + + + evennia — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia

+"""
+Evennia MUD/MUX/MU* creation system
+
+This is the main top-level API for Evennia. You can explore the evennia library
+by accessing evennia.<subpackage> directly. From inside the game you can read
+docs of all object by viewing its `__doc__` string, such as through
+
+    py evennia.ObjectDB.__doc__
+
+For full functionality you should explore this module via a django-
+aware shell. Go to your game directory and use the command
+
+   evennia shell
+
+to launch such a shell (using python or ipython depending on your install).
+See www.evennia.com for full documentation.
+
+"""
+import evennia
+
+# docstring header
+
+DOCSTRING = """
+Evennia MU* creation system.
+
+Online manual and API docs are found at http://www.evennia.com.
+
+Flat-API shortcut names:
+{}
+"""
+
+# Delayed loading of properties
+
+# Typeclasses
+
+DefaultAccount = None
+DefaultGuest = None
+DefaultObject = None
+DefaultCharacter = None
+DefaultRoom = None
+DefaultExit = None
+DefaultChannel = None
+DefaultScript = None
+
+# Database models
+ObjectDB = None
+AccountDB = None
+ScriptDB = None
+ChannelDB = None
+Msg = None
+ServerConfig = None
+
+# Properties
+AttributeProperty = None
+TagProperty = None
+TagCategoryProperty = None
+
+# commands
+Command = None
+CmdSet = None
+default_cmds = None
+syscmdkeys = None
+InterruptCommand = None
+
+# search functions
+search_object = None
+search_script = None
+search_account = None
+search_channel = None
+search_message = None
+search_help = None
+search_tag = None
+
+# create functions
+create_object = None
+create_script = None
+create_account = None
+create_channel = None
+create_message = None
+create_help_entry = None
+
+# utilities
+settings = None
+lockfuncs = None
+inputhandler = None
+logger = None
+gametime = None
+ansi = None
+spawn = None
+managers = None
+contrib = None
+EvMenu = None
+EvTable = None
+EvForm = None
+EvEditor = None
+EvMore = None
+ANSIString = None
+signals = None
+FuncParser = None
+
+# Handlers
+SESSION_HANDLER = None
+PORTAL_SESSION_HANDLER = None
+SERVER_SESSION_HANDLER = None
+TASK_HANDLER = None
+TICKER_HANDLER = None
+MONITOR_HANDLER = None
+
+# Containers
+GLOBAL_SCRIPTS = None
+OPTION_CLASSES = None
+
+PROCESS_ID = None
+
+TWISTED_APPLICATION = None
+EVENNIA_PORTAL_SERVICE = None
+EVENNIA_SERVER_SERVICE = None
+
+
+def _create_version():
+    """
+    Helper function for building the version string
+    """
+    import os
+    from subprocess import STDOUT, CalledProcessError, check_output
+
+    version = "Unknown"
+    root = os.path.dirname(os.path.abspath(__file__))
+    try:
+        with open(os.path.join(root, "VERSION.txt"), "r") as f:
+            version = f.read().strip()
+    except IOError as err:
+        print(err)
+    try:
+        rev = (
+            check_output("git rev-parse --short HEAD", shell=True, cwd=root, stderr=STDOUT)
+            .strip()
+            .decode()
+        )
+        version = "%s (rev %s)" % (version, rev)
+    except (IOError, CalledProcessError, OSError):
+        # ignore if we cannot get to git
+        pass
+    return version
+
+
+__version__ = _create_version()
+del _create_version
+
+_LOADED = False
+
+PORTAL_MODE = False
+
+
+def _init(portal_mode=False):
+    """
+    This function is called automatically by the launcher only after
+    Evennia has fully initialized all its models. It sets up the API
+    in a safe environment where all models are available already.
+    """
+    global _LOADED
+    if _LOADED:
+        return
+    _LOADED = True
+    global DefaultAccount, DefaultObject, DefaultGuest, DefaultCharacter
+    global DefaultRoom, DefaultExit, DefaultChannel, DefaultScript
+    global ObjectDB, AccountDB, ScriptDB, ChannelDB, Msg
+    global Command, CmdSet, default_cmds, syscmdkeys, InterruptCommand
+    global search_object, search_script, search_account, search_channel
+    global search_help, search_tag, search_message
+    global create_object, create_script, create_account, create_channel
+    global create_message, create_help_entry
+    global signals
+    global settings, lockfuncs, logger, utils, gametime, ansi, spawn, managers
+    global contrib, TICKER_HANDLER, MONITOR_HANDLER, SESSION_HANDLER, PROCESS_ID
+    global TASK_HANDLER, PORTAL_SESSION_HANDLER, SERVER_SESSION_HANDLER
+    global GLOBAL_SCRIPTS, OPTION_CLASSES, EVENNIA_PORTAL_SERVICE, EVENNIA_SERVER_SERVICE, TWISTED_APPLICATION
+    global EvMenu, EvTable, EvForm, EvMore, EvEditor
+    global ANSIString, FuncParser
+    global AttributeProperty, TagProperty, TagCategoryProperty, ServerConfig
+    global PORTAL_MODE
+    PORTAL_MODE = portal_mode
+
+    # Parent typeclasses
+    # utilities
+    import os
+
+    from django.conf import settings
+
+    from . import contrib
+    from .accounts.accounts import DefaultAccount, DefaultGuest
+    from .accounts.models import AccountDB
+    from .commands.cmdset import CmdSet
+    from .commands.command import Command, InterruptCommand
+    from .comms.comms import DefaultChannel
+    from .comms.models import ChannelDB, Msg
+    from .locks import lockfuncs
+    from .objects.models import ObjectDB
+    from .objects.objects import (
+        DefaultCharacter,
+        DefaultExit,
+        DefaultObject,
+        DefaultRoom,
+    )
+    from .prototypes.spawner import spawn
+    from .scripts.models import ScriptDB
+    from .scripts.monitorhandler import MONITOR_HANDLER
+    from .scripts.scripts import DefaultScript
+    from .scripts.taskhandler import TASK_HANDLER
+    from .scripts.tickerhandler import TICKER_HANDLER
+    from .server import signals
+    from .server.models import ServerConfig
+    from .typeclasses.attributes import AttributeProperty
+    from .typeclasses.tags import TagCategoryProperty, TagProperty
+    from .utils import ansi, class_from_module, gametime, logger
+    from .utils.ansi import ANSIString
+
+    if not PORTAL_MODE:
+        # containers
+        from .utils.containers import GLOBAL_SCRIPTS, OPTION_CLASSES
+
+    # create functions
+    from .utils.create import (
+        create_account,
+        create_channel,
+        create_help_entry,
+        create_message,
+        create_object,
+        create_script,
+    )
+    from .utils.eveditor import EvEditor
+    from .utils.evform import EvForm
+    from .utils.evmenu import EvMenu
+    from .utils.evmore import EvMore
+    from .utils.evtable import EvTable
+    from .utils.funcparser import FuncParser
+
+    # search functions
+    from .utils.search import (
+        search_account,
+        search_channel,
+        search_help,
+        search_message,
+        search_object,
+        search_script,
+        search_tag,
+    )
+    from .utils.utils import class_from_module
+
+    PROCESS_ID = os.getpid()
+
+    from twisted.application.service import Application
+
+    TWISTED_APPLICATION = Application("Evennia")
+
+    _evennia_service_class = None
+
+    if portal_mode:
+        # Set up the PortalSessionHandler
+        from evennia.server.portal import portalsessionhandler
+
+        portal_sess_handler_class = class_from_module(settings.PORTAL_SESSION_HANDLER_CLASS)
+        portalsessionhandler.PORTAL_SESSIONS = portal_sess_handler_class()
+        SESSION_HANDLER = portalsessionhandler.PORTAL_SESSIONS
+        evennia.PORTAL_SESSION_HANDLER = evennia.SESSION_HANDLER
+        _evennia_service_class = class_from_module(settings.EVENNIA_PORTAL_SERVICE_CLASS)
+        EVENNIA_PORTAL_SERVICE = _evennia_service_class()
+        EVENNIA_PORTAL_SERVICE.setServiceParent(TWISTED_APPLICATION)
+
+        from django.db import connection
+
+        # we don't need a connection to the database so close it right away
+        try:
+            connection.close()
+        except Exception:
+            pass
+
+    else:
+        # Create the ServerSesssionHandler
+        from evennia.server import sessionhandler
+
+        sess_handler_class = class_from_module(settings.SERVER_SESSION_HANDLER_CLASS)
+        sessionhandler.SESSIONS = sess_handler_class()
+        sessionhandler.SESSION_HANDLER = sessionhandler.SESSIONS
+        SESSION_HANDLER = sessionhandler.SESSIONS
+        SERVER_SESSION_HANDLER = SESSION_HANDLER
+        _evennia_service_class = class_from_module(settings.EVENNIA_SERVER_SERVICE_CLASS)
+        EVENNIA_SERVER_SERVICE = _evennia_service_class()
+        EVENNIA_SERVER_SERVICE.setServiceParent(TWISTED_APPLICATION)
+
+    # API containers
+
+    class _EvContainer(object):
+        """
+        Parent for other containers
+
+        """
+
+        def _help(self):
+            "Returns list of contents"
+            names = [name for name in self.__class__.__dict__ if not name.startswith("_")]
+            names += [name for name in self.__dict__ if not name.startswith("_")]
+            print(self.__doc__ + "-" * 60 + "\n" + ", ".join(names))
+
+        help = property(_help)
+
+    class DBmanagers(_EvContainer):
+        """
+        Links to instantiated Django database managers. These are used
+        to perform more advanced custom database queries than the standard
+        search functions allow.
+
+        helpentries - HelpEntry.objects
+        accounts - AccountDB.objects
+        scripts - ScriptDB.objects
+        msgs    - Msg.objects
+        channels - Channel.objects
+        objects - ObjectDB.objects
+        serverconfigs - ServerConfig.objects
+        tags - Tags.objects
+        attributes - Attributes.objects
+
+        """
+
+        from .accounts.models import AccountDB
+        from .comms.models import ChannelDB, Msg
+        from .help.models import HelpEntry
+        from .objects.models import ObjectDB
+        from .scripts.models import ScriptDB
+        from .server.models import ServerConfig
+        from .typeclasses.attributes import Attribute
+        from .typeclasses.tags import Tag
+
+        # create container's properties
+        helpentries = HelpEntry.objects
+        accounts = AccountDB.objects
+        scripts = ScriptDB.objects
+        msgs = Msg.objects
+        channels = ChannelDB.objects
+        objects = ObjectDB.objects
+        serverconfigs = ServerConfig.objects
+        attributes = Attribute.objects
+        tags = Tag.objects
+        # remove these so they are not visible as properties
+        del HelpEntry, AccountDB, ScriptDB, Msg, ChannelDB
+        # del ExternalChannelConnection
+        del ObjectDB, ServerConfig, Tag, Attribute
+
+    managers = DBmanagers()
+    del DBmanagers
+
+    class DefaultCmds(_EvContainer):
+        """
+        This container holds direct shortcuts to all default commands in Evennia.
+
+        To access in code, do 'from evennia import default_cmds' then
+        access the properties on the imported default_cmds object.
+
+        """
+
+        from .commands.default.cmdset_account import AccountCmdSet
+        from .commands.default.cmdset_character import CharacterCmdSet
+        from .commands.default.cmdset_session import SessionCmdSet
+        from .commands.default.cmdset_unloggedin import UnloggedinCmdSet
+        from .commands.default.muxcommand import MuxAccountCommand, MuxCommand
+
+        def __init__(self):
+            "populate the object with commands"
+
+            def add_cmds(module):
+                "helper method for populating this object with cmds"
+                from evennia.utils import utils
+
+                cmdlist = utils.variable_from_module(module, module.__all__)
+                self.__dict__.update(dict([(c.__name__, c) for c in cmdlist]))
+
+            from .commands.default import (
+                account,
+                admin,
+                batchprocess,
+                building,
+                comms,
+                general,
+                help,
+                system,
+                unloggedin,
+            )
+
+            add_cmds(admin)
+            add_cmds(building)
+            add_cmds(batchprocess)
+            add_cmds(building)
+            add_cmds(comms)
+            add_cmds(general)
+            add_cmds(account)
+            add_cmds(help)
+            add_cmds(system)
+            add_cmds(unloggedin)
+
+    default_cmds = DefaultCmds()
+    del DefaultCmds
+
+    class SystemCmds(_EvContainer):
+        """
+        Creating commands with keys set to these constants will make
+        them system commands called as a replacement by the parser when
+        special situations occur. If not defined, the hard-coded
+        responses in the server are used.
+
+        CMD_NOINPUT - no input was given on command line
+        CMD_NOMATCH - no valid command key was found
+        CMD_MULTIMATCH - multiple command matches were found
+        CMD_LOGINSTART - this command will be called as the very
+                         first command when an account connects to
+                         the server.
+
+        To access in code, do 'from evennia import syscmdkeys' then
+        access the properties on the imported syscmdkeys object.
+
+        """
+
+        from .commands import cmdhandler
+
+        CMD_NOINPUT = cmdhandler.CMD_NOINPUT
+        CMD_NOMATCH = cmdhandler.CMD_NOMATCH
+        CMD_MULTIMATCH = cmdhandler.CMD_MULTIMATCH
+        CMD_LOGINSTART = cmdhandler.CMD_LOGINSTART
+        del cmdhandler
+
+    syscmdkeys = SystemCmds()
+    del SystemCmds
+    del _EvContainer
+
+
+
[docs]def set_trace(term_size=(140, 80), debugger="auto"): + """ + Helper function for running a debugger inside the Evennia event loop. + + Args: + term_size (tuple, optional): Only used for Pudb and defines the size of the terminal + (width, height) in number of characters. + debugger (str, optional): One of 'auto', 'pdb' or 'pudb'. Pdb is the standard debugger. Pudb + is an external package with a different, more 'graphical', ncurses-based UI. With + 'auto', will use pudb if possible, otherwise fall back to pdb. Pudb is available through + `pip install pudb`. + + Notes: + To use: + + 1) add this to a line to act as a breakpoint for entering the debugger: + + from evennia import set_trace; set_trace() + + 2) restart evennia in interactive mode + + evennia istart + + 3) debugger will appear in the interactive terminal when breakpoint is reached. Exit + with 'q', remove the break line and restart server when finished. + + """ + import sys + + dbg = None + + if debugger in ("auto", "pudb"): + try: + from pudb import debugger + + dbg = debugger.Debugger(stdout=sys.__stdout__, term_size=term_size) + except ImportError: + if debugger == "pudb": + raise + pass + + if not dbg: + import pdb + + dbg = pdb.Pdb(stdout=sys.__stdout__) + + try: + # Start debugger, forcing it up one stack frame (otherwise `set_trace` + # will start debugger this point, not the actual code location) + dbg.set_trace(sys._getframe().f_back) + except Exception: + # Stopped at breakpoint. Press 'n' to continue into the code. + dbg.set_trace()
+ + +# initialize the doc string +global __doc__ +__doc__ = DOCSTRING.format( + "\n- " + + "\n- ".join( + f"evennia.{key}" + for key in sorted(globals()) + if not key.startswith("_") and key not in ("DOCSTRING",) + ) +) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/accounts/accounts.html b/docs/latest/_modules/evennia/accounts/accounts.html new file mode 100644 index 0000000000..e140ff0263 --- /dev/null +++ b/docs/latest/_modules/evennia/accounts/accounts.html @@ -0,0 +1,2129 @@ + + + + + + + + evennia.accounts.accounts — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.accounts.accounts

+"""
+Typeclass for Account objects.
+
+Note that this object is primarily intended to
+store OOC information, not game info! This
+object represents the actual user (not their
+character) and has NO actual presence in the
+game world (this is handled by the associated
+character object, so you should customize that
+instead for most things).
+
+"""
+import re
+import time
+import typing
+from random import getrandbits
+
+import evennia
+from django.conf import settings
+from django.contrib.auth import authenticate, password_validation
+from django.core.exceptions import ImproperlyConfigured, ValidationError
+from django.utils import timezone
+from django.utils.module_loading import import_string
+from django.utils.translation import gettext as _
+from evennia.accounts.manager import AccountManager
+from evennia.accounts.models import AccountDB
+from evennia.commands.cmdsethandler import CmdSetHandler
+from evennia.comms.models import ChannelDB
+from evennia.objects.models import ObjectDB
+from evennia.scripts.scripthandler import ScriptHandler
+from evennia.server.models import ServerConfig
+from evennia.server.signals import (
+    SIGNAL_ACCOUNT_POST_CREATE,
+    SIGNAL_ACCOUNT_POST_LOGIN_FAIL,
+    SIGNAL_OBJECT_POST_PUPPET,
+    SIGNAL_OBJECT_POST_UNPUPPET,
+)
+from evennia.server.throttle import Throttle
+from evennia.typeclasses.attributes import ModelAttributeBackend, NickHandler
+from evennia.typeclasses.models import TypeclassBase
+from evennia.utils import class_from_module, create, logger
+from evennia.utils.optionhandler import OptionHandler
+from evennia.utils.utils import is_iter, lazy_property, make_iter, to_str, variable_from_module
+
+__all__ = ("DefaultAccount", "DefaultGuest")
+
+_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1))
+_MULTISESSION_MODE = settings.MULTISESSION_MODE
+_AUTO_CREATE_CHARACTER_WITH_ACCOUNT = settings.AUTO_CREATE_CHARACTER_WITH_ACCOUNT
+_AUTO_PUPPET_ON_LOGIN = settings.AUTO_PUPPET_ON_LOGIN
+_MAX_NR_SIMULTANEOUS_PUPPETS = settings.MAX_NR_SIMULTANEOUS_PUPPETS
+_MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS
+_CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
+_MUDINFO_CHANNEL = None
+_CONNECT_CHANNEL = None
+_CMDHANDLER = None
+
+
+# Create throttles for too many account-creations and login attempts
+CREATION_THROTTLE = Throttle(
+    name="creation",
+    limit=settings.CREATION_THROTTLE_LIMIT,
+    timeout=settings.CREATION_THROTTLE_TIMEOUT,
+)
+LOGIN_THROTTLE = Throttle(
+    name="login", limit=settings.LOGIN_THROTTLE_LIMIT, timeout=settings.LOGIN_THROTTLE_TIMEOUT
+)
+
+
+class AccountSessionHandler(object):
+    """
+    Manages the session(s) attached to an account.
+
+    """
+
+    def __init__(self, account):
+        """
+        Initializes the handler.
+
+        Args:
+            account (Account): The Account on which this handler is defined.
+
+        """
+        self.account = account
+
+    def get(self, sessid=None):
+        """
+        Get the sessions linked to this object.
+
+        Args:
+            sessid (int, optional): Specify a given session by
+                session id.
+
+        Returns:
+            sessions (list): A list of Session objects. If `sessid`
+                is given, this is a list with one (or zero) elements.
+
+        """
+        if sessid:
+            return make_iter(evennia.SESSION_HANDLER.session_from_account(self.account, sessid))
+        else:
+            return evennia.SESSION_HANDLER.sessions_from_account(self.account)
+
+    def all(self):
+        """
+        Alias to get(), returning all sessions.
+
+        Returns:
+            sessions (list): All sessions.
+
+        """
+        return self.get()
+
+    def count(self):
+        """
+        Get amount of sessions connected.
+
+        Returns:
+            sesslen (int): Number of sessions handled.
+
+        """
+        return len(self.get())
+
+
+class CharactersHandler:
+    """
+    A simple Handler that lives on DefaultAccount as .characters via @lazy_property used to
+    wrap access to .db._playable_characters.
+    """
+
+    def __init__(self, owner: "DefaultAccount"):
+        """
+        Create the CharactersHandler.
+
+        Args:
+            owner: The Account that owns this handler.
+        """
+        self.owner = owner
+        self._ensure_playable_characters()
+        self._clean()
+
+    def _ensure_playable_characters(self):
+        if self.owner.db._playable_characters is None:
+            self.owner.db._playable_characters = []
+
+    def _clean(self):
+        # Remove all instances of None from the list.
+        self.owner.db._playable_characters = [x for x in self.owner.db._playable_characters if x]
+
+    def add(self, character: "DefaultCharacter"):
+        """
+        Add a character to this account's list of playable characters.
+
+        Args:
+            character (DefaultCharacter): The character to add.
+        """
+        self._clean()
+        if character not in self.owner.db._playable_characters:
+            self.owner.db._playable_characters.append(character)
+            self.owner.at_post_add_character(character)
+
+    def remove(self, character: "DefaultCharacter"):
+        """
+        Remove a character from this account's list of playable characters.
+
+        Args:
+            character (DefaultCharacter): The character to remove.
+        """
+        self._clean()
+        if character in self.owner.db._playable_characters:
+            self.owner.db._playable_characters.remove(character)
+            self.owner.at_post_remove_character(character)
+
+    def all(self) -> list["DefaultCharacter"]:
+        """
+        Get all playable characters.
+
+        Returns:
+            list[DefaultCharacter]: All playable characters.
+        """
+        self._clean()
+        return list(self.owner.db._playable_characters)
+
+    def count(self) -> int:
+        """
+        Get the number of playable characters.
+
+        Returns:
+            int: The number of playable characters.
+        """
+        return len(self.all())
+
+    __len__ = count
+
+    def __iter__(self):
+        return iter(self.all())
+
+
+
[docs]class DefaultAccount(AccountDB, metaclass=TypeclassBase): + """ + This is the base Typeclass for all Accounts. Accounts represent + the person playing the game and tracks account info, password + etc. They are OOC entities without presence in-game. An Account + can connect to a Character Object in order to "enter" the + game. + + Account Typeclass API: + + * Available properties (only available on initiated typeclass objects) + + - key (string) - name of account + - name (string)- wrapper for user.username + - aliases (list of strings) - aliases to the object. Will be saved to + database as AliasDB entries but returned as strings. + - dbref (int, read-only) - unique #id-number. Also "id" can be used. + - date_created (string) - time stamp of object creation + - permissions (list of strings) - list of permission strings + - user (User, read-only) - django User authorization object + - obj (Object) - game object controlled by account. 'character' can also + be used. + - sessions (list of Sessions) - sessions connected to this account + - is_superuser (bool, read-only) - if the connected user is a superuser + + * Handlers + + - locks - lock-handler: use locks.add() to add new lock strings + - db - attribute-handler: store/retrieve database attributes on this + self.db.myattr=val, val=self.db.myattr + - ndb - non-persistent attribute handler: same as db but does not + create a database entry when storing data + - scripts - script-handler. Add new scripts to object with scripts.add() + - cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object + - nicks - nick-handler. New nicks with nicks.add(). + + * Helper methods + + - msg(text=None, from_obj=None, session=None, options=None, **kwargs) + - execute_cmd(raw_string) + - search(ostring, global_search=False, attribute_name=None, + use_nicks=False, location=None, + ignore_errors=False, account=False) + - is_typeclass(typeclass, exact=False) + - swap_typeclass(new_typeclass, clean_attributes=False, no_default=True) + - access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False) + - check_permstring(permstring) + + * Hook methods + + basetype_setup() + at_account_creation() + + > note that the following hooks are also found on Objects and are + usually handled on the character level: + + - at_init() + - at_access() + - at_cmdset_get(**kwargs) + - at_first_login() + - at_post_login(session=None) + - at_disconnect() + - at_message_receive() + - at_message_send() + - at_server_reload() + - at_server_shutdown() + + """ + + # Determines which order command sets begin to be assembled from. + # Accounts are usually second. + cmdset_provider_order = 50 + cmdset_provider_error_order = 0 + cmdset_provider_type = "account" + + objects = AccountManager() + + # Used by account.create_character() to choose default typeclass for characters. + default_character_typeclass = settings.BASE_CHARACTER_TYPECLASS + + lockstring = ( + "examine:perm(Admin);edit:perm(Admin);" + "delete:perm(Admin);boot:perm(Admin);msg:all();" + "noidletimeout:perm(Builder) or perm(noidletimeout)" + ) + + # properties +
[docs] @lazy_property + def cmdset(self): + return CmdSetHandler(self, True)
+ +
[docs] @lazy_property + def scripts(self): + return ScriptHandler(self)
+ +
[docs] @lazy_property + def nicks(self): + return NickHandler(self, ModelAttributeBackend)
+ +
[docs] @lazy_property + def sessions(self): + return AccountSessionHandler(self)
+ +
[docs] @lazy_property + def options(self): + return OptionHandler( + self, + options_dict=settings.OPTIONS_ACCOUNT_DEFAULT, + savefunc=self.attributes.add, + loadfunc=self.attributes.get, + save_kwargs={"category": "option"}, + load_kwargs={"category": "option"}, + )
+ +
[docs] @lazy_property + def characters(self): + return CharactersHandler(self)
+ +
[docs] def get_cmdset_providers(self) -> dict[str, "CmdSetProvider"]: + """ + Overrideable method which returns a dictionary of every kind of object which + has a cmdsethandler linked to this Account, and should participate in cmdset + merging. + + Accounts have no way of being aware of anything besides themselves, unfortunately. + + Returns: + dict[str, CmdSetProvider]: The CmdSetProviders linked to this Object. + """ + return {"account": self}
+ +
[docs] def at_post_add_character(self, character: "DefaultCharacter"): + """ + Called after a character is added to this account's list of playable characters. + + Use it to easily implement custom logic when a character is added to an account. + + Args: + character (DefaultCharacter): The character that was added. + """ + pass
+ +
[docs] def at_post_remove_character(self, character: "DefaultCharacter"): + """ + Called after a character is removed from this account's list of playable characters. + + Use it to easily implement custom logic when a character is removed from an account. + + Args: + character (DefaultCharacter): The character that was removed. + """ + pass
+ +
[docs] def uses_screenreader(self, session=None): + """ + Shortcut to determine if a session uses a screenreader. If no session given, + will return true if any of the sessions use a screenreader. + + Args: + session (Session, optional): The session to check for screen reader. + + """ + if session: + return bool(session.protocol_flags.get("SCREENREADER", False)) + else: + return any( + session.protocol_flags.get("SCREENREADER") for session in self.sessions.all() + )
+ +
[docs] def get_display_name(self, looker, **kwargs): + """ + This is used by channels and other OOC communications methods to give a + custom display of this account's input. + + Args: + looker (Account): The one that will see this name. + **kwargs: Unused by default, can be used to pass game-specific data. + + Returns: + str: The name, possibly modified. + + """ + return f"|c{self.key}|n"
+ + # session-related methods + +
[docs] def disconnect_session_from_account(self, session, reason=None): + """ + Access method for disconnecting a given session from the + account (connection happens automatically in the + sessionhandler) + + Args: + session (Session): Session to disconnect. + reason (str, optional): Eventual reason for the disconnect. + + """ + evennia.SESSION_HANDLER.disconnect(session, reason)
+ + # puppeting operations + +
[docs] def puppet_object(self, session, obj): + """ + Use the given session to control (puppet) the given object (usually + a Character type). + + Args: + session (Session): session to use for puppeting + obj (Object): the object to start puppeting + + Raises: + RuntimeError: If puppeting is not possible, the + `exception.msg` will contain the reason. + + + """ + # safety checks + if not obj: + raise RuntimeError("Object not found") + if not session: + raise RuntimeError("Session not found") + if self.get_puppet(session) == obj: + # already puppeting this object + self.msg("You are already puppeting this object.") + return + if not obj.access(self, "puppet"): + # no access + self.msg(f"You don't have permission to puppet '{obj.key}'.") + return + if obj.account: + # object already puppeted + if obj.account == self: + if obj.sessions.count(): + # we may take over another of our sessions + # output messages to the affected sessions + if _MULTISESSION_MODE in (1, 3): + txt1 = f"Sharing |c{obj.name}|n with another of your sessions." + txt2 = f"|c{obj.name}|n|G is now shared from another of your sessions.|n" + self.msg(txt1, session=session) + self.msg(txt2, session=obj.sessions.all()) + else: + txt1 = f"Taking over |c{obj.name}|n from another of your sessions." + txt2 = f"|c{obj.name}|n|R is now acted from another of your sessions.|n" + self.msg(txt1, session=session) + self.msg(txt2, session=obj.sessions.all()) + self.unpuppet_object(obj.sessions.get()) + elif obj.account.is_connected: + # controlled by another account + self.msg(_("|c{key}|R is already puppeted by another Account.").format(key=obj.key)) + return + + if session.puppet: + # cleanly unpuppet eventual previous object puppeted by this session + self.unpuppet_object(session) + # if we get to this point the character is ready to puppet or it + # was left with a lingering account/session reference from an unclean + # server kill or similar + + # check so we are not puppeting too much already + if _MAX_NR_SIMULTANEOUS_PUPPETS is not None: + already_puppeted = self.get_all_puppets() + if ( + not self.is_superuser + and not self.check_permstring("Developer") + and obj not in already_puppeted + and len(self.get_all_puppets()) >= _MAX_NR_SIMULTANEOUS_PUPPETS + ): + self.msg( + _(f"You cannot control any more puppets (max {_MAX_NR_SIMULTANEOUS_PUPPETS})") + ) + return + + # do the puppeting + obj.at_pre_puppet(self, session=session) + # used to track in case of crash so we can clean up later + obj.tags.add("puppeted", category="account") + + # do the connection + obj.sessions.add(session) + obj.account = self + session.puid = obj.id + session.puppet = obj + + # re-cache locks to make sure superuser bypass is updated + obj.locks.cache_lock_bypass(obj) + # final hook + obj.at_post_puppet() + SIGNAL_OBJECT_POST_PUPPET.send(sender=obj, account=self, session=session)
+ +
[docs] def unpuppet_object(self, session): + """ + Disengage control over an object. + + Args: + session (Session or list): The session or a list of + sessions to disengage from their puppets. + + Raises: + RuntimeError With message about error. + + """ + for session in make_iter(session): + obj = session.puppet + if obj: + # do the disconnect, but only if we are the last session to puppet + obj.at_pre_unpuppet() + obj.sessions.remove(session) + if not obj.sessions.count(): + del obj.account + obj.at_post_unpuppet(self, session=session) + obj.tags.remove("puppeted", category="account") + SIGNAL_OBJECT_POST_UNPUPPET.send(sender=obj, session=session, account=self) + # Just to be sure we're always clear. + session.puppet = None + session.puid = None
+ +
[docs] def unpuppet_all(self): + """ + Disconnect all puppets. This is called by server before a + reset/shutdown. + """ + self.unpuppet_object(self.sessions.all())
+ +
[docs] def get_puppet(self, session): + """ + Get an object puppeted by this session through this account. This is + the main method for retrieving the puppeted object from the + account's end. + + Args: + session (Session): Find puppeted object based on this session + + Returns: + puppet (Object): The matching puppeted object, if any. + + """ + return session.puppet if session else None
+ +
[docs] def get_all_puppets(self): + """ + Get all currently puppeted objects. + + Returns: + puppets (list): All puppeted objects currently controlled + by this Account. + + """ + return list(set(session.puppet for session in self.sessions.all() if session.puppet))
+ + def __get_single_puppet(self): + """ + This is a legacy convenience link for use with `MULTISESSION_MODE`. + + Returns: + puppets (Object or list): Users of `MULTISESSION_MODE` 0 or 1 will + always get the first puppet back. Users of higher `MULTISESSION_MODE`s will + get a list of all puppeted objects. + + """ + puppets = self.get_all_puppets() + if _MULTISESSION_MODE in (0, 1): + return puppets and puppets[0] or None + return puppets + + character = property(__get_single_puppet) + puppet = property(__get_single_puppet) + + # utility methods +
[docs] @classmethod + def is_banned(cls, **kwargs): + """ + Checks if a given username or IP is banned. + + Keyword Args: + ip (str, optional): IP address. + username (str, optional): Username. + + Returns: + is_banned (bool): Whether either is banned or not. + + """ + + ip = kwargs.get("ip", "") + if isinstance(ip, (tuple, list)): + ip = ip[0] + ip = ip.strip() + username = kwargs.get("username", "").lower().strip() + + # Check IP and/or name bans + bans = ServerConfig.objects.conf("server_bans") + if bans and ( + any(tup[0] == username for tup in bans if username) + or any(tup[2].match(ip) for tup in bans if ip and tup[2]) + ): + return True + + return False
+ +
[docs] @classmethod + def get_username_validators( + cls, validator_config=getattr(settings, "AUTH_USERNAME_VALIDATORS", []) + ): + """ + Retrieves and instantiates validators for usernames. + + Args: + validator_config (list): List of dicts comprising the battery of + validators to apply to a username. + + Returns: + validators (list): List of instantiated Validator objects. + """ + objs = [] + for validator in validator_config: + try: + klass = import_string(validator["NAME"]) + except ImportError: + msg = ( + f"The module in NAME could not be imported: {validator['NAME']}. " + "Check your AUTH_USERNAME_VALIDATORS setting." + ) + raise ImproperlyConfigured(msg) + objs.append(klass(**validator.get("OPTIONS", {}))) + return objs
+ +
[docs] @classmethod + def authenticate(cls, username, password, ip="", **kwargs): + """ + Checks the given username/password against the database to see if the + credentials are valid. + + Note that this simply checks credentials and returns a valid reference + to the user-- it does not log them in! + + To finish the job: + After calling this from a Command, associate the account with a Session: + - session.sessionhandler.login(session, account) + + ...or after calling this from a View, associate it with an HttpRequest: + - django.contrib.auth.login(account, request) + + Args: + username (str): Username of account + password (str): Password of account + ip (str, optional): IP address of client + + Keyword Args: + session (Session, optional): Session requesting authentication + + Returns: + account (DefaultAccount, None): Account whose credentials were + provided if not banned. + errors (list): Error messages of any failures. + + """ + errors = [] + if ip: + ip = str(ip) + + # See if authentication is currently being throttled + if ip and LOGIN_THROTTLE.check(ip): + errors.append(_("Too many login failures; please try again in a few minutes.")) + + # With throttle active, do not log continued hits-- it is a + # waste of storage and can be abused to make your logs harder to + # read and/or fill up your disk. + return None, errors + + # Check IP and/or name bans + banned = cls.is_banned(username=username, ip=ip) + if banned: + # this is a banned IP or name! + errors.append( + _( + "|rYou have been banned and cannot continue from here." + "\nIf you feel this ban is in error, please email an admin.|x" + ) + ) + logger.log_sec(f"Authentication Denied (Banned): {username} (IP: {ip}).") + LOGIN_THROTTLE.update(ip, "Too many sightings of banned artifact.") + return None, errors + + # Authenticate and get Account object + account = authenticate(username=username, password=password) + if not account: + # User-facing message + errors.append(_("Username and/or password is incorrect.")) + + # Log auth failures while throttle is inactive + logger.log_sec(f"Authentication Failure: {username} (IP: {ip}).") + + # Update throttle + if ip: + LOGIN_THROTTLE.update(ip, _("Too many authentication failures.")) + + # Try to call post-failure hook + session = kwargs.get("session", None) + if session: + account = AccountDB.objects.get_account_from_name(username) + if account: + SIGNAL_ACCOUNT_POST_LOGIN_FAIL.send(sender=account, session=session) + account.at_failed_login(session) + + return None, errors + + # Account successfully authenticated + logger.log_sec(f"Authentication Success: {account} (IP: {ip}).") + return account, errors
+ +
[docs] @classmethod + def normalize_username(cls, username): + """ + Django: Applies NFKC Unicode normalization to usernames so that visually + identical characters with different Unicode code points are considered + identical. + + (This deals with the Turkish "i" problem and similar + annoyances. Only relevant if you go out of your way to allow Unicode + usernames though-- Evennia accepts ASCII by default.) + + In this case we're simply piggybacking on this feature to apply + additional normalization per Evennia's standards. + """ + username = super(DefaultAccount, cls).normalize_username(username) + + # strip excessive spaces in accountname + username = re.sub(r"\s+", " ", username).strip() + + return username
+ +
[docs] @classmethod + def validate_username(cls, username): + """ + Checks the given username against the username validator associated with + Account objects, and also checks the database to make sure it is unique. + + Args: + username (str): Username to validate + + Returns: + valid (bool): Whether or not the password passed validation + errors (list): Error messages of any failures + + """ + valid = [] + errors = [] + + # Make sure we're at least using the default validator + validators = cls.get_username_validators() + if not validators: + validators = [cls.username_validator] + + # Try username against all enabled validators + for validator in validators: + try: + valid.append(not validator(username)) + except ValidationError as e: + valid.append(False) + errors.extend(e.messages) + + # Disqualify if any check failed + if False in valid: + valid = False + else: + valid = True + + return valid, errors
+ +
[docs] @classmethod + def validate_password(cls, password, account=None): + """ + Checks the given password against the list of Django validators enabled + in the server.conf file. + + Args: + password (str): Password to validate + + Keyword Args: + account (DefaultAccount, optional): Account object to validate the + password for. Optional, but Django includes some validators to + do things like making sure users aren't setting passwords to the + same value as their username. If left blank, these user-specific + checks are skipped. + + Returns: + valid (bool): Whether or not the password passed validation + error (ValidationError, None): Any validation error(s) raised. Multiple + errors can be nested within a single object. + + """ + valid = False + error = None + + # Validation returns None on success; invert it and return a more sensible bool + try: + valid = not password_validation.validate_password(password, user=account) + except ValidationError as e: + error = e + + return valid, error
+ +
[docs] def set_password(self, password, **kwargs): + """ + Applies the given password to the account. Logs and triggers the `at_password_change` hook. + + Args: + password (str): Password to set. + + Notes: + This is called by Django also when logging in; it should not be mixed up with + validation, since that would mean old passwords in the database (pre validation checks) + could get invalidated. + + """ + super().set_password(password) + logger.log_sec(f"Password successfully changed for {self}.") + self.at_password_change()
+ +
[docs] def get_character_slots(self) -> typing.Optional[int]: + """ + Returns the number of character slots this account has, or + None if there are no limits. + + By default, that's settings.MAX_NR_CHARACTERS but this makes it easy to override. + Maybe for your game, players can be rewarded with more slots, somehow. + + Returns: + int (optional): The number of character slots this account has, or None + if there are no limits. + """ + return settings.MAX_NR_CHARACTERS
+ +
[docs] def get_available_character_slots(self) -> typing.Optional[int]: + """ + Returns the number of character slots this account has available, or None if + there are no limits. + + Returns: + int (optional): The number of open character slots this account has, or None + if there are no limits. + """ + if (slots := self.get_character_slots()) is None: + return None + return max(0, slots - len(self.characters))
+ +
[docs] def check_available_slots(self, **kwargs) -> typing.Optional[str]: + """ + Helper method used to determine if an account can create additional characters using + the character slot system. + + Returns: + str (optional): An error message regarding the status of slots. If present, this + will halt character creation. If not, character creation can proceed. + """ + if (slots := self.get_available_character_slots()) is not None: + if slots <= 0: + if not (self.is_superuser or self.check_permstring("Developer")): + plural = "" if (max_slots := self.get_character_slots()) == 1 else "s" + return f"You may only have a maximum of {max_slots} character{plural}."
+ +
[docs] def create_character(self, *args, **kwargs): + """ + Create a character linked to this account. + + Args: + key (str, optional): If not given, use the same name as the account. + typeclass (str, optional): Typeclass to use for this character. If + not given, use self.default_character_class. + permissions (list, optional): If not given, use the account's permissions. + ip (str, optional): The client IP creating this character. Will fall back to the + one stored for the account if not given. + kwargs (any): Other kwargs will be used in the create_call. + Returns: + Object: A new character of the `character_typeclass` type. None on an error. + list or None: A list of errors, or None. + + """ + # check character slot usage. + if slot_check := self.check_available_slots(): + return None, [slot_check] + + # parse inputs + character_key = kwargs.pop("key", self.key) + character_ip = kwargs.pop("ip", self.db.creator_ip) + character_permissions = kwargs.pop("permissions", self.permissions) + + # Load the appropriate Character class + character_typeclass = kwargs.pop("typeclass", self.default_character_typeclass) + Character = class_from_module(character_typeclass) + + if "location" not in kwargs: + kwargs["location"] = ObjectDB.objects.get_id(settings.START_LOCATION) + + # Create the character + character, errs = Character.create( + character_key, + self, + ip=character_ip, + typeclass=character_typeclass, + permissions=character_permissions, + **kwargs, + ) + if character: + self.at_post_create_character(character, ip=character_ip) + + return character, errs
+ +
[docs] def at_post_create_character(self, character, **kwargs): + """ + An overloadable hook method that allows for further customization of newly created characters. + """ + if character not in self.characters: + self.characters.add(character) + + # We need to set this to have @ic auto-connect to this character + if len(self.characters) == 1: + self.db._last_puppet = character + + character.locks.add( + f"puppet:id({character.id}) or pid({self.id}) or perm(Developer) or" + f" pperm(Developer);delete:id({self.id}) or perm(Admin)" + ) + + logger.log_sec( + f"Character Created: {character} (Caller: {self}, IP: {kwargs.get('ip', None)})." + )
+ +
[docs] @classmethod + def create(cls, *args, **kwargs): + """ + Creates an Account (or Account/Character pair for MULTISESSION_MODE<2) + with default (or overridden) permissions and having joined them to the + appropriate default channels. + + Keyword Args: + username (str): Username of Account owner + password (str): Password of Account owner + email (str, optional): Email address of Account owner + ip (str, optional): IP address of requesting connection + guest (bool, optional): Whether or not this is to be a Guest account + + permissions (str, optional): Default permissions for the Account + typeclass (str, optional): Typeclass to use for new Account + character_typeclass (str, optional): Typeclass to use for new char + when applicable. + + Returns: + account (Account): Account if successfully created; None if not + errors (list): List of error messages in string form + + """ + + account = None + errors = [] + + username = kwargs.get("username") + password = kwargs.get("password") + email = kwargs.get("email", "").strip() + guest = kwargs.get("guest", False) + + permissions = kwargs.get("permissions", settings.PERMISSION_ACCOUNT_DEFAULT) + typeclass = kwargs.get("typeclass", cls) + + ip = kwargs.get("ip", "") + if isinstance(ip, (tuple, list)): + ip = ip[0] + + if ip and CREATION_THROTTLE.check(ip): + errors.append( + _("You are creating too many accounts. Please log into an existing account.") + ) + return None, errors + + # Normalize username + username = cls.normalize_username(username) + + # Validate username + if not guest: + valid, errs = cls.validate_username(username) + if not valid: + # this echoes the restrictions made by django's auth + # module (except not allowing spaces, for convenience of + # logging in). + errors.extend(errs) + return None, errors + + # Validate password + # Have to create a dummy Account object to check username similarity + valid, errs = cls.validate_password(password, account=cls(username=username)) + if not valid: + errors.extend(errs) + return None, errors + + # Check IP and/or name bans + banned = cls.is_banned(username=username, ip=ip) + if banned: + # this is a banned IP or name! + string = _( + "|rYou have been banned and cannot continue from here." + "\nIf you feel this ban is in error, please email an admin.|x" + ) + errors.append(string) + return None, errors + + # everything's ok. Create the new account. + try: + try: + account = create.create_account( + username, email, password, permissions=permissions, typeclass=typeclass + ) + logger.log_sec(f"Account Created: {account} (IP: {ip}).") + + except Exception: + errors.append( + _( + "There was an error creating the Account. " + "If this problem persists, contact an admin." + ) + ) + logger.log_trace() + return None, errors + + # This needs to be set so the engine knows this account is + # logging in for the first time. (so it knows to call the right + # hooks during login later) + account.db.FIRST_LOGIN = True + + # Record IP address of creation, if available + if ip: + account.db.creator_ip = ip + + # join the new account to the public channels + for chan_info in settings.DEFAULT_CHANNELS: + if chankey := chan_info.get("key"): + channel = ChannelDB.objects.get_channel(chankey) + if not channel or not ( + channel.access(account, "listen") and channel.connect(account) + ): + string = ( + f"New account '{account.key}' could not connect to default channel" + f" '{chankey}'!" + ) + logger.log_err(string) + else: + logger.log_err(f"Default channel '{chan_info}' is missing a 'key' field!") + + if account and _AUTO_CREATE_CHARACTER_WITH_ACCOUNT: + # Auto-create a character to go with this account + + character, errs = account.create_character( + typeclass=kwargs.get("character_typeclass", account.default_character_typeclass) + ) + if errs: + errors.extend(errs) + + except Exception: + # We are in the middle between logged in and -not, so we have + # to handle tracebacks ourselves at this point. If we don't, + # we won't see any errors at all. + errors.append(_("An error occurred. Please e-mail an admin if the problem persists.")) + logger.log_trace() + + # Update the throttle to indicate a new account was created from this IP + if ip and not guest: + CREATION_THROTTLE.update(ip, "Too many accounts being created.") + SIGNAL_ACCOUNT_POST_CREATE.send(sender=account, ip=ip) + return account, errors
+ +
[docs] def delete(self, *args, **kwargs): + """ + Deletes the account persistently. + + Notes: + `*args` and `**kwargs` are passed on to the base delete + mechanism (these are usually not used). + + Return: + bool: If deletion was successful. Only time it fails would be + if the Account was already deleted. Note that even on a failure, + connected resources (nicks/aliases etc) will still have been + deleted. + + """ + for session in self.sessions.all(): + # unpuppeting all objects and disconnecting the user, if any + # sessions remain (should usually be handled from the + # deleting command) + try: + self.unpuppet_object(session) + except RuntimeError: + # no puppet to disconnect from + pass + session.sessionhandler.disconnect(session, reason=_("Account being deleted.")) + self.scripts.stop() + self.attributes.clear() + self.nicks.clear() + self.aliases.clear() + if not self.pk: + return False + super().delete(*args, **kwargs) + return True
+ + # methods inherited from database model + +
[docs] def msg(self, text=None, from_obj=None, session=None, options=None, **kwargs): + """ + Evennia -> User + This is the main route for sending data back to the user from the + server. + + Args: + text (str or tuple, optional): The message to send. This + is treated internally like any send-command, so its + value can be a tuple if sending multiple arguments to + the `text` oob command. + from_obj (Object or Account or list, optional): Object sending. If given, its + at_msg_send() hook will be called. If iterable, call on all entities. + session (Session or list, optional): Session object or a list of + Sessions to receive this send. If given, overrules the + default send behavior for the current + MULTISESSION_MODE. + options (list): Protocol-specific options. Passed on to the protocol. + Keyword Args: + any (dict): All other keywords are passed on to the protocol. + + """ + if from_obj: + # call hook + for obj in make_iter(from_obj): + try: + obj.at_msg_send(text=text, to_obj=self, **kwargs) + except Exception: + # this may not be assigned. + logger.log_trace() + try: + if not self.at_msg_receive(text=text, **kwargs): + # abort message to this account + return + except Exception: + # this may not be assigned. + pass + + kwargs["options"] = options + + if text is not None: + if not (isinstance(text, str) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text) + except Exception: + text = repr(text) + kwargs["text"] = text + + # session relay + sessions = make_iter(session) if session else self.sessions.all() + for session in sessions: + session.data_out(**kwargs)
+ +
[docs] def execute_cmd(self, raw_string, session=None, **kwargs): + """ + Do something as this account. This method is never called normally, + but only when the account object itself is supposed to execute the + command. It takes account nicks into account, but not nicks of + eventual puppets. + + Args: + raw_string (str): Raw command input coming from the command line. + session (Session, optional): The session to be responsible + for the command-send + + Keyword Args: + kwargs (any): Other keyword arguments will be added to the + found command object instance as variables before it + executes. This is unused by default Evennia but may be + used to set flags and change operating parameters for + commands at run-time. + + """ + # break circular import issues + global _CMDHANDLER + if not _CMDHANDLER: + from evennia.commands.cmdhandler import cmdhandler as _CMDHANDLER + raw_string = self.nicks.nickreplace( + raw_string, categories=("inputline", "channel"), include_account=False + ) + if not session and _MULTISESSION_MODE in (0, 1): + # for these modes we use the first/only session + sessions = self.sessions.get() + session = sessions[0] if sessions else None + + return _CMDHANDLER(self, raw_string, callertype="account", session=session, **kwargs)
+ + # channel receive hooks + +
[docs] def at_pre_channel_msg(self, message, channel, senders=None, **kwargs): + """ + Called by the Channel just before passing a message into `channel_msg`. + This allows for tweak messages per-user and also to abort the + receive on the receiver-level. + + Args: + message (str): The message sent to the channel. + channel (Channel): The sending channel. + senders (list, optional): Accounts or Objects acting as senders. + For most normal messages, there is only a single sender. If + there are no senders, this may be a broadcasting message. + **kwargs: These are additional keywords passed into `channel_msg`. + If `no_prefix=True` or `emit=True` are passed, the channel + prefix will not be added (`[channelname]: ` by default) + + Returns: + str or None: Allows for customizing the message for this recipient. + If returning `None` (or `False`) message-receiving is aborted. + The returning string will be passed into `self.channel_msg`. + + Notes: + This support posing/emotes by starting channel-send with : or ;. + + """ + if senders: + sender_string = ", ".join(sender.get_display_name(self) for sender in senders) + message_lstrip = message.lstrip() + if message_lstrip.startswith((":", ";")): + # this is a pose, should show as e.g. "User1 smiles to channel" + spacing = "" if message_lstrip[1:].startswith((":", "'", ",")) else " " + message = f"{sender_string}{spacing}{message_lstrip[1:]}" + else: + # normal message + message = f"{sender_string}: {message}" + + if not kwargs.get("no_prefix") and not kwargs.get("emit"): + message = channel.channel_prefix() + message + + return message
+ +
[docs] def channel_msg(self, message, channel, senders=None, **kwargs): + """ + This performs the actions of receiving a message to an un-muted + channel. + + Args: + message (str): The message sent to the channel. + channel (Channel): The sending channel. + senders (list, optional): Accounts or Objects acting as senders. + For most normal messages, there is only a single sender. If + there are no senders, this may be a broadcasting message or + similar. + **kwargs: These are additional keywords originally passed into + `Channel.msg`. + + Notes: + Before this, `Channel.at_pre_channel_msg` will fire, which offers a way + to customize the message for the receiver on the channel-level. + + """ + self.msg( + text=(message, {"from_channel": channel.id}), + from_obj=senders, + options={"from_channel": channel.id}, + )
+ +
[docs] def at_post_channel_msg(self, message, channel, senders=None, **kwargs): + """ + Called by `self.channel_msg` after message was received. + + Args: + message (str): The message sent to the channel. + channel (Channel): The sending channel. + senders (list, optional): Accounts or Objects acting as senders. + For most normal messages, there is only a single sender. If + there are no senders, this may be a broadcasting message. + **kwargs: These are additional keywords passed into `channel_msg`. + + """ + pass
+ + # search method + +
[docs] def search( + self, + searchdata, + return_puppet=False, + search_object=False, + typeclass=None, + nofound_string=None, + multimatch_string=None, + use_nicks=True, + quiet=False, + **kwargs, + ): + """ + This is similar to `DefaultObject.search` but defaults to searching + for Accounts only. + + Args: + searchdata (str or int): Search criterion, the Account's + key or dbref to search for. + return_puppet (bool, optional): Instructs the method to + return matches as the object the Account controls rather + than the Account itself (or None) if nothing is puppeted). + search_object (bool, optional): Search for Objects instead of + Accounts. This is used by e.g. the @examine command when + wanting to examine Objects while OOC. + typeclass (Account typeclass, optional): Limit the search + only to this particular typeclass. This can be used to + limit to specific account typeclasses or to limit the search + to a particular Object typeclass if `search_object` is True. + nofound_string (str, optional): A one-time error message + to echo if `searchdata` leads to no matches. If not given, + will fall back to the default handler. + multimatch_string (str, optional): A one-time error + message to echo if `searchdata` leads to multiple matches. + If not given, will fall back to the default handler. + use_nicks (bool, optional): Use account-level nick replacement. + quiet (bool, optional): If set, will not show any error to the user, + and will also lead to returning a list of matches. + + Return: + match (Account, Object or None): A single Account or Object match. + list: If `quiet=True` this is a list of 0, 1 or more Account or Object matches. + + Notes: + Extra keywords are ignored, but are allowed in call in + order to make API more consistent with + objects.objects.DefaultObject.search. + + """ + # handle me, self and *me, *self + if isinstance(searchdata, str): + # handle wrapping of common terms + if searchdata.lower() in ("me", "*me", "self", "*self"): + return self + searchdata = self.nicks.nickreplace( + searchdata, categories=("account",), include_account=False + ) + if search_object: + matches = ObjectDB.objects.object_search(searchdata, typeclass=typeclass) + else: + matches = AccountDB.objects.account_search(searchdata, typeclass=typeclass) + + if quiet: + matches = list(matches) + if return_puppet: + matches = [match.puppet for match in matches] + else: + matches = _AT_SEARCH_RESULT( + matches, + self, + query=searchdata, + nofound_string=nofound_string, + multimatch_string=multimatch_string, + ) + if matches and return_puppet: + try: + matches = matches.puppet + except AttributeError: + return None + return matches
+ +
[docs] def access( + self, accessing_obj, access_type="read", default=False, no_superuser_bypass=False, **kwargs + ): + """ + Determines if another object has permission to access this + object in whatever way. + + Args: + accessing_obj (Object): Object trying to access this one. + access_type (str, optional): Type of access sought. + default (bool, optional): What to return if no lock of + access_type was found + no_superuser_bypass (bool, optional): Turn off superuser + lock bypassing. Be careful with this one. + + Keyword Args: + kwargs (any): Passed to the at_access hook along with the result. + + Returns: + result (bool): Result of access check. + + """ + result = super().access( + accessing_obj, + access_type=access_type, + default=default, + no_superuser_bypass=no_superuser_bypass, + ) + self.at_access(result, accessing_obj, access_type, **kwargs) + return result
+ + @property + def idle_time(self): + """ + Returns the idle time of the least idle session in seconds. If + no sessions are connected it returns nothing. + """ + idle = [session.cmd_last_visible for session in self.sessions.all()] + if idle: + return time.time() - float(max(idle)) + return None + + @property + def connection_time(self): + """ + Returns the maximum connection time of all connected sessions + in seconds. Returns nothing if there are no sessions. + """ + conn = [session.conn_time for session in self.sessions.all()] + if conn: + return time.time() - float(min(conn)) + return None + + # account hooks + +
[docs] def basetype_setup(self): + """ + This sets up the basic properties for an account. Overload this + with at_account_creation rather than changing this method. + + """ + # A basic security setup + self.locks.add(self.lockstring) + + # The ooc account cmdset + self.cmdset.add_default(_CMDSET_ACCOUNT, persistent=True)
+ +
[docs] def at_account_creation(self): + """ + This is called once, the very first time the account is created + (i.e. first time they register with the game). It's a good + place to store attributes all accounts should have, like + configuration values etc. + + """ + # set an (empty) attribute holding the characters this account has + lockstring = "attrread:perm(Admins);attredit:perm(Admins);attrcreate:perm(Admins);" + self.attributes.add("_playable_characters", [], lockstring=lockstring) + self.attributes.add("_saved_protocol_flags", {}, lockstring=lockstring)
+ +
[docs] def at_init(self): + """ + This is always called whenever this object is initiated -- + that is, whenever it its typeclass is cached from memory. This + happens on-demand first time the object is used or activated + in some way after being created but also after each server + restart or reload. In the case of account objects, this usually + happens the moment the account logs in or reconnects after a + reload. + + """ + pass
+ + # Note that the hooks below also exist in the character object's + # typeclass. You can often ignore these and rely on the character + # ones instead, unless you are implementing a multi-character game + # and have some things that should be done regardless of which + # character is currently connected to this account. + +
[docs] def at_first_save(self): + """ + This is a generic hook called by Evennia when this object is + saved to the database the very first time. You generally + don't override this method but the hooks called by it. + + """ + self.basetype_setup() + self.at_account_creation() + # initialize Attribute/TagProperties + self.init_evennia_properties() + + permissions = [settings.PERMISSION_ACCOUNT_DEFAULT] + if hasattr(self, "_createdict"): + # this will only be set if the utils.create_account + # function was used to create the object. + cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = f"#{self.dbid}" + updates.append("db_key") + elif self.key != cdict.get("key"): + updates.append("db_key") + self.db_key = cdict["key"] + if updates: + self.save(update_fields=updates) + + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("permissions"): + permissions = cdict["permissions"] + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) + del self._createdict + + self.permissions.batch_add(*permissions)
+ +
[docs] def at_access(self, result, accessing_obj, access_type, **kwargs): + """ + This is triggered after an access-call on this Account has + completed. + + Args: + result (bool): The result of the access check. + accessing_obj (any): The object requesting the access + check. + access_type (str): The type of access checked. + + Keyword Args: + kwargs (any): These are passed on from the access check + and can be used to relay custom instructions from the + check mechanism. + + Notes: + This method cannot affect the result of the lock check and + its return value is not used in any way. It can be used + e.g. to customize error messages in a central location or + create other effects based on the access result. + + """ + pass
+ +
[docs] def at_cmdset_get(self, **kwargs): + """ + Called just before cmdsets on this object are requested by the + command handler. If changes need to be done on the fly to the + cmdset before passing them on to the cmdhandler, this is the + place to do it. This is called also if the object currently + have no cmdsets. + + Keyword Args: + caller (Object, Account or Session): The object requesting the cmdsets. + current (CmdSet): The current merged cmdset. + force_init (bool): If `True`, force a re-build of the cmdset. (seems unused) + **kwargs: Arbitrary input for overloads. + + """ + pass
+ +
[docs] def get_cmdsets(self, caller, current, **kwargs): + """ + Called by the CommandHandler to get a list of cmdsets to merge. + + Args: + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + **kwargs: Arbitrary input for overloads. + + Returns: + tuple: A tuple of (current, cmdsets), which is probably self.cmdset.current and self.cmdset.cmdset_stack + """ + return self.cmdset.current, list(self.cmdset.cmdset_stack)
+ +
[docs] def at_first_login(self, **kwargs): + """ + Called the very first time this account logs into the game. + Note that this is called *before* at_pre_login, so no session + is established and usually no character is yet assigned at + this point. This hook is intended for account-specific setup + like configurations. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_password_change(self, **kwargs): + """ + Called after a successful password set/modify. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_pre_login(self, **kwargs): + """ + Called every time the user logs in, just before the actual + login-state is set. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ + def _send_to_connect_channel(self, message): + """ + Helper method for loading and sending to the comm channel dedicated to + connection messages. This will also be sent to the mudinfo channel. + + Args: + message (str): A message to send to the connect channel. + + """ + global _MUDINFO_CHANNEL, _CONNECT_CHANNEL + if _MUDINFO_CHANNEL is None: + if settings.CHANNEL_MUDINFO: + try: + _MUDINFO_CHANNEL = ChannelDB.objects.get(db_key=settings.CHANNEL_MUDINFO["key"]) + except ChannelDB.DoesNotExist: + logger.log_trace() + else: + _MUDINFO = False + if _CONNECT_CHANNEL is None: + if settings.CHANNEL_CONNECTINFO: + try: + _CONNECT_CHANNEL = ChannelDB.objects.get( + db_key=settings.CHANNEL_CONNECTINFO["key"] + ) + except ChannelDB.DoesNotExist: + logger.log_trace() + else: + _CONNECT_CHANNEL = False + + 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.msg(f"[{now}]: {message}") + if _CONNECT_CHANNEL: + _CONNECT_CHANNEL.msg(f"[{now}]: {message}") + +
[docs] def at_post_login(self, session=None, **kwargs): + """ + Called at the end of the login process, just before letting + the account loose. + + Args: + session (Session, optional): Session logging in, if any. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This is called *before* an eventual Character's + `at_post_login` hook. By default it is used to set up + auto-puppeting based on `MULTISESSION_MODE` + + """ + # if we have saved protocol flags on ourselves, load them here. + protocol_flags = self.attributes.get("_saved_protocol_flags", {}) + if session and protocol_flags: + session.update_flags(**protocol_flags) + + # inform the client that we logged in through an OOB message + if session: + session.msg(logged_in={}) + + self._send_to_connect_channel(_("|G{key} connected|n").format(key=self.key)) + if _AUTO_PUPPET_ON_LOGIN: + # in this mode we try to auto-connect to our last connected object, if any + try: + self.puppet_object(session, self.db._last_puppet) + except RuntimeError: + self.msg(_("The Character does not exist.")) + return + else: + # In this mode we don't auto-connect but by default end up at a character selection + # screen. We execute look on the account. + self.msg(self.at_look(target=self.characters, session=session), session=session)
+ +
[docs] def at_failed_login(self, session, **kwargs): + """ + Called by the login process if a user account is targeted correctly + but provided with an invalid password. By default it does nothing, + but exists to be overridden. + + Args: + session (session): Session logging in. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + """ + pass
+ +
[docs] def at_disconnect(self, reason=None, **kwargs): + """ + Called just before user is disconnected. + + Args: + reason (str, optional): The reason given for the disconnect, + (echoed to the connection channel by default). + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + + """ + reason = f" ({reason if reason else ''})" + self._send_to_connect_channel( + _("|R{key} disconnected{reason}|n").format(key=self.key, reason=reason) + )
+ +
[docs] def at_post_disconnect(self, **kwargs): + """ + This is called *after* disconnection is complete. No messages + can be relayed to the account from here. After this call, the + account should not be accessed any more, making this a good + spot for deleting it (in the case of a guest account account, + for example). + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_msg_receive(self, text=None, from_obj=None, **kwargs): + """ + This hook is called whenever someone sends a message to this + object using the `msg` method. + + Note that from_obj may be None if the sender did not include + itself as an argument to the obj.msg() call - so you have to + check for this. . + + Consider this a pre-processing method before msg is passed on + to the user session. If this method returns False, the msg + will not be passed on. + + Args: + text (str, optional): The message received. + from_obj (any, optional): The object sending the message. + + Keyword Args: + This includes any keywords sent to the `msg` method. + + Returns: + receive (bool): If this message should be received. + + Notes: + If this method returns False, the `msg` operation + will abort without sending the message. + + """ + return True
+ +
[docs] def at_msg_send(self, text=None, to_obj=None, **kwargs): + """ + This is a hook that is called when *this* object sends a + message to another object with `obj.msg(text, to_obj=obj)`. + + Args: + text (str, optional): Text to send. + to_obj (any, optional): The object to send to. + + Keyword Args: + Keywords passed from msg() + + Notes: + Since this method is executed by `from_obj`, if no `from_obj` + was passed to `DefaultCharacter.msg` this hook will never + get called. + + """ + pass
+ +
[docs] def at_server_reload(self): + """ + This hook is called whenever the server is shutting down for + restart/reboot. If you want to, for example, save + non-persistent properties across a restart, this is the place + to do it. + """ + pass
+ +
[docs] def at_server_shutdown(self): + """ + This hook is called whenever the server is shutting down fully + (i.e. not for a restart). + """ + pass
+ + ooc_appearance_template = """ +-------------------------------------------------------------------- +{header} + +{sessions} + + |whelp|n - more commands + |wpublic <text>|n - talk on public channel + |wcharcreate <name> [=description]|n - create new character + |wchardelete <name>|n - delete a character + |wic <name>|n - enter the game as character (|wooc|n to get back here) + |wic|n - enter the game as latest character controlled. + +{characters} +{footer} +-------------------------------------------------------------------- +""".strip() + +
[docs] def at_look(self, target=None, session=None, **kwargs): + """ + Called when this object executes a look. It allows to customize + just what this means. + + Args: + target (Object or list, optional): An object or a list + objects to inspect. This is normally a list of characters. + session (Session, optional): The session doing this look. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + look_string (str): A prepared look string, ready to send + off to any recipient (usually to ourselves) + + """ + + if target and not is_iter(target): + # single target - just show it + if hasattr(target, "return_appearance"): + return target.return_appearance(self) + else: + return f"{target} has no in-game appearance." + + # multiple targets - this is a list of characters + characters = list(tar for tar in target if tar) if target else [] + ncars = len(characters) + sessions = self.sessions.all() + nsess = len(sessions) + + if not nsess: + # no sessions, nothing to report + return "" + + # header text + txt_header = f"Account |g{self.name}|n (you are Out-of-Character)" + + # sessions + sess_strings = [] + for isess, sess in enumerate(sessions): + ip_addr = sess.address[0] if isinstance(sess.address, tuple) else sess.address + addr = f"{sess.protocol_key} ({ip_addr})" + sess_str = ( + f"|w* {isess + 1}|n" + if session and session.sessid == sess.sessid + else f" {isess + 1}" + ) + + sess_strings.append(f"{sess_str} {addr}") + + txt_sessions = "|wConnected session(s):|n\n" + "\n".join(sess_strings) + + if not characters: + txt_characters = "You don't have a character yet. Use |wcharcreate|n." + else: + max_chars = ( + "unlimited" + if self.is_superuser or _MAX_NR_CHARACTERS is None + else _MAX_NR_CHARACTERS + ) + + char_strings = [] + for char in characters: + csessions = char.sessions.all() + if csessions: + for sess in csessions: + # character is already puppeted + sid = sess in sessions and sessions.index(sess) + 1 + if sess and sid: + char_strings.append( + f" - |G{char.name}|n [{', '.join(char.permissions.all())}] " + f"(played by you in session {sid})" + ) + else: + char_strings.append( + f" - |R{char.name}|n [{', '.join(char.permissions.all())}] " + "(played by someone else)" + ) + else: + # character is "free to puppet" + char_strings.append(f" - {char.name} [{', '.join(char.permissions.all())}]") + + txt_characters = ( + f"Available character(s) ({ncars}/{max_chars}, |wic <name>|n to play):|n\n" + + "\n".join(char_strings) + ) + return self.ooc_appearance_template.format( + header=txt_header, + sessions=txt_sessions, + characters=txt_characters, + footer="", + )
+ + +
[docs]class DefaultGuest(DefaultAccount): + """ + This class is used for guest logins. Unlike Accounts, Guests and + their characters are deleted after disconnection. + + """ + +
[docs] @classmethod + def create(cls, **kwargs): + """ + Forwards request to cls.authenticate(); returns a DefaultGuest object + if one is available for use. + + """ + return cls.authenticate(**kwargs)
+ +
[docs] @classmethod + def authenticate(cls, **kwargs): + """ + Gets or creates a Guest account object. + + Keyword Args: + ip (str, optional): IP address of requester; used for ban checking, + throttling and logging + + Returns: + account (Object): Guest account object, if available + errors (list): List of error messages accrued during this request. + + """ + errors = [] + account = None + username = None + ip = kwargs.get("ip", "").strip() + + # check if guests are enabled. + if not settings.GUEST_ENABLED: + errors.append(_("Guest accounts are not enabled on this server.")) + return None, errors + + try: + # Find an available guest name. + for name in settings.GUEST_LIST: + if not AccountDB.objects.filter(username__iexact=name).exists(): + username = name + break + if not username: + errors.append(_("All guest accounts are in use. Please try again later.")) + if ip: + LOGIN_THROTTLE.update(ip, "Too many requests for Guest access.") + return None, errors + else: + # build a new account with the found guest username + password = "%016x" % getrandbits(64) + home = settings.GUEST_HOME + permissions = settings.PERMISSION_GUEST_DEFAULT + typeclass = settings.BASE_GUEST_TYPECLASS + + # Call parent class creator + account, errs = super(DefaultGuest, cls).create( + guest=True, + username=username, + password=password, + permissions=permissions, + typeclass=typeclass, + home=home, + ip=ip, + ) + errors.extend(errs) + + if not account.characters: + # this can happen for multisession_mode > 1. For guests we + # always auto-create a character, regardless of multi-session-mode. + character, errs = account.create_character() + + if errs: + errors.extend(errs) + + return account, errors + + except Exception: + # We are in the middle between logged in and -not, so we have + # to handle tracebacks ourselves at this point. If we don't, + # we won't see any errors at all. + errors.append(_("An error occurred. Please e-mail an admin if the problem persists.")) + logger.log_trace() + return None, errors + + return account, errors
+ +
[docs] def at_post_login(self, session=None, **kwargs): + """ + By default, Guests only have one character regardless of which + MAX_NR_CHARACTERS we use. They also always auto-puppet a matching + character and don't get a choice. + + Args: + session (Session, optional): Session connecting. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + self._send_to_connect_channel(_("|G{key} connected|n").format(key=self.key)) + self.puppet_object(session, self.db._last_puppet)
+ +
[docs] def at_server_shutdown(self): + """ + We repeat the functionality of `at_disconnect()` here just to + be on the safe side. + """ + super().at_server_shutdown() + for character in self.characters: + character.delete()
+ +
[docs] def at_post_disconnect(self, **kwargs): + """ + Once having disconnected, destroy the guest's characters and + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + super().at_post_disconnect() + for character in self.characters: + character.delete() + self.delete()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/accounts/bots.html b/docs/latest/_modules/evennia/accounts/bots.html new file mode 100644 index 0000000000..bd955e6948 --- /dev/null +++ b/docs/latest/_modules/evennia/accounts/bots.html @@ -0,0 +1,877 @@ + + + + + + + + evennia.accounts.bots — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.accounts.bots

+"""
+Bots are a special child typeclasses of
+Account that are  controlled by the server.
+
+"""
+
+import time
+
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+import evennia
+from evennia.accounts.accounts import DefaultAccount
+from evennia.scripts.scripts import DefaultScript
+from evennia.utils import logger, search, utils
+from evennia.utils.ansi import strip_ansi
+
+_IDLE_TIMEOUT = settings.IDLE_TIMEOUT
+
+_IRC_ENABLED = settings.IRC_ENABLED
+_RSS_ENABLED = settings.RSS_ENABLED
+_GRAPEVINE_ENABLED = settings.GRAPEVINE_ENABLED
+_DISCORD_ENABLED = settings.DISCORD_ENABLED and hasattr(settings, "DISCORD_BOT_TOKEN")
+
+
+
[docs]class BotStarter(DefaultScript): + """ + This non-repeating script has the + sole purpose of kicking its bot + into gear when it is initialized. + + """ + +
[docs] def at_script_creation(self): + """ + Called once, when script is created. + + """ + self.key = "botstarter" + self.desc = "bot start/keepalive" + self.persistent = True
+ +
[docs] def at_server_start(self): + self.at_start()
+ +
[docs] def at_start(self): + """ + Kick bot into gear. + + """ + if not self.account.sessions.all(): + self.account.start()
+ +
[docs] def at_repeat(self): + """ + Called self.interval seconds to keep connection. We cannot use + the IDLE command from inside the game since the system will + not catch it (commands executed from the server side usually + has no sessions). So we update the idle counter manually here + instead. This keeps the bot getting hit by IDLE_TIMEOUT. + + """ + for session in evennia.SESSION_HANDLER.sessions_from_account(self.account): + session.update_session_counters(idle=True)
+ + +# +# Bot base class + + +
[docs]class Bot(DefaultAccount): + """ + A Bot will start itself when the server starts (it will generally + not do so on a reload - that will be handled by the normal Portal + session resync) + + """ + +
[docs] def basetype_setup(self): + """ + This sets up the basic properties for the bot. + + """ + # the text encoding to use. + self.db.encoding = "utf-8" + # A basic security setup (also avoid idle disconnects) + lockstring = ( + "examine:perm(Admin);edit:perm(Admin);delete:perm(Admin);" + "boot:perm(Admin);msg:false();noidletimeout:true()" + ) + self.locks.add(lockstring) + # set the basics of being a bot + self.scripts.add(BotStarter, key="bot_starter") + self.is_bot = True
+ +
[docs] def start(self, **kwargs): + """ + This starts the bot, whatever that may mean. + + """ + pass
+ +
[docs] def msg(self, text=None, from_obj=None, session=None, options=None, **kwargs): + """ + Evennia -> outgoing protocol + + """ + super().msg(text=text, from_obj=from_obj, session=session, options=options, **kwargs)
+ +
[docs] def execute_cmd(self, raw_string, session=None): + """ + Incoming protocol -> Evennia + + """ + super().msg(raw_string, session=session)
+ +
[docs] def at_server_shutdown(self): + """ + We need to handle this case manually since the shutdown may be + a reset. + + """ + for session in self.sessions.all(): + session.sessionhandler.disconnect(session)
+ + +# Bot implementations + +# IRC + + +
[docs]class IRCBot(Bot): + """ + Bot for handling IRC connections. + + """ + + # override this on a child class to use custom factory + factory_path = "evennia.server.portal.irc.IRCBotFactory" + +
[docs] def start( + self, + ev_channel=None, + irc_botname=None, + irc_channel=None, + irc_network=None, + irc_port=None, + irc_ssl=None, + ): + """ + Start by telling the portal to start a new session. + + Args: + ev_channel (str): Key of the Evennia channel to connect to. + irc_botname (str): Name of bot to connect to irc channel. If + not set, use `self.key`. + irc_channel (str): Name of channel on the form `#channelname`. + irc_network (str): URL of the IRC network, like `irc.freenode.net`. + irc_port (str): Port number of the irc network, like `6667`. + irc_ssl (bool): Indicates whether to use SSL connection. + + """ + if not _IRC_ENABLED: + # the bot was created, then IRC was turned off. We delete + # ourselves (this will also kill the start script) + self.delete() + return + + # if keywords are given, store (the BotStarter script + # will not give any keywords, so this should normally only + # happen at initialization) + if irc_botname: + self.db.irc_botname = irc_botname + elif not self.db.irc_botname: + self.db.irc_botname = self.key + if ev_channel: + # connect to Evennia channel + channel = search.channel_search(ev_channel) + if not channel: + raise RuntimeError(f"Evennia Channel '{ev_channel}' not found.") + channel = channel[0] + channel.connect(self) + self.db.ev_channel = channel + if irc_channel: + self.db.irc_channel = irc_channel + if irc_network: + self.db.irc_network = irc_network + if irc_port: + self.db.irc_port = irc_port + if irc_ssl: + self.db.irc_ssl = irc_ssl + + # instruct the server and portal to create a new session with + # the stored configuration + configdict = { + "uid": self.dbid, + "botname": self.db.irc_botname, + "channel": self.db.irc_channel, + "network": self.db.irc_network, + "port": self.db.irc_port, + "ssl": self.db.irc_ssl, + } + evennia.SESSION_HANDLER.start_bot_session(self.factory_path, configdict)
+ +
[docs] def at_msg_send(self, **kwargs): + "Shortcut here or we can end up in infinite loop" + pass
+ +
[docs] def get_nicklist(self, caller): + """ + Retrive the nick list from the connected channel. + + Args: + caller (Object or Account): The requester of the list. This will + be stored and echoed to when the irc network replies with the + requested info. + + Notes: Since the return is asynchronous, the caller is stored internally + in a list; all callers in this list will get the nick info once it + returns (it is a custom OOB inputfunc option). The callback will not + survive a reload (which should be fine, it's very quick). + """ + if not hasattr(self, "_nicklist_callers"): + self._nicklist_callers = [] + self._nicklist_callers.append(caller) + super().msg(request_nicklist="") + return
+ +
[docs] def ping(self, caller): + """ + Fire a ping to the IRC server. + + Args: + caller (Object or Account): The requester of the ping. + + """ + if not hasattr(self, "_ping_callers"): + self._ping_callers = [] + self._ping_callers.append(caller) + super().msg(ping="")
+ +
[docs] def reconnect(self): + """ + Force a protocol-side reconnect of the client without + having to destroy/recreate the bot "account". + + """ + super().msg(reconnect="")
+ +
[docs] def msg(self, text=None, **kwargs): + """ + Takes text from connected channel (only). + + Args: + text (str, optional): Incoming text from channel. + + Keyword Args: + options (dict): Options dict with the following allowed keys: + - from_channel (str): dbid of a channel this text originated from. + - from_obj (list): list of objects sending this text. + + """ + from_obj = kwargs.get("from_obj", None) + options = kwargs.get("options", None) or {} + + if not self.ndb.ev_channel and self.db.ev_channel: + # cache channel lookup + self.ndb.ev_channel = self.db.ev_channel + + if ( + "from_channel" in options + and text + and self.ndb.ev_channel.dbid == options["from_channel"] + ): + if not from_obj or from_obj != [self]: + super().msg(channel=text)
+ +
[docs] def execute_cmd(self, session=None, txt=None, **kwargs): + """ + Take incoming data and send it to connected channel. This is + triggered by the bot_data_in Inputfunc. + + Args: + session (Session, optional): Session responsible for this + command. Note that this is the bot. + txt (str, optional): Command string. + Keyword Args: + user (str): The name of the user who sent the message. + channel (str): The name of channel the message was sent to. + type (str): Nature of message. Either 'msg', 'action', 'nicklist' + or 'ping'. + nicklist (list, optional): Set if `type='nicklist'`. This is a list + of nicks returned by calling the `self.get_nicklist`. It must look + for a list `self._nicklist_callers` which will contain all callers + waiting for the nicklist. + timings (float, optional): Set if `type='ping'`. This is the return + (in seconds) of a ping request triggered with `self.ping`. The + return must look for a list `self._ping_callers` which will contain + all callers waiting for the ping return. + + """ + if kwargs["type"] == "nicklist": + # the return of a nicklist request + if hasattr(self, "_nicklist_callers") and self._nicklist_callers: + chstr = f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})" + nicklist = ", ".join(sorted(kwargs["nicklist"], key=lambda n: n.lower())) + for obj in self._nicklist_callers: + obj.msg("Nicks at {chstr}:\n {nicklist}".format(chstr=chstr, nicklist=nicklist)) + self._nicklist_callers = [] + return + + elif kwargs["type"] == "ping": + # the return of a ping + if hasattr(self, "_ping_callers") and self._ping_callers: + chstr = f"{self.db.irc_channel} ({self.db.irc_network}:{self.db.irc_port})" + for obj in self._ping_callers: + obj.msg( + "IRC ping return from {chstr} took {time}s.".format( + chstr=chstr, time=kwargs["timing"] + ) + ) + self._ping_callers = [] + return + + elif kwargs["type"] == "privmsg": + # A private message to the bot - a command. + user = kwargs["user"] + + if txt.lower().startswith("who"): + # return server WHO list (abbreviated for IRC) + whos = [] + t0 = time.time() + for sess in evennia.SESSION_HANDLER.get_sessions(): + delta_cmd = t0 - sess.cmd_last_visible + delta_conn = t0 - session.conn_time + account = sess.get_account() + whos.append( + "%s (%s/%s)" + % ( + utils.crop("|w%s|n" % account.name, width=25), + utils.time_format(delta_conn, 0), + utils.time_format(delta_cmd, 1), + ) + ) + text = f"Who list (online/idle): {', '.join(sorted(whos, key=lambda w: w.lower()))}" + elif txt.lower().startswith("about"): + # some bot info + text = f"This is an Evennia IRC bot connecting from '{settings.SERVERNAME}'." + else: + text = "I understand 'who' and 'about'." + super().msg(privmsg=((text,), {"user": user})) + else: + # something to send to the main channel + if kwargs["type"] == "action": + # An action (irc pose) + text = f"{kwargs['user']}@{kwargs['channel']} {txt}" + else: + # msg - A normal channel message + text = f"{kwargs['user']}@{kwargs['channel']}: {txt}" + + if not self.ndb.ev_channel and self.db.ev_channel: + # cache channel lookup + self.ndb.ev_channel = self.db.ev_channel + + if self.ndb.ev_channel: + self.ndb.ev_channel.msg(text, senders=self)
+ + +# +# RSS +# + + +
[docs]class RSSBot(Bot): + """ + An RSS relayer. The RSS protocol itself runs a ticker to update + its feed at regular intervals. + + """ + +
[docs] def start(self, ev_channel=None, rss_url=None, rss_rate=None): + """ + Start by telling the portal to start a new RSS session + + Args: + ev_channel (str): Key of the Evennia channel to connect to. + rss_url (str): Full URL to the RSS feed to subscribe to. + rss_rate (int): How often for the feedreader to update. + + Raises: + RuntimeError: If `ev_channel` does not exist. + + """ + if not _RSS_ENABLED: + # The bot was created, then RSS was turned off. Delete ourselves. + self.delete() + return + + if ev_channel: + # connect to Evennia channel + channel = search.channel_search(ev_channel) + if not channel: + raise RuntimeError(f"Evennia Channel '{ev_channel}' not found.") + channel = channel[0] + self.db.ev_channel = channel + if rss_url: + self.db.rss_url = rss_url + if rss_rate: + self.db.rss_rate = rss_rate + # instruct the server and portal to create a new session with + # the stored configuration + configdict = {"uid": self.dbid, "url": self.db.rss_url, "rate": self.db.rss_rate} + evennia.SESSION_HANDLER.start_bot_session( + "evennia.server.portal.rss.RSSBotFactory", configdict + )
+ +
[docs] def execute_cmd(self, txt=None, session=None, **kwargs): + """ + Take incoming data and send it to connected channel. This is + triggered by the bot_data_in Inputfunc. + + Args: + session (Session, optional): Session responsible for this + command. + txt (str, optional): Command string. + kwargs (dict, optional): Additional Information passed from bot. + Not used by the RSSbot by default. + + """ + if not self.ndb.ev_channel and self.db.ev_channel: + # cache channel lookup + self.ndb.ev_channel = self.db.ev_channel + if self.ndb.ev_channel: + self.ndb.ev_channel.msg(txt, senders=self.id)
+ + +# Grapevine bot + + +
[docs]class GrapevineBot(Bot): + """ + g Grapevine (https://grapevine.haus) relayer. The channel to connect to is the first + name in the settings.GRAPEVINE_CHANNELS list. + + """ + + factory_path = "evennia.server.portal.grapevine.RestartingWebsocketServerFactory" + +
[docs] def start(self, ev_channel=None, grapevine_channel=None): + """ + Start by telling the portal to connect to the grapevine network. + + """ + if not _GRAPEVINE_ENABLED: + self.delete() + return + + # connect to Evennia channel + if ev_channel: + # connect to Evennia channel + channel = search.channel_search(ev_channel) + if not channel: + raise RuntimeError(f"Evennia Channel '{ev_channel}' not found.") + channel = channel[0] + channel.connect(self) + self.db.ev_channel = channel + + if grapevine_channel: + self.db.grapevine_channel = grapevine_channel + + # these will be made available as properties on the protocol factory + configdict = {"uid": self.dbid, "grapevine_channel": self.db.grapevine_channel} + + evennia.SESSION_HANDLER.start_bot_session(self.factory_path, configdict)
+ +
[docs] def at_msg_send(self, **kwargs): + "Shortcut here or we can end up in infinite loop" + pass
+ +
[docs] def msg(self, text=None, **kwargs): + """ + Takes text from connected channel (only). + + Args: + text (str, optional): Incoming text from channel. + + Keyword Args: + options (dict): Options dict with the following allowed keys: + - from_channel (str): dbid of a channel this text originated from. + - from_obj (list): list of objects sending this text. + + """ + from_obj = kwargs.get("from_obj", None) + options = kwargs.get("options", None) or {} + + if not self.ndb.ev_channel and self.db.ev_channel: + # cache channel lookup + self.ndb.ev_channel = self.db.ev_channel + + if ( + "from_channel" in options + and text + and self.ndb.ev_channel.dbid == options["from_channel"] + ): + if not from_obj or from_obj != [self]: + # send outputfunc channel(msg, chan, sender) + + text = text[0] if isinstance(text, (tuple, list)) else text + + prefix, text = text.split(":", 1) + + super().msg( + channel=( + text.strip(), + self.db.grapevine_channel, + ", ".join(obj.key for obj in from_obj), + {}, + ) + )
+ +
[docs] def execute_cmd( + self, + txt=None, + session=None, + event=None, + grapevine_channel=None, + sender=None, + game=None, + **kwargs, + ): + """ + Take incoming data from protocol and send it to connected channel. This is + triggered by the bot_data_in Inputfunc. + """ + if event == "channels/broadcast": + # A private message to the bot - a command. + + text = f"{sender}@{game}: {txt}" + + if not self.ndb.ev_channel and self.db.ev_channel: + # simple cache of channel lookup + self.ndb.ev_channel = self.db.ev_channel + if self.ndb.ev_channel: + self.ndb.ev_channel.msg(text, senders=self)
+ + +# Discord + + +
[docs]class DiscordBot(Bot): + """ + Discord bot relay. You will need to set up your own bot + (https://discord.com/developers/applications) and add the bot token as `DISCORD_BOT_TOKEN` to + `secret_settings.py` to use + """ + + factory_path = "evennia.server.portal.discord.DiscordWebsocketServerFactory" + +
[docs] def at_init(self): + """ + Load required channels back into memory + + """ + if channel_links := self.db.channels: + # this attribute contains a list of evennia<->discord links in the form + # of ("evennia_channel", "discord_chan_id") + # grab Evennia channels, cache and connect + channel_set = {evchan for evchan, dcid in channel_links} + self.ndb.ev_channels = {} + for channel_name in list(channel_set): + channel = search.search_channel(channel_name) + if not channel: + raise RuntimeError(f"Evennia Channel {channel_name} not found.") + channel = channel[0] + self.ndb.ev_channels[channel_name] = channel
+ +
[docs] def start(self): + """ + Tell the Discord protocol to connect. + + """ + if not _DISCORD_ENABLED: + self.delete() + return + + if self.ndb.ev_channels: + for channel in self.ndb.ev_channels.values(): + channel.connect(self) + + elif channel_links := self.db.channels: + # this attribute contains a list of evennia<->discord links in the form + # of ("evennia_channel", "discord_chan_id") + # grab Evennia channels, cache and connect + channel_set = {evchan for evchan, dcid in channel_links} + self.ndb.ev_channels = {} + for channel_name in list(channel_set): + channel = search.search_channel(channel_name) + if not channel: + raise RuntimeError(f"Evennia Channel {channel_name} not found.") + channel = channel[0] + self.ndb.ev_channels[channel_name] = channel + channel.connect(self) + + # connect + # these will be made available as properties on the protocol factory + configdict = {"uid": self.dbid} + evennia.SESSION_HANDLER.start_bot_session(self.factory_path, configdict)
+ +
[docs] def at_pre_channel_msg(self, message, channel, senders=None, **kwargs): + """ + Called by the Channel just before passing a message into `channel_msg`. + + We overload this to set the channel tag prefix. + + """ + kwargs["no_prefix"] = not self.db.tag_channel + return super().at_pre_channel_msg(message, channel, senders=senders, **kwargs)
+ +
[docs] def channel_msg(self, message, channel, senders=None, relayed=False, **kwargs): + """ + Passes channel messages received on to discord + + Args: + message (str) - Incoming text from channel. + channel (Channel) - The channel the message is being received from + + Keyword Args: + senders (list or None) - Object(s) sending the message + relayed (bool) - A flag identifying whether the message was relayed by the bot. + + """ + if relayed: + # don't relay our own relayed messages + return + if channel_list := self.db.channels: + # get all the discord channels connected to this evennia channel + channel_name = channel.name + for dc_chan in [dcid for evchan, dcid in channel_list if evchan == channel_name]: + # send outputfunc channel(msg, discord channel) + super().msg(channel=(strip_ansi(message.strip()), dc_chan))
+ +
[docs] def change_nickname(self, new_nickname, guild_id, user_id, **kwargs): + """ + Changes a given user's nickname on the given guild the bot is in. + + Args: + new_nickname (str) - The user's new nickname. + guild_id (int) - The guild the nickname will be changed in. + user_id (int) - The Discord ID of the user who's nickname will be changed. + + """ + super().msg(nickname=(new_nickname, guild_id, user_id))
+ +
[docs] def assign_role(self, role_id, guild_id, user_id, **kwargs): + """ + Assigns a user the role on the given guild the bot is in. + + Args: + role_id (int) - The Discord role's ID. + guild_id (int) - The guild the role will be assigned in. + user_id (int) - The user the given role will be assigned to. + """ + + super().msg(role=(role_id, guild_id, user_id))
+ +
[docs] def direct_msg(self, message, sender, **kwargs): + """ + Called when the Discord bot receives a direct message on Discord. + + Args: + message (str) - Incoming text from Discord. + sender (tuple) - The Discord info for the sender in the form (id, nickname) + + Keyword args: + **kwargs (optional) - Unused by default, but can carry additional data from the protocol. + + """ + pass
+ +
[docs] def relay_to_channel( + self, message, to_channel, sender=None, from_channel=None, from_server=None, **kwargs + ): + """ + Formats and sends a Discord -> Evennia message. Called when the Discord bot receives a + channel message on Discord. + + Args: + message (str) - Incoming text from Discord. + to_channel (Channel) - The Evennia channel receiving the message + + Keyword args: + sender (tuple) - The Discord info for the sender in the form `(id, nickname)` + from_channel (str) - The Discord channel name + from_server (str) - The Discord server name + kwargs - Any additional keywords. Unused by default, but available for adding additional + flags or parameters. + + """ + + tag_str = "" + if from_channel and self.db.tag_channel: + tag_str = f"#{from_channel}" + if from_server and self.db.tag_guild: + if tag_str: + tag_str += f"@{from_server}" + else: + tag_str = from_server + + if tag_str: + tag_str = f"[{tag_str}] " + + if sender: + sender_name = f"|c{sender[1]}|n: " + + message = f"{tag_str}{sender_name}{message}" + to_channel.msg(message, senders=None, relayed=True)
+ +
[docs] def execute_cmd( + self, + txt=None, + session=None, + type=None, + sender=None, + **kwargs, + ): + """ + Take incoming data from protocol and send it to connected channel. This is + triggered by the bot_data_in Inputfunc. + + Keyword args: + txt (str) - The content of the message from Discord. + session (Session) - The protocol session this command came from. + type (str, optional) - Indicates the type of activity from Discord, if + the protocol pre-processed it. + sender (tuple) - Identifies the author of the Discord activity in a tuple of two + strings, in the form of (id, nickname) + + kwargs - Any additional data specific to a particular type of actions. The data for + any Discord actions not pre-processed by the protocol will also be passed via kwargs. + + """ + # normal channel message + if type == "channel": + channel_id = kwargs.get("channel_id") + channel_name = self.db.discord_channels.get(channel_id, {}).get("name", channel_id) + guild_id = kwargs.get("guild_id") + guild = self.db.guilds.get(guild_id) + + if channel_links := self.db.channels: + for ev_channel in [ + ev_chan for ev_chan, dc_id in channel_links if dc_id == channel_id + ]: + channel = search.channel_search(ev_channel) + if not channel: + continue + channel = channel[0] + self.relay_to_channel(txt, channel, sender, channel_name, guild) + + # direct message + elif type == "direct": + # pass on to the DM hook + self.direct_msg(txt, sender, **kwargs) + + # guild info update + elif type == "guild": + if guild_id := kwargs.get("guild_id"): + if not self.db.guilds: + self.db.guilds = {} + self.db.guilds[guild_id] = kwargs.get("guild_name", "Unidentified") + if not self.db.discord_channels: + self.db.discord_channels = {} + self.db.discord_channels.update(kwargs.get("channels", {}))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/accounts/manager.html b/docs/latest/_modules/evennia/accounts/manager.html new file mode 100644 index 0000000000..99007f1024 --- /dev/null +++ b/docs/latest/_modules/evennia/accounts/manager.html @@ -0,0 +1,407 @@ + + + + + + + + evennia.accounts.manager — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.accounts.manager

+"""
+The managers for the custom Account object and permissions.
+"""
+
+import datetime
+
+from django.conf import settings
+from django.contrib.auth.models import UserManager
+from django.utils import timezone
+
+from evennia.server import signals
+from evennia.typeclasses.managers import TypeclassManager, TypedObjectManager
+from evennia.utils.utils import class_from_module, dbid_to_obj, make_iter
+
+__all__ = ("AccountManager", "AccountDBManager")
+
+
+#
+# Account Manager
+#
+
+
+
[docs]class AccountDBManager(TypedObjectManager, UserManager): + """ + This AccountManager implements methods for searching + and manipulating Accounts directly from the database. + + Evennia-specific search methods (will return Characters if + possible or a Typeclass/list of Typeclassed objects, whereas + Django-general methods will return Querysets or database objects): + + dbref (converter) + dbref_search + get_dbref_range + object_totals + typeclass_search + num_total_accounts + get_connected_accounts + get_recently_created_accounts + get_recently_connected_accounts + get_account_from_email + get_account_from_uid + get_account_from_name + account_search (equivalent to evennia.search_account) + + """ + +
[docs] def num_total_accounts(self): + """ + Get total number of accounts. + + Returns: + count (int): The total number of registered accounts. + + """ + return self.count()
+ +
[docs] def get_connected_accounts(self): + """ + Get all currently connected accounts. + + Returns: + count (list): Account objects with currently + connected sessions. + + """ + return self.filter(db_is_connected=True)
+ +
[docs] def get_recently_created_accounts(self, days=7): + """ + Get accounts recently created. + + Args: + days (int, optional): How many days in the past "recently" means. + + Returns: + accounts (list): The Accounts created the last `days` interval. + + """ + end_date = timezone.now() + tdelta = datetime.timedelta(days) + start_date = end_date - tdelta + return self.filter(date_joined__range=(start_date, end_date))
+ +
[docs] def get_recently_connected_accounts(self, days=7): + """ + Get accounts recently connected to the game. + + Args: + days (int, optional): Number of days backwards to check + + Returns: + accounts (list): The Accounts connected to the game in the + last `days` interval. + + """ + end_date = timezone.now() + tdelta = datetime.timedelta(days) + start_date = end_date - tdelta + return self.filter(last_login__range=(start_date, end_date)).order_by("-last_login")
+ +
[docs] def get_account_from_email(self, uemail): + """ + Search account by + Returns an account object based on email address. + + Args: + uemail (str): An email address to search for. + + Returns: + account (Account): A found account, if found. + + """ + return self.filter(email__iexact=uemail)
+ +
[docs] def get_account_from_uid(self, uid): + """ + Get an account by id. + + Args: + uid (int): Account database id. + + Returns: + account (Account): The result. + + """ + try: + return self.get(id=uid) + except self.model.DoesNotExist: + return None
+ +
[docs] def get_account_from_name(self, uname): + """ + Get account object based on name. + + Args: + uname (str): The Account name to search for. + + Returns: + account (Account): The found account. + + """ + try: + return self.get(username__iexact=uname) + except self.model.DoesNotExist: + return None
+ +
[docs] def search_account(self, ostring, exact=True, typeclass=None): + """ + Searches for a particular account by name or + database id. + + Args: + ostring (str or int): A key string or database id. + exact (bool, optional): Only valid for string matches. If + `True`, requires exact (non-case-sensitive) match, + otherwise also match also keys containing the `ostring` + (non-case-sensitive fuzzy match). + typeclass (str or Typeclass, optional): Limit the search only to + accounts of this typeclass. + Returns: + Queryset: A queryset (an iterable) with 0, 1 or more matches. + + """ + dbref = self.dbref(ostring) + if dbref or dbref == 0: + # dbref search is always exact + dbref_match = self.search_dbref(dbref) + if dbref_match: + return dbref_match + + query = {"username__iexact" if exact else "username__icontains": ostring} + if typeclass: + # we accept both strings and actual typeclasses + if callable(typeclass): + typeclass = f"{typeclass.__module__}.{typeclass.__name__}" + else: + typeclass = str(typeclass) + query["db_typeclass_path"] = typeclass + if exact: + matches = self.filter(**query) + else: + matches = self.filter(**query) + if not matches: + # try alias match + matches = self.filter( + db_tags__db_tagtype__iexact="alias", + **{"db_tags__db_key__iexact" if exact else "db_tags__db_key__icontains": ostring}, + ) + return matches
+ +
[docs] def create_account( + self, + key, + email, + password, + typeclass=None, + is_superuser=False, + locks=None, + permissions=None, + tags=None, + attributes=None, + report_to=None, + ): + """ + This creates a new account. + + Args: + key (str): The account's name. This should be unique. + email (str or None): Email on valid addr@addr.domain form. If + the empty string, will be set to None. + password (str): Password in cleartext. + + Keyword Args: + typeclass (str): The typeclass to use for the account. + is_superuser (bool): Whether or not this account is to be a superuser + locks (str): Lockstring. + permission (list): List of permission strings. + tags (list): List of Tags on form `(key, category[, data])` + attributes (list): List of Attributes on form + `(key, value [, category, [,lockstring [, default_pass]]])` + report_to (Object): An object with a msg() method to report + errors to. If not given, errors will be logged. + + Returns: + Account: The newly created Account. + Raises: + ValueError: If `key` already exists in database. + + + Notes: + Usually only the server admin should need to be superuser, all + other access levels can be handled with more fine-grained + permissions or groups. A superuser bypasses all lock checking + operations and is thus not suitable for play-testing the game. + + """ + typeclass = typeclass if typeclass else settings.BASE_ACCOUNT_TYPECLASS + locks = make_iter(locks) if locks is not None else None + permissions = make_iter(permissions) if permissions is not None else None + tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None + + if isinstance(typeclass, str): + # a path is given. Load the actual typeclass. + typeclass = class_from_module(typeclass, settings.TYPECLASS_PATHS) + + # setup input for the create command. We use AccountDB as baseclass + # here to give us maximum freedom (the typeclasses will load + # correctly when each object is recovered). + + if not email: + email = None + if self.model.objects.filter(username__iexact=key): + raise ValueError("An Account with the name '%s' already exists." % key) + + # this handles a given dbref-relocate to an account. + report_to = dbid_to_obj(report_to, self.model) + + # create the correct account entity, using the setup from + # base django auth. + now = timezone.now() + email = typeclass.objects.normalize_email(email) + new_account = typeclass( + username=key, + email=email, + is_staff=is_superuser, + is_superuser=is_superuser, + last_login=now, + date_joined=now, + ) + if password is not None: + # the password may be None for 'fake' accounts, like bots + valid, error = new_account.validate_password(password, new_account) + if not valid: + raise error + + new_account.set_password(password) + + new_account._createdict = dict( + locks=locks, + permissions=permissions, + report_to=report_to, + tags=tags, + attributes=attributes, + ) + # saving will trigger the signal that calls the + # at_first_save hook on the typeclass, where the _createdict + # can be used. + new_account.save() + + # note that we don't send a signal here, that is sent from the Account.create helper method + # instead. + + return new_account
+ + # back-compatibility alias + account_search = search_account
+ + +
[docs]class AccountManager(AccountDBManager, TypeclassManager): + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/accounts/models.html b/docs/latest/_modules/evennia/accounts/models.html new file mode 100644 index 0000000000..0ec36ae520 --- /dev/null +++ b/docs/latest/_modules/evennia/accounts/models.html @@ -0,0 +1,284 @@ + + + + + + + + evennia.accounts.models — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.accounts.models

+"""
+Account
+
+The account class is an extension of the default Django user class,
+and is customized for the needs of Evennia.
+
+We use the Account to store a more mud-friendly style of permission
+system as well as to allow the admin more flexibility by storing
+attributes on the Account.  Within the game we should normally use the
+Account manager's methods to create users so that permissions are set
+correctly.
+
+To make the Account model more flexible for your own game, it can also
+persistently store attributes of its own. This is ideal for extra
+account info and OOC account configuration variables etc.
+
+"""
+from django.conf import settings
+from django.contrib.auth.models import AbstractUser
+from django.db import models
+from django.utils.encoding import smart_str
+
+from evennia.accounts.manager import AccountDBManager
+from evennia.server.signals import SIGNAL_ACCOUNT_POST_RENAME
+from evennia.typeclasses.models import TypedObject
+from evennia.utils.utils import make_iter
+
+__all__ = ("AccountDB",)
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+_DA = object.__delattr__
+
+_TYPECLASS = None
+
+
+# ------------------------------------------------------------
+#
+# AccountDB
+#
+# ------------------------------------------------------------
+
+
+
[docs]class AccountDB(TypedObject, AbstractUser): + """ + This is a special model using Django's 'profile' functionality + and extends the default Django User model. It is defined as such + by use of the variable AUTH_PROFILE_MODULE in the settings. + One accesses the fields/methods. We try use this model as much + as possible rather than User, since we can customize this to + our liking. + + The TypedObject supplies the following (inherited) properties: + + - key - main name + - typeclass_path - the path to the decorating typeclass + - typeclass - auto-linked typeclass + - date_created - time stamp of object creation + - permissions - perm strings + - dbref - #id of object + - db - persistent attribute storage + - ndb - non-persistent attribute storage + + The AccountDB adds the following properties: + + - is_connected - If any Session is currently connected to this Account + - name - alias for user.username + - sessions - sessions connected to this account + - is_superuser - bool if this account is a superuser + - is_bot - bool if this account is a bot and not a real account + + """ + + # + # AccountDB Database model setup + # + # inherited fields (from TypedObject): + # db_key, db_typeclass_path, db_date_created, db_permissions + + # store a connected flag here too, not just in sessionhandler. + # This makes it easier to track from various out-of-process locations + db_is_connected = models.BooleanField( + default=False, + verbose_name="is_connected", + help_text="If player is connected to game or not", + ) + # database storage of persistant cmdsets. + db_cmdset_storage = models.CharField( + "cmdset", + max_length=255, + null=True, + help_text=( + "optional python path to a cmdset class. If creating a Character, this will " + "default to settings.CMDSET_CHARACTER." + ), + ) + # marks if this is a "virtual" bot account object + db_is_bot = models.BooleanField( + default=False, verbose_name="is_bot", help_text="Used to identify irc/rss bots" + ) + + # Database manager + objects = AccountDBManager() + + # defaults + __defaultclasspath__ = "evennia.accounts.accounts.DefaultAccount" + __applabel__ = "accounts" + __settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS + + class Meta: + verbose_name = "Account" + + # cmdset_storage property + # This seems very sensitive to caching, so leaving it be for now /Griatch + # @property + def __cmdset_storage_get(self): + """ + Getter. Allows for value = self.name. Returns a list of cmdset_storage. + """ + storage = self.db_cmdset_storage + # we need to check so storage is not None + return [path.strip() for path in storage.split(",")] if storage else [] + + # @cmdset_storage.setter + def __cmdset_storage_set(self, value): + """ + Setter. Allows for self.name = value. Stores as a comma-separated + string. + """ + _SA(self, "db_cmdset_storage", ",".join(str(val).strip() for val in make_iter(value))) + _GA(self, "save")() + + # @cmdset_storage.deleter + def __cmdset_storage_del(self): + "Deleter. Allows for del self.name" + _SA(self, "db_cmdset_storage", None) + _GA(self, "save")() + + cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set, __cmdset_storage_del) + + # + # property/field access + # + + def __str__(self): + return smart_str(f"{self.name}(account {self.dbid})") + + def __repr__(self): + return f"{self.name}(account#{self.dbid})" + + # @property + def __username_get(self): + return self.username + + def __username_set(self, value): + old_name = self.username + self.username = value + self.save(update_fields=["username"]) + SIGNAL_ACCOUNT_POST_RENAME.send(self, old_name=old_name, new_name=value) + + def __username_del(self): + del self.username + + # aliases + name = property(__username_get, __username_set, __username_del) + key = property(__username_get, __username_set, __username_del) + + # @property + def __uid_get(self): + "Getter. Retrieves the user id" + return self.id + + def __uid_set(self, value): + raise Exception("User id cannot be set!") + + def __uid_del(self): + raise Exception("User id cannot be deleted!") + + uid = property(__uid_get, __uid_set, __uid_del)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/cmdhandler.html b/docs/latest/_modules/evennia/commands/cmdhandler.html new file mode 100644 index 0000000000..a87788e332 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/cmdhandler.html @@ -0,0 +1,909 @@ + + + + + + + + evennia.commands.cmdhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.cmdhandler

+"""
+Command handler
+
+This module contains the infrastructure for accepting commands on the
+command line. The processing of a command works as follows:
+
+1. The calling object (caller) is analyzed based on its callertype.
+2. Cmdsets are gathered from different sources:
+   - object cmdsets: all objects at caller's location are scanned for non-empty
+     cmdsets. This includes cmdsets on exits.
+   - caller: the caller is searched for its own currently active cmdset.
+   - account: lastly the cmdsets defined on caller.account are added.
+3. The collected cmdsets are merged together to a combined, current cmdset.
+4. If the input string is empty -> check for CMD_NOINPUT command in
+   current cmdset or fallback to error message. Exit.
+5. The Command Parser is triggered, using the current cmdset to analyze the
+   input string for possible command matches.
+6. If multiple matches are found -> check for CMD_MULTIMATCH in current
+   cmdset, or fallback to error message. Exit.
+7. If no match was found -> check for CMD_NOMATCH in current cmdset or
+   fallback to error message. Exit.
+8. At this point we have found a normal command. We assign useful variables to it that
+   will be available to the command coder at run-time.
+9. We have a unique cmdobject, primed for use. Call all hooks:
+   `at_pre_cmd()`, `cmdobj.parse()`, `cmdobj.func()` and finally `at_post_cmd()`.
+10. Return deferred that will fire with the return from `cmdobj.func()` (unused by default).
+
+"""
+
+import types
+from collections import defaultdict
+from copy import copy
+from itertools import chain
+from traceback import format_exc
+from weakref import WeakValueDictionary
+
+from django.conf import settings
+from django.utils.translation import gettext as _
+from twisted.internet import reactor
+from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.internet.task import deferLater
+
+from evennia.commands.command import InterruptCommand
+from evennia.commands.cmdset import CmdSet
+from evennia.utils import logger, utils
+from evennia.utils.utils import string_suggestions
+
+_IN_GAME_ERRORS = settings.IN_GAME_ERRORS
+
+__all__ = ("cmdhandler", "InterruptCommand")
+_GA = object.__getattribute__
+_CMDSET_MERGE_CACHE = WeakValueDictionary()
+
+# tracks recursive calls by each caller
+# to avoid infinite loops (commands calling themselves)
+_COMMAND_NESTING = defaultdict(lambda: 0)
+_COMMAND_RECURSION_LIMIT = 10
+
+# This decides which command parser is to be used.
+# You have to restart the server for changes to take effect.
+_COMMAND_PARSER = utils.variable_from_module(*settings.COMMAND_PARSER.rsplit(".", 1))
+
+# System command names - import these variables rather than trying to
+# remember the actual string constants. If not defined, Evennia
+# hard-coded defaults are used instead.
+
+# command to call if user just presses <return> with no input
+CMD_NOINPUT = "__noinput_command"
+# command to call if no command match was found
+CMD_NOMATCH = "__nomatch_command"
+# command to call if multiple command matches were found
+CMD_MULTIMATCH = "__multimatch_command"
+# command to call as the very first one when the user connects.
+# (is expected to display the login screen)
+CMD_LOGINSTART = "__unloggedin_look_command"
+
+
+# Function for handling multiple command matches.
+_SEARCH_AT_RESULT = utils.variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1))
+
+# Output strings. The first is the IN_GAME_ERRORS return, the second
+# is the normal "production message to echo to the account.
+
+_ERROR_UNTRAPPED = (
+    _(
+        """
+An untrapped error occurred.
+"""
+    ),
+    _(
+        """
+An untrapped error occurred. Please file a bug report detailing the steps to reproduce.
+"""
+    ),
+)
+
+_ERROR_CMDSETS = (
+    _(
+        """
+A cmdset merger-error occurred. This is often due to a syntax
+error in one of the cmdsets to merge.
+"""
+    ),
+    _(
+        """
+A cmdset merger-error occurred. Please file a bug report detailing the
+steps to reproduce.
+"""
+    ),
+)
+
+_ERROR_NOCMDSETS = (
+    _(
+        """
+No command sets found! This is a critical bug that can have
+multiple causes.
+"""
+    ),
+    _(
+        """
+No command sets found! This is a sign of a critical bug.  If
+disconnecting/reconnecting doesn't" solve the problem, try to contact
+the server admin through" some other means for assistance.
+"""
+    ),
+)
+
+_ERROR_CMDHANDLER = (
+    _(
+        """
+A command handler bug occurred. If this is not due to a local change,
+please file a bug report with the Evennia project, including the
+traceback and steps to reproduce.
+"""
+    ),
+    _(
+        """
+A command handler bug occurred. Please notify staff - they should
+likely file a bug report with the Evennia project.
+"""
+    ),
+)
+
+_ERROR_RECURSION_LIMIT = _(
+    "Command recursion limit ({recursion_limit}) reached for '{raw_cmdname}' ({cmdclass})."
+)
+
+
+# delayed imports
+_GET_INPUT = None
+
+
+# helper functions
+def err_helper(raw_string, cmdid=None):
+    if cmdid is not None:
+        return raw_string, {"cmdid": cmdid}
+    return raw_string
+
+
+def _msg_err(receiver, stringtuple, cmdid=None):
+    """
+    Helper function for returning an error to the caller.
+
+    Args:
+        receiver (Object): object to get the error message.
+        stringtuple (tuple): tuple with two strings - one for the
+            _IN_GAME_ERRORS mode (with the traceback) and one with the
+            production string (with a timestamp) to be shown to the user.
+
+    """
+    string = _("{traceback}\n{errmsg}\n(Traceback was logged {timestamp}).")
+    timestamp = logger.timeformat()
+    tracestring = format_exc()
+    logger.log_trace()
+    if _IN_GAME_ERRORS:
+        out = string.format(
+            traceback=tracestring, errmsg=stringtuple[0].strip(), timestamp=timestamp
+        ).strip()
+    else:
+        out = string.format(
+            traceback=tracestring.splitlines()[-1],
+            errmsg=stringtuple[1].strip(),
+            timestamp=timestamp,
+        ).strip()
+    receiver.msg(err_helper(out, cmdid=cmdid))
+
+
+def _process_input(caller, prompt, result, cmd, generator):
+    """
+    Specifically handle the get_input value to send to _progressive_cmd_run as
+    part of yielding from a Command's `func`.
+
+    Args:
+        caller (Character, Account or Session): the caller.
+        prompt (str): The sent prompt.
+        result (str): The unprocessed answer.
+        cmd (Command): The command itself.
+        generator (GeneratorType): The generator.
+
+    Returns:
+        result (bool): Always `False` (stop processing).
+
+    """
+    # We call it using a Twisted deferLater to make sure the input is properly closed.
+    deferLater(reactor, 0, _progressive_cmd_run, cmd, generator, response=result)
+    return False
+
+
+def _progressive_cmd_run(cmd, generator, response=None):
+    """
+    Progressively call the command that was given in argument. Used
+    when `yield` is present in the Command's `func()` method.
+
+    Args:
+        cmd (Command): the command itself.
+        generator (GeneratorType): the generator describing the processing.
+        reponse (str, optional): the response to send to the generator.
+
+    Raises:
+        ValueError: If the func call yields something not identifiable as a
+            time-delay or a string prompt.
+
+    Note:
+        This function is responsible for executing the command, if
+        the func() method contains 'yield' instructions.  The yielded
+        value will be accessible at each step and will affect the
+        process.  If the value is a number, just delay the execution
+        of the command.  If it's a string, wait for the user input.
+
+    """
+    global _GET_INPUT
+    if not _GET_INPUT:
+        from evennia.utils.evmenu import get_input as _GET_INPUT
+
+    try:
+        if response is None:
+            value = next(generator)
+        else:
+            value = generator.send(response)
+    except StopIteration:
+        # duplicated from cmdhandler._run_command, to have these
+        # run in the right order while staying inside the deferred
+        cmd.at_post_cmd()
+        if cmd.save_for_next:
+            # store a reference to this command, possibly
+            # accessible by the next command.
+            cmd.caller.ndb.last_cmd = copy(cmd)
+        else:
+            cmd.caller.ndb.last_cmd = None
+    else:
+        if isinstance(value, (int, float)):
+            utils.delay(value, _progressive_cmd_run, cmd, generator)
+        elif isinstance(value, str):
+            _GET_INPUT(cmd.caller, value, _process_input, cmd=cmd, generator=generator)
+        else:
+            raise ValueError("unknown type for a yielded value in command: {}".format(type(value)))
+
+
+# custom Exceptions
+
+
+class NoCmdSets(Exception):
+    "No cmdsets found. Critical error."
+    pass
+
+
+class ExecSystemCommand(Exception):
+    "Run a system command"
+
+    def __init__(self, syscmd, sysarg):
+        self.args = (syscmd, sysarg)  # needed by exception error handling
+        self.syscmd = syscmd
+        self.sysarg = sysarg
+
+
+class ErrorReported(Exception):
+    "Re-raised when a subsructure already reported the error"
+
+    def __init__(self, raw_string):
+        self.args = (raw_string,)
+        self.raw_string = raw_string
+
+
+# Helper function
+def generate_cmdset_providers(called_by, session=None):
+    cmdset_providers = dict()
+    cmdset_providers.update(called_by.get_cmdset_providers())
+    if session and session is not called_by:
+        cmdset_providers.update(session.get_cmdset_providers())
+
+    cmdset_providers_list = list(cmdset_providers.values())
+    cmdset_providers_list.sort(key=lambda x: getattr(x, "cmdset_provider_order", 0))
+    # sort the dictionary by priority. This can be done because Python now cares about dictionary insert order.
+    cmdset_providers = {c.cmdset_provider_type: c for c in cmdset_providers_list}
+
+    if not cmdset_providers:
+        raise RuntimeError("cmdhandler: no command objects found.")
+
+    # the caller will be the one to receive messages and excert its permissions.
+    # we assign the caller with preference 'bottom up'
+    caller = cmdset_providers_list[-1]
+
+    cmdset_providers_errors_list = sorted(
+        cmdset_providers_list, key=lambda x: getattr(x, "cmdset_provider_error_order", 0)
+    )
+
+    # The error_to is the default recipient for errors. Tries to make sure an account
+    # does not get spammed for errors while preserving character mirroring.
+    error_to = cmdset_providers_errors_list[-1]
+
+    return cmdset_providers, cmdset_providers_list, cmdset_providers_errors_list, caller, error_to
+
+
+@inlineCallbacks
+def get_and_merge_cmdsets(
+    caller, cmdset_providers, callertype, raw_string, report_to=None, cmdid=None
+):
+    """
+    Gather all relevant cmdsets and merge them.
+
+    Args:
+        caller (Session, Account or Object): The entity executing the command. Which
+            type of object this is depends on the current game state; for example
+            when the user is not logged in, this will be a Session, when being OOC
+            it will be an Account and when puppeting an object this will (often) be
+            a Character Object. In the end it depends on where the cmdset is stored.
+        cmdset_providers (list): A list of sorted objects which provide cmdsets.
+        callertype (str): This identifies caller as either "account", "object" or "session"
+            to avoid having to do this check internally.
+        raw_string (str): The input string. This is only used for error reporting.
+        report_to (Object, optional): If given, this object will receive error messages
+
+    Returns:
+        cmdset (Deferred): This deferred fires with the merged cmdset
+        result once merger finishes.
+
+    Notes:
+        The cdmsets are merged in order or generality, so that the
+        Object's cmdset is merged last (and will thus take precedence
+        over same-named and same-prio commands on Account and Session).
+
+    """
+    try:
+
+        @inlineCallbacks
+        def _get_local_obj_cmdsets(obj):
+            """
+            Helper-method; Get Object-level cmdsets
+
+            """
+            # Gather cmdsets from location, objects in location or carried
+            try:
+                local_obj_cmdsets = [None]
+                try:
+                    location = obj.location
+                except Exception:
+                    location = None
+                if location:
+                    # Gather all cmdsets stored on objects in the room and
+                    # also in the caller's inventory and the location itself
+                    local_objlist = yield (
+                        location.contents_get(exclude=obj) + obj.contents_get() + [location]
+                    )
+                    local_objlist = [o for o in local_objlist if not o._is_deleted]
+                    for lobj in local_objlist:
+                        try:
+                            # call hook in case we need to do dynamic changing to cmdset
+                            _GA(lobj, "at_cmdset_get")(caller=caller)
+                        except Exception:
+                            logger.log_trace()
+                    # the call-type lock is checked here, it makes sure an account
+                    # is not seeing e.g. the commands on a fellow account (which is why
+                    # the no_superuser_bypass must be True)
+                    local_obj_cmdsets = yield list(
+                        chain.from_iterable(
+                            lobj.cmdset.cmdset_stack
+                            for lobj in local_objlist
+                            if (
+                                lobj.cmdset.current
+                                and lobj.access(
+                                    caller, access_type="call", no_superuser_bypass=True
+                                )
+                            )
+                        )
+                    )
+                    for cset in local_obj_cmdsets:
+                        # This is necessary for object sets, or we won't be able to
+                        # separate the command sets from each other in a busy room. We
+                        # only keep the setting if duplicates were set to False/True
+                        # explicitly.
+                        cset.old_duplicates = cset.duplicates
+                        cset.duplicates = True if cset.duplicates is None else cset.duplicates
+                returnValue(local_obj_cmdsets)
+            except Exception:
+                _msg_err(caller, _ERROR_CMDSETS)
+                raise ErrorReported(raw_string)
+
+        @inlineCallbacks
+        def _get_cmdsets(obj, current):
+            """
+            Helper method; Get cmdset while making sure to trigger all
+            hooks safely. Returns the stack and the valid options.
+
+            """
+            try:
+                yield obj.at_cmdset_get(caller=caller, current=current)
+            except Exception:
+                _msg_err(caller, _ERROR_CMDSETS)
+                raise ErrorReported(raw_string)
+            try:
+                returnValue(obj.get_cmdsets(caller=caller, current=current))
+            except AttributeError:
+                returnValue(((None, None, None), []))
+
+        local_obj_cmdsets = []
+
+        current_cmdset = CmdSet()
+        object_cmdsets = list()
+        for cmdobj in cmdset_providers:
+            current, cur_cmdsets = yield _get_cmdsets(cmdobj, current_cmdset)
+            if current:
+                current_cmdset = current_cmdset + current
+            if cur_cmdsets:
+                object_cmdsets += cur_cmdsets
+            match cmdobj.cmdset_provider_type:
+                case "object":
+                    if not current.no_objs:
+                        local_obj_cmdsets = yield _get_local_obj_cmdsets(cmdobj)
+                        if current.no_exits:
+                            # filter out all exits
+                            local_obj_cmdsets = [
+                                cmdset for cmdset in local_obj_cmdsets if cmdset.key != "ExitCmdSet"
+                            ]
+                        object_cmdsets += local_obj_cmdsets
+
+        # weed out all non-found sets
+        cmdsets = yield [
+            cmdset for cmdset in object_cmdsets if cmdset and cmdset.key != "_EMPTY_CMDSET"
+        ]
+        # report cmdset errors to user (these should already have been logged)
+        yield [
+            report_to.msg(err_helper(cmdset.errmessage, cmdid=cmdid))
+            for cmdset in cmdsets
+            if cmdset.key == "_CMDSET_ERROR"
+        ]
+
+        if cmdsets:
+            # faster to do tuple on list than to build tuple directly
+            mergehash = tuple([id(cmdset) for cmdset in cmdsets])
+            if mergehash in _CMDSET_MERGE_CACHE:
+                # cached merge exist; use that
+                cmdset = _CMDSET_MERGE_CACHE[mergehash]
+            else:
+                # we group and merge all same-prio cmdsets separately (this avoids
+                # order-dependent clashes in certain cases, such as
+                # when duplicates=True)
+                tempmergers = {}
+                for cmdset in cmdsets:
+                    prio = cmdset.priority
+                    if prio in tempmergers:
+                        # merge same-prio cmdset together separately
+                        tempmergers[prio] = yield tempmergers[prio] + cmdset
+                    else:
+                        tempmergers[prio] = cmdset
+
+                # sort cmdsets after reverse priority (highest prio are merged in last)
+                sorted_cmdsets = yield sorted(list(tempmergers.values()), key=lambda x: x.priority)
+
+                # Merge all command sets into one, beginning with the lowest-prio one
+                cmdset = sorted_cmdsets[0]
+                for merging_cmdset in sorted_cmdsets[1:]:
+                    cmdset = yield cmdset + merging_cmdset
+                # store the original, ungrouped set for diagnosis
+                cmdset.merged_from = cmdsets
+                # cache
+                _CMDSET_MERGE_CACHE[mergehash] = cmdset
+        else:
+            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!
+        # TODO - removed because this causes cmdset overlaps across sessions/accounts
+        # - see https://github.com/evennia/evennia/issues/2855
+        # if cmdset:
+        #     caller.cmdset.current = cmdset
+
+        returnValue(cmdset)
+    except ErrorReported:
+        raise
+    except Exception:
+        _msg_err(caller, _ERROR_CMDSETS)
+        raise
+        # raise ErrorReported
+
+
+# Main command-handler function
+
+
+
[docs]@inlineCallbacks +def cmdhandler( + called_by, + raw_string, + _testing=False, + callertype="session", + session=None, + cmdobj=None, + cmdobj_key=None, + **kwargs, +): + """ + This is the main mechanism that handles any string sent to the engine. + + Args: + called_by (Session, Account or Object): Object from which this + command was called. which this was called from. What this is + depends on the game state. + raw_string (str): The command string as given on the command line. + _testing (bool, optional): Used for debug purposes and decides if we + should actually execute the command or not. If True, the + command instance will be returned. + callertype (str, optional): One of "session", "account" or + "object". These are treated in decending order, so when the + Session is the caller, it will merge its own cmdset into + cmdsets from both Account and eventual puppeted Object (and + cmdsets in its room etc). An Account will only include its own + cmdset and the Objects and so on. Merge order is the same + order, so that Object cmdsets are merged in last, giving them + precendence for same-name and same-prio commands. + session (Session, optional): Relevant if callertype is "account" - the session will help + retrieve the correct cmdsets from puppeted objects. + cmdobj (Command, optional): If given a command instance, this will be executed using + `called_by` as the caller, `raw_string` representing its arguments and (optionally) + `cmdobj_key` as its input command name. No cmdset lookup will be performed but + all other options apply as normal. This allows for running a specific Command + within the command system mechanism. + cmdobj_key (string, optional): Used together with `cmdobj` keyword to specify + which cmdname should be assigned when calling the specified Command instance. This + is made available as `self.cmdstring` when the Command runs. + If not given, the command will be assumed to be called as `cmdobj.key`. + + Keyword Args: + kwargs (any): other keyword arguments will be assigned as named variables on the + retrieved command object *before* it is executed. This is unused + in default Evennia but may be used by code to set custom flags or + special operating conditions for a command as it executes. + + Returns: + deferred (Deferred): This deferred is fired with the return + value of the command's `func` method. This is not used in + default Evennia. + + """ + cmdid = kwargs.get("cmdid", None) + + @inlineCallbacks + def _run_command(cmd, cmdname, args, raw_cmdname, cmdset, session, account, cmdset_providers): + """ + Helper function: This initializes and runs the Command + instance once the parser has identified it as either a normal + command or one of the system commands. + + Args: + cmd (Command): Command object + cmdname (str): Name of command + args (str): extra text entered after the identified command + raw_cmdname (str): Name of Command, unaffected by eventual + prefix-stripping (if no prefix-stripping, this is the same + as cmdname). + cmdset (CmdSet): Command sert the command belongs to (if any).. + session (Session): Session of caller (if any). + account (Account): Account of caller (if any). + cmdset_providers (dict): Dictionary of all cmdset-providing objects. + + Returns: + deferred (Deferred): this will fire with the return of the + command's `func` method. + + Raises: + RuntimeError: If command recursion limit was reached. + + """ + global _COMMAND_NESTING + try: + # Assign useful variables to the instance + cmd.caller = caller + cmd.cmdname = cmdname + cmd.raw_cmdname = raw_cmdname + cmd.cmdstring = cmdname # deprecated + cmd.args = args + cmd.cmdset = cmdset + cmd.cmdset_providers = cmdset_providers.copy() + cmd.session = session + cmd.account = account + cmd.raw_string = unformatted_raw_string + # cmd.obj # set via on-object cmdset handler for each command, + # since this may be different for every command when + # merging multiple cmdsets + + if _testing: + # only return the command instance + returnValue(cmd) + + # assign custom kwargs to found cmd object + for key, val in kwargs.items(): + setattr(cmd, key, val) + + _COMMAND_NESTING[called_by] += 1 + if _COMMAND_NESTING[called_by] > _COMMAND_RECURSION_LIMIT: + err = _ERROR_RECURSION_LIMIT.format( + recursion_limit=_COMMAND_RECURSION_LIMIT, + raw_cmdname=raw_cmdname, + cmdclass=cmd.__class__, + ) + raise RuntimeError(err) + + # pre-command hook + abort = yield cmd.at_pre_cmd() + if abort: + # abort sequence + returnValue(abort) + + # Parse and execute + yield cmd.parse() + + # main command code + # (return value is normally None) + ret = cmd.func() + if isinstance(ret, types.GeneratorType): + # cmd.func() is a generator, execute progressively + _progressive_cmd_run(cmd, ret) + ret = yield ret + # note that the _progressive_cmd_run will itself run + # the at_post_cmd etc as it finishes; this is a bit of + # code duplication but there seems to be no way to + # catch the StopIteration here (it's not in the same + # frame since this is in a deferred chain) + else: + ret = yield ret + # post-command hook + yield cmd.at_post_cmd() + + if cmd.save_for_next: + # store a reference to this command, possibly + # accessible by the next command. + caller.ndb.last_cmd = yield copy(cmd) + else: + caller.ndb.last_cmd = None + + # return result to the deferred + returnValue(ret) + + except InterruptCommand: + # Do nothing, clean exit + pass + except Exception: + _msg_err(caller, _ERROR_UNTRAPPED) + raise ErrorReported(raw_string) + finally: + _COMMAND_NESTING[called_by] -= 1 + + ( + cmdset_providers, + cmdset_providers_list, + cmdset_providers_list_error, + caller, + error_to, + ) = generate_cmdset_providers(called_by, session=session) + + account = cmdset_providers.get("account", None) + + try: # catch bugs in cmdhandler itself + try: # catch special-type commands + if cmdobj: + # the command object is already given + cmd = cmdobj() if callable(cmdobj) else cmdobj + cmdname = cmdobj_key if cmdobj_key else cmd.key + args = raw_string + unformatted_raw_string = "%s%s" % (cmdname, args) + cmdset = None + raw_cmdname = cmdname + # session = session + # account = account + + else: + # no explicit cmdobject given, figure it out + cmdset = yield get_and_merge_cmdsets( + caller, cmdset_providers_list, callertype, raw_string, cmdid=cmdid + ) + if not cmdset: + # this is bad and shouldn't happen. + raise NoCmdSets + # store the completely unmodified raw string - including + # whitespace and eventual prefixes-to-be-stripped. + unformatted_raw_string = raw_string + raw_string = raw_string.strip() + if not raw_string: + # Empty input. Test for system command instead. + syscmd = yield cmdset.get(CMD_NOINPUT) + sysarg = "" + raise ExecSystemCommand(syscmd, sysarg) + # Parse the input string and match to available cmdset. + # This also checks for permissions, so all commands in match + # are commands the caller is allowed to call. + matches = yield _COMMAND_PARSER(raw_string, cmdset, caller) + + # Deal with matches + + if len(matches) > 1: + # We have a multiple-match + syscmd = yield cmdset.get(CMD_MULTIMATCH) + sysarg = _("There were multiple matches.") + if syscmd: + # use custom CMD_MULTIMATCH + syscmd.matches = matches + else: + # fall back to default error handling + sysarg = yield _SEARCH_AT_RESULT( + [match[2] for match in matches], caller, query=matches[0][0] + ) + raise ExecSystemCommand(syscmd, sysarg) + + cmdname, args, cmd, raw_cmdname = "", "", None, "" + if len(matches) == 1: + # We have a unique command match. But it may still be invalid. + match = matches[0] + cmdname, args, cmd, raw_cmdname = (match[0], match[1], match[2], match[5]) + + if not matches: + # No commands match our entered command + syscmd = yield cmdset.get(CMD_NOMATCH) + if syscmd: + # use custom CMD_NOMATCH command + sysarg = raw_string + else: + # fallback to default error text + sysarg = _("Command '{command}' is not available.").format( + command=raw_string + ) + suggestions = string_suggestions( + raw_string, + cmdset.get_all_cmd_keys_and_aliases(caller), + cutoff=0.7, + maxnum=3, + ) + if suggestions: + sysarg += _(" Maybe you meant {command}?").format( + command=utils.list_to_string( + suggestions, endsep=_("or"), addquote=True + ) + ) + else: + sysarg += _(' Type "help" for help.') + raise ExecSystemCommand(syscmd, sysarg) + + if not cmd.retain_instance: + # making a copy allows multiple users to share the command also when yield is used + cmd = copy(cmd) + + # A normal command. + ret = yield _run_command( + cmd, cmdname, args, raw_cmdname, cmdset, session, account, cmdset_providers + ) + returnValue(ret) + + except ErrorReported as exc: + # this error was already reported, so we + # catch it here and don't pass it on. + logger.log_err("User input was: '%s'." % exc.raw_string) + + except ExecSystemCommand as exc: + # Not a normal command: run a system command, if available, + # or fall back to a return string. + syscmd = exc.syscmd + sysarg = exc.sysarg + + if syscmd: + ret = yield _run_command( + syscmd, + syscmd.key, + sysarg, + unformatted_raw_string, + cmdset, + session, + account, + cmdset_providers, + ) + returnValue(ret) + elif sysarg: + # return system arg + error_to.msg(err_helper(exc.sysarg, cmdid=cmdid)) + + except NoCmdSets: + # Critical error. + logger.log_err("No cmdsets found: %s" % caller) + error_to.msg(err_helper(_ERROR_NOCMDSETS, cmdid=cmdid)) + + except Exception: + # We should not end up here. If we do, it's a programming bug. + _msg_err(error_to, _ERROR_UNTRAPPED) + + except Exception: + # This catches exceptions in cmdhandler exceptions themselves + _msg_err(error_to, _ERROR_CMDHANDLER)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/cmdparser.html b/docs/latest/_modules/evennia/commands/cmdparser.html new file mode 100644 index 0000000000..c860209043 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/cmdparser.html @@ -0,0 +1,310 @@ + + + + + + + + evennia.commands.cmdparser — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.cmdparser

+"""
+The default command parser. Use your own by assigning
+`settings.COMMAND_PARSER` to a Python path to a module containing the
+replacing cmdparser function. The replacement parser must accept the
+same inputs as the default one.
+
+"""
+
+
+import re
+
+from django.conf import settings
+
+from evennia.utils.logger import log_trace
+
+_MULTIMATCH_REGEX = re.compile(settings.SEARCH_MULTIMATCH_REGEX, re.I + re.U)
+_CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
+
+
+
[docs]def create_match(cmdname, string, cmdobj, raw_cmdname): + """ + Builds a command match by splitting the incoming string and + evaluating the quality of the match. + + Args: + cmdname (str): Name of command to check for. + string (str): The string to match against. + cmdobj (str): The full Command instance. + raw_cmdname (str, optional): If CMD_IGNORE_PREFIX is set and the cmdname starts with + one of the prefixes to ignore, this contains the raw, unstripped cmdname, + otherwise it is None. + + Returns: + match (tuple): This is on the form (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname), + where `cmdname` is the command's name and `args` is the rest of the incoming + string, without said command name. `cmdobj` is + the Command instance, the cmdlen is the same as len(cmdname) and mratio + is a measure of how big a part of the full input string the cmdname + takes up - an exact match would be 1.0. Finally, the `raw_cmdname` is + the cmdname unmodified by eventual prefix-stripping. + + """ + cmdlen, strlen = len(str(cmdname)), len(str(string)) + mratio = 1 - (strlen - cmdlen) / (1.0 * strlen) + args = string[cmdlen:] + return (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname)
+ + +
[docs]def build_matches(raw_string, cmdset, include_prefixes=False): + """ + Build match tuples by matching raw_string against available commands. + + Args: + raw_string (str): Input string that can look in any way; the only assumption is + that the sought command's name/alias must be *first* in the string. + cmdset (CmdSet): The current cmdset to pick Commands from. + include_prefixes (bool): If set, include prefixes like @, ! etc (specified in settings) + in the match, otherwise strip them before matching. + + Returns: + matches (list) A list of match tuples created by `cmdparser.create_match`. + + """ + matches = [] + try: + orig_string = raw_string + if not include_prefixes and len(raw_string) > 1: + raw_string = raw_string.lstrip(_CMD_IGNORE_PREFIXES) + search_string = raw_string.lower() + for cmd in cmdset: + cmdname, raw_cmdname = cmd.match(search_string, include_prefixes=include_prefixes) + if cmdname: + matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname)) + except Exception: + log_trace("cmdhandler error. raw_input:%s" % raw_string) + return matches
+ + +
[docs]def try_num_differentiators(raw_string): + """ + Test if user tried to separate multi-matches with a number separator + (default 1-name, 2-name etc). This is usually called last, if no other + match was found. + + Args: + raw_string (str): The user input to parse. + + Returns: + mindex, new_raw_string (tuple): If a multimatch-separator was detected, + this is stripped out as an integer to separate between the matches. The + new_raw_string is the result of stripping out that identifier. If no + such form was found, returns (None, None). + + Example: + In the default configuration, entering 2-ball (e.g. in a room will more + than one 'ball' object), will lead to a multimatch and this function + will parse `"2-ball"` and return `(2, "ball")`. + + """ + # no matches found + num_ref_match = _MULTIMATCH_REGEX.match(raw_string) + if num_ref_match: + # the user might be trying to identify the command + # with a #num-command style syntax. We expect the regex to + # contain the groups "number" and "name". + mindex, new_raw_string = ( + num_ref_match.group("number"), + num_ref_match.group("name") + num_ref_match.group("args"), + ) + return int(mindex), new_raw_string + else: + return None, None
+ + +
[docs]def cmdparser(raw_string, cmdset, caller, match_index=None): + """ + This function is called by the cmdhandler once it has + gathered and merged all valid cmdsets valid for this particular parsing. + + Args: + raw_string (str): The unparsed text entered by the caller. + cmdset (CmdSet): The merged, currently valid cmdset + caller (Session, Account or Object): The caller triggering this parsing. + match_index (int, optional): Index to pick a given match in a + list of same-named command matches. If this is given, it suggests + this is not the first time this function was called: normally + the first run resulted in a multimatch, and the index is given + to select between the results for the second run. + + Returns: + matches (list): This is a list of match-tuples as returned by `create_match`. + If no matches were found, this is an empty list. + + Notes: + The cmdparser understand the following command combinations (where + [] marks optional parts. + + ``` + [cmdname[ cmdname2 cmdname3 ...] [the rest] + ``` + + A command may consist of any number of space-separated words of any + length, and contain any character. It may also be empty. + + The parser makes use of the cmdset to find command candidates. The + parser return a list of matches. Each match is a tuple with its + first three elements being the parsed cmdname (lower case), + the remaining arguments, and the matched cmdobject from the cmdset. + + """ + if not raw_string: + return [] + + # find matches, first using the full name + matches = build_matches(raw_string, cmdset, include_prefixes=True) + + if not matches or len(matches) > 1: + # no single match, try parsing for optional numerical tags like 1-cmd + # or cmd-2, cmd.2 etc + match_index, new_raw_string = try_num_differentiators(raw_string) + if match_index is not None: + matches.extend(build_matches(new_raw_string, cmdset, include_prefixes=True)) + + if not matches and _CMD_IGNORE_PREFIXES: + # still no match. Try to strip prefixes + raw_string = raw_string.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_string) > 1 else raw_string + matches = build_matches(raw_string, cmdset, include_prefixes=False) + + # only select command matches we are actually allowed to call. + matches = [match for match in matches if match[2].access(caller, "cmd")] + + # try to bring the number of matches down to 1 + if len(matches) > 1: + # See if it helps to analyze the match with preserved case but only if + # it leaves at least one match. + trimmed = [match for match in matches if raw_string.startswith(match[0])] + if trimmed: + matches = trimmed + + if len(matches) > 1: + # we still have multiple matches. Sort them by count quality. + matches = sorted(matches, key=lambda m: m[3]) + # only pick the matches with highest count quality + quality = [mat[3] for mat in matches] + matches = matches[-quality.count(quality[-1]) :] + + if len(matches) > 1: + # still multiple matches. Fall back to ratio-based quality. + matches = sorted(matches, key=lambda m: m[4]) + # only pick the highest rated ratio match + quality = [mat[4] for mat in matches] + matches = matches[-quality.count(quality[-1]) :] + + if len(matches) > 1 and match_index is not None: + # We couldn't separate match by quality, but we have an + # index argument to tell us which match to use. + if 0 < match_index <= len(matches): + matches = [matches[match_index - 1]] + else: + # we tried to give an index outside of the range - this means + # a no-match + matches = [] + + # no matter what we have at this point, we have to return it. + return matches
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/cmdset.html b/docs/latest/_modules/evennia/commands/cmdset.html new file mode 100644 index 0000000000..b071907d9f --- /dev/null +++ b/docs/latest/_modules/evennia/commands/cmdset.html @@ -0,0 +1,811 @@ + + + + + + + + evennia.commands.cmdset — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.cmdset

+"""
+
+A Command Set (CmdSet) holds a set of commands. The Cmdsets can be
+merged and combined to create new sets of commands in a
+non-destructive way. This makes them very powerful for implementing
+custom game states where different commands (or different variations
+of commands) are available to the accounts depending on circumstance.
+
+The available merge operations are partly borrowed from mathematical
+Set theory.
+
+
+* Union The two command sets are merged so that as many commands as
+    possible of each cmdset ends up in the merged cmdset. Same-name
+    commands are merged by priority.  This is the most common default.
+    Ex: A1,A3 + B1,B2,B4,B5 = A1,B2,A3,B4,B5
+* Intersect - Only commands found in *both* cmdsets (i.e. which have
+    same names) end up in the merged cmdset, with the higher-priority
+    cmdset replacing the lower one. Ex: A1,A3 + B1,B2,B4,B5 = A1
+* Replace -   The commands of this cmdset completely replaces the
+    lower-priority cmdset's commands, regardless of if same-name commands
+    exist. Ex: A1,A3 + B1,B2,B4,B5 = A1,A3
+* Remove -    This removes the relevant commands from the
+    lower-priority cmdset completely.  They are not replaced with
+    anything, so this in effects uses the high-priority cmdset as a filter
+    to affect the low-priority cmdset.  Ex: A1,A3 + B1,B2,B4,B5 = B2,B4,B5
+
+"""
+from weakref import WeakKeyDictionary
+
+from django.utils.translation import gettext as _
+
+from evennia.utils.utils import inherits_from, is_iter
+
+__all__ = ("CmdSet",)
+
+
+class _CmdSetMeta(type):
+    """
+    This metaclass makes some minor on-the-fly convenience fixes to
+    the cmdset class.
+
+    """
+
+    def __init__(cls, *args, **kwargs):
+        """
+        Fixes some things in the cmdclass
+
+        """
+        # by default we key the cmdset the same as the
+        # name of its class.
+        if not hasattr(cls, "key") or not cls.key:
+            cls.key = cls.__name__
+        cls.path = "%s.%s" % (cls.__module__, cls.__name__)
+
+        if not isinstance(cls.key_mergetypes, dict):
+            cls.key_mergetypes = {}
+
+        super().__init__(*args, **kwargs)
+
+
+
[docs]class CmdSet(object, metaclass=_CmdSetMeta): + """ + This class describes a unique cmdset that understands priorities. + CmdSets can be merged and made to perform various set operations + on each other. CmdSets have priorities that affect which of their + ingoing commands gets used. + + In the examples, cmdset A always have higher priority than cmdset B. + + key - the name of the cmdset. This can be used on its own for game + operations + + mergetype (partly from Set theory): + + Union - The two command sets are merged so that as many + commands as possible of each cmdset ends up in the + merged cmdset. Same-name commands are merged by + priority. This is the most common default. + Ex: A1,A3 + B1,B2,B4,B5 = A1,B2,A3,B4,B5 + Intersect - Only commands found in *both* cmdsets + (i.e. which have same names) end up in the merged + cmdset, with the higher-priority cmdset replacing the + lower one. Ex: A1,A3 + B1,B2,B4,B5 = A1 + Replace - The commands of this cmdset completely replaces + the lower-priority cmdset's commands, regardless + of if same-name commands exist. + Ex: A1,A3 + B1,B2,B4,B5 = A1,A3 + Remove - This removes the relevant commands from the + lower-priority cmdset completely. They are not + replaced with anything, so this in effects uses the + high-priority cmdset as a filter to affect the + low-priority cmdset. + Ex: A1,A3 + B1,B2,B4,B5 = B2,B4,B5 + + Note: Commands longer than 2 characters and starting + with double underscrores, like '__noinput_command' + are considered 'system commands' and are + excempt from all merge operations - they are + ALWAYS included across mergers and only affected + if same-named system commands replace them. + + priority- All cmdsets are always merged in pairs of two so that + the higher set's mergetype is applied to the + lower-priority cmdset. Default commands have priority 0, + high-priority ones like Exits and Channels have 10 and 9. + Priorities can be negative as well to give default + commands preference. + + duplicates - determines what happens when two sets of equal + priority merge (only). Defaults to None and has the first of them in the + merger (i.e. A above) automatically taking + 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, + but can be good for things like cmdsets on non-account + objects in a room, to allow the system to warn that + more than one 'ball' in the room has the same 'kick' + command defined on it, so it may offer a chance to + 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 + {CmdSetkeystring:mergetype}. Priorities still apply. + Example: {'Myevilcmdset','Replace'} which would make + sure for this set to always use 'Replace' on + Myevilcmdset no matter what overall mergetype this set + has. + + no_objs - don't include any commands from nearby objects + when searching for suitable commands + no_exits - ignore the names of exits when matching against + commands + no_channels - ignore the name of channels when matching against + commands (WARNING- this is dangerous since the + account can then not even ask staff for help if + something goes wrong) + + + """ + + key = "Unnamed CmdSet" + mergetype = "Union" + priority = 0 + + # 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 + # 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 + + persistent = False + key_mergetypes = {} + errmessage = "" + # pre-store properties to duplicate straight off + to_duplicate = ( + "key", + "cmdsetobj", + "no_exits", + "no_objs", + "no_channels", + "persistent", + "mergetype", + "priority", + "duplicates", + "errmessage", + ) + +
[docs] def __init__(self, cmdsetobj=None, key=None): + """ + Creates a new CmdSet instance. + + Args: + cmdsetobj (Session, Account, Object, optional): This is the database object + to which this particular instance of cmdset is related. It + is often a character but may also be a regular object, Account + or Session. + key (str, optional): The idenfier for this cmdset. This + helps if wanting to selectively remov cmdsets. + + """ + + if key: + self.key = key + self.commands = [] + self.system_commands = [] + self.actual_mergetype = self.mergetype + self.cmdsetobj = cmdsetobj + # this is set only on merged sets, in cmdhandler.py, in order to + # track, list and debug mergers correctly. + self.merged_from = [] + + # initialize system + self.at_cmdset_creation() + self._contains_cache = WeakKeyDictionary() # {}
+ + # Priority-sensitive merge operations for cmdsets + + def _union(self, cmdset_a, cmdset_b): + """ + Merge two sets using union merger + + Args: + cmdset_a (Cmdset): Cmdset given higher priority in the case of a tie. + cmdset_b (Cmdset): Cmdset given lower priority in the case of a tie. + + Returns: + cmdset_c (Cmdset): The result of A U B operation. + + Notes: + Union, C = A U B, means that C gets all elements from both A and B. + + """ + cmdset_c = cmdset_a._duplicate() + # we make copies, not refs by use of [:] + cmdset_c.commands = cmdset_a.commands[:] + if cmdset_a.duplicates and cmdset_a.priority == cmdset_b.priority: + cmdset_c.commands.extend(cmdset_b.commands) + else: + cmdset_c.commands.extend([cmd for cmd in cmdset_b if cmd not in cmdset_a]) + return cmdset_c + + def _intersect(self, cmdset_a, cmdset_b): + """ + Merge two sets using intersection merger + + Args: + cmdset_a (Cmdset): Cmdset given higher priority in the case of a tie. + cmdset_b (Cmdset): Cmdset given lower priority in the case of a tie. + + Returns: + cmdset_c (Cmdset): The result of A (intersect) B operation. + + Notes: + Intersection, C = A (intersect) B, means that C only gets the + parts of A and B that are the same (that is, the commands + of each set having the same name. Only the one of these + having the higher prio ends up in C). + + """ + cmdset_c = cmdset_a._duplicate() + if cmdset_a.duplicates and cmdset_a.priority == cmdset_b.priority: + for cmd in [cmd for cmd in cmdset_a if cmd in cmdset_b]: + cmdset_c.add(cmd) + cmdset_c.add(cmdset_b.get(cmd)) + else: + cmdset_c.commands = [cmd for cmd in cmdset_a if cmd in cmdset_b] + return cmdset_c + + def _replace(self, cmdset_a, cmdset_b): + """ + Replace the contents of one set with another + + Args: + cmdset_a (Cmdset): Cmdset replacing + cmdset_b (Cmdset): Cmdset to replace + + Returns: + cmdset_c (Cmdset): This is indentical to cmdset_a. + + Notes: + C = A, where B is ignored. + + """ + cmdset_c = cmdset_a._duplicate() + cmdset_c.commands = cmdset_a.commands[:] + return cmdset_c + + def _remove(self, cmdset_a, cmdset_b): + """ + Filter a set by another. + + Args: + cmdset_a (Cmdset): Cmdset acting as a removal filter. + cmdset_b (Cmdset): Cmdset to filter + + Returns: + cmdset_c (Cmdset): B, with all matching commands from A removed. + + Notes: + C = B - A, where A is used to remove the commands of B. + + """ + + cmdset_c = cmdset_a._duplicate() + cmdset_c.commands = [cmd for cmd in cmdset_b if cmd not in cmdset_a] + return cmdset_c + + def _instantiate(self, cmd): + """ + checks so that object is an instantiated command and not, say + a cmdclass. If it is, instantiate it. Other types, like + strings, are passed through. + + Args: + cmd (any): Entity to analyze. + + Returns: + result (any): An instantiated Command or the input unmodified. + + """ + if callable(cmd): + return cmd() + else: + return cmd + + def _duplicate(self): + """ + Returns a new cmdset with the same settings as this one (no + actual commands are copied over) + + Returns: + cmdset (Cmdset): A copy of the current cmdset. + """ + cmdset = CmdSet() + for key, val in ((key, getattr(self, key)) for key in self.to_duplicate): + if val != getattr(cmdset, key): + # only copy if different from default; avoid turning + # class-vars into instance vars + setattr(cmdset, key, val) + cmdset.key_mergetypes = self.key_mergetypes.copy() + return cmdset + + def __str__(self): + """ + Show all commands in cmdset when printing it. + + Returns: + commands (str): Representation of commands in Cmdset. + + """ + perm = "perm" if self.persistent 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): + """ + Allows for things like 'for cmd in cmdset': + + Returns: + iterable (iter): Commands in Cmdset. + + """ + return iter(self.commands) + + def __contains__(self, othercmd): + """ + Returns True if this cmdset contains the given command (as + defined by command name and aliases). This allows for things + like 'if cmd in cmdset' + + """ + ret = self._contains_cache.get(othercmd) + if ret is None: + ret = othercmd in self.commands + self._contains_cache[othercmd] = ret + return ret + + def __add__(self, cmdset_a): + """ + Merge this cmdset (B) with another cmdset (A) using the + operator, + + C = B + A + + Here, we (by convention) say that 'A is merged onto B to form + C'. The actual merge operation used in the 'addition' depends + on which priorities A and B have. The one of the two with the + highest priority will apply and give its properties to C. In + the case of a tie, A takes priority and replaces the + same-named commands in B unless A has the 'duplicate' variable + set (which means both sets' commands are kept). + """ + + # It's okay to merge with None + if not cmdset_a: + return self + + sys_commands_a = cmdset_a.get_system_cmds() + sys_commands_b = self.get_system_cmds() + + if self.priority <= cmdset_a.priority: + # A higher or equal priority to B + + # preserve system __commands + sys_commands = sys_commands_a + [ + cmd for cmd in sys_commands_b if cmd not in sys_commands_a + ] + + mergetype = cmdset_a.key_mergetypes.get(self.key, cmdset_a.mergetype) + if mergetype == "Intersect": + cmdset_c = self._intersect(cmdset_a, self) + elif mergetype == "Replace": + cmdset_c = self._replace(cmdset_a, self) + elif mergetype == "Remove": + cmdset_c = self._remove(cmdset_a, self) + else: # Union + cmdset_c = self._union(cmdset_a, self) + + # 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 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 + + # preserver system __commands + sys_commands = sys_commands_b + [ + cmd for cmd in sys_commands_a if cmd not in sys_commands_b + ] + + mergetype = self.key_mergetypes.get(cmdset_a.key, self.mergetype) + if mergetype == "Intersect": + cmdset_c = self._intersect(self, cmdset_a) + elif mergetype == "Replace": + cmdset_c = self._replace(self, cmdset_a) + elif mergetype == "Remove": + cmdset_c = self._remove(self, cmdset_a) + else: # Union + cmdset_c = self._union(self, cmdset_a) + + # 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 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. + # This is used for diagnosis. + cmdset_c.actual_mergetype = mergetype + + # print "__add__ for %s (prio %i) called with %s (prio %i)." % (self.key, self.priority, + # cmdset_a.key, cmdset_a.priority) + + # return the system commands to the cmdset + cmdset_c.add(sys_commands, allow_duplicates=True) + return cmdset_c + +
[docs] def add(self, cmd, allow_duplicates=False): + """ + Add a new command or commands to this CmdSet, a list of + commands or a cmdset to this cmdset. Note that this is *not* + a merge operation (that is handled by the + operator). + + Args: + cmd (Command, list, Cmdset): This allows for adding one or + more commands to this Cmdset in one go. If another Cmdset + is given, all its commands will be added. + allow_duplicates (bool, optional): If set, will not try to remove + duplicate cmds in the set. This is needed during the merge process + to avoid wiping commands coming from cmdsets with duplicate=True. + + Notes: + If cmd already exists in set, it will replace the old one + (no priority checking etc happens here). This is very useful + when overloading default commands). + + If cmd is another cmdset class or -instance, the commands of + that command set is added to this one, as if they were part of + the original cmdset definition. No merging or priority checks + are made, rather later added commands will simply replace + existing ones to make a unique set. + + """ + if inherits_from(cmd, "evennia.commands.cmdset.CmdSet"): + # cmd is a command set so merge all commands in that set + # to this one. We raise a visible error if we created + # an infinite loop (adding cmdset to itself somehow) + cmdset = cmd + try: + cmdset = self._instantiate(cmdset) + except RuntimeError: + err = ( + "Adding cmdset {cmdset} to {cls} lead to an " + "infinite loop. When adding a cmdset to another, " + "make sure they are not themself cyclically added to " + "the new cmdset somewhere in the chain." + ) + raise RuntimeError(_(err.format(cmdset=cmdset, cls=self.__class__))) + cmds = cmdset.commands + elif is_iter(cmd): + cmds = [self._instantiate(c) for c in cmd] + else: + cmds = [self._instantiate(cmd)] + + commands = self.commands + system_commands = self.system_commands + + for cmd in cmds: + # Ensure commands know their source cmdset. + cmd.cmdset_source = self + # add all commands + if not hasattr(cmd, "obj") or cmd.obj is None: + cmd.obj = self.cmdsetobj + + # remove duplicates and add new + for _dum in range(commands.count(cmd)): + commands.remove(cmd) + commands.append(cmd) + + # add system_command to separate list as well, + # for quick look-up. These have no + if cmd.key.startswith("__"): + # remove same-matches and add new + for _dum in range(system_commands.count(cmd)): + system_commands.remove(cmd) + system_commands.append(cmd) + + if not allow_duplicates: + # extra run to make sure to avoid doublets + commands = list(set(commands)) + self.commands = commands
+ +
[docs] def remove(self, cmd): + """ + Remove a command instance from the cmdset. + + Args: + cmd (Command or str): Either the Command object to remove + or the key of such a command. + + """ + if isinstance(cmd, str): + _cmd = next((_cmd for _cmd in self.commands if _cmd.key == cmd), None) + if _cmd is None: + if not cmd.startswith("__"): + # if a syscommand, keep the original string and instantiate on it + return None + else: + cmd = _cmd + + cmd = self._instantiate(cmd) + if cmd.key.startswith("__"): + try: + ic = self.system_commands.index(cmd) + del self.system_commands[ic] + except ValueError: + # ignore error + pass + else: + self.commands = [oldcmd for oldcmd in self.commands if oldcmd != cmd]
+ +
[docs] def get(self, cmd): + """ + Get a command from the cmdset. This is mostly useful to + check if the command is part of this cmdset or not. + + Args: + cmd (Command or str): Either the Command object or its key. + + Returns: + cmd (Command): The first matching Command in the set. + + """ + if isinstance(cmd, str): + _cmd = next((_cmd for _cmd in self.commands if _cmd.key == cmd), None) + if _cmd is None: + if not cmd.startswith("__"): + # if a syscommand, keep the original string and instantiate on it + return None + else: + cmd = _cmd + + cmd = self._instantiate(cmd) + for thiscmd in self.commands: + if thiscmd == cmd: + return thiscmd + return None
+ +
[docs] def count(self): + """ + Number of commands in set. + + Returns: + N (int): Number of commands in this Cmdset. + + """ + return len(self.commands)
+ +
[docs] def get_system_cmds(self): + """ + Get system commands in cmdset + + Returns: + sys_cmds (list): The system commands in the set. + + Notes: + As far as the Cmdset is concerned, system commands are any + commands with a key starting with double underscore __. + These are excempt from merge operations. + + """ + return self.system_commands
+ +
[docs] def make_unique(self, caller): + """ + Remove duplicate command-keys (unsafe) + + Args: + caller (object): Commands on this object will + get preference in the duplicate removal. + + Notes: + This is an unsafe command meant to clean out a cmdset of + doublet commands after it has been created. It is useful + for commands inheriting cmdsets from the cmdhandler where + obj-based cmdsets always are added double. Doublets will + be weeded out with preference to commands defined on + caller, otherwise just by first-come-first-served. + + """ + unique = {} + for cmd in self.commands: + if cmd.key in unique: + ocmd = unique[cmd.key] + if (hasattr(cmd, "obj") and cmd.obj == caller) and not ( + hasattr(ocmd, "obj") and ocmd.obj == caller + ): + unique[cmd.key] = cmd + else: + unique[cmd.key] = cmd + self.commands = list(unique.values())
+ +
[docs] def get_all_cmd_keys_and_aliases(self, caller=None): + """ + Collects keys/aliases from commands + + Args: + caller (Object, optional): If set, this is used to check access permissions + on each command. Only commands that pass are returned. + + Returns: + names (list): A list of all command keys and aliases in this cmdset. If `caller` + was given, this list will only contain commands to which `caller` passed + the `call` locktype check. + + """ + names = [] + if caller: + [names.extend(cmd._keyaliases) for cmd in self.commands if cmd.access(caller)] + else: + [names.extend(cmd._keyaliases) for cmd in self.commands] + return names
+ +
[docs] def at_cmdset_creation(self): + """ + Hook method - this should be overloaded in the inheriting + class, and should take care of populating the cmdset by use of + self.add(). + + """ + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/cmdsethandler.html b/docs/latest/_modules/evennia/commands/cmdsethandler.html new file mode 100644 index 0000000000..2afa4ff2cd --- /dev/null +++ b/docs/latest/_modules/evennia/commands/cmdsethandler.html @@ -0,0 +1,770 @@ + + + + + + + + evennia.commands.cmdsethandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.cmdsethandler

+"""
+CmdSethandler
+
+The Cmdsethandler tracks an object's 'Current CmdSet', which is the
+current merged sum of all CmdSets added to it.
+
+A CmdSet constitues a set of commands. The CmdSet works as a special
+intelligent container that, when added to other CmdSet make sure that
+same-name commands are treated correctly (usually so there are no
+doublets).  This temporary but up-to-date merger of CmdSet is jointly
+called the Current Cmset. It is this Current CmdSet that the
+commandhandler looks through whenever an account enters a command (it
+also adds CmdSets from objects in the room in real-time). All account
+objects have a 'default cmdset' containing all the normal in-game mud
+commands (look etc).
+
+So what is all this cmdset complexity good for?
+
+In its simplest form, a CmdSet has no commands, only a key name. In
+this case the cmdset's use is up to each individual game - it can be
+used by an AI module for example (mobs in cmdset 'roam' move from room
+to room, in cmdset 'attack' they enter combat with accounts).
+
+Defining commands in cmdsets offer some further powerful game-design
+consequences however. Here are some examples:
+
+As mentioned above, all accounts always have at least the Default
+CmdSet.  This contains the set of all normal-use commands in-game,
+stuff like look and @desc etc. Now assume our players end up in a dark
+room. You don't want the player to be able to do much in that dark
+room unless they light a candle. You could handle this by changing all
+your normal commands to check if the player is in a dark room. This
+rapidly goes unwieldly and error prone. Instead you just define a
+cmdset with only those commands you want to be available in the 'dark'
+cmdset - maybe a modified look command and a 'light candle' command -
+and have this completely replace the default cmdset.
+
+Another example: Say you want your players to be able to go
+fishing. You could implement this as a 'fish' command that fails
+whenever the account has no fishing rod. Easy enough.  But what if you
+want to make fishing more complex - maybe you want four-five different
+commands for throwing your line, reeling in, etc? Most players won't
+(we assume) have fishing gear, and having all those detailed commands
+is cluttering up the command list. And what if you want to use the
+'throw' command also for throwing rocks etc instead of 'using it up'
+for a minor thing like fishing?
+
+So instead you put all those detailed fishing commands into their own
+CommandSet called 'Fishing'. Whenever the player gives the command
+'fish' (presumably the code checks there is also water nearby), only
+THEN this CommandSet is added to the Cmdhandler of the account. The
+'throw' command (which normally throws rocks) is replaced by the
+custom 'fishing variant' of throw. What has happened is that the
+Fishing CommandSet was merged on top of the Default ones, and due to
+how we defined it, its command overrules the default ones.
+
+When we are tired of fishing, we give the 'go home' command (or
+whatever) and the Cmdhandler simply removes the fishing CommandSet
+so that we are back at defaults (and can throw rocks again).
+
+Since any number of CommandSets can be piled on top of each other, you
+can then implement separate sets for different situations. For
+example, you can have a 'On a boat' set, onto which you then tack on
+the 'Fishing' set. Fishing from a boat? No problem!
+
+"""
+import sys
+from importlib import import_module
+from inspect import trace
+from traceback import format_exc
+
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+from evennia.commands.cmdset import CmdSet
+from evennia.server.models import ServerConfig
+from evennia.utils import logger, utils
+
+__all__ = ("import_cmdset", "CmdSetHandler")
+
+_CACHED_CMDSETS = {}
+_CMDSET_PATHS = utils.make_iter(settings.CMDSET_PATHS)
+_IN_GAME_ERRORS = settings.IN_GAME_ERRORS
+_CMDSET_FALLBACKS = settings.CMDSET_FALLBACKS
+
+
+# Output strings
+
+_ERROR_CMDSET_IMPORT = _(
+    """{traceback}
+Error loading cmdset '{path}'
+(Traceback was logged {timestamp})"""
+)
+
+_ERROR_CMDSET_KEYERROR = _(
+    """Error loading cmdset: No cmdset class '{classname}' in '{path}'.
+(Traceback was logged {timestamp})"""
+)
+
+_ERROR_CMDSET_SYNTAXERROR = _(
+    """{traceback}
+SyntaxError encountered when loading cmdset '{path}'.
+(Traceback was logged {timestamp})"""
+)
+
+_ERROR_CMDSET_EXCEPTION = _(
+    """{traceback}
+Compile/Run error when loading cmdset '{path}'.
+(Traceback was logged {timestamp})"""
+)
+
+_ERROR_CMDSET_FALLBACK = _(
+    """
+Error encountered for cmdset at path '{path}'.
+Replacing with fallback '{fallback_path}'.
+"""
+)
+
+_ERROR_CMDSET_NO_FALLBACK = _("""Fallback path '{fallback_path}' failed to generate a cmdset.""")
+
+
+class _ErrorCmdSet(CmdSet):
+    """
+    This is a special cmdset used to report errors.
+    """
+
+    key = "_CMDSET_ERROR"
+    errmessage = "Error when loading cmdset."
+
+
+class _EmptyCmdSet(CmdSet):
+    """
+    This cmdset represents an empty cmdset
+    """
+
+    key = "_EMPTY_CMDSET"
+    priority = -101
+    mergetype = "Union"
+
+
+
[docs]def import_cmdset(path, cmdsetobj, emit_to_obj=None, no_logging=False): + """ + This helper function is used by the cmdsethandler to load a cmdset + instance from a python module, given a python_path. It's usually accessed + through the cmdsethandler's add() and add_default() methods. + path - This is the full path to the cmdset object on python dot-form + + Args: + path (str): The path to the command set to load. + cmdsetobj (CmdSet): The database object/typeclass on which this cmdset is to be + assigned (this can be also channels and exits, as well as accounts + but there will always be such an object) + emit_to_obj (Object, optional): If given, error is emitted to + this object (in addition to logging) + no_logging (bool, optional): Don't log/send error messages. + This can be useful if import_cmdset is just used to check if + this is a valid python path or not. + Returns: + cmdset (CmdSet): The imported command set. If an error was + encountered, `commands.cmdsethandler._ErrorCmdSet` is returned + for the benefit of the handler. + + """ + python_paths = [path] + [ + "%s.%s" % (prefix, path) for prefix in _CMDSET_PATHS if not path.startswith(prefix) + ] + errstring = "" + for python_path in python_paths: + if "." in path: + modpath, classname = python_path.rsplit(".", 1) + else: + raise ImportError(f"The path '{path}' is not on the form modulepath.ClassName") + + try: + # first try to get from cache + cmdsetclass = _CACHED_CMDSETS.get(python_path, None) + + if not cmdsetclass: + try: + module = import_module(modpath, package="evennia") + except ImportError as exc: + if len(trace()) > 2: + # error in module, make sure to not hide it. + dum, dum, tb = sys.exc_info() + raise exc.with_traceback(tb) + else: + # try next suggested path + errstring += _("\n(Unsuccessfully tried '{path}').").format( + path=python_path + ) + continue + try: + cmdsetclass = getattr(module, classname) + except AttributeError as exc: + if len(trace()) > 2: + # Attribute error within module, don't hide it + dum, dum, tb = sys.exc_info() + raise exc.with_traceback(tb) + else: + errstring += _("\n(Unsuccessfully tried '{path}').").format( + path=python_path + ) + continue + _CACHED_CMDSETS[python_path] = cmdsetclass + + # instantiate the cmdset (and catch its errors) + if callable(cmdsetclass): + cmdsetclass = cmdsetclass(cmdsetobj) + return cmdsetclass + except ImportError as err: + logger.log_trace() + errstring += _ERROR_CMDSET_IMPORT + if _IN_GAME_ERRORS: + errstring = errstring.format( + path=python_path, traceback=format_exc(), timestamp=logger.timeformat() + ) + else: + errstring = errstring.format( + path=python_path, traceback=err, timestamp=logger.timeformat() + ) + break + except KeyError: + logger.log_trace() + errstring += _ERROR_CMDSET_KEYERROR + errstring = errstring.format( + classname=classname, path=python_path, timestamp=logger.timeformat() + ) + break + except SyntaxError as err: + logger.log_trace() + errstring += _ERROR_CMDSET_SYNTAXERROR + if _IN_GAME_ERRORS: + errstring = errstring.format( + path=python_path, traceback=format_exc(), timestamp=logger.timeformat() + ) + else: + errstring = errstring.format( + path=python_path, traceback=err, timestamp=logger.timeformat() + ) + break + except Exception as err: + logger.log_trace() + errstring += _ERROR_CMDSET_EXCEPTION + if _IN_GAME_ERRORS: + errstring = errstring.format( + path=python_path, traceback=format_exc(), timestamp=logger.timeformat() + ) + else: + errstring = errstring.format( + path=python_path, traceback=err, timestamp=logger.timeformat() + ) + break + + if errstring: + # returning an empty error cmdset + errstring = errstring.strip() + if not no_logging: + logger.log_err(errstring) + if emit_to_obj and not ServerConfig.objects.conf("server_starting_mode"): + emit_to_obj.msg(errstring) + err_cmdset = _ErrorCmdSet() + err_cmdset.errmessage = errstring + return err_cmdset + return None # undefined error
+ + +# classes + + +
[docs]class CmdSetHandler(object): + """ + The CmdSetHandler is always stored on an object, this object is supplied + as an argument. + + The 'current' cmdset is the merged set currently active for this object. + This is the set the game engine will retrieve when determining which + commands are available to the object. The cmdset_stack holds a history of + all CmdSets to allow the handler to remove/add cmdsets at will. Doing so + will re-calculate the 'current' cmdset. + """ + +
[docs] def __init__(self, obj, init_true=True): + """ + This method is called whenever an object is recreated. + + Args: + obj (Object): An reference to the game object this handler + belongs to. + init_true (bool, optional): Set when the handler is initializing + and loads the current cmdset. + + """ + self.obj = obj + + # the id of the "merged" current cmdset for easy access. + self.key = None + # 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)] + # this tracks which mergetypes are actually in play in the stack + self.mergetype_stack = ["Union"] + + # the subset of the cmdset_paths that are to be stored in the database + self.persistent_paths = [""] + + if init_true: + self.update(init_mode=True) # is then called from the object __init__.
+ + def __str__(self): + """ + Display current commands + + """ + + 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): + 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] + if mergetype != self.current.mergetype: + merged_on = self.cmdset_stack[-2].key + mergetype = _("custom {mergetype} on cmdset '{cmdset}'") + mergetype = mergetype.format(mergetype=mergetype, cmdset=merged_on) + + if mergelist: + # current is a result of mergers + mergelist = "+".join(mergelist) + strings.append(f" <Merged {mergelist}>: {self.current}") + else: + # 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): + """ + Method wrapper for import_cmdset; Loads a cmdset from a + module. + + Args: + cmdset_path (str): The python path to an cmdset object. + emit_to_obj (Object): The object to send error messages to + + Returns: + cmdset (Cmdset): The imported cmdset. + + """ + if not emit_to_obj: + emit_to_obj = self.obj + return import_cmdset(cmdset_path, self.obj, emit_to_obj) + +
[docs] def update(self, init_mode=False): + """ + Re-adds all sets in the handler to have an updated current + + Args: + init_mode (bool, optional): Used automatically right after + this handler was created; it imports all persistent 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 persistent cmdsets + storage = self.obj.cmdset_storage + if storage: + self.cmdset_stack = [] + for pos, path in enumerate(storage): + if pos == 0 and not path: + self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)] + elif path: + cmdset = self._import_cmdset(path) + if cmdset: + if cmdset.key == "_CMDSET_ERROR": + # If a cmdset fails to load, check if we have a fallback path to use + fallback_path = _CMDSET_FALLBACKS.get(path, None) + if fallback_path: + err = _ERROR_CMDSET_FALLBACK.format( + path=path, fallback_path=fallback_path + ) + logger.log_err(err) + if _IN_GAME_ERRORS: + self.obj.msg(err) + cmdset = self._import_cmdset(fallback_path) + # If no cmdset is returned from the fallback, we can't go further + if not cmdset: + err = _ERROR_CMDSET_NO_FALLBACK.format( + fallback_path=fallback_path + ) + logger.log_err(err) + if _IN_GAME_ERRORS: + self.obj.msg(err) + continue + cmdset.persistent = cmdset.key != "_CMDSET_ERROR" + self.cmdset_stack.append(cmdset) + + # merge the stack into a new merged cmdset + new_current = None + self.mergetype_stack = [] + for cmdset in self.cmdset_stack: + try: + # for cmdset's '+' operator, order matters. + new_current = cmdset + new_current + except TypeError: + continue + self.mergetype_stack.append(new_current.actual_mergetype) + self.current = new_current
+ +
[docs] def add(self, cmdset, emit_to_obj=None, persistent=False, default_cmdset=False, **kwargs): + """ + Add a cmdset to the handler, on top of the old ones, unless it + is set as the default one (it will then end up at the bottom of the stack) + + Args: + cmdset (CmdSet or str): Can be a cmdset object or the python path + to such an object. + emit_to_obj (Object, optional): An object to receive error messages. + persistent (bool, optional): Let cmdset remain across server reload. + default_cmdset (Cmdset, optional): Insert this to replace the + default cmdset position (there is only one such position, + always at the bottom of the stack). + + Notes: + An interesting feature of this method is if you were to send + it an *already instantiated cmdset* (i.e. not a class), the + current cmdsethandler's obj attribute will then *not* be + transferred over to this already instantiated set (this is + because it might be used elsewhere and can cause strange + effects). This means you could in principle have the + handler launch command sets tied to a *different* object + than the handler. Not sure when this would be useful, but + it's a 'quirk' that has to be documented. + + """ + if "permanent" in kwargs: + logger.log_dep("obj.cmdset.add() kwarg 'permanent' has changed name to 'persistent'.") + persistent = kwargs["permanent"] if persistent is False else persistent + + if not (isinstance(cmdset, str) or utils.inherits_from(cmdset, CmdSet)): + string = _("Only CmdSets can be added to the cmdsethandler!") + raise Exception(string) + + if callable(cmdset): + cmdset = cmdset(self.obj) + elif isinstance(cmdset, str): + # this is (maybe) a python path. Try to import from cache. + cmdset = self._import_cmdset(cmdset) + if cmdset and cmdset.key != "_CMDSET_ERROR": + cmdset.persistent = persistent + if persistent and cmdset.key != "_CMDSET_ERROR": + # store the path permanently + storage = self.obj.cmdset_storage or [""] + if default_cmdset: + storage[0] = cmdset.path + else: + storage.append(cmdset.path) + self.obj.cmdset_storage = storage + if default_cmdset: + self.cmdset_stack[0] = cmdset + else: + self.cmdset_stack.append(cmdset) + self.update()
+ +
[docs] def add_default(self, cmdset, emit_to_obj=None, persistent=True, **kwargs): + """ + Shortcut for adding a default cmdset. + + Args: + cmdset (Cmdset): The Cmdset to add. + emit_to_obj (Object, optional): Gets error messages + persistent (bool, optional): The new Cmdset should survive a server reboot. + + """ + if "permanent" in kwargs: + logger.log_dep( + "obj.cmdset.add_default() kwarg 'permanent' has changed name to 'persistent'." + ) + persistent = kwargs["permanent"] if persistent is None else persistent + self.add(cmdset, emit_to_obj=emit_to_obj, persistent=persistent, default_cmdset=True)
+ +
[docs] def remove(self, cmdset=None, default_cmdset=False): + """ + Remove a cmdset from the handler. + + Args: + cmdset (CommandSet or str, optional): This can can be supplied either as a cmdset-key, + an instance of the CmdSet or a python path to the cmdset. + If no key is given, the last cmdset in the stack is + removed. Whenever the cmdset_stack changes, the cmdset is + updated. If default_cmdset is set, this argument is ignored. + default_cmdset (bool, optional): If set, this will remove the + default cmdset (at the bottom of the stack). + + """ + if default_cmdset: + # remove the default cmdset only + if self.cmdset_stack: + cmdset = self.cmdset_stack[0] + if cmdset.persistent: + storage = self.obj.cmdset_storage or [""] + storage[0] = "" + self.obj.cmdset_storage = storage + self.cmdset_stack[0] = _EmptyCmdSet(cmdsetobj=self.obj) + else: + self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)] + self.update() + return + + if len(self.cmdset_stack) < 2: + # don't allow deleting default cmdsets here. + return + + if not cmdset: + # remove the last one in the stack + cmdset = self.cmdset_stack.pop() + if cmdset.persistent: + storage = self.obj.cmdset_storage + storage.pop() + self.obj.cmdset_storage = storage + else: + # try it as a callable + if callable(cmdset) and hasattr(cmdset, "path"): + delcmdsets = [cset for cset in self.cmdset_stack[1:] if cset.path == cmdset.path] + else: + # try it as a path or key + delcmdsets = [ + cset + for cset in self.cmdset_stack[1:] + if cset.path == cmdset or cset.key == cmdset + ] + storage = [] + + if any(cset.persistent for cset in delcmdsets): + # only hit database if there's need to + storage = self.obj.cmdset_storage + updated = False + for cset in delcmdsets: + if cset.persistent: + try: + storage.remove(cset.path) + updated = True + except ValueError: + # nothing to remove + pass + if updated: + self.obj.cmdset_storage = storage + for cset in delcmdsets: + # clean the in-memory stack + try: + self.cmdset_stack.remove(cset) + except ValueError: + # nothing to remove + pass + # re-sync the cmdsethandler. + self.update()
+ + # legacy alias + delete = remove + +
[docs] def remove_default(self): + """ + This explicitly deletes only the default cmdset. + + """ + self.remove(default_cmdset=True)
+ + # legacy alias + delete_default = remove_default + +
[docs] def get(self): + """ + Get all cmdsets. + + Returns: + cmdsets (list): All the command sets currently in the handler. + + """ + return self.cmdset_stack
+ + # backwards-compatible alias + all = get + +
[docs] def clear(self): + """ + Removes all Command Sets from the handler except the default one + (use `self.remove_default` to remove that). + + """ + self.cmdset_stack = [self.cmdset_stack[0]] + storage = self.obj.cmdset_storage + if storage: + storage = storage[0] + self.obj.cmdset_storage = storage + self.update()
+ +
[docs] def has(self, cmdset, must_be_default=False): + """ + checks so the cmdsethandler contains a given cmdset + + Args: + cmdset (str or Cmdset): Cmdset key, pythonpath or + Cmdset to check the existence for. + must_be_default (bool, optional): Only return True if + the checked cmdset is the default one. + + Returns: + has_cmdset (bool): Whether or not the cmdset is in the handler. + + """ + if callable(cmdset) and hasattr(cmdset, "path"): + # try it as a callable + if must_be_default: + return self.cmdset_stack and (self.cmdset_stack[0].path == cmdset.path) + else: + return any([cset for cset in self.cmdset_stack if cset.path == cmdset.path]) + else: + # try it as a path or key + if must_be_default: + return self.cmdset_stack and ( + self.cmdset_stack[0].key == cmdset or self.cmdset_stack[0].path == cmdset + ) + else: + return any( + [ + cset + for cset in self.cmdset_stack + if cset.path == cmdset or cset.key == cmdset + ] + )
+ + # backwards-compatability alias + has_cmdset = has + +
[docs] def reset(self): + """ + Force reload of all cmdsets in handler. This should be called + after _CACHED_CMDSETS have been cleared (normally this is + handled automatically by @reload). + + """ + new_cmdset_stack = [] + for cmdset in self.cmdset_stack: + if cmdset.key == "_EMPTY_CMDSET": + new_cmdset_stack.append(cmdset) + else: + new_cmdset_stack.append(self._import_cmdset(cmdset.path)) + self.cmdset_stack = new_cmdset_stack + self.update()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/command.html b/docs/latest/_modules/evennia/commands/command.html new file mode 100644 index 0000000000..404f28c785 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/command.html @@ -0,0 +1,880 @@ + + + + + + + + evennia.commands.command — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.command

+"""
+The base Command class.
+
+All commands in Evennia inherit from the 'Command' class in this module.
+
+"""
+import inspect
+import math
+import re
+
+from django.conf import settings
+from django.urls import reverse
+from django.utils.text import slugify
+
+from evennia.locks.lockhandler import LockHandler
+from evennia.utils.ansi import ANSIString
+from evennia.utils.evtable import EvTable
+from evennia.utils.utils import fill, is_iter, lazy_property, make_iter
+
+CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
+
+
+
[docs]class InterruptCommand(Exception): + + """Cleanly interrupt a command.""" + + pass
+ + +def _init_command(cls, **kwargs): + """ + Helper command. + Makes sure all data are stored as lowercase and + do checking on all properties that should be in list form. + Sets up locks to be more forgiving. This is used both by the metaclass + and (optionally) at instantiation time. + + If kwargs are given, these are set as instance-specific properties + on the command - but note that the Command instance is *re-used* on a given + host object, so a kwarg value set on the instance will *remain* on the instance + for subsequent uses of that Command on that particular object. + + """ + for i in range(len(kwargs)): + # used for dynamic creation of commands + key, value = kwargs.popitem() + setattr(cls, key, value) + + cls.key = cls.key.lower() + if cls.aliases and not is_iter(cls.aliases): + try: + cls.aliases = [str(alias).strip().lower() for alias in cls.aliases.split(",")] + except Exception: + cls.aliases = [] + cls.aliases = list(set(alias for alias in cls.aliases if alias and alias != cls.key)) + + # optimization - a set is much faster to match against than a list. This is useful + # for 'does any match' kind of queries + cls._matchset = set([cls.key] + cls.aliases) + # optimization for looping over keys+aliases. We sort by longest entry first, since we + # want to be able to catch commands with spaces in the alias/key (so if you have key 'smile' + # and alias 'smile at', writing 'smile at' should not risk being interpreted as 'smile' + # with an argument 'at') + cls._keyaliases = tuple(sorted([cls.key] + cls.aliases, key=len, reverse=True)) + + # by default we don't save the command between runs + if not hasattr(cls, "save_for_next"): + cls.save_for_next = False + + # pre-process locks as defined in class definition + temp = [] + if hasattr(cls, "permissions"): + cls.locks = cls.permissions + if not hasattr(cls, "locks"): + # default if one forgets to define completely + cls.locks = "cmd:all()" + if "cmd:" not in cls.locks: + cls.locks = "cmd:all();" + cls.locks + for lockstring in cls.locks.split(";"): + if lockstring and ":" not in lockstring: + lockstring = "cmd:%s" % lockstring + temp.append(lockstring) + cls.lock_storage = ";".join(temp) + + if hasattr(cls, "arg_regex") and isinstance(cls.arg_regex, str): + cls.arg_regex = re.compile(r"%s" % cls.arg_regex, re.I + re.UNICODE) + if not hasattr(cls, "auto_help"): + cls.auto_help = True + if not hasattr(cls, "is_exit"): + cls.is_exit = False + if not hasattr(cls, "help_category"): + cls.help_category = "general" + if not hasattr(cls, "retain_instance"): + cls.retain_instance = False + + # make sure to pick up the parent's docstring if the child class is + # missing one (important for auto-help) + if cls.__doc__ is None: + for parent_class in inspect.getmro(cls): + if parent_class.__doc__ is not None: + cls.__doc__ = parent_class.__doc__ + break + cls.help_category = cls.help_category.lower() + + # pre-prepare a help index entry for quicker lookup + # strip the @- etc to allow help to be agnostic + stripped_key = cls.key[1:] if cls.key and cls.key[0] in CMD_IGNORE_PREFIXES else "" + stripped_aliases = " ".join( + al[1:] if al and al[0] in CMD_IGNORE_PREFIXES else al for al in cls.aliases + ) + cls.search_index_entry = { + "key": cls.key, + "aliases": " ".join(cls.aliases), + "no_prefix": f"{stripped_key} {stripped_aliases}", + "category": cls.help_category, + "text": cls.__doc__, + "tags": "", + } + + +
[docs]class CommandMeta(type): + """ + The metaclass cleans up all properties on the class + """ + +
[docs] def __init__(cls, *args, **kwargs): + _init_command(cls, **kwargs) + super().__init__(*args, **kwargs)
+ + +# The Command class is the basic unit of an Evennia command; when +# defining new commands, the admin subclass this class and +# define their own parser method to handle the input. The +# advantage of this is inheritage; commands that have similar +# structure can parse the input string the same way, minimizing +# parsing errors. + + +
[docs]class Command(metaclass=CommandMeta): + """ + ## Base command + + (you may see this if a child command had no help text defined) + + Usage: + command [args] + + This is the base command class. Inherit from this + to create new commands. + + The cmdhandler makes the following variables available to the + command methods (so you can always assume them to be there): + + self.caller - the game object calling the command + self.cmdstring - the command name used to trigger this command (allows + you to know which alias was used, for example) + self.args - everything supplied to the command following the cmdstring + (this is usually what is parsed in self.parse()) + self.cmdset - the merged cmdset from which this command was matched (useful only + seldomly, notably for help-type commands, to create dynamic + help entries and lists) + self.cmdset_source - the specific cmdset this command was matched from. + self.obj - the object on which this command is defined. If a default command, + this is usually the same as caller. + self.raw_string - the full raw string input, including the command name, + any args and no parsing. + + The following class properties can/should be defined on your child class: + + key - identifier for command (e.g. "look") + aliases - (optional) list of aliases (e.g. ["l", "loo"]) + locks - lock string (default is "cmd:all()") + help_category - how to organize this help entry in help system + (default is "General") + auto_help - defaults to True. Allows for turning off auto-help generation + 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 + format it similar to this one). This behavior can be changed by + overriding the method 'get_help' of a command: by default, this + method returns cmd.__doc__ (that is, this very docstring, or + the docstring of your command). You can, however, extend or + replace this without disabling auto_help. + """ + + # the main way to call this command (e.g. 'look') + key = "command" + # alternative ways to call the command (e.g. 'l', 'glance', 'examine') + aliases = [] + # a list of lock definitions on the form + # cmd:[NOT] func(args) [ AND|OR][ NOT] func2(args) + locks = settings.COMMAND_DEFAULT_LOCKS + # used by the help system to group commands in lists. + help_category = settings.COMMAND_DEFAULT_HELP_CATEGORY + # This allows to turn off auto-help entry creation for individual commands. + auto_help = True + # optimization for quickly separating exit-commands from normal commands + is_exit = False + # define the command not only by key but by the regex form of its arguments + arg_regex = settings.COMMAND_DEFAULT_ARG_REGEX + # whether self.msg sends to all sessions of a related account/object (default + # is to only send to the session sending the command). + msg_all_sessions = settings.COMMAND_DEFAULT_MSG_ALL_SESSIONS + # whether the exact command instance should be retained between command calls. + # By default it's False; this allows for retaining state and saves some CPU, but + # can cause cross-talk between users if multiple users access the same command + # (especially if the command is using yield) + retain_instance = False + + # auto-set (by Evennia on command instantiation) are: + # obj - which object this command is defined on + # session - which session is responsible for triggering this command. Only set + # if triggered by an account. + +
[docs] def __init__(self, **kwargs): + """ + The lockhandler works the same as for objects. + optional kwargs will be set as properties on the Command at runtime, + overloading evential same-named class properties. + + """ + if kwargs: + _init_command(self, **kwargs) + self._optimize()
+ +
[docs] @lazy_property + def lockhandler(self): + return LockHandler(self)
+ + def __str__(self): + """ + Print the command key + """ + return self.key + + def __eq__(self, cmd): + """ + Compare two command instances to each other by matching their + key and aliases. + + Args: + cmd (Command or str): Allows for equating both Command + objects and their keys. + + Returns: + equal (bool): If the commands are equal or not. + + """ + try: + # first assume input is a command (the most common case) + return self._matchset.intersection(cmd._matchset) + except AttributeError: + # probably got a string + return cmd in self._matchset + + def __hash__(self): + """ + Python 3 requires that any class which implements __eq__ must also + implement __hash__ and that the corresponding hashes for equivalent + instances are themselves equivalent. + + Technically, the following implementation is only valid for comparison + against other Commands, as our __eq__ supports comparison against + str, too. + + """ + return hash("command") + + def __ne__(self, cmd): + """ + The logical negation of __eq__. Since this is one of the most + called methods in Evennia (along with __eq__) we do some + code-duplication here rather than issuing a method-lookup to + __eq__. + """ + try: + return self._matchset.isdisjoint(cmd._matchset) + except AttributeError: + return cmd not in self._matchset + + def __contains__(self, query): + """ + This implements searches like 'if query in cmd'. It's a fuzzy + matching used by the help system, returning True if query can + be found as a substring of the commands key or its aliases. + + Args: + query (str): query to match against. Should be lower case. + + Returns: + result (bool): Fuzzy matching result. + + """ + return any(query in keyalias for keyalias in self._keyaliases) + + def _optimize(self): + """ + Optimize the key and aliases for lookups. + """ + # optimization - a set is much faster to match against than a list + matches = [self.key.lower()] + matches.extend(x.lower() for x in self.aliases) + + self._matchset = set(matches) + # optimization for looping over keys+aliases. We sort by longest entry first, since we + # want to be able to catch commands with spaces in the alias/key (so if you have key 'smile' + # and alias 'smile at', writing 'smile at' should not risk being interpreted as 'smile' + # with an argument 'at') + self._keyaliases = tuple(sorted(matches, key=len, reverse=True)) + + self._noprefix_aliases = {x.lstrip(CMD_IGNORE_PREFIXES): x for x in self._keyaliases} + +
[docs] def set_key(self, new_key): + """ + Update key. + + Args: + new_key (str): The new key. + + Notes: + This is necessary to use to make sure the optimization + caches are properly updated as well. + + """ + self.key = new_key.lower() + self._optimize()
+ +
[docs] def set_aliases(self, new_aliases): + """ + Replace aliases with new ones. + + Args: + new_aliases (str or list): Either a ;-separated string + or a list of aliases. These aliases will replace the + existing ones, if any. + + Notes: + This is necessary to use to make sure the optimization + caches are properly updated as well. + + """ + if isinstance(new_aliases, str): + new_aliases = new_aliases.split(";") + aliases = (str(alias).strip().lower() for alias in make_iter(new_aliases)) + self.aliases = list(set(alias for alias in aliases if alias != self.key)) + self._optimize()
+ +
[docs] def match(self, cmdname, include_prefixes=True): + """ + This is called by the system when searching the available commands, + in order to determine if this is the one we wanted. cmdname was + previously extracted from the raw string by the system. + + Args: + cmdname (str): Always lowercase when reaching this point. + + Kwargs: + include_prefixes (bool): If false, will compare against the _noprefix + variants of commandnames. + + Returns: + result (bool): Match result. + + """ + if include_prefixes: + for cmd_key in self._keyaliases: + if cmdname.startswith(cmd_key) and ( + not self.arg_regex or self.arg_regex.match(cmdname[len(cmd_key) :]) + ): + return cmd_key, cmd_key + else: + for k, v in self._noprefix_aliases.items(): + if cmdname.startswith(k) and ( + not self.arg_regex or self.arg_regex.match(cmdname[len(k) :]) + ): + return k, v + return None, None
+ +
[docs] def access(self, srcobj, access_type="cmd", default=False): + """ + This hook is called by the cmdhandler to determine if srcobj + is allowed to execute this command. It should return a boolean + value and is not normally something that need to be changed since + it's using the Evennia permission system directly. + + Args: + srcobj (Object): Object trying to gain permission + access_type (str, optional): The lock type to check. + default (bool, optional): The fallback result if no lock + of matching `access_type` is found on this Command. + + """ + return self.lockhandler.check(srcobj, access_type, default=default)
+ +
[docs] def msg(self, text=None, to_obj=None, from_obj=None, session=None, **kwargs): + """ + This is a shortcut instead of calling msg() directly on an + object - it will detect if caller is an Object or an Account and + also appends self.session automatically if self.msg_all_sessions is False. + + Args: + text (str, optional): Text string of message to send. + to_obj (Object, optional): Target object of message. Defaults to self.caller. + from_obj (Object, optional): Source of message. Defaults to to_obj. + session (Session, optional): Supply data only to a unique + session (ignores the value of `self.msg_all_sessions`). + + Keyword Args: + options (dict): Options to the protocol. + any (any): All other keywords are interpreted as th + name of send-instructions. + + """ + from_obj = from_obj or self.caller + to_obj = to_obj or from_obj + if not session and not self.msg_all_sessions: + if to_obj == self.caller: + session = self.session + else: + session = to_obj.sessions.get() + to_obj.msg(text=text, from_obj=from_obj, session=session, **kwargs)
+ +
[docs] def execute_cmd(self, raw_string, session=None, obj=None, **kwargs): + """ + A shortcut of execute_cmd on the caller. It appends the + session automatically. + + Args: + raw_string (str): Execute this string as a command input. + session (Session, optional): If not given, the current command's Session will be used. + obj (Object or Account, optional): Object or Account on which to call the execute_cmd. + If not given, self.caller will be used. + + Keyword Args: + Other keyword arguments will be added to the found command + object instace as variables before it executes. This is + unused by default Evennia but may be used to set flags and + change operating paramaters for commands at run-time. + + """ + obj = self.caller if obj is None else obj + session = self.session if session is None else session + obj.execute_cmd(raw_string, session=session, **kwargs)
+ + # Common Command hooks + +
[docs] def at_pre_cmd(self): + """ + This hook is called before self.parse() on all commands. If + this hook returns anything but False/None, the command + sequence is aborted. + + """ + pass
+ +
[docs] def at_post_cmd(self): + """ + This hook is called after the command has finished executing + (after self.func()). + + """ + pass
+ +
[docs] def parse(self): + """ + Once the cmdhandler has identified this as the command we + want, this function is run. If many of your commands have a + similar syntax (for example 'cmd arg1 = arg2') you should + simply define this once and just let other commands of the + same form inherit from this. See the docstring of this module + for which object properties are available to use (notably + self.args). + + """ + pass
+ +
[docs] def get_command_info(self): + """ + This is the default output of func() if no func() overload is done. + Provided here as a separate method so that it can be called for debugging + purposes when making commands. + + """ + variables = "\n".join( + " |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items() + ) + string = f""" +Command {self} has no defined `func()` - showing on-command variables: +{variables} + """ + # a simple test command to show the available properties + string += "-" * 50 + string += "\n|w%s|n - Command variables from evennia:\n" % self.key + string += "-" * 50 + string += "\nname of cmd (self.key): |w%s|n\n" % self.key + string += "cmd aliases (self.aliases): |w%s|n\n" % self.aliases + string += "cmd locks (self.locks): |w%s|n\n" % self.locks + string += "help category (self.help_category): |w%s|n\n" % self.help_category.capitalize() + string += "object calling (self.caller): |w%s|n\n" % self.caller + string += "object storing cmdset (self.obj): |w%s|n\n" % self.obj + string += "command string given (self.cmdstring): |w%s|n\n" % self.cmdstring + # show cmdset.key instead of cmdset to shorten output + string += fill( + "current cmdset (self.cmdset): |w%s|n\n" + % (self.cmdset.key if self.cmdset.key else self.cmdset.__class__) + ) + + self.msg(string)
+ +
[docs] def func(self): + """ + This is the actual executing part of the command. It is + called directly after self.parse(). See the docstring of this + module for which object properties are available (beyond those + set in self.parse()) + + """ + self.get_command_info()
+ +
[docs] def get_extra_info(self, caller, **kwargs): + """ + Display some extra information that may help distinguish this + command from others, for instance, in a disambiguity prompt. + + If this command is a potential match in an ambiguous + situation, one distinguishing feature may be its attachment to + a nearby object, so we include this if available. + + Args: + caller (TypedObject): The caller who typed an ambiguous + term handed to the search function. + + Returns: + A string with identifying information to disambiguate the + object, conventionally with a preceding space. + + """ + if hasattr(self, "obj") and self.obj and self.obj != caller: + return " (%s)" % self.obj.get_display_name(caller).strip() + return ""
+ +
[docs] def get_help(self, caller, cmdset): + """ + Return the help message for this command and this caller. + + By default, return self.__doc__ (the docstring just under + the class definition). You can override this behavior, + though, and even customize it depending on the caller, or other + commands the caller can use. + + Args: + caller (Object or Account): the caller asking for help on the command. + cmdset (CmdSet): the command set (if you need additional commands). + + Returns: + docstring (str): the help text to provide the caller for this command. + + """ + return self.__doc__
+ +
[docs] def web_get_detail_url(self): + """ + Returns the URI path for a View that allows users to view details for + this object. + + ex. Oscar (Character) = '/characters/oscar/1/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-detail' would be referenced by this method. + + ex. + :: + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', + CharDetailView.as_view(), name='character-detail') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can view this object is the developer's + responsibility. + + Returns: + path (str): URI path to object detail page, if defined. + + """ + try: + return reverse( + "help-entry-detail", + kwargs={"category": slugify(self.help_category), "topic": slugify(self.key)}, + ) + except Exception as e: + return "#"
+ +
[docs] def web_get_admin_url(self): + """ + Returns the URI path for the Django Admin page for this object. + + ex. Account#1 = '/admin/accounts/accountdb/1/change/' + + Returns: + path (str): URI path to Django Admin page for object. + + """ + return False
+ +
[docs] def client_width(self): + """ + Get the client screenwidth for the session using this command. + + Returns: + client width (int): The width (in characters) of the client window. + + """ + if self.session: + return self.session.protocol_flags.get( + "SCREENWIDTH", {0: settings.CLIENT_DEFAULT_WIDTH} + )[0] + return settings.CLIENT_DEFAULT_WIDTH
+ +
[docs] def styled_table(self, *args, **kwargs): + """ + Create an EvTable styled by on user preferences. + + Args: + *args (str): Column headers. If not colored explicitly, these will get colors + from user options. + Keyword Args: + any (str, int or dict): EvTable options, including, optionally a `table` dict + detailing the contents of the table. + Returns: + table (EvTable): An initialized evtable entity, either complete (if using `table` kwarg) + or incomplete and ready for use with `.add_row` or `.add_collumn`. + + """ + border_color = self.account.options.get("border_color") + column_color = self.account.options.get("column_names_color") + + colornames = ["|%s%s|n" % (column_color, col) for col in args] + + h_line_char = kwargs.pop("header_line_char", "~") + header_line_char = ANSIString(f"|{border_color}{h_line_char}|n") + c_char = kwargs.pop("corner_char", "+") + corner_char = ANSIString(f"|{border_color}{c_char}|n") + + b_left_char = kwargs.pop("border_left_char", "||") + border_left_char = ANSIString(f"|{border_color}{b_left_char}|n") + + b_right_char = kwargs.pop("border_right_char", "||") + border_right_char = ANSIString(f"|{border_color}{b_right_char}|n") + + b_bottom_char = kwargs.pop("border_bottom_char", "-") + border_bottom_char = ANSIString(f"|{border_color}{b_bottom_char}|n") + + b_top_char = kwargs.pop("border_top_char", "-") + border_top_char = ANSIString(f"|{border_color}{b_top_char}|n") + + table = EvTable( + *colornames, + header_line_char=header_line_char, + corner_char=corner_char, + border_left_char=border_left_char, + border_right_char=border_right_char, + border_top_char=border_top_char, + border_bottom_char=border_bottom_char, + **kwargs, + ) + return table
+ + def _render_decoration( + self, + header_text=None, + fill_character=None, + edge_character=None, + mode="header", + color_header=True, + width=None, + ): + """ + Helper for formatting a string into a pretty display, for a header, separator or footer. + + Keyword Args: + header_text (str): Text to include in header. + fill_character (str): This single character will be used to fill the width of the + display. + edge_character (str): This character caps the edges of the display. + mode(str): One of 'header', 'separator' or 'footer'. + color_header (bool): If the header should be colorized based on user options. + width (int): If not given, the client's width will be used if available. + + Returns: + string (str): The decorated and formatted text. + + """ + + colors = dict() + colors["border"] = self.account.options.get("border_color") + colors["headertext"] = self.account.options.get("%s_text_color" % mode) + colors["headerstar"] = self.account.options.get("%s_star_color" % mode) + + width = width or self.client_width() + if edge_character: + width -= 2 + + if header_text: + if color_header: + header_text = ANSIString(header_text).clean() + header_text = ANSIString("|n|%s%s|n" % (colors["headertext"], header_text)) + if mode == "header": + begin_center = ANSIString( + "|n|%s<|%s* |n" % (colors["border"], colors["headerstar"]) + ) + end_center = ANSIString("|n |%s*|%s>|n" % (colors["headerstar"], colors["border"])) + center_string = ANSIString(begin_center + header_text + end_center) + else: + center_string = ANSIString("|n |%s%s |n" % (colors["headertext"], header_text)) + else: + center_string = "" + + fill_character = self.account.options.get("%s_fill" % mode) + + remain_fill = width - len(center_string) + if remain_fill % 2 == 0: + right_width = remain_fill / 2 + left_width = remain_fill / 2 + else: + right_width = math.floor(remain_fill / 2) + left_width = math.ceil(remain_fill / 2) + + right_fill = ANSIString("|n|%s%s|n" % (colors["border"], fill_character * int(right_width))) + left_fill = ANSIString("|n|%s%s|n" % (colors["border"], fill_character * int(left_width))) + + if edge_character: + edge_fill = ANSIString("|n|%s%s|n" % (colors["border"], edge_character)) + main_string = ANSIString(center_string) + final_send = ( + ANSIString(edge_fill) + left_fill + main_string + right_fill + ANSIString(edge_fill) + ) + else: + final_send = left_fill + ANSIString(center_string) + right_fill + return final_send + +
[docs] def styled_header(self, *args, **kwargs): + """ + Create a pretty header. + """ + + if "mode" not in kwargs: + kwargs["mode"] = "header" + return self._render_decoration(*args, **kwargs)
+ +
[docs] def styled_separator(self, *args, **kwargs): + """ + Create a separator. + + """ + if "mode" not in kwargs: + kwargs["mode"] = "separator" + return self._render_decoration(*args, **kwargs)
+ +
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/account.html b/docs/latest/_modules/evennia/commands/default/account.html new file mode 100644 index 0000000000..69c87cf01d --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/account.html @@ -0,0 +1,1127 @@ + + + + + + + + evennia.commands.default.account — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.account

+"""
+Account (OOC) commands. These are stored on the Account object
+and self.caller is thus always an Account, not an Object/Character.
+
+These commands go in the AccountCmdset and are accessible also
+when puppeting a Character (although with lower priority)
+
+These commands use the account_caller property which tells the command
+parent (MuxCommand, usually) to setup caller correctly. They use
+self.account to make sure to always use the account object rather than
+self.caller (which change depending on the level you are calling from)
+The property self.character can be used to access the character when
+these commands are triggered with a connected character (such as the
+case of the `ooc` command), it is None if we are OOC.
+
+Note that under MULTISESSION_MODE > 2, Account commands should use
+self.msg() and similar methods to reroute returns to the correct
+method. Otherwise all text will be returned to all connected sessions.
+
+"""
+import time
+from codecs import lookup as codecs_lookup
+
+from django.conf import settings
+
+import evennia
+from evennia.utils import create, logger, search, utils
+
+COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+_MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS
+_AUTO_PUPPET_ON_LOGIN = settings.AUTO_PUPPET_ON_LOGIN
+
+# limit symbol import for API
+__all__ = (
+    "CmdOOCLook",
+    "CmdIC",
+    "CmdOOC",
+    "CmdPassword",
+    "CmdQuit",
+    "CmdCharCreate",
+    "CmdOption",
+    "CmdSessions",
+    "CmdWho",
+    "CmdColorTest",
+    "CmdQuell",
+    "CmdCharDelete",
+    "CmdStyle",
+)
+
+
+class MuxAccountLookCommand(COMMAND_DEFAULT_CLASS):
+    """
+    Custom parent (only) parsing for OOC looking, sets a "playable"
+    property on the command based on the parsing.
+
+    """
+
+    def parse(self):
+        """Custom parsing"""
+
+        super().parse()
+
+        playable = self.account.characters
+        # store playable property
+        if self.args:
+            self.playable = dict((utils.to_str(char.key.lower()), char) for char in playable).get(
+                self.args.lower(), None
+            )
+        else:
+            self.playable = playable
+
+
+# Obs - these are all intended to be stored on the Account, and as such,
+# use self.account instead of self.caller, just to be sure. Also self.msg()
+# is used to make sure returns go to the right session
+
+
+# note that this is inheriting from MuxAccountLookCommand,
+# and has the .playable property.
+
[docs]class CmdOOCLook(MuxAccountLookCommand): + """ + look while out-of-character + + Usage: + look + + Look in the ooc state. + """ + + # This is an OOC version of the look command. Since a + # Account doesn't have an in-game existence, there is no + # concept of location or "self". If we are controlling + # a character, pass control over to normal look. + + key = "look" + aliases = ["l", "ls"] + locks = "cmd:all()" + help_category = "General" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """implement the ooc look command""" + + if self.session.puppet: + # if we are puppeting, this is only reached in the case the that puppet + # has no look command on its own. + self.msg("You currently have no ability to look around.") + return + + if _AUTO_PUPPET_ON_LOGIN and _MAX_NR_CHARACTERS == 1 and self.playable: + # only one exists and is allowed - simplify + self.msg("You are out-of-character (OOC).\nUse |wic|n to get back into the game.") + return + + # call on-account look helper method + self.msg(self.account.at_look(target=self.playable, session=self.session))
+ + +
[docs]class CmdCharCreate(COMMAND_DEFAULT_CLASS): + """ + create a new character + + Usage: + charcreate <charname> [= desc] + + Create a new character, optionally giving it a description. You + may use upper-case letters in the name - you will nevertheless + always be able to access your character using lower-case letters + if you want. + """ + + key = "charcreate" + locks = "cmd:pperm(Player)" + help_category = "General" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """create the new character""" + account = self.account + if not self.args: + self.msg("Usage: charcreate <charname> [= description]") + return + key = self.lhs + description = self.rhs or "This is a character." + + new_character, errors = self.account.create_character( + key=key, description=description, ip=self.session.address + ) + + if errors: + self.msg(errors) + if not new_character: + return + + self.msg( + f"Created new character {new_character.key}. Use |wic {new_character.key}|n to enter" + " the game as this character." + )
+ + +
[docs]class CmdCharDelete(COMMAND_DEFAULT_CLASS): + """ + delete a character - this cannot be undone! + + Usage: + chardelete <charname> + + Permanently deletes one of your characters. + """ + + key = "chardelete" + locks = "cmd:pperm(Player)" + help_category = "General" + +
[docs] def func(self): + """delete the character""" + account = self.account + + if not self.args: + self.msg("Usage: chardelete <charactername>") + return + + # use the playable_characters list to search + match = [ + char + for char in utils.make_iter(account.characters) + if char.key.lower() == self.args.lower() + ] + if not match: + self.msg("You have no such character to delete.") + return + elif len(match) > 1: + self.msg( + "Aborting - there are two characters with the same name. Ask an admin to delete the" + " right one." + ) + return + else: # one match + from evennia.utils.evmenu import get_input + + def _callback(caller, callback_prompt, result): + if result.lower() == "yes": + # only take action + delobj = caller.ndb._char_to_delete + key = delobj.key + caller.characters.remove(delobj) + delobj.delete() + self.msg(f"Character '{key}' was permanently deleted.") + logger.log_sec( + f"Character Deleted: {key} (Caller: {account}, IP: {self.session.address})." + ) + else: + self.msg("Deletion was aborted.") + del caller.ndb._char_to_delete + + match = match[0] + account.ndb._char_to_delete = match + + # Return if caller has no permission to delete this + if not match.access(account, "delete"): + self.msg("You do not have permission to delete this character.") + return + + prompt = ( + "|rThis will permanently destroy '%s'. This cannot be undone.|n Continue yes/[no]?" + ) + get_input(account, prompt % match.key, _callback)
+ + +
[docs]class CmdIC(COMMAND_DEFAULT_CLASS): + """ + control an object you have permission to puppet + + Usage: + ic <character> + + Go in-character (IC) as a given Character. + + This will attempt to "become" a different object assuming you have + the right to do so. Note that it's the ACCOUNT character that puppets + characters/objects and which needs to have the correct permission! + + You cannot become an object that is already controlled by another + account. In principle <character> can be any in-game object as long + as you the account have access right to puppet it. + """ + + key = "ic" + # lock must be all() for different puppeted objects to access it. + locks = "cmd:all()" + aliases = "puppet" + help_category = "General" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """ + Main puppet method + """ + account = self.account + session = self.session + + new_character = None + character_candidates = [] + + if not self.args: + character_candidates = [account.db._last_puppet] if account.db._last_puppet else [] + if not character_candidates: + self.msg("Usage: ic <character>") + return + else: + # argument given + + if playables := account.characters: + # look at the playable_characters list first + character_candidates.extend( + utils.make_iter( + account.search( + self.args, + candidates=playables, + search_object=True, + quiet=True, + ) + ) + ) + + if account.locks.check_lockstring(account, "perm(Builder)"): + # builders and higher should be able to puppet more than their + # playable characters. + if session.puppet: + # start by local search - this helps to avoid the user + # getting locked into their playable characters should one + # happen to be named the same as another. We replace the suggestion + # from playable_characters here - this allows builders to puppet objects + # with the same name as their playable chars should it be necessary + # (by going to the same location). + character_candidates = [ + char + for char in session.puppet.search(self.args, quiet=True) + if char.access(account, "puppet") + ] + if not character_candidates: + # fall back to global search only if Builder+ has no + # playable_characters in list and is not standing in a room + # with a matching char. + character_candidates.extend( + [ + char + for char in search.object_search(self.args) + if char.access(account, "puppet") + ] + ) + + # handle possible candidates + if not character_candidates: + self.msg("That is not a valid character choice.") + return + if len(character_candidates) > 1: + self.msg( + "Multiple targets with the same name:\n %s" + % ", ".join("%s(#%s)" % (obj.key, obj.id) for obj in character_candidates) + ) + return + else: + new_character = character_candidates[0] + + # do the puppet puppet + try: + account.puppet_object(session, new_character) + account.db._last_puppet = new_character + logger.log_sec( + f"Puppet Success: (Caller: {account}, Target: {new_character}, IP:" + f" {self.session.address})." + ) + except RuntimeError as exc: + self.msg(f"|rYou cannot become |C{new_character.name}|n: {exc}") + logger.log_sec( + f"Puppet Failed: %s (Caller: {account}, Target: {new_character}, IP:" + f" {self.session.address})." + )
+ + +# note that this is inheriting from MuxAccountLookCommand, +# and as such has the .playable property. +
[docs]class CmdOOC(MuxAccountLookCommand): + """ + stop puppeting and go ooc + + Usage: + ooc + + Go out-of-character (OOC). + + This will leave your current character and put you in a incorporeal OOC state. + """ + + key = "ooc" + locks = "cmd:pperm(Player)" + aliases = "unpuppet" + help_category = "General" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """Implement function""" + + account = self.account + session = self.session + + old_char = account.get_puppet(session) + if not old_char: + string = "You are already OOC." + self.msg(string) + return + + account.db._last_puppet = old_char + + # disconnect + try: + account.unpuppet_object(session) + self.msg("\n|GYou go OOC.|n\n") + + if _AUTO_PUPPET_ON_LOGIN and _MAX_NR_CHARACTERS == 1 and self.playable: + # only one character exists and is allowed - simplify + self.msg("You are out-of-character (OOC).\nUse |wic|n to get back into the game.") + return + + self.msg(account.at_look(target=self.playable, session=session)) + + except RuntimeError as exc: + self.msg(f"|rCould not unpuppet from |c{old_char}|n: {exc}")
+ + +
[docs]class CmdSessions(COMMAND_DEFAULT_CLASS): + """ + check your connected session(s) + + Usage: + sessions + + Lists the sessions currently connected to your account. + + """ + + key = "sessions" + locks = "cmd:all()" + help_category = "General" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """Implement function""" + account = self.account + sessions = account.sessions.all() + table = self.styled_table( + "|wsessid", "|wprotocol", "|whost", "|wpuppet/character", "|wlocation" + ) + for sess in sorted(sessions, key=lambda x: x.sessid): + char = account.get_puppet(sess) + table.add_row( + str(sess.sessid), + str(sess.protocol_key), + isinstance(sess.address, tuple) and sess.address[0] or sess.address, + char and str(char) or "None", + char and str(char.location) or "N/A", + ) + self.msg(f"|wYour current session(s):|n\n{table}")
+ + +
[docs]class CmdWho(COMMAND_DEFAULT_CLASS): + """ + list who is currently online + + Usage: + who + doing + + Shows who is currently online. Doing is an alias that limits info + also for those with all permissions. + """ + + key = "who" + aliases = "doing" + locks = "cmd:all()" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """ + Get all connected accounts by polling session. + """ + account = self.account + session_list = evennia.SESSION_HANDLER.get_sessions() + + session_list = sorted(session_list, key=lambda o: o.account.key) + + if self.cmdstring == "doing": + show_session_data = False + else: + show_session_data = account.check_permstring("Developer") or account.check_permstring( + "Admins" + ) + + naccounts = evennia.SESSION_HANDLER.account_count() + if show_session_data: + # privileged info + table = self.styled_table( + "|wAccount Name", + "|wOn for", + "|wIdle", + "|wPuppeting", + "|wRoom", + "|wCmds", + "|wProtocol", + "|wHost", + ) + for session in session_list: + if not session.logged_in: + continue + delta_cmd = time.time() - session.cmd_last_visible + delta_conn = time.time() - session.conn_time + session_account = session.get_account() + puppet = session.get_puppet() + location = puppet.location.key if puppet and puppet.location else "None" + table.add_row( + utils.crop(session_account.get_display_name(account), width=25), + utils.time_format(delta_conn, 0), + utils.time_format(delta_cmd, 1), + utils.crop(puppet.get_display_name(account) if puppet else "None", width=25), + utils.crop(location, width=25), + session.cmd_total, + session.protocol_key, + isinstance(session.address, tuple) and session.address[0] or session.address, + ) + else: + # unprivileged + table = self.styled_table("|wAccount name", "|wOn for", "|wIdle") + for session in session_list: + if not session.logged_in: + continue + delta_cmd = time.time() - session.cmd_last_visible + delta_conn = time.time() - session.conn_time + session_account = session.get_account() + table.add_row( + utils.crop(session_account.get_display_name(account), width=25), + utils.time_format(delta_conn, 0), + utils.time_format(delta_cmd, 1), + ) + is_one = naccounts == 1 + self.msg( + "|wAccounts:|n\n%s\n%s unique account%s logged in." + % (table, "One" if is_one else naccounts, "" if is_one else "s") + )
+ + +
[docs]class CmdOption(COMMAND_DEFAULT_CLASS): + """ + Set an account option + + Usage: + option[/save] [name = value] + + Switches: + save - Save the current option settings for future logins. + clear - Clear the saved options. + + This command allows for viewing and setting client interface + settings. Note that saved options may not be able to be used if + later connecting with a client with different capabilities. + + + """ + + key = "option" + aliases = "options" + switch_options = ("save", "clear") + locks = "cmd:all()" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """ + Implements the command + """ + if self.session is None: + return + + flags = self.session.protocol_flags + + # Display current options + if not self.args: + # list the option settings + + if "save" in self.switches: + # save all options + self.caller.db._saved_protocol_flags = flags + self.msg("|gSaved all options. Use option/clear to remove.|n") + if "clear" in self.switches: + # clear all saves + self.caller.db._saved_protocol_flags = {} + self.msg("|gCleared all saved options.") + + options = dict(flags) # make a copy of the flag dict + saved_options = dict(self.caller.attributes.get("_saved_protocol_flags", default={})) + + if "SCREENWIDTH" in options: + if len(options["SCREENWIDTH"]) == 1: + options["SCREENWIDTH"] = options["SCREENWIDTH"][0] + else: + options["SCREENWIDTH"] = " \n".join( + "%s : %s" % (screenid, size) + for screenid, size in options["SCREENWIDTH"].items() + ) + if "SCREENHEIGHT" in options: + if len(options["SCREENHEIGHT"]) == 1: + options["SCREENHEIGHT"] = options["SCREENHEIGHT"][0] + else: + options["SCREENHEIGHT"] = " \n".join( + "%s : %s" % (screenid, size) + for screenid, size in options["SCREENHEIGHT"].items() + ) + options.pop("TTYPE", None) + + header = ("Name", "Value", "Saved") if saved_options else ("Name", "Value") + table = self.styled_table(*header) + for key in sorted(options): + row = [key, options[key]] + if saved_options: + saved = " |YYes|n" if key in saved_options else "" + changed = ( + "|y*|n" if key in saved_options and flags[key] != saved_options[key] else "" + ) + row.append("%s%s" % (saved, changed)) + table.add_row(*row) + self.msg(f"|wClient settings ({self.session.protocol_key}):|n\n{table}|n") + + return + + if not self.rhs: + self.msg("Usage: option [name = [value]]") + return + + # Try to assign new values + + def validate_encoding(new_encoding): + # helper: change encoding + try: + codecs_lookup(new_encoding) + except LookupError: + raise RuntimeError(f"The encoding '|w{new_encoding}|n' is invalid. ") + return val + + def validate_size(new_size): + return {0: int(new_size)} + + def validate_bool(new_bool): + return True if new_bool.lower() in ("true", "on", "1") else False + + def update(new_name, new_val, validator): + # helper: update property and report errors + try: + old_val = flags.get(new_name, False) + new_val = validator(new_val) + if old_val == new_val: + self.msg(f"Option |w{new_name}|n was kept as '|w{old_val}|n'.") + else: + flags[new_name] = new_val + self.msg( + f"Option |w{new_name}|n was changed from '|w{old_val}|n' to" + f" '|w{new_val}|n'." + ) + return {new_name: new_val} + except Exception as err: + self.msg(f"|rCould not set option |w{new_name}|r:|n {err}") + return False + + validators = { + "ANSI": validate_bool, + "CLIENTNAME": utils.to_str, + "ENCODING": validate_encoding, + "MCCP": validate_bool, + "NOGOAHEAD": validate_bool, + "MXP": validate_bool, + "NOCOLOR": validate_bool, + "NOPKEEPALIVE": validate_bool, + "OOB": validate_bool, + "RAW": validate_bool, + "SCREENHEIGHT": validate_size, + "SCREENWIDTH": validate_size, + "SCREENREADER": validate_bool, + "TERM": utils.to_str, + "UTF-8": validate_bool, + "XTERM256": validate_bool, + "INPUTDEBUG": validate_bool, + "FORCEDENDLINE": validate_bool, + "LOCALECHO": validate_bool, + } + + name = self.lhs.upper() + val = self.rhs.strip() + optiondict = False + if val and name in validators: + optiondict = update(name, val, validators[name]) + else: + self.msg("|rNo option named '|w%s|r'." % name) + if optiondict: + # a valid setting + if "save" in self.switches: + # save this option only + saved_options = self.account.attributes.get("_saved_protocol_flags", default={}) + saved_options.update(optiondict) + self.account.attributes.add("_saved_protocol_flags", saved_options) + for key in optiondict: + self.msg(f"|gSaved option {key}.|n") + if "clear" in self.switches: + # clear this save + for key in optiondict: + self.account.attributes.get("_saved_protocol_flags", {}).pop(key, None) + self.msg(f"|gCleared saved {key}.") + self.session.update_flags(**optiondict)
+ + +
[docs]class CmdPassword(COMMAND_DEFAULT_CLASS): + """ + change your password + + Usage: + password <old password> = <new password> + + Changes your password. Make sure to pick a safe one. + """ + + key = "password" + locks = "cmd:pperm(Player)" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """hook function.""" + + account = self.account + if not self.rhs: + self.msg("Usage: password <oldpass> = <newpass>") + return + oldpass = self.lhslist[0] # Both of these are + newpass = self.rhslist[0] # already stripped by parse() + + # Validate password + validated, error = account.validate_password(newpass) + + if not account.check_password(oldpass): + self.msg("The specified old password isn't correct.") + elif not validated: + errors = [e for suberror in error.messages for e in error.messages] + string = "\n".join(errors) + self.msg(string) + else: + account.set_password(newpass) + account.save() + self.msg("Password changed.") + logger.log_sec( + f"Password Changed: {account} (Caller: {account}, IP: {self.session.address})." + )
+ + +
[docs]class CmdQuit(COMMAND_DEFAULT_CLASS): + """ + quit the game + + Usage: + quit + + Switch: + all - disconnect all connected sessions + + Gracefully disconnect your current session from the + game. Use the /all switch to disconnect from all sessions. + """ + + key = "quit" + switch_options = ("all",) + locks = "cmd:all()" + + # this is used by the parent + account_caller = True + +
[docs] def func(self): + """hook function""" + account = self.account + + if "all" in self.switches: + account.msg( + "|RQuitting|n all sessions. Hope to see you soon again.", session=self.session + ) + reason = "quit/all" + for session in account.sessions.all(): + account.disconnect_session_from_account(session, reason) + else: + nsess = len(account.sessions.all()) + reason = "quit" + if nsess == 2: + account.msg("|RQuitting|n. One session is still connected.", session=self.session) + elif nsess > 2: + account.msg( + "|RQuitting|n. %i sessions are still connected." % (nsess - 1), + session=self.session, + ) + else: + # we are quitting the last available session + account.msg("|RQuitting|n. Hope to see you again, soon.", session=self.session) + account.disconnect_session_from_account(self.session, reason)
+ + +
[docs]class CmdColorTest(COMMAND_DEFAULT_CLASS): + """ + testing which colors your client support + + Usage: + color ansi | xterm256 + + Prints a color map along with in-mud color codes to use to produce + them. It also tests what is supported in your client. Choices are + 16-color ansi (supported in most muds) or the 256-color xterm256 + standard. No checking is done to determine your client supports + color - if not you will see rubbish appear. + """ + + key = "color" + locks = "cmd:all()" + help_category = "General" + + # this is used by the parent + account_caller = True + + # the slices of the ANSI_PARSER lists to use for retrieving the + # relevant color tags to display. Replace if using another schema. + # This command can only show one set of markup. + slice_bright_fg = slice(7, 15) # from ANSI_PARSER.ansi_map + slice_dark_fg = slice(15, 23) # from ANSI_PARSER.ansi_map + slice_dark_bg = slice(-8, None) # from ANSI_PARSER.ansi_map + slice_bright_bg = slice(None, None) # from ANSI_PARSER.ansi_xterm256_bright_bg_map + +
[docs] def table_format(self, table): + """ + Helper method to format the ansi/xterm256 tables. + Takes a table of columns [[val,val,...],[val,val,...],...] + """ + if not table: + return [[]] + + extra_space = 1 + max_widths = [max([len(str(val)) for val in col]) for col in table] + ftable = [] + for irow in range(len(table[0])): + ftable.append( + [ + str(col[irow]).ljust(max_widths[icol]) + " " * extra_space + for icol, col in enumerate(table) + ] + ) + return ftable
+ +
[docs] def func(self): + """Show color tables""" + + if self.args.startswith("a"): + # show ansi 16-color table + from evennia.utils import ansi + + ap = ansi.ANSI_PARSER + # ansi colors + # show all ansi color-related codes + bright_fg = [ + "%s%s|n" % (code, code.replace("|", "||")) + for code, _ in ap.ansi_map[self.slice_bright_fg] + ] + dark_fg = [ + "%s%s|n" % (code, code.replace("|", "||")) + for code, _ in ap.ansi_map[self.slice_dark_fg] + ] + dark_bg = [ + "%s%s|n" % (code.replace("\\", ""), code.replace("|", "||").replace("\\", "")) + for code, _ in ap.ansi_map[self.slice_dark_bg] + ] + bright_bg = [ + "%s%s|n" % (code.replace("\\", ""), code.replace("|", "||").replace("\\", "")) + for code, _ in ap.ansi_xterm256_bright_bg_map[self.slice_bright_bg] + ] + dark_fg.extend(["" for _ in range(len(bright_fg) - len(dark_fg))]) + table = utils.format_table([bright_fg, dark_fg, bright_bg, dark_bg]) + string = "ANSI colors:" + for row in table: + string += "\n " + " ".join(row) + self.msg(string) + self.msg( + "||X : black. ||/ : return, ||- : tab, ||_ : space, ||* : invert, ||u : underline\n" + "To combine background and foreground, add background marker last, e.g. ||r||[B.\n" + "Note: bright backgrounds like ||[r requires your client handling Xterm256 colors." + ) + + elif self.args.startswith("x"): + # show xterm256 table + table = [[], [], [], [], [], [], [], [], [], [], [], []] + for ir in range(6): + for ig in range(6): + for ib in range(6): + # foreground table + table[ir].append("|%i%i%i%s|n" % (ir, ig, ib, "||%i%i%i" % (ir, ig, ib))) + # background table + table[6 + ir].append( + "|%i%i%i|[%i%i%i%s|n" + % (5 - ir, 5 - ig, 5 - ib, ir, ig, ib, "||[%i%i%i" % (ir, ig, ib)) + ) + table = self.table_format(table) + string = ( + "Xterm256 colors (if not all hues show, your client might not report that it can" + " handle xterm256):" + ) + string += "\n" + "\n".join("".join(row) for row in table) + table = [[], [], [], [], [], [], [], [], [], [], [], []] + for ibatch in range(4): + for igray in range(6): + letter = chr(97 + (ibatch * 6 + igray)) + inverse = chr(122 - (ibatch * 6 + igray)) + table[0 + igray].append("|=%s%s |n" % (letter, "||=%s" % letter)) + table[6 + igray].append("|=%s|[=%s%s |n" % (inverse, letter, "||[=%s" % letter)) + for igray in range(6): + # the last row (y, z) has empty columns + if igray < 2: + letter = chr(121 + igray) + inverse = chr(98 - igray) + fg = "|=%s%s |n" % (letter, "||=%s" % letter) + bg = "|=%s|[=%s%s |n" % (inverse, letter, "||[=%s" % letter) + else: + fg, bg = " ", " " + table[0 + igray].append(fg) + table[6 + igray].append(bg) + table = self.table_format(table) + string += "\n" + "\n".join("".join(row) for row in table) + self.msg(string) + else: + # malformed input + self.msg("Usage: color ansi||xterm256")
+ + +
[docs]class CmdQuell(COMMAND_DEFAULT_CLASS): + """ + use character's permissions instead of account's + + Usage: + quell + unquell + + Normally the permission level of the Account is used when puppeting a + Character/Object to determine access. This command will switch the lock + system to make use of the puppeted Object's permissions instead. This is + useful mainly for testing. + Hierarchical permission quelling only work downwards, thus an Account cannot + use a higher-permission Character to escalate their permission level. + Use the unquell command to revert back to normal operation. + """ + + key = "quell" + aliases = ["unquell"] + locks = "cmd:pperm(Player)" + help_category = "General" + + # this is used by the parent + account_caller = True + + def _recache_locks(self, account): + """Helper method to reset the lockhandler on an already puppeted object""" + if self.session: + char = self.session.puppet + if char: + # we are already puppeting an object. We need to reset + # the lock caches (otherwise the superuser status change + # won't be visible until repuppet) + char.locks.reset() + account.locks.reset() + +
[docs] def func(self): + """Perform the command""" + account = self.account + permstr = ( + account.is_superuser and "(superuser)" or "(%s)" % ", ".join(account.permissions.all()) + ) + if self.cmdstring in ("unquell", "unquell"): + if not account.attributes.get("_quell"): + self.msg(f"Already using normal Account permissions {permstr}.") + else: + account.attributes.remove("_quell") + self.msg(f"Account permissions {permstr} restored.") + else: + if account.attributes.get("_quell"): + self.msg(f"Already quelling Account {permstr} permissions.") + return + account.attributes.add("_quell", True) + puppet = self.session.puppet if self.session else None + if puppet: + cpermstr = "(%s)" % ", ".join(puppet.permissions.all()) + cpermstr = f"Quelling to current puppet's permissions {cpermstr}." + cpermstr += ( + f"\n(Note: If this is higher than Account permissions {permstr}," + " the lowest of the two will be used.)" + ) + cpermstr += "\nUse unquell to return to normal permission usage." + self.msg(cpermstr) + else: + self.msg(f"Quelling Account permissions {permstr}. Use unquell to get them back.") + self._recache_locks(account)
+ + +
[docs]class CmdStyle(COMMAND_DEFAULT_CLASS): + """ + In-game style options + + Usage: + style + style <option> = <value> + + Configure stylings for in-game display elements like table borders, help + entries etc. Use without arguments to see all available options. + + """ + + key = "style" + switch_options = ["clear"] + +
[docs] def func(self): + if not self.args: + self.list_styles() + return + self.set()
+ +
[docs] def list_styles(self): + table = self.styled_table("Option", "Description", "Type", "Value", width=78) + for op_key in self.account.options.options_dict.keys(): + op_found = self.account.options.get(op_key, return_obj=True) + table.add_row( + op_key, op_found.description, op_found.__class__.__name__, op_found.display() + ) + self.msg(str(table))
+ +
[docs] def set(self): + try: + result = self.account.options.set(self.lhs, self.rhs) + except ValueError as e: + self.msg(str(e)) + return + self.msg(f"Style {result.key} set to {result.display()}")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/admin.html b/docs/latest/_modules/evennia/commands/default/admin.html new file mode 100644 index 0000000000..f657f5e935 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/admin.html @@ -0,0 +1,702 @@ + + + + + + + + evennia.commands.default.admin — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.admin

+"""
+
+Admin commands
+
+"""
+
+import re
+import time
+
+from django.conf import settings
+
+import evennia
+from evennia.server.models import ServerConfig
+from evennia.utils import class_from_module, evtable, logger, search
+
+COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+PERMISSION_HIERARCHY = [p.lower() for p in settings.PERMISSION_HIERARCHY]
+
+# limit members for API inclusion
+__all__ = (
+    "CmdBoot",
+    "CmdBan",
+    "CmdUnban",
+    "CmdEmit",
+    "CmdNewPassword",
+    "CmdPerm",
+    "CmdWall",
+    "CmdForce",
+)
+
+
+
[docs]class CmdBoot(COMMAND_DEFAULT_CLASS): + """ + kick an account from the server. + + Usage + boot[/switches] <account obj> [: reason] + + Switches: + quiet - Silently boot without informing account + sid - boot by session id instead of name or dbref + + Boot an account object from the server. If a reason is + supplied it will be echoed to the user unless /quiet is set. + """ + + key = "boot" + switch_options = ("quiet", "sid") + locks = "cmd:perm(boot) or perm(Admin)" + help_category = "Admin" + +
[docs] def func(self): + """Implementing the function""" + caller = self.caller + args = self.args + + if not args: + caller.msg("Usage: boot[/switches] <account> [:reason]") + return + + if ":" in args: + args, reason = [a.strip() for a in args.split(":", 1)] + else: + args, reason = args, "" + + boot_list = [] + + if "sid" in self.switches: + # Boot a particular session id. + sessions = evennia.SESSION_HANDLER.get_sessions(True) + for sess in sessions: + # Find the session with the matching session id. + if sess.sessid == int(args): + boot_list.append(sess) + break + else: + # Boot by account object + pobj = search.account_search(args) + if not pobj: + caller.msg(f"Account {args} was not found.") + return + pobj = pobj[0] + if not pobj.access(caller, "boot"): + caller.msg(f"You don't have the permission to boot {pobj.key}.") + return + # we have a bootable object with a connected user + matches = evennia.SESSION_HANDLER.sessions_from_account(pobj) + for match in matches: + boot_list.append(match) + + if not boot_list: + caller.msg("No matching sessions found. The Account does not seem to be online.") + return + + # Carry out the booting of the sessions in the boot list. + + feedback = None + if "quiet" not in self.switches: + feedback = f"You have been disconnected by {caller.name}.\n" + if reason: + feedback += f"\nReason given: {reason}" + + for session in boot_list: + session.msg(feedback) + session.account.disconnect_session_from_account(session) + + if pobj and boot_list: + logger.log_sec( + f"Booted: {pobj} (Reason: {reason}, Caller: {caller}, IP: {self.session.address})." + )
+ + +# regex matching IP addresses with wildcards, eg. 233.122.4.* +IPREGEX = re.compile(r"[0-9*]{1,3}\.[0-9*]{1,3}\.[0-9*]{1,3}\.[0-9*]{1,3}") + + +def list_bans(cmd, banlist): + """ + Helper function to display a list of active bans. Input argument + is the banlist read into the two commands ban and unban below. + + Args: + cmd (Command): Instance of the Ban command. + banlist (list): List of bans to list. + """ + if not banlist: + return "No active bans were found." + + table = cmd.styled_table("|wid", "|wname/ip", "|wdate", "|wreason") + for inum, ban in enumerate(banlist): + table.add_row(str(inum + 1), ban[0] and ban[0] or ban[1], ban[3], ban[4]) + return f"|wActive bans:|n\n{table}" + + +
[docs]class CmdBan(COMMAND_DEFAULT_CLASS): + """ + ban an account from the server + + Usage: + ban [<name or ip> [: reason]] + + Without any arguments, shows numbered list of active bans. + + This command bans a user from accessing the game. Supply an optional + reason to be able to later remember why the ban was put in place. + + It is often preferable to ban an account from the server than to + delete an account with accounts/delete. If banned by name, that account + account can no longer be logged into. + + IP (Internet Protocol) address banning allows blocking all access + from a specific address or subnet. Use an asterisk (*) as a + wildcard. + + Examples: + ban thomas - ban account 'thomas' + ban/ip 134.233.2.111 - ban specific ip address + ban/ip 134.233.2.* - ban all in a subnet + ban/ip 134.233.*.* - even wider ban + + A single IP filter can be easy to circumvent by changing computers + or requesting a new IP address. Setting a wide IP block filter with + wildcards might be tempting, but remember that it may also + accidentally block innocent users connecting from the same country + or region. + + """ + + key = "ban" + aliases = ["bans"] + locks = "cmd:perm(ban) or perm(Developer)" + help_category = "Admin" + +
[docs] def func(self): + """ + Bans are stored in a serverconf db object as a list of + dictionaries: + [ (name, ip, ipregex, date, reason), + (name, ip, ipregex, date, reason),... ] + where name and ip are set by the user and are shown in + lists. ipregex is a converted form of ip where the * is + replaced by an appropriate regex pattern for fast + matching. date is the time stamp the ban was instigated and + 'reason' is any optional info given to the command. Unset + values in each tuple is set to the empty string. + """ + banlist = ServerConfig.objects.conf("server_bans") + if not banlist: + banlist = [] + + if not self.args or ( + self.switches and not any(switch in ("ip", "name") for switch in self.switches) + ): + self.msg(list_bans(self, banlist)) + return + + now = time.ctime() + reason = "" + if ":" in self.args: + ban, reason = self.args.rsplit(":", 1) + else: + ban = self.args + ban = ban.lower() + ipban = IPREGEX.findall(ban) + if not ipban: + # store as name + typ = "Name" + bantup = (ban, "", "", now, reason) + else: + # an ip address. + typ = "IP" + ban = ipban[0] + # replace * with regex form and compile it + ipregex = ban.replace(".", "\.") + ipregex = ipregex.replace("*", "[0-9]{1,3}") + ipregex = re.compile(r"%s" % ipregex) + bantup = ("", ban, ipregex, now, reason) + + ret = yield (f"Are you sure you want to {typ}-ban '|w{ban}|n' [Y]/N?") + if str(ret).lower() in ("no", "n"): + self.msg("Aborted.") + return + + # save updated banlist + banlist.append(bantup) + ServerConfig.objects.conf("server_bans", banlist) + self.msg(f"{typ}-ban '|w{ban}|n' was added. Use |wunban|n to reinstate.") + logger.log_sec( + f"Banned {typ}: {ban.strip()} (Caller: {self.caller}, IP: {self.session.address})." + )
+ + +
[docs]class CmdUnban(COMMAND_DEFAULT_CLASS): + """ + remove a ban from an account + + Usage: + unban <banid> + + This will clear an account name/ip ban previously set with the ban + command. Use this command without an argument to view a numbered + list of bans. Use the numbers in this list to select which one to + unban. + + """ + + key = "unban" + locks = "cmd:perm(unban) or perm(Developer)" + help_category = "Admin" + +
[docs] def func(self): + """Implement unbanning""" + + banlist = ServerConfig.objects.conf("server_bans") + + if not self.args: + self.msg(list_bans(self, banlist)) + return + + try: + num = int(self.args) + except Exception: + self.msg("You must supply a valid ban id to clear.") + return + + if not banlist: + self.msg("There are no bans to clear.") + elif not (0 < num < len(banlist) + 1): + self.msg(f"Ban id |w{self.args}|n was not found.") + else: + # all is ok, ask, then clear ban + ban = banlist[num - 1] + value = (" ".join([s for s in ban[:2]])).strip() + + ret = yield (f"Are you sure you want to unban {num}: '|w{value}|n' [Y]/N?") + if str(ret).lower() in ("n", "no"): + self.msg("Aborted.") + return + + del banlist[num - 1] + ServerConfig.objects.conf("server_bans", banlist) + self.msg(f"Cleared ban {num}: '{value}'") + logger.log_sec( + f"Unbanned: {value.strip()} (Caller: {self.caller}, IP: {self.session.address})." + )
+ + +
[docs]class CmdEmit(COMMAND_DEFAULT_CLASS): + """ + admin command for emitting message to multiple objects + + Usage: + emit[/switches] [<obj>, <obj>, ... =] <message> + remit [<obj>, <obj>, ... =] <message> + pemit [<obj>, <obj>, ... =] <message> + + Switches: + room - limit emits to rooms only (default) + accounts - limit emits to accounts only + contents - send to the contents of matched objects too + + Emits a message to the selected objects or to + your immediate surroundings. If the object is a room, + send to its contents. remit and pemit are just + limited forms of emit, for sending to rooms and + to accounts respectively. + """ + + key = "emit" + aliases = ["pemit", "remit"] + switch_options = ("room", "accounts", "contents") + locks = "cmd:perm(emit) or perm(Builder)" + help_category = "Admin" + +
[docs] def func(self): + """Implement the command""" + + caller = self.caller + args = self.args + + if not args: + string = "Usage: " + string += "\nemit[/switches] [<obj>, <obj>, ... =] <message>" + string += "\nremit [<obj>, <obj>, ... =] <message>" + string += "\npemit [<obj>, <obj>, ... =] <message>" + caller.msg(string) + return + + rooms_only = "rooms" in self.switches + accounts_only = "accounts" in self.switches + send_to_contents = "contents" in self.switches + + # we check which command was used to force the switches + if self.cmdstring == "remit": + rooms_only = True + send_to_contents = True + elif self.cmdstring == "pemit": + accounts_only = True + + if not self.rhs: + message = self.args + objnames = [caller.location.key] + else: + message = self.rhs + objnames = self.lhslist + + # send to all objects + for objname in objnames: + obj = caller.search(objname, global_search=True) + if not obj: + return + if rooms_only and obj.location is not None: + caller.msg(f"{objname} is not a room. Ignored.") + continue + if accounts_only and not obj.has_account: + caller.msg(f"{objname} has no active account. Ignored.") + continue + if obj.access(caller, "tell"): + obj.msg(message) + if send_to_contents and hasattr(obj, "msg_contents"): + obj.msg_contents(message) + caller.msg(f"Emitted to {objname} and contents:\n{message}") + else: + caller.msg(f"Emitted to {objname}:\n{message}") + else: + caller.msg(f"You are not allowed to emit to {objname}.")
+ + +
[docs]class CmdNewPassword(COMMAND_DEFAULT_CLASS): + """ + change the password of an account + + Usage: + userpassword <user obj> = <new password> + + Set an account's password. + """ + + key = "userpassword" + locks = "cmd:perm(newpassword) or perm(Admin)" + help_category = "Admin" + +
[docs] def func(self): + """Implement the function.""" + + caller = self.caller + + if not self.rhs: + self.msg("Usage: userpassword <user obj> = <new password>") + return + + # the account search also matches 'me' etc. + account = caller.search_account(self.lhs) + if not account: + return + + newpass = self.rhs + + # Validate password + validated, error = account.validate_password(newpass) + if not validated: + errors = [e for suberror in error.messages for e in error.messages] + string = "\n".join(errors) + caller.msg(string) + return + + account.set_password(newpass) + account.save() + self.msg(f"{account.name} - new password set to '{newpass}'.") + if account.character != caller: + account.msg(f"{caller.name} has changed your password to '{newpass}'.") + logger.log_sec( + f"Password Changed: {account} (Caller: {caller}, IP: {self.session.address})." + )
+ + +
[docs]class CmdPerm(COMMAND_DEFAULT_CLASS): + """ + set the permissions of an account/object + + Usage: + perm[/switch] <object> [= <permission>[,<permission>,...]] + perm[/switch] *<account> [= <permission>[,<permission>,...]] + + Switches: + del - delete the given permission from <object> or <account>. + account - set permission on an account (same as adding * to name) + + This command sets/clears individual permission strings on an object + or account. If no permission is given, list all permissions on <object>. + """ + + key = "perm" + aliases = "setperm" + switch_options = ("del", "account") + locks = "cmd:perm(perm) or perm(Developer)" + help_category = "Admin" + +
[docs] def func(self): + """Implement function""" + + caller = self.caller + switches = self.switches + lhs, rhs = self.lhs, self.rhs + + if not self.args: + string = "Usage: perm[/switch] object [ = permission, permission, ...]" + caller.msg(string) + return + + accountmode = "account" in self.switches or lhs.startswith("*") + lhs = lhs.lstrip("*") + + if accountmode: + obj = caller.search_account(lhs) + else: + obj = caller.search(lhs, global_search=True) + if not obj: + return + + if not rhs: + if not obj.access(caller, "examine"): + caller.msg("You are not allowed to examine this object.") + return + + string = f"Permissions on |w{obj.key}|n: " + if not obj.permissions.all(): + string += "<None>" + else: + string += ", ".join(obj.permissions.all()) + if ( + hasattr(obj, "account") + and hasattr(obj.account, "is_superuser") + and obj.account.is_superuser + ): + string += "\n(... but this object is currently controlled by a SUPERUSER! " + string += "All access checks are passed automatically.)" + caller.msg(string) + return + + # we supplied an argument on the form obj = perm + locktype = "edit" if accountmode else "control" + if not obj.access(caller, locktype): + accountstr = "account" if accountmode else "object" + caller.msg(f"You are not allowed to edit this {accountstr}'s permissions.") + return + + caller_result = [] + target_result = [] + if "del" in switches: + # delete the given permission(s) from object. + for perm in self.rhslist: + obj.permissions.remove(perm) + if obj.permissions.get(perm): + caller_result.append( + f"\nPermissions {perm} could not be removed from {obj.name}." + ) + else: + caller_result.append( + f"\nPermission {perm} removed from {obj.name} (if they existed)." + ) + target_result.append( + f"\n{caller.name} revokes the permission(s) {perm} from you." + ) + logger.log_sec( + f"Permissions Deleted: {perm}, {obj} (Caller: {caller}, IP: {self.session.address})." + ) + else: + # add a new permission + permissions = obj.permissions.all() + + for perm in self.rhslist: + # don't allow to set a permission higher in the hierarchy than + # the one the caller has (to prevent self-escalation) + if perm.lower() in PERMISSION_HIERARCHY and not obj.locks.check_lockstring( + caller, f"dummy:perm({perm})" + ): + caller.msg( + "You cannot assign a permission higher than the one you have yourself." + ) + return + + if perm in permissions: + caller_result.append(f"\nPermission '{perm}' is already defined on {obj.name}.") + else: + obj.permissions.add(perm) + plystring = "the Account" if accountmode else "the Object/Character" + caller_result.append( + f"\nPermission '{perm}' given to {obj.name} ({plystring})." + ) + target_result.append( + f"\n{caller.name} gives you ({obj.name}, {plystring}) the permission '{perm}'." + ) + logger.log_sec( + f"Permissions Added: {perm}, {obj} (Caller: {caller}, IP: {self.session.address})." + ) + + caller.msg("".join(caller_result).strip()) + if target_result: + obj.msg("".join(target_result).strip())
+ + +
[docs]class CmdWall(COMMAND_DEFAULT_CLASS): + """ + make an announcement to all + + Usage: + wall <message> + + Announces a message to all connected sessions + including all currently unlogged in. + """ + + key = "wall" + locks = "cmd:perm(wall) or perm(Admin)" + help_category = "Admin" + +
[docs] def func(self): + """Implements command""" + if not self.args: + self.msg("Usage: wall <message>") + return + message = f'{self.caller.name} shouts "{self.args}"' + self.msg("Announcing to all connected sessions ...") + evennia.SESSION_HANDLER.announce_all(message)
+ + +
[docs]class CmdForce(COMMAND_DEFAULT_CLASS): + """ + forces an object to execute a command + + Usage: + force <object>=<command string> + + Example: + force bob=get stick + """ + + key = "force" + locks = "cmd:perm(spawn) or perm(Builder)" + help_category = "Building" + perm_used = "edit" + +
[docs] def func(self): + """Implements the force command""" + if not self.lhs or not self.rhs: + self.msg("You must provide a target and a command string to execute.") + return + targ = self.caller.search(self.lhs) + if not targ: + return + if not targ.access(self.caller, self.perm_used): + self.msg(f"You don't have permission to force {targ} to execute commands.") + return + targ.execute_cmd(self.rhs) + self.msg(f"You have forced {targ} to: {self.rhs}")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/batchprocess.html b/docs/latest/_modules/evennia/commands/default/batchprocess.html new file mode 100644 index 0000000000..4a98f919e6 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/batchprocess.html @@ -0,0 +1,924 @@ + + + + + + + + evennia.commands.default.batchprocess — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.batchprocess

+"""
+Batch processors
+
+These commands implements the 'batch-command' and 'batch-code'
+processors, using the functionality in evennia.utils.batchprocessors.
+They allow for offline world-building.
+
+Batch-command is the simpler system. This reads a file (*.ev)
+containing a list of in-game commands and executes them in sequence as
+if they had been entered in the game (including permission checks
+etc).
+
+Batch-code is a full-fledged python code interpreter that reads blocks
+of python code (*.py) and executes them in sequence. This allows for
+much more power than Batch-command, but requires knowing Python and
+the Evennia API.  It is also a severe security risk and should
+therefore always be limited to superusers only.
+
+"""
+import re
+
+from django.conf import settings
+
+from evennia.commands.cmdset import CmdSet
+from evennia.utils import logger, utils
+from evennia.utils.batchprocessors import BATCHCMD, BATCHCODE
+
+_RE_COMMENT = re.compile(r"^#.*?$", re.MULTILINE + re.DOTALL)
+_RE_CODE_START = re.compile(r"^# batchcode code:", re.MULTILINE)
+_COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# limit symbols for API inclusion
+__all__ = ("CmdBatchCommands", "CmdBatchCode")
+
+_HEADER_WIDTH = 70
+_UTF8_ERROR = """
+ |rDecode error in '%s'.|n
+
+ This file contains non-ascii character(s). This is common if you
+ wrote some input in a language that has more letters and special
+ symbols than English; such as accents or umlauts.  This is usually
+ fine and fully supported! But for Evennia to know how to decode such
+ characters in a universal way, the batchfile must be saved with the
+ international 'UTF-8' encoding. This file is not.
+
+ Please re-save the batchfile with the UTF-8 encoding (refer to the
+ documentation of your text editor on how to do this, or switch to a
+ better featured one) and try again.
+
+ Error reported was: '%s'
+"""
+
+
+# -------------------------------------------------------------
+# Helper functions
+# -------------------------------------------------------------
+
+
+def format_header(caller, entry):
+    """
+    Formats a header
+    """
+    width = _HEADER_WIDTH - 10
+    # strip all comments for the header
+    if caller.ndb.batch_batchmode != "batch_commands":
+        # only do cleanup for  batchcode
+        entry = _RE_CODE_START.split(entry, 1)[1]
+        entry = _RE_COMMENT.sub("", entry).strip()
+    header = utils.crop(entry, width=width)
+    ptr = caller.ndb.batch_stackptr + 1
+    stacklen = len(caller.ndb.batch_stack)
+    header = "|w%02i/%02i|G: %s|n" % (ptr, stacklen, header)
+    # add extra space to the side for padding.
+    header = "%s%s" % (header, " " * (width - len(header)))
+    header = header.replace("\n", "\\n")
+
+    return header
+
+
+def format_code(entry):
+    """
+    Formats the viewing of code and errors
+    """
+    code = ""
+    for line in entry.split("\n"):
+        code += "\n|G>>>|n %s" % line
+    return code.strip()
+
+
+def batch_cmd_exec(caller):
+    """
+    Helper function for executing a single batch-command entry
+    """
+    ptr = caller.ndb.batch_stackptr
+    stack = caller.ndb.batch_stack
+    command = stack[ptr]
+    caller.msg(format_header(caller, command))
+    try:
+        caller.execute_cmd(command)
+    except Exception:
+        logger.log_trace()
+        return False
+    return True
+
+
+def batch_code_exec(caller):
+    """
+    Helper function for executing a single batch-code entry
+    """
+    ptr = caller.ndb.batch_stackptr
+    stack = caller.ndb.batch_stack
+    debug = caller.ndb.batch_debug
+    code = stack[ptr]
+
+    caller.msg(format_header(caller, code))
+    err = BATCHCODE.code_exec(code, extra_environ={"caller": caller}, debug=debug)
+    if err:
+        caller.msg(format_code(err))
+        return False
+    return True
+
+
+def step_pointer(caller, step=1):
+    """
+    Step in stack, returning the item located.
+
+    stackptr - current position in stack
+    stack - the stack of units
+    step - how many steps to move from stackptr
+    """
+    ptr = caller.ndb.batch_stackptr
+    stack = caller.ndb.batch_stack
+    nstack = len(stack)
+    if ptr + step <= 0:
+        caller.msg("|RBeginning of batch file.")
+    if ptr + step >= nstack:
+        caller.msg("|REnd of batch file.")
+    caller.ndb.batch_stackptr = max(0, min(nstack - 1, ptr + step))
+
+
+def show_curr(caller, showall=False):
+    """
+    Show the current position in stack
+    """
+    stackptr = caller.ndb.batch_stackptr
+    stack = caller.ndb.batch_stack
+
+    if stackptr >= len(stack):
+        caller.ndb.batch_stackptr = len(stack) - 1
+        show_curr(caller, showall)
+        return
+
+    entry = stack[stackptr]
+
+    string = format_header(caller, entry)
+    codeall = entry.strip()
+    string += "|G(hh for help)"
+    if showall:
+        for line in codeall.split("\n"):
+            string += "\n|G||n %s" % line
+    caller.msg(string)
+
+
+def purge_processor(caller):
+    """
+    This purges all effects running
+    on the caller.
+    """
+    try:
+        del caller.ndb.batch_stack
+        del caller.ndb.batch_stackptr
+        del caller.ndb.batch_pythonpath
+        del caller.ndb.batch_batchmode
+    except Exception:
+        # something might have already been erased; it's not critical
+        pass
+    # clear everything back to the state before the batch call
+    if caller.ndb.batch_cmdset_backup:
+        caller.cmdset.cmdset_stack = caller.ndb.batch_cmdset_backup
+        caller.cmdset.update()
+        del caller.ndb.batch_cmdset_backup
+    else:
+        # something went wrong. Purge cmdset except default
+        caller.cmdset.clear()
+
+    # caller.scripts.validate()  # this will purge interactive mode
+
+
+# -------------------------------------------------------------
+# main access commands
+# -------------------------------------------------------------
+
+
+
[docs]class CmdBatchCommands(_COMMAND_DEFAULT_CLASS): + """ + build from batch-command file + + Usage: + batchcommands[/interactive] <python.path.to.file> + + Switch: + interactive - this mode will offer more control when + executing the batch file, like stepping, + skipping, reloading etc. + + Runs batches of commands from a batch-cmd text file (*.ev). + + """ + + key = "batchcommands" + aliases = ["batchcommand", "batchcmd"] + switch_options = ("interactive",) + locks = "cmd:perm(batchcommands) or perm(Developer)" + help_category = "Building" + +
[docs] def func(self): + """Starts the processor.""" + + caller = self.caller + + args = self.args + if not args: + caller.msg("Usage: batchcommands[/interactive] <path.to.file>") + return + python_path = self.args + + # parse indata file + + try: + commands = BATCHCMD.parse_file(python_path) + except UnicodeDecodeError as err: + caller.msg(_UTF8_ERROR % (python_path, err)) + return + except IOError as err: + if err: + err = "{}\n".format(str(err)) + else: + err = "" + string = ( + "%s'%s' could not load. You have to supply python paths " + "from one of the defined batch-file directories\n (%s)." + ) + caller.msg(string % (err, python_path, ", ".join(settings.BASE_BATCHPROCESS_PATHS))) + return + if not commands: + caller.msg(f"File {python_path} seems empty of valid commands.") + return + + switches = self.switches + + # Store work data in cache + caller.ndb.batch_stack = commands + caller.ndb.batch_stackptr = 0 + caller.ndb.batch_pythonpath = python_path + caller.ndb.batch_batchmode = "batch_commands" + # we use list() here to create a new copy of the cmdset stack + caller.ndb.batch_cmdset_backup = list(caller.cmdset.cmdset_stack) + caller.cmdset.add(BatchSafeCmdSet) + + if "inter" in switches or "interactive" in switches: + # Allow more control over how batch file is executed + + # Set interactive state directly + caller.cmdset.add(BatchInteractiveCmdSet) + + caller.msg(f"\nBatch-command processor - Interactive mode for {python_path} ...") + show_curr(caller) + else: + caller.msg( + "Running Batch-command processor - Automatic mode " + f"for {python_path} (this might take some time) ..." + ) + + # run in-process (might block) + for _ in range(len(commands)): + # loop through the batch file + if not batch_cmd_exec(caller): + return + step_pointer(caller, 1) + # clean out the safety cmdset and clean out all other + # temporary attrs. + caller.msg(f"|G Batchfile '{python_path}' applied.") + purge_processor(caller)
+ + +
[docs]class CmdBatchCode(_COMMAND_DEFAULT_CLASS): + """ + build from batch-code file + + Usage: + batchcode[/interactive] <python path to file> + + Switch: + interactive - this mode will offer more control when + executing the batch file, like stepping, + skipping, reloading etc. + debug - auto-delete all objects that has been marked as + deletable in the script file (see example files for + syntax). This is useful so as to to not leave multiple + object copies behind when testing out the script. + + Runs batches of commands from a batch-code text file (*.py). + + """ + + key = "batchcode" + aliases = ["batchcodes"] + switch_options = ("interactive", "debug") + locks = "cmd:superuser()" + help_category = "Building" + +
[docs] def func(self): + """Starts the processor.""" + + caller = self.caller + + args = self.args + if not args: + caller.msg("Usage: batchcode[/interactive/debug] <path.to.file>") + return + python_path = self.args + debug = "debug" in self.switches + + # parse indata file + try: + codes = BATCHCODE.parse_file(python_path) + except UnicodeDecodeError as err: + caller.msg(_UTF8_ERROR % (python_path, err)) + return + except IOError as err: + if err: + err = f"{err}\n" + else: + err = "" + string = ( + "%s'%s' could not load. You have to supply python paths " + "from one of the defined batch-file directories\n (%s)." + ) + caller.msg(string % (err, python_path, ", ".join(settings.BASE_BATCHPROCESS_PATHS))) + return + if not codes: + caller.msg(f"File {python_path} seems empty of functional code.") + return + + switches = self.switches + + # Store work data in cache + caller.ndb.batch_stack = codes + caller.ndb.batch_stackptr = 0 + caller.ndb.batch_pythonpath = python_path + caller.ndb.batch_batchmode = "batch_code" + caller.ndb.batch_debug = debug + # we use list() here to create a new copy of cmdset_stack + caller.ndb.batch_cmdset_backup = list(caller.cmdset.cmdset_stack) + caller.cmdset.add(BatchSafeCmdSet) + + if "inter" in switches or "interactive" in switches: + # Allow more control over how batch file is executed + + # Set interactive state directly + caller.cmdset.add(BatchInteractiveCmdSet) + + caller.msg(f"\nBatch-code processor - Interactive mode for {python_path} ...") + show_curr(caller) + else: + caller.msg(f"Running Batch-code processor - Automatic mode for {python_path} ...") + + for _ in range(len(codes)): + # loop through the batch file + if not batch_code_exec(caller): + return + step_pointer(caller, 1) + # clean out the safety cmdset and clean out all other + # temporary attrs. + caller.msg(f"|G Batchfile '{python_path}' applied.") + purge_processor(caller)
+ + +# ------------------------------------------------------------- +# State-commands for the interactive batch processor modes +# (these are the same for both processors) +# ------------------------------------------------------------- + + +class CmdStateAbort(_COMMAND_DEFAULT_CLASS): + """ + abort + + This is a safety feature. It force-ejects us out of the processor and to + the default cmdset, regardless of what current cmdset the processor might + have put us in (e.g. when testing buggy scripts etc). + """ + + key = "abort" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + """Exit back to default.""" + purge_processor(self.caller) + self.msg("Exited processor and reset out active cmdset back to the default one.") + + +class CmdStateLL(_COMMAND_DEFAULT_CLASS): + """ + ll + + Look at the full source for the current + command definition. + """ + + key = "ll" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + show_curr(self.caller, showall=True) + + +class CmdStatePP(_COMMAND_DEFAULT_CLASS): + """ + pp + + Process the currently shown command definition. + """ + + key = "pp" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + """ + This checks which type of processor we are running. + """ + caller = self.caller + if caller.ndb.batch_batchmode == "batch_code": + batch_code_exec(caller) + else: + batch_cmd_exec(caller) + + +class CmdStateRR(_COMMAND_DEFAULT_CLASS): + """ + rr + + Reload the batch file, keeping the current + position in it. + """ + + key = "rr" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + if caller.ndb.batch_batchmode == "batch_code": + new_data = BATCHCODE.parse_file(caller.ndb.batch_pythonpath) + else: + new_data = BATCHCMD.parse_file(caller.ndb.batch_pythonpath) + caller.ndb.batch_stack = new_data + caller.msg(format_code("File reloaded. Staying on same command.")) + show_curr(caller) + + +class CmdStateRRR(_COMMAND_DEFAULT_CLASS): + """ + rrr + + Reload the batch file, starting over + from the beginning. + """ + + key = "rrr" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + if caller.ndb.batch_batchmode == "batch_code": + BATCHCODE.parse_file(caller.ndb.batch_pythonpath) + else: + BATCHCMD.parse_file(caller.ndb.batch_pythonpath) + caller.ndb.batch_stackptr = 0 + caller.msg(format_code("File reloaded. Restarting from top.")) + show_curr(caller) + + +class CmdStateNN(_COMMAND_DEFAULT_CLASS): + """ + nn + + Go to next command. No commands are executed. + """ + + key = "nn" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + arg = self.args + if arg and arg.isdigit(): + step = int(self.args) + else: + step = 1 + step_pointer(caller, step) + show_curr(caller) + + +class CmdStateNL(_COMMAND_DEFAULT_CLASS): + """ + nl + + Go to next command, viewing its full source. + No commands are executed. + """ + + key = "nl" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + arg = self.args + if arg and arg.isdigit(): + step = int(self.args) + else: + step = 1 + step_pointer(caller, step) + show_curr(caller, showall=True) + + +class CmdStateBB(_COMMAND_DEFAULT_CLASS): + """ + bb + + Backwards to previous command. No commands + are executed. + """ + + key = "bb" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + arg = self.args + if arg and arg.isdigit(): + step = -int(self.args) + else: + step = -1 + step_pointer(caller, step) + show_curr(caller) + + +class CmdStateBL(_COMMAND_DEFAULT_CLASS): + """ + bl + + Backwards to previous command, viewing its full + source. No commands are executed. + """ + + key = "bl" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + arg = self.args + if arg and arg.isdigit(): + step = -int(self.args) + else: + step = -1 + step_pointer(caller, step) + show_curr(caller, showall=True) + + +class CmdStateSS(_COMMAND_DEFAULT_CLASS): + """ + ss [steps] + + Process current command, then step to the next + one. If steps is given, + process this many commands. + """ + + key = "ss" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + arg = self.args + if arg and arg.isdigit(): + step = int(self.args) + else: + step = 1 + + for _ in range(step): + if caller.ndb.batch_batchmode == "batch_code": + batch_code_exec(caller) + else: + batch_cmd_exec(caller) + step_pointer(caller, 1) + show_curr(caller) + + +class CmdStateSL(_COMMAND_DEFAULT_CLASS): + """ + sl [steps] + + Process current command, then step to the next + one, viewing its full source. If steps is given, + process this many commands. + """ + + key = "sl" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + arg = self.args + if arg and arg.isdigit(): + step = int(self.args) + else: + step = 1 + + for _ in range(step): + if caller.ndb.batch_batchmode == "batch_code": + batch_code_exec(caller) + else: + batch_cmd_exec(caller) + step_pointer(caller, 1) + show_curr(caller) + + +class CmdStateCC(_COMMAND_DEFAULT_CLASS): + """ + cc + + Continue to process all remaining + commands. + """ + + key = "cc" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + nstack = len(caller.ndb.batch_stack) + ptr = caller.ndb.batch_stackptr + step = nstack - ptr + + for _ in range(step): + if caller.ndb.batch_batchmode == "batch_code": + batch_code_exec(caller) + else: + batch_cmd_exec(caller) + step_pointer(caller, 1) + show_curr(caller) + + purge_processor(self) + caller.msg(format_code("Finished processing batch file.")) + + +class CmdStateJJ(_COMMAND_DEFAULT_CLASS): + """ + jj <command number> + + Jump to specific command number + """ + + key = "jj" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + arg = self.args + if arg and arg.isdigit(): + number = int(self.args) - 1 + else: + caller.msg(format_code("You must give a number index.")) + return + ptr = caller.ndb.batch_stackptr + step = number - ptr + step_pointer(caller, step) + show_curr(caller) + + +class CmdStateJL(_COMMAND_DEFAULT_CLASS): + """ + jl <command number> + + Jump to specific command number and view its full source. + """ + + key = "jl" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + caller = self.caller + arg = self.args + if arg and arg.isdigit(): + number = int(self.args) - 1 + else: + caller.msg(format_code("You must give a number index.")) + return + ptr = caller.ndb.batch_stackptr + step = number - ptr + step_pointer(caller, step) + show_curr(caller, showall=True) + + +class CmdStateQQ(_COMMAND_DEFAULT_CLASS): + """ + qq + + Quit the batchprocessor. + """ + + key = "qq" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + purge_processor(self.caller) + self.msg("Aborted interactive batch mode.") + + +class CmdStateHH(_COMMAND_DEFAULT_CLASS): + """Help command""" + + key = "hh" + help_category = "BatchProcess" + locks = "cmd:perm(batchcommands)" + + def func(self): + string = """ + Interactive batch processing commands: + + nn [steps] - next command (no processing) + nl [steps] - next & look + bb [steps] - back to previous command (no processing) + bl [steps] - back & look + jj <N> - jump to command nr N (no processing) + jl <N> - jump & look + pp - process currently shown command (no step) + ss [steps] - process & step + sl [steps] - process & step & look + ll - look at full definition of current command + rr - reload batch file (stay on current) + rrr - reload batch file (start from first) + hh - this help list + + cc - continue processing to end, then quit. + qq - quit (abort all remaining commands) + + abort - this is a safety command that always is available + regardless of what cmdsets gets added to us during + batch-command processing. It immediately shuts down + the processor and returns us to the default cmdset. + """ + self.msg(string) + + +# ------------------------------------------------------------- +# +# Defining the cmdsets for the interactive batchprocessor +# mode (same for both processors) +# +# ------------------------------------------------------------- + + +class BatchSafeCmdSet(CmdSet): + """ + The base cmdset for the batch processor. + This sets a 'safe' abort command that will + always be available to get out of everything. + """ + + key = "Batch_default" + priority = 150 # override other cmdsets. + + def at_cmdset_creation(self): + """Init the cmdset""" + self.add(CmdStateAbort()) + + +class BatchInteractiveCmdSet(CmdSet): + """ + The cmdset for the interactive batch processor mode. + """ + + key = "Batch_interactive" + priority = 104 + + def at_cmdset_creation(self): + """init the cmdset""" + self.add(CmdStateAbort()) + self.add(CmdStateLL()) + self.add(CmdStatePP()) + self.add(CmdStateRR()) + self.add(CmdStateRRR()) + self.add(CmdStateNN()) + self.add(CmdStateNL()) + self.add(CmdStateBB()) + self.add(CmdStateBL()) + self.add(CmdStateSS()) + self.add(CmdStateSL()) + self.add(CmdStateCC()) + self.add(CmdStateJJ()) + self.add(CmdStateJL()) + self.add(CmdStateQQ()) + self.add(CmdStateHH()) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/building.html b/docs/latest/_modules/evennia/commands/default/building.html new file mode 100644 index 0000000000..954a986643 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/building.html @@ -0,0 +1,4622 @@ + + + + + + + + evennia.commands.default.building — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.building

+"""
+Building and world design commands
+"""
+import re
+import typing
+
+from django.conf import settings
+from django.core.paginator import Paginator
+from django.db.models import Max, Min, Q
+
+import evennia
+from evennia import InterruptCommand
+from evennia.commands.cmdhandler import get_and_merge_cmdsets, generate_cmdset_providers
+from evennia.locks.lockhandler import LockException
+from evennia.objects.models import ObjectDB
+from evennia.prototypes import menus as olc_menus
+from evennia.prototypes import prototypes as protlib
+from evennia.prototypes import spawner
+from evennia.scripts.models import ScriptDB
+from evennia.utils import create, funcparser, logger, search, utils
+from evennia.utils.ansi import raw as ansi_raw
+from evennia.utils.dbserialize import deserialize
+from evennia.utils.eveditor import EvEditor
+from evennia.utils.evmore import EvMore
+from evennia.utils.evtable import EvTable
+from evennia.utils.utils import (
+    class_from_module,
+    crop,
+    dbref,
+    display_len,
+    format_grid,
+    get_all_typeclasses,
+    inherits_from,
+    interactive,
+    list_to_string,
+    variable_from_module,
+)
+
+COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+_FUNCPARSER = None
+_ATTRFUNCPARSER = None
+
+_KEY_REGEX = re.compile(r"(?P<attr>.*?)(?P<key>(\[.*\]\ *)+)?$")
+
+# limit symbol import for API
+__all__ = (
+    "ObjManipCommand",
+    "CmdSetObjAlias",
+    "CmdCopy",
+    "CmdCpAttr",
+    "CmdMvAttr",
+    "CmdCreate",
+    "CmdDesc",
+    "CmdDestroy",
+    "CmdDig",
+    "CmdTunnel",
+    "CmdLink",
+    "CmdUnLink",
+    "CmdSetHome",
+    "CmdListCmdSets",
+    "CmdName",
+    "CmdOpen",
+    "CmdSetAttribute",
+    "CmdTypeclass",
+    "CmdWipe",
+    "CmdLock",
+    "CmdExamine",
+    "CmdFind",
+    "CmdTeleport",
+    "CmdScripts",
+    "CmdObjects",
+    "CmdTag",
+    "CmdSpawn",
+)
+
+# used by set
+from ast import literal_eval as _LITERAL_EVAL
+
+LIST_APPEND_CHAR = "+"
+
+# used by find
+CHAR_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS
+ROOM_TYPECLASS = settings.BASE_ROOM_TYPECLASS
+EXIT_TYPECLASS = settings.BASE_EXIT_TYPECLASS
+_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+
+_PROTOTYPE_PARENTS = None
+
+
+
[docs]class ObjManipCommand(COMMAND_DEFAULT_CLASS): + """ + This is a parent class for some of the defining objmanip commands + since they tend to have some more variables to define new objects. + + Each object definition can have several components. First is + always a name, followed by an optional alias list and finally an + some optional data, such as a typeclass or a location. A comma ',' + separates different objects. Like this: + + name1;alias;alias;alias:option, name2;alias;alias ... + + Spaces between all components are stripped. + + A second situation is attribute manipulation. Such commands + are simpler and offer combinations + + objname/attr/attr/attr, objname/attr, ... + + """ + + # OBS - this is just a parent - it's not intended to actually be + # included in a commandset on its own! + + # used by get_object_typeclass as defaults. + default_typeclasses = { + "object": settings.BASE_OBJECT_TYPECLASS, + "character": settings.BASE_CHARACTER_TYPECLASS, + "room": settings.BASE_ROOM_TYPECLASS, + "exit": settings.BASE_EXIT_TYPECLASS, + } + +
[docs] def parse(self): + """ + We need to expand the default parsing to get all + the cases, see the module doc. + """ + # get all the normal parsing done (switches etc) + super().parse() + + obj_defs = ([], []) # stores left- and right-hand side of '=' + obj_attrs = ([], []) # " + + for iside, arglist in enumerate((self.lhslist, self.rhslist)): + # lhslist/rhslist is already split by ',' at this point + for objdef in arglist: + aliases, option, attrs = [], None, [] + if ":" in objdef: + objdef, option = [part.strip() for part in objdef.rsplit(":", 1)] + if ";" in objdef: + objdef, aliases = [part.strip() for part in objdef.split(";", 1)] + aliases = [alias.strip() for alias in aliases.split(";") if alias.strip()] + if "/" in objdef: + objdef, attrs = [part.strip() for part in objdef.split("/", 1)] + _attrs = [] + + # Should an attribute key is specified, ie. we're working + # on a dict, what we want is to lowercase attribute name + # as usual but to preserve dict key case as one would + # expect: + # + # set box/MyAttr = {'FooBar': 1} + # Created attribute box/myattr [category:None] = {'FooBar': 1} + # set box/MyAttr['FooBar'] = 2 + # Modified attribute box/myattr [category:None] = {'FooBar': 2} + for match in ( + match + for part in map(str.strip, attrs.split("/")) + if part and (match := _KEY_REGEX.match(part.strip())) + ): + attr = match.group("attr").lower() + # reappend untouched key, if present + if match.group("key"): + attr += match.group("key") + _attrs.append(attr) + attrs = _attrs + # store data + obj_defs[iside].append({"name": objdef, "option": option, "aliases": aliases}) + obj_attrs[iside].append({"name": objdef, "attrs": attrs}) + + # store for future access + self.lhs_objs = obj_defs[0] + self.rhs_objs = obj_defs[1] + self.lhs_objattr = obj_attrs[0] + self.rhs_objattr = obj_attrs[1]
+ +
[docs] def get_object_typeclass( + self, obj_type: str = "object", typeclass: str = None, method: str = "cmd_create", **kwargs + ) -> tuple[typing.Optional["Builder"], list[str]]: + """ + This hook is called by build commands to determine which typeclass to use for a specific purpose. For instance, + when using dig, the system can use this to autodetect which kind of Room typeclass to use based on where the + builder is currently located. + + Note: Although intended to be used with typeclasses, as long as this hook returns a class with a create method, + which accepts the same API as DefaultObject.create(), build commands and other places should take it. + + Args: + obj_type (str, optional): The type of object that is being created. Defaults to "object". Evennia provides + "room", "exit", and "character" by default, but this can be extended. + typeclass (str, optional): The typeclass that was requested by the player. Defaults to None. + Can also be an actual class. + method (str, optional): The method that is calling this hook. Defaults to "cmd_create". + Others are "cmd_dig", "cmd_open", "cmd_tunnel", etc. + + Returns: + results_tuple (tuple[Optional[Builder], list[str]]): A tuple containing the typeclass to use and a list of + errors. (which might be empty.) + """ + + found_typeclass = typeclass or self.default_typeclasses.get(obj_type, None) + if not found_typeclass: + return None, [f"No typeclass found for object type '{obj_type}'."] + + try: + type_class = ( + class_from_module(found_typeclass, settings.TYPECLASS_PATHS) + if isinstance(found_typeclass, str) + else found_typeclass + ) + except ImportError: + return None, [f"Typeclass '{found_typeclass}' could not be imported."] + + if not hasattr(type_class, "create"): + return None, [f"Typeclass '{found_typeclass}' is not creatable."] + + return type_class, []
+ + +
[docs]class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): + """ + adding permanent aliases for object + + Usage: + alias <obj> [= [alias[,alias,alias,...]]] + alias <obj> = + alias/category <obj> = [alias[,alias,...]:<category> + + Switches: + category - requires ending input with :category, to store the + given aliases with the given category. + + Assigns aliases to an object so it can be referenced by more + than one name. Assign empty to remove all aliases from object. If + assigning a category, all aliases given will be using this category. + + Observe that this is not the same thing as personal aliases + created with the 'nick' command! Aliases set with alias are + changing the object in question, making those aliases usable + by everyone. + """ + + key = "@alias" + aliases = "setobjalias" + switch_options = ("category",) + locks = "cmd:perm(setobjalias) or perm(Builder)" + help_category = "Building" + + method_type = "cmd_create" + +
[docs] def func(self): + """Set the aliases.""" + + caller = self.caller + + if not self.lhs: + string = "Usage: alias <obj> [= [alias[,alias ...]]]" + self.msg(string) + return + objname = self.lhs + + # Find the object to receive aliases + obj = caller.search(objname) + if not obj: + return + if self.rhs is None: + # no =, so we just list aliases on object. + aliases = obj.aliases.all(return_key_and_category=True) + if aliases: + caller.msg( + "Aliases for %s: %s" + % ( + obj.get_display_name(caller), + ", ".join( + "'%s'%s" + % (alias, "" if category is None else "[category:'%s']" % category) + for (alias, category) in aliases + ), + ) + ) + else: + caller.msg(f"No aliases exist for '{obj.get_display_name(caller)}'.") + return + + if not (obj.access(caller, "control") or obj.access(caller, "edit")): + caller.msg("You don't have permission to do that.") + return + + if not self.rhs: + # we have given an empty =, so delete aliases + old_aliases = obj.aliases.all() + if old_aliases: + caller.msg( + "Cleared aliases from %s: %s" + % (obj.get_display_name(caller), ", ".join(old_aliases)) + ) + obj.aliases.clear() + else: + caller.msg("No aliases to clear.") + return + + category = None + if "category" in self.switches: + if ":" in self.rhs: + rhs, category = self.rhs.rsplit(":", 1) + category = category.strip() + else: + caller.msg( + "If specifying the /category switch, the category must be given " + "as :category at the end." + ) + else: + rhs = self.rhs + + # merge the old and new aliases (if any) + old_aliases = obj.aliases.get(category=category, return_list=True) + new_aliases = [alias.strip().lower() for alias in rhs.split(",") if alias.strip()] + + # make the aliases only appear once + old_aliases.extend(new_aliases) + aliases = list(set(old_aliases)) + + # save back to object. + obj.aliases.add(aliases, category=category) + + # we need to trigger this here, since this will force + # (default) Exits to rebuild their Exit commands with the new + # aliases + obj.at_cmdset_get(force_init=True) + + # report all aliases on the object + caller.msg( + "Alias(es) for '%s' set to '%s'%s." + % ( + obj.get_display_name(caller), + str(obj.aliases), + " (category: '%s')" % category if category else "", + ) + )
+ + +
[docs]class CmdCopy(ObjManipCommand): + """ + copy an object and its properties + + Usage: + copy <original obj> [= <new_name>][;alias;alias..] + [:<new_location>] [,<new_name2> ...] + + Create one or more copies of an object. If you don't supply any targets, + one exact copy of the original object will be created with the name *_copy. + """ + + key = "@copy" + locks = "cmd:perm(copy) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """Uses ObjManipCommand.parse()""" + + caller = self.caller + args = self.args + if not args: + caller.msg( + "Usage: copy <obj> [=<new_name>[;alias;alias..]]" + "[:<new_location>] [, <new_name2>...]" + ) + return + + if not self.rhs: + # this has no target =, so an identical new object is created. + from_obj_name = self.args + from_obj = caller.search(from_obj_name) + if not from_obj: + return + to_obj_name = "%s_copy" % from_obj_name + to_obj_aliases = ["%s_copy" % alias for alias in from_obj.aliases.all()] + copiedobj = ObjectDB.objects.copy_object( + from_obj, new_key=to_obj_name, new_aliases=to_obj_aliases + ) + if copiedobj: + string = "Identical copy of %s, named '%s' was created." % ( + from_obj_name, + to_obj_name, + ) + else: + string = "There was an error copying %s." + else: + # we have specified =. This might mean many object targets + from_obj_name = self.lhs_objs[0]["name"] + from_obj = caller.search(from_obj_name) + if not from_obj: + return + for objdef in self.rhs_objs: + # loop through all possible copy-to targets + to_obj_name = objdef["name"] + to_obj_aliases = objdef["aliases"] + to_obj_location = objdef["option"] + if to_obj_location: + to_obj_location = caller.search(to_obj_location, global_search=True) + if not to_obj_location: + return + + copiedobj = ObjectDB.objects.copy_object( + from_obj, + new_key=to_obj_name, + new_location=to_obj_location, + new_aliases=to_obj_aliases, + ) + if copiedobj: + string = ( + f"Copied {from_obj_name} to '{to_obj_name}' (aliases: {to_obj_aliases})." + ) + else: + string = f"There was an error copying {from_obj_name} to '{to_obj_name}'." + # we are done, echo to user + caller.msg(string)
+ + +
[docs]class CmdCpAttr(ObjManipCommand): + """ + copy attributes between objects + + Usage: + cpattr[/switch] <obj>/<attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...] + cpattr[/switch] <obj>/<attr> = <obj1> [,<obj2>,<obj3>,...] + cpattr[/switch] <attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...] + cpattr[/switch] <attr> = <obj1>[,<obj2>,<obj3>,...] + + Switches: + move - delete the attribute from the source object after copying. + + Example: + cpattr coolness = Anna/chillout, Anna/nicety, Tom/nicety + -> + copies the coolness attribute (defined on yourself), to attributes + on Anna and Tom. + + Copy the attribute one object to one or more attributes on another object. + If you don't supply a source object, yourself is used. + """ + + key = "@cpattr" + switch_options = ("move",) + locks = "cmd:perm(cpattr) or perm(Builder)" + help_category = "Building" + +
[docs] def check_from_attr(self, obj, attr, clear=False): + """ + Hook for overriding on subclassed commands. Checks to make sure a + caller can copy the attr from the object in question. If not, return a + false value and the command will abort. An error message should be + provided by this function. + + If clear is True, user is attempting to move the attribute. + """ + return True
+ +
[docs] def check_to_attr(self, obj, attr): + """ + Hook for overriding on subclassed commands. Checks to make sure a + caller can write to the specified attribute on the specified object. + If not, return a false value and the attribute will be skipped. An + error message should be provided by this function. + """ + return True
+ +
[docs] def check_has_attr(self, obj, attr): + """ + Hook for overriding on subclassed commands. Do any preprocessing + required and verify an object has an attribute. + """ + if not obj.attributes.has(attr): + self.msg(f"{obj.name} doesn't have an attribute {attr}.") + return False + return True
+ +
[docs] def get_attr(self, obj, attr): + """ + Hook for overriding on subclassed commands. Do any preprocessing + required and get the attribute from the object. + """ + return obj.attributes.get(attr)
+ +
[docs] def func(self): + """ + Do the copying. + """ + caller = self.caller + + if not self.rhs: + string = """Usage: + cpattr[/switch] <obj>/<attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...] + cpattr[/switch] <obj>/<attr> = <obj1> [,<obj2>,<obj3>,...] + cpattr[/switch] <attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...] + cpattr[/switch] <attr> = <obj1>[,<obj2>,<obj3>,...]""" + caller.msg(string) + return + + lhs_objattr = self.lhs_objattr + to_objs = self.rhs_objattr + from_obj_name = lhs_objattr[0]["name"] + from_obj_attrs = lhs_objattr[0]["attrs"] + + if not from_obj_attrs: + # this means the from_obj_name is actually an attribute + # name on self. + from_obj_attrs = [from_obj_name] + from_obj = self.caller + else: + from_obj = caller.search(from_obj_name) + if not from_obj or not to_objs: + caller.msg("You have to supply both source object and target(s).") + return + # copy to all to_obj:ects + if "move" in self.switches: + clear = True + else: + clear = False + if not self.check_from_attr(from_obj, from_obj_attrs[0], clear=clear): + return + + for attr in from_obj_attrs: + if not self.check_has_attr(from_obj, attr): + return + + if (len(from_obj_attrs) != len(set(from_obj_attrs))) and clear: + self.msg("|RCannot have duplicate source names when moving!") + return + + result = [] + + for to_obj in to_objs: + to_obj_name = to_obj["name"] + to_obj_attrs = to_obj["attrs"] + to_obj = caller.search(to_obj_name) + if not to_obj: + result.append(f"\nCould not find object '{to_obj_name}'") + continue + for inum, from_attr in enumerate(from_obj_attrs): + try: + to_attr = to_obj_attrs[inum] + except IndexError: + # if there are too few attributes given + # on the to_obj, we copy the original name instead. + to_attr = from_attr + if not self.check_to_attr(to_obj, to_attr): + continue + value = self.get_attr(from_obj, from_attr) + to_obj.attributes.add(to_attr, value) + if clear and not (from_obj == to_obj and from_attr == to_attr): + from_obj.attributes.remove(from_attr) + result.append( + f"\nMoved {from_obj.name}.{from_attr} -> {to_obj_name}.{to_attr}. (value:" + f" {repr(value)})" + ) + else: + result.append( + f"\nCopied {from_obj.name}.{from_attr} -> {to_obj.name}.{to_attr}. (value:" + f" {repr(value)})" + ) + caller.msg("".join(result))
+ + +
[docs]class CmdMvAttr(ObjManipCommand): + """ + move attributes between objects + + Usage: + mvattr[/switch] <obj>/<attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...] + mvattr[/switch] <obj>/<attr> = <obj1> [,<obj2>,<obj3>,...] + mvattr[/switch] <attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...] + mvattr[/switch] <attr> = <obj1>[,<obj2>,<obj3>,...] + + Switches: + copy - Don't delete the original after moving. + + Move an attribute from one object to one or more attributes on another + object. If you don't supply a source object, yourself is used. + """ + + key = "@mvattr" + switch_options = ("copy",) + locks = "cmd:perm(mvattr) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """ + Do the moving + """ + if not self.rhs: + string = """Usage: + mvattr[/switch] <obj>/<attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...] + mvattr[/switch] <obj>/<attr> = <obj1> [,<obj2>,<obj3>,...] + mvattr[/switch] <attr> = <obj1>/<attr1> [,<obj2>/<attr2>,<obj3>/<attr3>,...] + mvattr[/switch] <attr> = <obj1>[,<obj2>,<obj3>,...]""" + self.msg(string) + return + + # simply use cpattr for all the functionality + if "copy" in self.switches: + self.execute_cmd("cpattr %s" % self.args) + else: + self.execute_cmd("cpattr/move %s" % self.args)
+ + +
[docs]class CmdCreate(ObjManipCommand): + """ + create new objects + + Usage: + create[/drop] <objname>[;alias;alias...][:typeclass], <objname>... + + switch: + drop - automatically drop the new object into your current + location (this is not echoed). This also sets the new + object's home to the current location rather than to you. + + Creates one or more new objects. If typeclass is given, the object + is created as a child of this typeclass. The typeclass script is + assumed to be located under types/ and any further + directory structure is given in Python notation. So if you have a + correct typeclass 'RedButton' defined in + types/examples/red_button.py, you could create a new + object of this type like this: + + create/drop button;red : examples.red_button.RedButton + + """ + + key = "@create" + switch_options = ("drop",) + locks = "cmd:perm(create) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """ + Creates the object. + """ + + caller = self.caller + + if not self.args: + string = "Usage: create[/drop] <newname>[;alias;alias...] [:typeclass.path]" + caller.msg(string) + return + + # create the objects + for objdef in self.lhs_objs: + string = "" + name = objdef["name"] + aliases = objdef["aliases"] + + obj_typeclass, errors = self.get_object_typeclass( + obj_type="object", typeclass=objdef["option"] + ) + if errors: + self.msg(errors) + if not obj_typeclass: + continue + + obj, errors = obj_typeclass.create( + name, caller, home=caller, aliases=aliases, report_to=caller, caller=caller + ) + if errors: + self.msg(errors) + if not obj: + continue + + if aliases: + string = ( + f"You create a new {obj.typename}: {obj.name} (aliases: {', '.join(aliases)})." + ) + else: + string = f"You create a new {obj.typename}: {obj.name}." + # set a default desc + if not obj.db.desc: + obj.db.desc = "You see nothing special." + if "drop" in self.switches: + if caller.location: + obj.home = caller.location + obj.move_to(caller.location, quiet=True, move_type="drop") + if string: + caller.msg(string)
+ + +def _desc_load(caller): + return caller.db.evmenu_target.db.desc or "" + + +def _desc_save(caller, buf): + """ + Save line buffer to the desc prop. This should + return True if successful and also report its status to the user. + """ + caller.db.evmenu_target.db.desc = buf + caller.msg("Saved.") + return True + + +def _desc_quit(caller): + caller.attributes.remove("evmenu_target") + caller.msg("Exited editor.") + + +
[docs]class CmdDesc(COMMAND_DEFAULT_CLASS): + """ + describe an object or the current room. + + Usage: + desc [<obj> =] <description> + + Switches: + edit - Open up a line editor for more advanced editing. + + Sets the "desc" attribute on an object. If an object is not given, + describe the current room. + """ + + key = "@desc" + switch_options = ("edit",) + locks = "cmd:perm(desc) or perm(Builder)" + help_category = "Building" + +
[docs] def edit_handler(self): + if self.rhs: + self.msg("|rYou may specify a value, or use the edit switch, but not both.|n") + return + if self.args: + obj = self.caller.search(self.args) + else: + obj = self.caller.location or self.msg("|rYou can't describe oblivion.|n") + if not obj: + return + + if not (obj.access(self.caller, "control") or obj.access(self.caller, "edit")): + self.msg(f"You don't have permission to edit the description of {obj.key}.") + return + + self.caller.db.evmenu_target = obj + # launch the editor + EvEditor( + self.caller, + loadfunc=_desc_load, + savefunc=_desc_save, + quitfunc=_desc_quit, + key="desc", + persistent=True, + ) + return
+ +
[docs] def func(self): + """Define command""" + + caller = self.caller + if not self.args and "edit" not in self.switches: + caller.msg("Usage: desc [<obj> =] <description>") + return + + if "edit" in self.switches: + self.edit_handler() + return + + if "=" in self.args: + # We have an = + obj = caller.search(self.lhs) + if not obj: + return + desc = self.rhs or "" + else: + obj = caller.location or self.msg("|rYou don't have a location to describe.|n") + if not obj: + return + desc = self.args + if obj.access(self.caller, "control") or obj.access(self.caller, "edit"): + obj.db.desc = desc + caller.msg(f"The description was set on {obj.get_display_name(caller)}.") + else: + caller.msg(f"You don't have permission to edit the description of {obj.key}.")
+ + +
[docs]class CmdDestroy(COMMAND_DEFAULT_CLASS): + """ + permanently delete objects + + Usage: + destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...] + + Switches: + override - The destroy command will usually avoid accidentally + destroying account objects. This switch overrides this safety. + force - destroy without confirmation. + Examples: + destroy house, roof, door, 44-78 + destroy 5-10, flower, 45 + destroy/force north + + Destroys one or many objects. If dbrefs are used, a range to delete can be + given, e.g. 4-10. Also the end points will be deleted. This command + displays a confirmation before destroying, to make sure of your choice. + You can specify the /force switch to bypass this confirmation. + """ + + key = "@destroy" + aliases = ["@delete", "@del"] + switch_options = ("override", "force") + locks = "cmd:perm(destroy) or perm(Builder)" + help_category = "Building" + + confirm = True # set to False to always bypass confirmation + default_confirm = "yes" # what to assume if just pressing enter (yes/no) + +
[docs] def func(self): + """Implements the command.""" + + caller = self.caller + delete = True + + if not self.args or not self.lhslist: + caller.msg("Usage: destroy[/switches] [obj, obj2, obj3, [dbref-dbref],...]") + delete = False + + def delobj(obj): + # helper function for deleting a single object + string = "" + if not obj.pk: + string = f"\nObject {obj.db_key} was already deleted." + else: + objname = obj.name + if not (obj.access(caller, "control") or obj.access(caller, "delete")): + return f"\nYou don't have permission to delete {objname}." + if obj.account and "override" not in self.switches: + return ( + f"\nObject {objname} is controlled by an active account. Use /override to" + " delete anyway." + ) + if obj.dbid == int(settings.DEFAULT_HOME.lstrip("#")): + return ( + f"\nYou are trying to delete |c{objname}|n, which is set as DEFAULT_HOME. " + "Re-point settings.DEFAULT_HOME to another " + "object before continuing." + ) + + # check if object to delete had exits or objects inside it + obj_exits = obj.exits if hasattr(obj, "exits") else () + obj_contents = obj.contents if hasattr(obj, "contents") else () + had_exits = bool(obj_exits) + had_objs = any(entity for entity in obj_contents if entity not in obj_exits) + + # do the deletion + okay = obj.delete() + if not okay: + string += ( + f"\nERROR: {objname} not deleted, probably because delete() returned False." + ) + else: + string += f"\n{objname} was destroyed." + if had_exits: + string += f" Exits to and from {objname} were destroyed as well." + if had_objs: + string += f" Objects inside {objname} were moved to their homes." + return string + + objs = [] + for objname in self.lhslist: + if not delete: + continue + + if "-" in objname: + # might be a range of dbrefs + dmin, dmax = [utils.dbref(part, reqhash=False) for part in objname.split("-", 1)] + if dmin and dmax: + for dbref in range(int(dmin), int(dmax + 1)): + obj = caller.search("#" + str(dbref)) + if obj: + objs.append(obj) + continue + else: + obj = caller.search(objname) + else: + obj = caller.search(objname) + + if obj is None: + self.msg( + " (Objects to destroy must either be local or specified with a unique #dbref.)" + ) + elif obj not in objs: + objs.append(obj) + + if objs and ("force" not in self.switches and type(self).confirm): + confirm = "Are you sure you want to destroy " + if len(objs) == 1: + confirm += objs[0].get_display_name(caller) + elif len(objs) < 5: + confirm += ", ".join([obj.get_display_name(caller) for obj in objs]) + else: + confirm += ", ".join(["#{}".format(obj.id) for obj in objs]) + confirm += " [yes]/no?" if self.default_confirm == "yes" else " yes/[no]" + answer = "" + answer = yield (confirm) + answer = self.default_confirm if answer == "" else answer + + if answer and answer not in ("yes", "y", "no", "n"): + caller.msg( + "Canceled: Either accept the default by pressing return or specify yes/no." + ) + delete = False + elif answer.strip().lower() in ("n", "no"): + caller.msg("Canceled: No object was destroyed.") + delete = False + + if delete: + results = [] + for obj in objs: + results.append(delobj(obj)) + + if results: + caller.msg("".join(results).strip())
+ + +
[docs]class CmdDig(ObjManipCommand): + """ + build new rooms and connect them to the current location + + Usage: + dig[/switches] <roomname>[;alias;alias...][:typeclass] + [= <exit_to_there>[;alias][:typeclass]] + [, <exit_to_here>[;alias][:typeclass]] + + Switches: + tel or teleport - move yourself to the new room + + Examples: + dig kitchen = north;n, south;s + dig house:myrooms.MyHouseTypeclass + dig sheer cliff;cliff;sheer = climb up, climb down + + This command is a convenient way to build rooms quickly; it creates the + new room and you can optionally set up exits back and forth between your + current room and the new one. You can add as many aliases as you + like to the name of the room and the exits in question; an example + would be 'north;no;n'. + """ + + key = "@dig" + switch_options = ("teleport",) + locks = "cmd:perm(dig) or perm(Builder)" + help_category = "Building" + + method_type = "cmd_dig" + + # lockstring of newly created rooms, for easy overloading. + # Will be formatted with the {id} of the creating object. + new_room_lockstring = ( + "control:id({id}) or perm(Admin); " + "delete:id({id}) or perm(Admin); " + "edit:id({id}) or perm(Admin)" + ) + +
[docs] def func(self): + """Do the digging. Inherits variables from ObjManipCommand.parse()""" + + caller = self.caller + + if not self.lhs: + string = "Usage: dig[/teleport] <roomname>[;alias;alias...][:parent] [= <exit_there>" + string += "[;alias;alias..][:parent]] " + string += "[, <exit_back_here>[;alias;alias..][:parent]]" + caller.msg(string) + return + + room = self.lhs_objs[0] + + if not room["name"]: + caller.msg("You must supply a new room name.") + return + location = caller.location + + # Create the new room + room_typeclass, errors = self.get_object_typeclass( + obj_type="room", typeclass=room["option"], method=self.method_type + ) + if errors: + self.msg("|rError creating room:|n %s" % errors) + if not room_typeclass: + return + + # create room + new_room, errors = room_typeclass.create( + room["name"], + aliases=room["aliases"], + report_to=caller, + caller=caller, + method=self.method_type, + ) + if errors: + self.msg("|rError creating room:|n %s" % errors) + if not new_room: + return + + alias_string = "" + if new_room.aliases.all(): + alias_string = " (%s)" % ", ".join(new_room.aliases.all()) + + room_string = f"Created room {new_room}({new_room.dbref}){alias_string} of type {new_room}." + + # create exit to room + + exit_to_string = "" + exit_back_string = "" + + if self.rhs_objs: + to_exit = self.rhs_objs[0] + if not to_exit["name"]: + exit_to_string = "\nNo exit created to new room." + elif not location: + exit_to_string = "\nYou cannot create an exit from a None-location." + else: + # Build the exit to the new room from the current one + exit_typeclass, errors = self.get_object_typeclass( + obj_type="exit", typeclass=to_exit["option"], method=self.method_type + ) + if errors: + self.msg("|rError creating exit:|n %s" % errors) + if not exit_typeclass: + return + + new_to_exit, errors = exit_typeclass.create( + to_exit["name"], + location=location, + destination=new_room, + aliases=to_exit["aliases"], + report_to=caller, + caller=caller, + method=self.method_type, + ) + if errors: + self.msg("|rError creating exit:|n %s" % errors) + if not new_to_exit: + return + + alias_string = "" + if new_to_exit.aliases.all(): + alias_string = " (%s)" % ", ".join(new_to_exit.aliases.all()) + exit_to_string = ( + f"\nCreated Exit from {location.name} to {new_room.name}:" + f" {new_to_exit}({new_to_exit.dbref}){alias_string}." + ) + + # Create exit back from new room + + if len(self.rhs_objs) > 1: + # Building the exit back to the current room + back_exit = self.rhs_objs[1] + if not back_exit["name"]: + exit_back_string = "\nNo back exit created." + elif not location: + exit_back_string = "\nYou cannot create an exit back to a None-location." + else: + exit_typeclass, errors = self.get_object_typeclass( + obj_type="exit", typeclass=back_exit["option"], method=self.method_type + ) + if errors: + self.msg("|rError creating exit:|n %s" % errors) + if not exit_typeclass: + return + new_back_exit, errors = exit_typeclass.create( + back_exit["name"], + location=new_room, + destination=location, + aliases=back_exit["aliases"], + report_to=caller, + caller=caller, + method=self.method_type, + ) + if errors: + self.msg("|rError creating exit:|n %s" % errors) + if not new_back_exit: + return + alias_string = "" + if new_back_exit.aliases.all(): + alias_string = " (%s)" % ", ".join(new_back_exit.aliases.all()) + exit_back_string = ( + f"\nCreated Exit back from {new_room.name} to {location.name}:" + f" {new_back_exit}({new_back_exit.dbref}){alias_string}." + ) + caller.msg(f"{room_string}{exit_to_string}{exit_back_string}") + if new_room and "teleport" in self.switches: + caller.move_to(new_room, move_type="teleport")
+ + +
[docs]class CmdTunnel(COMMAND_DEFAULT_CLASS): + """ + create new rooms in cardinal directions only + + Usage: + tunnel[/switch] <direction>[:typeclass] [= <roomname>[;alias;alias;...][:typeclass]] + + Switches: + oneway - do not create an exit back to the current location + tel - teleport to the newly created room + + Example: + tunnel n + tunnel n = house;mike's place;green building + + This is a simple way to build using pre-defined directions: + |wn,ne,e,se,s,sw,w,nw|n (north, northeast etc) + |wu,d|n (up and down) + |wi,o|n (in and out) + The full names (north, in, southwest, etc) will always be put as + main name for the exit, using the abbreviation as an alias (so an + exit will always be able to be used with both "north" as well as + "n" for example). Opposite directions will automatically be + created back from the new room unless the /oneway switch is given. + For more flexibility and power in creating rooms, use dig. + """ + + key = "@tunnel" + aliases = ["@tun"] + switch_options = ("oneway", "tel") + locks = "cmd: perm(tunnel) or perm(Builder)" + help_category = "Building" + + method_type = "cmd_tunnel" + + # store the direction, full name and its opposite + directions = { + "n": ("north", "s"), + "ne": ("northeast", "sw"), + "e": ("east", "w"), + "se": ("southeast", "nw"), + "s": ("south", "n"), + "sw": ("southwest", "ne"), + "w": ("west", "e"), + "nw": ("northwest", "se"), + "u": ("up", "d"), + "d": ("down", "u"), + "i": ("in", "o"), + "o": ("out", "i"), + } + +
[docs] def func(self): + """Implements the tunnel command""" + + if not self.args or not self.lhs: + string = ( + "Usage: tunnel[/switch] <direction>[:typeclass] [= <roomname>" + "[;alias;alias;...][:typeclass]]" + ) + self.msg(string) + return + + # If we get a typeclass, we need to get just the exitname + exitshort = self.lhs.split(":")[0] + + if exitshort not in self.directions: + string = "tunnel can only understand the following directions: %s." % ",".join( + sorted(self.directions.keys()) + ) + string += "\n(use dig for more freedom)" + self.msg(string) + return + + # retrieve all input and parse it + exitname, backshort = self.directions[exitshort] + backname = self.directions[backshort][0] + + # if we received a typeclass for the exit, add it to the alias(short name) + if ":" in self.lhs: + # limit to only the first : character + exit_typeclass = ":" + self.lhs.split(":", 1)[-1] + # exitshort and backshort are the last part of the exit strings, + # so we add our typeclass argument after + exitshort += exit_typeclass + backshort += exit_typeclass + + roomname = "Some place" + if self.rhs: + roomname = self.rhs # this may include aliases; that's fine. + + telswitch = "" + if "tel" in self.switches: + telswitch = "/teleport" + backstring = "" + if "oneway" not in self.switches: + backstring = f", {backname};{backshort}" + + # build the string we will use to call dig + digstring = f"dig{telswitch} {roomname} = {exitname};{exitshort}{backstring}" + self.execute_cmd(digstring)
+ + + + + + + + +
[docs]class CmdSetHome(CmdLink): + """ + set an object's home location + + Usage: + sethome <obj> [= <home_location>] + sethome <obj> + + The "home" location is a "safety" location for objects; they + will be moved there if their current location ceases to exist. All + objects should always have a home location for this reason. + It is also a convenient target of the "home" command. + + If no location is given, just view the object's home location. + """ + + key = "@sethome" + locks = "cmd:perm(sethome) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """implement the command""" + if not self.args: + string = "Usage: sethome <obj> [= <home_location>]" + self.msg(string) + return + + obj = self.caller.search(self.lhs, global_search=True) + if not obj: + return + if not self.rhs: + # just view + home = obj.home + if not home: + string = "This object has no home location set!" + else: + string = f"{obj}'s current home is {home}({home.dbref})." + else: + # set a home location + new_home = self.caller.search(self.rhs, global_search=True) + if not new_home: + return + old_home = obj.home + obj.home = new_home + if old_home: + string = ( + f"Home location of {obj} was changed from {old_home}({old_home.dbref} to" + f" {new_home}({new_home.dbref})." + ) + else: + string = f"Home location of {obj} was set to {new_home}({new_home.dbref})." + self.msg(string)
+ + +
[docs]class CmdListCmdSets(COMMAND_DEFAULT_CLASS): + """ + list command sets defined on an object + + Usage: + cmdsets <obj> + + This displays all cmdsets assigned + to a user. Defaults to yourself. + """ + + key = "@cmdsets" + locks = "cmd:perm(listcmdsets) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """list the cmdsets""" + + caller = self.caller + if self.arglist: + obj = caller.search(self.arglist[0]) + if not obj: + return + else: + obj = caller + string = f"{obj.cmdset}" + caller.msg(string)
+ + +
[docs]class CmdName(ObjManipCommand): + """ + change the name and/or aliases of an object + + Usage: + name <obj> = <newname>;alias1;alias2 + + Rename an object to something new. Use *obj to + rename an account. + + """ + + key = "@name" + aliases = ["@rename"] + locks = "cmd:perm(rename) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """change the name""" + + caller = self.caller + if not self.args: + caller.msg("Usage: name <obj> = <newname>[;alias;alias;...]") + return + + obj = None + if self.lhs_objs: + objname = self.lhs_objs[0]["name"] + if objname.startswith("*"): + # account mode + obj = caller.account.search(objname.lstrip("*")) + if obj: + if self.rhs_objs[0]["aliases"]: + caller.msg("Accounts can't have aliases.") + return + newname = self.rhs + if not newname: + caller.msg("No name defined!") + return + if not (obj.access(caller, "control") or obj.access(caller, "edit")): + caller.msg(f"You don't have right to edit this account {obj}.") + return + obj.username = newname + obj.save() + caller.msg(f"Account's name changed to '{newname}'.") + return + # object search, also with * + obj = caller.search(objname) + if not obj: + return + if self.rhs_objs: + newname = self.rhs_objs[0]["name"] + aliases = self.rhs_objs[0]["aliases"] + else: + newname = self.rhs + aliases = None + if not newname and not aliases: + caller.msg("No names or aliases defined!") + return + if not (obj.access(caller, "control") or obj.access(caller, "edit")): + caller.msg(f"You don't have the right to edit {obj}.") + return + # change the name and set aliases: + if newname: + obj.key = newname + astring = "" + if aliases: + [obj.aliases.add(alias) for alias in aliases] + astring = " (%s)" % ", ".join(aliases) + # fix for exits - we need their exit-command to change name too + if obj.destination: + obj.flush_from_cache(force=True) + caller.msg(f"Object's name changed to '{newname}'{astring}.")
+ + +
[docs]class CmdOpen(ObjManipCommand): + """ + open a new exit from the current room + + Usage: + open <new exit>[;alias;alias..][:typeclass] [,<return exit>[;alias;..][:typeclass]]] = <destination> + + Handles the creation of exits. If a destination is given, the exit + will point there. The <return exit> argument sets up an exit at the + destination leading back to the current room. Destination name + can be given both as a #dbref and a name, if that name is globally + unique. + + """ + + key = "@open" + locks = "cmd:perm(open) or perm(Builder)" + help_category = "Building" + + method_type = "cmd_open" + + new_obj_lockstring = "control:id({id}) or perm(Admin);delete:id({id}) or perm(Admin)" + + # a custom member method to chug out exits and do checks +
[docs] def create_exit(self, exit_name, location, destination, exit_aliases=None, typeclass=None): + """ + Helper function to avoid code duplication. + At this point we know destination is a valid location + + """ + caller = self.caller + string = "" + # check if this exit object already exists at the location. + # we need to ignore errors (so no automatic feedback)since we + # have to know the result of the search to decide what to do. + exit_obj = caller.search(exit_name, location=location, quiet=True, exact=True) + if len(exit_obj) > 1: + # give error message and return + caller.search(exit_name, location=location, exact=True) + return None + if exit_obj: + exit_obj = exit_obj[0] + if not exit_obj.destination: + # we are trying to link a non-exit + caller.msg( + f"'{exit_name}' already exists and is not an exit!\nIf you want to convert it " + "to an exit, you must assign an object to the 'destination' property first." + ) + return None + # we are re-linking an old exit. + old_destination = exit_obj.destination + if old_destination: + string = f"Exit {exit_name} already exists." + if old_destination.id != destination.id: + # reroute the old exit. + exit_obj.destination = destination + if exit_aliases: + [exit_obj.aliases.add(alias) for alias in exit_aliases] + string += ( + f" Rerouted its old destination '{old_destination.name}' to" + f" '{destination.name}' and changed aliases." + ) + else: + string += " It already points to the correct place." + + else: + # exit does not exist before. Create a new one. + exit_typeclass, errors = self.get_object_typeclass( + obj_type="exit", typeclass=typeclass, method=self.method_type + ) + if errors: + self.msg("|rError creating exit:|n %s" % errors) + if not exit_typeclass: + return + exit_obj, errors = exit_typeclass.create( + exit_name, + location=location, + aliases=exit_aliases, + report_to=caller, + caller=caller, + method=self.method_type, + ) + if errors: + self.msg("|rError creating exit:|n %s" % errors) + if not exit_obj: + return + if exit_obj: + # storing a destination is what makes it an exit! + exit_obj.destination = destination + string = ( + "" + if not exit_aliases + else " (aliases: %s)" % ", ".join([str(e) for e in exit_aliases]) + ) + string = ( + f"Created new Exit '{exit_name}' from {location.name} to" + f" {destination.name}{string}." + ) + else: + string = f"Error: Exit '{exit.name}' not created." + # emit results + caller.msg(string) + return exit_obj
+ +
[docs] def parse(self): + super().parse() + self.location = self.caller.location + if not self.args or not self.rhs: + self.msg( + "Usage: open <new exit>[;alias...][:typeclass]" + "[,<return exit>[;alias..][:typeclass]]] " + "= <destination>" + ) + raise InterruptCommand + if not self.location: + self.msg("You cannot create an exit from a None-location.") + raise InterruptCommand + self.destination = self.caller.search(self.rhs, global_search=True) + if not self.destination: + raise InterruptCommand + self.exit_name = self.lhs_objs[0]["name"] + self.exit_aliases = self.lhs_objs[0]["aliases"] + self.exit_typeclass = self.lhs_objs[0]["option"]
+ +
[docs] def func(self): + """ + This is where the processing starts. + Uses the ObjManipCommand.parser() for pre-processing + as well as the self.create_exit() method. + """ + # Create exit + ok = self.create_exit( + self.exit_name, self.location, self.destination, self.exit_aliases, self.exit_typeclass + ) + if not ok: + # an error; the exit was not created, so we quit. + return + # Create back exit, if any + if len(self.lhs_objs) > 1: + back_exit_name = self.lhs_objs[1]["name"] + back_exit_aliases = self.lhs_objs[1]["aliases"] + back_exit_typeclass = self.lhs_objs[1]["option"] + self.create_exit( + back_exit_name, + self.destination, + self.location, + back_exit_aliases, + back_exit_typeclass, + )
+ + +def _convert_from_string(cmd, strobj): + """ + Converts a single object in *string form* to its equivalent python + type. + + Python earlier than 2.6: + Handles floats, ints, and limited nested lists and dicts + (can't handle lists in a dict, for example, this is mainly due to + the complexity of parsing this rather than any technical difficulty - + if there is a need for set-ing such complex structures on the + command line we might consider adding it). + Python 2.6 and later: + Supports all Python structures through literal_eval as long as they + are valid Python syntax. If they are not (such as [test, test2], ie + without the quotes around the strings), the entire structure will + be converted to a string and a warning will be given. + + We need to convert like this since all data being sent over the + telnet connection by the Account is text - but we will want to + store it as the "real" python type so we can do convenient + comparisons later (e.g. obj.db.value = 2, if value is stored as a + string this will always fail). + """ + + # Use literal_eval to parse python structure exactly. + try: + return _LITERAL_EVAL(strobj) + except (SyntaxError, ValueError): + # treat as string + strobj = utils.to_str(strobj) + string = ( + f'|RNote: name "|r{strobj}|R" was converted to a string. Make sure this is acceptable.' + ) + cmd.caller.msg(string) + return strobj + except Exception as err: + string = f"|RUnknown error in evaluating Attribute: {err}" + return string + + +
[docs]class CmdSetAttribute(ObjManipCommand): + """ + set attribute on an object or account + + Usage: + set[/switch] <obj>/<attr>[:category] = <value> + set[/switch] <obj>/<attr>[:category] = # delete attribute + set[/switch] <obj>/<attr>[:category] # view attribute + set[/switch] *<account>/<attr>[:category] = <value> + + Switch: + edit: Open the line editor (string values only) + script: If we're trying to set an attribute on a script + channel: If we're trying to set an attribute on a channel + account: If we're trying to set an attribute on an account + room: Setting an attribute on a room (global search) + exit: Setting an attribute on an exit (global search) + char: Setting an attribute on a character (global search) + character: Alias for char, as above. + + Example: + set self/foo = "bar" + set/delete self/foo + set self/foo = $dbref(#53) + + Sets attributes on objects. The second example form above clears a + previously set attribute while the third form inspects the current value of + the attribute (if any). The last one (with the star) is a shortcut for + operating on a player Account rather than an Object. + + If you want <value> to be an object, use $dbef(#dbref) or + $search(key) to assign it. You need control or edit access to + the object you are adding. + + The most common data to save with this command are strings and + numbers. You can however also set Python primitives such as lists, + dictionaries and tuples on objects (this might be important for + the functionality of certain custom objects). This is indicated + by you starting your value with one of |c'|n, |c"|n, |c(|n, |c[|n + or |c{ |n. + + Once you have stored a Python primitive as noted above, you can include + |c[<key>]|n in <attr> to reference nested values in e.g. a list or dict. + + Remember that if you use Python primitives like this, you must + write proper Python syntax too - notably you must include quotes + around your strings or you will get an error. + + """ + + key = "@set" + locks = "cmd:perm(set) or perm(Builder)" + help_category = "Building" + nested_re = re.compile(r"\[.*?\]") + not_found = object() + +
[docs] def check_obj(self, obj): + """ + This may be overridden by subclasses in case restrictions need to be + placed on whether certain objects can have attributes set by certain + accounts. + + This function is expected to display its own error message. + + Returning False will abort the command. + """ + return True
+ +
[docs] def check_attr(self, obj, attr_name, category): + """ + This may be overridden by subclasses in case restrictions need to be + placed on what attributes can be set by who beyond the normal lock. + + This functions is expected to display its own error message. It is + run once for every attribute that is checked, blocking only those + attributes which are not permitted and letting the others through. + """ + return attr_name
+ +
[docs] def split_nested_attr(self, attr): + """ + Yields tuples of (possible attr name, nested keys on that attr). + For performance, this is biased to the deepest match, but allows compatibility + with older attrs that might have been named with `[]`'s. + + > list(split_nested_attr("nested['asdf'][0]")) + [ + ('nested', ['asdf', 0]), + ("nested['asdf']", [0]), + ("nested['asdf'][0]", []), + ] + """ + quotes = "\"'" + + def clean_key(val): + val = val.strip("[]") + if val[0] in quotes: + return val.strip(quotes) + if val[0] == LIST_APPEND_CHAR: + # List insert/append syntax + return val + try: + return int(val) + except ValueError: + return val + + parts = self.nested_re.findall(attr) + + base_attr = "" + if parts: + base_attr = attr[: attr.find(parts[0])] + for index, part in enumerate(parts): + yield (base_attr, [clean_key(p) for p in parts[index:]]) + base_attr += part + yield (attr, [])
+ +
[docs] def do_nested_lookup(self, value, *keys): + result = value + for key in keys: + try: + result = result.__getitem__(key) + except (IndexError, KeyError, TypeError): + return self.not_found + return result
+ +
[docs] def view_attr(self, obj, attr, category): + """ + Look up the value of an attribute and return a string displaying it. + """ + nested = False + for key, nested_keys in self.split_nested_attr(attr): + nested = True + if obj.attributes.has(key): + val = obj.attributes.get(key) + val = self.do_nested_lookup(val, *nested_keys) + if val is not self.not_found: + return f"\nAttribute {obj.name}/|w{attr}|n [category:{category}] = {val}" + error = f"\nAttribute {obj.name}/|w{attr} [category:{category}] does not exist." + if nested: + error += " (Nested lookups attempted)" + return error
+ +
[docs] def rm_attr(self, obj, attr, category): + """ + Remove an attribute from the object, or a nested data structure, and report back. + """ + nested = False + for key, nested_keys in self.split_nested_attr(attr): + nested = True + if obj.attributes.has(key, category): + if nested_keys: + del_key = nested_keys[-1] + val = obj.attributes.get(key, category=category) + deep = self.do_nested_lookup(val, *nested_keys[:-1]) + if deep is not self.not_found: + try: + del deep[del_key] + except (IndexError, KeyError, TypeError): + continue + return f"\nDeleted attribute {obj.name}/|w{attr}|n [category:{category}]." + else: + exists = obj.attributes.has(key, category) + if exists: + obj.attributes.remove(attr, category=category) + return f"\nDeleted attribute {obj.name}/|w{attr}|n [category:{category}]." + else: + return ( + f"\nNo attribute {obj.name}/|w{attr}|n [category: {category}] " + "was found to delete." + ) + error = f"\nNo attribute {obj.name}/|w{attr}|n [category: {category}] was found to delete." + if nested: + error += " (Nested lookups attempted)" + return error
+ +
[docs] def set_attr(self, obj, attr, value, category): + done = False + for key, nested_keys in self.split_nested_attr(attr): + if obj.attributes.has(key, category) and nested_keys: + acc_key = nested_keys[-1] + lookup_value = obj.attributes.get(key, category) + deep = self.do_nested_lookup(lookup_value, *nested_keys[:-1]) + if deep is not self.not_found: + # To support appending and inserting to lists + # a key that starts with LIST_APPEND_CHAR will insert a new item at that + # location, and move the other elements down. + # Using LIST_APPEND_CHAR alone will append to the list + if isinstance(acc_key, str) and acc_key[0] == LIST_APPEND_CHAR: + try: + if len(acc_key) > 1: + where = int(acc_key[1:]) + deep.insert(where, value) + else: + deep.append(value) + except (ValueError, AttributeError): + pass + else: + value = lookup_value + attr = key + done = True + break + + # List magic failed, just use like a key/index + try: + deep[acc_key] = value + except TypeError as err: + # Tuples can't be modified + return f"\n{err} - {deep}" + + value = lookup_value + attr = key + done = True + break + + verb = "Modified" if obj.attributes.has(attr) else "Created" + try: + if not done: + obj.attributes.add(attr, value, category) + return f"\n{verb} attribute {obj.name}/|w{attr}|n [category:{category}] = {value}" + except SyntaxError: + # this means literal_eval tried to parse a faulty string + return ( + "\n|RCritical Python syntax error in your value. Only " + "primitive Python structures are allowed.\nYou also " + "need to use correct Python syntax. Remember especially " + "to put quotes around all strings inside lists and " + "dicts.|n" + )
+ + @interactive + def edit_handler(self, obj, attr, caller): + """Activate the line editor""" + + def load(caller): + """Called for the editor to load the buffer""" + + try: + old_value = obj.attributes.get(attr, raise_exception=True) + except AttributeError: + # we set empty buffer on nonexisting Attribute because otherwise + # we'd always have the string "None" in the buffer to start with + old_value = "" + return str(old_value) # we already confirmed we are ok with this + + def save(caller, buf): + """Called when editor saves its buffer.""" + obj.attributes.add(attr, buf) + caller.msg(f"Saved Attribute {attr}.") + + # check non-strings before activating editor + try: + old_value = obj.attributes.get(attr, raise_exception=True) + if not isinstance(old_value, str): + answer = yield ( + f"|rWarning: Attribute |w{attr}|r is of type |w{type(old_value).__name__}|r. " + "\nTo continue editing, it must be converted to (and saved as) a string. " + "Continue? [Y]/N?" + ) + if answer.lower() in ("n", "no"): + self.msg("Aborted edit.") + return + except AttributeError: + pass + + # start the editor + EvEditor(self.caller, load, save, key=f"{obj}/{attr}") + +
[docs] def search_for_obj(self, objname): + """ + Searches for an object matching objname. The object may be of different typeclasses. + Args: + objname: Name of the object we're looking for + + Returns: + A typeclassed object, or None if nothing is found. + """ + from evennia.utils.utils import variable_from_module + + _AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1)) + caller = self.caller + if objname.startswith("*") or "account" in self.switches: + found_obj = caller.search_account(objname.lstrip("*")) + elif "script" in self.switches: + found_obj = _AT_SEARCH_RESULT(search.search_script(objname), caller) + elif "channel" in self.switches: + found_obj = _AT_SEARCH_RESULT(search.search_channel(objname), caller) + else: + global_search = True + if "char" in self.switches or "character" in self.switches: + typeclass = settings.BASE_CHARACTER_TYPECLASS + elif "room" in self.switches: + typeclass = settings.BASE_ROOM_TYPECLASS + elif "exit" in self.switches: + typeclass = settings.BASE_EXIT_TYPECLASS + else: + global_search = False + typeclass = None + found_obj = caller.search(objname, global_search=global_search, typeclass=typeclass) + return found_obj
+ +
[docs] def func(self): + """Implement the set attribute - a limited form of py.""" + + caller = self.caller + if not self.args: + caller.msg("Usage: set obj/attr[:category] = value. Use empty value to clear.") + return + + # get values prepared by the parser + value = self.rhs + objname = self.lhs_objattr[0]["name"] + attrs = self.lhs_objattr[0]["attrs"] + category = self.lhs_objs[0].get("option") # None if unset + + obj = self.search_for_obj(objname) + if not obj: + return + + if not self.check_obj(obj): + return + + result = [] + if "edit" in self.switches: + # edit in the line editor + if not (obj.access(self.caller, "control") or obj.access(self.caller, "edit")): + caller.msg(f"You don't have permission to edit {obj.key}.") + return + + if len(attrs) > 1: + caller.msg("The Line editor can only be applied to one attribute at a time.") + return + if not attrs: + caller.msg( + "Use `set/edit <objname>/<attr>` to define the Attribute to edit.\nTo " + "edit the current room description, use `set/edit here/desc` (or " + "use the `desc` command)." + ) + return + self.edit_handler(obj, attrs[0], caller) + return + if not value: + if self.rhs is None: + # no = means we inspect the attribute(s) + if not attrs: + attrs = [ + attr.key + for attr in obj.attributes.get( + category=None, return_obj=True, return_list=True + ) + ] + for attr in attrs: + if not self.check_attr(obj, attr, category): + continue + result.append(self.view_attr(obj, attr, category)) + else: + # deleting the attribute(s) + if not (obj.access(self.caller, "control") or obj.access(self.caller, "edit")): + caller.msg(f"You don't have permission to edit {obj.key}.") + return + for attr in attrs: + if not self.check_attr(obj, attr, category): + continue + result.append(self.rm_attr(obj, attr, category)) + else: + # setting attribute(s). Make sure to convert to real Python type before saving. + # add support for $dbref() and $search() in set argument + global _ATTRFUNCPARSER + if not _ATTRFUNCPARSER: + _ATTRFUNCPARSER = funcparser.FuncParser( + { + "dbref": funcparser.funcparser_callable_search, + "search": funcparser.funcparser_callable_search, + } + ) + + if not (obj.access(self.caller, "control") or obj.access(self.caller, "edit")): + caller.msg(f"You don't have permission to edit {obj.key}.") + return + for attr in attrs: + if not self.check_attr(obj, attr, category): + continue + # from evennia import set_trace;set_trace() + parsed_value = _ATTRFUNCPARSER.parse(value, return_str=False, caller=caller) + if hasattr(parsed_value, "access"): + # if this is an object we must have the right to read it, if so, + # we will not convert it to a string + if not ( + parsed_value.access(caller, "control") + or parsed_value.access(self.caller, "edit") + ): + caller.msg( + f"You don't have permission to set object with identifier '{value}'." + ) + continue + value = parsed_value + else: + value = _convert_from_string(self, value) + result.append(self.set_attr(obj, attr, value, category)) + # check if anything was done + if not result: + caller.msg( + "No valid attributes were found. Usage: set obj/attr[:category] = value. Use empty" + " value to clear." + ) + else: + # send feedback + caller.msg("".join(result).strip("\n"))
+ + +
[docs]class CmdTypeclass(COMMAND_DEFAULT_CLASS): + """ + set or change an object's typeclass + + Usage: + typeclass[/switch] <object> [= typeclass.path] + typeclass/prototype <object> = prototype_key + + typeclasses or typeclass/list/show [typeclass.path] + swap - this is a shorthand for using /force/reset flags. + update - this is a shorthand for using the /force/reload flag. + + Switch: + show, examine - display the current typeclass of object (default) or, if + given a typeclass path, show the docstring of that typeclass. + update - *only* re-run at_object_creation on this object + meaning locks or other properties set later may remain. + reset - clean out *all* the attributes and properties on the + object - basically making this a new clean object. This will also + reset cmdsets! + force - change to the typeclass also if the object + already has a typeclass of the same name. + list - show available typeclasses. Only typeclasses in modules actually + imported or used from somewhere in the code will show up here + (those typeclasses are still available if you know the path) + prototype - clean and overwrite the object with the specified + prototype key - effectively making a whole new object. + + Example: + type button = examples.red_button.RedButton + type/prototype button=a red button + + If the typeclass_path is not given, the current object's typeclass is + assumed. + + View or set an object's typeclass. If setting, the creation hooks of the + new typeclass will be run on the object. If you have clashing properties on + the old class, use /reset. By default you are protected from changing to a + typeclass of the same name as the one you already have - use /force to + override this protection. + + The given typeclass must be identified by its location using python + dot-notation pointing to the correct module and class. If no typeclass is + given (or a wrong typeclass is given). Errors in the path or new typeclass + will lead to the old typeclass being kept. The location of the typeclass + module is searched from the default typeclass directory, as defined in the + server settings. + + """ + + key = "@typeclass" + aliases = ["@type", "@parent", "@swap", "@update", "@typeclasses"] + switch_options = ("show", "examine", "update", "reset", "force", "list", "prototype") + locks = "cmd:perm(typeclass) or perm(Builder)" + help_category = "Building" + + def _generic_search(self, query, typeclass_path): + caller = self.caller + if typeclass_path: + # make sure we search the right database table + try: + new_typeclass = class_from_module(typeclass_path) + except ImportError: + # this could be a prototype and not a typeclass at all + return caller.search(query) + + dbclass = new_typeclass.__dbclass__ + + if caller.__dbclass__ == dbclass: + # object or account match + obj = caller.search(query) + if not obj: + return + elif self.account and self.account.__dbclass__ == dbclass: + # applying account while caller is object + caller.msg(f"Trying to search {new_typeclass} with query '{self.lhs}'.") + obj = self.account.search(query) + if not obj: + return + elif hasattr(caller, "puppet") and caller.puppet.__dbclass__ == dbclass: + # applying object while caller is account + caller.msg(f"Trying to search {new_typeclass} with query '{self.lhs}'.") + obj = caller.puppet.search(query) + if not obj: + return + else: + # other mismatch between caller and specified typeclass + caller.msg(f"Trying to search {new_typeclass} with query '{self.lhs}'.") + obj = new_typeclass.search(query) + if not obj: + if isinstance(obj, list): + caller.msg(f"Could not find {new_typeclass} with query '{self.lhs}'.") + return + else: + # no rhs, use caller's typeclass + obj = caller.search(query) + if not obj: + return + + return obj + +
[docs] def func(self): + """Implements command""" + + caller = self.caller + + if "list" in self.switches or self.cmdname in ("typeclasses", "@typeclasses"): + tclasses = get_all_typeclasses() + contribs = [key for key in sorted(tclasses) if key.startswith("evennia.contrib")] or [ + "<None loaded>" + ] + core = [ + key for key in sorted(tclasses) if key.startswith("evennia") and key not in contribs + ] or ["<None loaded>"] + game = [key for key in sorted(tclasses) if not key.startswith("evennia")] or [ + "<None loaded>" + ] + string = ( + "|wCore typeclasses|n\n" + " {core}\n" + "|wLoaded Contrib typeclasses|n\n" + " {contrib}\n" + "|wGame-dir typeclasses|n\n" + " {game}" + ).format( + core="\n ".join(core), contrib="\n ".join(contribs), game="\n ".join(game) + ) + EvMore(caller, string, exit_on_lastpage=True) + return + + if not self.args: + caller.msg("Usage: %s <object> [= typeclass]" % self.cmdstring) + return + + if "show" in self.switches or "examine" in self.switches: + oquery = self.lhs + obj = caller.search(oquery, quiet=True) + if not obj: + # no object found to examine, see if it's a typeclass-path instead + tclasses = get_all_typeclasses() + matches = [ + (key, tclass) for key, tclass in tclasses.items() if key.endswith(oquery) + ] + nmatches = len(matches) + if nmatches > 1: + caller.msg( + "Multiple typeclasses found matching {}:\n {}".format( + oquery, "\n ".join(tup[0] for tup in matches) + ) + ) + elif not matches: + caller.msg(f"No object or typeclass path found to match '{oquery}'") + else: + # one match found + caller.msg(f"Docstring for typeclass '{oquery}': \n{matches[0][1].__doc__}") + else: + # do the search again to get the error handling in case of multi-match + obj = caller.search(oquery) + if not obj: + return + caller.msg( + f"{obj.name}'s current typeclass is" + f" '{obj.__class__.__module__}.{obj.__class__.__name__}'" + ) + return + + obj = self._generic_search(self.lhs, self.rhs) + if not obj: + return + + if not hasattr(obj, "__dbclass__"): + string = "%s is not a typed object." % obj.name + caller.msg(string) + return + + new_typeclass = self.rhs or obj.path + + prototype = None + if "prototype" in self.switches: + key = self.rhs + prototype = protlib.search_prototype(key=key) + if len(prototype) > 1: + caller.msg( + "More than one match for {}:\n{}".format( + key, "\n".join(proto.get("prototype_key", "") for proto in prototype) + ) + ) + return + elif prototype: + # one match + prototype = prototype[0] + else: + # no match + caller.msg(f"No prototype '{key}' was found.") + return + new_typeclass = prototype["typeclass"] + self.switches.append("force") + + if "show" in self.switches or "examine" in self.switches: + caller.msg(f"{obj.name}'s current typeclass is '{obj.__class__}'") + return + + if self.cmdstring in ("swap", "@swap"): + self.switches.append("force") + self.switches.append("reset") + elif self.cmdstring in ("update", "@update"): + self.switches.append("force") + self.switches.append("update") + + if not (obj.access(caller, "control") or obj.access(caller, "edit")): + caller.msg("You are not allowed to do that.") + return + + if not hasattr(obj, "swap_typeclass"): + caller.msg("This object cannot have a type at all!") + return + + is_same = obj.is_typeclass(new_typeclass, exact=True) + if is_same and "force" not in self.switches: + string = ( + f"{obj.name} already has the typeclass '{new_typeclass}'. Use /force to override." + ) + else: + reset = "reset" in self.switches + update = "update" in self.switches or not reset # default to update + + hooks = "at_object_creation" if update and not reset else "all" + old_typeclass_path = obj.typeclass_path + + if reset: + answer = yield ( + "|yNote that this will reset the object back to its typeclass' default state," + " removing any custom locks/perms/attributes etc that may have been added by an" + " explicit create_object call. Use `update` or type/force instead in order to" + " keep such data. Continue [Y]/N?|n" + ) + if answer.upper() in ("N", "NO"): + caller.msg("Aborted.") + return + + # special prompt for the user in cases where we want + # to confirm changes. + if "prototype" in self.switches: + diff, _ = spawner.prototype_diff_from_object(prototype, obj) + txt = spawner.format_diff(diff) + prompt = ( + f"Applying prototype '{prototype['key']}' over '{obj.name}' will cause the" + f" follow changes:\n{txt}\n" + ) + if not reset: + prompt += ( + "\n|yWARNING:|n Use the /reset switch to apply the prototype over a blank" + " state." + ) + prompt += "\nAre you sure you want to apply these changes [yes]/no?" + answer = yield (prompt) + if answer and answer in ("no", "n"): + caller.msg("Canceled: No changes were applied.") + return + + # we let this raise exception if needed + obj.swap_typeclass( + new_typeclass, clean_attributes=reset, clean_cmdsets=reset, run_start_hooks=hooks + ) + + if "prototype" in self.switches: + modified = spawner.batch_update_objects_with_prototype( + prototype, objects=[obj], caller=self.caller + ) + prototype_success = modified > 0 + if not prototype_success: + caller.msg(f"Prototype {prototype['key']} failed to apply.") + + if is_same: + string = f"{obj.name} updated its existing typeclass ({obj.path}).\n" + else: + string = ( + f"{obj.name} changed typeclass from {old_typeclass_path} to" + f" {obj.typeclass_path}.\n" + ) + if update: + string += "Only the at_object_creation hook was run (update mode)." + else: + string += "All object creation hooks were run." + if reset: + string += " All old attributes where deleted before the swap." + else: + string += ( + " Attributes set before swap were not removed\n(use `swap` or `type/reset` to" + " clear all)." + ) + if "prototype" in self.switches and prototype_success: + string += ( + f" Prototype '{prototype['key']}' was successfully applied over the object" + " type." + ) + + caller.msg(string)
+ + +
[docs]class CmdWipe(ObjManipCommand): + """ + clear all attributes from an object + + Usage: + wipe <object>[/<attr>[/<attr>...]] + + Example: + wipe box + wipe box/colour + + Wipes all of an object's attributes, or optionally only those + matching the given attribute-wildcard search string. + """ + + key = "@wipe" + locks = "cmd:perm(wipe) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """ + inp is the dict produced in ObjManipCommand.parse() + """ + + caller = self.caller + + if not self.args: + caller.msg("Usage: wipe <object>[/<attr>/<attr>...]") + return + + # get the attributes set by our custom parser + objname = self.lhs_objattr[0]["name"] + attrs = self.lhs_objattr[0]["attrs"] + + obj = caller.search(objname) + if not obj: + return + if not (obj.access(caller, "control") or obj.access(caller, "edit")): + caller.msg("You are not allowed to do that.") + return + if not attrs: + # wipe everything + obj.attributes.clear() + string = f"Wiped all attributes on {obj.name}." + else: + for attrname in attrs: + obj.attributes.remove(attrname) + string = f"Wiped attributes {','.join(attrs)} on {obj.name}." + caller.msg(string)
+ + +
[docs]class CmdLock(ObjManipCommand): + """ + assign a lock definition to an object + + Usage: + lock <object or *account>[ = <lockstring>] + or + lock[/switch] <object or *account>/<access_type> + + Switch: + del - delete given access type + view - view lock associated with given access type (default) + + If no lockstring is given, shows all locks on + object. + + Lockstring is of the form + access_type:[NOT] func1(args)[ AND|OR][ NOT] func2(args) ...] + Where func1, func2 ... valid lockfuncs with or without arguments. + Separator expressions need not be capitalized. + + For example: + 'get: id(25) or perm(Admin)' + The 'get' lock access_type is checked e.g. by the 'get' command. + An object locked with this example lock will only be possible to pick up + by Admins or by an object with id=25. + + You can add several access_types after one another by separating + them by ';', i.e: + 'get:id(25); delete:perm(Builder)' + """ + + key = "@lock" + aliases = ["@locks"] + locks = "cmd: perm(locks) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """Sets up the command""" + + caller = self.caller + if not self.args: + string = "Usage: lock <object>[ = <lockstring>] or lock[/switch] <object>/<access_type>" + caller.msg(string) + return + + if "/" in self.lhs: + # call of the form lock obj/access_type + objname, access_type = [p.strip() for p in self.lhs.split("/", 1)] + obj = None + if objname.startswith("*"): + obj = caller.search_account(objname.lstrip("*")) + if not obj: + obj = caller.search(objname) + if not obj: + return + has_control_access = obj.access(caller, "control") + if access_type == "control" and not has_control_access: + # only allow to change 'control' access if you have 'control' access already + caller.msg("You need 'control' access to change this type of lock.") + return + + if not (has_control_access or obj.access(caller, "edit")): + caller.msg("You are not allowed to do that.") + return + + lockdef = obj.locks.get(access_type) + + if lockdef: + if "del" in self.switches: + obj.locks.delete(access_type) + string = "deleted lock %s" % lockdef + else: + string = lockdef + else: + string = f"{obj} has no lock of access type '{access_type}'." + caller.msg(string) + return + + if self.rhs: + # we have a = separator, so we are assigning a new lock + if self.switches: + swi = ", ".join(self.switches) + caller.msg( + f"Switch(es) |w{swi}|n can not be used with a " + "lock assignment. Use e.g. " + "|wlock/del objname/locktype|n instead." + ) + return + + objname, lockdef = self.lhs, self.rhs + obj = None + if objname.startswith("*"): + obj = caller.search_account(objname.lstrip("*")) + if not obj: + obj = caller.search(objname) + if not obj: + return + if not (obj.access(caller, "control") or obj.access(caller, "edit")): + caller.msg("You are not allowed to do that.") + return + ok = False + lockdef = re.sub(r"\'|\"", "", lockdef) + try: + ok = obj.locks.add(lockdef) + except LockException as e: + caller.msg(str(e)) + if "cmd" in lockdef.lower() and inherits_from( + obj, "evennia.objects.objects.DefaultExit" + ): + # special fix to update Exits since "cmd"-type locks won't + # update on them unless their cmdsets are rebuilt. + obj.at_init() + if ok: + caller.msg(f"Added lock '{lockdef}' to {obj}.") + return + + # if we get here, we are just viewing all locks on obj + obj = None + if self.lhs.startswith("*"): + obj = caller.search_account(self.lhs.lstrip("*")) + if not obj: + obj = caller.search(self.lhs) + if not obj: + return + if not (obj.access(caller, "control") or obj.access(caller, "edit")): + caller.msg("You are not allowed to do that.") + return + caller.msg("\n".join(obj.locks.all()))
+ + +
[docs]class CmdExamine(ObjManipCommand): + """ + get detailed information about an object + + Usage: + examine [<object>[/attrname]] + examine [*<account>[/attrname]] + + Switch: + account - examine an Account (same as adding *) + object - examine an Object (useful when OOC) + script - examine a Script + channel - examine a Channel + + The examine command shows detailed game info about an + object and optionally a specific attribute on it. + If object is not specified, the current location is examined. + + Append a * before the search string to examine an account. + + """ + + key = "@examine" + aliases = ["@ex", "@exam"] + locks = "cmd:perm(examine) or perm(Builder)" + help_category = "Building" + arg_regex = r"(/\w+?(\s|$))|\s|$" + switch_options = ["account", "object", "script", "channel"] + + object_type = "object" + + detail_color = "|c" + header_color = "|w" + quell_color = "|r" + separator = "-" + +
[docs] def msg(self, text): + """ + Central point for sending messages to the caller. This tags + the message as 'examine' for eventual custom markup in the client. + + Attributes: + text (str): The text to send. + + """ + super().msg(text=(text, {"type": "examine"}))
+ +
[docs] def format_key(self, obj): + return f"{obj.name} ({obj.dbref})"
+ +
[docs] def format_aliases(self, obj): + if hasattr(obj, "aliases") and obj.aliases.all(): + return ", ".join(utils.make_iter(str(obj.aliases)))
+ +
[docs] def format_typeclass(self, obj): + if hasattr(obj, "typeclass_path"): + return f"{obj.typename} ({obj.typeclass_path})"
+ +
[docs] def format_sessions(self, obj): + if hasattr(obj, "sessions"): + sessions = obj.sessions.all() + if sessions: + return ", ".join(f"#{sess.sessid}" for sess in obj.sessions.all())
+ +
[docs] def format_email(self, obj): + if hasattr(obj, "email") and obj.email: + return f"{self.detail_color}{obj.email}|n"
+ +
[docs] def format_last_login(self, obj): + if hasattr(obj, "last_login") and obj.last_login: + return f"{self.detail_color}{obj.last_login}|n"
+ +
[docs] def format_account_key(self, account): + return f"{self.detail_color}{account.name}|n ({account.dbref})"
+ +
[docs] def format_account_typeclass(self, account): + return f"{account.typename} ({account.typeclass_path})"
+ +
[docs] def format_account_permissions(self, account): + perms = account.permissions.all() + if account.is_superuser: + perms = ["<Superuser>"] + elif not perms: + perms = ["<None>"] + perms = ", ".join(perms) + if account.attributes.has("_quell"): + perms += f" {self.quell_color}(quelled)|n" + return perms
+ +
[docs] def format_location(self, obj): + if hasattr(obj, "location") and obj.location: + return f"{obj.location.key} (#{obj.location.id})"
+ +
[docs] def format_home(self, obj): + if hasattr(obj, "home") and obj.home: + return f"{obj.home.key} (#{obj.home.id})"
+ +
[docs] def format_destination(self, obj): + if hasattr(obj, "destination") and obj.destination: + return f"{obj.destination.key} (#{obj.destination.id})"
+ +
[docs] def format_permissions(self, obj): + perms = obj.permissions.all() + if perms: + perms_string = ", ".join(perms) + if obj.is_superuser: + perms_string += " <Superuser>" + return perms_string
+ +
[docs] def format_locks(self, obj): + locks = str(obj.locks) + if locks: + return utils.fill("; ".join([lock for lock in locks.split(";")]), indent=2) + return "Default"
+ +
[docs] def format_scripts(self, obj): + if hasattr(obj, "scripts") and hasattr(obj.scripts, "all") and obj.scripts.all(): + return f"{obj.scripts}"
+ +
[docs] def format_single_tag(self, tag): + if tag.db_category: + return f"{tag.db_key}[{tag.db_category}]" + else: + return f"{tag.db_key}"
+ +
[docs] def format_tags(self, obj): + if hasattr(obj, "tags"): + tags = sorted(obj.tags.all(return_objs=True)) + if tags: + formatted_tags = [self.format_single_tag(tag) for tag in tags] + return utils.fill(", ".join(formatted_tags), indent=2)
+ +
[docs] def format_single_cmdset_options(self, cmdset): + def _truefalse(string, value): + if value is None: + return "" + if value: + return f"{string}: T" + return f"{string}: F" + + txt = ", ".join( + _truefalse(opt, getattr(cmdset, opt)) + for opt in ("no_exits", "no_objs", "no_channels", "duplicates") + if getattr(cmdset, opt) is not None + ) + return ", " + txt if txt else ""
+ +
[docs] def format_single_cmdset(self, cmdset): + options = self.format_single_cmdset_options(cmdset) + return f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype}, prio {cmdset.priority}{options})"
+ +
[docs] def format_stored_cmdsets(self, obj): + if hasattr(obj, "cmdset"): + stored_cmdset_strings = [] + stored_cmdsets = sorted(obj.cmdset.all(), key=lambda x: x.priority, reverse=True) + for cmdset in stored_cmdsets: + if cmdset.key != "_EMPTY_CMDSET": + stored_cmdset_strings.append(self.format_single_cmdset(cmdset)) + return "\n " + "\n ".join(stored_cmdset_strings)
+ +
[docs] def format_merged_cmdsets(self, obj, current_cmdset): + if not hasattr(obj, "cmdset"): + return None + + 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 inherits_from(obj, evennia.DefaultObject) and obj.account: + # get Attribute-cmdsets if they exist + all_cmdsets.extend([(cmdset.key, cmdset) for cmdset in obj.account.cmdset.all()]) + if obj.sessions.count(): + # if there are more sessions than one on objects it's because of multisession mode + # we only show the first session's cmdset here (it is -in principle- possible + # that different sessions have different cmdsets but for admins who want such + # madness it is better that they overload with their own CmdExamine to handle it). + all_cmdsets.extend( + [(cmdset.key, cmdset) for cmdset in obj.account.sessions.all()[0].cmdset.all()] + ) + else: + try: + # we have to protect this since many objects don't have sessions. + all_cmdsets.extend( + [ + (cmdset.key, cmdset) + for cmdset in obj.get_session(obj.sessions.get()).cmdset.all() + ] + ) + except (TypeError, AttributeError): + # an error means we are merging an object without a session + pass + all_cmdsets = [cmdset for cmdset in dict(all_cmdsets).values()] + all_cmdsets.sort(key=lambda x: x.priority, reverse=True) + + merged_cmdset_strings = [] + for cmdset in all_cmdsets: + if cmdset.key != "_EMPTY_CMDSET": + merged_cmdset_strings.append(self.format_single_cmdset(cmdset)) + return "\n " + "\n ".join(merged_cmdset_strings)
+ +
[docs] def format_current_cmds(self, obj, current_cmdset): + current_commands = sorted([cmd.key for cmd in current_cmdset if cmd.access(obj, "cmd")]) + return "\n" + utils.fill(", ".join(current_commands), indent=2)
+ + def _get_attribute_value_type(self, attrvalue): + typ = "" + if not isinstance(attrvalue, str): + try: + name = attrvalue.__class__.__name__ + except AttributeError: + try: + name = attrvalue.__name__ + except AttributeError: + name = attrvalue + if str(name).startswith("_Saver"): + try: + typ = str(type(deserialize(attrvalue))) + except Exception: + typ = str(type(deserialize(attrvalue))) + else: + typ = str(type(attrvalue)) + return typ + +
[docs] def format_single_attribute_detail(self, obj, attr): + global _FUNCPARSER + if not _FUNCPARSER: + _FUNCPARSER = funcparser.FuncParser(settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES) + + key, category, value = attr.db_key, attr.db_category, attr.value + typ = self._get_attribute_value_type(value) + typ = f" |B[type: {typ}]|n" if typ else "" + value = utils.to_str(value) + value = _FUNCPARSER.parse(ansi_raw(value), escape=True) + return ( + f"Attribute {obj.name}/{self.header_color}{key}|n " + f"[category={category}]{typ}:\n\n{value}" + )
+ +
[docs] def format_single_attribute(self, attr): + global _FUNCPARSER + if not _FUNCPARSER: + _FUNCPARSER = funcparser.FuncParser(settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES) + + key, category, value = attr.db_key, attr.db_category, attr.value + typ = self._get_attribute_value_type(value) + typ = f" |B[type: {typ}]|n" if typ else "" + value = utils.to_str(value) + value = _FUNCPARSER.parse(ansi_raw(value), escape=True) + value = utils.crop(value) + if category: + return f"{self.header_color}{key}|n[{category}]={value}{typ}" + else: + return f"{self.header_color}{key}|n={value}{typ}"
+ +
[docs] def format_attributes(self, obj): + output = "\n " + "\n ".join( + sorted(self.format_single_attribute(attr) for attr in obj.db_attributes.all()) + ) + if output.strip(): + # we don't want just an empty line + return output
+ +
[docs] def format_nattributes(self, obj): + try: + ndb_attr = obj.nattributes.all() + except Exception: + return + + if ndb_attr and ndb_attr[0]: + return "\n " + "\n ".join( + sorted(self.format_single_attribute(attr) for attr in ndb_attr) + )
+ +
[docs] def format_exits(self, obj): + if hasattr(obj, "exits"): + exits = ", ".join(f"{exit.name}({exit.dbref})" for exit in obj.exits) + return exits if exits else None
+ +
[docs] def format_chars(self, obj): + if hasattr(obj, "contents"): + chars = ", ".join(f"{obj.name}({obj.dbref})" for obj in obj.contents if obj.account) + return chars if chars else None
+ +
[docs] def format_things(self, obj): + if hasattr(obj, "contents"): + things = ", ".join( + f"{obj.name}({obj.dbref})" + for obj in obj.contents + if not obj.account and not obj.destination + ) + return things if things else None
+ +
[docs] def format_script_desc(self, obj): + if hasattr(obj, "db_desc") and obj.db_desc: + return crop(obj.db_desc, 20)
+ +
[docs] def format_script_is_persistent(self, obj): + if hasattr(obj, "db_persistent"): + return "T" if obj.db_persistent else "F"
+ +
[docs] def format_script_timer_data(self, obj): + if hasattr(obj, "db_interval") and obj.db_interval > 0: + start_delay = "T" if obj.db_start_delay else "F" + next_repeat = obj.time_until_next_repeat() + active = "|grunning|n" if obj.db_is_active and next_repeat else "|rinactive|n" + interval = obj.db_interval + next_repeat = "N/A" if next_repeat is None else f"{next_repeat}s" + repeats = "" + if obj.db_repeats: + remaining_repeats = obj.remaining_repeats() + remaining_repeats = 0 if remaining_repeats is None else remaining_repeats + repeats = f" - {remaining_repeats}/{obj.db_repeats} remain" + return ( + f"{active} - interval: {interval}s " + f"(next: {next_repeat}{repeats}, start_delay: {start_delay})" + )
+ +
[docs] def format_channel_sub_totals(self, obj): + if hasattr(obj, "db_account_subscriptions"): + account_subs = obj.db_account_subscriptions.all() + object_subs = obj.db_object_subscriptions.all() + online = len(obj.subscriptions.online()) + ntotal = account_subs.count() + object_subs.count() + return f"{ntotal} ({online} online)"
+ +
[docs] def format_channel_account_subs(self, obj): + if hasattr(obj, "db_account_subscriptions"): + account_subs = obj.db_account_subscriptions.all() + if account_subs: + return "\n " + "\n ".join( + format_grid([sub.key for sub in account_subs], sep=" ", width=_DEFAULT_WIDTH) + )
+ +
[docs] def format_channel_object_subs(self, obj): + if hasattr(obj, "db_object_subscriptions"): + object_subs = obj.db_object_subscriptions.all() + if object_subs: + return "\n " + "\n ".join( + format_grid([sub.key for sub in object_subs], sep=" ", width=_DEFAULT_WIDTH) + )
+ +
[docs] def get_formatted_obj_data(self, obj, current_cmdset): + """ + Calls all other `format_*` methods. + + """ + objdata = {} + objdata["Name/key"] = self.format_key(obj) + objdata["Aliases"] = self.format_aliases(obj) + objdata["Typeclass"] = self.format_typeclass(obj) + objdata["Sessions"] = self.format_sessions(obj) + objdata["Email"] = self.format_email(obj) + objdata["Last Login"] = self.format_last_login(obj) + if inherits_from(obj, evennia.DefaultObject) and obj.has_account: + objdata["Account"] = self.format_account_key(obj.account) + objdata[" Account Typeclass"] = self.format_account_typeclass(obj.account) + objdata[" Account Permissions"] = self.format_account_permissions(obj.account) + objdata["Location"] = self.format_location(obj) + objdata["Home"] = self.format_home(obj) + objdata["Destination"] = self.format_destination(obj) + objdata["Permissions"] = self.format_permissions(obj) + objdata["Locks"] = self.format_locks(obj) + if current_cmdset and not ( + len(obj.cmdset.all()) == 1 and obj.cmdset.current.key == "_EMPTY_CMDSET" + ): + objdata["Stored Cmdset(s)"] = self.format_stored_cmdsets(obj) + objdata["Merged Cmdset(s)"] = self.format_merged_cmdsets(obj, current_cmdset) + objdata[ + f"Commands available to {obj.key} (result of Merged Cmdset(s))" + ] = self.format_current_cmds(obj, current_cmdset) + if self.object_type == "script": + objdata["Description"] = self.format_script_desc(obj) + objdata["Persistent"] = self.format_script_is_persistent(obj) + objdata["Script Repeat"] = self.format_script_timer_data(obj) + objdata["Scripts"] = self.format_scripts(obj) + objdata["Tags"] = self.format_tags(obj) + objdata["Persistent Attributes"] = self.format_attributes(obj) + objdata["Non-Persistent Attributes"] = self.format_nattributes(obj) + objdata["Exits"] = self.format_exits(obj) + objdata["Characters"] = self.format_chars(obj) + objdata["Content"] = self.format_things(obj) + if self.object_type == "channel": + objdata["Subscription Totals"] = self.format_channel_sub_totals(obj) + objdata["Account Subscriptions"] = self.format_channel_account_subs(obj) + objdata["Object Subscriptions"] = self.format_channel_object_subs(obj) + + return objdata
+ +
[docs] def format_output(self, obj, current_cmdset): + """ + Formats the full examine page return. + + """ + objdata = self.get_formatted_obj_data(obj, current_cmdset) + + # format output + main_str = [] + max_width = -1 + for header, block in objdata.items(): + if block is not None: + blockstr = f"{self.header_color}{header}|n: {block}" + max_width = max(max_width, max(display_len(line) for line in blockstr.split("\n"))) + main_str.append(blockstr) + main_str = "\n".join(main_str) + + max_width = max(0, min(self.client_width(), max_width)) + sep = self.separator * max_width + + return f"{sep}\n{main_str}\n{sep}"
+ + def _search_by_object_type(self, obj_name, objtype): + """ + Route to different search functions depending on the object type being + examined. This also handles error reporting for multimatches/no matches. + + Args: + obj_name (str): The search query. + objtype (str): One of 'object', 'account', 'script' or 'channel'. + Returns: + any: `None` if no match or multimatch, otherwise a single result. + + """ + obj = None + + if objtype == "object": + obj = self.caller.search(obj_name) + elif objtype == "account": + try: + obj = self.caller.search_account(obj_name.lstrip("*")) + except AttributeError: + # this means we are calling examine from an account object + obj = self.caller.search( + obj_name.lstrip("*"), search_object="object" in self.switches + ) + else: + obj = getattr(search, f"search_{objtype}")(obj_name) + if not obj: + self.msg(f"No {objtype} found with key {obj_name}.") + obj = None + elif len(obj) > 1: + err = "Multiple {objtype} found with key {obj_name}:\n{matches}" + self.msg( + err.format( + obj_name=obj_name, matches=", ".join(f"{ob.key}(#{ob.id})" for ob in obj) + ) + ) + obj = None + else: + obj = obj[0] + return obj + +
[docs] def parse(self): + super().parse() + + self.examine_objs = [] + + if not self.args: + # If no arguments are provided, examine the invoker's location. + if hasattr(self.caller, "location"): + self.examine_objs.append((self.caller.location, None)) + else: + self.msg("You need to supply a target to examine.") + raise InterruptCommand + else: + for objdef in self.lhs_objattr: + # note that we check the objtype for every repeat; this will always + # be the same result, but it makes for a cleaner code and multi-examine + # is not so common anyway. + + obj = None + obj_name = objdef["name"] # name + obj_attrs = objdef["attrs"] # /attrs + + # identify object type, in prio account - script - channel + object_type = "object" + if ( + utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount") + or "account" in self.switches + or obj_name.startswith("*") + ): + object_type = "account" + elif "script" in self.switches: + object_type = "script" + elif "channel" in self.switches: + object_type = "channel" + + self.object_type = object_type + obj = self._search_by_object_type(obj_name, object_type) + + if obj: + self.examine_objs.append((obj, obj_attrs))
+ +
[docs] def func(self): + """Process command""" + for obj, obj_attrs in self.examine_objs: + # these are parsed out in .parse already + + if not obj.access(self.caller, "examine"): + # If we don't have special info access, just look + # at the object instead. + self.msg(self.caller.at_look(obj)) + continue + + if obj_attrs: + # we are only interested in specific attributes + attrs = [attr for attr in obj.db_attributes.all() if attr.db_key in obj_attrs] + if not attrs: + self.msg(f"No attributes found on {obj.name}.") + else: + out_strings = [] + for attr in attrs: + out_strings.append(self.format_single_attribute_detail(obj, attr)) + out_str = "\n".join(out_strings) + max_width = max(display_len(line) for line in out_strings) + max_width = max(0, min(max_width, self.client_width())) + sep = self.separator * max_width + self.msg(f"{sep}\n{out_str}") + return + + # examine the obj itself + + if self.object_type in ("object", "account"): + # for objects and accounts we need to set up an asynchronous + # fetch of the cmdset and not proceed with the examine display + # until the fetch is complete + session = None + if obj.sessions.count(): + mergemode = "session" + session = obj.sessions.get()[0] + elif self.object_type == "account": + mergemode = "account" + else: + mergemode = "object" + + account = None + objct = None + if self.object_type == "account": + account = obj + else: + account = obj.account + objct = obj + + # this is usually handled when a command runs, but when we examine + # we may have leftover inherited cmdsets directly after a move etc. + obj.cmdset.update() + # using callback to print results whenever function returns. + + def _get_cmdset_callback(current_cmdset): + self.msg(self.format_output(obj, current_cmdset).strip()) + + ( + command_objects, + command_objects_list, + command_objects_list_error, + caller, + error_to, + ) = generate_cmdset_providers(obj, session=session) + + get_and_merge_cmdsets( + obj, command_objects_list, mergemode, self.raw_string, error_to + ).addCallback(_get_cmdset_callback) + + else: + # for objects without cmdsets we can proceed to examine immediately + self.msg(self.format_output(obj, None).strip())
+ + +
[docs]class CmdFind(COMMAND_DEFAULT_CLASS): + """ + search the database for objects + + Usage: + find[/switches] <name or dbref or *account> [= dbrefmin[-dbrefmax]] + locate - this is a shorthand for using the /loc switch. + + Switches: + room - only look for rooms (location=None) + exit - only look for exits (destination!=None) + char - only look for characters (BASE_CHARACTER_TYPECLASS) + exact - only exact matches are returned. + loc - display object location if exists and match has one result + startswith - search for names starting with the string, rather than containing + + Searches the database for an object of a particular name or exact #dbref. + Use *accountname to search for an account. The switches allows for + limiting object matches to certain game entities. Dbrefmin and dbrefmax + limits matches to within the given dbrefs range, or above/below if only + one is given. + """ + + key = "@find" + aliases = ["@search", "@locate"] + switch_options = ("room", "exit", "char", "exact", "loc", "startswith") + locks = "cmd:perm(find) or perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + """Search functionality""" + caller = self.caller + switches = self.switches + + if not self.args or (not self.lhs and not self.rhs): + caller.msg("Usage: find <string> [= low [-high]]") + return + + if "locate" in self.cmdstring: # Use option /loc as a default for locate command alias + switches.append("loc") + + searchstring = self.lhs + + try: + # Try grabbing the actual min/max id values by database aggregation + qs = ObjectDB.objects.values("id").aggregate(low=Min("id"), high=Max("id")) + low, high = sorted(qs.values()) + if not (low and high): + raise ValueError( + f"{self.__class__.__name__}: Min and max ID not returned by aggregation;" + " falling back to queryset slicing." + ) + except Exception as e: + logger.log_trace(e) + # If that doesn't work for some reason (empty DB?), guess the lower + # bound and do a less-efficient query to find the upper. + low, high = 1, ObjectDB.objects.all().order_by("-id").first().id + + if self.rhs: + try: + # Check that rhs is either a valid dbref or dbref range + bounds = tuple( + sorted(dbref(x, False) for x in re.split("[-\s]+", self.rhs.strip())) + ) + + # dbref() will return either a valid int or None + assert bounds + # None should not exist in the bounds list + assert None not in bounds + + low = bounds[0] + if len(bounds) > 1: + high = bounds[-1] + + except AssertionError: + caller.msg("Invalid dbref range provided (not a number).") + return + except IndexError as e: + logger.log_err( + f"{self.__class__.__name__}: Error parsing upper and lower bounds of query." + ) + logger.log_trace(e) + + low = min(low, high) + high = max(low, high) + + is_dbref = utils.dbref(searchstring) + is_account = searchstring.startswith("*") + + restrictions = "" + if self.switches: + restrictions = ", %s" % ", ".join(self.switches) + + if is_dbref or is_account: + if is_dbref: + # a dbref search + result = caller.search(searchstring, global_search=True, quiet=True) + string = "|wExact dbref match|n(#%i-#%i%s):" % (low, high, restrictions) + else: + # an account search + searchstring = searchstring.lstrip("*") + result = caller.search_account(searchstring, quiet=True) + string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) + + if "room" in switches: + result = result if inherits_from(result, ROOM_TYPECLASS) else None + if "exit" in switches: + result = result if inherits_from(result, EXIT_TYPECLASS) else None + if "char" in switches: + result = result if inherits_from(result, CHAR_TYPECLASS) else None + + if not result: + string += "\n |RNo match found.|n" + elif not low <= int(result[0].id) <= high: + string += f"\n |RNo match found for '{searchstring}' in #dbref interval.|n" + else: + result = result[0] + string += f"\n|g {result.get_display_name(caller)} - {result.path}|n" + if "loc" in self.switches and not is_account and result.location: + string += f" (|wlocation|n: |g{result.location.get_display_name(caller)}|n)" + else: + # Not an account/dbref search but a wider search; build a queryset. + # Searches for key and aliases + if "exact" in switches: + keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) + aliasquery = Q( + db_tags__db_key__iexact=searchstring, + db_tags__db_tagtype__iexact="alias", + id__gte=low, + id__lte=high, + ) + elif "startswith" in switches: + keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high) + aliasquery = Q( + db_tags__db_key__istartswith=searchstring, + db_tags__db_tagtype__iexact="alias", + id__gte=low, + id__lte=high, + ) + else: + keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) + aliasquery = Q( + db_tags__db_key__icontains=searchstring, + db_tags__db_tagtype__iexact="alias", + id__gte=low, + id__lte=high, + ) + + # Keep the initial queryset handy for later reuse + result_qs = ObjectDB.objects.filter(keyquery | aliasquery).distinct() + nresults = result_qs.count() + + # Use iterator to minimize memory ballooning on large result sets + results = result_qs.iterator() + + # Check and see if type filtering was requested; skip it if not + if any(x in switches for x in ("room", "exit", "char")): + obj_ids = set() + for obj in results: + if ( + ("room" in switches and inherits_from(obj, ROOM_TYPECLASS)) + or ("exit" in switches and inherits_from(obj, EXIT_TYPECLASS)) + or ("char" in switches and inherits_from(obj, CHAR_TYPECLASS)) + ): + obj_ids.add(obj.id) + + # Filter previous queryset instead of requesting another + filtered_qs = result_qs.filter(id__in=obj_ids).distinct() + nresults = filtered_qs.count() + + # Use iterator again to minimize memory ballooning + results = filtered_qs.iterator() + + # still results after type filtering? + if nresults: + if nresults > 1: + header = f"{nresults} Matches" + else: + header = "One Match" + + string = f"|w{header}|n(#{low}-#{high}{restrictions}):" + res = None + for res in results: + string += f"\n |g{res.get_display_name(caller)} - {res.path}|n" + if ( + "loc" in self.switches + and nresults == 1 + and res + and getattr(res, "location", None) + ): + string += f" (|wlocation|n: |g{res.location.get_display_name(caller)}|n)" + else: + string = f"|wNo Matches|n(#{low}-#{high}{restrictions}):" + string += f"\n |RNo matches found for '{searchstring}'|n" + + # send result + caller.msg(string.strip())
+ + +class ScriptEvMore(EvMore): + """ + Listing 1000+ Scripts can be very slow and memory-consuming. So + we use this custom EvMore child to build en EvTable only for + each page of the list. + + """ + + def init_pages(self, scripts): + """Prepare the script list pagination""" + script_pages = Paginator(scripts, max(1, int(self.height / 2))) + super().init_pages(script_pages) + + def page_formatter(self, scripts): + """Takes a page of scripts and formats the output + into an EvTable.""" + + if not scripts: + return "<No scripts>" + + table = EvTable( + "|wdbref|n", + "|wobj|n", + "|wkey|n", + "|wintval|n", + "|wnext|n", + "|wrept|n", + "|wtypeclass|n", + "|wdesc|n", + align="r", + border="tablecols", + width=self.width, + ) + + for script in scripts: + nextrep = script.time_until_next_repeat() + if nextrep is None: + nextrep = script.db._paused_time + nextrep = f"PAUSED {int(nextrep)}s" if nextrep else "--" + else: + nextrep = f"{nextrep}s" + + maxrepeat = script.repeats + remaining = script.remaining_repeats() or 0 + if maxrepeat: + rept = "%i/%i" % (maxrepeat - remaining, maxrepeat) + else: + rept = "-/-" + + table.add_row( + f"#{script.id}", + f"{script.obj.key}({script.obj.dbref})" + if (hasattr(script, "obj") and script.obj) + else "<Global>", + script.key, + script.interval if script.interval > 0 else "--", + nextrep, + rept, + script.typeclass_path.rsplit(".", 1)[-1], + crop(script.desc, width=20), + ) + + return str(table) + + +
[docs]class CmdScripts(COMMAND_DEFAULT_CLASS): + """ + List and manage all running scripts. Allows for creating new global + scripts. + + Usage: + script[/switches] [script-#dbref, key, script.path] + script[/start||stop] <obj> = [<script.path or script-key>] + + Switches: + start - start/unpause an existing script's timer. + stop - stops an existing script's timer + pause - pause a script's timer + delete - deletes script. This will also stop the timer as needed + + Examples: + script - list all scripts + script foo.bar.Script - create a new global Script + script/pause foo.bar.Script - pause global script + script scriptname|#dbref - examine named existing global script + script/delete #dbref[-#dbref] - delete script or range by #dbref + + script myobj = - list all scripts on object + script myobj = foo.bar.Script - create and assign script to object + script/stop myobj = name|#dbref - stop named script on object + script/delete myobj = name|#dbref - delete script on object + script/delete myobj = - delete ALL scripts on object + + When given with an `<obj>` as left-hand-side, this creates and + assigns a new script to that object. Without an `<obj>`, this + manages and inspects global scripts. + + If no switches are given, this command just views all active + scripts. The argument can be either an object, at which point it + will be searched for all scripts defined on it, or a script name + or #dbref. For using the /stop switch, a unique script #dbref is + required since whole classes of scripts often have the same name. + + Use the `script` build-level command for managing scripts attached to + objects. + + """ + + key = "@scripts" + aliases = ["@script"] + switch_options = ("create", "start", "stop", "pause", "delete") + locks = "cmd:perm(scripts) or perm(Builder)" + help_category = "System" + + excluded_typeclass_paths = ["evennia.prototypes.prototypes.DbPrototype"] + + switch_mapping = { + "create": "|gCreated|n", + "start": "|gStarted|n", + "stop": "|RStopped|n", + "pause": "|Paused|n", + "delete": "|rDeleted|n", + } + # never show these script types + hide_script_paths = ("evennia.prototypes.prototypes.DbPrototype",) + + def _search_script(self, args): + # test first if this is a script match + scripts = ScriptDB.objects.get_all_scripts(key=args).exclude( + db_typeclass_path__in=self.hide_script_paths + ) + if scripts: + return scripts + # try typeclass path + scripts = ( + ScriptDB.objects.filter(db_typeclass_path__iendswith=args) + .exclude(db_typeclass_path__in=self.hide_script_paths) + .order_by("id") + ) + if scripts: + return scripts + if "-" in args: + # may be a dbref-range + val1, val2 = (dbref(part.strip()) for part in args.split("-", 1)) + if val1 and val2: + scripts = ( + ScriptDB.objects.filter(id__in=(range(val1, val2 + 1))) + .exclude(db_typeclass_path__in=self.hide_script_paths) + .order_by("id") + ) + if scripts: + return scripts + +
[docs] def func(self): + """implement method""" + + caller = self.caller + + if not self.args: + # show all scripts + scripts = ScriptDB.objects.all().exclude(db_typeclass_path__in=self.hide_script_paths) + if not scripts: + caller.msg("No scripts found.") + return + ScriptEvMore(caller, scripts.order_by("id"), session=self.session) + return + + # find script or object to operate on + scripts, obj = None, None + if self.rhs: + obj_query = self.lhs + script_query = self.rhs + elif self.rhs is not None: + # an empty "=" + obj_query = self.lhs + script_query = None + else: + obj_query = None + script_query = self.args + + scripts = self._search_script(script_query) if script_query else None + objects = caller.search(obj_query, quiet=True) if obj_query else None + obj = objects[0] if objects else None + + if not self.switches: + # creation / view mode + if obj: + # we have an object + if self.rhs: + # creation mode + if obj.scripts.add(self.rhs, autostart=True): + caller.msg( + f"Script |w{self.rhs}|n successfully added and " + f"started on {obj.get_display_name(caller)}." + ) + else: + caller.msg( + f"Script {self.rhs} could not be added and/or started " + f"on {obj.get_display_name(caller)} (or it started and " + "immediately shut down)." + ) + else: + # just show all scripts on object + scripts = ScriptDB.objects.filter(db_obj=obj).exclude( + db_typeclass_path__in=self.hide_script_paths + ) + if scripts: + ScriptEvMore(caller, scripts.order_by("id"), session=self.session) + else: + caller.msg(f"No scripts defined on {obj}") + + elif scripts: + # show found script(s) + ScriptEvMore(caller, scripts.order_by("id"), session=self.session) + + else: + # create global script + try: + new_script = create.create_script(self.args) + except ImportError: + logger.log_trace() + new_script = None + + if new_script: + caller.msg( + f"Global Script Created - {new_script.key} ({new_script.typeclass_path})" + ) + ScriptEvMore(caller, [new_script], session=self.session) + else: + caller.msg( + f"Global Script |rNOT|n Created |r(see log)|n - arguments: {self.args}" + ) + + elif scripts or obj: + # modification switches - must operate on existing scripts + + if not scripts: + scripts = ScriptDB.objects.filter(db_obj=obj).exclude( + db_typeclass_path__in=self.hide_script_paths + ) + + if scripts.count() > 1: + ret = yield ( + f"Multiple scripts found: {scripts}. Are you sure you want to " + "operate on all of them? [Y]/N? " + ) + if ret.lower() in ("n", "no"): + caller.msg("Aborted.") + return + + for script in scripts: + script_key = script.key + script_typeclass_path = script.typeclass_path + scripttype = f"Script on {obj}" if obj else "Global Script" + + for switch in self.switches: + verb = self.switch_mapping[switch] + msgs = [] + try: + getattr(script, switch)() + except Exception: + logger.log_trace() + msgs.append( + f"{scripttype} |rNOT|n {verb} |r(see log)|n - " + f"{script_key} ({script_typeclass_path})|n" + ) + else: + msgs.append(f"{scripttype} {verb} - {script_key} ({script_typeclass_path})") + caller.msg("\n".join(msgs)) + if "delete" not in self.switches: + if script and script.pk: + ScriptEvMore(caller, [script], session=self.session) + else: + caller.msg("Script was deleted automatically.") + else: + caller.msg("No scripts found.")
+ + +
[docs]class CmdObjects(COMMAND_DEFAULT_CLASS): + """ + statistics on objects in the database + + Usage: + objects [<nr>] + + Gives statictics on objects in database as well as + a list of <nr> latest objects in database. If not + given, <nr> defaults to 10. + """ + + key = "@objects" + locks = "cmd:perm(listobjects) or perm(Builder)" + help_category = "System" + +
[docs] def func(self): + """Implement the command""" + + caller = self.caller + nlim = int(self.args) if self.args and self.args.isdigit() else 10 + nobjs = ObjectDB.objects.count() + Character = class_from_module(settings.BASE_CHARACTER_TYPECLASS) + nchars = Character.objects.all_family().count() + Room = class_from_module(settings.BASE_ROOM_TYPECLASS) + nrooms = Room.objects.all_family().count() + Exit = class_from_module(settings.BASE_EXIT_TYPECLASS) + nexits = Exit.objects.all_family().count() + nother = nobjs - nchars - nrooms - nexits + nobjs = nobjs or 1 # fix zero-div error with empty database + + # total object sum table + totaltable = self.styled_table( + "|wtype|n", "|wcomment|n", "|wcount|n", "|w%|n", border="table", align="l" + ) + totaltable.align = "l" + totaltable.add_row( + "Characters", + "(BASE_CHARACTER_TYPECLASS + children)", + nchars, + "%.2f" % ((float(nchars) / nobjs) * 100), + ) + totaltable.add_row( + "Rooms", + "(BASE_ROOM_TYPECLASS + children)", + nrooms, + "%.2f" % ((float(nrooms) / nobjs) * 100), + ) + totaltable.add_row( + "Exits", + "(BASE_EXIT_TYPECLASS + children)", + nexits, + "%.2f" % ((float(nexits) / nobjs) * 100), + ) + totaltable.add_row("Other", "", nother, "%.2f" % ((float(nother) / nobjs) * 100)) + + # typeclass table + typetable = self.styled_table( + "|wtypeclass|n", "|wcount|n", "|w%|n", border="table", align="l" + ) + typetable.align = "l" + dbtotals = ObjectDB.objects.get_typeclass_totals() + for stat in dbtotals: + typetable.add_row( + stat.get("typeclass", "<error>"), + stat.get("count", -1), + "%.2f" % stat.get("percent", -1), + ) + + # last N table + objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim) :] + latesttable = self.styled_table( + "|wcreated|n", "|wdbref|n", "|wname|n", "|wtypeclass|n", align="l", border="table" + ) + latesttable.align = "l" + for obj in objs: + latesttable.add_row( + utils.datetime_format(obj.date_created), obj.dbref, obj.key, obj.path + ) + + string = "\n|wObject subtype totals (out of %i Objects):|n\n%s" % (nobjs, totaltable) + string += "\n|wObject typeclass distribution:|n\n%s" % typetable + string += "\n|wLast %s Objects created:|n\n%s" % (min(nobjs, nlim), latesttable) + caller.msg(string)
+ + +
[docs]class CmdTeleport(COMMAND_DEFAULT_CLASS): + """ + teleport object to another location + + Usage: + tel/switch [<object> to||=] <target location> + + Examples: + tel Limbo + tel/quiet box = Limbo + tel/tonone box + + Switches: + quiet - don't echo leave/arrive messages to the source/target + locations for the move. + intoexit - if target is an exit, teleport INTO + the exit object instead of to its destination + tonone - if set, teleport the object to a None-location. If this + switch is set, <target location> is ignored. + Note that the only way to retrieve + an object from a None location is by direct #dbref + reference. A puppeted object cannot be moved to None. + loc - teleport object to the target's location instead of its contents + + Teleports an object somewhere. If no object is given, you yourself are + teleported to the target location. + + To lock an object from being teleported, set its `teleport` lock, it will be + checked with the caller. To block + a destination from being teleported to, set the destination's `teleport_here` + lock - it will be checked with the thing being teleported. Admins and + higher permissions can always teleport. + + """ + + key = "@teleport" + aliases = "@tel" + switch_options = ("quiet", "intoexit", "tonone", "loc") + rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage. + locks = "cmd:perm(teleport) or perm(Builder)" + help_category = "Building" + +
[docs] def parse(self): + """ + Breaking out searching here to make this easier to override. + + """ + super().parse() + self.obj_to_teleport = self.caller + self.destination = None + if self.rhs: + self.obj_to_teleport = self.caller.search(self.lhs, global_search=True) + if not self.obj_to_teleport: + self.msg("Did not find object to teleport.") + raise InterruptCommand + self.destination = self.caller.search(self.rhs, global_search=True) + elif self.lhs: + self.destination = self.caller.search(self.lhs, global_search=True)
+ +
[docs] def func(self): + """Performs the teleport""" + + caller = self.caller + obj_to_teleport = self.obj_to_teleport + destination = self.destination + + if "tonone" in self.switches: + # teleporting to None + + if destination: + # in this case lhs is always the object to teleport + obj_to_teleport = destination + + if obj_to_teleport.has_account: + caller.msg( + f"Cannot teleport a puppeted object ({obj_to_teleport.key}, puppeted by" + f" {obj_to_teleport.account}) to a None-location." + ) + return + caller.msg(f"Teleported {obj_to_teleport} -> None-location.") + if obj_to_teleport.location and "quiet" not in self.switches: + obj_to_teleport.location.msg_contents( + f"{caller} teleported {obj_to_teleport} into nothingness.", exclude=caller + ) + obj_to_teleport.location = None + return + + if not self.args: + caller.msg("Usage: teleport[/switches] [<obj> =] <target or (X,Y,Z)>||home") + return + + if not destination: + caller.msg("Destination not found.") + return + + if "loc" in self.switches: + destination = destination.location + if not destination: + caller.msg("Destination has no location.") + return + + if obj_to_teleport == destination: + caller.msg("You can't teleport an object inside of itself!") + return + + if obj_to_teleport == destination.location: + caller.msg("You can't teleport an object inside something it holds!") + return + + if obj_to_teleport.location and obj_to_teleport.location == destination: + caller.msg(f"{obj_to_teleport} is already at {destination}.") + return + + # check any locks + if not (caller.permissions.check("Admin") or obj_to_teleport.access(caller, "teleport")): + caller.msg( + f"{obj_to_teleport} 'teleport'-lock blocks you from teleporting it anywhere." + ) + return + + if not ( + caller.permissions.check("Admin") + or destination.access(obj_to_teleport, "teleport_here") + ): + caller.msg( + f"{destination} 'teleport_here'-lock blocks {obj_to_teleport} from moving there." + ) + return + + # try the teleport + if not obj_to_teleport.location: + # teleporting from none-location + obj_to_teleport.location = destination + caller.msg(f"Teleported {obj_to_teleport} None -> {destination}") + elif obj_to_teleport.move_to( + destination, + quiet="quiet" in self.switches, + emit_to_obj=caller, + use_destination="intoexit" not in self.switches, + move_type="teleport", + ): + if obj_to_teleport == caller: + caller.msg(f"Teleported to {destination}.") + else: + caller.msg(f"Teleported {obj_to_teleport} -> {destination}.") + else: + caller.msg("Teleportation failed.")
+ + +
[docs]class CmdTag(COMMAND_DEFAULT_CLASS): + """ + handles the tags of an object + + Usage: + tag[/del] <obj> [= <tag>[:<category>]] + tag/search <tag>[:<category] + + Switches: + search - return all objects with a given Tag + del - remove the given tag. If no tag is specified, + clear all tags on object. + + Manipulates and lists tags on objects. Tags allow for quick + grouping of and searching for objects. If only <obj> is given, + list all tags on the object. If /search is used, list objects + with the given tag. + The category can be used for grouping tags themselves, but it + should be used with restrain - tags on their own are usually + enough to for most grouping schemes. + """ + + key = "@tag" + aliases = ["@tags"] + options = ("search", "del") + locks = "cmd:perm(tag) or perm(Builder)" + help_category = "Building" + arg_regex = r"(/\w+?(\s|$))|\s|$" + +
[docs] def func(self): + """Implement the tag functionality""" + + if not self.args: + self.msg("Usage: tag[/switches] <obj> [= <tag>[:<category>]]") + return + if "search" in self.switches: + # search by tag + tag = self.args + category = None + if ":" in tag: + tag, category = [part.strip() for part in tag.split(":", 1)] + objs = search.search_tag(tag, category=category) + nobjs = len(objs) + if nobjs > 0: + catstr = ( + " (category: '|w%s|n')" % category + if category + else ("" if nobjs == 1 else " (may have different tag categories)") + ) + matchstr = ", ".join(o.get_display_name(self.caller) for o in objs) + + string = "Found |w%i|n object%s with tag '|w%s|n'%s:\n %s" % ( + nobjs, + "s" if nobjs > 1 else "", + tag, + catstr, + matchstr, + ) + else: + string = "No objects found with tag '%s%s'." % ( + tag, + " (category: %s)" % category if category else "", + ) + self.msg(string) + return + if "del" in self.switches: + # remove one or all tags + obj = self.caller.search(self.lhs, global_search=True) + if not obj: + return + if self.rhs: + # remove individual tag + tag = self.rhs + category = None + if ":" in tag: + tag, category = [part.strip() for part in tag.split(":", 1)] + if obj.tags.get(tag, category=category): + obj.tags.remove(tag, category=category) + string = "Removed tag '%s'%s from %s." % ( + tag, + " (category: %s)" % category if category else "", + obj, + ) + else: + string = "No tag '%s'%s to delete on %s." % ( + tag, + " (category: %s)" % category if category else "", + obj, + ) + else: + # no tag specified, clear all tags + old_tags = [ + "%s%s" % (tag, " (category: %s)" % category if category else "") + for tag, category in obj.tags.all(return_key_and_category=True) + ] + if old_tags: + obj.tags.clear() + string = "Cleared all tags from %s: %s" % (obj, ", ".join(sorted(old_tags))) + else: + string = "No Tags to clear on %s." % obj + self.msg(string) + return + # no search/deletion + if self.rhs: + # = is found; command args are of the form obj = tag + # first search locally, then global + obj = self.caller.search(self.lhs, quiet=True) + if not obj: + obj = self.caller.search(self.lhs, global_search=True) + else: + obj = obj[0] + if not obj: + return + tag = self.rhs + category = None + if ":" in tag: + tag, category = [part.strip() for part in tag.split(":", 1)] + # create the tag + obj.tags.add(tag, category=category) + string = "Added tag '%s'%s to %s." % ( + tag, + " (category: %s)" % category if category else "", + obj, + ) + self.msg(string) + else: + # no = found - list tags on object + # first search locally, then global + obj = self.caller.search(self.args, quiet=True) + if not obj: + obj = self.caller.search(self.args, global_search=True) + else: + obj = obj[0] + if not obj: + return + tagtuples = obj.tags.all(return_key_and_category=True) + ntags = len(tagtuples) + tags = [tup[0] for tup in tagtuples] + categories = [" (category: %s)" % tup[1] if tup[1] else "" for tup in tagtuples] + if ntags: + string = "Tag%s on %s: %s" % ( + "s" if ntags > 1 else "", + obj, + ", ".join(sorted("'%s'%s" % (tags[i], categories[i]) for i in range(ntags))), + ) + else: + string = f"No tags attached to {obj}." + self.msg(string)
+ + +# helper functions for spawn + + +
[docs]class CmdSpawn(COMMAND_DEFAULT_CLASS): + """ + spawn objects from prototype + + Usage: + spawn[/noloc] <prototype_key> + spawn[/noloc] <prototype_dict> + + spawn/search [prototype_keykey][;tag[,tag]] + spawn/list [tag, tag, ...] + spawn/list modules - list only module-based prototypes + spawn/show [<prototype_key>] + spawn/update <prototype_key> + + spawn/save <prototype_dict> + spawn/edit [<prototype_key>] + olc - equivalent to spawn/edit + + Switches: + noloc - allow location to be None if not specified explicitly. Otherwise, + location will default to caller's current location. + search - search prototype by name or tags. + list - list available prototypes, optionally limit by tags. + show, examine - inspect prototype by key. If not given, acts like list. + raw - show the raw dict of the prototype as a one-line string for manual editing. + save - save a prototype to the database. It will be listable by /list. + delete - remove a prototype from database, if allowed to. + update - find existing objects with the same prototype_key and update + them with latest version of given prototype. If given with /save, + will auto-update all objects with the old version of the prototype + without asking first. + edit, menu, olc - create/manipulate prototype in a menu interface. + + Example: + spawn GOBLIN + spawn {"key":"goblin", "typeclass":"monster.Monster", "location":"#2"} + spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all() + \f + Dictionary keys: + |wprototype_parent |n - name of parent prototype to use. Required if typeclass is + not set. Can be a path or a list for multiple inheritance (inherits + left to right). If set one of the parents must have a typeclass. + |wtypeclass |n - string. Required if prototype_parent is not set. + |wkey |n - string, the main object identifier + |wlocation |n - this should be a valid object or #dbref + |whome |n - valid object or #dbref + |wdestination|n - only valid for exits (object or dbref) + |wpermissions|n - string or list of permission strings + |wlocks |n - a lock-string + |waliases |n - string or list of strings. + |wndb_|n<name> - value of a nattribute (ndb_ is stripped) + + |wprototype_key|n - name of this prototype. Unique. Used to store/retrieve from db + and update existing prototyped objects if desired. + |wprototype_desc|n - desc of this prototype. Used in listings + |wprototype_locks|n - locks of this prototype. Limits who may use prototype + |wprototype_tags|n - tags of this prototype. Used to find prototype + + any other keywords are interpreted as Attributes and their values. + + The available prototypes are defined globally in modules set in + settings.PROTOTYPE_MODULES. If spawn is used without arguments it + displays a list of available prototypes. + + """ + + key = "@spawn" + aliases = ["@olc"] + switch_options = ( + "noloc", + "search", + "list", + "show", + "raw", + "examine", + "save", + "delete", + "menu", + "olc", + "update", + "edit", + ) + locks = "cmd:perm(spawn) or perm(Builder)" + help_category = "Building" + + def _search_prototype(self, prototype_key, quiet=False): + """ + Search for prototype and handle no/multi-match and access. + + Returns a single found prototype or None - in the + case, the caller has already been informed of the + search error we need not do any further action. + + """ + prototypes = protlib.search_prototype(prototype_key) + nprots = len(prototypes) + + # handle the search result + err = None + if not prototypes: + err = f"No prototype named '{prototype_key}' was found." + elif nprots > 1: + err = "Found {} prototypes matching '{}':\n {}".format( + nprots, + prototype_key, + ", ".join(proto.get("prototype_key", "") for proto in prototypes), + ) + else: + # we have a single prototype, check access + prototype = prototypes[0] + if not self.caller.locks.check_lockstring( + self.caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True + ): + err = "You don't have access to use this prototype." + + if err: + # return None on any error + if not quiet: + self.msg(err) + return + return prototype + + def _parse_prototype(self, inp, expect=dict): + """ + Parse a prototype dict or key from the input and convert it safely + into a dict if appropriate. + + Args: + inp (str): The input from user. + expect (type, optional): + Returns: + prototype (dict, str or None): The parsed prototype. If None, the error + was already reported. + + """ + eval_err = None + try: + prototype = _LITERAL_EVAL(inp) + except (SyntaxError, ValueError) as err: + # treat as string + eval_err = err + prototype = utils.to_str(inp) + finally: + # it's possible that the input was a prototype-key, in which case + # it's okay for the LITERAL_EVAL to fail. Only if the result does not + # match the expected type do we have a problem. + if not isinstance(prototype, expect): + if eval_err: + string = ( + f"{inp}\n{eval_err}\n|RCritical Python syntax error in argument. Only" + " primitive Python structures are allowed. \nMake sure to use correct" + " Python syntax. Remember especially to put quotes around all strings" + " inside lists and dicts.|n For more advanced uses, embed funcparser" + " callables ($funcs) in the strings." + ) + else: + string = f"Expected {expect}, got {type(prototype)}." + self.msg(string) + return + + if expect == dict: + # an actual prototype. We need to make sure it's safe, + # so don't allow exec. + # TODO: Exec support is deprecated. Remove completely for 1.0. + if "exec" in prototype and not self.caller.check_permstring("Developer"): + self.msg("Spawn aborted: You are not allowed to use the 'exec' prototype key.") + return + try: + # we homogenize the prototype first, to be more lenient with free-form + protlib.validate_prototype(protlib.homogenize_prototype(prototype)) + except RuntimeError as err: + self.msg(str(err)) + return + return prototype + + def _get_prototype_detail(self, query=None, prototypes=None): + """ + Display the detailed specs of one or more prototypes. + + Args: + query (str, optional): If this is given and `prototypes` is not, search for + the prototype(s) by this query. This may be a partial query which + may lead to multiple matches, all being displayed. + prototypes (list, optional): If given, ignore `query` and only show these + prototype-details. + Returns: + display (str, None): A formatted string of one or more prototype details. + If None, the caller was already informed of the error. + + + """ + if not prototypes: + # we need to query. Note that if query is None, all prototypes will + # be returned. + prototypes = protlib.search_prototype(key=query) + if prototypes: + return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes) + elif query: + self.msg(f"No prototype named '{query}' was found.") + else: + self.msg("No prototypes found.") + + def _list_prototypes(self, key=None, tags=None): + """Display prototypes as a list, optionally limited by key/tags.""" + protlib.list_prototypes(self.caller, key=key, tags=tags, session=self.session) + + @interactive + def _update_existing_objects(self, caller, prototype_key, quiet=False): + """ + Update existing objects (if any) with this prototype-key to the latest + prototype version. + + Args: + caller (Object): This is necessary for @interactive to work. + prototype_key (str): The prototype to update. + quiet (bool, optional): If set, don't report to user if no + old objects were found to update. + Returns: + n_updated (int): Number of updated objects. + + """ + prototype = self._search_prototype(prototype_key) + if not prototype: + return + + existing_objects = protlib.search_objects_with_prototype(prototype_key) + if not existing_objects: + if not quiet: + caller.msg("No existing objects found with an older version of this prototype.") + return + + if existing_objects: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ( + f"There are {n_existing} existing object(s) with an older version " + f"of prototype '{prototype_key}'. Should it be re-applied to them{slow}? [Y]/N" + ) + answer = yield (string) + if answer.lower() in ["n", "no"]: + caller.msg( + "|rNo update was done of existing objects. " + "Use spawn/update <key> to apply later as needed.|n" + ) + return + try: + n_updated = spawner.batch_update_objects_with_prototype( + prototype, + objects=existing_objects, + caller=caller, + ) + except Exception: + logger.log_trace() + caller.msg(f"{n_updated} objects were updated.") + return + + def _parse_key_desc_tags(self, argstring, desc=True): + """ + Parse ;-separated input list. + """ + key, desc, tags = "", "", [] + if ";" in argstring: + parts = [part.strip().lower() for part in argstring.split(";")] + if len(parts) > 1 and desc: + key = parts[0] + desc = parts[1] + tags = parts[2:] + else: + key = parts[0] + tags = parts[1:] + else: + key = argstring.strip().lower() + return key, desc, tags + +
[docs] def func(self): + """Implements the spawner""" + + caller = self.caller + noloc = "noloc" in self.switches + + # run the menu/olc + if ( + self.cmdstring == "olc" + or "menu" in self.switches + or "olc" in self.switches + or "edit" in self.switches + ): + # OLC menu mode + prototype = None + if self.lhs: + prototype_key = self.lhs + prototype = self._search_prototype(prototype_key) + if not prototype: + return + olc_menus.start_olc(caller, session=self.session, prototype=prototype) + return + + if "search" in self.switches: + # query for a key match. The arg is a search query or nothing. + + if not self.args: + # an empty search returns the full list + self._list_prototypes() + return + + # search for key;tag combinations + key, _, tags = self._parse_key_desc_tags(self.args, desc=False) + self._list_prototypes(key, tags) + return + + if "raw" in self.switches: + # query for key match and return the prototype as a safe one-liner string. + if not self.args: + caller.msg("You need to specify a prototype-key to get the raw data for.") + prototype = self._search_prototype(self.args) + if not prototype: + return + caller.msg(str(prototype)) + return + + if "show" in self.switches or "examine" in self.switches: + # show a specific prot detail. The argument is a search query or empty. + if not self.args: + # we don't show the list of all details, that's too spammy. + caller.msg("You need to specify a prototype-key to show.") + return + + detail_string = self._get_prototype_detail(self.args) + if not detail_string: + return + caller.msg(detail_string) + return + + if "list" in self.switches: + # for list, all optional arguments are tags. + tags = self.lhslist + err = self._list_prototypes(tags=tags) + if err: + caller.msg( + "No prototypes found with prototype-tag(s): {}".format( + list_to_string(tags, "or") + ) + ) + return + + if "save" in self.switches: + # store a prototype to the database store + if not self.args: + caller.msg( + "Usage: spawn/save [<key>[;desc[;tag,tag[,...][;lockstring]]]] =" + " <prototype_dict>" + ) + return + if self.rhs: + # input on the form key = prototype + prototype_key, prototype_desc, prototype_tags = self._parse_key_desc_tags(self.lhs) + prototype_key = None if not prototype_key else prototype_key + prototype_desc = None if not prototype_desc else prototype_desc + prototype_tags = None if not prototype_tags else prototype_tags + prototype_input = self.rhs.strip() + else: + prototype_key = prototype_desc = None + prototype_tags = None + prototype_input = self.lhs.strip() + + # handle parsing + prototype = self._parse_prototype(prototype_input) + if not prototype: + return + + prot_prototype_key = prototype.get("prototype_key") + + if not (prototype_key or prot_prototype_key): + caller.msg( + "A prototype_key must be given, either as `prototype_key = <prototype>` " + "or as a key 'prototype_key' inside the prototype structure." + ) + return + + if prototype_key is None: + prototype_key = prot_prototype_key + + if prot_prototype_key != prototype_key: + caller.msg("(Replacing `prototype_key` in prototype with given key.)") + prototype["prototype_key"] = prototype_key + + if prototype_desc is not None and prot_prototype_key != prototype_desc: + caller.msg("(Replacing `prototype_desc` in prototype with given desc.)") + prototype["prototype_desc"] = prototype_desc + if prototype_tags is not None and prototype.get("prototype_tags") != prototype_tags: + caller.msg("(Replacing `prototype_tags` in prototype with given tag(s))") + prototype["prototype_tags"] = prototype_tags + + string = "" + # check for existing prototype (exact match) + old_prototype = self._search_prototype(prototype_key, quiet=True) + + diff = spawner.prototype_diff(old_prototype, prototype, homogenize=True) + diffstr = spawner.format_diff(diff) + new_prototype_detail = self._get_prototype_detail(prototypes=[prototype]) + + if old_prototype: + if not diffstr: + string = f"|yAlready existing Prototype:|n\n{new_prototype_detail}\n" + question = ( + "\nThere seems to be no changes. Do you still want to (re)save? [Y]/N" + ) + else: + string = ( + f'|yExisting prototype "{prototype_key}" found. Change:|n\n{diffstr}\n' + f"|yNew changed prototype:|n\n{new_prototype_detail}" + ) + question = ( + "\n|yDo you want to apply the change to the existing prototype?|n [Y]/N" + ) + else: + string = f"|yCreating new prototype:|n\n{new_prototype_detail}" + question = "\nDo you want to continue saving? [Y]/N" + + answer = yield (string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rSave cancelled.|n") + return + + # all seems ok. Try to save. + try: + prot = protlib.save_prototype(prototype) + if not prot: + caller.msg("|rError saving:|R {}.|n".format(prototype_key)) + return + except protlib.PermissionError as err: + caller.msg("|rError saving:|R {}|n".format(err)) + return + caller.msg("|gSaved prototype:|n {}".format(prototype_key)) + + # check if we want to update existing objects + + self._update_existing_objects(self.caller, prototype_key, quiet=True) + return + + if not self.args: + # all switches beyond this point gets a common non-arg return + ncount = len(protlib.search_prototype()) + caller.msg( + "Usage: spawn <prototype-key> or {{key: value, ...}}" + f"\n ({ncount} existing prototypes. Use /list to inspect)" + ) + return + + if "delete" in self.switches: + # remove db-based prototype + prototype_detail = self._get_prototype_detail(self.args) + if not prototype_detail: + return + + string = f"|rDeleting prototype:|n\n{prototype_detail}" + question = "\nDo you want to continue deleting? [Y]/N" + answer = yield (string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rDeletion cancelled.|n") + return + + try: + success = protlib.delete_prototype(self.args) + except protlib.PermissionError as err: + retmsg = f"|rError deleting:|R {err}|n" + else: + retmsg = ( + "Deletion successful" + if success + else "Deletion failed (does the prototype exist?)" + ) + caller.msg(retmsg) + return + + if "update" in self.switches: + # update existing prototypes + prototype_key = self.args.strip().lower() + self._update_existing_objects(self.caller, prototype_key) + return + + # If we get to this point, we use not switches but are trying a + # direct creation of an object from a given prototype or -key + + prototype = self._parse_prototype( + self.args, expect=dict if self.args.strip().startswith("{") else str + ) + if not prototype: + # this will only let through dicts or strings + return + + key = "<unnamed>" + if isinstance(prototype, str): + # A prototype key we are looking to apply + prototype_key = prototype + prototype = self._search_prototype(prototype_key) + + if not prototype: + return + + # proceed to spawning + try: + for obj in spawner.spawn(prototype, caller=self.caller): + self.msg("Spawned %s." % obj.get_display_name(self.caller)) + if not prototype.get("location") and not noloc: + # we don't hardcode the location in the prototype (unless the user + # did so manually) - that would lead to it having to be 'removed' every + # time we try to update objects with this prototype in the future. + obj.location = caller.location + except RuntimeError as err: + caller.msg(err)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/cmdset_account.html b/docs/latest/_modules/evennia/commands/default/cmdset_account.html new file mode 100644 index 0000000000..e1c5bcb5fe --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/cmdset_account.html @@ -0,0 +1,190 @@ + + + + + + + + evennia.commands.default.cmdset_account — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.cmdset_account

+"""
+
+This is the cmdset for Account (OOC) commands.  These are
+stored on the Account object and should thus be able to handle getting
+an Account object as caller rather than a Character.
+
+Note - in order for session-rerouting (in MULTISESSION_MODE=2) to
+function, all commands in this cmdset should use the self.msg()
+command method rather than caller.msg().
+"""
+
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.default import (
+    account,
+    admin,
+    building,
+    comms,
+    general,
+    help,
+    system,
+)
+
+
+
[docs]class AccountCmdSet(CmdSet): + """ + Implements the account command set. + """ + + key = "DefaultAccount" + priority = -10 + +
[docs] def at_cmdset_creation(self): + "Populates the cmdset" + + # Account-specific commands + self.add(account.CmdOOCLook()) + self.add(account.CmdIC()) + self.add(account.CmdOOC()) + self.add(account.CmdCharCreate()) + self.add(account.CmdCharDelete()) + # self.add(account.CmdSessions()) + self.add(account.CmdWho()) + self.add(account.CmdOption()) + self.add(account.CmdQuit()) + self.add(account.CmdPassword()) + self.add(account.CmdColorTest()) + self.add(account.CmdQuell()) + self.add(account.CmdStyle()) + + # nicks + self.add(general.CmdNick()) + + # testing + self.add(building.CmdExamine()) + + # Help command + self.add(help.CmdHelp()) + + # system commands + self.add(system.CmdReload()) + self.add(system.CmdReset()) + self.add(system.CmdShutdown()) + self.add(system.CmdPy()) + + # Admin commands + self.add(admin.CmdNewPassword()) + + # Comm commands + self.add(comms.CmdChannel()) + self.add(comms.CmdPage()) + self.add(comms.CmdIRC2Chan()) + self.add(comms.CmdIRCStatus()) + self.add(comms.CmdRSS2Chan()) + self.add(comms.CmdGrapevine2Chan()) + self.add(comms.CmdDiscord2Chan())
+ # self.add(comms.CmdChannels()) + # self.add(comms.CmdAddCom()) + # self.add(comms.CmdDelCom()) + # self.add(comms.CmdAllCom()) + # self.add(comms.CmdCdestroy()) + # self.add(comms.CmdChannelCreate()) + # self.add(comms.CmdClock()) + # self.add(comms.CmdCBoot()) + # self.add(comms.CmdCWho()) + # self.add(comms.CmdCdesc()) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/cmdset_character.html b/docs/latest/_modules/evennia/commands/default/cmdset_character.html new file mode 100644 index 0000000000..14a828a2f0 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/cmdset_character.html @@ -0,0 +1,202 @@ + + + + + + + + evennia.commands.default.cmdset_character — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.cmdset_character

+"""
+This module ties together all the commands default Character objects have
+available (i.e. IC commands). Note that some commands, such as
+communication-commands are instead put on the account level, in the
+Account cmdset. Account commands remain available also to Characters.
+"""
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.default import (
+    admin,
+    batchprocess,
+    building,
+    general,
+    help,
+    system,
+)
+
+
+
[docs]class CharacterCmdSet(CmdSet): + """ + Implements the default command set. + """ + + key = "DefaultCharacter" + priority = 0 + +
[docs] def at_cmdset_creation(self): + "Populates the cmdset" + + # The general commands + self.add(general.CmdLook()) + self.add(general.CmdHome()) + self.add(general.CmdInventory()) + self.add(general.CmdPose()) + self.add(general.CmdNick()) + self.add(general.CmdSetDesc()) + self.add(general.CmdGet()) + self.add(general.CmdDrop()) + self.add(general.CmdGive()) + self.add(general.CmdSay()) + self.add(general.CmdWhisper()) + self.add(general.CmdAccess()) + + # The help system + self.add(help.CmdHelp()) + self.add(help.CmdSetHelp()) + + # System commands + self.add(system.CmdPy()) + self.add(system.CmdAccounts()) + self.add(system.CmdService()) + self.add(system.CmdAbout()) + self.add(system.CmdTime()) + self.add(system.CmdServerLoad()) + # self.add(system.CmdPs()) + self.add(system.CmdTickers()) + self.add(system.CmdTasks()) + + # Admin commands + self.add(admin.CmdBoot()) + self.add(admin.CmdBan()) + self.add(admin.CmdUnban()) + self.add(admin.CmdEmit()) + self.add(admin.CmdPerm()) + self.add(admin.CmdWall()) + self.add(admin.CmdForce()) + + # Building and world manipulation + self.add(building.CmdTeleport()) + self.add(building.CmdSetObjAlias()) + self.add(building.CmdListCmdSets()) + self.add(building.CmdWipe()) + self.add(building.CmdSetAttribute()) + self.add(building.CmdName()) + self.add(building.CmdDesc()) + self.add(building.CmdCpAttr()) + self.add(building.CmdMvAttr()) + self.add(building.CmdCopy()) + self.add(building.CmdFind()) + self.add(building.CmdOpen()) + self.add(building.CmdLink()) + self.add(building.CmdUnLink()) + self.add(building.CmdCreate()) + self.add(building.CmdDig()) + self.add(building.CmdTunnel()) + self.add(building.CmdDestroy()) + self.add(building.CmdExamine()) + self.add(building.CmdTypeclass()) + self.add(building.CmdLock()) + self.add(building.CmdSetHome()) + self.add(building.CmdTag()) + self.add(building.CmdSpawn()) + self.add(building.CmdScripts()) + self.add(building.CmdObjects()) + + # Batchprocessor commands + self.add(batchprocess.CmdBatchCommands()) + self.add(batchprocess.CmdBatchCode())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/cmdset_session.html b/docs/latest/_modules/evennia/commands/default/cmdset_session.html new file mode 100644 index 0000000000..12c37f8d30 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/cmdset_session.html @@ -0,0 +1,123 @@ + + + + + + + + evennia.commands.default.cmdset_session — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.cmdset_session

+"""
+This module stores session-level commands.
+"""
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.default import account
+
+
+
[docs]class SessionCmdSet(CmdSet): + """ + Sets up the unlogged cmdset. + """ + + key = "DefaultSession" + priority = -20 + +
[docs] def at_cmdset_creation(self): + "Populate the cmdset" + self.add(account.CmdSessions())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/cmdset_unloggedin.html b/docs/latest/_modules/evennia/commands/default/cmdset_unloggedin.html new file mode 100644 index 0000000000..1abd30dfd6 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/cmdset_unloggedin.html @@ -0,0 +1,132 @@ + + + + + + + + evennia.commands.default.cmdset_unloggedin — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.cmdset_unloggedin

+"""
+This module describes the unlogged state of the default game.
+The setting STATE_UNLOGGED should be set to the python path
+of the state instance in this module.
+"""
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.default import unloggedin
+
+
+
[docs]class UnloggedinCmdSet(CmdSet): + """ + Sets up the unlogged cmdset. + """ + + key = "DefaultUnloggedin" + priority = 0 + +
[docs] def at_cmdset_creation(self): + "Populate the cmdset" + self.add(unloggedin.CmdUnconnectedConnect()) + self.add(unloggedin.CmdUnconnectedCreate()) + self.add(unloggedin.CmdUnconnectedQuit()) + self.add(unloggedin.CmdUnconnectedLook()) + self.add(unloggedin.CmdUnconnectedHelp()) + self.add(unloggedin.CmdUnconnectedEncoding()) + self.add(unloggedin.CmdUnconnectedScreenreader()) + self.add(unloggedin.CmdUnconnectedInfo())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/comms.html b/docs/latest/_modules/evennia/commands/default/comms.html new file mode 100644 index 0000000000..acb743eb79 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/comms.html @@ -0,0 +1,2197 @@ + + + + + + + + evennia.commands.default.comms — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.comms

+"""
+Communication commands:
+
+- channel
+- page
+- irc/rss/grapevine/discord linking
+
+"""
+
+from django.conf import settings
+
+from evennia.accounts import bots
+from evennia.accounts.models import AccountDB
+from evennia.comms.comms import DefaultChannel
+from evennia.comms.models import Msg
+from evennia.locks.lockhandler import LockException
+from evennia.utils import create, logger, search, utils
+from evennia.utils.evmenu import ask_yes_no
+from evennia.utils.logger import tail_log_file
+from evennia.utils.utils import class_from_module, strip_unsafe_input
+
+COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
+CHANNEL_DEFAULT_TYPECLASS = class_from_module(
+    settings.BASE_CHANNEL_TYPECLASS, fallback=settings.FALLBACK_CHANNEL_TYPECLASS
+)
+
+
+# limit symbol import for API
+__all__ = (
+    "CmdChannel",
+    "CmdObjectChannel",
+    "CmdPage",
+    "CmdIRC2Chan",
+    "CmdIRCStatus",
+    "CmdRSS2Chan",
+    "CmdGrapevine2Chan",
+    "CmdDiscord2Chan",
+)
+_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+
+# helper functions to make it easier to override the main CmdChannel
+# command and to keep the legacy addcom etc commands around.
+
+
+
[docs]class CmdChannel(COMMAND_DEFAULT_CLASS): + """ + Use and manage in-game channels. + + Usage: + channel channelname <msg> + channel channel name = <msg> + channel (show all subscription) + channel/all (show available channels) + channel/alias channelname = alias[;alias...] + channel/unalias alias + channel/who channelname + channel/history channelname [= index] + channel/sub channelname [= alias[;alias...]] + channel/unsub channelname[,channelname, ...] + channel/mute channelname[,channelname,...] + channel/unmute channelname[,channelname,...] + + channel/create channelname[;alias;alias[:typeclass]] [= description] + channel/destroy channelname [= reason] + channel/desc channelname = description + channel/lock channelname = lockstring + channel/unlock channelname = lockstring + channel/ban channelname (list bans) + channel/ban[/quiet] channelname[, channelname, ...] = subscribername [: reason] + channel/unban[/quiet] channelname[, channelname, ...] = subscribername + channel/boot[/quiet] channelname[,channelname,...] = subscribername [: reason] + + # subtopics + + ## sending + + Usage: channel channelname msg + channel channel name = msg (with space in channel name) + + This sends a message to the channel. Note that you will rarely use this + command like this; instead you can use the alias + + channelname <msg> + channelalias <msg> + + For example + + public Hello World + pub Hello World + + (this shortcut doesn't work for aliases containing spaces) + + See channel/alias for help on setting channel aliases. + + ## alias and unalias + + Usage: channel/alias channel = alias[;alias[;alias...]] + channel/unalias alias + channel - this will list your subs and aliases to each channel + + Set one or more personal aliases for referencing a channel. For example: + + channel/alias warrior's guild = warrior;wguild;warchannel;warrior guild + + You can now send to the channel using all of these: + + warrior's guild Hello + warrior Hello + wguild Hello + warchannel Hello + + Note that this will not work if the alias has a space in it. So the + 'warrior guild' alias must be used with the `channel` command: + + channel warrior guild = Hello + + Channel-aliases can be removed one at a time, using the '/unalias' switch. + + ## who + + Usage: channel/who channelname + + List the channel's subscribers. Shows who are currently offline or are + muting the channel. Subscribers who are 'muting' will not see messages sent + to the channel (use channel/mute to mute a channel). + + ## history + + Usage: channel/history channel [= index] + + This will display the last |c20|n lines of channel history. By supplying an + index number, you will step that many lines back before viewing those 20 lines. + + For example: + + channel/history public = 35 + + will go back 35 lines and show the previous 20 lines from that point (so + lines -35 to -55). + + ## sub and unsub + + Usage: channel/sub channel [=alias[;alias;...]] + channel/unsub channel + + This subscribes you to a channel and optionally assigns personal shortcuts + for you to use to send to that channel (see aliases). When you unsub, all + your personal aliases will also be removed. + + ## mute and unmute + + Usage: channel/mute channelname + channel/unmute channelname + + Muting silences all output from the channel without actually + un-subscribing. Other channel members will see that you are muted in the /who + list. Sending a message to the channel will automatically unmute you. + + ## create and destroy + + Usage: channel/create channelname[;alias;alias[:typeclass]] [= description] + channel/destroy channelname [= reason] + + Creates a new channel (or destroys one you control). You will automatically + join the channel you create and everyone will be kicked and loose all aliases + to a destroyed channel. + + ## lock and unlock + + Usage: channel/lock channelname = lockstring + channel/unlock channelname = lockstring + + Note: this is an admin command. + + A lockstring is on the form locktype:lockfunc(). Channels understand three + locktypes: + listen - who may listen or join the channel. + send - who may send messages to the channel + control - who controls the channel. This is usually the one creating + the channel. + + Common lockfuncs are all() and perm(). To make a channel everyone can + listen to but only builders can talk on, use this: + + listen:all() + send: perm(Builders) + + ## boot and ban + + Usage: + channel/boot[/quiet] channelname[,channelname,...] = subscribername [: reason] + channel/ban channelname[, channelname, ...] = subscribername [: reason] + channel/unban channelname[, channelname, ...] = subscribername + channel/unban channelname + channel/ban channelname (list bans) + + Booting will kick a named subscriber from channel(s) temporarily. The + 'reason' will be passed to the booted user. Unless the /quiet switch is + used, the channel will also be informed of the action. A booted user is + still able to re-connect, but they'll have to set up their aliases again. + + Banning will blacklist a user from (re)joining the provided channels. It + will then proceed to boot them from those channels if they were connected. + The 'reason' and `/quiet` works the same as for booting. + + Example: + boot mychannel1 = EvilUser : Kicking you to cool down a bit. + ban mychannel1,mychannel2= EvilUser : Was banned for spamming. + + """ + + key = "@channel" + aliases = ["@chan", "@channels"] + help_category = "Comms" + # these cmd: lock controls access to the channel command itself + # the admin: lock controls access to /boot/ban/unban switches + # the manage: lock controls access to /create/destroy/desc/lock/unlock switches + locks = "cmd:not pperm(channel_banned);admin:all();manage:all();changelocks:perm(Admin)" + switch_options = ( + "list", + "all", + "history", + "sub", + "unsub", + "mute", + "unmute", + "alias", + "unalias", + "create", + "destroy", + "desc", + "lock", + "unlock", + "boot", + "ban", + "unban", + "who", + ) + # disable this in child command classes if wanting on-character channels + account_caller = True + +
[docs] def search_channel(self, channelname, exact=False, handle_errors=True): + """ + Helper function for searching for a single channel with some error + handling. + + Args: + channelname (str): Name, alias #dbref or partial name/alias to search + for. + exact (bool, optional): If an exact or fuzzy-match of the name should be done. + Note that even for a fuzzy match, an exactly given, unique channel name + will always be returned. + handle_errors (bool): If true, use `self.msg` to report errors if + there are non/multiple matches. If so, the return will always be + a single match or None. + Returns: + object, list or None: If `handle_errors` is `True`, this is either a found Channel + or `None`. Otherwise it's a list of zero, one or more channels found. + Notes: + The 'listen' and 'control' accesses are checked before returning. + + """ + caller = self.caller + # first see if this is a personal alias + channelname = caller.nicks.get(key=channelname, category="channel") or channelname + + # always try the exact match first. + channels = CHANNEL_DEFAULT_TYPECLASS.objects.channel_search(channelname, exact=True) + + if not channels and not exact: + # try fuzzy matching as well + channels = CHANNEL_DEFAULT_TYPECLASS.objects.channel_search(channelname, exact=exact) + + # check permissions + channels = [ + channel + for channel in channels + if channel.access(caller, "listen") or channel.access(caller, "control") + ] + + if handle_errors: + if not channels: + self.msg( + f"No channel found matching '{channelname}' " + "(could also be due to missing access)." + ) + return None + elif len(channels) > 1: + self.msg( + f"Multiple possible channel matches/alias for '{channelname}':\n" + + ", ".join(chan.key for chan in channels) + ) + return None + return channels[0] + else: + if not channels: + return [] + elif len(channels) > 1: + return list(channels) + return [channels[0]]
+ +
[docs] def msg_channel(self, channel, message, **kwargs): + """ + Send a message to a given channel. This will check the 'send' + permission on the channel. + + Args: + channel (Channel): The channel to send to. + message (str): The message to send. + **kwargs: Unused by default. These kwargs will be passed into + all channel messaging hooks for custom overriding. + + """ + if not channel.access(self.caller, "send"): + self.msg(f"You are not allowed to send messages to channel {channel}") + return + + # avoid unsafe tokens in message + message = strip_unsafe_input(message, self.session) + + channel.msg(message, senders=self.caller, **kwargs)
+ +
[docs] def get_channel_history(self, channel, start_index=0): + """ + View a channel's history. + + Args: + channel (Channel): The channel to access. + message (str): The message to send. + **kwargs: Unused by default. These kwargs will be passed into + all channel messaging hooks for custom overriding. + + """ + caller = self.caller + log_file = channel.get_log_filename() + + def send_msg(lines): + return self.msg( + "".join(line.split("[-]", 1)[1] if "[-]" in line else line for line in lines) + ) + + # asynchronously tail the log file + tail_log_file(log_file, start_index, 20, callback=send_msg)
+ +
[docs] def sub_to_channel(self, channel): + """ + Subscribe to a channel. Note that all permissions should + be checked before this step. + + Args: + channel (Channel): The channel to access. + + Returns: + bool, str: True, None if connection failed. If False, + the second part is an error string. + + """ + caller = self.caller + + if channel.has_connection(caller): + return False, f"Already listening to channel {channel.key}." + + # this sets up aliases in post_join_channel by default + result = channel.connect(caller) + + return result, "" if result else f"Were not allowed to subscribe to channel {channel.key}"
+ +
[docs] def unsub_from_channel(self, channel, **kwargs): + """ + Un-Subscribe to a channel. Note that all permissions should + be checked before this step. + + Args: + channel (Channel): The channel to unsub from. + **kwargs: Passed on to nick removal. + + Returns: + bool, str: True, None if un-connection succeeded. If False, + the second part is an error string. + + """ + caller = self.caller + + if not channel.has_connection(caller): + return False, f"Not listening to channel {channel.key}." + + # this will also clean aliases + result = channel.disconnect(caller) + + return result, "" if result else f"Could not unsubscribe from channel {channel.key}"
+ +
[docs] def add_alias(self, channel, alias, **kwargs): + """ + Add a new alias (nick) for the user to use with this channel. + + Args: + channel (Channel): The channel to alias. + alias (str): The personal alias to use for this channel. + **kwargs: If given, passed into nicks.add. + + Note: + We add two nicks - one is a plain `alias -> channel.key` that + we need to be able to reference this channel easily. The other + is a templated nick to easily be able to send messages to the + channel without needing to give the full `channel` command. The + structure of this nick is given by `self.channel_msg_pattern` + and `self.channel_msg_nick_replacement`. By default it maps + `alias <msg> -> channel <channelname> = <msg>`, so that you can + for example just write `pub Hello` to send a message. + + The alias created is `alias $1 -> channel channel = $1`, to allow + for sending to channel using the main channel command. + + """ + channel.add_user_channel_alias(self.caller, alias, **kwargs)
+ +
[docs] def remove_alias(self, alias, **kwargs): + """ + Remove an alias from a channel. + + Args: + alias (str, optional): The alias to remove. + The channel will be reverse-determined from the + alias, if it exists. + + Returns: + bool, str: True, None if removal succeeded. If False, + the second part is an error string. + **kwargs: If given, passed into nicks.get/add. + + Note: + This will remove two nicks - the plain channel alias and the templated + nick used for easily sending messages to the channel. + + """ + if self.caller.nicks.has(alias, category="channel", **kwargs): + DefaultChannel.remove_user_channel_alias(self.caller, alias) + return True, "" + return False, "No such alias was defined."
+ +
[docs] def get_channel_aliases(self, channel): + """ + Get a user's aliases for a given channel. The user is retrieved + through self.caller. + + Args: + channel (Channel): The channel to act on. + + Returns: + list: A list of zero, one or more alias-strings. + + """ + chan_key = channel.key.lower() + nicktuples = self.caller.nicks.get(category="channel", return_tuple=True, return_list=True) + if nicktuples: + return [tup[2] for tup in nicktuples if tup[3].lower() == chan_key] + return []
+ +
[docs] def mute_channel(self, channel): + """ + Temporarily mute a channel. + + Args: + channel (Channel): The channel to alias. + + Returns: + bool, str: True, None if muting successful. If False, + the second part is an error string. + """ + if channel.mute(self.caller): + return True, "" + return False, f"Channel {channel.key} was already muted."
+ +
[docs] def unmute_channel(self, channel): + """ + Unmute a channel. + + Args: + channel (Channel): The channel to alias. + + Returns: + bool, str: True, None if unmuting successful. If False, + the second part is an error string. + + """ + if channel.unmute(self.caller): + return True, "" + return False, f"Channel {channel.key} was already unmuted."
+ +
[docs] def create_channel(self, name, description, typeclass=None, aliases=None): + """ + Create a new channel. Its name must not previously exist (case agnostic) + (users can alias as needed). Will also connect to the new channel. + + Args: + name (str): The new channel name/key. + description (str): This is used in listings. + aliases (list): A list of strings - alternative aliases for the channel + (not to be confused with per-user aliases; these are available for + everyone). + + Returns: + channel, str: new_channel, "" if creation successful. If False, + the second part is an error string. + + """ + caller = self.caller + if typeclass: + typeclass = class_from_module(typeclass) + else: + typeclass = CHANNEL_DEFAULT_TYPECLASS + + if typeclass.objects.channel_search(name, exact=True): + return False, f"Channel {name} already exists." + + # set up the new channel + lockstring = "send:all();listen:all();control:id(%s)" % caller.id + + new_chan = create.create_channel( + name, aliases=aliases, desc=description, locks=lockstring, typeclass=typeclass + ) + self.sub_to_channel(new_chan) + return new_chan, ""
+ +
[docs] def destroy_channel(self, channel, message=None): + """ + Destroy an existing channel. Access should be checked before + calling this function. + + Args: + channel (Channel): The channel to alias. + message (str, optional): Final message to send onto the channel + before destroying it. If not given, a default message is + used. Set to the empty string for no message. + + if typeclass: + pass + + """ + caller = self.caller + + channel_key = channel.key + if message is None: + message = ( + f"|rChannel {channel_key} is being destroyed. " + "Make sure to clean any channel aliases.|n" + ) + if message: + channel.msg(message, senders=caller, bypass_mute=True) + channel.delete() + logger.log_sec(f"Channel {channel_key} was deleted by {caller}")
+ +
[docs] def set_lock(self, channel, lockstring): + """ + Set a lockstring on a channel. Permissions must have been + checked before this call. + + Args: + channel (Channel): The channel to operate on. + lockstring (str): A lockstring on the form 'type:lockfunc();...' + + Returns: + bool, str: True, None if setting lock was successful. If False, + the second part is an error string. + + """ + try: + channel.locks.add(lockstring) + except LockException as err: + return False, err + return True, ""
+ +
[docs] def unset_lock(self, channel, lockstring): + """ + Remove locks in a lockstring on a channel. Permissions must have been + checked before this call. + + Args: + channel (Channel): The channel to operate on. + lockstring (str): A lockstring on the form 'type:lockfunc();...' + + Returns: + bool, str: True, None if setting lock was successful. If False, + the second part is an error string. + + """ + try: + channel.locks.remove(lockstring) + except LockException as err: + return False, err + return True, ""
+ +
[docs] def set_desc(self, channel, description): + """ + Set a channel description. This is shown in listings etc. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to operate on. + description (str): A short description of the channel. + + Returns: + bool, str: True, None if setting lock was successful. If False, + the second part is an error string. + + """ + channel.db.desc = description
+ +
[docs] def boot_user(self, channel, target, quiet=False, reason=""): + """ + Boot a user from a channel, with optional reason. This will + also remove all their aliases for this channel. + + Args: + channel (Channel): The channel to operate on. + target (Object or Account): The entity to boot. + quiet (bool, optional): Whether or not to announce to channel. + reason (str, optional): A reason for the boot. + + Returns: + bool, str: True, None if setting lock was successful. If False, + the second part is an error string. + + """ + if not channel.subscriptions.has(target): + return False, f"{target} is not connected to channel {channel.key}." + # find all of target's nicks linked to this channel and delete them + for nick in [ + nick + for nick in target.nicks.get(category="channel") or [] + if nick.value[3].lower() == channel.key + ]: + nick.delete() + channel.disconnect(target) + reason = f" Reason: {reason}" if reason else "" + target.msg(f"You were booted from channel {channel.key} by {self.caller.key}.{reason}") + if not quiet: + channel.msg(f"{target.key} was booted from channel by {self.caller.key}.{reason}") + + logger.log_sec( + f"Channel Boot: {target} (Channel: {channel}, " + f"Reason: {reason.strip()}, Caller: {self.caller}" + ) + return True, ""
+ +
[docs] def ban_user(self, channel, target, quiet=False, reason=""): + """ + Ban a user from a channel, by locking them out. This will also + boot them, if they are currently connected. + + Args: + channel (Channel): The channel to operate on. + target (Object or Account): The entity to ban + quiet (bool, optional): Whether or not to announce to channel. + reason (str, optional): A reason for the ban + + Returns: + bool, str: True, None if banning was successful. If False, + the second part is an error string. + + """ + self.boot_user(channel, target, quiet=quiet, reason=reason) + if channel.ban(target): + return True, "" + return False, f"{target} is already banned from this channel."
+ +
[docs] def unban_user(self, channel, target): + """ + Un-Ban a user from a channel. This will not reconnect them + to the channel, just allow them to connect again (assuming + they have the suitable 'listen' lock like everyone else). + + Args: + channel (Channel): The channel to operate on. + target (Object or Account): The entity to unban + + Returns: + bool, str: True, None if unbanning was successful. If False, + the second part is an error string. + + """ + if channel.unban(target): + return True, "" + return False, f"{target} was not previously banned from this channel."
+ +
[docs] def channel_list_bans(self, channel): + """ + Show a channel's bans. + + Args: + channel (Channel): The channel to operate on. + + Returns: + list: A list of strings, each the name of a banned user. + + """ + return [banned.key for banned in channel.banlist]
+ +
[docs] def channel_list_who(self, channel): + """ + Show a list of online people is subscribing to a channel. This will check + the 'control' permission of `caller` to determine if only online users + should be returned or everyone. + + Args: + channel (Channel): The channel to operate on. + + Returns: + list: A list of prepared strings, with name + markers for if they are + muted or offline. + + """ + caller = self.caller + mute_list = list(channel.mutelist) + online_list = channel.subscriptions.online() + if channel.access(caller, "control"): + # for those with channel control, show also offline users + all_subs = list(channel.subscriptions.all()) + else: + # for others, only show online users + all_subs = online_list + + who_list = [] + for subscriber in all_subs: + name = subscriber.get_display_name(caller) + conditions = ( + "muting" if subscriber in mute_list else "", + "offline" if subscriber not in online_list else "", + ) + conditions = [cond for cond in conditions if cond] + cond_text = "(" + ", ".join(conditions) + ")" if conditions else "" + who_list.append(f"{name}{cond_text}") + + return who_list
+ +
[docs] def list_channels(self, channelcls=CHANNEL_DEFAULT_TYPECLASS): + """ + Return a available channels. + + Args: + channelcls (Channel, optional): The channel-class to query on. Defaults + to the default channel class from settings. + + Returns: + tuple: A tuple `(subbed_chans, available_chans)` with the channels + currently subscribed to, and those we have 'listen' access to but + don't actually sub to yet. + + """ + caller = self.caller + subscribed_channels = list(channelcls.objects.get_subscriptions(caller)) + unsubscribed_available_channels = [ + chan + for chan in channelcls.objects.get_all_channels() + if chan not in subscribed_channels and chan.access(caller, "listen") + ] + return subscribed_channels, unsubscribed_available_channels
+ +
[docs] def display_subbed_channels(self, subscribed): + """ + Display channels subscribed to. + + Args: + subscribed (list): List of subscribed channels + + Returns: + EvTable: Table to display. + + """ + comtable = self.styled_table( + "id", + "channel", + "my aliases", + "locks", + "description", + align="l", + maxwidth=_DEFAULT_WIDTH, + ) + for chan in subscribed: + locks = "-" + chanid = "-" + if chan.access(self.caller, "control"): + locks = chan.locks + chanid = chan.id + + my_aliases = ", ".join(self.get_channel_aliases(chan)) + comtable.add_row( + *( + chanid, + "{key}{aliases}".format( + key=chan.key, + aliases=";" + ";".join(chan.aliases.all()) if chan.aliases.all() else "", + ), + my_aliases, + locks, + chan.db.desc, + ) + ) + return comtable
+ +
[docs] def display_all_channels(self, subscribed, available): + """ + Display all available channels + + Args: + subscribed (list): List of subscribed channels + Returns: + EvTable: Table to display. + + """ + caller = self.caller + + comtable = self.styled_table( + "sub", + "channel", + "aliases", + "my aliases", + "description", + maxwidth=_DEFAULT_WIDTH, + ) + channels = subscribed + available + + for chan in channels: + if chan not in subscribed: + substatus = "|rNo|n" + elif caller in chan.mutelist: + substatus = "|rMuting|n" + else: + substatus = "|gYes|n" + my_aliases = ", ".join(self.get_channel_aliases(chan)) + comtable.add_row( + *( + substatus, + chan.key, + ",".join(chan.aliases.all()) if chan.aliases.all() else "", + my_aliases, + chan.db.desc, + ) + ) + comtable.reformat_column(0, width=8) + + return comtable
+ +
[docs] def func(self): + """ + Main functionality of command. + """ + # from evennia import set_trace;set_trace() + + caller = self.caller + switches = self.switches + channel_names = [name for name in self.lhslist if name] + + # from evennia import set_trace;set_trace() + + if "all" in switches: + # show all available channels + subscribed, available = self.list_channels() + table = self.display_all_channels(subscribed, available) + + self.msg( + "\n|wAvailable channels|n (use no argument to " + f"only show your subscriptions)\n{table}" + ) + return + + if not channel_names: + # empty arg show only subscribed channels + subscribed, _ = self.list_channels() + table = self.display_subbed_channels(subscribed) + + self.msg(f"\n|wChannel subscriptions|n (use |w/all|n to see all available):\n{table}") + return + + if not self.switches and not self.args: + self.msg("Usage[/switches]: channel [= message]") + return + + if "create" in switches: + # create a new channel + if not self.access(caller, "manage"): + self.msg("You don't have access to use channel/create.") + return + + config = self.lhs + if not config: + self.msg("To create: channel/create name[;aliases][:typeclass] [= description]") + return + name, *typeclass = config.rsplit(":", 1) + typeclass = typeclass[0] if typeclass else None + name, *aliases = name.rsplit(";") + description = self.rhs or "" + chan, err = self.create_channel(name, description, typeclass=typeclass, aliases=aliases) + if chan: + self.msg(f"Created (and joined) new channel '{chan.key}'.") + else: + self.msg(err) + return + + if "unalias" in switches: + # remove a personal alias (no channel needed) + alias = self.args.strip() + if not alias: + self.msg("Specify the alias to remove as channel/unalias <alias>") + return + success, err = self.remove_alias(alias) + if success: + self.msg(f"Removed your channel alias '{alias}'.") + else: + self.msg(err) + return + + possible_lhs_message = "" + if not self.rhs and self.args and " " in self.args: + # since we want to support messaging with `channel name text` (for + # channels without a space in their name), we need to check if the + # first 'channel name' is in fact 'channelname text' + no_rhs_channel_name = self.args.split(" ", 1)[0] + possible_lhs_message = self.args[len(no_rhs_channel_name) :] + if possible_lhs_message.strip() == "=": + possible_lhs_message = "" + channel_names.append(no_rhs_channel_name) + + channels = [] + errors = [] + for channel_name in channel_names: + # find a channel by fuzzy-matching. This also checks + # 'listen/control' perms. + found_channels = self.search_channel(channel_name, exact=False, handle_errors=False) + if not found_channels: + errors.append( + f"No channel found matching '{channel_name}' " + "(could also be due to missing access)." + ) + elif len(found_channels) > 1: + errors.append( + f"Multiple possible channel matches/alias for '{channel_name}':\n" + + ", ".join(chan.key for chan in found_channels) + ) + else: + channels.append(found_channels[0]) + + if not channels: + self.msg("\n".join(errors)) + return + + # we have at least one channel at this point + channel = channels[0] + + if not switches: + if self.rhs: + # send message to channel + self.msg_channel(channel, self.rhs.strip()) + elif channel and possible_lhs_message: + # called on the form channelname message without = + self.msg_channel(channel, possible_lhs_message.strip()) + else: + # inspect a given channel + subscribed, available = self.list_channels() + if channel in subscribed: + table = self.display_subbed_channels([channel]) + header = f"Channel |w{channel.key}|n" + self.msg( + f"{header}\n(use |w{channel.key} <msg>|n (or a channel-alias) " + "to chat and the 'channel' command " + f"to customize)\n{table}" + ) + elif channel in available: + table = self.display_all_channels([], [channel]) + self.msg( + "\n|wNot subscribed to this channel|n (use /list to " + f"show all subscriptions)\n{table}" + ) + return + + if "history" in switches or "hist" in switches: + # view channel history + + index = self.rhs or 0 + try: + index = max(0, int(index)) + except ValueError: + self.msg( + "The history index (describing how many lines to go back) " + "must be an integer >= 0." + ) + return + self.get_channel_history(channel, start_index=index) + return + + if "sub" in switches: + # subscribe to a channel + aliases = [] + if self.rhs: + aliases = set(alias.strip().lower() for alias in self.rhs.split(";")) + success, err = self.sub_to_channel(channel) + if success: + for alias in aliases: + self.add_alias(channel, alias) + alias_txt = ", ".join(aliases) + alias_txt = f" using alias(es) {alias_txt}" if aliases else "" + self.msg( + "You are now subscribed " + f"to the channel {channel.key}{alias_txt}. Use /alias to " + "add additional aliases for referring to the channel." + ) + else: + self.msg(err) + return + + if "unsub" in switches: + # un-subscribe from a channel + success, err = self.unsub_from_channel(channel) + if success: + self.msg(f"You un-subscribed from channel {channel.key}. All aliases were cleared.") + else: + self.msg(err) + return + + if "alias" in switches: + # create a new personal alias for a channel + alias = self.rhs + if not alias: + self.msg("Specify the alias as channel/alias channelname = alias") + return + self.add_alias(channel, alias) + self.msg(f"Added/updated your alias '{alias}' for channel {channel.key}.") + return + + if "mute" in switches: + # mute a given channel + success, err = self.mute_channel(channel) + if success: + self.msg(f"Muted channel {channel.key}.") + else: + self.msg(err) + return + + if "unmute" in switches: + # unmute a given channel + success, err = self.unmute_channel(channel) + if success: + self.msg(f"Un-muted channel {channel.key}.") + else: + self.msg(err) + return + + if "destroy" in switches or "delete" in switches: + # destroy a channel we control + + if not self.access(caller, "manage"): + self.msg("You don't have access to use channel/destroy.") + return + + if not channel.access(caller, "control"): + self.msg("You can only delete channels you control.") + return + + reason = self.rhs or None + + def _perform_delete(caller, *args, **kwargs): + self.destroy_channel(channel, message=reason) + self.msg(f"Channel {channel.key} was successfully deleted.") + + ask_yes_no( + caller, + prompt=( + f"Are you sure you want to delete channel '{channel.key}' " + "(make sure name is correct!)?\nThis will disconnect and " + "remove all users' aliases. {options}?" + ), + yes_action=_perform_delete, + no_action="Aborted.", + default="N", + ) + + if "desc" in switches: + # set channel description + + if not self.access(caller, "manage"): + self.msg("You don't have access to use channel/desc.") + return + + if not channel.access(caller, "control"): + self.msg("You can only change description of channels you control.") + return + + desc = self.rhs.strip() + + if not desc: + self.msg("Usage: /desc channel = description") + return + + self.set_desc(channel, desc) + self.msg("Updated channel description.") + + if "lock" in switches: + # add a lockstring to channel + + if not self.access(caller, "changelocks"): + self.msg("You don't have access to use channel/lock.") + return + + if not channel.access(caller, "control"): + self.msg("You need 'control'-access to change locks on this channel.") + return + + lockstring = self.rhs.strip() + + if not lockstring: + self.msg("Usage: channel/lock channelname = lockstring") + return + + success, err = self.set_lock(channel, self.rhs) + if success: + self.msg("Added/updated lock on channel.") + else: + self.msg(f"Could not add/update lock: {err}") + return + + if "unlock" in switches: + # remove/update lockstring from channel + + if not self.access(caller, "changelocks"): + self.msg("You don't have access to use channel/unlock.") + return + + if not channel.access(caller, "control"): + self.msg("You need 'control'-access to change locks on this channel.") + return + + lockstring = self.rhs.strip() + + if not lockstring: + self.msg("Usage: channel/unlock channelname = lockstring") + return + + success, err = self.unset_lock(channel, self.rhs) + if success: + self.msg("Removed lock from channel.") + else: + self.msg(f"Could not remove lock: {err}") + return + + if "boot" in switches: + # boot a user from channel(s) + + if not self.access(caller, "admin"): + self.msg("You don't have access to use channel/boot.") + return + + if not self.rhs: + self.msg("Usage: channel/boot channel[,channel,...] = username [:reason]") + return + + target_str, *reason = self.rhs.rsplit(":", 1) + reason = reason[0].strip() if reason else "" + + for chan in channels: + if not chan.access(caller, "control"): + self.msg(f"You need 'control'-access to boot a user from {chan.key}.") + return + + # the target must be a member of all given channels + target = caller.search(target_str, candidates=chan.subscriptions.all()) + if not target: + self.msg(f"Cannot boot '{target_str}' - not in channel {chan.key}.") + return + + def _boot_user(caller, *args, **kwargs): + for chan in channels: + success, err = self.boot_user(chan, target, quiet=False, reason=reason) + if success: + self.msg(f"Booted {target.key} from channel {chan.key}.") + else: + self.msg(f"Cannot boot {target.key} from channel {chan.key}: {err}") + + channames = ", ".join(chan.key for chan in channels) + reasonwarn = ( + ". Also note that your reason will be echoed to the channel" if reason else "" + ) + ask_yes_no( + caller, + prompt=( + f"Are you sure you want to boot user {target.key} from " + f"channel(s) {channames} (make sure name/channels are correct{reasonwarn}). " + "{options}?" + ), + yes_action=_boot_user, + no_action="Aborted.", + default="Y", + ) + return + + if "ban" in switches: + # ban a user from channel(s) + + if not self.access(caller, "admin"): + self.msg("You don't have access to use channel/ban.") + return + + if not self.rhs: + # view bans for channels + + if not channel.access(caller, "control"): + self.msg(f"You need 'control'-access to view bans on channel {channel.key}") + return + + bans = [ + "Channel bans " + "(to ban, use channel/ban channel[,channel,...] = username [:reason]" + ] + bans.extend(self.channel_list_bans(channel)) + self.msg("\n".join(bans)) + return + + target_str, *reason = self.rhs.rsplit(":", 1) + reason = reason[0].strip() if reason else "" + + for chan in channels: + # the target must be a member of all given channels + if not chan.access(caller, "control"): + self.msg(f"You don't have access to ban users on channel {chan.key}") + return + + target = caller.search(target_str, candidates=chan.subscriptions.all()) + + if not target: + self.msg(f"Cannot ban '{target_str}' - not in channel {chan.key}.") + return + + def _ban_user(caller, *args, **kwargs): + for chan in channels: + success, err = self.ban_user(chan, target, quiet=False, reason=reason) + if success: + self.msg(f"Banned {target.key} from channel {chan.key}.") + else: + self.msg(f"Cannot boot {target.key} from channel {chan.key}: {err}") + + channames = ", ".join(chan.key for chan in channels) + reasonwarn = ( + ". Also note that your reason will be echoed to the channel" if reason else "" + ) + ask_yes_no( + caller, + ( + f"Are you sure you want to ban user {target.key} from " + f"channel(s) {channames} (make sure name/channels are correct{reasonwarn}) " + "{options}?" + ), + _ban_user, + "Aborted.", + ) + return + + if "unban" in switches: + # unban a previously banned user from channel + + if not self.access(caller, "admin"): + self.msg("You don't have access to use channel/unban.") + return + + target_str = self.rhs.strip() + + if not target_str: + self.msg("Usage: channel[,channel,...] = user") + return + + banlists = [] + for chan in channels: + # the target must be a member of all given channels + if not chan.access(caller, "control"): + self.msg(f"You don't have access to unban users on channel {chan.key}") + return + banlists.extend(chan.banlist) + + target = caller.search(target_str, candidates=banlists) + if not target: + self.msg(f"Could not find a banned user '{target_str}' in given channel(s).") + return + + for chan in channels: + success, err = self.unban_user(channel, target) + if success: + self.msg(f"Un-banned {target_str} from channel {chan.key}") + else: + self.msg(err) + return + + if "who" in switches: + # view who's a member of a channel + + who_list = [f"Subscribed to {channel.key}:"] + who_list.extend(self.channel_list_who(channel)) + self.msg("\n".join(who_list)) + return
+ + +# a channel-command parent for use with Characters/Objects. +
[docs]class CmdObjectChannel(CmdChannel): + account_caller = False
+ + +
[docs]class CmdPage(COMMAND_DEFAULT_CLASS): + """ + send a private message to another account + + Usage: + page <account> <message> + page[/switches] [<account>,<account>,... = <message>] + tell '' + page <number> + + Switches: + last - shows who you last messaged + list - show your last <number> of tells/pages (default) + + Send a message to target user (if online). If no argument is given, you + will get a list of your latest messages. The equal sign is needed for + multiple targets or if sending to target with space in the name. + + """ + + key = "page" + aliases = ["tell"] + switch_options = ("last", "list") + locks = "cmd:not pperm(page_banned)" + help_category = "Comms" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """Implement function using the Msg methods""" + + # Since account_caller is set above, this will be an Account. + caller = self.caller + + # get the messages we've sent (not to channels) + pages_we_sent = Msg.objects.get_messages_by_sender(caller).order_by("-db_date_created") + # get last messages we've got + pages_we_got = Msg.objects.get_messages_by_receiver(caller).order_by("-db_date_created") + targets, message, number = [], None, None + + if "last" in self.switches: + if pages_we_sent: + recv = ",".join(obj.key for obj in pages_we_sent[0].receivers) + self.msg(f"You last paged |c{recv}|n:{pages_we_sent[0].message}") + return + else: + self.msg("You haven't paged anyone yet.") + return + + if self.args: + if self.rhs: + for target in self.lhslist: + target_obj = self.caller.search(target) + if not target_obj: + return + targets.append(target_obj) + message = self.rhs.strip() + else: + target, *message = self.args.split(" ", 1) + if target and target.isnumeric(): + # a number to specify a historic page + number = int(target) + elif target: + target_obj = self.caller.search(target, quiet=True) + if target_obj: + # a proper target + targets = [target_obj[0]] + message = message[0].strip() + else: + # a message with a space in it - put it back together + message = target + " " + (message[0] if message else "") + else: + # a single-word message + message = message[0].strip() + + pages = list(pages_we_sent) + list(pages_we_got) + pages = sorted(pages, key=lambda page: page.date_created) + + if message: + # send a message + if not targets: + # no target given - send to last person we paged + if pages_we_sent: + targets = pages_we_sent[0].receivers + else: + self.msg("Who do you want page?") + return + + header = f"|wAccount|n |c{caller.key}|n |wpages:|n" + if message.startswith(":"): + message = f"{caller.key} {message.strip(':').strip()}" + + # create the persistent message object + create.create_message(caller, message, receivers=targets) + + # tell the accounts they got a message. + received = [] + rstrings = [] + for target in targets: + if not target.access(caller, "msg"): + rstrings.append(f"You are not allowed to page {target}.") + continue + target.msg(f"{header} {message}") + if hasattr(target, "sessions") and not target.sessions.count(): + received.append(f"|C{target.name}|n") + rstrings.append( + f"{received[-1]} is offline. They will see your message " + "if they list their pages later." + ) + else: + received.append(f"|c{target.name}|n") + if rstrings: + self.msg("\n".join(rstrings)) + self.msg("You paged %s with: '%s'." % (", ".join(received), message)) + return + + else: + # no message to send + if number is not None and len(pages) > number: + lastpages = pages[-number:] + else: + lastpages = pages + 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: + 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 + 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, + ) + ) + lastpages = "\n ".join(listing) + + if lastpages: + string = f"Your latest pages:\n {lastpages}" + else: + string = "You haven't paged anyone yet." + self.msg(string) + return
+ + +def _list_bots(cmd): + """ + Helper function to produce a list of all IRC bots. + + Args: + cmd (Command): Instance of the Bot command. + Returns: + bots (str): A table of bots or an error message. + + """ + ircbots = [ + bot for bot in AccountDB.objects.filter(db_is_bot=True, username__startswith="ircbot-") + ] + if ircbots: + table = cmd.styled_table( + "|w#dbref|n", + "|wbotname|n", + "|wev-channel|n", + "|wirc-channel|n", + "|wSSL|n", + maxwidth=_DEFAULT_WIDTH, + ) + for ircbot in ircbots: + ircinfo = "%s (%s:%s)" % ( + ircbot.db.irc_channel, + ircbot.db.irc_network, + ircbot.db.irc_port, + ) + table.add_row( + "#%i" % ircbot.id, + ircbot.db.irc_botname, + ircbot.db.ev_channel, + ircinfo, + ircbot.db.irc_ssl, + ) + return table + else: + return "No irc bots found." + + +
[docs]class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): + """ + Link an evennia channel to an external IRC channel + + Usage: + irc2chan[/switches] <evennia_channel> = <ircnetwork> <port> <#irchannel> <botname>[:typeclass] + irc2chan/delete botname|#dbid + + Switches: + /delete - this will delete the bot and remove the irc connection + to the channel. Requires the botname or #dbid as input. + /remove - alias to /delete + /disconnect - alias to /delete + /list - show all irc<->evennia mappings + /ssl - use an SSL-encrypted connection + + Example: + irc2chan myircchan = irc.dalnet.net 6667 #mychannel evennia-bot + irc2chan public = irc.freenode.net 6667 #evgaming #evbot:accounts.mybot.MyBot + + This creates an IRC bot that connects to a given IRC network and + channel. If a custom typeclass path is given, this will be used + instead of the default bot class. + The bot will relay everything said in the evennia channel to the + IRC channel and vice versa. The bot will automatically connect at + server start, so this command need only be given once. The + /disconnect switch will permanently delete the bot. To only + temporarily deactivate it, use the |wservices|n command instead. + Provide an optional bot class path to use a custom bot. + """ + + key = "irc2chan" + switch_options = ("delete", "remove", "disconnect", "list", "ssl") + locks = "cmd:serversetting(IRC_ENABLED) and pperm(Developer)" + help_category = "Comms" + +
[docs] def func(self): + """Setup the irc-channel mapping""" + + if not settings.IRC_ENABLED: + string = """IRC is not enabled. You need to activate it in game/settings.py.""" + self.msg(string) + return + + if "list" in self.switches: + # show all connections + self.msg(_list_bots(self)) + return + + if "disconnect" in self.switches or "remove" in self.switches or "delete" in self.switches: + botname = f"ircbot-{self.lhs}" + matches = AccountDB.objects.filter(db_is_bot=True, username=botname) + dbref = utils.dbref(self.lhs) + if not matches and dbref: + # try dbref match + matches = AccountDB.objects.filter(db_is_bot=True, id=dbref) + if matches: + matches[0].delete() + self.msg("IRC connection destroyed.") + else: + self.msg("IRC connection/bot could not be removed, does it exist?") + return + + if not self.args or not self.rhs: + string = ( + "Usage: irc2chan[/switches] <evennia_channel> =" + " <ircnetwork> <port> <#irchannel> <botname>[:typeclass]" + ) + self.msg(string) + return + + channel = self.lhs + self.rhs = self.rhs.replace("#", " ") # to avoid Python comment issues + try: + irc_network, irc_port, irc_channel, irc_botname = [ + part.strip() for part in self.rhs.split(None, 4) + ] + irc_channel = f"#{irc_channel}" + except Exception: + string = "IRC bot definition '%s' is not valid." % self.rhs + self.msg(string) + return + + botclass = None + if ":" in irc_botname: + irc_botname, botclass = [part.strip() for part in irc_botname.split(":", 2)] + botname = f"ircbot-{irc_botname}" + # If path given, use custom bot otherwise use default. + botclass = botclass if botclass else bots.IRCBot + irc_ssl = "ssl" in self.switches + + # create a new bot + bot = AccountDB.objects.filter(username__iexact=botname) + if bot: + # re-use an existing bot + bot = bot[0] + if not bot.is_bot: + self.msg(f"Account '{botname}' already exists and is not a bot.") + return + else: + try: + bot = create.create_account(botname, None, None, typeclass=botclass) + except Exception as err: + self.msg(f"|rError, could not create the bot:|n '{err}'.") + return + bot.start( + ev_channel=channel, + irc_botname=irc_botname, + irc_channel=irc_channel, + irc_network=irc_network, + irc_port=irc_port, + irc_ssl=irc_ssl, + ) + self.msg("Connection created. Starting IRC bot.")
+ + +
[docs]class CmdIRCStatus(COMMAND_DEFAULT_CLASS): + """ + Check and reboot IRC bot. + + Usage: + ircstatus [#dbref ping | nicklist | reconnect] + + If not given arguments, will return a list of all bots (like + irc2chan/list). The 'ping' argument will ping the IRC network to + see if the connection is still responsive. The 'nicklist' argument + (aliases are 'who' and 'users') will return a list of users on the + remote IRC channel. Finally, 'reconnect' will force the client to + disconnect and reconnect again. This may be a last resort if the + client has silently lost connection (this may happen if the remote + network experience network issues). During the reconnection + messages sent to either channel will be lost. + + """ + + key = "ircstatus" + locks = "cmd:serversetting(IRC_ENABLED) and perm(ircstatus) or perm(Builder))" + help_category = "Comms" + +
[docs] def func(self): + """Handles the functioning of the command.""" + + if not self.args: + self.msg(_list_bots(self)) + return + # should always be on the form botname option + args = self.args.split() + if len(args) != 2: + self.msg("Usage: ircstatus [#dbref ping||nicklist||reconnect]") + return + botname, option = args + if option not in ("ping", "users", "reconnect", "nicklist", "who"): + self.msg("Not a valid option.") + return + matches = None + if utils.dbref(botname): + matches = AccountDB.objects.filter(db_is_bot=True, id=utils.dbref(botname)) + if not matches: + self.msg( + "No matching IRC-bot found. Use ircstatus without arguments to list active bots." + ) + return + ircbot = matches[0] + channel = ircbot.db.irc_channel + network = ircbot.db.irc_network + port = ircbot.db.irc_port + chtext = f"IRC bot '{ircbot.db.irc_botname}' on channel {channel} ({network}:{port})" + if option == "ping": + # check connection by sending outself a ping through the server. + self.msg(f"Pinging through {chtext}.") + ircbot.ping(self.caller) + elif option in ("users", "nicklist", "who"): + # retrieve user list. The bot must handles the echo since it's + # an asynchronous call. + self.msg(f"Requesting nicklist from {channel} ({network}:{port}).") + ircbot.get_nicklist(self.caller) + elif self.caller.locks.check_lockstring( + self.caller, "dummy:perm(ircstatus) or perm(Developer)" + ): + # reboot the client + self.msg(f"Forcing a disconnect + reconnect of {chtext}.") + ircbot.reconnect() + else: + self.msg("You don't have permission to force-reload the IRC bot.")
+ + +# RSS connection +
[docs]class CmdRSS2Chan(COMMAND_DEFAULT_CLASS): + """ + link an evennia channel to an external RSS feed + + Usage: + rss2chan[/switches] <evennia_channel> = <rss_url> + + Switches: + /disconnect - this will stop the feed and remove the connection to the + channel. + /remove - " + /list - show all rss->evennia mappings + + Example: + rss2chan rsschan = http://code.google.com/feeds/p/evennia/updates/basic + + This creates an RSS reader that connects to a given RSS feed url. Updates + will be echoed as a title and news link to the given channel. The rate of + updating is set with the RSS_UPDATE_INTERVAL variable in settings (default + is every 10 minutes). + + When disconnecting you need to supply both the channel and url again so as + to identify the connection uniquely. + """ + + key = "rss2chan" + switch_options = ("disconnect", "remove", "list") + locks = "cmd:serversetting(RSS_ENABLED) and pperm(Developer)" + help_category = "Comms" + +
[docs] def func(self): + """Setup the rss-channel mapping""" + + # checking we have all we need + if not settings.RSS_ENABLED: + string = """RSS is not enabled. You need to activate it in game/settings.py.""" + self.msg(string) + return + try: + import feedparser + + assert feedparser # to avoid checker error of not being used + except ImportError: + string = ( + "RSS requires python-feedparser (https://pypi.python.org/pypi/feedparser)." + " Install before continuing." + ) + self.msg(string) + return + + if "list" in self.switches: + # show all connections + rssbots = [ + bot + for bot in AccountDB.objects.filter(db_is_bot=True, username__startswith="rssbot-") + ] + if rssbots: + table = self.styled_table( + "|wdbid|n", + "|wupdate rate|n", + "|wev-channel", + "|wRSS feed URL|n", + border="cells", + maxwidth=_DEFAULT_WIDTH, + ) + for rssbot in rssbots: + table.add_row( + rssbot.id, rssbot.db.rss_rate, rssbot.db.ev_channel, rssbot.db.rss_url + ) + self.msg(table) + else: + self.msg("No rss bots found.") + return + + if "disconnect" in self.switches or "remove" in self.switches or "delete" in self.switches: + botname = f"rssbot-{self.lhs}" + matches = AccountDB.objects.filter(db_is_bot=True, db_key=botname) + if not matches: + # try dbref match + matches = AccountDB.objects.filter(db_is_bot=True, id=self.args.lstrip("#")) + if matches: + matches[0].delete() + self.msg("RSS connection destroyed.") + else: + self.msg("RSS connection/bot could not be removed, does it exist?") + return + + if not self.args or not self.rhs: + string = "Usage: rss2chan[/switches] <evennia_channel> = <rss url>" + self.msg(string) + return + channel = self.lhs + url = self.rhs + + botname = f"rssbot-{url}" + bot = AccountDB.objects.filter(username__iexact=botname) + if bot: + # re-use existing bot + bot = bot[0] + if not bot.is_bot: + self.msg(f"Account '{botname}' already exists and is not a bot.") + return + else: + # create a new bot + bot = create.create_account(botname, None, None, typeclass=bots.RSSBot) + bot.start(ev_channel=channel, rss_url=url, rss_rate=10) + self.msg("RSS reporter created. Fetching RSS.")
+ + +
[docs]class CmdGrapevine2Chan(COMMAND_DEFAULT_CLASS): + """ + Link an Evennia channel to an external Grapevine channel + + Usage: + grapevine2chan[/switches] <evennia_channel> = <grapevine_channel> + grapevine2chan/disconnect <connection #id> + + Switches: + /list - (or no switch): show existing grapevine <-> Evennia + mappings and available grapevine chans + /remove - alias to disconnect + /delete - alias to disconnect + + Example: + grapevine2chan mygrapevine = gossip + + This creates a link between an in-game Evennia channel and an external + Grapevine channel. The game must be registered with the Grapevine network + (register at https://grapevine.haus) and the GRAPEVINE_* auth information + must be added to game settings. + """ + + key = "grapevine2chan" + switch_options = ("disconnect", "remove", "delete", "list") + locks = "cmd:serversetting(GRAPEVINE_ENABLED) and pperm(Developer)" + help_category = "Comms" + +
[docs] def func(self): + """Setup the Grapevine channel mapping""" + + if not settings.GRAPEVINE_ENABLED: + self.msg("Set GRAPEVINE_ENABLED=True in settings to enable.") + return + + if "list" in self.switches: + # show all connections + gwbots = [ + bot + for bot in AccountDB.objects.filter( + db_is_bot=True, username__startswith="grapevinebot-" + ) + ] + if gwbots: + table = self.styled_table( + "|wdbid|n", + "|wev-channel", + "|wgw-channel|n", + border="cells", + maxwidth=_DEFAULT_WIDTH, + ) + for gwbot in gwbots: + table.add_row(gwbot.id, gwbot.db.ev_channel, gwbot.db.grapevine_channel) + self.msg(table) + else: + self.msg("No grapevine bots found.") + return + + if "disconnect" in self.switches or "remove" in self.switches or "delete" in self.switches: + botname = f"grapevinebot-{self.lhs}" + matches = AccountDB.objects.filter(db_is_bot=True, db_key=botname) + + if not matches: + # try dbref match + matches = AccountDB.objects.filter(db_is_bot=True, id=self.args.lstrip("#")) + if matches: + matches[0].delete() + self.msg("Grapevine connection destroyed.") + else: + self.msg("Grapevine connection/bot could not be removed, does it exist?") + return + + if not self.args or not self.rhs: + string = "Usage: grapevine2chan[/switches] <evennia_channel> = <grapevine_channel>" + self.msg(string) + return + + channel = self.lhs + grapevine_channel = self.rhs + + botname = "grapewinebot-%s-%s" % (channel, grapevine_channel) + bot = AccountDB.objects.filter(username__iexact=botname) + if bot: + # re-use existing bot + bot = bot[0] + if not bot.is_bot: + self.msg(f"Account '{botname}' already exists and is not a bot.") + return + else: + self.msg(f"Reusing bot '{botname}' ({bot.dbref})") + else: + # create a new bot + bot = create.create_account(botname, None, None, typeclass=bots.GrapevineBot) + + bot.start(ev_channel=channel, grapevine_channel=grapevine_channel) + self.msg(f"Grapevine connection created {channel} <-> {grapevine_channel}.")
+ + +
[docs]class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): + """ + Link an Evennia channel to an external Discord channel + + Usage: + discord2chan[/switches] + discord2chan[/switches] <evennia_channel> [= <discord_channel_id>] + + Switches: + /list - (or no switch) show existing Evennia <-> Discord links + /remove - remove an existing link by link ID + /delete - alias to remove + /guild - toggle the Discord server tag on/off + /channel - toggle the Evennia/Discord channel tags on/off + /start - tell the bot to start, in case it lost its connection + + Example: + discord2chan mydiscord = 555555555555555 + + This creates a link between an in-game Evennia channel and an external + Discord channel. You must have a valid Discord bot application + ( https://discord.com/developers/applications ) and your DISCORD_BOT_TOKEN + must be added to settings. (Please put it in secret_settings !) + """ + + key = "discord2chan" + aliases = ("discord",) + switch_options = ( + "channel", + "delete", + "guild", + "list", + "remove", + "start", + ) + locks = "cmd:serversetting(DISCORD_ENABLED) and pperm(Developer)" + help_category = "Comms" + +
[docs] def func(self): + """Manage the Evennia<->Discord channel links""" + + if not settings.DISCORD_BOT_TOKEN: + self.msg( + "You must add your Discord bot application token to settings as DISCORD_BOT_TOKEN" + ) + return + + discord_bot = [ + bot for bot in AccountDB.objects.filter(db_is_bot=True, username="DiscordBot") + ] + if not discord_bot: + # create a new discord bot + bot_class = class_from_module(settings.DISCORD_BOT_CLASS, fallback=bots.DiscordBot) + discord_bot = create.create_account("DiscordBot", None, None, typeclass=bot_class) + discord_bot.start() + self.msg("Created and initialized a new Discord relay bot.") + else: + discord_bot = discord_bot[0] + + if not discord_bot.is_typeclass(settings.DISCORD_BOT_CLASS, exact=True): + self.msg( + f"WARNING: The Discord bot's typeclass is '{discord_bot.typeclass_path}'. This does" + f" not match {settings.DISCORD_BOT_CLASS} in settings!" + ) + + if "start" in self.switches: + if discord_bot.sessions.all(): + self.msg("The Discord bot is already running.") + else: + discord_bot.start() + self.msg("Starting the Discord bot session.") + return + + if "guild" in self.switches: + discord_bot.db.tag_guild = not discord_bot.db.tag_guild + self.msg( + f"Messages to Evennia |wwill {'' if discord_bot.db.tag_guild else 'not '}|ninclude" + " the Discord server." + ) + return + if "channel" in self.switches: + discord_bot.db.tag_channel = not discord_bot.db.tag_channel + self.msg( + f"Relayed messages |wwill {'' if discord_bot.db.tag_channel else 'not '}|ninclude" + " the originating channel." + ) + return + + if "list" in self.switches or not self.args: + # show all connections + if channel_list := discord_bot.db.channels: + table = self.styled_table( + "|wLink ID|n", + "|wEvennia|n", + "|wDiscord|n", + border="cells", + maxwidth=_DEFAULT_WIDTH, + ) + # iterate through the channel links + # load in the pretty names for the discord channels from cache + dc_chan_names = discord_bot.attributes.get("discord_channels", {}) + for i, (evchan, dcchan) in enumerate(channel_list): + dc_info = dc_chan_names.get(dcchan, {"name": dcchan, "guild": "unknown"}) + table.add_row( + i, evchan, f"#{dc_info.get('name','?')}@{dc_info.get('guild','?')}" + ) + self.msg(table) + else: + self.msg("No Discord connections found.") + return + + if "disconnect" in self.switches or "remove" in self.switches or "delete" in self.switches: + if channel_list := discord_bot.db.channels: + try: + lid = int(self.args.strip()) + except ValueError: + self.msg("Usage: discord2chan/remove <link id>") + return + if lid < len(channel_list): + ev_chan, dc_chan = discord_bot.db.channels.pop(lid) + dc_chan_names = discord_bot.attributes.get("discord_channels", {}) + dc_info = dc_chan_names.get(dc_chan, {"name": "unknown", "guild": "unknown"}) + self.msg( + f"Removed link between {ev_chan} and" + f" #{dc_info.get('name','?')}@{dc_info.get('guild','?')}" + ) + return + else: + self.msg("There are no active connections to Discord.") + return + + ev_channel = self.lhs + dc_channel = self.rhs + + if ev_channel and not dc_channel: + # show all discord channels linked to self.lhs + if channel_list := discord_bot.db.channels: + table = self.styled_table( + "|wLink ID|n", + "|wEvennia|n", + "|wDiscord|n", + border="cells", + maxwidth=_DEFAULT_WIDTH, + ) + # iterate through the channel links + # load in the pretty names for the discord channels from cache + dc_chan_names = discord_bot.attributes.get("discord_channels", {}) + results = False + for i, (evchan, dcchan) in enumerate(channel_list): + if evchan.lower() == ev_channel.lower(): + dc_info = dc_chan_names.get(dcchan, {"name": dcchan, "guild": "unknown"}) + table.add_row(i, evchan, f"#{dc_info['name']}@{dc_info['guild']}") + results = True + if results: + self.msg(table) + else: + self.msg(f"There are no Discord channels connected to {ev_channel}.") + else: + self.msg("There are no active connections to Discord.") + return + + # check if link already exists + if channel_list := discord_bot.db.channels: + if (ev_channel, dc_channel) in channel_list: + self.msg("Those channels are already linked.") + return + else: + discord_bot.db.channels = [] + # create the new link + channel_obj = search.search_channel(ev_channel) + if not channel_obj: + self.msg(f"There is no channel '{ev_channel}'") + return + channel_obj = channel_obj[0] + discord_bot.db.channels.append((channel_obj.name, dc_channel)) + channel_obj.connect(discord_bot) + if dc_chans := discord_bot.db.discord_channels: + dc_channel_name = dc_chans.get(dc_channel, {}).get("name", dc_channel) + else: + dc_channel_name = dc_channel + self.msg(f"Discord connection created: {channel_obj.name} <-> #{dc_channel_name}.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/general.html b/docs/latest/_modules/evennia/commands/default/general.html new file mode 100644 index 0000000000..c6e09a6501 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/general.html @@ -0,0 +1,834 @@ + + + + + + + + evennia.commands.default.general — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.general

+"""
+General Character commands usually available to all characters
+"""
+import re
+
+from django.conf import settings
+
+import evennia
+from evennia.typeclasses.attributes import NickTemplateInvalid
+from evennia.utils import utils
+
+COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# limit symbol import for API
+__all__ = (
+    "CmdHome",
+    "CmdLook",
+    "CmdNick",
+    "CmdInventory",
+    "CmdSetDesc",
+    "CmdGet",
+    "CmdDrop",
+    "CmdGive",
+    "CmdSay",
+    "CmdWhisper",
+    "CmdPose",
+    "CmdAccess",
+)
+
+
+
[docs]class CmdHome(COMMAND_DEFAULT_CLASS): + """ + move to your character's home location + + Usage: + home + + Teleports you to your home location. + """ + + key = "home" + locks = "cmd:perm(home) or perm(Builder)" + arg_regex = r"$" + +
[docs] def func(self): + """Implement the command""" + caller = self.caller + home = caller.home + if not home: + caller.msg("You have no home!") + elif home == caller.location: + caller.msg("You are already home!") + else: + caller.msg("There's no place like home ...") + caller.move_to(home, move_type="teleport")
+ + +
[docs]class CmdLook(COMMAND_DEFAULT_CLASS): + """ + look at location or object + + Usage: + look + look <obj> + look *<account> + + Observes your location or objects in your vicinity. + """ + + key = "look" + aliases = ["l", "ls"] + locks = "cmd:all()" + arg_regex = r"\s|$" + +
[docs] def func(self): + """ + Handle the looking. + """ + caller = self.caller + if not self.args: + target = caller.location + if not target: + caller.msg("You have no location to look at!") + return + else: + target = caller.search(self.args) + if not target: + return + desc = caller.at_look(target) + # add the type=look to the outputfunc to make it + # easy to separate this output in client. + self.msg(text=(desc, {"type": "look"}), options=None)
+ + +
[docs]class CmdNick(COMMAND_DEFAULT_CLASS): + """ + define a personal alias/nick by defining a string to + match and replace it with another on the fly + + Usage: + nick[/switches] <string> [= [replacement_string]] + nick[/switches] <template> = <replacement_template> + nick/delete <string> or number + nicks + + Switches: + inputline - replace on the inputline (default) + object - replace on object-lookup + account - replace on account-lookup + list - show all defined aliases (also "nicks" works) + delete - remove nick by index in /list + clearall - clear all nicks + + Examples: + nick hi = say Hello, I'm Sarah! + nick/object tom = the tall man + nick build $1 $2 = create/drop $1;$2 + nick tell $1 $2=page $1=$2 + nick tm?$1=page tallman=$1 + nick tm\=$1=page tallman=$1 + + A 'nick' is a personal string replacement. Use $1, $2, ... to catch arguments. + Put the last $-marker without an ending space to catch all remaining text. You + can also use unix-glob matching for the left-hand side <string>: + + * - matches everything + ? - matches 0 or 1 single characters + [abcd] - matches these chars in any order + [!abcd] - matches everything not among these chars + \= - escape literal '=' you want in your <string> + + Note that no objects are actually renamed or changed by this command - your nicks + are only available to you. If you want to permanently add keywords to an object + for everyone to use, you need build privileges and the alias command. + + """ + + key = "nick" + switch_options = ("inputline", "object", "account", "list", "delete", "clearall") + aliases = ["nickname", "nicks"] + locks = "cmd:all()" + +
[docs] def parse(self): + """ + Support escaping of = with \= + """ + super().parse() + args = (self.lhs or "") + (" = %s" % self.rhs if self.rhs else "") + parts = re.split(r"(?<!\\)=", args, 1) + self.rhs = None + if len(parts) < 2: + self.lhs = parts[0].strip() + else: + self.lhs, self.rhs = [part.strip() for part in parts] + self.lhs = self.lhs.replace("\=", "=")
+ +
[docs] def func(self): + """Create the nickname""" + + def _cy(string): + "add color to the special markers" + return re.sub(r"(\$[0-9]+|\*|\?|\[.+?\])", r"|Y\1|n", string) + + caller = self.caller + switches = self.switches + nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")] + specified_nicktype = bool(nicktypes) + nicktypes = nicktypes if specified_nicktype else ["inputline"] + + nicklist = ( + utils.make_iter(caller.nicks.get(category="inputline", return_obj=True) or []) + + utils.make_iter(caller.nicks.get(category="object", return_obj=True) or []) + + utils.make_iter(caller.nicks.get(category="account", return_obj=True) or []) + ) + + if "list" in switches or self.cmdstring in ("nicks",): + if not nicklist: + string = "|wNo nicks defined.|n" + else: + table = self.styled_table("#", "Type", "Nick match", "Replacement") + for inum, nickobj in enumerate(nicklist): + _, _, nickvalue, replacement = nickobj.value + table.add_row( + str(inum + 1), nickobj.db_category, _cy(nickvalue), _cy(replacement) + ) + string = "|wDefined Nicks:|n\n%s" % table + caller.msg(string) + return + + if "clearall" in switches: + caller.nicks.clear() + caller.account.nicks.clear() + caller.msg("Cleared all nicks.") + return + + if "delete" in switches or "del" in switches: + if not self.args or not self.lhs: + caller.msg("usage nick/delete <nick> or <#num> ('nicks' for list)") + return + # see if a number was given + arg = self.args.lstrip("#") + oldnicks = [] + if arg.isdigit(): + # we are given a index in nicklist + delindex = int(arg) + if 0 < delindex <= len(nicklist): + oldnicks.append(nicklist[delindex - 1]) + else: + caller.msg("Not a valid nick index. See 'nicks' for a list.") + return + else: + if not specified_nicktype: + nicktypes = ("object", "account", "inputline") + for nicktype in nicktypes: + oldnicks.append(caller.nicks.get(arg, category=nicktype, return_obj=True)) + + oldnicks = [oldnick for oldnick in oldnicks if oldnick] + if oldnicks: + for oldnick in oldnicks: + nicktype = oldnick.category + nicktypestr = "%s-nick" % nicktype.capitalize() + _, _, old_nickstring, old_replstring = oldnick.value + caller.nicks.remove(old_nickstring, category=nicktype) + caller.msg( + f"{nicktypestr} removed: '|w{old_nickstring}|n' -> |w{old_replstring}|n." + ) + else: + caller.msg("No matching nicks to remove.") + return + + if not self.rhs and self.lhs: + # check what a nick is set to + strings = [] + if not specified_nicktype: + nicktypes = ("object", "account", "inputline") + for nicktype in nicktypes: + nicks = [ + nick + for nick in utils.make_iter( + caller.nicks.get(category=nicktype, return_obj=True) + ) + if nick + ] + for nick in nicks: + _, _, nick, repl = nick.value + if nick.startswith(self.lhs): + strings.append(f"{nicktype.capitalize()}-nick: '{nick}' -> '{repl}'") + if strings: + caller.msg("\n".join(strings)) + else: + caller.msg(f"No nicks found matching '{self.lhs}'") + return + + if not self.rhs and self.lhs: + # check what a nick is set to + strings = [] + if not specified_nicktype: + nicktypes = ("object", "account", "inputline") + for nicktype in nicktypes: + if nicktype == "account": + obj = account + else: + obj = caller + nicks = utils.make_iter(obj.nicks.get(category=nicktype, return_obj=True)) + for nick in nicks: + _, _, nick, repl = nick.value + if nick.startswith(self.lhs): + strings.append(f"{nicktype.capitalize()}-nick: '{nick}' -> '{repl}'") + if strings: + caller.msg("\n".join(strings)) + else: + caller.msg(f"No nicks found matching '{self.lhs}'") + return + + if not self.rhs and self.lhs: + # check what a nick is set to + strings = [] + if not specified_nicktype: + nicktypes = ("object", "account", "inputline") + for nicktype in nicktypes: + if nicktype == "account": + obj = account + else: + obj = caller + nicks = utils.make_iter(obj.nicks.get(category=nicktype, return_obj=True)) + for nick in nicks: + _, _, nick, repl = nick.value + if nick.startswith(self.lhs): + strings.append(f"{nicktype.capitalize()}-nick: '{nick}' -> '{repl}'") + if strings: + caller.msg("\n".join(strings)) + else: + caller.msg(f"No nicks found matching '{self.lhs}'") + return + + if not self.args or not self.lhs: + caller.msg("Usage: nick[/switches] nickname = [realname]") + return + + # setting new nicks + + nickstring = self.lhs + replstring = self.rhs + + if replstring == nickstring: + caller.msg("No point in setting nick same as the string to replace...") + return + + # check so we have a suitable nick type + errstring = "" + string = "" + for nicktype in nicktypes: + nicktypestr = f"{nicktype.capitalize()}-nick" + old_nickstring = None + old_replstring = None + + oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True) + if oldnick: + _, _, old_nickstring, old_replstring = oldnick.value + if replstring: + # creating new nick + errstring = "" + if oldnick: + if replstring == old_replstring: + string += f"\nIdentical {nicktypestr.lower()} already set." + else: + string += ( + f"\n{nicktypestr} '|w{old_nickstring}|n' updated to map to" + f" '|w{replstring}|n'." + ) + else: + string += f"\n{nicktypestr} '|w{nickstring}|n' mapped to '|w{replstring}|n'." + try: + caller.nicks.add(nickstring, replstring, category=nicktype) + except NickTemplateInvalid: + caller.msg( + "You must use the same $-markers both in the nick and in the replacement." + ) + return + elif old_nickstring and old_replstring: + # just looking at the nick + string += f"\n{nicktypestr} '|w{old_nickstring}|n' maps to '|w{old_replstring}|n'." + errstring = "" + string = errstring if errstring else string + caller.msg(_cy(string))
+ + +
[docs]class CmdInventory(COMMAND_DEFAULT_CLASS): + """ + view inventory + + Usage: + inventory + inv + + Shows your inventory. + """ + + key = "inventory" + aliases = ["inv", "i"] + locks = "cmd:all()" + arg_regex = r"$" + +
[docs] def func(self): + """check inventory""" + items = self.caller.contents + 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: + singular, _ = item.get_numbered_name(1, self.caller) + table.add_row( + f"|C{singular}|n", + "{}|n".format(utils.crop(raw_ansi(item.db.desc or ""), width=50) or ""), + ) + string = f"|wYou are carrying:\n{table}" + self.msg(text=(string, {"type": "inventory"}))
+ + +
[docs]class CmdGet(COMMAND_DEFAULT_CLASS): + """ + pick up something + + Usage: + get <obj> + + Picks up an object from your location and puts it in + your inventory. + """ + + key = "get" + aliases = "grab" + locks = "cmd:all();view:perm(Developer);read:perm(Developer)" + arg_regex = r"\s|$" + +
[docs] def func(self): + """implements the command.""" + + caller = self.caller + + if not self.args: + caller.msg("Get what?") + return + obj = caller.search(self.args, location=caller.location) + if not obj: + return + if caller == obj: + caller.msg("You can't get yourself.") + return + if not obj.access(caller, "get"): + if obj.db.get_err_msg: + caller.msg(obj.db.get_err_msg) + else: + caller.msg("You can't get that.") + return + + # calling at_pre_get hook method + if not obj.at_pre_get(caller): + return + + success = obj.move_to(caller, quiet=True, move_type="get") + if not success: + caller.msg("This can't be picked up.") + else: + singular, _ = obj.get_numbered_name(1, caller) + caller.location.msg_contents(f"$You() $conj(pick) up {singular}.", from_obj=caller) + # calling at_get hook method + obj.at_get(caller)
+ + +
[docs]class CmdDrop(COMMAND_DEFAULT_CLASS): + """ + drop something + + Usage: + drop <obj> + + Lets you drop an object from your inventory into the + location you are currently in. + """ + + key = "drop" + locks = "cmd:all()" + arg_regex = r"\s|$" + +
[docs] def func(self): + """Implement command""" + + caller = self.caller + if not self.args: + caller.msg("Drop what?") + return + + # Because the DROP command by definition looks for items + # in inventory, call the search function using location = caller + obj = caller.search( + self.args, + location=caller, + nofound_string=f"You aren't carrying {self.args}.", + multimatch_string=f"You carry more than one {self.args}:", + ) + if not obj: + return + + # Call the object script's at_pre_drop() method. + if not obj.at_pre_drop(caller): + return + + success = obj.move_to(caller.location, quiet=True, move_type="drop") + if not success: + caller.msg("This couldn't be dropped.") + else: + singular, _ = obj.get_numbered_name(1, caller) + caller.location.msg_contents(f"$You() $conj(drop) {singular}.", from_obj=caller) + # Call the object script's at_drop() method. + obj.at_drop(caller)
+ + +
[docs]class CmdGive(COMMAND_DEFAULT_CLASS): + """ + give away something to someone + + Usage: + give <inventory obj> <to||=> <target> + + Gives an item from your inventory to another person, + placing it in their inventory. + """ + + key = "give" + rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage. + locks = "cmd:all()" + arg_regex = r"\s|$" + +
[docs] def func(self): + """Implement give""" + + caller = self.caller + if not self.args or not self.rhs: + caller.msg("Usage: give <inventory object> = <target>") + return + to_give = caller.search( + self.lhs, + location=caller, + nofound_string=f"You aren't carrying {self.lhs}.", + multimatch_string=f"You carry more than one {self.lhs}:", + ) + target = caller.search(self.rhs) + if not (to_give and target): + return + + singular, _ = to_give.get_numbered_name(1, caller) + if target == caller: + caller.msg(f"You keep {singular} to yourself.") + return + if not to_give.location == caller: + caller.msg(f"You are not holding {singular}.") + return + + # calling at_pre_give hook method + if not to_give.at_pre_give(caller, target): + return + + # give object + success = to_give.move_to(target, quiet=True, move_type="give") + if not success: + caller.msg(f"You could not give {singular} to {target.key}.") + else: + caller.msg(f"You give {singular} to {target.key}.") + target.msg(f"{caller.key} gives you {singular}.") + # Call the object script's at_give() method. + to_give.at_give(caller, target)
+ + +
[docs]class CmdSetDesc(COMMAND_DEFAULT_CLASS): + """ + describe yourself + + Usage: + setdesc <description> + + Add a description to yourself. This + will be visible to people when they + look at you. + """ + + key = "setdesc" + locks = "cmd:all()" + arg_regex = r"\s|$" + +
[docs] def func(self): + """add the description""" + + if not self.args: + self.msg("You must add a description.") + return + + self.caller.db.desc = self.args.strip() + self.msg("You set your description.")
+ + +
[docs]class CmdSay(COMMAND_DEFAULT_CLASS): + """ + speak as your character + + Usage: + say <message> + + Talk to those in your current location. + """ + + key = "say" + aliases = ['"', "'"] + locks = "cmd:all()" + + # don't require a space after `say/'/"` + arg_regex = None + +
[docs] def func(self): + """Run the say command""" + + caller = self.caller + + if not self.args: + caller.msg("Say what?") + return + + speech = self.args + + # Calling the at_pre_say hook on the character + speech = caller.at_pre_say(speech) + + # If speech is empty, stop here + if not speech: + return + + # Call the at_post_say hook on the character + caller.at_say(speech, msg_self=True)
+ + +
[docs]class CmdWhisper(COMMAND_DEFAULT_CLASS): + """ + Speak privately as your character to another + + Usage: + whisper <character> = <message> + whisper <char1>, <char2> = <message> + + Talk privately to one or more characters in your current location, without + others in the room being informed. + """ + + key = "whisper" + locks = "cmd:all()" + +
[docs] def func(self): + """Run the whisper command""" + + caller = self.caller + + if not self.lhs or not self.rhs: + caller.msg("Usage: whisper <character> = <message>") + return + + receivers = [recv.strip() for recv in self.lhs.split(",")] + + receivers = [caller.search(receiver) for receiver in set(receivers)] + receivers = [recv for recv in receivers if recv] + + speech = self.rhs + # If the speech is empty, abort the command + if not speech or not receivers: + return + + # Call a hook to change the speech before whispering + speech = caller.at_pre_say(speech, whisper=True, receivers=receivers) + + # no need for self-message if we are whispering to ourselves (for some reason) + msg_self = None if caller in receivers else True + caller.at_say(speech, msg_self=msg_self, receivers=receivers, whisper=True)
+ + +
[docs]class CmdPose(COMMAND_DEFAULT_CLASS): + """ + strike a pose + + Usage: + pose <pose text> + pose's <pose text> + + Example: + pose is standing by the wall, smiling. + -> others will see: + Tom is standing by the wall, smiling. + + Describe an action being taken. The pose text will + automatically begin with your name. + """ + + key = "pose" + aliases = [":", "emote"] + locks = "cmd:all()" + arg_regex = "" + + # we want to be able to pose without whitespace between + # the command/alias and the pose (e.g. :pose) + arg_regex = None + +
[docs] def parse(self): + """ + Custom parse the cases where the emote + starts with some special letter, such + as 's, at which we don't want to separate + the caller's name and the emote with a + space. + """ + args = self.args + if args and not args[0] in ["'", ",", ":"]: + args = " %s" % args.strip() + self.args = args
+ +
[docs] def func(self): + """Hook function""" + if not self.args: + msg = "What do you want to do?" + self.msg(msg) + else: + msg = f"{self.caller.name}{self.args}" + self.caller.location.msg_contents(text=(msg, {"type": "pose"}), from_obj=self.caller)
+ + +
[docs]class CmdAccess(COMMAND_DEFAULT_CLASS): + """ + show your current game access + + Usage: + access + + This command shows you the permission hierarchy and + which permission groups you are a member of. + """ + + key = "access" + aliases = ["groups", "hierarchy"] + locks = "cmd:all()" + arg_regex = r"$" + +
[docs] def func(self): + """Load the permission groups""" + + caller = self.caller + hierarchy_full = settings.PERMISSION_HIERARCHY + string = "\n|wPermission Hierarchy|n (climbing):\n %s" % ", ".join(hierarchy_full) + + if self.caller.account.is_superuser: + cperms = "<Superuser>" + pperms = "<Superuser>" + else: + cperms = ", ".join(caller.permissions.all()) + pperms = ", ".join(caller.account.permissions.all()) + + string += "\n|wYour access|n:" + string += f"\nCharacter |c{caller.key}|n: {cperms}" + if utils.inherits_from(caller, evennia.DefaultObject): + string += f"\nAccount |c{caller.account.key}|n: {pperms}" + caller.msg(string)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/help.html b/docs/latest/_modules/evennia/commands/default/help.html new file mode 100644 index 0000000000..e7f81b7f6c --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/help.html @@ -0,0 +1,1143 @@ + + + + + + + + evennia.commands.default.help — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.help

+"""
+The help command. The basic idea is that help texts for commands are best
+written by those that write the commands - the developers. So command-help is
+all auto-loaded and searched from the current command set. The normal,
+database-tied help system is used for collaborative creation of other help
+topics such as RP help or game-world aides. Help entries can also be created
+outside the game in modules given by ``settings.FILE_HELP_ENTRY_MODULES``.
+
+"""
+
+from collections import defaultdict
+from dataclasses import dataclass
+from itertools import chain
+
+from django.conf import settings
+
+from evennia.help.filehelp import FILE_HELP_ENTRIES
+from evennia.help.models import HelpEntry
+from evennia.help.utils import help_search_with_index, parse_entry_for_subcategories
+from evennia.utils import create, evmore
+from evennia.utils.ansi import ANSIString
+from evennia.utils.eveditor import EvEditor
+from evennia.utils.utils import (
+    class_from_module,
+    dedent,
+    format_grid,
+    inherits_from,
+    pad,
+)
+
+CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
+COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
+HELP_MORE_ENABLED = settings.HELP_MORE_ENABLED
+DEFAULT_HELP_CATEGORY = settings.DEFAULT_HELP_CATEGORY
+HELP_CLICKABLE_TOPICS = settings.HELP_CLICKABLE_TOPICS
+
+# limit symbol import for API
+__all__ = ("CmdHelp", "CmdSetHelp")
+
+
+@dataclass
+class HelpCategory:
+    """
+    Mock 'help entry' to search categories with the same code.
+
+    """
+
+    key: str
+
+    @property
+    def search_index_entry(self):
+        return {
+            "key": self.key,
+            "aliases": "",
+            "category": self.key,
+            "no_prefix": "",
+            "tags": "",
+            "text": "",
+        }
+
+    def __hash__(self):
+        return hash(id(self))
+
+
+
[docs]class CmdHelp(COMMAND_DEFAULT_CLASS): + """ + Get help. + + Usage: + help + help <topic, command or category> + help <topic>/<subtopic> + help <topic>/<subtopic>/<subsubtopic> ... + + Use the 'help' command alone to see an index of all help topics, organized + by category. Some big topics may offer additional sub-topics. + + """ + + key = "help" + aliases = ["?"] + locks = "cmd:all()" + arg_regex = r"\s|$" + + # this is a special cmdhandler flag that makes the cmdhandler also pack + # the current cmdset with the call to self.func(). + return_cmdset = True + + # Help messages are wrapped in an EvMore call (unless using the webclient + # with separate help popups) If you want to avoid this, simply add + # 'HELP_MORE_ENABLED = False' in your settings/conf/settings.py + help_more = HELP_MORE_ENABLED + + # colors for the help index + index_type_separator_clr = "|w" + index_category_clr = "|W" + index_topic_clr = "|G" + + # suggestion cutoff, between 0 and 1 (1 => perfect match) + suggestion_cutoff = 0.6 + + # number of suggestions (set to 0 to remove suggestions from help) + suggestion_maxnum = 5 + + # separator between subtopics: + subtopic_separator_char = r"/" + + # should topics disply their help entry when clicked + clickable_topics = HELP_CLICKABLE_TOPICS + +
[docs] def msg_help(self, text, **kwargs): + """ + messages text to the caller, adding an extra oob argument to indicate + that this is a help command result and could be rendered in a separate + help window. + + """ + if type(self).help_more: + usemore = True + + if self.session and self.session.protocol_key in ( + "webclient/websocket", + "webclient/ajax", + ): + try: + options = self.account.db._saved_webclient_options + if options and options["helppopup"]: + usemore = False + except KeyError: + pass + + if usemore: + # adding the 'text_kwargs' keyword means it will be sent with the text outputfunc + # for every page. + evmore.msg( + self.caller, text, session=self.session, text_kwargs={"type": "help"}, **kwargs + ) + return + + self.msg(text=(text, {"type": "help"}))
+ +
[docs] def format_help_entry( + self, + topic="", + help_text="", + aliases=None, + suggested=None, + subtopics=None, + click_topics=True, + ): + """This visually formats the help entry. + This method can be overridden to customize the way a help + entry is displayed. + + Args: + title (str, optional): The title of the help entry. + help_text (str, optional): Text of the help entry. + aliases (list, optional): List of help-aliases (displayed in header). + suggested (list, optional): Strings suggested reading (based on title). + subtopics (list, optional): A list of strings - the subcategories available + for this entry. + click_topics (bool, optional): Should help topics be clickable. Default is True. + + Returns: + help_message (str): Help entry formated for console. + + """ + separator = "|C" + "-" * self.client_width() + "|n" + start = f"{separator}\n" + + title = f"|CHelp for |w{topic}|n" if topic else "|rNo help found|n" + + if aliases: + aliases = " |C(aliases: {}|C)|n".format("|C,|n ".join(f"|w{ali}|n" for ali in aliases)) + else: + aliases = "" + + help_text = "\n" + dedent(help_text.strip("\n")) if help_text else "" + + if subtopics: + if click_topics: + subtopics = [ + f"|lchelp {topic}/{subtop}|lt|w{topic}/{subtop}|n|le" for subtop in subtopics + ] + else: + subtopics = [f"|w{topic}/{subtop}|n" for subtop in subtopics] + subtopics = "\n|CSubtopics:|n\n {}".format( + "\n ".join( + format_grid( + subtopics, width=self.client_width(), line_prefix=self.index_topic_clr + ) + ) + ) + else: + subtopics = "" + + if suggested: + suggested = sorted(suggested) + if click_topics: + suggested = [f"|lchelp {sug}|lt|w{sug}|n|le" for sug in suggested] + else: + suggested = [f"|w{sug}|n" for sug in suggested] + suggested = "\n|COther topic suggestions:|n\n{}".format( + "\n ".join( + format_grid( + suggested, width=self.client_width(), line_prefix=self.index_topic_clr + ) + ) + ) + else: + suggested = "" + + end = start + + partorder = (start, title + aliases, help_text, subtopics, suggested, end) + + return "\n".join(part.rstrip() for part in partorder if part)
+ +
[docs] def format_help_index( + self, cmd_help_dict=None, db_help_dict=None, title_lone_category=False, click_topics=True + ): + """Output a category-ordered g for displaying the main help, grouped by + category. + + Args: + cmd_help_dict (dict): A dict `{"category": [topic, topic, ...]}` for + command-based help. + db_help_dict (dict): A dict `{"category": [topic, topic], ...]}` for + database-based help. + title_lone_category (bool, optional): If a lone category should + be titled with the category name or not. While pointless in a + general index, the title should probably show when explicitly + listing the category itself. + click_topics (bool, optional): If help-topics are clickable or not + (for webclient or telnet clients with MXP support). + Returns: + str: The help index organized into a grid. + + Notes: + The input are the pre-loaded help files for commands and database-helpfiles + respectively. You can override this method to return a custom display of the list of + commands and topics. + + """ + + def _group_by_category(help_dict): + grid = [] + verbatim_elements = [] + + if len(help_dict) == 1 and not title_lone_category: + # don't list categories if there is only one + for category in help_dict: + # gather and sort the entries from the help dictionary + entries = sorted(set(help_dict.get(category, []))) + + # make the help topics clickable + if click_topics: + entries = [f"|lchelp {entry}|lt{entry}|le" for entry in entries] + + # add the entries to the grid + grid.extend(entries) + else: + # list the categories + for category in sorted(set(list(help_dict.keys()))): + category_str = f"-- {category.title()} " + grid.append( + ANSIString( + self.index_category_clr + + category_str + + "-" * (width - len(category_str)) + + self.index_topic_clr + ) + ) + verbatim_elements.append(len(grid) - 1) + + # gather and sort the entries from the help dictionary + entries = sorted(set(help_dict.get(category, []))) + + # make the help topics clickable + if click_topics: + entries = [f"|lchelp {entry}|lt{entry}|le" for entry in entries] + + # add the entries to the grid + grid.extend(entries) + + return grid, verbatim_elements + + help_index = "" + width = self.client_width() + grid = [] + verbatim_elements = [] + cmd_grid, db_grid = "", "" + + if any(cmd_help_dict.values()): + # get the command-help entries by-category + sep1 = ( + self.index_type_separator_clr + + pad("Commands", width=width, fillchar="-") + + self.index_topic_clr + ) + grid, verbatim_elements = _group_by_category(cmd_help_dict) + gridrows = format_grid( + grid, + width, + sep=" ", + verbatim_elements=verbatim_elements, + line_prefix=self.index_topic_clr, + ) + cmd_grid = ANSIString("\n").join(gridrows) if gridrows else "" + + if any(db_help_dict.values()): + # get db-based help entries by-category + sep2 = ( + self.index_type_separator_clr + + pad("Game & World", width=width, fillchar="-") + + self.index_topic_clr + ) + grid, verbatim_elements = _group_by_category(db_help_dict) + gridrows = format_grid( + grid, + width, + sep=" ", + verbatim_elements=verbatim_elements, + line_prefix=self.index_topic_clr, + ) + db_grid = ANSIString("\n").join(gridrows) if gridrows else "" + + # only show the main separators if there are actually both cmd and db-based help + if cmd_grid and db_grid: + help_index = f"{sep1}\n{cmd_grid}\n{sep2}\n{db_grid}" + else: + help_index = f"{cmd_grid}{db_grid}" + + return help_index
+ +
[docs] def can_read_topic(self, cmd_or_topic, caller): + """ + Helper method. If this return True, the given help topic + be viewable in the help listing. Note that even if this returns False, + the entry will still be visible in the help index unless `should_list_topic` + is also returning False. + + Args: + cmd_or_topic (Command, HelpEntry or FileHelpEntry): The topic/command to test. + caller: the caller checking for access. + + Returns: + bool: If command can be viewed or not. + + Notes: + This uses the 'read' lock. If no 'read' lock is defined, the topic is assumed readable + by all. + + """ + if inherits_from(cmd_or_topic, "evennia.commands.command.Command"): + return cmd_or_topic.auto_help and cmd_or_topic.access(caller, "read", default=True) + else: + return cmd_or_topic.access(caller, "read", default=True)
+ +
[docs] def can_list_topic(self, cmd_or_topic, caller): + """ + Should the specified command appear in the help table? + + This method only checks whether a specified command should appear in the table of + topics/commands. The command can be used by the caller (see the 'should_show_help' method) + and the command will still be available, for instance, if a character type 'help name of the + command'. However, if you return False, the specified command will not appear in the table. + This is sometimes useful to "hide" commands in the table, but still access them through the + help system. + + Args: + cmd_or_topic (Command, HelpEntry or FileHelpEntry): The topic/command to test. + caller: the caller checking for access. + + Returns: + bool: If command should be listed or not. + + Notes: + The `.auto_help` propery is checked for commands. For all help entries, + the 'view' lock will be checked, and if no such lock is defined, the 'read' + lock will be used. If neither lock is defined, the help entry is assumed to be + accessible to all. + + """ + if hasattr(cmd_or_topic, "auto_help") and not cmd_or_topic.auto_help: + return False + + has_view = ( + "view:" in cmd_or_topic.locks + if inherits_from(cmd_or_topic, "evennia.commands.command.Command") + else cmd_or_topic.locks.get("view") + ) + + if has_view: + return cmd_or_topic.access(caller, "view", default=True) + else: + # no explicit 'view' lock - use the 'read' lock + return cmd_or_topic.access(caller, "read", default=True)
+ +
[docs] def collect_topics(self, caller, mode="list"): + """ + Collect help topics from all sources (cmd/db/file). + + Args: + caller (Object or Account): The user of the Command. + mode (str): One of 'list' or 'query', where the first means we are collecting to view + the help index and the second because of wanting to search for a specific help + entry/cmd to read. This determines which access should be checked. + + Returns: + tuple: A tuple of three dicts containing the different types of help entries + in the order cmd-help, db-help, file-help: + `({key: cmd,...}, {key: dbentry,...}, {key: fileentry,...}` + + """ + # start with cmd-help + cmdset = self.cmdset + # removing doublets in cmdset, caused by cmdhandler + # having to allow doublet commands to manage exits etc. + cmdset.make_unique(caller) + # retrieve all available commands and database / file-help topics. + # also check the 'cmd:' lock here + cmd_help_topics = [cmd for cmd in cmdset if cmd and cmd.access(caller, "cmd")] + # get all file-based help entries, checking perms + file_help_topics = {topic.key.lower().strip(): topic for topic in FILE_HELP_ENTRIES.all()} + # get db-based help entries, checking perms + db_help_topics = {topic.key.lower().strip(): topic for topic in HelpEntry.objects.all()} + if mode == "list": + # check the view lock for all help entries/commands and determine key + cmd_help_topics = { + cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key: cmd + for cmd in cmd_help_topics + if self.can_list_topic(cmd, caller) + } + db_help_topics = { + key: entry + for key, entry in db_help_topics.items() + if self.can_list_topic(entry, caller) + } + file_help_topics = { + key: entry + for key, entry in file_help_topics.items() + if self.can_list_topic(entry, caller) + } + else: + # query - check the read lock on entries + cmd_help_topics = { + cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key: cmd + for cmd in cmd_help_topics + if self.can_read_topic(cmd, caller) + } + db_help_topics = { + key: entry + for key, entry in db_help_topics.items() + if self.can_read_topic(entry, caller) + } + file_help_topics = { + key: entry + for key, entry in file_help_topics.items() + if self.can_read_topic(entry, caller) + } + + return cmd_help_topics, db_help_topics, file_help_topics
+ + + +
[docs] def parse(self): + """ + input is a string containing the command or topic to match. + + The allowed syntax is + :: + + help <topic>[/<subtopic>[/<subtopic>[/...]]] + + The database/command query is always for `<topic>`, and any subtopics + is then parsed from there. If a `<topic>` has spaces in it, it is + always matched before assuming the space begins a subtopic. + + """ + # parse the query + + if self.args: + self.subtopics = [ + part.strip().lower() for part in self.args.split(self.subtopic_separator_char) + ] + self.topic = self.subtopics.pop(0) + else: + self.topic = "" + self.subtopics = []
+ +
[docs] def strip_cmd_prefix(self, key, all_keys): + """ + Conditional strip of a command prefix, such as @ in @desc. By default + this will be hidden unless there is a duplicate without the prefix + in the full command set (such as @open and open). + + Args: + key (str): Command key to analyze. + all_cmds (list): All command-keys (and potentially aliases). + + Returns: + str: Potentially modified key to use in help display. + + """ + if key and key[0] in CMD_IGNORE_PREFIXES and key[1:] not in all_keys: + # filter out e.g. `@` prefixes from display if there is duplicate + # with the prefix in the set (such as @open/open) + return key[1:] + return key
+ +
[docs] def func(self): + """ + Run the dynamic help entry creator. + """ + caller = self.caller + query, subtopics, cmdset = self.topic, self.subtopics, self.cmdset + clickable_topics = self.clickable_topics + + if not query: + # list all available help entries, grouped by category. We want to + # build dictionaries {category: [topic, topic, ...], ...} + + cmd_help_topics, db_help_topics, file_help_topics = self.collect_topics( + caller, mode="list" + ) + + # db-topics override file-based ones + file_db_help_topics = {**file_help_topics, **db_help_topics} + + # group by category (cmds are listed separately) + cmd_help_by_category = defaultdict(list) + file_db_help_by_category = defaultdict(list) + + # get a collection of all keys + aliases to be able to strip prefixes like @ + key_and_aliases = set(chain(*(cmd._keyaliases for cmd in cmd_help_topics.values()))) + + for key, cmd in cmd_help_topics.items(): + key = self.strip_cmd_prefix(key, key_and_aliases) + cmd_help_by_category[cmd.help_category].append(key) + for key, entry in file_db_help_topics.items(): + file_db_help_by_category[entry.help_category].append(key) + + # generate the index and display + output = self.format_help_index( + cmd_help_by_category, file_db_help_by_category, click_topics=clickable_topics + ) + self.msg_help(output) + + return + + # search for a specific entry. We need to check for 'read' access here before + # building the set of possibilities. + cmd_help_topics, db_help_topics, file_help_topics = self.collect_topics( + caller, mode="query" + ) + + # get a collection of all keys + aliases to be able to strip prefixes like @ + key_and_aliases = set(chain(*(cmd._keyaliases for cmd in cmd_help_topics.values()))) + + # db-help topics takes priority over file-help + file_db_help_topics = {**file_help_topics, **db_help_topics} + + # commands take priority over the other types + all_topics = {**file_db_help_topics, **cmd_help_topics} + + # get all categories + all_categories = list( + set(HelpCategory(topic.help_category) for topic in all_topics.values()) + ) + + # all available help options - will be searched in order. We also check # the + # read-permission here. + entries = list(all_topics.values()) + all_categories + + # lunr search fields/boosts + match, suggestions = self.do_search(query, entries) + + if not match: + # no topic matches found. Only give suggestions. + help_text = f"There is no help topic matching '{query}'." + + if not suggestions: + # we don't even have a good suggestion. Run a second search, + # doing a full-text search in the actual texts of the help + # entries + + search_fields = [ + {"field_name": "text", "boost": 1}, + ] + + for match_query in [query, f"{query}*", f"*{query}"]: + _, suggestions = help_search_with_index( + match_query, + entries, + suggestion_maxnum=self.suggestion_maxnum, + fields=search_fields, + ) + if suggestions: + help_text += ( + "\n... But matches were found within the help " + "texts of the suggestions below." + ) + suggestions = [ + self.strip_cmd_prefix(sugg, key_and_aliases) for sugg in suggestions + ] + break + + output = self.format_help_entry( + topic=None, # this will give a no-match style title + help_text=help_text, + suggested=suggestions, + click_topics=clickable_topics, + ) + + self.msg_help(output) + return + + if isinstance(match, HelpCategory): + # no subtopics for categories - these are just lists of topics + category = match.key + category_lower = category.lower() + cmds_in_category = [ + key for key, cmd in cmd_help_topics.items() if category_lower == cmd.help_category + ] + topics_in_category = [ + key + for key, topic in file_db_help_topics.items() + if category_lower == topic.help_category + ] + output = self.format_help_index( + {category: cmds_in_category}, + {category: topics_in_category}, + title_lone_category=True, + click_topics=clickable_topics, + ) + self.msg_help(output) + return + + if inherits_from(match, "evennia.commands.command.Command"): + # a command match + topic = match.key + help_text = match.get_help(caller, cmdset) + aliases = match.aliases + suggested = suggestions[1:] + else: + # a database (or file-help) match + topic = match.key + help_text = match.entrytext + aliases = match.aliases if isinstance(match.aliases, list) else match.aliases.all() + suggested = suggestions[1:] + + # parse for subtopics. The subtopic_map is a dict with the current topic/subtopic + # text is stored under a `None` key and all other keys are subtopic titles pointing + # to nested dicts. + + subtopic_map = parse_entry_for_subcategories(help_text) + help_text = subtopic_map[None] + subtopic_index = [subtopic for subtopic in subtopic_map if subtopic is not None] + + if subtopics: + # if we asked for subtopics, parse the found topic_text to see if any match. + # the subtopics is a list describing the path through the subtopic_map. + + for subtopic_query in subtopics: + if subtopic_query not in subtopic_map: + # exact match failed. Try startswith-match + fuzzy_match = False + for key in subtopic_map: + if key and key.startswith(subtopic_query): + subtopic_query = key + fuzzy_match = True + break + + if not fuzzy_match: + # startswith failed - try an 'in' match + for key in subtopic_map: + if key and subtopic_query in key: + subtopic_query = key + fuzzy_match = True + break + + if not fuzzy_match: + # no match found - give up + checked_topic = topic + f"/{subtopic_query}" + output = self.format_help_entry( + topic=topic, + help_text=f"No help entry found for '{checked_topic}'", + subtopics=subtopic_index, + click_topics=clickable_topics, + ) + self.msg_help(output) + return + + # if we get here we have an exact or fuzzy match + + subtopic_map = subtopic_map.pop(subtopic_query) + subtopic_index = [subtopic for subtopic in subtopic_map if subtopic is not None] + # keep stepping down into the tree, append path to show position + topic = topic + f"/{subtopic_query}" + + # we reached the bottom of the topic tree + help_text = subtopic_map[None] + + topic = self.strip_cmd_prefix(topic, key_and_aliases) + if subtopics: + aliases = None + else: + aliases = [self.strip_cmd_prefix(alias, key_and_aliases) for alias in aliases] + suggested = [self.strip_cmd_prefix(sugg, key_and_aliases) for sugg in suggested] + + output = self.format_help_entry( + topic=topic, + help_text=help_text, + aliases=aliases, + subtopics=subtopic_index, + suggested=suggested, + click_topics=clickable_topics, + ) + + self.msg_help(output)
+ + +def _loadhelp(caller): + entry = caller.db._editing_help + if entry: + return entry.entrytext + else: + return "" + + +def _savehelp(caller, buffer): + entry = caller.db._editing_help + caller.msg("Saved help entry.") + if entry: + entry.entrytext = buffer + + +def _quithelp(caller): + caller.msg("Closing the editor.") + del caller.db._editing_help + + +
[docs]class CmdSetHelp(CmdHelp): + """ + Edit the help database. + + Usage: + sethelp[/switches] <topic>[[;alias;alias][,category[,locks]] [= <text>] + + Switches: + edit - open a line editor to edit the topic's help text. + replace - overwrite existing help topic. + append - add text to the end of existing topic with a newline between. + extend - as append, but don't add a newline. + delete - remove help topic. + + Examples: + sethelp lore = In the beginning was ... + sethelp/append pickpocketing,Thievery = This steals ... + sethelp/replace pickpocketing, ,attr(is_thief) = This steals ... + sethelp/edit thievery + + If not assigning a category, the `settings.DEFAULT_HELP_CATEGORY` category + will be used. If no lockstring is specified, everyone will be able to read + the help entry. Sub-topics are embedded in the help text. + + Note that this cannot modify command-help entries - these are modified + in-code, outside the game. + + # SUBTOPICS + + ## Adding subtopics + + Subtopics helps to break up a long help entry into sub-sections. Users can + access subtopics with |whelp topic/subtopic/...|n Subtopics are created and + stored together with the main topic. + + To start adding subtopics, add the text '# SUBTOPICS' on a new line at the + end of your help text. After this you can now add any number of subtopics, + each starting with '## <subtopic-name>' on a line, followed by the + help-text of that subtopic. + Use '### <subsub-name>' to add a sub-subtopic and so on. Max depth is 5. A + subtopic's title is case-insensitive and can consist of multiple words - + the user will be able to enter a partial match to access it. + + For example: + + | Main help text for <topic> + | + | # SUBTOPICS + | + | ## about + | + | Text for the '<topic>/about' subtopic' + | + | ### more about-info + | + | Text for the '<topic>/about/more about-info sub-subtopic + | + | ## extra + | + | Text for the '<topic>/extra' subtopic + + """ + + key = "sethelp" + aliases = [] + switch_options = ("edit", "replace", "append", "extend", "delete") + locks = "cmd:perm(Helper)" + help_category = "Building" + arg_regex = None + +
[docs] def parse(self): + """We want to use the default parser rather than the CmdHelp.parse""" + return COMMAND_DEFAULT_CLASS.parse(self)
+ +
[docs] def func(self): + """Implement the function""" + + switches = self.switches + lhslist = self.lhslist + + if not self.args: + self.msg( + "Usage: sethelp[/switches] <topic>[;alias;alias][,category[,locks,..] = <text>" + ) + return + + nlist = len(lhslist) + topicstr = lhslist[0] if nlist > 0 else "" + if not topicstr: + self.msg("You have to define a topic!") + return + topicstrlist = topicstr.split(";") + topicstr, aliases = ( + topicstrlist[0], + topicstrlist[1:] if len(topicstr) > 1 else [], + ) + aliastxt = ("(aliases: %s)" % ", ".join(aliases)) if aliases else "" + old_entry = None + + # check if we have an old entry with the same name + + cmd_help_topics, db_help_topics, file_help_topics = self.collect_topics( + self.caller, mode="query" + ) + # db-help topics takes priority over file-help + file_db_help_topics = {**file_help_topics, **db_help_topics} + # commands take priority over the other types + all_topics = {**file_db_help_topics, **cmd_help_topics} + # get all categories + all_categories = list( + set(HelpCategory(topic.help_category) for topic in all_topics.values()) + ) + # all available help options - will be searched in order. We also check # the + # read-permission here. + entries = list(all_topics.values()) + all_categories + + # default setup + category = lhslist[1] if nlist > 1 else DEFAULT_HELP_CATEGORY + lockstring = ",".join(lhslist[2:]) if nlist > 2 else "read:all()" + + # search for existing entries of this or other types + old_entry = None + for querystr in topicstrlist: + match, _ = self.do_search(querystr, entries) + if match: + warning = None + if isinstance(match, HelpCategory): + warning = ( + f"'{querystr}' matches (or partially matches) the name of " + f"help-category '{match.key}'. If you continue, your help entry will " + "take precedence and the category (or part of its name) *may* not " + "be usable for grouping help entries anymore." + ) + elif inherits_from(match, "evennia.commands.command.Command"): + warning = ( + f"'{querystr}' matches (or partially matches) the key/alias of " + f"Command '{match.key}'. Command-help take precedence over other " + "help entries so your help *may* be impossible to reach for those " + "with access to that command." + ) + elif inherits_from(match, "evennia.help.filehelp.FileHelpEntry"): + warning = ( + f"'{querystr}' matches (or partially matches) the name/alias of the " + f"file-based help topic '{match.key}'. File-help entries cannot be " + "modified from in-game (they are files on-disk). If you continue, " + "your help entry may shadow the file-based one's name partly or " + "completely." + ) + if warning: + # show a warning for a clashing help-entry type. Even if user accepts this + # we don't break here since we may need to show warnings for other inputs. + # We don't count this as an old-entry hit because we can't edit these + # types of entries. + self.msg(f"|rWarning:\n|r{warning}|n") + repl = yield ("|wDo you still want to continue? Y/[N]?|n") + if repl.lower() not in ("y", "yes"): + self.msg("Aborted.") + return + else: + # a db-based help entry - this is OK + old_entry = match + category = lhslist[1] if nlist > 1 else old_entry.help_category + lockstring = ",".join(lhslist[2:]) if nlist > 2 else old_entry.locks.get() + break + + category = category.lower() + + if "edit" in switches: + # open the line editor to edit the helptext. No = is needed. + if old_entry: + topicstr = old_entry.key + if self.rhs: + # we assume append here. + old_entry.entrytext += "\n%s" % self.rhs + helpentry = old_entry + else: + helpentry = create.create_help_entry( + topicstr, + self.rhs, + category=category, + locks=lockstring, + aliases=aliases, + ) + self.caller.db._editing_help = helpentry + + EvEditor( + self.caller, + loadfunc=_loadhelp, + savefunc=_savehelp, + quitfunc=_quithelp, + key="topic {}".format(topicstr), + persistent=True, + ) + return + + if "append" in switches or "merge" in switches or "extend" in switches: + # merge/append operations + if not old_entry: + self.msg(f"Could not find topic '{topicstr}'. You must give an exact name.") + return + if not self.rhs: + self.msg("You must supply text to append/merge.") + return + if "merge" in switches: + old_entry.entrytext += " " + self.rhs + else: + old_entry.entrytext += "\n%s" % self.rhs + old_entry.aliases.add(aliases) + self.msg(f"Entry updated:\n{old_entry.entrytext}{aliastxt}") + return + + if "delete" in switches or "del" in switches: + # delete the help entry + if not old_entry: + self.msg(f"Could not find topic '{topicstr}'{aliastxt}.") + return + old_entry.delete() + self.msg(f"Deleted help entry '{topicstr}'{aliastxt}.") + return + + # at this point it means we want to add a new help entry. + if not self.rhs: + self.msg("You must supply a help text to add.") + return + if old_entry: + if "replace" in switches: + # overwrite old entry + old_entry.key = topicstr + old_entry.entrytext = self.rhs + old_entry.help_category = category + old_entry.locks.clear() + old_entry.locks.add(lockstring) + old_entry.aliases.add(aliases) + old_entry.save() + self.msg(f"Overwrote the old topic '{topicstr}'{aliastxt}.") + else: + self.msg( + f"Topic '{topicstr}'{aliastxt} already exists. Use /edit to open in editor, or " + "/replace, /append and /merge to modify it directly." + ) + else: + # no old entry. Create a new one. + new_entry = create.create_help_entry( + topicstr, self.rhs, category=category, locks=lockstring, aliases=aliases + ) + if new_entry: + self.msg(f"Topic '{topicstr}'{aliastxt} was successfully created.") + if "edit" in switches: + # open the line editor to edit the helptext + self.caller.db._editing_help = new_entry + EvEditor( + self.caller, + loadfunc=_loadhelp, + savefunc=_savehelp, + quitfunc=_quithelp, + key="topic {}".format(new_entry.key), + persistent=True, + ) + return + else: + self.msg(f"Error when creating topic '{topicstr}'{aliastxt}! Contact an admin.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/muxcommand.html b/docs/latest/_modules/evennia/commands/default/muxcommand.html new file mode 100644 index 0000000000..e076769a19 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/muxcommand.html @@ -0,0 +1,375 @@ + + + + + + + + evennia.commands.default.muxcommand — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.muxcommand

+"""
+The command template for the default MUX-style command set. There
+is also an Account/OOC version that makes sure caller is an Account object.
+"""
+
+from evennia.commands.command import Command
+from evennia.utils import utils
+
+# limit symbol import for API
+__all__ = ("MuxCommand", "MuxAccountCommand")
+
+
+
[docs]class MuxCommand(Command): + """ + This sets up the basis for a MUX command. The idea + is that most other Mux-related commands should just + inherit from this and don't have to implement much + parsing of their own unless they do something particularly + advanced. + + Note that the class's __doc__ string (this text) is + used by Evennia to create the automatic help entry for + the command, so make sure to document consistently here. + """ + +
[docs] def has_perm(self, srcobj): + """ + This is called by the cmdhandler to determine + if srcobj is allowed to execute this command. + We just show it here for completeness - we + are satisfied using the default check in Command. + """ + return super().has_perm(srcobj)
+ +
[docs] def at_pre_cmd(self): + """ + This hook is called before self.parse() on all commands + """ + pass
+ +
[docs] def at_post_cmd(self): + """ + This hook is called after the command has finished executing + (after self.func()). + """ + pass
+ +
[docs] def parse(self): + """ + This method is called by the cmdhandler once the command name + has been identified. It creates a new set of member variables + that can be later accessed from self.func() (see below) + + The following variables are available for our use when entering this + method (from the command definition, and assigned on the fly by the + cmdhandler): + self.key - the name of this command ('look') + self.aliases - the aliases of this cmd ('l') + self.permissions - permission string for this command + self.help_category - overall category of command + + self.caller - the object calling this command + self.cmdstring - the actual command name used to call this + (this allows you to know which alias was used, + for example) + self.args - the raw input; everything following self.cmdstring. + self.cmdset - the cmdset from which this command was picked. Not + often used (useful for commands like 'help' or to + list all available commands etc) + self.obj - the object on which this command was defined. It is often + the same as self.caller. + + A MUX command has the following possible syntax: + + name[ with several words][/switch[/switch..]] arg1[,arg2,...] [[=|,] arg[,..]] + + The 'name[ with several words]' part is already dealt with by the + cmdhandler at this point, and stored in self.cmdname (we don't use + it here). The rest of the command is stored in self.args, which can + start with the switch indicator /. + + Optional variables to aid in parsing, if set: + self.switch_options - (tuple of valid /switches expected by this + command (without the /)) + self.rhs_split - Alternate string delimiter or tuple of strings + to separate left/right hand sides. tuple form + gives priority split to first string delimiter. + + This parser breaks self.args into its constituents and stores them in the + following variables: + self.switches = [list of /switches (without the /)] + self.raw = This is the raw argument input, including switches + self.args = This is re-defined to be everything *except* the switches + self.lhs = Everything to the left of = (lhs:'left-hand side'). If + no = is found, this is identical to self.args. + self.rhs: Everything to the right of = (rhs:'right-hand side'). + If no '=' is found, this is None. + self.lhslist - [self.lhs split into a list by comma] + self.rhslist - [list of self.rhs split into a list by comma] + self.arglist = [list of space-separated args (stripped, including '=' if it exists)] + + All args and list members are stripped of excess whitespace around the + strings, but case is preserved. + """ + raw = self.args + args = raw.strip() + # Without explicitly setting these attributes, they assume default values: + if not hasattr(self, "switch_options"): + self.switch_options = None + if not hasattr(self, "rhs_split"): + self.rhs_split = "=" + if not hasattr(self, "account_caller"): + self.account_caller = False + + # split out switches + switches, delimiters = [], self.rhs_split + if self.switch_options: + self.switch_options = [opt.lower() for opt in self.switch_options] + if args and len(args) > 1 and raw[0] == "/": + # we have a switch, or a set of switches. These end with a space. + switches = args[1:].split(None, 1) + if len(switches) > 1: + switches, args = switches + switches = switches.split("/") + else: + args = "" + switches = switches[0].split("/") + # If user-provides switches, parse them with parser switch options. + if switches and self.switch_options: + valid_switches, unused_switches, extra_switches = [], [], [] + for element in switches: + option_check = [opt for opt in self.switch_options if opt == element] + if not option_check: + option_check = [ + opt for opt in self.switch_options if opt.startswith(element) + ] + match_count = len(option_check) + if match_count > 1: + extra_switches.extend( + option_check + ) # Either the option provided is ambiguous, + elif match_count == 1: + valid_switches.extend(option_check) # or it is a valid option abbreviation, + elif match_count == 0: + unused_switches.append(element) # or an extraneous option to be ignored. + if extra_switches: # User provided switches + self.msg( + "|g%s|n: |wAmbiguous switch supplied: Did you mean /|C%s|w?" + % (self.cmdstring, " |nor /|C".join(extra_switches)) + ) + if unused_switches: + plural = "" if len(unused_switches) == 1 else "es" + self.msg( + '|g%s|n: |wExtra switch%s "/|C%s|w" ignored.' + % (self.cmdstring, plural, "|n, /|C".join(unused_switches)) + ) + switches = valid_switches # Only include valid_switches in command function call + arglist = [arg.strip() for arg in args.split()] + + # check for arg1, arg2, ... = argA, argB, ... constructs + lhs, rhs = args.strip(), None + if lhs: + if delimiters and hasattr(delimiters, "__iter__"): # If delimiter is iterable, + best_split = delimiters[0] # (default to first delimiter) + for this_split in delimiters: # try each delimiter + if this_split in lhs: # to find first successful split + best_split = this_split # to be the best split. + break + else: + best_split = delimiters + # Parse to separate left into left/right sides using best_split delimiter string + if best_split in lhs: + lhs, rhs = lhs.split(best_split, 1) + # Trim user-injected whitespace + rhs = rhs.strip() if rhs is not None else None + lhs = lhs.strip() + # Further split left/right sides by comma delimiter + lhslist = [arg.strip() for arg in lhs.split(",")] if lhs is not None else [] + rhslist = [arg.strip() for arg in rhs.split(",")] if rhs is not None else [] + # save to object properties: + self.raw = raw + self.switches = switches + self.args = args.strip() + self.arglist = arglist + self.lhs = lhs + self.lhslist = lhslist + self.rhs = rhs + self.rhslist = rhslist + + # if the class has the account_caller property set on itself, we make + # sure that self.caller is always the account if possible. We also create + # a special property "character" for the puppeted object, if any. This + # is convenient for commands defined on the Account only. + if self.account_caller: + if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"): + # caller is an Object/Character + self.character = self.caller + self.caller = self.caller.account + elif utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount"): + # caller was already an Account + self.character = self.caller.get_puppet(self.session) + else: + self.character = None
+ +
[docs] def get_command_info(self): + """ + Update of parent class's get_command_info() for MuxCommand. + """ + variables = "\n".join( + " |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items() + ) + string = f""" +Command {self} has no defined `func()` - showing on-command variables: No child func() defined for {self} - available variables: +{variables} + """ + self.msg(string) + # a simple test command to show the available properties + string = "-" * 50 + string += f"\n|w{self.key}|n - Command variables from evennia:\n" + string += "-" * 50 + string += f"\nname of cmd (self.key): |w{self.key}|n\n" + string += f"cmd aliases (self.aliases): |w{self.aliases}|n\n" + string += f"cmd locks (self.locks): |w{self.locks}|n\n" + string += f"help category (self.help_category): |w{self.help_category}|n\n" + string += f"object calling (self.caller): |w{self.caller}|n\n" + string += f"object storing cmdset (self.obj): |w{self.obj}|n\n" + string += f"command string given (self.cmdstring): |w{self.cmdstring}|n\n" + # show cmdset.key instead of cmdset to shorten output + string += utils.fill(f"current cmdset (self.cmdset): |w{self.cmdset}|n\n") + string += "\n" + "-" * 50 + string += "\nVariables from MuxCommand baseclass\n" + string += "-" * 50 + string += f"\nraw argument (self.raw): |w{self.raw}|n \n" + string += f"cmd args (self.args): |w{self.args}|n\n" + string += f"cmd switches (self.switches): |w{self.switches}|n\n" + string += f"cmd options (self.switch_options): |w{self.switch_options}|n\n" + string += f"cmd parse left/right using (self.rhs_split): |w{self.rhs_split}|n\n" + string += f"space-separated arg list (self.arglist): |w{self.arglist}|n\n" + string += f"lhs, left-hand side of '=' (self.lhs): |w{self.lhs}|n\n" + string += f"lhs, comma separated (self.lhslist): |w{self.lhslist}|n\n" + string += f"rhs, right-hand side of '=' (self.rhs): |w{self.rhs}|n\n" + string += f"rhs, comma separated (self.rhslist): |w{self.rhslist}|n\n" + string += "-" * 50 + self.msg(string)
+ +
[docs] def func(self): + """ + This is the hook function that actually does all the work. It is called + by the cmdhandler right after self.parser() finishes, and so has access + to all the variables defined therein. + """ + self.get_command_info()
+ + +
[docs]class MuxAccountCommand(MuxCommand): + """ + This is an on-Account version of the MuxCommand. Since these commands sit + on Accounts rather than on Characters/Objects, we need to check + this in the parser. + + Account commands are available also when puppeting a Character, it's + just that they are applied with a lower priority and are always + available, also when disconnected from a character (i.e. "ooc"). + + This class makes sure that caller is always an Account object, while + creating a new property "character" that is set only if a + character is actually attached to this Account and Session. + """ + + account_caller = True # Using MuxAccountCommand explicitly defaults the caller to an account
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/syscommands.html b/docs/latest/_modules/evennia/commands/default/syscommands.html new file mode 100644 index 0000000000..0a4e490de1 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/syscommands.html @@ -0,0 +1,206 @@ + + + + + + + + evennia.commands.default.syscommands — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.syscommands

+"""
+System commands
+
+These are the default commands called by the system commandhandler
+when various exceptions occur. If one of these commands are not
+implemented and part of the current cmdset, the engine falls back
+to a default solution instead.
+
+Some system commands are shown in this module
+as a REFERENCE only (they are not all added to Evennia's
+default cmdset since they don't currently do anything differently from the
+default backup systems hard-wired in the engine).
+
+Overloading these commands in a cmdset can be used to create
+interesting effects. An example is using the NoMatch system command
+to implement a line-editor where you don't have to start each
+line with a command (if there is no match to a known command,
+the line is just added to the editor buffer).
+"""
+
+from django.conf import settings
+
+# The command keys the engine is calling
+# (the actual names all start with __)
+from evennia.commands.cmdhandler import CMD_MULTIMATCH, CMD_NOINPUT, CMD_NOMATCH
+from evennia.comms.models import ChannelDB
+from evennia.utils import create, utils
+from evennia.utils.utils import at_search_result
+
+COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# Command called when there is no input at line
+# (i.e. an lone return key)
+
+
+
[docs]class SystemNoInput(COMMAND_DEFAULT_CLASS): + """ + This is called when there is no input given + """ + + key = CMD_NOINPUT + locks = "cmd:all()" + +
[docs] def func(self): + "Do nothing." + pass
+ + +# +# Command called when there was no match to the +# command name +# +
[docs]class SystemNoMatch(COMMAND_DEFAULT_CLASS): + """ + No command was found matching the given input. + """ + + key = CMD_NOMATCH + locks = "cmd:all()" + +
[docs] def func(self): + """ + This is given the failed raw string as input. + """ + self.msg("Huh?")
+ + +# +# Command called when there were multiple matches to the command. +# +
[docs]class SystemMultimatch(COMMAND_DEFAULT_CLASS): + """ + Multiple command matches. + + The cmdhandler adds a special attribute 'matches' to this + system command. + + matches = [(cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname) , (cmdname, ...), ...] + + Here, `cmdname` is the command's name and `args` the rest of the incoming string, + without said command name. `cmdobj` is the Command instance, the cmdlen is + the same as len(cmdname) and mratio is a measure of how big a part of the + full input string the cmdname takes up - an exact match would be 1.0. Finally, + the `raw_cmdname` is the cmdname unmodified by eventual prefix-stripping. + + """ + + key = CMD_MULTIMATCH + locks = "cmd:all()" + +
[docs] def func(self): + """ + Handle multiple-matches by using the at_search_result default handler. + + """ + # this was set by the cmdparser and is a tuple + # (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname). See + # evennia.commands.cmdparse.create_match for more details. + matches = self.matches + # at_search_result will itself msg the multimatch options to the caller. + at_search_result([match[2] for match in matches], self.caller, query=matches[0][0])
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/system.html b/docs/latest/_modules/evennia/commands/default/system.html new file mode 100644 index 0000000000..6f8e4b9efa --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/system.html @@ -0,0 +1,1292 @@ + + + + + + + + evennia.commands.default.system — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.system

+"""
+
+System commands
+
+"""
+
+
+import code
+import datetime
+import os
+import sys
+import time
+import traceback
+
+import django
+import twisted
+from django.conf import settings
+
+import evennia
+from evennia.accounts.models import AccountDB
+from evennia.scripts.taskhandler import TaskHandlerTask
+from evennia.utils import gametime, logger, search, utils
+from evennia.utils.eveditor import EvEditor
+from evennia.utils.evmenu import ask_yes_no
+from evennia.utils.evtable import EvTable
+from evennia.utils.utils import class_from_module, iter_to_str
+
+COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
+_TASK_HANDLER = None
+_BROADCAST_SERVER_RESTART_MESSAGES = settings.BROADCAST_SERVER_RESTART_MESSAGES
+
+# delayed imports
+_RESOURCE = None
+_IDMAPPER = None
+
+# limit symbol import for API
+__all__ = (
+    "CmdAccounts",
+    "CmdReload",
+    "CmdReset",
+    "CmdShutdown",
+    "CmdPy",
+    "CmdService",
+    "CmdAbout",
+    "CmdTime",
+    "CmdServerLoad",
+    "CmdTasks",
+    "CmdTickers",
+)
+
+
+
[docs]class CmdReload(COMMAND_DEFAULT_CLASS): + """ + reload the server + + Usage: + reload [reason] + + This restarts the server. The Portal is not + affected. Non-persistent scripts will survive a reload (use + reset to purge) and at_reload() hooks will be called. + """ + + key = "@reload" + aliases = ["@restart"] + locks = "cmd:perm(reload) or perm(Developer)" + help_category = "System" + +
[docs] def func(self): + """ + Reload the system. + """ + reason = "" + if self.args: + reason = "(Reason: %s) " % self.args.rstrip(".") + if _BROADCAST_SERVER_RESTART_MESSAGES: + evennia.SESSION_HANDLER.announce_all(f" Server restart initiated {reason}...") + evennia.SESSION_HANDLER.portal_restart_server()
+ + +
[docs]class CmdReset(COMMAND_DEFAULT_CLASS): + """ + reset and reboot the server + + Usage: + reset + + Notes: + For normal updating you are recommended to use reload rather + than this command. Use shutdown for a complete stop of + everything. + + This emulates a cold reboot of the Server component of Evennia. + The difference to shutdown is that the Server will auto-reboot + and that it does not affect the Portal, so no users will be + disconnected. Contrary to reload however, all shutdown hooks will + be called and any non-database saved scripts, ndb-attributes, + cmdsets etc will be wiped. + + """ + + key = "@reset" + aliases = ["@reboot"] + locks = "cmd:perm(reload) or perm(Developer)" + help_category = "System" + +
[docs] def func(self): + """ + Reload the system. + """ + evennia.SESSION_HANDLER.announce_all(" Server resetting/restarting ...") + evennia.SESSION_HANDLER.portal_reset_server()
+ + +
[docs]class CmdShutdown(COMMAND_DEFAULT_CLASS): + + """ + stop the server completely + + Usage: + shutdown [announcement] + + Gracefully shut down both Server and Portal. + """ + + key = "@shutdown" + locks = "cmd:perm(shutdown) or perm(Developer)" + help_category = "System" + +
[docs] def func(self): + """Define function""" + # Only allow shutdown if caller has session + if not self.caller.sessions.get(): + return + self.msg("Shutting down server ...") + announcement = "\nServer is being SHUT DOWN!\n" + if self.args: + announcement += "%s\n" % self.args + logger.log_info(f"Server shutdown by {self.caller.name}.") + evennia.SESSION_HANDLER.announce_all(announcement) + evennia.SESSION_HANDLER.portal_shutdown()
+ + +def _py_load(caller): + return "" + + +def _py_code(caller, buf): + """ + Execute the buffer. + """ + measure_time = caller.db._py_measure_time + client_raw = caller.db._py_clientraw + string = "Executing code%s ..." % (" (measure timing)" if measure_time else "") + caller.msg(string) + _run_code_snippet( + caller, buf, mode="exec", measure_time=measure_time, client_raw=client_raw, show_input=False + ) + return True + + +def _py_quit(caller): + del caller.db._py_measure_time + caller.msg("Exited the code editor.") + + +def _run_code_snippet( + caller, pycode, mode="eval", measure_time=False, client_raw=False, show_input=True +): + """ + Run code and try to display information to the caller. + + Args: + caller (Object): The caller. + pycode (str): The Python code to run. + measure_time (bool, optional): Should we measure the time of execution? + client_raw (bool, optional): Should we turn off all client-specific escaping? + show_input (bookl, optional): Should we display the input? + + """ + # Try to retrieve the session + if hasattr(caller, "sessions"): + sessions = caller.sessions.all() + + available_vars = evennia_local_vars(caller) + + if show_input: + for session in sessions: + data = { + # TODO: 'highlight' is not used yet + "text": (f">>> {pycode}", {"type": "py_input"}), + "options": {"raw": True, "highlight": True}, + } + try: + caller.msg(session=session, **data) + except TypeError: + caller.msg(**data) + + try: + # reroute standard output to game client console + old_stdout = sys.stdout + old_stderr = sys.stderr + + class FakeStd: + def __init__(self, caller): + self.caller = caller + + def write(self, string): + self.caller.msg(text=(string.rstrip("\n"), {"type": "py_output"})) + + fake_std = FakeStd(caller) + sys.stdout = fake_std + sys.stderr = fake_std + + try: + pycode_compiled = compile(pycode, "", mode) + except Exception: + mode = "exec" + pycode_compiled = compile(pycode, "", mode) + + duration = "" + if measure_time: + t0 = time.time() + ret = eval(pycode_compiled, {}, available_vars) + t1 = time.time() + duration = f" (runtime ~ {(t1 - t0) * 1000:.4f} ms)" + caller.msg(duration) + else: + ret = eval(pycode_compiled, {}, available_vars) + + except Exception: + errlist = traceback.format_exc().split("\n") + if len(errlist) > 4: + errlist = errlist[4:] + ret = "\n".join("%s" % line for line in errlist if line) + finally: + # return to old stdout + sys.stdout = old_stdout + sys.stderr = old_stderr + + if ret is None: + return + + if not client_raw: + ret = str(ret) + + if isinstance(ret, tuple): + # we must convert here to allow msg to pass it (a tuple is confused + # with a outputfunc structure) + ret = str(ret) + + for session in sessions: + try: + caller.msg( + (ret, {"type": "py_output"}), + session=session, + options={"raw": True, "client_raw": client_raw, "highlight": True}, + ) + except TypeError: + caller.msg( + (ret, {"type": "py_output"}), + options={"raw": True, "client_raw": client_raw, "highlight": True}, + ) + + +def evennia_local_vars(caller): + """Return Evennia local variables usable in the py command as a dictionary.""" + import evennia + + return { + "self": caller, + "me": caller, + "here": getattr(caller, "location", None), + "evennia": evennia, + "ev": evennia, + "inherits_from": utils.inherits_from, + } + + +class EvenniaPythonConsole(code.InteractiveConsole): + + """Evennia wrapper around a Python interactive console.""" + + def __init__(self, caller): + super().__init__(evennia_local_vars(caller)) + self.caller = caller + + def write(self, string): + """Don't send to stderr, send to self.caller.""" + self.caller.msg(string) + + def push(self, line): + """Push some code, whether complete or not.""" + old_stdout = sys.stdout + old_stderr = sys.stderr + + class FakeStd: + def __init__(self, caller): + self.caller = caller + + def write(self, string): + self.caller.msg(string.split("\n", 1)[0]) + + fake_std = FakeStd(self.caller) + sys.stdout = fake_std + sys.stderr = fake_std + result = None + try: + result = super().push(line) + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + return result + + +
[docs]class CmdPy(COMMAND_DEFAULT_CLASS): + """ + execute a snippet of python code + + Usage: + py [cmd] + py/edit + py/time <cmd> + py/clientraw <cmd> + py/noecho + + Switches: + time - output an approximate execution time for <cmd> + edit - open a code editor for multi-line code experimentation + clientraw - turn off all client-specific escaping. Note that this may + lead to different output depending on prototocol (such as angular brackets + being parsed as HTML in the webclient but not in telnet clients) + noecho - in Python console mode, turn off the input echo (e.g. if your client + does this for you already) + + Without argument, open a Python console in-game. This is a full console, + accepting multi-line Python code for testing and debugging. Type `exit()` to + return to the game. If Evennia is reloaded, the console will be closed. + + Enter a line of instruction after the 'py' command to execute it + immediately. Separate multiple commands by ';' or open the code editor + using the /edit switch (all lines added in editor will be executed + immediately when closing or using the execute command in the editor). + + A few variables are made available for convenience in order to offer access + to the system (you can import more at execution time). + + Available variables in py environment: + self, me : caller + here : caller.location + evennia : the evennia API + inherits_from(obj, parent) : check object inheritance + + You can explore The evennia API from inside the game by calling + the `__doc__` property on entities: + py evennia.__doc__ + py evennia.managers.__doc__ + + |rNote: In the wrong hands this command is a severe security risk. It + should only be accessible by trusted server admins/superusers.|n + + """ + + key = "@py" + aliases = ["@!"] + switch_options = ("time", "edit", "clientraw", "noecho") + locks = "cmd:perm(py) or perm(Developer)" + help_category = "System" + arg_regex = "" + +
[docs] def func(self): + """hook function""" + + caller = self.caller + pycode = self.args + + noecho = "noecho" in self.switches + + if "edit" in self.switches: + caller.db._py_measure_time = "time" in self.switches + caller.db._py_clientraw = "clientraw" in self.switches + EvEditor( + self.caller, + loadfunc=_py_load, + savefunc=_py_code, + quitfunc=_py_quit, + key="Python exec: :w or :!", + persistent=True, + codefunc=_py_code, + ) + return + + if not pycode: + # Run in interactive mode + console = EvenniaPythonConsole(self.caller) + banner = ( + "|gEvennia Interactive Python mode{echomode}\n" + "Python {version} on {platform}".format( + echomode=" (no echoing of prompts)" if noecho else "", + version=sys.version, + platform=sys.platform, + ) + ) + self.msg(banner) + line = "" + main_prompt = "|x[py mode - quit() to exit]|n" + prompt = main_prompt + while line.lower() not in ("exit", "exit()"): + try: + line = yield (prompt) + if noecho: + prompt = "..." if console.push(line) else main_prompt + else: + if line: + self.caller.msg(f">>> {line}") + prompt = line if console.push(line) else main_prompt + except SystemExit: + break + self.msg("|gClosing the Python console.|n") + return + + _run_code_snippet( + caller, + self.args, + measure_time="time" in self.switches, + client_raw="clientraw" in self.switches, + )
+ + +
[docs]class CmdAccounts(COMMAND_DEFAULT_CLASS): + """ + Manage registered accounts + + Usage: + accounts [nr] + accounts/delete <name or #id> [: reason] + + Switches: + delete - delete an account from the server + + By default, lists statistics about the Accounts registered with the game. + It will list the <nr> amount of latest registered accounts + If not given, <nr> defaults to 10. + """ + + key = "@accounts" + aliases = ["@account"] + switch_options = ("delete",) + locks = "cmd:perm(listaccounts) or perm(Admin)" + help_category = "System" + +
[docs] def func(self): + """List the accounts""" + + caller = self.caller + args = self.args + + if "delete" in self.switches: + account = getattr(caller, "account") + if not account or not account.check_permstring("Developer"): + caller.msg("You are not allowed to delete accounts.") + return + if not args: + caller.msg("Usage: accounts/delete <name or #id> [: reason]") + return + reason = "" + if ":" in args: + args, reason = [arg.strip() for arg in args.split(":", 1)] + # We use account_search since we want to be sure to find also accounts + # that lack characters. + accounts = search.account_search(args) + if not accounts: + self.msg("Could not find an account by that name.") + return + if len(accounts) > 1: + string = "There were multiple matches:\n" + string += "\n".join(" %s %s" % (account.id, account.key) for account in accounts) + self.msg(string) + return + account = accounts.first() + if not account.access(caller, "delete"): + self.msg("You don't have the permissions to delete that account.") + return + username = account.username + # ask for confirmation + confirm = ( + "It is often better to block access to an account rather than to delete it. " + "|yAre you sure you want to permanently delete " + "account '|n{}|y'|n yes/[no]?".format(username) + ) + answer = yield (confirm) + if answer.lower() not in ("y", "yes"): + caller.msg("Canceled deletion.") + return + + # Boot the account then delete it. + self.msg("Informing and disconnecting account ...") + string = f"\nYour account '{username}' is being *permanently* deleted.\n" + if reason: + string += " Reason given:\n '%s'" % reason + account.msg(string) + logger.log_sec( + f"Account Deleted: {account} (Reason: {reason}, Caller: {caller}, IP:" + f" {self.session.address})." + ) + account.delete() + self.msg("Account %s was successfully deleted." % username) + return + + # No switches, default to displaying a list of accounts. + if self.args and self.args.isdigit(): + nlim = int(self.args) + else: + nlim = 10 + + naccounts = AccountDB.objects.count() + + # typeclass table + dbtotals = AccountDB.objects.object_totals() + typetable = self.styled_table( + "|wtypeclass|n", "|wcount|n", "|w%%|n", border="cells", align="l" + ) + for path, count in dbtotals.items(): + typetable.add_row(path, count, "%.2f" % ((float(count) / naccounts) * 100)) + # last N table + plyrs = AccountDB.objects.all().order_by("db_date_created")[max(0, naccounts - nlim) :] + latesttable = self.styled_table( + "|wcreated|n", "|wdbref|n", "|wname|n", "|wtypeclass|n", border="cells", align="l" + ) + for ply in plyrs: + latesttable.add_row( + utils.datetime_format(ply.date_created), ply.dbref, ply.key, ply.path + ) + + string = f"\n|wAccount typeclass distribution:|n\n{typetable}" + string += f"\n|wLast {min(naccounts, nlim)} Accounts created:|n\n{latesttable}" + caller.msg(string)
+ + +
[docs]class CmdService(COMMAND_DEFAULT_CLASS): + """ + manage system services + + Usage: + service[/switch] <service> + + Switches: + list - shows all available services (default) + start - activates or reactivate a service + stop - stops/inactivate a service (can often be restarted) + delete - tries to permanently remove a service + + Service management system. Allows for the listing, + starting, and stopping of services. If no switches + are given, services will be listed. Note that to operate on the + service you have to supply the full (green or red) name as given + in the list. + """ + + key = "@service" + aliases = ["@services"] + switch_options = ("list", "start", "stop", "delete") + locks = "cmd:perm(service) or perm(Developer)" + help_category = "System" + +
[docs] def func(self): + """Implement command""" + + caller = self.caller + switches = self.switches + + if switches and switches[0] not in ("list", "start", "stop", "delete"): + caller.msg("Usage: service/<list|start|stop|delete> [servicename]") + return + + # get all services + service_collection = evennia.SESSION_HANDLER.server.services + + if not switches or switches[0] == "list": + # Just display the list of installed services and their + # status, then exit. + table = self.styled_table( + "|wService|n (use services/start|stop|delete)", "|wstatus", align="l" + ) + for service in service_collection.services: + table.add_row(service.name, service.running and "|gRunning" or "|rNot Running") + caller.msg(str(table)) + return + + # Get the service to start / stop + + try: + service = service_collection.getServiceNamed(self.args) + except Exception: + string = "Invalid service name. This command is case-sensitive. " + string += "See service/list for valid service name (enter the full name exactly)." + caller.msg(string) + return + + if switches[0] in ("stop", "delete"): + # Stopping/killing a service gracefully closes it and disconnects + # any connections (if applicable). + + delmode = switches[0] == "delete" + if not service.running: + caller.msg("That service is not currently running.") + return + if service.name[:7] == "Evennia": + if delmode: + caller.msg("You cannot remove a core Evennia service (named 'Evennia*').") + return + string = ( + "|RYou seem to be shutting down a core Evennia " + "service (named 'Evennia*').\nNote that stopping " + "some TCP port services will *not* disconnect users " + "*already* connected on those ports, but *may* " + "instead cause spurious errors for them.\nTo safely " + "and permanently remove ports, change settings file " + "and restart the server.|n\n" + ) + caller.msg(string) + + if delmode: + service.stopService() + service_collection.removeService(service) + caller.msg(f"|gStopped and removed service '{self.args}'.|n") + else: + caller.msg(f"Stopping service '{self.args}'...") + try: + service.stopService() + except Exception as err: + caller.msg( + f"|rErrors were reported when stopping this service{err}.\n" + "If there are remaining problems, try reloading " + "or rebooting the server." + ) + caller.msg(f"|g... Stopped service '{self.args}'.|n") + return + + if switches[0] == "start": + # Attempt to start a service. + if service.running: + caller.msg("That service is already running.") + return + caller.msg(f"Starting service '{self.args}' ...") + try: + service.startService() + except Exception as err: + caller.msg( + f"|rErrors were reported when starting this service{err}.\n" + "If there are remaining problems, try reloading the server, changing the " + "settings if it's a non-standard service.|n" + ) + caller.msg("|gService started.|n")
+ + +
[docs]class CmdAbout(COMMAND_DEFAULT_CLASS): + """ + show Evennia info + + Usage: + about + + Display info about the game engine. + """ + + key = "@about" + aliases = "@version" + locks = "cmd:all()" + help_category = "System" + +
[docs] def func(self): + """Display information about server or target""" + + string = """ + |cEvennia|n MU* development system + + |wEvennia version|n: {version} + |wOS|n: {os} + |wPython|n: {python} + |wTwisted|n: {twisted} + |wDjango|n: {django} + + |wHomepage|n https://evennia.com + |wCode|n https://github.com/evennia/evennia + |wDemo|n https://demo.evennia.com + |wGame listing|n https://games.evennia.com + |wChat|n https://discord.gg/AJJpcRUhtF + |wForum|n https://github.com/evennia/evennia/discussions + |wLicence|n https://opensource.org/licenses/BSD-3-Clause + |wMaintainer|n (2010-) Griatch (griatch AT gmail DOT com) + |wMaintainer|n (2006-10) Greg Taylor + + """.format( + version=utils.get_evennia_version(), + os=os.name, + python=sys.version.split()[0], + twisted=twisted.version.short(), + django=django.get_version(), + ) + self.msg(string)
+ + +
[docs]class CmdTime(COMMAND_DEFAULT_CLASS): + """ + show server time statistics + + Usage: + time + + List Server time statistics such as uptime + and the current time stamp. + """ + + key = "@time" + aliases = "@uptime" + locks = "cmd:perm(time) or perm(Player)" + help_category = "System" + +
[docs] def func(self): + """Show server time data in a table.""" + table1 = self.styled_table("|wServer time", "", align="l", width=78) + table1.add_row("Current uptime", utils.time_format(gametime.uptime(), 3)) + table1.add_row("Portal uptime", utils.time_format(gametime.portal_uptime(), 3)) + table1.add_row("Total runtime", utils.time_format(gametime.runtime(), 2)) + table1.add_row("First start", datetime.datetime.fromtimestamp(gametime.server_epoch())) + table1.add_row("Current time", datetime.datetime.now()) + table1.reformat_column(0, width=30) + table2 = self.styled_table( + "|wIn-Game time", + "|wReal time x %g" % gametime.TIMEFACTOR, + align="l", + width=78, + border_top=0, + ) + epochtxt = "Epoch (%s)" % ("from settings" if settings.TIME_GAME_EPOCH else "server start") + table2.add_row(epochtxt, datetime.datetime.fromtimestamp(gametime.game_epoch())) + table2.add_row("Total time passed:", utils.time_format(gametime.gametime(), 2)) + table2.add_row( + "Current time ", datetime.datetime.fromtimestamp(gametime.gametime(absolute=True)) + ) + table2.reformat_column(0, width=30) + self.msg(str(table1) + "\n" + str(table2))
+ + +
[docs]class CmdServerLoad(COMMAND_DEFAULT_CLASS): + """ + show server load and memory statistics + + Usage: + server[/mem] + + Switches: + mem - return only a string of the current memory usage + flushmem - flush the idmapper cache + + This command shows server load statistics and dynamic memory + usage. It also allows to flush the cache of accessed database + objects. + + Some Important statistics in the table: + + |wServer load|n is an average of processor usage. It's usually + between 0 (no usage) and 1 (100% usage), but may also be + temporarily higher if your computer has multiple CPU cores. + + The |wResident/Virtual memory|n displays the total memory used by + the server process. + + Evennia |wcaches|n all retrieved database entities when they are + loaded by use of the idmapper functionality. This allows Evennia + to maintain the same instances of an entity and allowing + non-persistent storage schemes. The total amount of cached objects + are displayed plus a breakdown of database object types. + + The |wflushmem|n switch allows to flush the object cache. Please + note that due to how Python's memory management works, releasing + caches may not show you a lower Residual/Virtual memory footprint, + the released memory will instead be re-used by the program. + + """ + + key = "@server" + aliases = ["@serverload"] + switch_options = ("mem", "flushmem") + locks = "cmd:perm(list) or perm(Developer)" + help_category = "System" + +
[docs] def func(self): + """Show list.""" + + global _IDMAPPER + if not _IDMAPPER: + from evennia.utils.idmapper import models as _IDMAPPER + + if "flushmem" in self.switches: + # flush the cache + prev, _ = _IDMAPPER.cache_size() + nflushed = _IDMAPPER.flush_cache() + now, _ = _IDMAPPER.cache_size() + string = ( + "The Idmapper cache freed |w{idmapper}|n database objects.\n" + "The Python garbage collector freed |w{gc}|n Python instances total." + ) + self.msg(string.format(idmapper=(prev - now), gc=nflushed)) + return + + # display active processes + + os_windows = os.name == "nt" + pid = os.getpid() + + if os_windows: + # Windows requires the psutil module to even get paltry + # statistics like this (it's pretty much worthless, + # unfortunately, since it's not specific to the process) /rant + try: + import psutil + + has_psutil = True + except ImportError: + has_psutil = False + + if has_psutil: + loadavg = psutil.cpu_percent() + _mem = psutil.virtual_memory() + rmem = _mem.used / (1000.0 * 1000) + pmem = _mem.percent + + if "mem" in self.switches: + string = "Total computer memory usage: |w%g|n MB (%g%%)" + self.msg(string % (rmem, pmem)) + return + # Display table + loadtable = self.styled_table("property", "statistic", align="l") + loadtable.add_row("Total CPU load", "%g %%" % loadavg) + loadtable.add_row("Total computer memory usage", "%g MB (%g%%)" % (rmem, pmem)) + loadtable.add_row("Process ID", "%g" % pid), + else: + loadtable = ( + "Not available on Windows without 'psutil' library " + "(install with |wpip install psutil|n)." + ) + + else: + # Linux / BSD (OSX) - proper pid-based statistics + + global _RESOURCE + if not _RESOURCE: + import resource as _RESOURCE + + loadavg = os.getloadavg()[0] + rmem = ( + float(os.popen("ps -p %d -o %s | tail -1" % (pid, "rss")).read()) / 1000.0 + ) # resident memory + vmem = ( + float(os.popen("ps -p %d -o %s | tail -1" % (pid, "vsz")).read()) / 1000.0 + ) # virtual memory + pmem = float( + os.popen("ps -p %d -o %s | tail -1" % (pid, "%mem")).read() + ) # % of resident memory to total + rusage = _RESOURCE.getrusage(_RESOURCE.RUSAGE_SELF) + + if "mem" in self.switches: + string = "Memory usage: RMEM: |w%g|n MB (%g%%), VMEM (res+swap+cache): |w%g|n MB." + self.msg(string % (rmem, pmem, vmem)) + return + + loadtable = self.styled_table("property", "statistic", align="l") + loadtable.add_row("Server load (1 min)", "%g" % loadavg) + loadtable.add_row("Process ID", "%g" % pid), + loadtable.add_row("Memory usage", "%g MB (%g%%)" % (rmem, pmem)) + loadtable.add_row("Virtual address space", "") + loadtable.add_row("|x(resident+swap+caching)|n", "%g MB" % vmem) + loadtable.add_row( + "CPU time used (total)", + "%s (%gs)" % (utils.time_format(rusage.ru_utime), rusage.ru_utime), + ) + loadtable.add_row( + "CPU time used (user)", + "%s (%gs)" % (utils.time_format(rusage.ru_stime), rusage.ru_stime), + ) + loadtable.add_row( + "Page faults", + "%g hard, %g soft, %g swapouts" + % (rusage.ru_majflt, rusage.ru_minflt, rusage.ru_nswap), + ) + loadtable.add_row( + "Disk I/O", "%g reads, %g writes" % (rusage.ru_inblock, rusage.ru_oublock) + ) + loadtable.add_row("Network I/O", "%g in, %g out" % (rusage.ru_msgrcv, rusage.ru_msgsnd)) + loadtable.add_row( + "Context switching", + "%g vol, %g forced, %g signals" + % (rusage.ru_nvcsw, rusage.ru_nivcsw, rusage.ru_nsignals), + ) + + # os-generic + + string = "|wServer CPU and Memory load:|n\n%s" % loadtable + + # object cache count (note that sys.getsiseof is not called so this works for pypy too. + total_num, cachedict = _IDMAPPER.cache_size() + sorted_cache = sorted( + [(key, num) for key, num in cachedict.items() if num > 0], + key=lambda tup: tup[1], + reverse=True, + ) + memtable = self.styled_table("entity name", "number", "idmapper %", align="l") + for tup in sorted_cache: + memtable.add_row(tup[0], "%i" % tup[1], "%.2f" % (float(tup[1]) / total_num * 100)) + + string += "\n|w Entity idmapper cache:|n %i items\n%s" % (total_num, memtable) + + # return to caller + self.msg(string)
+ + +
[docs]class CmdTickers(COMMAND_DEFAULT_CLASS): + """ + View running tickers + + Usage: + tickers + + Note: Tickers are created, stopped and manipulated in Python code + using the TickerHandler. This is merely a convenience function for + inspecting the current status. + + """ + + key = "@tickers" + help_category = "System" + locks = "cmd:perm(tickers) or perm(Builder)" + +
[docs] def func(self): + from evennia import TICKER_HANDLER + + all_subs = TICKER_HANDLER.all_display() + if not all_subs: + self.msg("No tickers are currently active.") + return + table = self.styled_table("interval (s)", "object", "path/methodname", "idstring", "db") + for sub in all_subs: + table.add_row( + sub[3], + "%s%s" + % ( + sub[0] or "[None]", + sub[0] and " (#%s)" % (sub[0].id if hasattr(sub[0], "id") else "") or "", + ), + sub[1] if sub[1] else sub[2], + sub[4] or "[Unset]", + "*" if sub[5] else "-", + ) + self.msg("|wActive tickers|n:\n" + str(table))
+ + +
[docs]class CmdTasks(COMMAND_DEFAULT_CLASS): + """ + Display or terminate active tasks (delays). + + Usage: + tasks[/switch] [task_id or function_name] + + Switches: + pause - Pause the callback of a task. + unpause - Process all callbacks made since pause() was called. + do_task - Execute the task (call its callback). + call - Call the callback of this task. + remove - Remove a task without executing it. + cancel - Stop a task from automatically executing. + + Notes: + A task is a single use method of delaying the call of a function. Calls are created + in code, using `evennia.utils.delay`. + See |luhttps://www.evennia.com/docs/latest/Command-Duration.html|ltthe docs|le for help. + + By default, tasks that are canceled and never called are cleaned up after one minute. + + Examples: + - `tasks/cancel move_callback` - Cancels all movement delays from the slow_exit contrib. + In this example slow exits creates it's tasks with + `utils.delay(move_delay, move_callback)` + - `tasks/cancel 2` - Cancel task id 2. + + """ + + key = "@tasks" + aliases = ["@delays", "@task"] + switch_options = ("pause", "unpause", "do_task", "call", "remove", "cancel") + locks = "perm(Developer)" + help_category = "System" + +
[docs] @staticmethod + def coll_date_func(task): + """Replace regex characters in date string and collect deferred function name.""" + t_comp_date = str(task[0]).replace("-", "/") + t_func_name = str(task[1]).split(" ") + t_func_mem_ref = t_func_name[3] if len(t_func_name) >= 4 else None + return t_comp_date, t_func_mem_ref
+ +
[docs] def do_task_action(self, *args, **kwargs): + """ + Process the action of a tasks command. + + This exists to gain support with yes or no function from EvMenu. + """ + task_id = self.task_id + + # get a reference of the global task handler + global _TASK_HANDLER + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + + # verify manipulating the correct task + task_args = _TASK_HANDLER.tasks.get(task_id, False) + if not task_args: # check if the task is still active + self.msg("Task completed while waiting for input.") + return + else: + # make certain a task with matching IDs has not been created + t_comp_date, t_func_mem_ref = self.coll_date_func(task_args) + if self.t_comp_date != t_comp_date or self.t_func_mem_ref != t_func_mem_ref: + self.msg("Task completed while waiting for input.") + return + + # Do the action requested by command caller + action_return = self.task_action() + self.msg(f"{self.action_request} request completed.") + self.msg(f"The task function {self.action_request} returned: {action_return}")
+ +
[docs] def func(self): + # get a reference of the global task handler + global _TASK_HANDLER + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + # handle no tasks active. + if not _TASK_HANDLER.tasks: + self.msg("There are no active tasks.") + if self.switches or self.args: + self.msg("Likely the task has completed and been removed.") + return + + # handle caller's request to manipulate a task(s) + if self.switches and self.lhs: + # find if the argument is a task id or function name + action_request = self.switches[0] + try: + arg_is_id = int(self.lhslist[0]) + except ValueError: + arg_is_id = False + + # if the argument is a task id, proccess the action on a single task + if arg_is_id: + err_arg_msg = "Switch and task ID are required when manipulating a task." + task_comp_msg = "Task completed while processing request." + + # handle missing arguments or switches + if not self.switches and self.lhs: + self.msg(err_arg_msg) + return + + # create a handle for the task + task_id = arg_is_id + task = TaskHandlerTask(task_id) + + # handle task no longer existing + if not task.exists(): + self.msg(f"Task {task_id} does not exist.") + return + + # get a reference of the function caller requested + switch_action = getattr(task, action_request, False) + if not switch_action: + self.msg( + f"{self.switches[0]}, is not an acceptable task action or " + f"{task_comp_msg.lower()}" + ) + + # verify manipulating the correct task + if task_id in _TASK_HANDLER.tasks: + task_args = _TASK_HANDLER.tasks.get(task_id, False) + if not task_args: # check if the task is still active + self.msg(task_comp_msg) + return + else: + t_comp_date, t_func_mem_ref = self.coll_date_func(task_args) + t_func_name = str(task_args[1]).split(" ") + t_func_name = t_func_name[1] if len(t_func_name) >= 2 else None + + if task.exists(): # make certain the task has not been called yet. + prompt = ( + f"{action_request.capitalize()} task {task_id} with completion date " + f"{t_comp_date} ({t_func_name}) {{options}}?" + ) + no_msg = f"No {action_request} processed." + # record variables for use in do_task_action method + self.task_id = task_id + self.t_comp_date = t_comp_date + self.t_func_mem_ref = t_func_mem_ref + self.task_action = switch_action + self.action_request = action_request + ask_yes_no( + self.caller, + prompt=prompt, + yes_action=self.do_task_action, + no_action=no_msg, + default="Y", + allow_abort=True, + ) + return True + else: + self.msg(task_comp_msg) + return + + # the argument is not a task id, process the action on all task deferring the function + # specified as an argument + else: + name_match_found = False + arg_func_name = self.lhslist[0].lower() + + # repack tasks into a new dictionary + current_tasks = {} + for task_id, task_args in _TASK_HANDLER.tasks.items(): + current_tasks.update({task_id: task_args}) + + # call requested action on all tasks with the function name + for task_id, task_args in current_tasks.items(): + t_func_name = str(task_args[1]).split(" ") + t_func_name = t_func_name[1] if len(t_func_name) >= 2 else None + # skip this task if it is not for the function desired + if arg_func_name != t_func_name: + continue + name_match_found = True + task = TaskHandlerTask(task_id) + switch_action = getattr(task, action_request, False) + if switch_action: + action_return = switch_action() + self.msg(f"Task action {action_request} completed on task ID {task_id}.") + self.msg(f"The task function {action_request} returned: {action_return}") + + # provide a message if not tasks of the function name was found + if not name_match_found: + self.msg(f"No tasks deferring function name {arg_func_name} found.") + return + return True + + # check if an maleformed request was created + elif self.switches or self.lhs: + self.msg("Task command misformed.") + self.msg("Proper format tasks[/switch] [function name or task id]") + return + + # No task manupilation requested, build a table of tasks and display it + # get the width of screen in characters + width = self.client_width() + # create table header and list to hold tasks data and actions + tasks_header = ( + "Task ID", + "Completion Date", + "Function", + "Arguments", + "KWARGS", + "persistent", + ) + # empty list of lists, the size of the header + tasks_list = [list() for i in range(len(tasks_header))] + for task_id, task in _TASK_HANDLER.tasks.items(): + # collect data from the task + t_comp_date, t_func_mem_ref = self.coll_date_func(task) + t_func_name = str(task[1]).split(" ") + t_func_name = t_func_name[1] if len(t_func_name) >= 2 else None + t_args = str(task[2]) + t_kwargs = str(task[3]) + t_pers = str(task[4]) + # add task data to the tasks list + task_data = (task_id, t_comp_date, t_func_name, t_args, t_kwargs, t_pers) + for i in range(len(tasks_header)): + tasks_list[i].append(task_data[i]) + # create and display the table + tasks_table = EvTable( + *tasks_header, table=tasks_list, maxwidth=width, border="cells", align="c" + ) + actions = (f"/{switch}" for switch in self.switch_options) + helptxt = f"\nActions: {iter_to_str(actions)}" + self.msg(str(tasks_table) + helptxt)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/tests.html b/docs/latest/_modules/evennia/commands/default/tests.html new file mode 100644 index 0000000000..6dfa5f6d22 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/tests.html @@ -0,0 +1,2263 @@ + + + + + + + + evennia.commands.default.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.tests

+# -*- coding: utf-8 -*-
+"""
+ ** OBS - this is not a normal command module! **
+ ** You cannot import anything in this module as a command! **
+
+This is part of the Evennia unittest framework, for testing the
+stability and integrity of the codebase during updates. This module
+test the default command set. It is instantiated by the
+evennia/objects/tests.py module, which in turn is run by as part of the
+main test suite started with
+ > python game/manage.py test.
+
+"""
+import datetime
+from unittest.mock import MagicMock, Mock, patch
+
+from anything import Anything
+from django.conf import settings
+from django.test import override_settings
+from parameterized import parameterized
+from twisted.internet import task
+
+import evennia
+from evennia import (
+    DefaultCharacter,
+    DefaultExit,
+    DefaultObject,
+    DefaultRoom,
+    ObjectDB,
+    search_object,
+)
+from evennia.commands import cmdparser
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.command import Command, InterruptCommand
+from evennia.commands.default import (
+    account,
+    admin,
+    batchprocess,
+    building,
+    comms,
+    general,
+)
+from evennia.commands.default import help as help_module
+from evennia.commands.default import syscommands, system, unloggedin
+from evennia.commands.default.cmdset_character import CharacterCmdSet
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.prototypes import prototypes as protlib
+from evennia.utils import create, gametime, utils
+from evennia.utils.test_resources import BaseEvenniaCommandTest  # noqa
+from evennia.utils.test_resources import BaseEvenniaTest, EvenniaCommandTest
+
+# ------------------------------------------------------------
+# Command testing
+# ------------------------------------------------------------
+
+
+
[docs]class TestGeneral(BaseEvenniaCommandTest): +
[docs] def test_look(self): + rid = self.room1.id + self.call(general.CmdLook(), "here", "Room(#{})\nroom_desc".format(rid))
+ +
[docs] def test_look_no_location(self): + self.char1.location = None + self.call(general.CmdLook(), "", "You have no location to look at!")
+ +
[docs] def test_look_nonexisting(self): + self.call(general.CmdLook(), "yellow sign", "Could not find 'yellow sign'.")
+ +
[docs] def test_home(self): + self.call(general.CmdHome(), "", "You are already home")
+ +
[docs] def test_go_home(self): + self.call(building.CmdTeleport(), "/quiet Room2") + self.call(general.CmdHome(), "", "There's no place like home")
+ +
[docs] def test_no_home(self): + self.char1.home = None + self.call(general.CmdHome(), "", "You have no home")
+ +
[docs] def test_inventory(self): + self.call(general.CmdInventory(), "", "You are not carrying anything.")
+ +
[docs] def test_pose(self): + self.char2.msg = Mock() + self.call(general.CmdPose(), "looks around", "Char looks around") + self.char2.msg.assert_called_with( + text=("Char looks around", {"type": "pose"}), from_obj=self.char1 + )
+ +
[docs] def test_nick(self): + self.call( + general.CmdNick(), + "testalias = testaliasedstring1", + "Inputline-nick 'testalias' mapped to 'testaliasedstring1'.", + ) + self.call( + general.CmdNick(), + "/account testalias = testaliasedstring2", + "Account-nick 'testalias' mapped to 'testaliasedstring2'.", + ) + self.call( + general.CmdNick(), + "/object testalias = testaliasedstring3", + "Object-nick 'testalias' mapped to 'testaliasedstring3'.", + ) + self.assertEqual("testaliasedstring1", self.char1.nicks.get("testalias")) + self.assertEqual( + "testaliasedstring2", self.char1.nicks.get("testalias", category="account") + ) + self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account")) + self.assertEqual("testaliasedstring3", self.char1.nicks.get("testalias", category="object"))
+ +
[docs] def test_nick_list(self): + self.call(general.CmdNick(), "/list", "No nicks defined.") + self.call(general.CmdNick(), "test1 = Hello", "Inputline-nick 'test1' mapped to 'Hello'.") + self.call(general.CmdNick(), "/list", "Defined Nicks:")
+ +
[docs] def test_get_and_drop(self): + self.call(general.CmdGet(), "Obj", "You pick up an Obj.") + self.call(general.CmdDrop(), "Obj", "You drop an Obj.")
+ +
[docs] def test_give(self): + self.call(general.CmdGive(), "Obj to Char2", "You aren't carrying Obj.") + self.call(general.CmdGive(), "Obj = Char2", "You aren't carrying Obj.") + self.call(general.CmdGet(), "Obj", "You pick up an Obj.") + self.call(general.CmdGive(), "Obj to Char2", "You give") + self.call(general.CmdGive(), "Obj = Char", "You give", caller=self.char2)
+ +
[docs] def test_mux_command(self): + class CmdTest(MuxCommand): + key = "test" + switch_options = ("test", "testswitch", "testswitch2") + + def func(self): + self.msg("Switches matched: {}".format(self.switches)) + + self.call( + CmdTest(), + "/test/testswitch/testswitch2", + "Switches matched: ['test', 'testswitch', 'testswitch2']", + ) + self.call(CmdTest(), "/test", "Switches matched: ['test']") + self.call(CmdTest(), "/test/testswitch", "Switches matched: ['test', 'testswitch']") + self.call( + CmdTest(), "/testswitch/testswitch2", "Switches matched: ['testswitch', 'testswitch2']" + ) + self.call(CmdTest(), "/testswitch", "Switches matched: ['testswitch']") + self.call(CmdTest(), "/testswitch2", "Switches matched: ['testswitch2']") + self.call( + CmdTest(), + "/t", + "test: Ambiguous switch supplied: " + "Did you mean /test or /testswitch or /testswitch2?|Switches matched: []", + ) + self.call( + CmdTest(), + "/tests", + "test: Ambiguous switch supplied: " + "Did you mean /testswitch or /testswitch2?|Switches matched: []", + )
+ +
[docs] def test_say(self): + self.call(general.CmdSay(), "Testing", 'You say, "Testing"')
+ +
[docs] def test_whisper(self): + self.call( + general.CmdWhisper(), + "Obj = Testing", + 'You whisper to Obj, "Testing"', + caller=self.char2, + )
+ +
[docs] def test_access(self): + self.call(general.CmdAccess(), "", "Permission Hierarchy (climbing):")
+ + +
[docs]class TestHelp(BaseEvenniaCommandTest): + maxDiff = None + +
[docs] def setUp(self): + super().setUp() + # we need to set up a logger here since lunr takes over the logger otherwise + import logging + + logging.basicConfig(level=logging.ERROR)
+ +
[docs] def tearDown(self): + super().tearDown() + import logging + + logging.disable(level=logging.ERROR)
+ +
[docs] def test_help(self): + self.call(help_module.CmdHelp(), "", "Commands", cmdset=CharacterCmdSet())
+ +
[docs] def test_set_help(self): + self.call( + help_module.CmdSetHelp(), + "testhelp, General = This is a test", + "Topic 'testhelp' was successfully created.", + cmdset=CharacterCmdSet(), + ) + self.call(help_module.CmdHelp(), "testhelp", "Help for testhelp", cmdset=CharacterCmdSet())
+ + @parameterized.expand( + [ + ( + "test", # main help entry + ( + "Help for test\n\n" + "Main help text\n\n" + "Subtopics:\n" + " test/creating extra stuff" + " test/something else" + " test/more" + ), + ), + ( + "test/creating extra stuff", # subtopic, full match + ( + "Help for test/creating extra stuff\n\n" + "Help on creating extra stuff.\n\n" + "Subtopics:\n" + " test/creating extra stuff/subsubtopic\n" + ), + ), + ( + "test/creating", # startswith-match + ( + "Help for test/creating extra stuff\n\n" + "Help on creating extra stuff.\n\n" + "Subtopics:\n" + " test/creating extra stuff/subsubtopic\n" + ), + ), + ( + "test/extra", # partial match + ( + "Help for test/creating extra stuff\n\n" + "Help on creating extra stuff.\n\n" + "Subtopics:\n" + " test/creating extra stuff/subsubtopic\n" + ), + ), + ( + "test/extra/subsubtopic", # partial subsub-match + "Help for test/creating extra stuff/subsubtopic\n\nA subsubtopic text", + ), + ( + "test/creating extra/subsub", # partial subsub-match + "Help for test/creating extra stuff/subsubtopic\n\nA subsubtopic text", + ), + ("test/Something else", "Help for test/something else\n\nSomething else"), # case + ( + "test/More", # case + "Help for test/more\n\nAnother text\n\nSubtopics:\n test/more/second-more", + ), + ( + "test/More/Second-more", + ( + "Help for test/more/second-more\n\n" + "The Second More text.\n\n" + "Subtopics:\n" + " test/more/second-more/more again" + " test/more/second-more/third more" + ), + ), + ( + "test/More/-more", # partial match + ( + "Help for test/more/second-more\n\n" + "The Second More text.\n\n" + "Subtopics:\n" + " test/more/second-more/more again" + " test/more/second-more/third more" + ), + ), + ( + "test/more/second/more again", + "Help for test/more/second-more/more again\n\nEven more text.\n", + ), + ( + "test/more/second/third", + "Help for test/more/second-more/third more\n\nThird more text\n", + ), + ] + ) + def test_subtopic_fetch(self, helparg, expected): + """ + Check retrieval of subtopics. + + """ + + class TestCmd(Command): + """ + Main help text + + # SUBTOPICS + + ## creating extra stuff + + Help on creating extra stuff. + + ### subsubtopic + + A subsubtopic text + + ## Something else + + Something else + + ## More + + Another text + + ### Second-More + + The Second More text. + + #### More again + + Even more text. + + #### Third more + + Third more text + + """ + + key = "test" + + class TestCmdSet(CmdSet): + def at_cmdset_creation(self): + self.add(TestCmd()) + self.add(help_module.CmdHelp()) + + self.call(help_module.CmdHelp(), helparg, expected, cmdset=TestCmdSet())
+ + +
[docs]class TestSystem(BaseEvenniaCommandTest): +
[docs] def test_py(self): + # we are not testing CmdReload, CmdReset and CmdShutdown, CmdService or CmdTime + # since the server is not running during these tests. + self.call(system.CmdPy(), "1+2", ">>> 1+2|3") + self.call(system.CmdPy(), "/clientraw 1+2", ">>> 1+2|3")
+ +
[docs] def test_scripts(self): + self.call(building.CmdScripts(), "", "dbref ")
+ +
[docs] def test_objects(self): + self.call(building.CmdObjects(), "", "Object subtype totals")
+ +
[docs] def test_about(self): + self.call(system.CmdAbout(), "", None)
+ +
[docs] def test_server_load(self): + self.call(system.CmdServerLoad(), "", "Server CPU and Memory load:")
+ + +_TASK_HANDLER = None + + +
[docs]def func_test_cmd_tasks(): + return "success"
+ + +
[docs]class TestCmdTasks(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + # get a reference of TASK_HANDLER + self.timedelay = 5 + global _TASK_HANDLER + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + _TASK_HANDLER.clock = task.Clock() + self.task_handler = _TASK_HANDLER + self.task_handler.clear() + self.task = self.task_handler.add(self.timedelay, func_test_cmd_tasks) + task_args = self.task_handler.tasks.get(self.task.get_id(), False)
+ +
[docs] def tearDown(self): + super().tearDown() + self.task_handler.clear()
+ +
[docs] def test_no_tasks(self): + self.task_handler.clear() + self.call(system.CmdTasks(), "", "There are no active tasks.")
+ +
[docs] def test_active_task(self): + cmd_result = self.call(system.CmdTasks(), "") + for ptrn in ( + "Task ID", + "Completion", + "Date", + "Function", + "KWARGS", + "persisten", + "1", + r"\d+/\d+/\d+", + r"\d+\:", + r"\d+\:\d+", + r"\:\d+", + "func_test", + "{}", + "False", + ): + self.assertRegex(cmd_result, ptrn)
+ +
[docs] def test_persistent_task(self): + self.task_handler.clear() + self.task_handler.add(self.timedelay, func_test_cmd_tasks, persistent=True) + cmd_result = self.call(system.CmdTasks(), "") + self.assertRegex(cmd_result, "True")
+ +
[docs] def test_pause_unpause(self): + # test pause + args = f"/pause {self.task.get_id()}" + wanted_msg = "Pause task 1 with completion date" + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.char1.execute_cmd("y") + self.assertTrue(self.task.paused) + self.task_handler.clock.advance(self.timedelay + 1) + # test unpause + args = f"/unpause {self.task.get_id()}" + self.assertTrue(self.task.exists()) + wanted_msg = "Unpause task 1 with completion date" + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.char1.execute_cmd("y") + # verify task continues after unpause + self.task_handler.clock.advance(1) + self.assertFalse(self.task.exists())
+ +
[docs] def test_do_task(self): + args = f"/do_task {self.task.get_id()}" + wanted_msg = "Do_task task 1 with completion date" + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.char1.execute_cmd("y") + self.assertFalse(self.task.exists())
+ +
[docs] def test_remove(self): + args = f"/remove {self.task.get_id()}" + wanted_msg = "Remove task 1 with completion date" + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.char1.execute_cmd("y") + self.assertFalse(self.task.exists())
+ +
[docs] def test_call(self): + args = f"/call {self.task.get_id()}" + wanted_msg = "Call task 1 with completion date" + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.char1.execute_cmd("y") + # make certain the task is still active + self.assertTrue(self.task.active()) + # go past delay time, the task should call do_task and remove itself after calling. + self.task_handler.clock.advance(self.timedelay + 1) + self.assertFalse(self.task.exists())
+ +
[docs] def test_cancel(self): + args = f"/cancel {self.task.get_id()}" + wanted_msg = "Cancel task 1 with completion date" + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.char1.execute_cmd("y") + self.assertTrue(self.task.exists()) + self.assertFalse(self.task.active())
+ +
[docs] def test_func_name_manipulation(self): + self.task_handler.add(self.timedelay, func_test_cmd_tasks) # add an extra task + args = f"/remove func_test_cmd_tasks" + wanted_msg = ( + "Task action remove completed on task ID 1.|The task function remove returned: True|" + "Task action remove completed on task ID 2.|The task function remove returned: True" + ) + self.call(system.CmdTasks(), args, wanted_msg) + self.assertFalse(self.task_handler.tasks) # no tasks should exist.
+ +
[docs] def test_wrong_func_name(self): + args = f"/remove intentional_fail" + wanted_msg = "No tasks deferring function name intentional_fail found." + self.call(system.CmdTasks(), args, wanted_msg) + self.assertTrue(self.task.active())
+ +
[docs] def test_no_input(self): + args = f"/cancel {self.task.get_id()}" + self.call(system.CmdTasks(), args) + # task should complete since no input was received + self.task_handler.clock.advance(self.timedelay + 1) + self.assertFalse(self.task.exists())
+ +
[docs] def test_responce_of_yes(self): + self.call(system.CmdTasks(), f"/cancel {self.task.get_id()}") + self.char1.msg = Mock() + self.char1.execute_cmd("y") + text = "" + for _, _, kwargs in self.char1.msg.mock_calls: + text += kwargs.get("text", "") + self.assertEqual(text, "cancel request completed.The task function cancel returned: True") + self.assertTrue(self.task.exists())
+ +
[docs] def test_task_complete_waiting_input(self): + """Test for task completing while waiting for input.""" + self.call(system.CmdTasks(), f"/cancel {self.task.get_id()}") + self.task_handler.clock.advance(self.timedelay + 1) + self.char1.msg = Mock() + self.char1.execute_cmd("y") + text = "" + for _, _, kwargs in self.char1.msg.mock_calls: + text += kwargs.get("text", "") + self.assertEqual(text, "Task completed while waiting for input.") + self.assertFalse(self.task.exists())
+ +
[docs] def test_new_task_waiting_input(self): + """ + Test task completing than a new task with the same ID being made while waitinf for input. + """ + self.assertTrue(self.task.get_id(), 1) + self.call(system.CmdTasks(), f"/cancel {self.task.get_id()}") + self.task_handler.clock.advance(self.timedelay + 1) + self.assertFalse(self.task.exists()) + self.task = self.task_handler.add(self.timedelay, func_test_cmd_tasks) + self.assertTrue(self.task.get_id(), 1) + self.char1.msg = Mock() + self.char1.execute_cmd("y") + text = "" + for _, _, kwargs in self.char1.msg.mock_calls: + text += kwargs.get("text", "") + self.assertEqual(text, "Task completed while waiting for input.")
+ +
[docs] def test_misformed_command(self): + wanted_msg = ( + "Task command misformed.|Proper format tasks[/switch] [function name or task id]" + ) + self.call(system.CmdTasks(), f"/cancel", wanted_msg)
+ + +
[docs]class TestAdmin(BaseEvenniaCommandTest): +
[docs] def test_emit(self): + self.call(admin.CmdEmit(), "Char2 = Test", "Emitted to Char2:\nTest")
+ +
[docs] def test_perm(self): + self.call( + admin.CmdPerm(), + "Obj = Builder", + "Permission 'Builder' given to Obj (the Object/Character).", + ) + self.call( + admin.CmdPerm(), + "Char2 = Builder", + "Permission 'Builder' given to Char2 (the Object/Character).", + )
+ +
[docs] def test_wall(self): + self.call(admin.CmdWall(), "Test", "Announcing to all connected sessions ...")
+ +
[docs] def test_ban(self): + self.call(admin.CmdBan(), "Char", "Name-ban 'char' was added. Use unban to reinstate.")
+ +
[docs] def test_force(self): + cid = self.char2.id + self.call( + admin.CmdForce(), + "Char2=say test", + 'Char2(#{}) says, "test"|You have forced Char2 to: say test'.format(cid), + )
+ + +
[docs]class TestAccount(BaseEvenniaCommandTest): + """ + Test different account-specific modes + + """ + + @parameterized.expand( + # multisession-mode, auto-puppet, max_nr_characters + [ + (0, True, 1, "You are out-of-character"), + (1, True, 1, "You are out-of-character"), + (2, True, 1, "You are out-of-character"), + (3, True, 1, "You are out-of-character"), + (0, False, 1, "Account TestAccount"), + (1, False, 1, "Account TestAccount"), + (2, False, 1, "Account TestAccount"), + (3, False, 1, "Account TestAccount"), + (0, True, 2, "Account TestAccount"), + (1, True, 2, "Account TestAccount"), + (2, True, 2, "Account TestAccount"), + (3, True, 2, "Account TestAccount"), + (0, False, 2, "Account TestAccount"), + (1, False, 2, "Account TestAccount"), + (2, False, 2, "Account TestAccount"), + (3, False, 2, "Account TestAccount"), + ] + ) + def test_ooc_look(self, multisession_mode, auto_puppet, max_nr_chars, expected_result): + self.account.characters.add(self.char1) + self.account.unpuppet_all() + + with self.settings(MULTISESSION=multisession_mode): + # we need to patch the module header instead of settings + with patch("evennia.commands.default.account._MAX_NR_CHARACTERS", new=max_nr_chars): + with patch( + "evennia.commands.default.account._AUTO_PUPPET_ON_LOGIN", new=auto_puppet + ): + self.call( + account.CmdOOCLook(), + "", + expected_result, + caller=self.account, + ) + +
[docs] def test_ooc(self): + self.call(account.CmdOOC(), "", "You go OOC.", caller=self.account)
+ +
[docs] def test_ic(self): + self.account.characters.add(self.char1) + self.account.unpuppet_object(self.session) + self.call( + account.CmdIC(), "Char", "You become Char.", caller=self.account, receiver=self.char1 + )
+ +
[docs] def test_ic__other_object(self): + self.account.characters.add(self.obj1) + self.account.unpuppet_object(self.session) + self.call( + account.CmdIC(), "Obj", "You become Obj.", caller=self.account, receiver=self.obj1 + )
+ +
[docs] def test_ic__nonaccess(self): + self.account.unpuppet_object(self.session) + self.call( + account.CmdIC(), + "Nonexistent", + "That is not a valid character choice.", + caller=self.account, + receiver=self.account, + )
+ +
[docs] def test_password(self): + self.call( + account.CmdPassword(), + "testpassword = testpassword", + "Password changed.", + caller=self.account, + )
+ +
[docs] def test_option(self): + self.call(account.CmdOption(), "", "Client settings", caller=self.account)
+ +
[docs] def test_who(self): + self.call(account.CmdWho(), "", "Accounts:", caller=self.account)
+ +
[docs] def test_quit(self): + self.call( + account.CmdQuit(), "", "Quitting. Hope to see you again, soon.", caller=self.account + )
+ +
[docs] def test_sessions(self): + self.call(account.CmdSessions(), "", "Your current session(s):", caller=self.account)
+ +
[docs] def test_color_test(self): + self.call(account.CmdColorTest(), "ansi", "ANSI colors:", caller=self.account)
+ +
[docs] def test_char_create(self): + self.call( + account.CmdCharCreate(), + "Test1=Test char", + "Created new character Test1. Use ic Test1 to enter the game", + caller=self.account, + )
+ +
[docs] def test_char_delete(self): + # Chardelete requires user input; this test is mainly to confirm + # whether permissions are being checked + + # Add char to account playable characters + self.account.characters.add(self.char1) + + # Try deleting as Developer + self.call( + account.CmdCharDelete(), + "Char", + "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", + caller=self.account, + ) + + # Downgrade permissions on account + self.account.permissions.add("Player") + self.account.permissions.remove("Developer") + + # Set lock on character object to prevent deletion + self.char1.locks.add("delete:none()") + + # Try deleting as Player + self.call( + account.CmdCharDelete(), + "Char", + "You do not have permission to delete this character.", + caller=self.account, + ) + + # Set lock on character object to allow self-delete + self.char1.locks.add("delete:pid(%i)" % self.account.id) + + # Try deleting as Player again + self.call( + account.CmdCharDelete(), + "Char", + "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", + caller=self.account, + )
+ +
[docs] def test_quell(self): + self.call( + account.CmdQuell(), + "", + "Quelling to current puppet's permissions (developer).", + caller=self.account, + )
+ + +
[docs]class TestBuilding(BaseEvenniaCommandTest): +
[docs] def test_create(self): + typeclass = settings.BASE_OBJECT_TYPECLASS + name = typeclass.rsplit(".", 1)[1] + self.call( + building.CmdCreate(), + f"/d TestObj1:{typeclass}", # /d switch is abbreviated form of /drop + "You create a new %s: TestObj1." % name, + ) + self.call(building.CmdCreate(), "", "Usage: ") + self.call( + building.CmdCreate(), + f"TestObj1;foo;bar:{typeclass}", + "You create a new %s: TestObj1 (aliases: foo, bar)." % name, + )
+ +
[docs] def test_examine(self): + self.call(building.CmdExamine(), "", "Name/key: Room") + self.call(building.CmdExamine(), "Obj", "Name/key: Obj") + self.call(building.CmdExamine(), "Obj", "Name/key: Obj") + self.call(building.CmdExamine(), "*TestAccount", "Name/key: TestAccount") + + self.char1.db.test = "testval" + self.call( + building.CmdExamine(), "self/test", "Attribute Char/test [category=None]:\n\ntestval" + ) + self.call(building.CmdExamine(), "NotFound", "Could not find 'NotFound'.") + self.call(building.CmdExamine(), "out", "Name/key: out") + + # escape inlinefuncs + self.char1.db.test2 = "this is a $random() value." + self.call( + building.CmdExamine(), + "self/test2", + "Attribute Char/test2 [category=None]:\n\nthis is a \$random() value.", + ) + + self.room1.scripts.add(self.script.__class__) + self.call(building.CmdExamine(), "") + self.account.scripts.add(self.script.__class__) + self.call(building.CmdExamine(), "*TestAccount")
+ +
[docs] def test_set_obj_alias(self): + oid = self.obj1.id + self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj") + self.call( + building.CmdSetObjAlias(), + "Obj = TestObj1b", + "Alias(es) for 'Obj(#{})' set to 'testobj1b'.".format(oid), + ) + self.call(building.CmdSetObjAlias(), "", "Usage: ") + self.call(building.CmdSetObjAlias(), "NotFound =", "Could not find 'NotFound'.") + + self.call(building.CmdSetObjAlias(), "Obj", "Aliases for Obj(#{}): 'testobj1b'".format(oid)) + self.call(building.CmdSetObjAlias(), "Obj2 =", "Cleared aliases from Obj2") + self.call(building.CmdSetObjAlias(), "Obj2 =", "No aliases to clear.")
+ +
[docs] def test_copy(self): + self.call( + building.CmdCopy(), + "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", + "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']", + ) + self.call(building.CmdCopy(), "", "Usage: ") + self.call(building.CmdCopy(), "Obj", "Identical copy of Obj, named 'Obj_copy' was created.") + self.call(building.CmdCopy(), "NotFound = Foo", "Could not find 'NotFound'.")
+ +
[docs] def test_attribute_commands(self): + self.call(building.CmdSetAttribute(), "", "Usage: ") + self.call( + building.CmdSetAttribute(), + 'Obj/test1="value1"', + "Created attribute Obj/test1 [category:None] = value1", + ) + self.call( + building.CmdSetAttribute(), + 'Obj2/test2="value2"', + "Created attribute Obj2/test2 [category:None] = value2", + ) + self.call( + building.CmdSetAttribute(), + "Obj2/test2", + "Attribute Obj2/test2 [category:None] = value2", + ) + self.call( + building.CmdSetAttribute(), + "Obj2/NotFound", + "Attribute Obj2/notfound [category:None] does not exist.", + ) + + with patch("evennia.commands.default.building.EvEditor") as mock_ed: + self.call(building.CmdSetAttribute(), "/edit Obj2/test3") + mock_ed.assert_called_with(self.char1, Anything, Anything, key="Obj2/test3") + + self.call( + building.CmdSetAttribute(), + 'Obj2/test3="value3"', + "Created attribute Obj2/test3 [category:None] = value3", + ) + self.call( + building.CmdSetAttribute(), + "Obj2/test3 = ", + "Deleted attribute Obj2/test3 [category:None].", + ) + self.call( + building.CmdSetAttribute(), + "Obj2/test4:Foo = 'Bar'", + "Created attribute Obj2/test4 [category:Foo] = Bar", + ) + self.call( + building.CmdCpAttr(), + "/copy Obj2/test2 = Obj2/test3", + '@cpattr: Extra switch "/copy" ignored.|\nCopied Obj2.test2 -> Obj2.test3. ' + "(value: 'value2')", + ) + self.call(building.CmdMvAttr(), "", "Usage: ") + self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 -> Obj.test3") + self.call(building.CmdCpAttr(), "", "Usage: ") + self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 -> Obj2.test3") + + self.call(building.CmdWipe(), "", "Usage: ") + self.call(building.CmdWipe(), "Obj2/test2/test3", "Wiped attributes test2,test3 on Obj2.") + self.call(building.CmdWipe(), "Obj2", "Wiped all attributes on Obj2.")
+ +
[docs] def test_nested_attribute_commands(self): + # list - adding white space proves real parsing + self.call( + building.CmdSetAttribute(), + "Obj/test1=[1,2]", + "Created attribute Obj/test1 [category:None] = [1, 2]", + ) + self.call( + building.CmdSetAttribute(), "Obj/test1", "Attribute Obj/test1 [category:None] = [1, 2]" + ) + self.call( + building.CmdSetAttribute(), "Obj/test1[0]", "Attribute Obj/test1[0] [category:None] = 1" + ) + self.call( + building.CmdSetAttribute(), "Obj/test1[1]", "Attribute Obj/test1[1] [category:None] = 2" + ) + self.call( + building.CmdSetAttribute(), + "Obj/test1[0] = 99", + "Modified attribute Obj/test1 [category:None] = [99, 2]", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test1[0]", + "Attribute Obj/test1[0] [category:None] = 99", + ) + # list delete + self.call( + building.CmdSetAttribute(), + "Obj/test1[0] =", + "Deleted attribute Obj/test1[0] [category:None].", + ) + self.call( + building.CmdSetAttribute(), "Obj/test1[0]", "Attribute Obj/test1[0] [category:None] = 2" + ) + self.call( + building.CmdSetAttribute(), + "Obj/test1[1]", + "Attribute Obj/test1[1] [category:None] does not exist. (Nested lookups attempted)", + ) + # Delete non-existent + self.call( + building.CmdSetAttribute(), + "Obj/test1[5] =", + "No attribute Obj/test1[5] [category: None] was found to " + "delete. (Nested lookups attempted)", + ) + # Append + self.call( + building.CmdSetAttribute(), + "Obj/test1[+] = 42", + "Modified attribute Obj/test1 [category:None] = [2, 42]", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test1[+0] = -1", + "Modified attribute Obj/test1 [category:None] = [-1, 2, 42]", + ) + + # dict - removing white space proves real parsing + self.call( + building.CmdSetAttribute(), + "Obj/test2={ 'one': 1, 'two': 2 }", + "Created attribute Obj/test2 [category:None] = {'one': 1, 'two': 2}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2", + "Attribute Obj/test2 [category:None] = {'one': 1, 'two': 2}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['one']", + "Attribute Obj/test2['one'] [category:None] = 1", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['one]", + "Attribute Obj/test2['one] [category:None] = 1", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['one']=99", + "Modified attribute Obj/test2 [category:None] = {'one': 99, 'two': 2}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['one']", + "Attribute Obj/test2['one'] [category:None] = 99", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['two']", + "Attribute Obj/test2['two'] [category:None] = 2", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2[+'three']", + "Attribute Obj/test2[+'three'] [category:None] does not exist. (Nested lookups" + " attempted)", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2[+'three'] = 3", + "Modified attribute Obj/test2 [category:None] = {'one': 99, 'two': 2, \"+'three'\": 3}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2[+'three'] =", + "Deleted attribute Obj/test2[+'three'] [category:None].", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['three']=3", + "Modified attribute Obj/test2 [category:None] = {'one': 99, 'two': 2, 'three': 3}", + ) + # Dict delete + self.call( + building.CmdSetAttribute(), + "Obj/test2['two'] =", + "Deleted attribute Obj/test2['two'] [category:None].", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['two']", + "Attribute Obj/test2['two'] [category:None] does not exist. (Nested lookups attempted)", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2", + "Attribute Obj/test2 [category:None] = {'one': 99, 'three': 3}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2[0]", + "Attribute Obj/test2[0] [category:None] does not exist. (Nested lookups attempted)", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2['five'] =", + "No attribute Obj/test2['five'] [category: None] " + "was found to delete. (Nested lookups attempted)", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2[+]=42", + "Modified attribute Obj/test2 [category:None] = {'one': 99, 'three': 3, '+': 42}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test2[+1]=33", + "Modified attribute Obj/test2 [category:None] = " + "{'one': 99, 'three': 3, '+': 42, '+1': 33}", + ) + + # dict - case sensitive keys + + self.call( + building.CmdSetAttribute(), + "Obj/test_case = {'FooBar': 1}", + "Created attribute Obj/test_case [category:None] = {'FooBar': 1}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case['FooBar'] = 2", + "Modified attribute Obj/test_case [category:None] = {'FooBar': 2}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case", + "Attribute Obj/test_case [category:None] = {'FooBar': 2}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case['FooBar'] = {'BarBaz': 1}", + "Modified attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 1}}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case['FooBar']['BarBaz'] = 2", + "Modified attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 2}}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case", + "Attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 2}}", + ) + + # tuple + self.call( + building.CmdSetAttribute(), + "Obj/tup = (1,2)", + "Created attribute Obj/tup [category:None] = (1, 2)", + ) + self.call( + building.CmdSetAttribute(), + "Obj/tup[1] = 99", + "'tuple' object does not support item assignment - (1, 2)", + ) + self.call( + building.CmdSetAttribute(), + "Obj/tup[+] = 99", + "'tuple' object does not support item assignment - (1, 2)", + ) + self.call( + building.CmdSetAttribute(), + "Obj/tup[+1] = 99", + "'tuple' object does not support item assignment - (1, 2)", + ) + self.call( + building.CmdSetAttribute(), + # Special case for tuple, could have a better message + "Obj/tup[1] = ", + "No attribute Obj/tup[1] [category: None] " + "was found to delete. (Nested lookups attempted)", + ) + + # Deaper nesting + self.call( + building.CmdSetAttribute(), + "Obj/test3=[{'one': 1}]", + "Created attribute Obj/test3 [category:None] = [{'one': 1}]", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test3[0]['one']", + "Attribute Obj/test3[0]['one'] [category:None] = 1", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test3[0]", + "Attribute Obj/test3[0] [category:None] = {'one': 1}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test3[0]['one'] =", + "Deleted attribute Obj/test3[0]['one'] [category:None].", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test3[0]", + "Attribute Obj/test3[0] [category:None] = {}", + ) + self.call( + building.CmdSetAttribute(), "Obj/test3", "Attribute Obj/test3 [category:None] = [{}]" + ) + + # Naughty keys + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]='foo'", + "Created attribute Obj/test4[0] [category:None] = foo", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]", + "Attribute Obj/test4[0] [category:None] = foo", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test4=[{'one': 1}]", + "Created attribute Obj/test4 [category:None] = [{'one': 1}]", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]['one']", + "Attribute Obj/test4[0]['one'] [category:None] = 1", + ) + # Prefer nested items + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]", + "Attribute Obj/test4[0] [category:None] = {'one': 1}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]['one']", + "Attribute Obj/test4[0]['one'] [category:None] = 1", + ) + # Restored access + self.call(building.CmdWipe(), "Obj/test4", "Wiped attributes test4 on Obj.") + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]", + "Attribute Obj/test4[0] [category:None] = foo", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test4[0]['one']", + "Attribute Obj/test4[0]['one'] [category:None] does not exist. (Nested lookups" + " attempted)", + )
+ +
[docs] def test_split_nested_attr(self): + split_nested_attr = building.CmdSetAttribute().split_nested_attr + test_cases = { + "test1": [("test1", [])], + 'test2["dict"]': [("test2", ["dict"]), ('test2["dict"]', [])], + # Quotes not actually required + "test3[dict]": [("test3", ["dict"]), ("test3[dict]", [])], + 'test4["dict]': [("test4", ["dict"]), ('test4["dict]', [])], + # duplicate keys don't cause issues + "test5[0][0]": [("test5", [0, 0]), ("test5[0]", [0]), ("test5[0][0]", [])], + # String ints preserved + 'test6["0"][0]': [("test6", ["0", 0]), ('test6["0"]', [0]), ('test6["0"][0]', [])], + # Unmatched [] + "test7[dict": [("test7[dict", [])], + } + + for attr, result in test_cases.items(): + self.assertEqual(list(split_nested_attr(attr)), result)
+ +
[docs] def test_do_nested_lookup(self): + do_nested_lookup = building.CmdSetAttribute().do_nested_lookup + not_found = building.CmdSetAttribute.not_found + + def do_test_single(value, key, result): + self.assertEqual(do_nested_lookup(value, key), result) + + def do_test_multi(value, keys, result): + self.assertEqual(do_nested_lookup(value, *keys), result) + + do_test_single([], "test1", not_found) + do_test_single([1], "test2", not_found) + do_test_single([], 0, not_found) + do_test_single([], "0", not_found) + do_test_single([1], 2, not_found) + do_test_single([1], 0, 1) + do_test_single([1], "0", not_found) # str key is str not int + do_test_single({}, "test3", not_found) + do_test_single({}, 0, not_found) + do_test_single({"foo": "bar"}, "foo", "bar") + + do_test_multi({"one": [1, 2, 3]}, ("one", 0), 1) + do_test_multi([{}, {"two": 2}, 3], (1, "two"), 2)
+ +
[docs] def test_name(self): + self.call(building.CmdName(), "", "Usage: ") + self.call(building.CmdName(), "Obj2=Obj3", "Object's name changed to 'Obj3'.") + self.call( + building.CmdName(), + "*TestAccount=TestAccountRenamed", + "Account's name changed to 'TestAccountRenamed'.", + ) + self.call(building.CmdName(), "*NotFound=TestAccountRenamed", "Could not find '*NotFound'") + self.call( + building.CmdName(), "Obj3=Obj4;foo;bar", "Object's name changed to 'Obj4' (foo, bar)." + ) + self.call(building.CmdName(), "Obj4=", "No names or aliases defined!")
+ +
[docs] def test_desc(self): + oid = self.obj2.id + self.call( + building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2(#{}).".format(oid) + ) + self.call(building.CmdDesc(), "", "Usage: ") + + with patch("evennia.commands.default.building.EvEditor") as mock_ed: + self.call(building.CmdDesc(), "/edit") + mock_ed.assert_called_with( + self.char1, + key="desc", + loadfunc=building._desc_load, + quitfunc=building._desc_quit, + savefunc=building._desc_save, + persistent=True, + )
+ +
[docs] def test_empty_desc(self): + """ + empty desc sets desc as '' + """ + oid = self.obj2.id + o2d = self.obj2.db.desc + r1d = self.room1.db.desc + self.call(building.CmdDesc(), "Obj2=", "The description was set on Obj2(#{}).".format(oid)) + assert self.obj2.db.desc == "" and self.obj2.db.desc != o2d + assert self.room1.db.desc == r1d
+ +
[docs] def test_desc_default_to_room(self): + """no rhs changes room's desc""" + rid = self.room1.id + o2d = self.obj2.db.desc + r1d = self.room1.db.desc + self.call(building.CmdDesc(), "Obj2", "The description was set on Room(#{}).".format(rid)) + assert self.obj2.db.desc == o2d + assert self.room1.db.desc == "Obj2" and self.room1.db.desc != r1d
+ +
[docs] def test_destroy(self): + confirm = building.CmdDestroy.confirm + building.CmdDestroy.confirm = False + self.call(building.CmdDestroy(), "", "Usage: ") + self.call(building.CmdDestroy(), "Obj", "Obj was destroyed.") + self.call(building.CmdDestroy(), "Obj", "Obj2 was destroyed.") + self.call( + building.CmdDestroy(), + "Obj", + "Could not find 'Obj'.| (Objects to destroy " + "must either be local or specified with a unique #dbref.)", + ) + settings.DEFAULT_HOME = f"#{self.room1.dbid}" + self.call( + building.CmdDestroy(), settings.DEFAULT_HOME, "You are trying to delete" + ) # DEFAULT_HOME should not be deleted + self.char2.location = self.room2 + charid = self.char2.id + room1id = self.room1.id + room2id = self.room2.id + self.call( + building.CmdDestroy(), + self.room2.dbref, + "Char2(#{}) arrives to Room(#{}) from Room2(#{}).|Room2 was destroyed.".format( + charid, room1id, room2id + ), + ) + building.CmdDestroy.confirm = confirm
+ +
[docs] def test_destroy_sequence(self): + confirm = building.CmdDestroy.confirm + building.CmdDestroy.confirm = False + self.call( + building.CmdDestroy(), + "{}-{}".format(self.obj1.dbref, self.obj2.dbref), + "Obj was destroyed.\nObj2 was destroyed.", + )
+ +
[docs] def test_dig(self): + self.call(building.CmdDig(), "TestRoom1=testroom;tr,back;b", "Created room TestRoom1") + self.call(building.CmdDig(), "", "Usage: ")
+ +
[docs] def test_tunnel(self): + self.call(building.CmdTunnel(), "n = TestRoom2;test2", "Created room TestRoom2") + self.call(building.CmdTunnel(), "", "Usage: ") + self.call(building.CmdTunnel(), "foo = TestRoom2;test2", "tunnel can only understand the") + self.call(building.CmdTunnel(), "/tel e = TestRoom3;test3", "Created room TestRoom3") + DefaultRoom.objects.get_family(db_key="TestRoom3") + exits = DefaultExit.objects.filter_family(db_key__in=("east", "west")) + self.assertEqual(len(exits), 2)
+ +
[docs] def test_tunnel_exit_typeclass(self): + self.call( + building.CmdTunnel(), + "n:evennia.objects.objects.DefaultExit = TestRoom3", + "Created room TestRoom3", + )
+ +
[docs] def test_exit_commands(self): + self.call( + building.CmdOpen(), "TestExit1=Room2", "Created new Exit 'TestExit1' from Room to Room2" + ) + self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).") + self.call(building.CmdUnLink(), "", "Usage: ") + self.call(building.CmdLink(), "NotFound", "Could not find 'NotFound'.") + self.call(building.CmdLink(), "TestExit", "TestExit1 is an exit to Room.") + self.call(building.CmdLink(), "Obj", "Obj is not an exit. Its home location is Room.") + self.call( + building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere." + ) + + self.char1.location = self.room2 + self.call( + building.CmdOpen(), "TestExit2=Room", "Created new Exit 'TestExit2' from Room2 to Room." + ) + self.call( + building.CmdOpen(), + "TestExit2=Room", + "Exit TestExit2 already exists. It already points to the correct place.", + ) + + # ensure it matches locally first + self.call( + building.CmdLink(), "TestExit=Room2", "Link created TestExit2 -> Room2 (one way)." + ) + self.call( + building.CmdLink(), + "/twoway TestExit={}".format(self.exit.dbref), + "Link created TestExit2 (in Room2) <-> out (in Room) (two-way).", + ) + self.call( + building.CmdLink(), + "/twoway TestExit={}".format(self.room1.dbref), + "To create a two-way link, TestExit2 and Room must both have a location ", + ) + self.call( + building.CmdLink(), + "/twoway {}={}".format(self.exit.dbref, self.exit.dbref), + "Cannot link an object to itself.", + ) + self.call(building.CmdLink(), "", "Usage: ") + # ensure can still match globally when not a local name + self.call(building.CmdLink(), "TestExit1=Room2", "Note: TestExit1") + self.call( + building.CmdLink(), "TestExit1=", "Former exit TestExit1 no longer links anywhere." + )
+ +
[docs] def test_set_home(self): + self.call( + building.CmdSetHome(), "Obj = Room2", "Home location of Obj was changed from Room" + ) + self.call(building.CmdSetHome(), "", "Usage: ") + self.call(building.CmdSetHome(), "self", "Char's current home is Room") + self.call(building.CmdSetHome(), "Obj", "Obj's current home is Room2") + self.obj1.home = None + self.call(building.CmdSetHome(), "Obj = Room2", "Home location of Obj was set to Room")
+ +
[docs] def test_list_cmdsets(self): + self.call( + building.CmdListCmdSets(), + "", + "<CmdSetHandler> stack:\n <CmdSet DefaultCharacter, Union, perm, prio 0>:", + ) + self.call(building.CmdListCmdSets(), "NotFound", "Could not find 'NotFound'")
+ +
[docs] def test_typeclass(self): + self.call(building.CmdTypeclass(), "", "Usage: ") + self.call( + building.CmdTypeclass(), + "Obj = evennia.objects.objects.DefaultExit", + "Obj changed typeclass from evennia.objects.objects.DefaultObject " + "to evennia.objects.objects.DefaultExit.", + ) + self.call( + building.CmdTypeclass(), + "Obj2 = evennia.objects.objects.DefaultExit", + "Obj2 changed typeclass from evennia.objects.objects.DefaultObject " + "to evennia.objects.objects.DefaultExit.", + cmdstring="swap", + inputs=["yes"], + ) + self.call(building.CmdTypeclass(), "/list Obj", "Core typeclasses") + self.call( + building.CmdTypeclass(), + "/show Obj", + "Obj's current typeclass is 'evennia.objects.objects.DefaultExit'", + ) + self.call( + building.CmdTypeclass(), + "Obj = evennia.objects.objects.DefaultExit", + "Obj already has the typeclass 'evennia.objects.objects.DefaultExit'. Use /force to" + " override.", + ) + self.call( + building.CmdTypeclass(), + "/force Obj = evennia.objects.objects.DefaultExit", + "Obj updated its existing typeclass ", + ) + self.call(building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultObject") + self.call( + building.CmdTypeclass(), + "/show Obj", + "Obj's current typeclass is 'evennia.objects.objects.DefaultObject'", + ) + self.call( + building.CmdTypeclass(), + "Obj", + "Obj updated its existing typeclass (evennia.objects.objects.DefaultObject).\nOnly the" + " at_object_creation hook was run (update mode). Attributes set before swap were not" + " removed\n(use `swap` or `type/reset` to clear all).", + cmdstring="update", + ) + self.call( + building.CmdTypeclass(), + "/reset/force Obj=evennia.objects.objects.DefaultObject", + "Obj updated its existing typeclass (evennia.objects.objects.DefaultObject).\n" + "All object creation hooks were run. All old attributes where deleted before the swap.", + inputs=["yes"], + ) + + from evennia.prototypes.prototypes import homogenize_prototype + + test_prototype = [ + homogenize_prototype( + { + "prototype_key": "testkey", + "prototype_tags": [], + "typeclass": "typeclasses.objects.Object", + "key": "replaced_obj", + "attrs": [("foo", "bar", None, ""), ("desc", "protdesc", None, "")], + } + ) + ] + with patch( + "evennia.commands.default.building.protlib.search_prototype", + new=MagicMock(return_value=test_prototype), + ) as mprot: + self.call( + building.CmdTypeclass(), + "/prototype Obj=testkey", + "replaced_obj changed typeclass from evennia.objects.objects.DefaultObject to " + "typeclasses.objects.Object.\nOnly the at_object_creation hook was run " + "(update mode). Attributes set before swap were not removed\n" + "(use `swap` or `type/reset` to clear all). Prototype 'replaced_obj' was " + "successfully applied over the object type.", + ) + assert self.obj1.db.desc == "protdesc"
+ +
[docs] def test_lock(self): + self.call(building.CmdLock(), "", "Usage: ") + self.call(building.CmdLock(), "Obj = test:all()", "Added lock 'test:all()' to Obj.") + self.call( + building.CmdLock(), + "*TestAccount = test:all()", + "Added lock 'test:all()' to TestAccount", + ) + self.call(building.CmdLock(), "Obj/notfound", "Obj has no lock of access type 'notfound'.") + self.call(building.CmdLock(), "Obj/test", "test:all()") + self.call( + building.CmdLock(), + "/view Obj = edit:false()", + "Switch(es) view can not be used with a lock assignment. " + "Use e.g. lock/del objname/locktype instead.", + ) + self.call(building.CmdLock(), "Obj = control:false()") + self.call(building.CmdLock(), "Obj = edit:false()") + self.call(building.CmdLock(), "Obj/test", "You are not allowed to do that.") + self.obj1.locks.add("control:true()") + self.call(building.CmdLock(), "Obj", "call:true()") # etc + self.call(building.CmdLock(), "*TestAccount", "boot:perm(Admin)") # etc
+ +
[docs] def test_find(self): + rid2 = self.room2.id + rmax = rid2 + 100 + self.call(building.CmdFind(), "", "Usage: ") + self.call(building.CmdFind(), "oom2", "One Match") + self.call(building.CmdFind(), "oom2 = 1-{}".format(rmax), "One Match") + self.call(building.CmdFind(), "oom2 = 1 {}".format(rmax), "One Match") # space works too + self.call(building.CmdFind(), "Char2", "One Match", cmdstring="locate") + self.call( + building.CmdFind(), + "/ex Char2", # /ex is an ambiguous switch + "locate: Ambiguous switch supplied: Did you mean /exit or /exact?|", + cmdstring="locate", + ) + self.call(building.CmdFind(), "Char2", "One Match", cmdstring="locate") + self.call( + building.CmdFind(), "/l Char2", "One Match", cmdstring="find" + ) # /l switch is abbreviated form of /loc + self.call(building.CmdFind(), "Char2", "One Match", cmdstring="find") + self.call(building.CmdFind(), "/startswith Room2", "One Match") + + self.call(building.CmdFind(), self.char1.dbref, "Exact dbref match") + self.call(building.CmdFind(), "*TestAccount", "Match") + + self.call(building.CmdFind(), "/char Obj", "No Matches") + self.call(building.CmdFind(), "/room Obj", "No Matches") + self.call(building.CmdFind(), "/exit Obj", "No Matches") + self.call(building.CmdFind(), "/exact Obj", "One Match") + + # Test multitype filtering + with patch( + "evennia.commands.default.building.CHAR_TYPECLASS", + "evennia.objects.objects.DefaultCharacter", + ): + self.call(building.CmdFind(), "/char/room Obj", "No Matches") + self.call(building.CmdFind(), "/char/room/exit Char", "2 Matches") + self.call(building.CmdFind(), "/char/room/exit/startswith Cha", "2 Matches") + + # Test null search + self.call(building.CmdFind(), "=", "Usage: ") + + # Test bogus dbref range with no search term + self.call(building.CmdFind(), "= obj", "Invalid dbref range provided (not a number).") + self.call(building.CmdFind(), "= #1a", "Invalid dbref range provided (not a number).") + + # Test valid dbref ranges with no search term + id1 = self.obj1.id + id2 = self.obj2.id + maxid = ObjectDB.objects.latest("id").id + maxdiff = maxid - id1 + 1 + mdiff = id2 - id1 + 1 + + self.call(building.CmdFind(), f"=#{id1}", f"{maxdiff} Matches(#{id1}-#{maxid}") + self.call(building.CmdFind(), f"={id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + self.call(building.CmdFind(), f"={id1} - {id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + self.call(building.CmdFind(), f"={id1}- #{id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + self.call(building.CmdFind(), f"={id1}-#{id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + self.call(building.CmdFind(), f"=#{id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
+ +
[docs] def test_script(self): + self.call(building.CmdScripts(), "Obj =", "No scripts defined on Obj") + self.call( + building.CmdScripts(), + "Obj = scripts.scripts.DefaultScript", + "Script scripts.scripts.DefaultScript successfully added", + ) + self.call(building.CmdScripts(), "evennia.Dummy", "Global Script NOT Created ") + self.call( + building.CmdScripts(), + "evennia.scripts.scripts.DoNothing", + "Global Script Created - sys_do_nothing ", + ) + self.call(building.CmdScripts(), "Obj =", "dbref ") + + self.call( + building.CmdScripts(), "/start Obj = ", "Script on Obj Started " + ) # we allow running start again; this should still happen + self.call(building.CmdScripts(), "/stop Obj =", "Script on Obj Stopped - ") + + self.call( + building.CmdScripts(), + "Obj = scripts.scripts.DefaultScript", + "Script scripts.scripts.DefaultScript successfully added", + inputs=["Y"], + ) + self.call( + building.CmdScripts(), + "/start Obj = scripts.scripts.DefaultScript", + "Script on Obj Started ", + inputs=["Y"], + ) + self.call( + building.CmdScripts(), + "/stop Obj = scripts.scripts.DefaultScript", + "Script on Obj Stopped ", + inputs=["Y"], + ) + self.call( + building.CmdScripts(), + "/delete Obj = scripts.scripts.DefaultScript", + "Script on Obj Deleted ", + inputs=["Y"], + ) + self.call( + building.CmdScripts(), + "/delete evennia.scripts.scripts.DoNothing", + "Global Script Deleted -", + )
+ +
[docs] def test_script_multi_delete(self): + script1 = create.create_script() + script2 = create.create_script() + script3 = create.create_script() + + self.call( + building.CmdScripts(), + "/delete #{}-#{}".format(script1.id, script3.id), + f"Global Script Deleted - #{script1.id} (evennia.scripts.scripts.DefaultScript)|" + f"Global Script Deleted - #{script2.id} (evennia.scripts.scripts.DefaultScript)|" + f"Global Script Deleted - #{script3.id} (evennia.scripts.scripts.DefaultScript)", + inputs=["y"], + ) + self.assertFalse(script1.pk) + self.assertFalse(script2.pk) + self.assertFalse(script3.pk)
+ +
[docs] def test_teleport(self): + oid = self.obj1.id + rid = self.room1.id + rid2 = self.room2.id + self.call(building.CmdTeleport(), "", "Usage: ") + self.call(building.CmdTeleport(), "Obj = Room", "Obj is already at Room.") + self.call( + building.CmdTeleport(), + "Obj = NotFound", + "Could not find 'NotFound'.|Destination not found.", + ) + self.call( + building.CmdTeleport(), + "Obj = Room2", + "Obj(#{}) is leaving Room(#{}), heading for Room2(#{}).|Teleported Obj -> Room2.".format( + oid, rid, rid2 + ), + ) + self.call(building.CmdTeleport(), "NotFound = Room", "Could not find 'NotFound'.") + self.call( + building.CmdTeleport(), "Obj = Obj", "You can't teleport an object inside of itself!" + ) + + self.call(building.CmdTeleport(), "/tonone Obj2", "Teleported Obj2 -> None-location.") + self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#{})".format(rid2)) + self.call( + building.CmdTeleport(), + "/t", # /t switch is abbreviated form of /tonone + "Cannot teleport a puppeted object (Char, puppeted by TestAccount", + ) + self.call( + building.CmdTeleport(), + "/l Room2", # /l switch is abbreviated form of /loc + "Destination has no location.", + ) + self.call( + building.CmdTeleport(), + "/q me to Room2", # /q switch is abbreviated form of /quiet + "Char is already at Room2.", + )
+ +
[docs] def test_tag(self): + self.call(building.CmdTag(), "", "Usage: ") + + self.call(building.CmdTag(), "Obj = testtag") + self.call(building.CmdTag(), "Obj = testtag2") + self.call(building.CmdTag(), "Obj = testtag2:category1") + self.call(building.CmdTag(), "Obj = testtag3") + + self.call( + building.CmdTag(), + "Obj", + "Tags on Obj: 'testtag', 'testtag2', 'testtag2' (category: category1), 'testtag3'", + ) + + self.call(building.CmdTag(), "/search NotFound", "No objects found with tag 'NotFound'.") + self.call(building.CmdTag(), "/search testtag", "Found 1 object with tag 'testtag':") + self.call(building.CmdTag(), "/search testtag2", "Found 1 object with tag 'testtag2':") + self.call( + building.CmdTag(), + "/search testtag2:category1", + "Found 1 object with tag 'testtag2' (category: 'category1'):", + ) + + self.call(building.CmdTag(), "/del Obj = testtag3", "Removed tag 'testtag3' from Obj.") + self.call( + building.CmdTag(), + "/del Obj", + "Cleared all tags from Obj: testtag, testtag2, testtag2 (category: category1)", + )
+ +
[docs] def test_spawn(self): + def get_object(commandTest, obj_key): + # A helper function to get a spawned object and + # check that it exists in the process. + query = search_object(obj_key) + commandTest.assertIsNotNone(query) + commandTest.assertTrue(bool(query)) + obj = query[0] + commandTest.assertIsNotNone(obj) + return obj + + # Tests "spawn" without any arguments. + self.call(building.CmdSpawn(), " ", "Usage: spawn") + + # Tests "spawn <prototype_dictionary>" without specifying location. + + self.call( + building.CmdSpawn(), + "/save {'prototype_key': 'testprot', 'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}", + "Saved prototype: testprot", + inputs=["y"], + ) + + self.call( + building.CmdSpawn(), + "/save testprot2 = {'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}", + "(Replacing `prototype_key` in prototype with given key.)|Saved prototype: testprot2", + inputs=["y"], + ) + + self.call(building.CmdSpawn(), "/search ", "Key ") + self.call(building.CmdSpawn(), "/search test;test2", "No prototypes found.") + + self.call( + building.CmdSpawn(), + "/save {'key':'Test Char', 'typeclass':'evennia.objects.objects.DefaultCharacter'}", + "A prototype_key must be given, either as `prototype_key = <prototype>` or as " + "a key 'prototype_key' inside the prototype structure.", + ) + + self.call(building.CmdSpawn(), "/list", "Key ") + self.call(building.CmdSpawn(), "testprot", "Spawned Test Char") + + # Tests that the spawned object's location is the same as the character's location, since + # we did not specify it. + testchar = get_object(self, "Test Char") + self.assertEqual(testchar.location, self.char1.location) + testchar.delete() + + # Test "spawn <prototype_dictionary>" with a location other than the character's. + spawnLoc = self.room2 + if spawnLoc == self.char1.location: + # Just to make sure we use a different location, in case someone changes + # char1's default location in the future... + spawnLoc = self.room1 + + self.call( + building.CmdSpawn(), + "{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', " + "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, + "Spawned goblin", + ) + goblin = get_object(self, "goblin") + # Tests that the spawned object's type is a DefaultCharacter. + self.assertIsInstance(goblin, DefaultCharacter) + self.assertEqual(goblin.location, spawnLoc) + + goblin.delete() + + # create prototype + protlib.create_prototype( + { + "key": "Ball", + "typeclass": "evennia.objects.objects.DefaultCharacter", + "prototype_key": "testball", + } + ) + + # Tests "spawn <prototype_name>" + self.call(building.CmdSpawn(), "testball", "Spawned Ball") + + ball = get_object(self, "Ball") + self.assertEqual(ball.location, self.char1.location) + self.assertIsInstance(ball, DefaultObject) + ball.delete() + + # Tests "spawn/n ..." without specifying a location. + # Location should be "None". + self.call( + building.CmdSpawn(), "/n 'BALL'", "Spawned Ball" + ) # /n switch is abbreviated form of /noloc + ball = get_object(self, "Ball") + self.assertIsNone(ball.location) + ball.delete() + + self.call( + building.CmdSpawn(), + "/noloc {'prototype_parent':'TESTBALL', 'prototype_key': 'testball', 'location':'%s'}" + % spawnLoc.dbref, + "Error: Prototype testball tries to parent itself.", + ) + + # Tests "spawn/noloc ...", but DO specify a location. + # Location should be the specified location. + self.call( + building.CmdSpawn(), + "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo'," + " 'location':'%s'}" % spawnLoc.dbref, + "Spawned Ball", + ) + ball = get_object(self, "Ball") + self.assertEqual(ball.location, spawnLoc) + ball.delete() + + # test calling spawn with an invalid prototype. + self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST' was found.") + + # Test listing commands + self.call(building.CmdSpawn(), "/list", "Key ") + + # spawn/edit (missing prototype) + # brings up olc menu + msg = self.call(building.CmdSpawn(), "/edit") + assert "Prototype wizard" in msg + + # spawn/edit with valid prototype + # brings up olc menu loaded with prototype + msg = self.call(building.CmdSpawn(), "/edit testball") + assert "Prototype wizard" in msg + assert hasattr(self.char1.ndb._menutree, "olc_prototype") + assert ( + dict == type(self.char1.ndb._menutree.olc_prototype) + and "prototype_key" in self.char1.ndb._menutree.olc_prototype + and "key" in self.char1.ndb._menutree.olc_prototype + and "testball" == self.char1.ndb._menutree.olc_prototype["prototype_key"] + and "Ball" == self.char1.ndb._menutree.olc_prototype["key"] + ) + assert "Ball" in msg and "testball" in msg + + # spawn/edit with valid prototype (synomym) + msg = self.call(building.CmdSpawn(), "/edit BALL") + assert "Prototype wizard" in msg + assert "Ball" in msg and "testball" in msg + + # spawn/edit with invalid prototype + msg = self.call( + building.CmdSpawn(), "/edit NO_EXISTS", "No prototype named 'NO_EXISTS' was found." + ) + + # spawn/examine (missing prototype) + # lists all prototypes that exist + self.call(building.CmdSpawn(), "/examine", "You need to specify a prototype-key to show.") + + # spawn/examine with valid prototype + # prints the prototype + msg = self.call(building.CmdSpawn(), "/examine BALL") + assert "Ball" in msg and "testball" in msg + + # spawn/examine with invalid prototype + # shows error + self.call( + building.CmdSpawn(), "/examine NO_EXISTS", "No prototype named 'NO_EXISTS' was found." + )
+ + +import evennia.commands.default.comms as cmd_comms # noqa +from evennia.comms.comms import DefaultChannel # noqa +from evennia.utils.create import create_channel # noqa + + +
[docs]@patch("evennia.commands.default.comms.CHANNEL_DEFAULT_TYPECLASS", DefaultChannel) +class TestCommsChannel(BaseEvenniaCommandTest): + """ + Test the central `channel` command. + + """ + +
[docs] def setUp(self): + super().setUp() + self.channel = create_channel(key="testchannel", desc="A test channel") + self.channel.connect(self.char1) + self.cmdchannel = cmd_comms.CmdChannel + self.cmdchannel.account_caller = False
+ +
[docs] def tearDown(self): + if self.channel.pk: + self.channel.delete()
+ + # test channel command +
[docs] def test_channel__noarg(self): + self.call(self.cmdchannel(), "", "Channel subscriptions")
+ +
[docs] def test_channel__msg(self): + self.channel.msg = Mock() + self.call(self.cmdchannel(), "testchannel = Test message", "") + self.channel.msg.assert_called_with("Test message", senders=self.char1)
+ +
[docs] def test_channel__list(self): + self.call(self.cmdchannel(), "/list", "Channel subscriptions")
+ +
[docs] def test_channel__all(self): + self.call(self.cmdchannel(), "/all", "Available channels")
+ +
[docs] def test_channel__history(self): + with patch("evennia.commands.default.comms.tail_log_file") as mock_tail: + self.call(self.cmdchannel(), "/history testchannel", "") + mock_tail.assert_called()
+ +
[docs] def test_channel__sub(self): + self.channel.disconnect(self.char1) + + self.call(self.cmdchannel(), "/sub testchannel", "You are now subscribed") + self.assertTrue(self.char1 in self.channel.subscriptions.all()) + self.assertEqual( + self.char1.nicks.nickreplace("testchannel Hello"), "@channel testchannel = Hello" + )
+ +
[docs] def test_channel__unsub(self): + self.call(self.cmdchannel(), "/unsub testchannel", "You un-subscribed") + self.assertFalse(self.char1 in self.channel.subscriptions.all())
+ +
[docs] def test_channel__alias__unalias(self): + """Add and then remove a channel alias""" + + # add alias + self.call( + self.cmdchannel(), + "/alias testchannel = foo", + "Added/updated your alias 'foo' for channel testchannel.", + ) + self.assertEqual(self.char1.nicks.nickreplace("foo Hello"), "@channel testchannel = Hello") + + # use alias + self.channel.msg = Mock() + self.call(self.cmdchannel(), "foo = test message", "") + self.channel.msg.assert_called_with("test message", senders=self.char1) + + # remove alias + self.call(self.cmdchannel(), "/unalias foo", "Removed your channel alias 'foo'") + self.assertEqual(self.char1.nicks.get("foo $1", category="channel"), None)
+ +
[docs] def test_channel__mute(self): + self.call(self.cmdchannel(), "/mute testchannel", "Muted channel testchannel") + self.assertTrue(self.char1 in self.channel.mutelist)
+ +
[docs] def test_channel__unmute(self): + self.channel.mute(self.char1) + + self.call(self.cmdchannel(), "/unmute testchannel = Char1", "Un-muted channel testchannel") + self.assertFalse(self.char1 in self.channel.mutelist)
+ +
[docs] def test_channel__create(self): + self.call(self.cmdchannel(), "/create testchannel2", "Created (and joined) new channel")
+ +
[docs] def test_channel__destroy(self): + self.channel.msg = Mock() + self.call( + self.cmdchannel(), + "/destroy testchannel = delete reason", + "Are you sure you want to delete channel ", + inputs=["Yes"], + ) + self.channel.msg.assert_called_with("delete reason", bypass_mute=True, senders=self.char1)
+ +
[docs] def test_channel__desc(self): + self.call( + self.cmdchannel(), + "/desc testchannel = Another description", + "Updated channel description.", + )
+ +
[docs] def test_channel__lock(self): + self.call( + self.cmdchannel(), "/lock testchannel = foo:false()", "Added/updated lock on channel" + ) + self.assertEqual(self.channel.locks.get("foo"), "foo:false()")
+ +
[docs] def test_channel__unlock(self): + self.channel.locks.add("foo:true()") + self.call(self.cmdchannel(), "/unlock testchannel = foo", "Removed lock from channel") + self.assertEqual(self.channel.locks.get("foo"), "")
+ +
[docs] def test_channel__boot(self): + self.channel.connect(self.char2) + self.assertTrue(self.char2 in self.channel.subscriptions.all()) + self.channel.msg = Mock() + self.char2.msg = Mock() + + self.call( + self.cmdchannel(), + "/boot testchannel = Char2 : Booting from channel!", + "Are you sure ", + inputs=["Yes"], + ) + self.channel.msg.assert_called_with( + "Char2 was booted from channel by Char. Reason: Booting from channel!" + ) + self.char2.msg.assert_called_with( + "You were booted from channel testchannel by Char. Reason: Booting from channel!" + )
+ +
[docs] def test_channel__ban__unban(self): + """Test first ban and then unban""" + + # ban + self.channel.connect(self.char2) + self.assertTrue(self.char2 in self.channel.subscriptions.all()) + self.channel.msg = Mock() + self.char2.msg = Mock() + + self.call( + self.cmdchannel(), + "/ban testchannel = Char2 : Banning from channel!", + "Are you sure ", + inputs=["Yes"], + ) + self.channel.msg.assert_called_with( + "Char2 was booted from channel by Char. Reason: Banning from channel!" + ) + self.char2.msg.assert_called_with( + "You were booted from channel testchannel by Char. Reason: Banning from channel!" + ) + self.assertTrue(self.char2 in self.channel.banlist) + + # unban + + self.call( + self.cmdchannel(), + "/unban testchannel = Char2", + "Un-banned Char2 from channel testchannel", + ) + self.assertFalse(self.char2 in self.channel.banlist)
+ +
[docs] def test_channel__who(self): + self.call(self.cmdchannel(), "/who testchannel", "Subscribed to testchannel:\nChar")
+ + +from evennia.commands.default import comms # noqa + + +
[docs]class TestComms(BaseEvenniaCommandTest): +
[docs] def test_page(self): + self.call( + comms.CmdPage(), + "TestAccount2 = Test", + "TestAccount2 is offline. They will see your message if they list their pages later." + "|You paged TestAccount2 with: 'Test'.", + receiver=self.account, + )
+ + +
[docs]@override_settings(DISCORD_BOT_TOKEN="notarealtoken", DISCORD_ENABLED=True) +class TestDiscord(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.channel = create.create_channel(key="testchannel", desc="A test channel") + self.cmddiscord = cmd_comms.CmdDiscord2Chan + self.cmddiscord.account_caller = False + # create bot manually so it doesn't get started + self.discordbot = create.create_account( + "DiscordBot", None, None, typeclass="evennia.accounts.bots.DiscordBot" + )
+ +
[docs] def tearDown(self): + if self.channel.pk: + self.channel.delete()
+ + @parameterized.expand( + [ + ("", "No Discord connections found."), + ("/list", "No Discord connections found."), + ("/guild", "Messages to Evennia will include the Discord server."), + ("/channel", "Relayed messages will include the originating channel."), + ] + ) + def test_discord__switches(self, cmd_args, expected): + self.call(self.cmddiscord(), cmd_args, expected) + +
[docs] def test_discord__linking(self): + self.call( + self.cmddiscord(), "nosuchchannel = 5555555", "There is no channel 'nosuchchannel'" + ) + self.call( + self.cmddiscord(), + "testchannel = 5555555", + "Discord connection created: testchannel <-> #5555555", + ) + self.assertTrue(self.discordbot in self.channel.subscriptions.all()) + self.assertTrue(("testchannel", "5555555") in self.discordbot.db.channels) + self.call(self.cmddiscord(), "testchannel = 5555555", "Those channels are already linked.")
+ +
[docs] def test_discord__list(self): + self.discordbot.db.channels = [("testchannel", "5555555")] + cmdobj = self.cmddiscord() + cmdobj.msg = lambda text, **kwargs: setattr(self, "out", str(text)) + self.call(cmdobj, "", None) + self.assertIn("testchannel", self.out) + self.assertIn("5555555", self.out) + self.call(cmdobj, "testchannel", None) + self.assertIn("testchannel", self.out) + self.assertIn("5555555", self.out)
+ + +
[docs]class TestBatchProcess(BaseEvenniaCommandTest): + """ + Test the batch processor. + + """ + + # there is some sort of issue with the mock; it needs to loaded once to work + from evennia.contrib.tutorials.red_button import red_button # noqa + +
[docs] @patch("evennia.contrib.tutorials.red_button.red_button.repeat") + @patch("evennia.contrib.tutorials.red_button.red_button.delay") + def test_batch_commands(self, mock_tutorials, mock_repeat): + # cannot test batchcode here, it must run inside the server process + self.call( + batchprocess.CmdBatchCommands(), + "batchprocessor.example_batch_cmds_test", + "Running Batch-command processor - Automatic mode for" + " batchprocessor.example_batch_cmds", + ) + # we make sure to delete the button again here to stop the running reactor + confirm = building.CmdDestroy.confirm + building.CmdDestroy.confirm = False + self.call(building.CmdDestroy(), "button", "button was destroyed.") + building.CmdDestroy.confirm = confirm + mock_repeat.assert_called()
+ + +
[docs]class CmdInterrupt(Command): + key = "interrupt" + +
[docs] def parse(self): + raise InterruptCommand
+ +
[docs] def func(self): + self.msg("in func")
+ + +
[docs]class TestInterruptCommand(BaseEvenniaCommandTest): +
[docs] def test_interrupt_command(self): + ret = self.call(CmdInterrupt(), "") + self.assertEqual(ret, "")
+ + +
[docs]class TestUnconnectedCommand(BaseEvenniaCommandTest): +
[docs] def test_info_command(self): + # instead of using SERVER_START_TIME (0), we use 86400 because Windows won't let us use anything lower + gametime.SERVER_START_TIME = 86400 + expected = ( + "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END" + " INFO" + % ( + settings.SERVERNAME, + datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), + evennia.SESSION_HANDLER.account_count(), + utils.get_evennia_version(), + ) + ) + self.call(unloggedin.CmdUnconnectedInfo(), "", expected) + del gametime.SERVER_START_TIME
+ +
[docs] @override_settings(NEW_ACCOUNT_REGISTRATION_ENABLED=False) + def test_disabled_registration(self): + self.call( + unloggedin.CmdUnconnectedCreate(), + "testacct testpass", + "Registration is currently disabled.", + )
+ + +# Test syscommands + + +
[docs]class TestSystemCommands(BaseEvenniaCommandTest): +
[docs] def test_simple_defaults(self): + self.call(syscommands.SystemNoInput(), "") + self.call(syscommands.SystemNoMatch(), "Huh?")
+ +
[docs] def test_multimatch(self): + # set up fake matches and store on command instance + cmdset = CmdSet() + cmdset.add(general.CmdLook()) + cmdset.add(general.CmdLook()) + matches = cmdparser.build_matches("look", cmdset) + + multimatch = syscommands.SystemMultimatch() + multimatch.matches = matches + + self.call(multimatch, "look", "")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/commands/default/unloggedin.html b/docs/latest/_modules/evennia/commands/default/unloggedin.html new file mode 100644 index 0000000000..22101075b5 --- /dev/null +++ b/docs/latest/_modules/evennia/commands/default/unloggedin.html @@ -0,0 +1,573 @@ + + + + + + + + evennia.commands.default.unloggedin — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.commands.default.unloggedin

+"""
+Commands that are available from the connect screen.
+
+"""
+import datetime
+import re
+from codecs import lookup as codecs_lookup
+
+from django.conf import settings
+
+import evennia
+from evennia.commands.cmdhandler import CMD_LOGINSTART
+from evennia.comms.models import ChannelDB
+from evennia.utils import class_from_module, create, gametime, logger, utils
+
+COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# limit symbol import for API
+__all__ = (
+    "CmdUnconnectedConnect",
+    "CmdUnconnectedCreate",
+    "CmdUnconnectedQuit",
+    "CmdUnconnectedLook",
+    "CmdUnconnectedHelp",
+    "CmdUnconnectedEncoding",
+    "CmdUnconnectedInfo",
+    "CmdUnconnectedScreenreader",
+)
+
+CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
+
+
+def create_guest_account(session):
+    """
+    Creates a guest account/character for this session, if one is available.
+
+    Args:
+        session (Session): the session which will use the guest account/character.
+
+    Returns:
+        GUEST_ENABLED (boolean), account (Account):
+            the boolean is whether guest accounts are enabled at all.
+            the Account which was created from an available guest name.
+    """
+    enabled = settings.GUEST_ENABLED
+    address = session.address
+
+    # Get account class
+    Guest = class_from_module(settings.BASE_GUEST_TYPECLASS)
+
+    # Get an available guest account
+    # authenticate() handles its own throttling
+    account, errors = Guest.authenticate(ip=address)
+    if account:
+        return enabled, account
+    else:
+        session.msg("|R%s|n" % "\n".join(errors))
+        return enabled, None
+
+
+def create_normal_account(session, name, password):
+    """
+    Creates an account with the given name and password.
+
+    Args:
+        session (Session): the session which is requesting to create an account.
+        name (str): the name that the account wants to use for login.
+        password (str): the password desired by this account, for login.
+
+    Returns:
+        account (Account): the account which was created from the name and password.
+    """
+    # Get account class
+    Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
+
+    address = session.address
+
+    # Match account name and check password
+    # authenticate() handles all its own throttling
+    account, errors = Account.authenticate(
+        username=name, password=password, ip=address, session=session
+    )
+    if not account:
+        # No accountname or password match
+        session.msg("|R%s|n" % "\n".join(errors))
+        return None
+
+    return account
+
+
+
[docs]class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): + """ + connect to the game + + Usage (at login screen): + connect accountname password + connect "account name" "pass word" + + Use the create command to first create an account before logging in. + + If you have spaces in your name, enclose it in double quotes. + """ + + key = "connect" + aliases = ["conn", "con", "co"] + locks = "cmd:all()" # not really needed + arg_regex = r"\s.*?|$" + +
[docs] def func(self): + """ + Uses the Django admin api. Note that unlogged-in commands + have a unique position in that their func() receives + a session object instead of a source_object like all + other types of logged-in commands (this is because + there is no object yet before the account has logged in) + """ + session = self.caller + address = session.address + + args = self.args + # extract double quote parts + parts = [part.strip() for part in re.split(r"\"", args) if part.strip()] + if len(parts) == 1: + # this was (hopefully) due to no double quotes being found, or a guest login + parts = parts[0].split(None, 1) + + # Guest login + if len(parts) == 1 and parts[0].lower() == "guest": + # Get Guest typeclass + Guest = class_from_module(settings.BASE_GUEST_TYPECLASS) + + account, errors = Guest.authenticate(ip=address) + if account: + session.sessionhandler.login(session, account) + return + else: + session.msg("|R%s|n" % "\n".join(errors)) + return + + if len(parts) != 2: + session.msg("\n\r Usage (without <>): connect <name> <password>") + return + + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + + name, password = parts + account, errors = Account.authenticate( + username=name, password=password, ip=address, session=session + ) + if account: + session.sessionhandler.login(session, account) + else: + session.msg("|R%s|n" % "\n".join(errors))
+ + +
[docs]class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): + """ + create a new account account + + Usage (at login screen): + create <accountname> <password> + create "account name" "pass word" + + This creates a new account account. + + If you have spaces in your name, enclose it in double quotes. + """ + + key = "create" + aliases = ["cre", "cr"] + locks = "cmd:all()" + arg_regex = r"\s.*?|$" + +
[docs] def at_pre_cmd(self): + """Verify that account creation is enabled.""" + if not settings.NEW_ACCOUNT_REGISTRATION_ENABLED: + # truthy return cancels the command + self.msg("Registration is currently disabled.") + return True + + return super().at_pre_cmd()
+ +
[docs] def func(self): + """Do checks and create account""" + + session = self.caller + args = self.args.strip() + + address = session.address + + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + + # extract double quoted parts + parts = [part.strip() for part in re.split(r"\"", args) if part.strip()] + if len(parts) == 1: + # this was (hopefully) due to no quotes being found + parts = parts[0].split(None, 1) + if len(parts) != 2: + string = ( + "\n Usage (without <>): create <name> <password>" + "\nIf <name> or <password> contains spaces, enclose it in double quotes." + ) + session.msg(string) + return + + username, password = parts + + # pre-normalize username so the user know what they get + non_normalized_username = username + username = Account.normalize_username(username) + if non_normalized_username != username: + session.msg( + "Note: your username was normalized to strip spaces and remove characters " + "that could be visually confusing." + ) + + # have the user verify their new account was what they intended + answer = yield ( + f"You want to create an account '{username}' with password '{password}'." + "\nIs this what you intended? [Y]/N?" + ) + if answer.lower() in ("n", "no"): + session.msg("Aborted. If your user name contains spaces, surround it by quotes.") + return + + # everything's ok. Create the new player account. + account, errors = Account.create( + username=username, password=password, ip=address, session=session + ) + if account: + # tell the caller everything went well. + string = "A new account '%s' was created. Welcome!" + if " " in username: + string += ( + "\n\nYou can now log in with the command 'connect \"%s\" <your password>'." + ) + else: + string += "\n\nYou can now log with the command 'connect %s <your password>'." + session.msg(string % (username, username)) + else: + session.msg("|R%s|n" % "\n".join(errors))
+ + +
[docs]class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS): + """ + quit when in unlogged-in state + + Usage: + quit + + We maintain a different version of the quit command + here for unconnected accounts for the sake of simplicity. The logged in + version is a bit more complicated. + """ + + key = "quit" + aliases = ["q", "qu"] + locks = "cmd:all()" + +
[docs] def func(self): + """Simply close the connection.""" + session = self.caller + session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
+ + +
[docs]class CmdUnconnectedLook(COMMAND_DEFAULT_CLASS): + """ + look when in unlogged-in state + + Usage: + look + + This is an unconnected version of the look command for simplicity. + + This is called by the server and kicks everything in gear. + All it does is display the connect screen. + """ + + key = CMD_LOGINSTART + aliases = ["look", "l"] + locks = "cmd:all()" + +
[docs] def func(self): + """Show the connect screen.""" + + callables = utils.callables_from_module(CONNECTION_SCREEN_MODULE) + if "connection_screen" in callables: + connection_screen = callables["connection_screen"]() + else: + connection_screen = utils.random_string_from_module(CONNECTION_SCREEN_MODULE) + if not connection_screen: + connection_screen = "No connection screen found. Please contact an admin." + self.msg(connection_screen)
+ + +
[docs]class CmdUnconnectedHelp(COMMAND_DEFAULT_CLASS): + """ + get help when in unconnected-in state + + Usage: + help + + This is an unconnected version of the help command, + for simplicity. It shows a pane of info. + """ + + key = "help" + aliases = ["h", "?"] + locks = "cmd:all()" + +
[docs] def func(self): + """Shows help""" + + string = """ +You are not yet logged into the game. Commands available at this point: + + |wcreate|n - create a new account + |wconnect|n - connect with an existing account + |wlook|n - re-show the connection screen + |whelp|n - show this help + |wencoding|n - change the text encoding to match your client + |wscreenreader|n - make the server more suitable for use with screen readers + |wquit|n - abort the connection + +First create an account e.g. with |wcreate Anna c67jHL8p|n +(If you have spaces in your name, use double quotes: |wcreate "Anna the Barbarian" c67jHL8p|n +Next you can connect to the game: |wconnect Anna c67jHL8p|n + +You can use the |wlook|n command if you want to see the connect screen again. + +""" + + if settings.STAFF_CONTACT_EMAIL: + string += "For support, please contact: %s" % settings.STAFF_CONTACT_EMAIL + self.msg(string)
+ + +
[docs]class CmdUnconnectedEncoding(COMMAND_DEFAULT_CLASS): + """ + set which text encoding to use in unconnected-in state + + Usage: + encoding/switches [<encoding>] + + Switches: + clear - clear your custom encoding + + + This sets the text encoding for communicating with Evennia. This is mostly + an issue only if you want to use non-ASCII characters (i.e. letters/symbols + not found in English). If you see that your characters look strange (or you + get encoding errors), you should use this command to set the server + encoding to be the same used in your client program. + + Common encodings are utf-8 (default), latin-1, ISO-8859-1 etc. + + If you don't submit an encoding, the current encoding will be displayed + instead. + """ + + key = "encoding" + aliases = "encode" + locks = "cmd:all()" + +
[docs] def func(self): + """ + Sets the encoding. + """ + + if self.session is None: + return + + sync = False + if "clear" in self.switches: + # remove customization + old_encoding = self.session.protocol_flags.get("ENCODING", None) + if old_encoding: + string = "Your custom text encoding ('%s') was cleared." % old_encoding + else: + string = "No custom encoding was set." + self.session.protocol_flags["ENCODING"] = "utf-8" + sync = True + elif not self.args: + # just list the encodings supported + pencoding = self.session.protocol_flags.get("ENCODING", None) + string = "" + if pencoding: + string += ( + "Default encoding: |g%s|n (change with |wencoding <encoding>|n)" % pencoding + ) + encodings = settings.ENCODINGS + if encodings: + string += ( + "\nServer's alternative encodings (tested in this order):\n |g%s|n" + % ", ".join(encodings) + ) + if not string: + string = "No encodings found." + else: + # change encoding + old_encoding = self.session.protocol_flags.get("ENCODING", None) + encoding = self.args + try: + codecs_lookup(encoding) + except LookupError: + string = ( + "|rThe encoding '|w%s|r' is invalid. Keeping the previous encoding '|w%s|r'.|n" + % (encoding, old_encoding) + ) + else: + self.session.protocol_flags["ENCODING"] = encoding + string = "Your custom text encoding was changed from '|w%s|n' to '|w%s|n'." % ( + old_encoding, + encoding, + ) + sync = True + if sync: + self.session.sessionhandler.session_portal_sync(self.session) + self.msg(string.strip())
+ + +
[docs]class CmdUnconnectedScreenreader(COMMAND_DEFAULT_CLASS): + """ + Activate screenreader mode. + + Usage: + screenreader + + Used to flip screenreader mode on and off before logging in (when + logged in, use option screenreader on). + """ + + key = "screenreader" + +
[docs] def func(self): + """Flips screenreader setting.""" + new_setting = not self.session.protocol_flags.get("SCREENREADER", False) + self.session.protocol_flags["SCREENREADER"] = new_setting + string = "Screenreader mode turned |w%s|n." % ("on" if new_setting else "off") + self.msg(string) + self.session.sessionhandler.session_portal_sync(self.session)
+ + +
[docs]class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS): + """ + Provides MUDINFO output, so that Evennia games can be added to Mudconnector + and Mudstats. Sadly, the MUDINFO specification seems to have dropped off the + face of the net, but it is still used by some crawlers. This implementation + was created by looking at the MUDINFO implementation in MUX2, TinyMUSH, Rhost, + and PennMUSH. + """ + + key = "info" + locks = "cmd:all()" + +
[docs] def func(self): + self.msg( + "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END" + " INFO" + % ( + settings.SERVERNAME, + datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), + evennia.SESSION_HANDLER.account_count(), + utils.get_evennia_version(), + ) + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/comms/comms.html b/docs/latest/_modules/evennia/comms/comms.html new file mode 100644 index 0000000000..6930b4a30d --- /dev/null +++ b/docs/latest/_modules/evennia/comms/comms.html @@ -0,0 +1,1002 @@ + + + + + + + + evennia.comms.comms — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.comms.comms

+"""
+Base typeclass for in-game Channels.
+
+"""
+import re
+
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+from django.utils.text import slugify
+
+import evennia
+from evennia.comms.managers import ChannelManager
+from evennia.comms.models import ChannelDB
+from evennia.typeclasses.models import TypeclassBase
+from evennia.utils import create, logger
+from evennia.utils.utils import make_iter, inherits_from
+
+
+
[docs]class DefaultChannel(ChannelDB, metaclass=TypeclassBase): + """ + This is the base class for all Channel Comms. Inherit from this to + create different types of communication channels. + + Class-level variables: + - `send_to_online_only` (bool, default True) - if set, will only try to + send to subscribers that are actually active. This is a useful optimization. + - `log_file` (str, default `"channel_{channelname}.log"`). This is the + log file to which the channel history will be saved. The `{channelname}` tag + will be replaced by the key of the Channel. If an Attribute 'log_file' + is set, this will be used instead. If this is None and no Attribute is found, + no history will be saved. + - `channel_prefix_string` (str, default `"[{channelname} ]"`) - this is used + as a simple template to get the channel prefix with `.channel_prefix()`. It is used + in front of every channel message; use `{channelmessage}` token to insert the + name of the current channel. Set to `None` if you want no prefix (or want to + handle it in a hook during message generation instead. + - `channel_msg_nick_pattern`(str, default `"{alias}\\s*?|{alias}\\s+?(?P<arg1>.+?)") - + this is what used when a channel subscriber gets a channel nick assigned to this + channel. The nickhandler uses the pattern to pick out this channel's name from user + input. The `{alias}` token will get both the channel's key and any set/custom aliases + per subscriber. You need to allow for an `<arg1>` regex group to catch any message + that should be send to the channel. You usually don't need to change this pattern + unless you are changing channel command-style entirely. + - `channel_msg_nick_replacement` (str, default `"channel {channelname} = $1"` - this + is used by the nickhandler to generate a replacement string once the nickhandler (using + the `channel_msg_nick_pattern`) identifies that the channel should be addressed + to send a message to it. The `<arg1>` regex pattern match from `channel_msg_nick_pattern` + will end up at the `$1` position in the replacement. Together, this allows you do e.g. + 'public Hello' and have that become a mapping to `channel public = Hello`. By default, + the account-level `channel` command is used. If you were to rename that command you must + tweak the output to something like `yourchannelcommandname {channelname} = $1`. + + """ + + objects = ChannelManager() + + # channel configuration + + # only send to characters/accounts who has an active session (this is a + # good optimization since people can still recover history separately). + send_to_online_only = True + # store log in log file. `channel_key tag will be replace with key of channel. + # Will use log_file Attribute first, if given + log_file = "channel_{channelname}.log" + # which prefix to use when showing were a message is coming from. Set to + # None to disable and set this later. + channel_prefix_string = "[{channelname}] " + + # default nick-alias replacements (default using the 'channel' command) + channel_msg_nick_pattern = r"{alias}\s*?|{alias}\s+?(?P<arg1>.+?)" + channel_msg_nick_replacement = "@channel {channelname} = $1" + +
[docs] def at_first_save(self): + """ + Called by the typeclass system the very first time the channel + is saved to the database. Generally, don't overload this but + the hooks called by this method. + + """ + self.basetype_setup() + self.at_channel_creation() + # initialize Attribute/TagProperties + self.init_evennia_properties() + + if hasattr(self, "_createdict"): + # this is only set if the channel was created + # with the utils.create.create_channel function. + cdict = self._createdict + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#i" % self.dbid + elif cdict["key"] and self.key != cdict["key"]: + self.key = cdict["key"] + if cdict.get("aliases"): + self.aliases.add(cdict["aliases"]) + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("keep_log"): + self.attributes.add("keep_log", cdict["keep_log"]) + if cdict.get("desc"): + self.attributes.add("desc", cdict["desc"]) + if cdict.get("tags"): + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attrs"): + self.attributes.batch_add(*cdict["attrs"])
+ +
[docs] def basetype_setup(self): + self.locks.add("send:all();listen:all();control:perm(Admin)") + + # make sure we don't have access to a same-named old channel's history. + log_file = self.get_log_filename() + logger.rotate_log_file(log_file, num_lines_to_append=0)
+ +
[docs] def at_channel_creation(self): + """ + Called once, when the channel is first created. + + """ + pass
+ + # helper methods, for easy overloading + + _log_file = None + +
[docs] def get_log_filename(self): + """ + File name to use for channel log. + + Returns: + str: The filename to use (this is always assumed to be inside + settings.LOG_DIR) + + """ + if not self._log_file: + self._log_file = self.attributes.get( + "log_file", self.log_file.format(channelname=self.key.lower()) + ) + return self._log_file
+ +
[docs] def set_log_filename(self, filename): + """ + Set a custom log filename. + + Args: + filename (str): The filename to set. This is a path starting from + inside the settings.LOG_DIR location. + + """ + self.attributes.add("log_file", filename)
+ +
[docs] def has_connection(self, subscriber): + """ + Checks so this account is actually listening + to this channel. + + Args: + subscriber (Account or Object): Entity to check. + + Returns: + has_sub (bool): Whether the subscriber is subscribing to + this channel or not. + + Notes: + This will first try Account subscribers and only try Object + if the Account fails. + + """ + has_sub = self.subscriptions.has(subscriber) + if not has_sub and inherits_from(subscriber, evennia.DefaultObject): + # it's common to send an Object when we + # by default only allow Accounts to subscribe. + has_sub = self.subscriptions.has(subscriber.account) + return has_sub
+ + @property + def mutelist(self): + return self.db.mute_list or [] + + @property + def banlist(self): + return self.db.ban_list or [] + + @property + def wholist(self): + subs = self.subscriptions.all() + muted = list(self.mutelist) + listening = [ob for ob in subs if ob.is_connected and ob not in muted] + if subs: + # display listening subscribers in bold + string = ", ".join( + [ + account.key if account not in listening else f"|w{account.key}|n" + for account in subs + ] + ) + else: + string = "<None>" + return string + +
[docs] def mute(self, subscriber, **kwargs): + """ + Adds an entity to the list of muted subscribers. + A muted subscriber will no longer see channel messages, + but may use channel commands. + + Args: + subscriber (Object or Account): Subscriber to mute. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + bool: True if muting was successful, False if we were already + muted. + + """ + mutelist = self.mutelist + if subscriber not in mutelist: + mutelist.append(subscriber) + self.db.mute_list = mutelist + return True + return False
+ +
[docs] def unmute(self, subscriber, **kwargs): + """ + Removes an entity from the list of muted subscribers. A muted subscriber + will no longer see channel messages, but may use channel commands. + + Args: + subscriber (Object or Account): The subscriber to unmute. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + bool: True if unmuting was successful, False if we were already + unmuted. + + """ + mutelist = self.mutelist + if subscriber in mutelist: + mutelist.remove(subscriber) + return True + return False
+ +
[docs] def ban(self, target, **kwargs): + """ + Ban a given user from connecting to the channel. This will not stop + users already connected, so the user must be booted for this to take + effect. + + Args: + target (Object or Account): The entity to unmute. This need not + be a subscriber. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + bool: True if banning was successful, False if target was already + banned. + """ + banlist = self.banlist + if target not in banlist: + banlist.append(target) + self.db.ban_list = banlist + return True + return False
+ +
[docs] def unban(self, target, **kwargs): + """ + Un-Ban a given user. This will not reconnect them - they will still + have to reconnect and set up aliases anew. + + Args: + target (Object or Account): The entity to unmute. This need not + be a subscriber. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + bool: True if unbanning was successful, False if target was not + previously banned. + """ + banlist = list(self.banlist) + if target in banlist: + banlist = [banned for banned in banlist if banned != target] + self.db.ban_list = banlist + return True + return False
+ +
[docs] def connect(self, subscriber, **kwargs): + """ + Connect the user to this channel. This checks access. + + Args: + subscriber (Account or Object): the entity to subscribe + to this channel. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + success (bool): Whether or not the addition was + successful. + + """ + # check access + if subscriber in self.banlist or not self.access(subscriber, "listen"): + return False + # pre-join hook + connect = self.pre_join_channel(subscriber) + if not connect: + return False + # subscribe + self.subscriptions.add(subscriber) + # unmute + self.unmute(subscriber) + # post-join hook + self.post_join_channel(subscriber) + return True
+ +
[docs] def disconnect(self, subscriber, **kwargs): + """ + Disconnect entity from this channel. + + Args: + subscriber (Account of Object): the + entity to disconnect. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + success (bool): Whether or not the removal was + successful. + + """ + # pre-disconnect hook + disconnect = self.pre_leave_channel(subscriber) + if not disconnect: + return False + # disconnect + self.subscriptions.remove(subscriber) + # unmute + self.unmute(subscriber) + # post-disconnect hook + self.post_leave_channel(subscriber) + return True
+ +
[docs] def access( + self, + accessing_obj, + access_type="listen", + default=False, + no_superuser_bypass=False, + **kwargs, + ): + """ + Determines if another object has permission to access. + + Args: + accessing_obj (Object): Object trying to access this one. + access_type (str, optional): Type of access sought. + default (bool, optional): What to return if no lock of access_type was found + no_superuser_bypass (bool, optional): Turns off superuser + lock bypass. Be careful with this one. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + return (bool): Result of lock check. + + """ + return self.locks.check( + accessing_obj, + access_type=access_type, + default=default, + no_superuser_bypass=no_superuser_bypass, + )
+ +
[docs] @classmethod + def create(cls, key, creator=None, *args, **kwargs): + """ + Creates a basic Channel with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_channel() function. + + Args: + key (str): This must be unique. + creator (Account or Object): Entity to associate with this channel + (used for tracking) + + Keyword Args: + aliases (list of str): List of alternative (likely shorter) keynames. + description (str): A description of the channel, for use in listings. + locks (str): Lockstring. + keep_log (bool): Log channel throughput. + typeclass (str or class): The typeclass of the Channel (not + often used). + ip (str): IP address of creator (for object auditing). + + Returns: + channel (Channel): A newly created Channel. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + ip = kwargs.pop("ip", "") + + try: + kwargs["desc"] = kwargs.pop("description", "") + kwargs["typeclass"] = kwargs.get("typeclass", cls) + obj = create.create_channel(key, *args, **kwargs) + + # Record creator id and creation IP + if ip: + obj.db.creator_ip = ip + if creator: + obj.db.creator_id = creator.id + + except Exception as exc: + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(exc) + + return obj, errors
+ +
[docs] def delete(self): + """ + Deletes channel. + + Returns: + bool: If deletion was successful. Only time it can fail would be + if channel was already deleted. Even if it were to fail, all subscribers + will be disconnected. + + """ + self.attributes.clear() + self.aliases.clear() + for subscriber in self.subscriptions.all(): + self.disconnect(subscriber) + if not self.pk: + return False + super().delete() + return True
+ +
[docs] def channel_prefix(self): + """ + Hook method. How the channel should prefix itself for users. + + Returns: + str: The channel prefix. + + """ + return self.channel_prefix_string.format(channelname=self.key)
+ +
[docs] def add_user_channel_alias(self, user, alias, **kwargs): + """ + Add a personal user-alias for this channel to a given subscriber. + + Args: + user (Object or Account): The one to alias this channel. + alias (str): The desired alias. + + Note: + This is tightly coupled to the default `channel` command. If you + change that, you need to change this as well. + + We add two nicks - one is a plain `alias -> channel.key` that + users need to be able to reference this channel easily. The other + is a templated nick to easily be able to send messages to the + channel without needing to give the full `channel` command. The + structure of this nick is given by `self.channel_msg_nick_pattern` + and `self.channel_msg_nick_replacement`. By default it maps + `alias <msg> -> channel <channelname> = <msg>`, so that you can + for example just write `pub Hello` to send a message. + + The alias created is `alias $1 -> channel channel = $1`, to allow + for sending to channel using the main channel command. + + """ + chan_key = self.key.lower() + + # the message-pattern allows us to type the channel on its own without + # needing to use the `channel` command explicitly. + msg_nick_pattern = self.channel_msg_nick_pattern.format(alias=re.escape(alias)) + msg_nick_replacement = self.channel_msg_nick_replacement.format(channelname=chan_key) + user.nicks.add( + msg_nick_pattern, + msg_nick_replacement, + category="inputline", + pattern_is_regex=True, + **kwargs, + ) + + if chan_key != alias: + # this allows for using the alias for general channel lookups + user.nicks.add(alias, chan_key, category="channel", **kwargs)
+ +
[docs] @classmethod + def remove_user_channel_alias(cls, user, alias, **kwargs): + """ + Remove a personal channel alias from a user. + + Args: + user (Object or Account): The user to remove an alias from. + alias (str): The alias to remove. + **kwargs: Unused by default. Can be used to pass extra variables + into a custom implementation. + + Notes: + The channel-alias actually consists of two aliases - one + channel-based one for searching channels with the alias and one + inputline one for doing the 'channelalias msg' - call. + + This is a classmethod because it doesn't actually operate on the + channel instance. + + It sits on the channel because the nick structure for this is + pretty complex and needs to be located in a central place (rather + on, say, the channel command). + + """ + user.nicks.remove(alias, category="channel", **kwargs) + msg_nick_pattern = cls.channel_msg_nick_pattern.format(alias=alias) + user.nicks.remove(msg_nick_pattern, category="inputline", **kwargs)
+ +
[docs] def at_pre_msg(self, message, **kwargs): + """ + Called before the starting of sending the message to a receiver. This + is called before any hooks on the receiver itself. If this returns + None/False, the sending will be aborted. + + Args: + message (str): The message to send. + **kwargs (any): Keywords passed on from `.msg`. This includes + `senders`. + + Returns: + str, False or None: Any custom changes made to the message. If + falsy, no message will be sent. + + """ + return message
+ +
[docs] def msg(self, message, senders=None, bypass_mute=False, **kwargs): + """ + Send message to channel, causing it to be distributed to all non-muted + subscribed users of that channel. + + Args: + message (str): The message to send. + senders (Object, Account or list, optional): If not given, there is + no way to associate one or more senders with the message (like + a broadcast message or similar). + bypass_mute (bool, optional): If set, always send, regardless of + individual mute-state of subscriber. This can be used for + global announcements or warnings/alerts. + **kwargs (any): This will be passed on to all hooks. Use `no_prefix` + to exclude the channel prefix. + + Notes: + The call hook calling sequence is: + + - `msg = channel.at_pre_msg(message, **kwargs)` (aborts for all if return None) + - `msg = receiver.at_pre_channel_msg(msg, channel, **kwargs)` (aborts for receiver if return None) + - `receiver.at_channel_msg(msg, channel, **kwargs)` + - `receiver.at_post_channel_msg(msg, channel, **kwargs)`` + Called after all receivers are processed: + - `channel.at_post_all_msg(message, **kwargs)` + + (where the senders/bypass_mute are embedded into **kwargs for + later access in hooks) + + """ + senders = make_iter(senders) if senders else [] + if self.send_to_online_only: + receivers = self.subscriptions.online() + else: + receivers = self.subscriptions.all() + if not bypass_mute: + receivers = [receiver for receiver in receivers if receiver not in self.mutelist] + + send_kwargs = {"senders": senders, "bypass_mute": bypass_mute, **kwargs} + + # pre-send hook + message = self.at_pre_msg(message, **send_kwargs) + if message in (None, False): + return + + for receiver in receivers: + # send to each individual subscriber + + try: + recv_message = receiver.at_pre_channel_msg(message, self, **send_kwargs) + if recv_message in (None, False): + return + + receiver.channel_msg(recv_message, self, **send_kwargs) + + receiver.at_post_channel_msg(recv_message, self, **send_kwargs) + + except Exception: + logger.log_trace(f"Error sending channel message to {receiver}.") + + # post-send hook + self.at_post_msg(message, **send_kwargs)
+ +
[docs] def at_post_msg(self, message, **kwargs): + """ + This is called after sending to *all* valid recipients. It is normally + used for logging/channel history. + + Args: + message (str): The message sent. + **kwargs (any): Keywords passed on from `msg`, including `senders`. + + """ + # save channel history to log file + log_file = self.get_log_filename() + if log_file: + senders = ",".join(sender.key for sender in kwargs.get("senders", [])) + senders = f"{senders}: " if senders else "" + message = f"{senders}{message}" + logger.log_file(message, log_file)
+ +
[docs] def pre_join_channel(self, joiner, **kwargs): + """ + Hook method. Runs right before a channel is joined. If this + returns a false value, channel joining is aborted. + + Args: + joiner (object): The joining object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + should_join (bool): If `False`, channel joining is aborted. + + """ + return True
+ +
[docs] def post_join_channel(self, joiner, **kwargs): + """ + Hook method. Runs right after an object or account joins a channel. + + Args: + joiner (object): The joining object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + By default this adds the needed channel nicks to the joiner. + + """ + key_and_aliases = [self.key.lower()] + [alias.lower() for alias in self.aliases.all()] + for key_or_alias in key_and_aliases: + self.add_user_channel_alias(joiner, key_or_alias, **kwargs)
+ +
[docs] def pre_leave_channel(self, leaver, **kwargs): + """ + Hook method. Runs right before a user leaves a channel. If this returns a false + value, leaving the channel will be aborted. + + Args: + leaver (object): The leaving object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + should_leave (bool): If `False`, channel parting is aborted. + + """ + return True
+ +
[docs] def post_leave_channel(self, leaver, **kwargs): + """ + Hook method. Runs right after an object or account leaves a channel. + + Args: + leaver (object): The leaving object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + chan_key = self.key.lower() + key_or_aliases = [self.key.lower()] + [alias.lower() for alias in self.aliases.all()] + nicktuples = leaver.nicks.get(category="channel", return_tuple=True, return_list=True) + key_or_aliases += [tup[2] for tup in nicktuples if tup[3].lower() == chan_key] + for key_or_alias in key_or_aliases: + self.remove_user_channel_alias(leaver, key_or_alias, **kwargs)
+ +
[docs] def at_init(self): + """ + Hook method. This is always called whenever this channel is + initiated -- that is, whenever it its typeclass is cached from + memory. This happens on-demand first time the channel is used + or activated in some way after being created but also after + each server restart or reload. + + """ + pass
+ + # + # Web/Django methods + # + +
[docs] def web_get_admin_url(self): + """ + Returns the URI path for the Django Admin page for this object. + + ex. Account#1 = '/admin/accounts/accountdb/1/change/' + + Returns: + path (str): URI path to Django Admin page for object. + + """ + content_type = ContentType.objects.get_for_model(self.__class__) + return reverse( + "admin:%s_%s_change" % (content_type.app_label, content_type.model), args=(self.id,) + )
+ +
[docs] @classmethod + def web_get_create_url(cls): + """ + Returns the URI path for a View that allows users to create new + instances of this object. + + ex. Chargen = '/characters/create/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'channel-create' would be referenced by this method. + + ex. + url(r'channels/create/', ChannelCreateView.as_view(), name='channel-create') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can create new objects is the + developer's responsibility. + + Returns: + path (str): URI path to object creation page, if defined. + + """ + try: + return reverse("%s-create" % slugify(cls._meta.verbose_name)) + except Exception: + return "#"
+ +
[docs] def web_get_detail_url(self): + """ + Returns the URI path for a View that allows users to view details for + this object. + + ex. Oscar (Character) = '/characters/oscar/1/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'channel-detail' would be referenced by this method. + + ex. + :: + + url(r'channels/(?P<slug>[\w\d\-]+)/$', + ChannelDetailView.as_view(), name='channel-detail') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can view this object is the developer's + responsibility. + + Returns: + path (str): URI path to object detail page, if defined. + + """ + try: + return reverse( + "%s-detail" % slugify(self._meta.verbose_name), + kwargs={"slug": slugify(self.db_key)}, + ) + except Exception: + return "#"
+ +
[docs] def web_get_update_url(self): + """ + Returns the URI path for a View that allows users to update this + object. + + ex. Oscar (Character) = '/characters/oscar/1/change/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'channel-update' would be referenced by this method. + + ex. + :: + + url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$', + ChannelUpdateView.as_view(), name='channel-update') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can modify objects is the developer's + responsibility. + + Returns: + path (str): URI path to object update page, if defined. + + """ + try: + return reverse( + "%s-update" % slugify(self._meta.verbose_name), + kwargs={"slug": slugify(self.db_key)}, + ) + except Exception: + return "#"
+ +
[docs] def web_get_delete_url(self): + """ + Returns the URI path for a View that allows users to delete this object. + + ex. Oscar (Character) = '/characters/oscar/1/delete/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'channel-delete' would be referenced by this method. + + ex. + url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$', + ChannelDeleteView.as_view(), name='channel-delete') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can delete this object is the developer's + responsibility. + + Returns: + path (str): URI path to object deletion page, if defined. + + """ + try: + return reverse( + "%s-delete" % slugify(self._meta.verbose_name), + kwargs={"slug": slugify(self.db_key)}, + ) + except Exception: + return "#"
+ + # Used by Django Sites/Admin + get_absolute_url = web_get_detail_url + + # TODO Evennia 1.0+ removed hooks. Remove in 1.1. +
[docs] def message_transform(self, *args, **kwargs): + raise RuntimeError( + "Channel.message_transform is no longer used in 1.0+. " + "Use Account/Object.at_pre_channel_msg instead." + )
+ +
[docs] def distribute_message(self, msgobj, online=False, **kwargs): + raise RuntimeError("Channel.distribute_message is no longer used in 1.0+.")
+ +
[docs] def format_senders(self, senders=None, **kwargs): + raise RuntimeError( + "Channel.format_senders is no longer used in 1.0+. " + "Use Account/Object.at_pre_channel_msg instead." + )
+ +
[docs] def pose_transform(self, msgobj, sender_string, **kwargs): + raise RuntimeError( + "Channel.pose_transform is no longer used in 1.0+. " + "Use Account/Object.at_pre_channel_msg instead." + )
+ +
[docs] def format_external(self, msgobj, senders, emit=False, **kwargs): + raise RuntimeError( + "Channel.format_external is no longer used in 1.0+. " + "Use Account/Object.at_pre_channel_msg instead." + )
+ +
[docs] def format_message(self, msgobj, emit=False, **kwargs): + raise RuntimeError( + "Channel.format_message is no longer used in 1.0+. " + "Use Account/Object.at_pre_channel_msg instead." + )
+ +
[docs] def pre_send_message(self, msg, **kwargs): + raise RuntimeError("Channel.pre_send_message was renamed to Channel.at_pre_msg.")
+ +
[docs] def post_send_message(self, msg, **kwargs): + raise RuntimeError("Channel.post_send_message was renamed to Channel.at_post_msg.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/comms/managers.html b/docs/latest/_modules/evennia/comms/managers.html new file mode 100644 index 0000000000..ebca26ce67 --- /dev/null +++ b/docs/latest/_modules/evennia/comms/managers.html @@ -0,0 +1,634 @@ + + + + + + + + evennia.comms.managers — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.comms.managers

+"""
+These managers define helper methods for accessing the database from
+Comm system components.
+
+"""
+
+
+from django.conf import settings
+from django.db.models import Q
+
+from evennia.server import signals
+from evennia.typeclasses.managers import TypeclassManager, TypedObjectManager
+from evennia.utils import logger
+from evennia.utils.utils import class_from_module, dbref, make_iter
+
+_GA = object.__getattribute__
+_AccountDB = None
+_ObjectDB = None
+_ChannelDB = None
+_ScriptDB = None
+_SESSIONS = None
+
+# error class
+
+
+
[docs]class CommError(Exception): + """ + Raised by comm system, to allow feedback to player when caught. + """ + + pass
+ + +# +# helper functions +# + + +
[docs]def identify_object(inp): + """ + Helper function. Identifies if an object is an account or an object; + return its database model + + Args: + inp (any): Entity to be idtified. + + Returns: + identified (tuple): This is a tuple with (`inp`, identifier) + where `identifier` is one of "account", "object", "channel", + "string", "dbref" or None. + + """ + if hasattr(inp, "__dbclass__"): + clsname = inp.__dbclass__.__name__ + if clsname == "AccountDB": + return inp, "account" + elif clsname == "ObjectDB": + return inp, "object" + elif clsname == "ChannelDB": + return inp, "channel" + elif clsname == "ScriptDB": + return inp, "script" + if isinstance(inp, str): + return inp, "string" + elif dbref(inp): + return dbref(inp), "dbref" + else: + return inp, None
+ + +
[docs]def to_object(inp, objtype="account"): + """ + Locates the object related to the given accountname or channel key. + If input was already the correct object, return it. + + Args: + inp (any): The input object/string + objtype (str): Either 'account' or 'channel'. + + Returns: + obj (object): The correct object related to `inp`. + + """ + obj, typ = identify_object(inp) + if typ == objtype: + return obj + if objtype == "account": + if typ == "object": + return obj.account + if typ == "string": + return _AccountDB.objects.get(user_username__iexact=obj) + if typ == "dbref": + return _AccountDB.objects.get(id=obj) + logger.log_err("%s %s %s %s %s" % (objtype, inp, obj, typ, type(inp))) + raise CommError() + elif objtype == "object": + if typ == "account": + return obj.obj + if typ == "string": + return _ObjectDB.objects.get(db_key__iexact=obj) + if typ == "dbref": + return _ObjectDB.objects.get(id=obj) + logger.log_err("%s %s %s %s %s" % (objtype, inp, obj, typ, type(inp))) + raise CommError() + elif objtype == "channel": + if typ == "string": + return _ChannelDB.objects.get(db_key__iexact=obj) + if typ == "dbref": + return _ChannelDB.objects.get(id=obj) + logger.log_err("%s %s %s %s %s" % (objtype, inp, obj, typ, type(inp))) + raise CommError() + elif objtype == "script": + if typ == "string": + return _ScriptDB.objects.get(db_key__iexact=obj) + if typ == "dbref": + return _ScriptDB.objects.get(id=obj) + logger.log_err("%s %s %s %s %s" % (objtype, inp, obj, typ, type(inp))) + raise CommError() + + # an unknown + return None
+ + +# +# Msg manager +# + + +
[docs]class MsgManager(TypedObjectManager): + """ + This MsgManager implements methods for searching and manipulating + Messages directly from the database. + + These methods will all return database objects (or QuerySets) + directly. + + A Message represents one unit of communication, be it over a + Channel or via some form of in-game mail system. Like an e-mail, + it always has a sender and can have any number of receivers (some + of which may be Channels). + + """ + +
[docs] def identify_object(self, inp): + """ + Wrapper to identify_object if accessing via the manager directly. + + Args: + inp (any): Entity to be idtified. + + Returns: + identified (tuple): This is a tuple with (`inp`, identifier) + where `identifier` is one of "account", "object", "channel", + "string", "dbref" or None. + + """ + return identify_object(inp)
+ +
[docs] def get_message_by_id(self, idnum): + """ + Retrieve message by its id. + + Args: + idnum (int or str): The dbref to retrieve. + + Returns: + message (Msg): The message. + + """ + try: + return self.get(id=self.dbref(idnum, reqhash=False)) + except Exception: + return None
+ +
[docs] def get_messages_by_sender(self, sender): + """ + Get all messages sent by one entity - this could be either a + account or an object + + Args: + sender (Account or Object): The sender of the message. + + Returns: + QuerySet: Matching messages. + + Raises: + CommError: For incorrect sender types. + + """ + obj, typ = identify_object(sender) + if typ == "account": + return self.filter(db_sender_accounts=obj).exclude(db_hide_from_accounts=obj) + elif typ == "object": + return self.filter(db_sender_objects=obj).exclude(db_hide_from_objects=obj) + elif typ == "script": + return self.filter(db_sender_scripts=obj) + else: + raise CommError
+ +
[docs] def get_messages_by_receiver(self, recipient): + """ + Get all messages sent to one given recipient. + + Args: + recipient (Object, Account or Channel): The recipient of the messages to search for. + + Returns: + Queryset: Matching messages. + + Raises: + CommError: If the `recipient` is not of a valid type. + + """ + obj, typ = identify_object(recipient) + if typ == "account": + return self.filter(db_receivers_accounts=obj).exclude(db_hide_from_accounts=obj) + elif typ == "object": + return self.filter(db_receivers_objects=obj).exclude(db_hide_from_objects=obj) + elif typ == "script": + return self.filter(db_receivers_scripts=obj) + else: + raise CommError
+ +
[docs] def search_message(self, sender=None, receiver=None, freetext=None, dbref=None): + """ + Search the message database for particular messages. At least + one of the arguments must be given to do a search. + + Args: + sender (Object, Account or Script, optional): Get messages sent by a particular sender. + receiver (Object, Account or Channel, optional): Get messages + received by a certain account,object or channel + freetext (str): Search for a text string in a message. NOTE: + This can potentially be slow, so make sure to supply one of + the other arguments to limit the search. + dbref (int): The exact database id of the message. This will override + all other search criteria since it's unique and + always gives only one match. + + Returns: + Queryset: Iterable with 0, 1 or more matches. + + """ + # unique msg id + if dbref: + return self.objects.filter(id=dbref) + + # We use Q objects to gradually build up the query - this way we only + # need to do one database lookup at the end rather than gradually + # refining with multiple filter:s. Django Note: Q objects can be + # combined with & and | (=AND,OR). ~ negates the queryset + + # filter by sender (we need __pk to avoid an error with empty Q() objects) + sender, styp = identify_object(sender) + if sender: + spk = sender.pk + if styp == "account": + sender_restrict = Q(db_sender_accounts__pk=spk) & ~Q(db_hide_from_accounts__pk=spk) + elif styp == "object": + sender_restrict = Q(db_sender_objects__pk=spk) & ~Q(db_hide_from_objects__pk=spk) + elif styp == "script": + sender_restrict = Q(db_sender_scripts__pk=spk) + else: + sender_restrict = Q() + # filter by receiver + receiver, rtyp = identify_object(receiver) + if receiver: + rpk = receiver.pk + if rtyp == "account": + receiver_restrict = Q(db_receivers_accounts__pk=rpk) & ~Q(db_hide_from_accounts__pk=rpk) + elif rtyp == "object": + receiver_restrict = Q(db_receivers_objects__pk=rpk) & ~Q(db_hide_from_objects__pk=rpk) + elif rtyp == "script": + receiver_restrict = Q(db_receivers_scripts__pk=rpk) + elif rtyp == "channel": + raise DeprecationWarning( + "Msg.objects.search don't accept channel recipients since " + "Channels no longer accepts Msg objects." + ) + else: + receiver_restrict = Q() + # filter by full text + if freetext: + fulltext_restrict = Q(db_header__icontains=freetext) | Q(db_message__icontains=freetext) + else: + fulltext_restrict = Q() + # execute the query + return self.filter(sender_restrict & receiver_restrict & fulltext_restrict)
+ + # back-compatibility alias + message_search = search_message + +
[docs] def create_message( + self, + senderobj, + message, + receivers=None, + locks=None, + tags=None, + header=None, + **kwargs, + ): + """ + Create a new communication Msg. Msgs represent a unit of + database-persistent communication between entites. + + Args: + senderobj (Object, Account, Script, str or list): The entity (or + entities) sending the Msg. If a `str`, this is the id-string + for an external sender type. + message (str): Text with the message. Eventual headers, titles + etc should all be included in this text string. Formatting + will be retained. + receivers (Object, Account, Script, str or list): An Account/Object to send + to, or a list of them. If a string, it's an identifier for an external + receiver. + locks (str): Lock definition string. + tags (list): A list of tags or tuples `(tag[,category[,data]])`. + header (str): Mime-type or other optional information for the message + + Notes: + The Comm system is created to be very open-ended, so it's fully + possible to let a message both go several receivers at the same time, + it's up to the command definitions to limit this as desired. + + """ + if "channels" in kwargs: + raise DeprecationWarning( + "create_message() does not accept 'channel' kwarg anymore " + "- channels no longer accept Msg objects." + ) + + if not message: + # we don't allow empty messages. + return None + new_message = self.model(db_message=message) + new_message.save() + for sender in make_iter(senderobj): + new_message.senders = sender + new_message.header = header + for receiver in make_iter(receivers): + new_message.receivers = receiver + if locks: + new_message.locks.add(locks) + if tags: + new_message.tags.batch_add(*tags) + + new_message.save() + return new_message
+ + +# +# Channel manager +# + + +
[docs]class ChannelDBManager(TypedObjectManager): + """ + This ChannelManager implements methods for searching and + manipulating Channels directly from the database. + + These methods will all return database objects (or QuerySets) + directly. + + A Channel is an in-game venue for communication. It's essentially + representation of a re-sender: Users sends Messages to the + Channel, and the Channel re-sends those messages to all users + subscribed to the Channel. + + """ + +
[docs] def get_all_channels(self): + """ + Get all channels. + + Returns: + channels (list): All channels in game. + + """ + return self.all()
+ +
[docs] def get_channel(self, channelkey): + """ + Return the channel object if given its key. + Also searches its aliases. + + Args: + channelkey (str): Channel key to search for. + + Returns: + channel (Channel or None): A channel match. + + """ + dbref = self.dbref(channelkey) + if dbref: + try: + return self.get(id=dbref) + except self.model.DoesNotExist: + pass + results = self.filter( + Q(db_key__iexact=channelkey) + | Q(db_tags__db_tagtype__iexact="alias", db_tags__db_key__iexact=channelkey) + ).distinct() + return results[0] if results else None
+ +
[docs] def get_subscriptions(self, subscriber): + """ + Return all channels a given entity is subscribed to. + + Args: + subscriber (Object or Account): The one subscribing. + + Returns: + subscriptions (list): Channel subscribed to. + + """ + clsname = subscriber.__dbclass__.__name__ + if clsname == "AccountDB": + return subscriber.account_subscription_set.all() + if clsname == "ObjectDB": + return subscriber.object_subscription_set.all() + return []
+ +
[docs] def search_channel(self, ostring, exact=True): + """ + Search the channel database for a particular channel. + + Args: + ostring (str): The key or database id of the channel. + exact (bool, optional): Require an exact (but not + case sensitive) match. + + Returns: + Queryset: Iterable with 0, 1 or more matches. + + """ + dbref = self.dbref(ostring) + if dbref: + dbref_match = self.search_dbref(dbref) + if dbref_match: + return dbref_match + + if exact: + channels = self.filter( + Q(db_key__iexact=ostring) + | Q(db_tags__db_tagtype__iexact="alias", db_tags__db_key__iexact=ostring) + ).distinct() + else: + channels = self.filter( + Q(db_key__icontains=ostring) + | Q(db_tags__db_tagtype__iexact="alias", db_tags__db_key__icontains=ostring) + ).distinct() + return channels
+ +
[docs] def create_channel( + self, + key, + aliases=None, + desc=None, + locks=None, + keep_log=True, + typeclass=None, + tags=None, + attrs=None, + ): + """ + Create A communication Channel. A Channel serves as a central hub + for distributing Msgs to groups of people without specifying the + receivers explicitly. Instead accounts may 'connect' to the channel + and follow the flow of messages. By default the channel allows + access to all old messages, but this can be turned off with the + keep_log switch. + + Args: + key (str): This must be unique. + + Keyword Args: + aliases (list of str): List of alternative (likely shorter) keynames. + desc (str): A description of the channel, for use in listings. + locks (str): Lockstring. + keep_log (bool): Log channel throughput. + typeclass (str or class): The typeclass of the Channel (not + often used). + tags (list): A list of tags or tuples `(tag[,category[,data]])`. + attrs (list): List of attributes on form `(name, value[,category[,lockstring]])` + + Returns: + channel (Channel): A newly created channel. + + """ + typeclass = typeclass if typeclass else settings.BASE_CHANNEL_TYPECLASS + + if isinstance(typeclass, str): + # a path is given. Load the actual typeclass + typeclass = class_from_module(typeclass, settings.TYPECLASS_PATHS) + + # create new instance + new_channel = typeclass(db_key=key) + + # store call signature for the signal + new_channel._createdict = dict( + key=key, + aliases=aliases, + desc=desc, + locks=locks, + keep_log=keep_log, + tags=tags, + attrs=attrs, + ) + + # this will trigger the save signal which in turn calls the + # at_first_save hook on the typeclass, where the _createdict can be + # used. + new_channel.save() + + signals.SIGNAL_CHANNEL_POST_CREATE.send(sender=new_channel) + + return new_channel
+ + # back-compatibility alias + channel_search = search_channel
+ + +
[docs]class ChannelManager(ChannelDBManager, TypeclassManager): + """ + Wrapper to group the typeclass manager to a consistent name. + """ + + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/comms/models.html b/docs/latest/_modules/evennia/comms/models.html new file mode 100644 index 0000000000..ea04b28905 --- /dev/null +++ b/docs/latest/_modules/evennia/comms/models.html @@ -0,0 +1,823 @@ + + + + + + + + evennia.comms.models — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.comms.models

+"""
+Models for the in-game communication system.
+
+The comm system could take the form of channels, but can also be
+adopted for storing tells or in-game mail.
+
+The comsystem's main component is the Message (Msg), which carries the
+actual information between two parties.  Msgs are stored in the
+database and usually not deleted.  A Msg always have one sender (a
+user), but can have any number targets, both users and channels.
+
+For non-persistent (and slightly faster) use one can also use the
+TempMsg, which mimics the Msg API but without actually saving to the
+database.
+
+Channels are central objects that act as targets for Msgs. Accounts can
+connect to channels by use of a ChannelConnect object (this object is
+necessary to easily be able to delete connections on the fly).
+
+"""
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+
+from evennia.comms import managers
+from evennia.locks.lockhandler import LockHandler
+from evennia.typeclasses.models import TypedObject
+from evennia.typeclasses.tags import Tag, TagHandler
+from evennia.utils.idmapper.models import SharedMemoryModel
+from evennia.utils.utils import crop, lazy_property, make_iter
+
+__all__ = ("Msg", "TempMsg", "ChannelDB", "SubscriptionHandler")
+
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+_DA = object.__delattr__
+
+
+# ------------------------------------------------------------
+#
+# Msg
+#
+# ------------------------------------------------------------
+
+
+
[docs]class Msg(SharedMemoryModel): + """ + A single message. This model describes all ooc messages + sent in-game, both to channels and between accounts. + + The Msg class defines the following database fields (all + accessed via specific handler methods): + + - db_sender_accounts: Account senders + - db_sender_objects: Object senders + - db_sender_scripts: Script senders + - db_sender_external: External sender (defined as string name) + - db_receivers_accounts: Receiving accounts + - db_receivers_objects: Receiving objects + - db_receivers_scripts: Receiveing scripts + - db_receiver_external: External sender (defined as string name) + - db_header: Header text + - db_message: The actual message text + - db_date_created: time message was created / sent + - db_hide_from_sender: bool if message should be hidden from sender + - db_hide_from_receivers: list of receiver objects to hide message from + - db_lock_storage: Internal storage of lock strings. + + """ + + # + # Msg database model setup + # + # + # These databse fields are all set using their corresponding properties, + # named same as the field, but withtout the db_* prefix. + + db_sender_accounts = models.ManyToManyField( + "accounts.AccountDB", + related_name="sender_account_set", + blank=True, + verbose_name="Senders (Accounts)", + db_index=True, + ) + + db_sender_objects = models.ManyToManyField( + "objects.ObjectDB", + related_name="sender_object_set", + blank=True, + verbose_name="Senders (Objects)", + db_index=True, + ) + db_sender_scripts = models.ManyToManyField( + "scripts.ScriptDB", + related_name="sender_script_set", + blank=True, + verbose_name="Senders (Scripts)", + db_index=True, + ) + db_sender_external = models.CharField( + "external sender", + max_length=255, + null=True, + blank=True, + db_index=True, + help_text=( + "Identifier for single external sender, for use with senders " + "not represented by a regular database model." + ), + ) + + db_receivers_accounts = models.ManyToManyField( + "accounts.AccountDB", + related_name="receiver_account_set", + blank=True, + verbose_name="Receivers (Accounts)", + help_text="account receivers", + ) + + db_receivers_objects = models.ManyToManyField( + "objects.ObjectDB", + related_name="receiver_object_set", + blank=True, + verbose_name="Receivers (Objects)", + help_text="object receivers", + ) + db_receivers_scripts = models.ManyToManyField( + "scripts.ScriptDB", + related_name="receiver_script_set", + blank=True, + verbose_name="Receivers (Scripts)", + help_text="script_receivers", + ) + + db_receiver_external = models.CharField( + "external receiver", + max_length=1024, + null=True, + blank=True, + db_index=True, + help_text=( + "Identifier for single external receiver, for use with recievers " + "not represented by a regular database model." + ), + ) + + # header could be used for meta-info about the message if your system needs + # it, or as a separate store for the mail subject line maybe. + db_header = models.TextField("header", null=True, blank=True) + # the message body itself + db_message = models.TextField("message") + # send date + db_date_created = models.DateTimeField( + "date sent", editable=False, auto_now_add=True, db_index=True + ) + # lock storage + db_lock_storage = models.TextField( + "locks", blank=True, help_text="access locks on this message." + ) + + # these can be used to filter/hide a given message from supplied objects/accounts + db_hide_from_accounts = models.ManyToManyField( + "accounts.AccountDB", related_name="hide_from_accounts_set", blank=True + ) + + db_hide_from_objects = models.ManyToManyField( + "objects.ObjectDB", related_name="hide_from_objects_set", blank=True + ) + + db_tags = models.ManyToManyField( + Tag, + blank=True, + help_text=( + "tags on this message. Tags are simple string markers to " + "identify, group and alias messages." + ), + ) + + # Database manager + objects = managers.MsgManager() + _is_deleted = False + + class Meta(object): + "Define Django meta options" + verbose_name = "Msg" + +
[docs] @lazy_property + def locks(self): + return LockHandler(self)
+ +
[docs] @lazy_property + def tags(self): + return TagHandler(self)
+ + # Wrapper properties to easily set database fields. These are + # @property decorators that allows to access these fields using + # normal python operations (without having to remember to save() + # etc). So e.g. a property 'attr' has a get/set/del decorator + # defined that allows the user to do self.attr = value, + # value = self.attr and del self.attr respectively (where self + # is the object in question). + + @property + def senders(self): + "Getter. Allows for value = self.senders" + return ( + list(self.db_sender_accounts.all()) + + list(self.db_sender_objects.all()) + + list(self.db_sender_scripts.all()) + + ([self.db_sender_external] if self.db_sender_external else []) + ) + + @senders.setter + def senders(self, senders): + "Setter. Allows for self.sender = value" + + if isinstance(senders, str): + self.db_sender_external = senders + self.save(update_fields=["db_sender_external"]) + return + + for sender in make_iter(senders): + if not sender: + continue + if not hasattr(sender, "__dbclass__"): + raise ValueError("This is a not a typeclassed object!") + clsname = sender.__dbclass__.__name__ + if clsname == "ObjectDB": + self.db_sender_objects.add(sender) + elif clsname == "AccountDB": + self.db_sender_accounts.add(sender) + elif clsname == "ScriptDB": + self.db_sender_scripts.add(sender) + + @senders.deleter + def senders(self): + "Deleter. Clears all senders" + self.db_sender_accounts.clear() + self.db_sender_objects.clear() + self.db_sender_scripts.clear() + self.db_sender_external = "" + self.save() + +
[docs] def remove_sender(self, senders): + """ + Remove a single sender or a list of senders. + + Args: + senders (Account, Object, str or list): Senders to remove. + If a string, removes the external sender. + + """ + if isinstance(senders, str): + self.db_sender_external = "" + self.save(update_fields=["db_sender_external"]) + return + + for sender in make_iter(senders): + if not sender: + continue + if not hasattr(sender, "__dbclass__"): + raise ValueError("This is a not a typeclassed object!") + clsname = sender.__dbclass__.__name__ + if clsname == "ObjectDB": + self.db_sender_objects.remove(sender) + elif clsname == "AccountDB": + self.db_sender_accounts.remove(sender) + elif clsname == "ScriptDB": + self.db_sender_accounts.remove(sender)
+ + @property + def receivers(self): + """ + Getter. Allows for value = self.receivers. + Returns four lists of receivers: accounts, objects, scripts and + external_receivers. + + """ + return ( + list(self.db_receivers_accounts.all()) + + list(self.db_receivers_objects.all()) + + list(self.db_receivers_scripts.all()) + + ([self.db_receiver_external] if self.db_receiver_external else []) + ) + + @receivers.setter + def receivers(self, receivers): + """ + Setter. Allows for self.receivers = value. This appends a new receiver + to the message. If a string, replaces an external receiver. + + """ + if isinstance(receivers, str): + self.db_receiver_external = receivers + self.save(update_fields=["db_receiver_external"]) + return + + for receiver in make_iter(receivers): + if not receiver: + continue + if not hasattr(receiver, "__dbclass__"): + raise ValueError("This is a not a typeclassed object!") + clsname = receiver.__dbclass__.__name__ + if clsname == "ObjectDB": + self.db_receivers_objects.add(receiver) + elif clsname == "AccountDB": + self.db_receivers_accounts.add(receiver) + elif clsname == "ScriptDB": + self.db_receivers_scripts.add(receiver) + + @receivers.deleter + def receivers(self): + "Deleter. Clears all receivers" + self.db_receivers_accounts.clear() + self.db_receivers_objects.clear() + self.db_receivers_scripts.clear() + self.db_receiver_external = "" + self.save() + +
[docs] def remove_receiver(self, receivers): + """ + Remove a single receiver, a list of receivers, or a single extral receiver. + + Args: + receivers (Account, Object, Script, list or str): Receiver + to remove. A string removes the external receiver. + + """ + if isinstance(receivers, str): + self.db_receiver_external = "" + self.save(update_fields="db_receiver_external") + return + + for receiver in make_iter(receivers): + if not receiver: + continue + elif not hasattr(receiver, "__dbclass__"): + raise ValueError("This is a not a typeclassed object!") + clsname = receiver.__dbclass__.__name__ + if clsname == "ObjectDB": + self.db_receivers_objects.remove(receiver) + elif clsname == "AccountDB": + self.db_receivers_accounts.remove(receiver) + elif clsname == "ScriptDB": + self.db_receivers_scripts.remove(receiver)
+ + @property + def hide_from(self): + """ + Getter. Allows for value = self.hide_from. + Returns two lists of accounts and objects. + + """ + return ( + self.db_hide_from_accounts.all(), + self.db_hide_from_objects.all(), + ) + + @hide_from.setter + def hide_from(self, hiders): + """ + Setter. Allows for self.hide_from = value. Will append to hiders. + + """ + for hider in make_iter(hiders): + if not hider: + continue + if not hasattr(hider, "__dbclass__"): + raise ValueError("This is a not a typeclassed object!") + clsname = hider.__dbclass__.__name__ + if clsname == "AccountDB": + self.db_hide_from_accounts.add(hider.__dbclass__) + elif clsname == "ObjectDB": + self.db_hide_from_objects.add(hider.__dbclass__) + + @hide_from.deleter + def hide_from(self): + """ + Deleter. Allows for del self.hide_from_senders + + """ + self.db_hide_from_accounts.clear() + self.db_hide_from_objects.clear() + self.save() + + # + # Msg class methods + # + + def __str__(self): + """ + This handles what is shown when e.g. printing the message. + + """ + senders = ",".join(getattr(obj, "key", str(obj)) for obj in self.senders) + receivers = ",".join(getattr(obj, "key", str(obj)) for obj in self.receivers) + return "%s->%s: %s" % (senders, receivers, crop(self.message, width=40)) + +
[docs] def access(self, accessing_obj, access_type="read", default=False): + """ + Checks lock access. + + Args: + accessing_obj (Object or Account): The object trying to gain access. + access_type (str, optional): The type of lock access to check. + default (bool): Fallback to use if `access_type` lock is not defined. + + Returns: + result (bool): If access was granted or not. + + """ + return self.locks.check(accessing_obj, access_type=access_type, default=default)
+ + +# ------------------------------------------------------------ +# +# TempMsg +# +# ------------------------------------------------------------ + + +
[docs]class TempMsg: + """ + This is a non-persistent object for sending temporary messages that will not be stored. It + mimics the "real" Msg object, but doesn't require sender to be given. + + """ + +
[docs] def __init__( + self, + senders=None, + receivers=None, + message="", + header="", + type="", + lockstring="", + hide_from=None, + ): + """ + Creates the temp message. + + Args: + senders (any or list, optional): Senders of the message. + receivers (Account, Object, Script or list, optional): Receivers of this message. + message (str, optional): Message to send. + header (str, optional): Header of message. + type (str, optional): Message class, if any. + lockstring (str, optional): Lock for the message. + hide_from (Account, Object, or list, optional): Entities to hide this message from. + + """ + self.senders = senders and make_iter(senders) or [] + self.receivers = receivers and make_iter(receivers) or [] + self.type = type + self.header = header + self.message = message + self.lock_storage = lockstring + self.hide_from = hide_from and make_iter(hide_from) or [] + self.date_created = timezone.now()
+ +
[docs] @lazy_property + def locks(self): + return LockHandler(self)
+ + def __str__(self): + """ + This handles what is shown when e.g. printing the message. + + """ + senders = ",".join(obj.key for obj in self.senders) + receivers = ",".join(obj.key for obj in self.receivers) + return "%s->%s: %s" % (senders, receivers, crop(self.message, width=40)) + +
[docs] def remove_sender(self, sender): + """ + Remove a sender or a list of senders. + + Args: + sender (Object, Account, str or list): Senders to remove. + + """ + for o in make_iter(sender): + try: + self.senders.remove(o) + except ValueError: + pass # nothing to remove
+ +
[docs] def remove_receiver(self, receiver): + """ + Remove a receiver or a list of receivers + + Args: + receiver (Object, Account, Script, str or list): Receivers to remove. + + """ + + for o in make_iter(receiver): + try: + self.senders.remove(o) + except ValueError: + pass # nothing to remove
+ +
[docs] def access(self, accessing_obj, access_type="read", default=False): + """ + Checks lock access. + + Args: + accessing_obj (Object or Account): The object trying to gain access. + access_type (str, optional): The type of lock access to check. + default (bool): Fallback to use if `access_type` lock is not defined. + + Returns: + result (bool): If access was granted or not. + + """ + return self.locks.check(accessing_obj, access_type=access_type, default=default)
+ + +# ------------------------------------------------------------ +# +# Channel +# +# ------------------------------------------------------------ + + +
[docs]class SubscriptionHandler: + """ + This handler manages subscriptions to the + channel and hides away which type of entity is + subscribing (Account or Object) + + """ + +
[docs] def __init__(self, obj): + """ + Initialize the handler + + Attr: + obj (ChannelDB): The channel the handler sits on. + + """ + self.obj = obj + self._cache = None
+ + def _recache(self): + self._cache = { + account: True + for account in self.obj.db_account_subscriptions.all().order_by("pk") + if hasattr(account, "pk") and account.pk + } + self._cache.update( + { + obj: True + for obj in self.obj.db_object_subscriptions.all().order_by("pk") + if hasattr(obj, "pk") and obj.pk + } + ) + +
[docs] def has(self, entity): + """ + Check if the given entity subscribe to this channel + + Args: + entity (str, Account or Object): The entity to return. If + a string, it assumed to be the key or the #dbref + of the entity. + + Returns: + subscriber (Account, Object or None): The given + subscriber. + + """ + if self._cache is None: + self._recache() + return entity in self._cache
+ +
[docs] def add(self, entity): + """ + Subscribe an entity to this channel. + + Args: + entity (Account, Object or list): The entity or + list of entities to subscribe to this channel. + + Note: + No access-checking is done here, this must have + been done before calling this method. Also + no hooks will be called. + + """ + for subscriber in make_iter(entity): + if subscriber: + clsname = subscriber.__dbclass__.__name__ + # chooses the right type + if clsname == "ObjectDB": + self.obj.db_object_subscriptions.add(subscriber) + elif clsname == "AccountDB": + self.obj.db_account_subscriptions.add(subscriber) + self._recache()
+ +
[docs] def remove(self, entity): + """ + Remove a subscriber from the channel. + + Args: + entity (Account, Object or list): The entity or + entities to un-subscribe from the channel. + + """ + for subscriber in make_iter(entity): + if subscriber: + clsname = subscriber.__dbclass__.__name__ + # chooses the right type + if clsname == "AccountDB": + self.obj.db_account_subscriptions.remove(entity) + elif clsname == "ObjectDB": + self.obj.db_object_subscriptions.remove(entity) + self._recache()
+ +
[docs] def all(self): + """ + Get all subscriptions to this channel. + + Returns: + subscribers (list): The subscribers. This + may be a mix of Accounts and Objects! + + """ + if self._cache is None: + self._recache() + return self._cache
+ + get = all # alias + +
[docs] def online(self): + """ + Get all online accounts from our cache + Returns: + subscribers (list): Subscribers who are online or + are puppeted by an online account. + """ + subs = [] + recache_needed = False + for obj in self.all(): + from django.core.exceptions import ObjectDoesNotExist + + try: + if not obj.is_connected: + continue + except ObjectDoesNotExist: + # a subscribed object has already been deleted. Mark that we need a recache and + # ignore it + recache_needed = True + continue + subs.append(obj) + if recache_needed: + self._recache() + return subs
+ +
[docs] def clear(self): + """ + Remove all subscribers from channel. + + """ + self.obj.db_account_subscriptions.clear() + self.obj.db_object_subscriptions.clear() + self._cache = None
+ + +
[docs]class ChannelDB(TypedObject): + """ + This is the basis of a comm channel, only implementing + the very basics of distributing messages. + + The Channel class defines the following database fields + beyond the ones inherited from TypedObject: + + - db_account_subscriptions: The Account subscriptions. + - db_object_subscriptions: The Object subscriptions. + + """ + + db_account_subscriptions = models.ManyToManyField( + "accounts.AccountDB", + related_name="account_subscription_set", + blank=True, + verbose_name="account subscriptions", + db_index=True, + ) + + db_object_subscriptions = models.ManyToManyField( + "objects.ObjectDB", + related_name="object_subscription_set", + blank=True, + verbose_name="object subscriptions", + db_index=True, + ) + + # Database manager + objects = managers.ChannelDBManager() + + __settingclasspath__ = settings.BASE_CHANNEL_TYPECLASS + __defaultclasspath__ = "evennia.comms.comms.DefaultChannel" + __applabel__ = "comms" + + class Meta: + "Define Django meta options" + verbose_name = "Channel" + verbose_name_plural = "Channels" + + def __str__(self): + "Echoes the text representation of the channel." + return "Channel '%s' (%s)" % (self.key, self.db.desc) + +
[docs] @lazy_property + def subscriptions(self): + return SubscriptionHandler(self)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/awsstorage/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/awsstorage/tests.html new file mode 100644 index 0000000000..fcf69e57b7 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/awsstorage/tests.html @@ -0,0 +1,753 @@ + + + + + + + + evennia.contrib.base_systems.awsstorage.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.awsstorage.tests

+import datetime
+import gzip
+import pickle
+import threading
+from unittest import skipIf
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.core.files.base import ContentFile
+from django.test import TestCase, override_settings
+from django.utils.timezone import is_aware, utc
+
+_SKIP = False
+try:
+    from botocore.exceptions import ClientError
+
+    from .awsstorage import aws_s3_cdn as s3boto3
+except ImportError:
+    _SKIP = True
+
+
+try:
+    from django.utils.six.moves.urllib import parse as urlparse
+except ImportError:
+    from urllib import parse as urlparse
+
+
+try:
+    from unittest import mock
+except ImportError:  # Python 3.2 and below
+    import mock
+
+
+
[docs]@skipIf(_SKIP, "botocore not installed") +class S3Boto3TestCase(TestCase): +
[docs] def setUp(self): + self.storage = s3boto3.S3Boto3Storage(access_key="foo", secret_key="bar") + self.storage._connections.connection = mock.MagicMock()
+ + +
[docs]@skipIf(_SKIP, "botocore not installed") +class S3Boto3StorageTests(S3Boto3TestCase): +
[docs] def test_clean_name(self): + """ + Test the base case of _clean_name + """ + path = self.storage._clean_name("path/to/somewhere") + self.assertEqual(path, "path/to/somewhere")
+ +
[docs] def test_clean_name_normalize(self): + """ + Test the normalization of _clean_name + """ + path = self.storage._clean_name("path/to/../somewhere") + self.assertEqual(path, "path/somewhere")
+ +
[docs] def test_clean_name_trailing_slash(self): + """ + Test the _clean_name when the path has a trailing slash + """ + path = self.storage._clean_name("path/to/somewhere/") + self.assertEqual(path, "path/to/somewhere/")
+ +
[docs] def test_clean_name_windows(self): + """ + Test the _clean_name when the path has a trailing slash + """ + path = self.storage._clean_name("path\\to\\somewhere") + self.assertEqual(path, "path/to/somewhere")
+ +
[docs] def test_pickle_with_bucket(self): + """ + Test that the storage can be pickled with a bucket attached + """ + # Ensure the bucket has been used + self.storage.bucket + self.assertIsNotNone(self.storage._bucket) + + # Can't pickle MagicMock, but you can't pickle a real Bucket object either + p = pickle.dumps(self.storage) + new_storage = pickle.loads(p) + + self.assertIsInstance(new_storage._connections, threading.local) + # Put the mock connection back in + new_storage._connections.connection = mock.MagicMock() + + self.assertIsNone(new_storage._bucket) + new_storage.bucket + self.assertIsNotNone(new_storage._bucket)
+ +
[docs] def test_pickle_without_bucket(self): + """ + Test that the storage can be pickled, without a bucket instance + """ + + # Can't pickle a threadlocal + p = pickle.dumps(self.storage) + new_storage = pickle.loads(p) + + self.assertIsInstance(new_storage._connections, threading.local)
+ +
[docs] def test_storage_url_slashes(self): + """ + Test URL generation. + """ + self.storage.custom_domain = "example.com" + + # We expect no leading slashes in the path, + # and trailing slashes should be preserved. + self.assertEqual(self.storage.url(""), "https://example.com/") + self.assertEqual(self.storage.url("path"), "https://example.com/path") + self.assertEqual(self.storage.url("path/"), "https://example.com/path/") + self.assertEqual(self.storage.url("path/1"), "https://example.com/path/1") + self.assertEqual(self.storage.url("path/1/"), "https://example.com/path/1/")
+ +
[docs] def test_storage_save(self): + """ + Test saving a file + """ + name = "test_storage_save.txt" + content = ContentFile("new content") + self.storage.save(name, content) + self.storage.bucket.Object.assert_called_once_with(name) + + obj = self.storage.bucket.Object.return_value + obj.upload_fileobj.assert_called_with( + content, + ExtraArgs={ + "ContentType": "text/plain", + "ACL": self.storage.default_acl, + }, + )
+ +
[docs] def test_storage_save_with_acl(self): + """ + Test saving a file with user defined ACL. + """ + name = "test_storage_save.txt" + content = ContentFile("new content") + self.storage.default_acl = "private" + self.storage.save(name, content) + self.storage.bucket.Object.assert_called_once_with(name) + + obj = self.storage.bucket.Object.return_value + obj.upload_fileobj.assert_called_with( + content, + ExtraArgs={ + "ContentType": "text/plain", + "ACL": "private", + }, + )
+ +
[docs] def test_content_type(self): + """ + Test saving a file with a None content type. + """ + name = "test_image.jpg" + content = ContentFile("data") + content.content_type = None + self.storage.save(name, content) + self.storage.bucket.Object.assert_called_once_with(name) + + obj = self.storage.bucket.Object.return_value + obj.upload_fileobj.assert_called_with( + content, + ExtraArgs={ + "ContentType": "image/jpeg", + "ACL": self.storage.default_acl, + }, + )
+ +
[docs] def test_storage_save_gzipped(self): + """ + Test saving a gzipped file + """ + name = "test_storage_save.gz" + content = ContentFile("I am gzip'd") + self.storage.save(name, content) + obj = self.storage.bucket.Object.return_value + obj.upload_fileobj.assert_called_with( + content, + ExtraArgs={ + "ContentType": "application/octet-stream", + "ContentEncoding": "gzip", + "ACL": self.storage.default_acl, + }, + )
+ +
[docs] def test_storage_save_gzip(self): + """ + Test saving a file with gzip enabled. + """ + self.storage.gzip = True + name = "test_storage_save.css" + content = ContentFile("I should be gzip'd") + self.storage.save(name, content) + obj = self.storage.bucket.Object.return_value + obj.upload_fileobj.assert_called_with( + mock.ANY, + ExtraArgs={ + "ContentType": "text/css", + "ContentEncoding": "gzip", + "ACL": self.storage.default_acl, + }, + ) + args, kwargs = obj.upload_fileobj.call_args + content = args[0] + zfile = gzip.GzipFile(mode="rb", fileobj=content) + self.assertEqual(zfile.read(), b"I should be gzip'd")
+ +
[docs] def test_storage_save_gzip_twice(self): + """ + Test saving the same file content twice with gzip enabled. + """ + # Given + self.storage.gzip = True + name = "test_storage_save.css" + content = ContentFile("I should be gzip'd") + + # When + self.storage.save(name, content) + self.storage.save("test_storage_save_2.css", content) + + # Then + obj = self.storage.bucket.Object.return_value + obj.upload_fileobj.assert_called_with( + mock.ANY, + ExtraArgs={ + "ContentType": "text/css", + "ContentEncoding": "gzip", + "ACL": self.storage.default_acl, + }, + ) + args, kwargs = obj.upload_fileobj.call_args + content = args[0] + zfile = gzip.GzipFile(mode="rb", fileobj=content) + self.assertEqual(zfile.read(), b"I should be gzip'd")
+ +
[docs] def test_compress_content_len(self): + """ + Test that file returned by _compress_content() is readable. + """ + self.storage.gzip = True + content = ContentFile("I should be gzip'd") + content = self.storage._compress_content(content) + self.assertTrue(len(content.read()) > 0)
+ +
[docs] def test_storage_open_write(self): + """ + Test opening a file in write mode + """ + name = "test_open_for_writïng.txt" + content = "new content" + + # Set the encryption flag used for multipart uploads + self.storage.encryption = True + self.storage.reduced_redundancy = True + self.storage.default_acl = "public-read" + + file = self.storage.open(name, "w") + self.storage.bucket.Object.assert_called_with(name) + obj = self.storage.bucket.Object.return_value + # Set the name of the mock object + obj.key = name + + file.write(content) + obj.initiate_multipart_upload.assert_called_with( + ACL="public-read", + ContentType="text/plain", + ServerSideEncryption="AES256", + StorageClass="REDUCED_REDUNDANCY", + ) + + # Save the internal file before closing + multipart = obj.initiate_multipart_upload.return_value + multipart.parts.all.return_value = [mock.MagicMock(e_tag="123", part_number=1)] + file.close() + multipart.Part.assert_called_with(1) + part = multipart.Part.return_value + part.upload.assert_called_with(Body=content.encode("utf-8")) + multipart.complete.assert_called_once_with( + MultipartUpload={"Parts": [{"ETag": "123", "PartNumber": 1}]} + )
+ +
[docs] def test_storage_open_no_write(self): + """ + Test opening file in write mode and closing without writing. + + A file should be created as by obj.put(...). + """ + name = "test_open_no_write.txt" + + # Set the encryption flag used for puts + self.storage.encryption = True + self.storage.reduced_redundancy = True + self.storage.default_acl = "public-read" + + file = self.storage.open(name, "w") + self.storage.bucket.Object.assert_called_with(name) + obj = self.storage.bucket.Object.return_value + obj.load.side_effect = ClientError( + {"Error": {}, "ResponseMetadata": {"HTTPStatusCode": 404}}, "head_bucket" + ) + + # Set the name of the mock object + obj.key = name + + # Save the internal file before closing + file.close() + + obj.load.assert_called_once_with() + obj.put.assert_called_once_with( + ACL="public-read", + Body=b"", + ContentType="text/plain", + ServerSideEncryption="AES256", + StorageClass="REDUCED_REDUNDANCY", + )
+ +
[docs] def test_storage_open_no_overwrite_existing(self): + """ + Test opening an existing file in write mode and closing without writing. + """ + name = "test_open_no_overwrite_existing.txt" + + # Set the encryption flag used for puts + self.storage.encryption = True + self.storage.reduced_redundancy = True + self.storage.default_acl = "public-read" + + file = self.storage.open(name, "w") + self.storage.bucket.Object.assert_called_with(name) + obj = self.storage.bucket.Object.return_value + + # Set the name of the mock object + obj.key = name + + # Save the internal file before closing + file.close() + + obj.load.assert_called_once_with() + obj.put.assert_not_called()
+ +
[docs] def test_storage_write_beyond_buffer_size(self): + """ + Test writing content that exceeds the buffer size + """ + name = "test_open_for_writïng_beyond_buffer_size.txt" + + # Set the encryption flag used for multipart uploads + self.storage.encryption = True + self.storage.reduced_redundancy = True + self.storage.default_acl = "public-read" + + file = self.storage.open(name, "w") + self.storage.bucket.Object.assert_called_with(name) + obj = self.storage.bucket.Object.return_value + # Set the name of the mock object + obj.key = name + + # Initiate the multipart upload + file.write("") + obj.initiate_multipart_upload.assert_called_with( + ACL="public-read", + ContentType="text/plain", + ServerSideEncryption="AES256", + StorageClass="REDUCED_REDUNDANCY", + ) + multipart = obj.initiate_multipart_upload.return_value + + # Write content at least twice as long as the buffer size + written_content = "" + counter = 1 + while len(written_content) < 2 * file.buffer_size: + content = "hello, aws {counter}\n".format(counter=counter) + # Write more than just a few bytes in each iteration to keep the + # test reasonably fast + content += "*" * int(file.buffer_size / 10) + file.write(content) + written_content += content + counter += 1 + + # Save the internal file before closing + multipart.parts.all.return_value = [ + mock.MagicMock(e_tag="123", part_number=1), + mock.MagicMock(e_tag="456", part_number=2), + ] + file.close() + self.assertListEqual(multipart.Part.call_args_list, [mock.call(1), mock.call(2)]) + part = multipart.Part.return_value + uploaded_content = "".join( + args_list[1]["Body"].decode("utf-8") for args_list in part.upload.call_args_list + ) + self.assertEqual(uploaded_content, written_content) + multipart.complete.assert_called_once_with( + MultipartUpload={ + "Parts": [ + {"ETag": "123", "PartNumber": 1}, + {"ETag": "456", "PartNumber": 2}, + ] + } + )
+ +
[docs] def test_auto_creating_bucket(self): + self.storage.auto_create_bucket = True + Bucket = mock.MagicMock() + self.storage._connections.connection.Bucket.return_value = Bucket + self.storage._connections.connection.meta.client.meta.region_name = "sa-east-1" + + Bucket.meta.client.head_bucket.side_effect = ClientError( + {"Error": {}, "ResponseMetadata": {"HTTPStatusCode": 404}}, "head_bucket" + ) + self.storage._get_or_create_bucket("testbucketname") + Bucket.create.assert_called_once_with( + ACL="public-read", + CreateBucketConfiguration={ + "LocationConstraint": "sa-east-1", + }, + )
+ +
[docs] def test_auto_creating_bucket_with_acl(self): + self.storage.auto_create_bucket = True + self.storage.bucket_acl = "public-read" + Bucket = mock.MagicMock() + self.storage._connections.connection.Bucket.return_value = Bucket + self.storage._connections.connection.meta.client.meta.region_name = "sa-east-1" + + Bucket.meta.client.head_bucket.side_effect = ClientError( + {"Error": {}, "ResponseMetadata": {"HTTPStatusCode": 404}}, "head_bucket" + ) + self.storage._get_or_create_bucket("testbucketname") + Bucket.create.assert_called_once_with( + ACL="public-read", + CreateBucketConfiguration={ + "LocationConstraint": "sa-east-1", + }, + )
+ +
[docs] def test_storage_exists(self): + self.assertTrue(self.storage.exists("file.txt")) + self.storage.connection.meta.client.head_object.assert_called_with( + Bucket=self.storage.bucket_name, + Key="file.txt", + )
+ +
[docs] def test_storage_exists_false(self): + self.storage.connection.meta.client.head_object.side_effect = ClientError( + {"Error": {"Code": "404", "Message": "Not Found"}}, + "HeadObject", + ) + self.assertFalse(self.storage.exists("file.txt")) + self.storage.connection.meta.client.head_object.assert_called_with( + Bucket=self.storage.bucket_name, + Key="file.txt", + )
+ +
[docs] def test_storage_exists_doesnt_create_bucket(self): + with mock.patch.object(self.storage, "_get_or_create_bucket") as method: + self.storage.exists("file.txt") + self.assertFalse(method.called)
+ +
[docs] def test_storage_delete(self): + self.storage.delete("path/to/file.txt") + self.storage.bucket.Object.assert_called_with("path/to/file.txt") + self.storage.bucket.Object.return_value.delete.assert_called_with()
+ +
[docs] def test_storage_listdir_base(self): + # Files: + # some/path/1.txt + # 2.txt + # other/path/3.txt + # 4.txt + pages = [ + { + "CommonPrefixes": [ + {"Prefix": "some"}, + {"Prefix": "other"}, + ], + "Contents": [ + {"Key": "2.txt"}, + {"Key": "4.txt"}, + ], + }, + ] + + paginator = mock.MagicMock() + paginator.paginate.return_value = pages + self.storage._connections.connection.meta.client.get_paginator.return_value = paginator + + dirs, files = self.storage.listdir("") + paginator.paginate.assert_called_with(Bucket=None, Delimiter="/", Prefix="") + + self.assertEqual(dirs, ["some", "other"]) + self.assertEqual(files, ["2.txt", "4.txt"])
+ +
[docs] def test_storage_listdir_subdir(self): + # Files: + # some/path/1.txt + # some/2.txt + pages = [ + { + "CommonPrefixes": [ + {"Prefix": "some/path"}, + ], + "Contents": [ + {"Key": "some/2.txt"}, + ], + }, + ] + + paginator = mock.MagicMock() + paginator.paginate.return_value = pages + self.storage._connections.connection.meta.client.get_paginator.return_value = paginator + + dirs, files = self.storage.listdir("some/") + paginator.paginate.assert_called_with(Bucket=None, Delimiter="/", Prefix="some/") + + self.assertEqual(dirs, ["path"]) + self.assertEqual(files, ["2.txt"])
+ +
[docs] def test_storage_size(self): + obj = self.storage.bucket.Object.return_value + obj.content_length = 4098 + + name = "file.txt" + self.assertEqual(self.storage.size(name), obj.content_length)
+ +
[docs] def test_storage_mtime(self): + # Test both USE_TZ cases + for use_tz in (True, False): + with self.settings(USE_TZ=use_tz): + self._test_storage_mtime(use_tz)
+ + def _test_storage_mtime(self, use_tz): + obj = self.storage.bucket.Object.return_value + obj.last_modified = datetime.datetime.now(utc) + + name = "file.txt" + self.assertFalse( + is_aware(self.storage.modified_time(name)), + "Naive datetime object expected from modified_time()", + ) + + self.assertIs( + settings.USE_TZ, + is_aware(self.storage.get_modified_time(name)), + "{} datetime object expected from get_modified_time() when USE_TZ={}".format( + ("Naive", "Aware")[settings.USE_TZ], settings.USE_TZ + ), + ) + +
[docs] def test_storage_url(self): + name = "test_storage_size.txt" + url = "http://aws.amazon.com/%s" % name + self.storage.bucket.meta.client.generate_presigned_url.return_value = url + self.storage.bucket.name = "bucket" + self.assertEqual(self.storage.url(name), url) + self.storage.bucket.meta.client.generate_presigned_url.assert_called_with( + "get_object", + Params={"Bucket": self.storage.bucket.name, "Key": name}, + ExpiresIn=self.storage.querystring_expire, + ) + + custom_expire = 123 + + self.assertEqual(self.storage.url(name, expire=custom_expire), url) + self.storage.bucket.meta.client.generate_presigned_url.assert_called_with( + "get_object", + Params={"Bucket": self.storage.bucket.name, "Key": name}, + ExpiresIn=custom_expire, + )
+ +
[docs] def test_generated_url_is_encoded(self): + self.storage.custom_domain = "mock.cloudfront.net" + filename = "whacky & filename.mp4" + url = self.storage.url(filename) + parsed_url = urlparse.urlparse(url) + self.assertEqual(parsed_url.path, "/whacky%20%26%20filename.mp4") + self.assertFalse(self.storage.bucket.meta.client.generate_presigned_url.called)
+ +
[docs] def test_special_characters(self): + self.storage.custom_domain = "mock.cloudfront.net" + + name = "ãlöhâ.jpg" + content = ContentFile("new content") + self.storage.save(name, content) + self.storage.bucket.Object.assert_called_once_with(name) + + url = self.storage.url(name) + parsed_url = urlparse.urlparse(url) + self.assertEqual(parsed_url.path, "/%C3%A3l%C3%B6h%C3%A2.jpg")
+ +
[docs] def test_strip_signing_parameters(self): + expected = "http://bucket.s3-aws-region.amazonaws.com/foo/bar" + self.assertEqual( + self.storage._strip_signing_parameters( + "%s?X-Amz-Date=12345678&X-Amz-Signature=Signature" % expected + ), + expected, + ) + self.assertEqual( + self.storage._strip_signing_parameters( + "%s?expires=12345678&signature=Signature" % expected + ), + expected, + )
+ +
[docs] @skipIf(threading is None, "Test requires threading") + def test_connection_threading(self): + connections = [] + + def thread_storage_connection(): + connections.append(self.storage.connection) + + for x in range(2): + t = threading.Thread(target=thread_storage_connection) + t.start() + t.join() + + # Connection for each thread needs to be unique + self.assertIsNot(connections[0], connections[1])
+ +
[docs] def test_location_leading_slash(self): + msg = ( + "S3Boto3Storage.location cannot begin with a leading slash. " + "Found '/'. Use '' instead." + ) + with self.assertRaises(ImproperlyConfigured, msg=msg): + s3boto3.S3Boto3Storage(location="/")
+ +
[docs] def test_override_class_variable(self): + class MyStorage1(s3boto3.S3Boto3Storage): + location = "foo1" + + storage = MyStorage1() + self.assertEqual(storage.location, "foo1") + + class MyStorage2(s3boto3.S3Boto3Storage): + location = "foo2" + + storage = MyStorage2() + self.assertEqual(storage.location, "foo2")
+ +
[docs] def test_override_init_argument(self): + storage = s3boto3.S3Boto3Storage(location="foo1") + self.assertEqual(storage.location, "foo1") + storage = s3boto3.S3Boto3Storage(location="foo2") + self.assertEqual(storage.location, "foo2")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/building_menu/building_menu.html b/docs/latest/_modules/evennia/contrib/base_systems/building_menu/building_menu.html new file mode 100644 index 0000000000..c9e1c731e7 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/building_menu/building_menu.html @@ -0,0 +1,1378 @@ + + + + + + + + evennia.contrib.base_systems.building_menu.building_menu — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.building_menu.building_menu

+"""
+Module containing the building menu system.
+
+Evennia contributor: vincent-lg 2018
+
+Building menus are in-game menus, not unlike `EvMenu` though using a
+different approach.  Building menus have been specifically designed to edit
+information as a builder.  Creating a building menu in a command allows
+builders quick-editing of a given object, like a room.  If you follow the
+steps below to add the contrib, you will have access to an `@edit` command
+that will edit any default object offering to change its key and description.
+
+1. Import the `GenericBuildingCmd` class from this contrib in your
+   `mygame/commands/default_cmdset.py` file:
+
+    ```python
+    from evennia.contrib.base_systems.building_menu import GenericBuildingCmd
+
+    ```
+
+2. Below, add the command in the `CharacterCmdSet`:
+
+    ```python
+    # ... These lines should exist in the file
+    class CharacterCmdSet(default_cmds.CharacterCmdSet):
+        key = "DefaultCharacter"
+
+        def at_cmdset_creation(self):
+            super().at_cmdset_creation()
+            # ... add the line below
+            self.add(GenericBuildingCmd())
+    ```
+
+The `@edit` command will allow you to edit any object.  You will need to
+specify the object name or ID as an argument.  For instance: `@edit here`
+will edit the current room.  However, building menus can perform much more
+than this very simple example, read on for more details.
+
+Building menus can be set to edit about anything.  Here is an example of
+output you could obtain when editing the room:
+
+```
+ Editing the room: Limbo(#2)
+
+ [T]itle: the limbo room
+ [D]escription
+    This is the limbo room.  You can easily change this default description,
+    either by using the |y@desc/edit|n command, or simply by entering this
+    menu (enter |yd|n).
+ [E]xits:
+     north to A parking(#4)
+ [Q]uit this menu
+```
+
+From there, you can open the title choice by pressing t.  You can then
+change the room title by simply entering text, and go back to the
+main menu entering @ (all this is customizable).  Press q to quit this menu.
+
+The first thing to do is to create a new module and place a class
+inheriting from `BuildingMenu` in it.
+
+```python
+from evennia.contrib.base_systems.building_menu.building_menu import BuildingMenu
+
+class RoomBuildingMenu(BuildingMenu):
+    # ...
+
+```
+
+Next, override the `init` method.  You can add choices (like the title,
+description, and exits choices as seen above) by using the `add_choice`
+method.
+
+```
+class RoomBuildingMenu(BuildingMenu):
+    def init(self, room):
+        self.add_choice("title", "t", attr="key")
+```
+
+That will create the first choice, the title choice.  If one opens your menu
+and enter t, she will be in the title choice.  She can change the title
+(it will write in the room's `key` attribute) and then go back to the
+main menu using `@`.
+
+`add_choice` has a lot of arguments and offers a great deal of
+flexibility.  The most useful ones is probably the usage of callbacks,
+as you can set almost any argument in `add_choice` to be a callback, a
+function that you have defined above in your module.  This function will be
+called when the menu element is triggered.
+
+Notice that in order to edit a description, the best method to call isn't
+`add_choice`, but `add_choice_edit`.  This is a convenient shortcut
+which is available to quickly open an `EvEditor` when entering this choice
+and going back to the menu when the editor closes.
+
+```
+class RoomBuildingMenu(BuildingMenu):
+    def init(self, room):
+        self.add_choice("title", "t", attr="key")
+        self.add_choice_edit("description", key="d", attr="db.desc")
+```
+
+When you wish to create a building menu, you just need to import your
+class, create it specifying your intended caller and object to edit,
+then call `open`:
+
+```python
+from <wherever> import RoomBuildingMenu
+
+class CmdEdit(Command):
+
+    key = "redit"
+
+    def func(self):
+        menu = RoomBuildingMenu(self.caller, self.caller.location)
+        menu.open()
+```
+
+This is a very short introduction.  For more details, see the online tutorial
+(https://github.com/evennia/evennia/wiki/Building-menus) or read the
+heavily-documented code below.
+
+"""
+
+from inspect import getfullargspec
+from textwrap import dedent
+
+from django.conf import settings
+from evennia import CmdSet, Command
+from evennia.commands import cmdhandler
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.eveditor import EvEditor
+from evennia.utils.logger import log_err, log_trace
+from evennia.utils.utils import class_from_module
+
+# Constants
+_MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
+
+
+# Private functions
+def _menu_loadfunc(caller):
+    obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None])
+    if obj and attr:
+        for part in attr.split(".")[:-1]:
+            obj = getattr(obj, part)
+
+    return getattr(obj, attr.split(".")[-1]) if obj is not None else ""
+
+
+def _menu_savefunc(caller, buf):
+    obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None])
+    if obj and attr:
+        for part in attr.split(".")[:-1]:
+            obj = getattr(obj, part)
+
+        setattr(obj, attr.split(".")[-1], buf)
+
+    caller.attributes.remove("_building_menu_to_edit")
+    return True
+
+
+def _menu_quitfunc(caller):
+    caller.cmdset.add(
+        BuildingMenuCmdSet,
+        persistent=caller.ndb._building_menu and caller.ndb._building_menu.persistent or False,
+    )
+    if caller.ndb._building_menu:
+        caller.ndb._building_menu.move(back=True)
+
+
+def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=None):
+    """
+    Call the value, if appropriate, or just return it.
+
+    Args:
+        value (any): the value to obtain.  It might be a callable (see note).
+
+    Keyword Args:
+        menu (BuildingMenu, optional): the building menu to pass to value
+                if it is a callable.
+        choice (Choice, optional): the choice to pass to value if a callable.
+        string (str, optional): the raw string to pass to value if a callback.
+        obj (Object): the object to pass to value if a callable.
+        caller (Account or Object, optional): the caller to pass to value
+                if a callable.
+
+    Returns:
+        The value itself.  If the argument is a function, call it with
+        specific arguments (see note).
+
+    Note:
+        If `value` is a function, call it with varying arguments.  The
+        list of arguments will depend on the argument names in your callable.
+        - An argument named `menu` will contain the building menu or None.
+        - The `choice` argument will contain the choice or None.
+        - The `string` argument will contain the raw string or None.
+        - The `obj` argument will contain the object or None.
+        - The `caller` argument will contain the caller or None.
+        - Any other argument will contain the object (`obj`).
+        Thus, you could define callbacks like this:
+            def on_enter(menu, caller, obj):
+            def on_nomatch(string, choice, menu):
+            def on_leave(caller, room): # note that room will contain `obj`
+
+    """
+    if callable(value):
+        # Check the function arguments
+        kwargs = {}
+        spec = getfullargspec(value)
+        args = spec.args
+        if spec.varkw:
+            kwargs.update(dict(menu=menu, choice=choice, string=string, obj=obj, caller=caller))
+        else:
+            if "menu" in args:
+                kwargs["menu"] = menu
+            if "choice" in args:
+                kwargs["choice"] = choice
+            if "string" in args:
+                kwargs["string"] = string
+            if "obj" in args:
+                kwargs["obj"] = obj
+            if "caller" in args:
+                kwargs["caller"] = caller
+
+        # Fill missing arguments
+        for arg in args:
+            if arg not in kwargs:
+                kwargs[arg] = obj
+
+        # Call the function and return its return value
+        return value(**kwargs)
+
+    return value
+
+
+# Helper functions, to be used in menu choices
+
+
+
+
+
+
+
+
+
+
+
+# Building menu commands and CmdSet
+
+
+
[docs]class CmdNoInput(Command): + + """No input has been found.""" + + key = _CMD_NOINPUT + locks = "cmd:all()" + +
[docs] def __init__(self, **kwargs): + self.menu = kwargs.pop("building_menu", None) + super().__init__(**kwargs)
+ +
[docs] def func(self): + """Display the menu or choice text.""" + if self.menu: + self.menu.display() + else: + log_err("When CMDNOINPUT was called, the building menu couldn't be found") + self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") + self.caller.cmdset.delete(BuildingMenuCmdSet)
+ + +
[docs]class CmdNoMatch(Command): + + """No input has been found.""" + + key = _CMD_NOMATCH + locks = "cmd:all()" + +
[docs] def __init__(self, **kwargs): + self.menu = kwargs.pop("building_menu", None) + super().__init__(**kwargs)
+ +
[docs] def func(self): + """Call the proper menu or redirect to nomatch.""" + raw_string = self.args.rstrip() + if self.menu is None: + log_err("When CMDNOMATCH was called, the building menu couldn't be found") + self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") + self.caller.cmdset.delete(BuildingMenuCmdSet) + return + + choice = self.menu.current_choice + if raw_string in self.menu.keys_go_back: + if self.menu.keys: + self.menu.move(back=True) + elif self.menu.parents: + self.menu.open_parent_menu() + else: + self.menu.display() + elif choice: + if choice.nomatch(raw_string): + self.caller.msg(choice.format_text()) + else: + for choice in self.menu.relevant_choices: + if choice.key.lower() == raw_string.lower() or any( + raw_string.lower() == alias for alias in choice.aliases + ): + self.menu.move(choice.key) + return + + self.msg("|rUnknown command: {}|n.".format(raw_string))
+ + +
[docs]class BuildingMenuCmdSet(CmdSet): + + """Building menu CmdSet.""" + + key = "building_menu" + priority = 5 + mergetype = "Replace" + +
[docs] def at_cmdset_creation(self): + """Populates the cmdset with commands.""" + caller = self.cmdsetobj + + # The caller could recall the menu + menu = caller.ndb._building_menu + if menu is None: + menu = caller.db._building_menu + if menu: + menu = BuildingMenu.restore(caller) + + cmds = [CmdNoInput, CmdNoMatch] + for cmd in cmds: + self.add(cmd(building_menu=menu))
+ + +# Menu classes + + +
[docs]class Choice: + + """A choice object, created by `add_choice`.""" + +
[docs] def __init__( + self, + title, + key=None, + aliases=None, + attr=None, + text=None, + glance=None, + on_enter=None, + on_nomatch=None, + on_leave=None, + menu=None, + caller=None, + obj=None, + ): + """Constructor. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the choice. If not set, try to guess it based on the title. + aliases (list of str, optional): the allowed aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + text (str or callable, optional): a text to be displayed for this + choice. It can be a callable. + glance (str or callable, optional): an at-a-glance summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + menu (BuildingMenu, optional): the parent building menu. + on_enter (callable, optional): a callable to call when the + caller enters into the choice. + on_nomatch (callable, optional): a callable to call when no + match is entered in the choice. + on_leave (callable, optional): a callable to call when the caller + leaves the choice. + caller (Account or Object, optional): the caller. + obj (Object, optional): the object to edit. + + """ + self.title = title + self.key = key + self.aliases = aliases + self.attr = attr + self.text = text + self.glance = glance + self.on_enter = on_enter + self.on_nomatch = on_nomatch + self.on_leave = on_leave + self.menu = menu + self.caller = caller + self.obj = obj
+ + def __repr__(self): + return "<Choice (title={}, key={})>".format(self.title, self.key) + + @property + def keys(self): + """Return a tuple of keys separated by `sep_keys`.""" + return tuple(self.key.split(self.menu.sep_keys)) + +
[docs] def format_text(self): + """Format the choice text and return it, or an empty string.""" + text = "" + if self.text: + text = _call_or_get( + self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj + ) + text = dedent(text.strip("\n")) + text = text.format(obj=self.obj, caller=self.caller) + + return text
+ +
[docs] def enter(self, string): + """Called when the user opens the choice. + + Args: + string (str): the entered string. + + """ + if self.on_enter: + _call_or_get( + self.on_enter, + menu=self.menu, + choice=self, + string=string, + caller=self.caller, + obj=self.obj, + )
+ +
[docs] def nomatch(self, string): + """Called when the user entered something in the choice. + + Args: + string (str): the entered string. + + Returns: + to_display (bool): The return value of `nomatch` if set or + `True`. The rule is that if `no_match` returns `True`, + then the choice or menu is displayed. + + """ + if self.on_nomatch: + return _call_or_get( + self.on_nomatch, + menu=self.menu, + choice=self, + string=string, + caller=self.caller, + obj=self.obj, + ) + + return True
+ +
[docs] def leave(self, string): + """Called when the user closes the choice. + + Args: + string (str): the entered string. + + """ + if self.on_leave: + _call_or_get( + self.on_leave, + menu=self.menu, + choice=self, + string=string, + caller=self.caller, + obj=self.obj, + )
+ + +
[docs]class BuildingMenu: + + """ + Class allowing to create and set building menus to edit specific objects. + + A building menu is somewhat similar to `EvMenu`, but designed to edit + objects by builders, although it can be used for players in some contexts. + You could, for instance, create a building menu to edit a room with a + sub-menu for the room's key, another for the room's description, + another for the room's exits, and so on. + + To add choices (simple sub-menus), you should call `add_choice` (see the + full documentation of this method). With most arguments, you can + specify either a plain string or a callback. This callback will be + called when the operation is to be performed. + + Some methods are provided for frequent needs (see the `add_choice_*` + methods). Some helper functions are defined at the top of this + module in order to be used as arguments to `add_choice` + in frequent cases. + + """ + + keys_go_back = ["@"] # The keys allowing to go back in the menu tree + sep_keys = "." # The key separator for menus with more than 2 levels + joker_key = "*" # The special key meaning "anything" in a choice key + min_shortcut = 1 # The minimum length of shortcuts when `key` is not set + +
[docs] def __init__( + self, + caller=None, + obj=None, + title="Building menu: {obj}", + keys=None, + parents=None, + persistent=False, + ): + """Constructor, you shouldn't override. See `init` instead. + + Args: + caller (Account or Object): the caller. + obj (Object): the object to be edited, like a room. + title (str, optional): the menu title. + keys (list of str, optional): the starting menu keys (None + to start from the first level). + parents (tuple, optional): information for parent menus, + automatically supplied. + persistent (bool, optional): should this building menu + survive a reload/restart? + + Note: + If some of these options have to be changed, it is + preferable to do so in the `init` method and not to + override `__init__`. For instance: + class RoomBuildingMenu(BuildingMenu): + def init(self, room): + self.title = "Menu for room: {obj.key}(#{obj.id})" + # ... + + """ + self.caller = caller + self.obj = obj + self.title = title + self.keys = keys or [] + self.parents = parents or () + self.persistent = persistent + self.choices = [] + self.cmds = {} + self.can_quit = False + + if obj: + self.init(obj) + if not parents and not self.can_quit: + # Automatically add the menu to quit + self.add_choice_quit(key=None) + self._add_keys_choice()
+ + @property + def current_choice(self): + """Return the current choice or None. + + Returns: + choice (Choice): the current choice or None. + + Note: + We use the menu keys to identify the current position of + the caller in the menu. The menu `keys` hold a list of + keys that should match a choice to be usable. + + """ + menu_keys = self.keys + if not menu_keys: + return None + + for choice in self.choices: + choice_keys = choice.keys + if len(menu_keys) == len(choice_keys): + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue + + if not isinstance(menu_key, str) or menu_key != choice_key: + common = False + break + + if common: + return choice + + return None + + @property + def relevant_choices(self): + """Only return the relevant choices according to the current meny key. + + Returns: + relevant (list of Choice object): the relevant choices. + + Note: + We use the menu keys to identify the current position of + the caller in the menu. The menu `keys` hold a list of + keys that should match a choice to be usable. + + """ + menu_keys = self.keys + relevant = [] + for choice in self.choices: + choice_keys = choice.keys + if not menu_keys and len(choice_keys) == 1: + # First level choice with the menu key empty, that's relevant + relevant.append(choice) + elif len(menu_keys) == len(choice_keys) - 1: + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue + + if not isinstance(menu_key, str) or menu_key != choice_key: + common = False + break + + if common: + relevant.append(choice) + + return relevant + + def _save(self): + """Save the menu in a attributes on the caller. + + If `persistent` is set to `True`, also save in a persistent attribute. + + """ + self.caller.ndb._building_menu = self + + if self.persistent: + self.caller.db._building_menu = { + "class": type(self).__module__ + "." + type(self).__name__, + "obj": self.obj, + "title": self.title, + "keys": self.keys, + "parents": self.parents, + "persistent": self.persistent, + } + + def _add_keys_choice(self): + """Add the choices' keys if some choices don't have valid keys.""" + # If choices have been added without keys, try to guess them + for choice in self.choices: + if not choice.key: + title = strip_ansi(choice.title.strip()).lower() + length = self.min_shortcut + while length <= len(title): + i = 0 + while i < len(title) - length + 1: + guess = title[i : i + length] + if guess not in self.cmds: + choice.key = guess + break + + i += 1 + + if choice.key: + break + + length += 1 + + if choice.key: + self.cmds[choice.key] = choice + else: + raise ValueError("Cannot guess the key for {}".format(choice)) + +
[docs] def init(self, obj): + """Create the sub-menu to edit the specified object. + + Args: + obj (Object): the object to edit. + + Note: + This method is probably to be overridden in your subclasses. + Use `add_choice` and its variants to create menu choices. + + """ + pass
+ +
[docs] def add_choice( + self, + title, + key=None, + aliases=None, + attr=None, + text=None, + glance=None, + on_enter=None, + on_nomatch=None, + on_leave=None, + ): + """ + Add a choice, a valid sub-menu, in the current builder menu. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the sub-neu. If not set, try to guess it based on the + choice title. + aliases (list of str, optional): the aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + This is really useful if you want to edit an + attribute of the object (that's a frequent need). If + you don't want to do so, just use the `on_*` arguments. + text (str or callable, optional): a text to be displayed when + the menu is opened It can be a callable. + glance (str or callable, optional): an at-a-glance summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + on_enter (callable, optional): a callable to call when the + caller enters into this choice. + on_nomatch (callable, optional): a callable to call when + the caller enters something in this choice. If you + don't set this argument but you have specified + `attr`, then `obj`.`attr` will be set with the value + entered by the user. + on_leave (callable, optional): a callable to call when the + caller leaves the choice. + + Returns: + choice (Choice): the newly-created choice. + + Raises: + ValueError if the choice cannot be added. + + Note: + Most arguments can be callables, like functions. This has the + advantage of allowing great flexibility. If you specify + a callable in most of the arguments, the callable should return + the value expected by the argument (a str more often than + not). For instance, you could set a function to be called + to get the menu text, which allows for some filtering: + def text_exits(menu): + return "Some text to display" + class RoomBuildingMenu(BuildingMenu): + def init(self): + self.add_choice("exits", key="x", text=text_exits) + + The allowed arguments in a callable are specific to the + argument names (they are not sensitive to orders, not all + arguments have to be present). For more information, see + `_call_or_get`. + + """ + key = key or "" + key = key.lower() + aliases = aliases or [] + aliases = [a.lower() for a in aliases] + if attr and on_nomatch is None: + on_nomatch = menu_setattr + + if key and key in self.cmds: + raise ValueError( + "A conflict exists between {} and {}, both use key or alias {}".format( + self.cmds[key], title, repr(key) + ) + ) + + if attr: + if glance is None: + glance = "{obj." + attr + "}" + if text is None: + text = """ + ------------------------------------------------------------------------------- + {attr} for {{obj}}(#{{obj.id}}) + + You can change this value simply by entering it. + Use |y{back}|n to go back to the main menu. + + Current value: |c{{{obj_attr}}}|n + """.format( + attr=attr, obj_attr="obj." + attr, back="|n or |y".join(self.keys_go_back) + ) + + choice = Choice( + title, + key=key, + aliases=aliases, + attr=attr, + text=text, + glance=glance, + on_enter=on_enter, + on_nomatch=on_nomatch, + on_leave=on_leave, + menu=self, + caller=self.caller, + obj=self.obj, + ) + self.choices.append(choice) + if key: + self.cmds[key] = choice + + for alias in aliases: + self.cmds[alias] = choice + + return choice
+ +
[docs] def add_choice_edit( + self, + title="description", + key="d", + aliases=None, + attr="db.desc", + glance="\n {obj.db.desc}", + on_enter=None, + ): + """ + Add a simple choice to edit a given attribute in the EvEditor. + + Args: + title (str, optional): the choice's title. + key (str, optional): the choice's key. + aliases (list of str, optional): the choice's aliases. + glance (str or callable, optional): the at-a-glance description. + on_enter (callable, optional): a different callable to edit + the attribute. + + Returns: + choice (Choice): the newly-created choice. + + Note: + This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_edit` which opens + an EvEditor to edit the specified attribute. + When the caller closes the editor (with :q), the menu + will be re-opened. + + """ + on_enter = on_enter or menu_edit + return self.add_choice( + title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter, text="" + )
+ +
[docs] def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): + """ + Add a simple choice just to quit the building menu. + + Args: + title (str, optional): the choice's title. + key (str, optional): the choice's key. + aliases (list of str, optional): the choice's aliases. + on_enter (callable, optional): a different callable + to quit the building menu. + + Note: + This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_quit` which simply + closes the menu and displays a message. It also + removes the CmdSet from the caller. If you supply + another callable instead, make sure to do the same. + + """ + on_enter = on_enter or menu_quit + self.can_quit = True + return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter)
+ +
[docs] def open(self): + """Open the building menu for the caller. + + Note: + This method should be called once when the building menu + has been instanciated. From there, the building menu will + be re-created automatically when the server + reloads/restarts, assuming `persistent` is set to `True`. + + """ + caller = self.caller + self._save() + + # Remove the same-key cmdset if exists + if caller.cmdset.has(BuildingMenuCmdSet): + caller.cmdset.remove(BuildingMenuCmdSet) + + self.caller.cmdset.add(BuildingMenuCmdSet, persistent=self.persistent) + self.display()
+ +
[docs] def open_parent_menu(self): + """Open the parent menu, using `self.parents`. + + Note: + You probably don't need to call this method directly, + since the caller can go back to the parent menu using the + `keys_go_back` automatically. + + """ + parents = list(self.parents) + if parents: + parent_class, parent_obj, parent_keys = parents[-1] + del parents[-1] + + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + try: + menu_class = class_from_module(parent_class) + except Exception: + log_trace( + "BuildingMenu: attempting to load class {} failed".format(repr(parent_class)) + ) + return + + # Create the parent menu + try: + building_menu = menu_class( + self.caller, parent_obj, keys=parent_keys, parents=tuple(parents) + ) + except Exception: + log_trace( + "An error occurred while creating building menu {}".format(repr(parent_class)) + ) + return + else: + return building_menu.open()
+ +
[docs] def open_submenu(self, submenu_class, submenu_obj, parent_keys=None): + """ + Open a sub-menu, closing the current menu and opening the new one. + + Args: + submenu_class (str): the submenu class as a Python path. + submenu_obj (Object): the object to give to the submenu. + parent_keys (list of str, optional): the parent keys when + the submenu is closed. + + Note: + When the user enters `@` in the submenu, she will go back to + the current menu, with the `parent_keys` set as its keys. + Therefore, you should set it on the keys of the choice that + should be opened when the user leaves the submenu. + + Returns: + new_menu (BuildingMenu): the new building menu or None. + + """ + parent_keys = parent_keys or [] + parents = list(self.parents) + parents.append((type(self).__module__ + "." + type(self).__name__, self.obj, parent_keys)) + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + # Shift to the new menu + try: + menu_class = class_from_module(submenu_class) + except Exception: + log_trace( + "BuildingMenu: attempting to load class {} failed".format(repr(submenu_class)) + ) + return + + # Create the submenu + try: + building_menu = menu_class(self.caller, submenu_obj, parents=parents) + except Exception: + log_trace( + "An error occurred while creating building menu {}".format(repr(submenu_class)) + ) + return + else: + return building_menu.open()
+ +
[docs] def move(self, key=None, back=False, quiet=False, string=""): + """ + Move inside the menu. + + Args: + key (any): the portion of the key to add to the current + menu keys. If you wish to go back in the menu + tree, don't provide a `key`, just set `back` to `True`. + back (bool, optional): go back in the menu (`False` by default). + quiet (bool, optional): should the menu or choice be + displayed afterward? + string (str, optional): the string sent by the caller to move. + + Note: + This method will need to be called directly should you + use more than two levels in your menu. For instance, + in your room menu, if you want to have an "exits" + option, and then be able to enter "north" in this + choice to edit an exit. The specific exit choice + could be a different menu (with a different class), but + it could also be an additional level in your original menu. + If that's the case, you will need to use this method. + + """ + choice = self.current_choice + if choice: + choice.leave("") + + if not back: # Move forward + if not key: + raise ValueError("you are asking to move forward, you should specify a key.") + + self.keys.append(key) + else: # Move backward + if not self.keys: + raise ValueError( + "you already are at the top of the tree, you cannot move backward." + ) + + del self.keys[-1] + + self._save() + choice = self.current_choice + if choice: + choice.enter(string) + + if not quiet: + self.display()
+ +
[docs] def close(self): + """Close the building menu, removing the CmdSet.""" + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.delete(BuildingMenuCmdSet) + if self.caller.attributes.has("_building_menu"): + self.caller.attributes.remove("_building_menu") + if self.caller.nattributes.has("_building_menu"): + self.caller.nattributes.remove("_building_menu")
+ + # Display methods. Override for customization +
[docs] def display_title(self): + """Return the menu title to be displayed.""" + return _call_or_get(self.title, menu=self, obj=self.obj, caller=self.caller).format( + obj=self.obj + )
+ +
[docs] def display_choice(self, choice): + """Display the specified choice. + + Args: + choice (Choice): the menu choice. + + """ + title = _call_or_get( + choice.title, menu=self, choice=choice, obj=self.obj, caller=self.caller + ) + clear_title = title.lower() + pos = clear_title.find(choice.key.lower()) + ret = " " + if pos >= 0: + ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key) :] + else: + ret += "[|y" + choice.key.title() + "|n] " + title + + if choice.glance: + glance = _call_or_get( + choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj + ) + glance = glance.format(obj=self.obj, caller=self.caller) + + ret += ": " + glance + + return ret
+ +
[docs] def display(self): + """Display the entire menu or a single choice, depending on the keys.""" + choice = self.current_choice + if self.keys and choice: + text = choice.format_text() + else: + text = self.display_title() + "\n" + for choice in self.relevant_choices: + text += "\n" + self.display_choice(choice) + + self.caller.msg(text)
+ +
[docs] @staticmethod + def restore(caller): + """Restore the building menu for the caller. + + Args: + caller (Account or Object): the caller. + + Note: + This method should be automatically called if a menu is + saved in the caller, but the object itself cannot be found. + + """ + menu = caller.db._building_menu + if menu: + class_name = menu.get("class") + if not class_name: + log_err( + "BuildingMenu: on caller {}, a persistent attribute holds building menu " + "data, but no class could be found to restore the menu".format(caller) + ) + return + + try: + menu_class = class_from_module(class_name) + except Exception: + log_trace( + "BuildingMenu: attempting to load class {} failed".format(repr(class_name)) + ) + return + + # Create the menu + obj = menu.get("obj") + keys = menu.get("keys") + title = menu.get("title", "") + parents = menu.get("parents") + persistent = menu.get("persistent", False) + try: + building_menu = menu_class( + caller, obj, title=title, keys=keys, parents=parents, persistent=persistent + ) + except Exception: + log_trace( + "An error occurred while creating building menu {}".format(repr(class_name)) + ) + return + + return building_menu
+ + +# Generic building menu and command +
[docs]class GenericBuildingMenu(BuildingMenu): + + """A generic building menu, allowing to edit any object. + + This is more a demonstration menu. By default, it allows to edit the + object key and description. Nevertheless, it will be useful to demonstrate + how building menus are meant to be used. + + """ + +
[docs] def init(self, obj): + """Build the meny, adding the 'key' and 'description' choices. + + Args: + obj (Object): any object to be edited, like a character or room. + + Note: + The 'quit' choice will be automatically added, though you can + call `add_choice_quit` to add this choice with different options. + + """ + self.add_choice( + "key", + key="k", + attr="key", + glance="{obj.key}", + text=""" + ------------------------------------------------------------------------------- + Editing the key of {{obj.key}}(#{{obj.id}}) + + You can change the simply by entering it. + Use |y{back}|n to go back to the main menu. + + Current key: |c{{obj.key}}|n + """.format( + back="|n or |y".join(self.keys_go_back) + ), + ) + self.add_choice_edit("description", key="d", attr="db.desc")
+ + +
[docs]class GenericBuildingCmd(Command): + + """ + Generic building command. + + Syntax: + @edit [object] + + Open a building menu to edit the specified object. This menu allows to + change the object's key and description. + + Examples: + @edit here + @edit self + @edit #142 + + """ + + key = "@edit" + help_category = "Building" + +
[docs] def func(self): + if not self.args.strip(): + self.msg("You should provide an argument to this function: the object to edit.") + return + + obj = self.caller.search(self.args.strip(), global_search=True) + if not obj: + return + + menu = GenericBuildingMenu(self.caller, obj) + menu.open()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/building_menu/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/building_menu/tests.html new file mode 100644 index 0000000000..681acd88b0 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/building_menu/tests.html @@ -0,0 +1,284 @@ + + + + + + + + evennia.contrib.base_systems.building_menu.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.building_menu.tests

+"""
+Building menu tests.
+
+"""
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+
+from .building_menu import BuildingMenu, CmdNoMatch
+
+
+
+
+
+
[docs]class TestBuildingMenu(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test") + self.menu.add_choice("title", key="t", attr="key")
+ +
[docs] def test_quit(self): + """Try to quit the building menu.""" + self.assertFalse(self.char1.cmdset.has("building_menu")) + self.menu.open() + self.assertTrue(self.char1.cmdset.has("building_menu")) + self.call(CmdNoMatch(building_menu=self.menu), "q") + # char1 tries to quit the editor + self.assertFalse(self.char1.cmdset.has("building_menu"))
+ +
[docs] def test_setattr(self): + """Test the simple setattr provided by building menus.""" + self.menu.open() + self.call(CmdNoMatch(building_menu=self.menu), "t") + self.assertIsNotNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "some new title") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertIsNone(self.menu.current_choice) + self.assertEqual(self.room1.key, "some new title") + self.call(CmdNoMatch(building_menu=self.menu), "q")
+ +
[docs] def test_add_choice_without_key(self): + """Try to add choices without keys.""" + choices = [] + for i in range(20): + choices.append(self.menu.add_choice("choice", attr="test")) + self.menu._add_keys_choice() + keys = [ + "c", + "h", + "o", + "i", + "e", + "ch", + "ho", + "oi", + "ic", + "ce", + "cho", + "hoi", + "oic", + "ice", + "choi", + "hoic", + "oice", + "choic", + "hoice", + "choice", + ] + for i in range(20): + self.assertEqual(choices[i].key, keys[i]) + + # Adding another key of the same title would break, no more available shortcut + self.menu.add_choice("choice", attr="test") + with self.assertRaises(ValueError): + self.menu._add_keys_choice()
+ +
[docs] def test_callbacks(self): + """Test callbacks in menus.""" + self.room1.key = "room1" + + def on_enter(caller, menu): + caller.msg("on_enter:{}".format(menu.title)) + + def on_nomatch(caller, string, choice): + caller.msg("on_nomatch:{},{}".format(string, choice.key)) + + def on_leave(caller, obj): + caller.msg("on_leave:{}".format(obj.key)) + + self.menu.add_choice( + "test", key="e", on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave + ) + self.call(CmdNoMatch(building_menu=self.menu), "e", "on_enter:test") + self.call(CmdNoMatch(building_menu=self.menu), "ok", "on_nomatch:ok,e") + self.call(CmdNoMatch(building_menu=self.menu), "@", "on_leave:room1") + self.call(CmdNoMatch(building_menu=self.menu), "q")
+ +
[docs] def test_multi_level(self): + """Test multi-level choices.""" + + # Creaste three succeeding menu (t2 is contained in t1, t3 is contained in t2) + def on_nomatch_t1(caller, menu): + menu.move("whatever") # this will be valid since after t1 is a joker + + def on_nomatch_t2(caller, menu): + menu.move("t3") # this time the key matters + + t1 = self.menu.add_choice("what", key="t1", on_nomatch=on_nomatch_t1) + t2 = self.menu.add_choice("and", key="t1.*", on_nomatch=on_nomatch_t2) + t3 = self.menu.add_choice("why", key="t1.*.t3") + self.menu.open() + + # Move into t1 + self.assertIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + self.assertIsNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "t1") + self.assertEqual(self.menu.current_choice, t1) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Move into t2 + self.call(CmdNoMatch(building_menu=self.menu), "t2") + self.assertEqual(self.menu.current_choice, t2) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertIn(t3, self.menu.relevant_choices) + + # Move into t3 + self.call(CmdNoMatch(building_menu=self.menu), "t3") + self.assertEqual(self.menu.current_choice, t3) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Move back to t2 + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertEqual(self.menu.current_choice, t2) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertIn(t3, self.menu.relevant_choices) + + # Move back into t1 + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertEqual(self.menu.current_choice, t1) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Moves back to the main menu + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + self.assertIsNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "q")
+ +
[docs] def test_submenu(self): + """Test to add sub-menus.""" + + def open_exit(menu): + menu.open_submenu("evennia.contrib.base_systems.building_menu.tests.Submenu", self.exit) + return False + + self.menu.add_choice("exit", key="x", on_enter=open_exit) + self.menu.open() + self.call(CmdNoMatch(building_menu=self.menu), "x") + self.menu = self.char1.ndb._building_menu + self.call(CmdNoMatch(building_menu=self.menu), "t") + self.call(CmdNoMatch(building_menu=self.menu), "in") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.menu = self.char1.ndb._building_menu + self.assertEqual(self.char1.ndb._building_menu.obj, self.room1) + self.call(CmdNoMatch(building_menu=self.menu), "q") + self.assertEqual(self.exit.key, "in")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/color_markups/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/color_markups/tests.html new file mode 100644 index 0000000000..91145da138 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/color_markups/tests.html @@ -0,0 +1,173 @@ + + + + + + + + evennia.contrib.base_systems.color_markups.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.color_markups.tests

+"""
+Test Color markup.
+
+"""
+
+import re
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import color_markups
+
+
+
[docs]class TestColorMarkup(BaseEvenniaTest): + """ + Note: Normally this would be tested by importing the ansi parser and run + the mappings through it. This is not possible since the ansi module creates + its mapping at the module/class level; since the ansi module is used by so + many other modules it appears that trying to overload + settings to test it causes issues with unrelated tests. + """ + +
[docs] def test_curly_markup(self): + ansi_map = color_markups.CURLY_COLOR_ANSI_EXTRA_MAP + self.assertIsNotNone(re.match(re.escape(ansi_map[7][0]), "{r")) + self.assertIsNotNone(re.match(re.escape(ansi_map[-1][0]), "{[X")) + xterm_fg = color_markups.CURLY_COLOR_XTERM256_EXTRA_FG + self.assertIsNotNone(re.match(xterm_fg[0], "{001")) + self.assertIsNotNone(re.match(xterm_fg[0], "{123")) + self.assertIsNotNone(re.match(xterm_fg[0], "{455")) + xterm_bg = color_markups.CURLY_COLOR_XTERM256_EXTRA_BG + self.assertIsNotNone(re.match(xterm_bg[0], "{[001")) + self.assertIsNotNone(re.match(xterm_bg[0], "{[123")) + self.assertIsNotNone(re.match(xterm_bg[0], "{[455")) + xterm_gfg = color_markups.CURLY_COLOR_XTERM256_EXTRA_GFG + self.assertIsNotNone(re.match(xterm_gfg[0], "{=h")) + self.assertIsNotNone(re.match(xterm_gfg[0], "{=e")) + self.assertIsNotNone(re.match(xterm_gfg[0], "{=w")) + xterm_gbg = color_markups.CURLY_COLOR_XTERM256_EXTRA_GBG + self.assertIsNotNone(re.match(xterm_gbg[0], "{[=a")) + self.assertIsNotNone(re.match(xterm_gbg[0], "{[=k")) + self.assertIsNotNone(re.match(xterm_gbg[0], "{[=z")) + bright_map = color_markups.CURLY_COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP + self.assertEqual(bright_map[0][1], "{[500") + self.assertEqual(bright_map[-1][1], "{[222")
+ +
[docs] def test_mux_markup(self): + ansi_map = color_markups.MUX_COLOR_ANSI_EXTRA_MAP + self.assertIsNotNone(re.match(re.escape(ansi_map[10][0]), "%cr")) + self.assertIsNotNone(re.match(re.escape(ansi_map[-1][0]), "%cX")) + xterm_fg = color_markups.MUX_COLOR_XTERM256_EXTRA_FG + self.assertIsNotNone(re.match(xterm_fg[0], "%c001")) + self.assertIsNotNone(re.match(xterm_fg[0], "%c123")) + self.assertIsNotNone(re.match(xterm_fg[0], "%c455")) + xterm_bg = color_markups.MUX_COLOR_XTERM256_EXTRA_BG + self.assertIsNotNone(re.match(xterm_bg[0], "%c[001")) + self.assertIsNotNone(re.match(xterm_bg[0], "%c[123")) + self.assertIsNotNone(re.match(xterm_bg[0], "%c[455")) + xterm_gfg = color_markups.MUX_COLOR_XTERM256_EXTRA_GFG + self.assertIsNotNone(re.match(xterm_gfg[0], "%c=h")) + self.assertIsNotNone(re.match(xterm_gfg[0], "%c=e")) + self.assertIsNotNone(re.match(xterm_gfg[0], "%c=w")) + xterm_gbg = color_markups.MUX_COLOR_XTERM256_EXTRA_GBG + self.assertIsNotNone(re.match(xterm_gbg[0], "%c[=a")) + self.assertIsNotNone(re.match(xterm_gbg[0], "%c[=k")) + self.assertIsNotNone(re.match(xterm_gbg[0], "%c[=z")) + bright_map = color_markups.MUX_COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP + self.assertEqual(bright_map[0][1], "%c[500") + self.assertEqual(bright_map[-1][1], "%c[222")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/components.html b/docs/latest/_modules/evennia/contrib/base_systems/components.html new file mode 100644 index 0000000000..86676b7859 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/components.html @@ -0,0 +1,134 @@ + + + + + + + + evennia.contrib.base_systems.components — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.components

+"""
+Components - ChrisLR 2022
+
+This is a basic Component System.
+It allows you to use components on typeclasses using a simple syntax.
+This helps writing isolated code and reusing it over multiple objects.
+
+See the docs for more information.
+"""
+
+from evennia.contrib.base_systems.components.component import Component
+from evennia.contrib.base_systems.components.dbfield import DBField, NDBField, TagField
+from evennia.contrib.base_systems.components.holder import (
+    ComponentHolderMixin,
+    ComponentProperty,
+)
+
+
+
[docs]def get_component_class(component_name): + subclasses = Component.__subclasses__() + component_class = next((sc for sc in subclasses if sc.name == component_name), None) + if component_class is None: + message = ( + f"Component named {component_name} has not been found. " + f"Make sure it has been imported before being used." + ) + raise Exception(message) + + return component_class
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/components/component.html b/docs/latest/_modules/evennia/contrib/base_systems/components/component.html new file mode 100644 index 0000000000..9eaa5546fa --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/components/component.html @@ -0,0 +1,262 @@ + + + + + + + + evennia.contrib.base_systems.components.component — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.components.component

+"""
+Components - ChrisLR 2022
+
+This file contains the base class to inherit for creating new components.
+"""
+import itertools
+
+
+
[docs]class Component: + """ + This is the base class for components. + Any component must inherit from this class to be considered for usage. + + Each Component must supply the name, it is used as a slot name but also part of the attribute key. + """ + + name = "" + +
[docs] def __init__(self, host=None): + assert self.name, "All Components must have a Name" + self.host = host
+ +
[docs] @classmethod + def default_create(cls, host): + """ + This is called when the host is created + and should return the base initialized state of a component. + + Args: + host (object): The host typeclass instance + + Returns: + Component: The created instance of the component + + """ + new = cls(host) + return new
+ +
[docs] @classmethod + def create(cls, host, **kwargs): + """ + This is the method to call when supplying kwargs to initialize a component. + + Args: + host (object): The host typeclass instance + **kwargs: Key-Value of default values to replace. + To persist the value, the key must correspond to a DBField. + + Returns: + Component: The created instance of the component + + """ + + new = cls.default_create(host) + for key, value in kwargs.items(): + setattr(new, key, value) + + return new
+ +
[docs] def cleanup(self): + """ + This deletes all component attributes from the host's db + """ + for attribute in self._all_db_field_names: + delattr(self, attribute)
+ +
[docs] @classmethod + def load(cls, host): + """ + Loads a component instance + This is called whenever a component is loaded (ex: Server Restart) + + Args: + host (object): The host typeclass instance + + Returns: + Component: The loaded instance of the component + + """ + + return cls(host)
+ +
[docs] def at_added(self, host): + """ + This is the method called when a component is registered on a host. + + Args: + host (object): The host typeclass instance + + """ + + if self.host: + if self.host == host: + return + else: + raise ComponentRegisterError("Components must not register twice!") + + self.host = host
+ +
[docs] def at_removed(self, host): + """ + This is the method called when a component is removed from a host. + + Args: + host (object): The host typeclass instance + + """ + if host != self.host: + raise ComponentRegisterError("Component attempted to remove from the wrong host.") + self.host = None
+ + @property + def attributes(self): + """ + Shortcut property returning the host's AttributeHandler. + + Returns: + AttributeHandler: The Host's AttributeHandler + + """ + return self.host.attributes + + @property + def nattributes(self): + """ + Shortcut property returning the host's In-Memory AttributeHandler (Non Persisted). + + Returns: + AttributeHandler: The Host's In-Memory AttributeHandler + + """ + return self.host.nattributes + + @property + def _all_db_field_names(self): + return itertools.chain(self.db_field_names, self.ndb_field_names) + + @property + def db_field_names(self): + db_fields = getattr(self, "_db_fields", {}) + return db_fields.keys() + + @property + def ndb_field_names(self): + ndb_fields = getattr(self, "_ndb_fields", {}) + return ndb_fields.keys() + + @property + def tag_field_names(self): + tag_fields = getattr(self, "_tag_fields", {}) + return tag_fields.keys()
+ + +
[docs]class ComponentRegisterError(Exception): + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/components/dbfield.html b/docs/latest/_modules/evennia/contrib/base_systems/components/dbfield.html new file mode 100644 index 0000000000..460f68303d --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/components/dbfield.html @@ -0,0 +1,223 @@ + + + + + + + + evennia.contrib.base_systems.components.dbfield — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.components.dbfield

+"""
+Components - ChrisLR 2022
+
+This file contains the Descriptors used to set Fields in Components
+"""
+from evennia.typeclasses.attributes import AttributeProperty, NAttributeProperty
+
+
+
[docs]class DBField(AttributeProperty): + """ + Component Attribute Descriptor. + Allows you to set attributes related to a component on the class. + It uses AttributeProperty under the hood but prefixes the key with the component name. + """ + + def __set_name__(self, owner, name): + """ + Called when descriptor is first assigned to the class. + + Args: + owner (object): The component classF on which this is set + name (str): The name that was used to set the DBField. + """ + key = f"{owner.name}::{name}" + self._key = key + db_fields = getattr(owner, "_db_fields", None) + if db_fields is None: + db_fields = {} + setattr(owner, "_db_fields", db_fields) + db_fields[name] = self
+ + +
[docs]class NDBField(NAttributeProperty): + """ + Component In-Memory Attribute Descriptor. + Allows you to set in-memory attributes related to a component on the class. + It uses NAttributeProperty under the hood but prefixes the key with the component name. + """ + + def __set_name__(self, owner, name): + """ + Called when descriptor is first assigned to the class. + + Args: + owner (object): The component class on which this is set + name (str): The name that was used to set the DBField. + """ + key = f"{owner.name}::{name}" + self._key = key + ndb_fields = getattr(owner, "_ndb_fields", None) + if ndb_fields is None: + ndb_fields = {} + setattr(owner, "_ndb_fields", ndb_fields) + ndb_fields[name] = self
+ + +
[docs]class TagField: + """ + Component Tags Descriptor. + Allows you to set Tags related to a component on the class. + The tags are set with a prefixed category, so it can support + multiple tags or enforce a single one. + + Default value of a tag is added when the component is registered. + Tags are removed if the component itself is removed. + """ + +
[docs] def __init__(self, default=None, enforce_single=False): + self._category_key = None + self._default = default + self._enforce_single = enforce_single
+ + def __set_name__(self, owner, name): + """ + Called when TagField is first assigned to the class. + It is called with the component class and the name of the field. + """ + self._category_key = f"{owner.name}::{name}" + tag_fields = getattr(owner, "_tag_fields", None) + if tag_fields is None: + tag_fields = {} + setattr(owner, "_tag_fields", tag_fields) + tag_fields[name] = self + + def __get__(self, instance, owner): + """ + Called when retrieving the value of the TagField. + It is called with the component instance and the class. + """ + tag_value = instance.host.tags.get( + default=self._default, + category=self._category_key, + ) + return tag_value + + def __set__(self, instance, value): + """ + Called when setting a value on the TagField. + It is called with the component instance and the value. + """ + + tag_handler = instance.host.tags + if self._enforce_single: + tag_handler.clear(category=self._category_key) + + tag_handler.add( + key=value, + category=self._category_key, + ) + + def __delete__(self, instance): + """ + Used when 'del' is called on the TagField. + It is called with the component instance. + """ + instance.host.tags.clear(category=self._category_key)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/components/holder.html b/docs/latest/_modules/evennia/contrib/base_systems/components/holder.html new file mode 100644 index 0000000000..8acb1bae9c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/components/holder.html @@ -0,0 +1,421 @@ + + + + + + + + evennia.contrib.base_systems.components.holder — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.components.holder

+"""
+Components - ChrisLR 2022
+
+This file contains the classes that allow a typeclass to use components.
+"""
+
+from evennia.contrib.base_systems import components
+from evennia.contrib.base_systems.components import signals
+
+
+
[docs]class ComponentProperty: + """ + This allows you to register a component on a typeclass. + Components registered with this property are automatically added + to any instance of this typeclass. + + Defaults can be overridden for this typeclass by passing kwargs + """ + +
[docs] def __init__(self, component_name, **kwargs): + """ + Initializes the descriptor + + Args: + component_name (str): The name of the component + **kwargs (any): Key=Values overriding default values of the component + """ + self.component_name = component_name + self.values = kwargs
+ + def __get__(self, instance, owner): + component = instance.components.get(self.component_name) + return component + + def __set__(self, instance, value): + raise Exception("Cannot set a class property") + + def __set_name__(self, owner, name): + # Retrieve the class_components set on the direct class only + class_components = owner.__dict__.get("_class_components") + if not class_components: + # Create a new list, including inherited class components + class_components = list(getattr(owner, "_class_components", [])) + setattr(owner, "_class_components", class_components) + + class_components.append((self.component_name, self.values))
+ + +
[docs]class ComponentHandler: + """ + This is the handler that will be added to any typeclass that inherits from ComponentHolder. + It lets you add or remove components and will load components as needed. + It stores the list of registered components on the host .db with component_names as key. + """ + +
[docs] def __init__(self, host): + self.host = host + self._loaded_components = {}
+ +
[docs] def add(self, component): + """ + Method to add a Component to a host. + It caches the loaded component and appends its name to the host's component name list. + It will also call the component's 'at_added' method, passing its host. + + Args: + component (object): The 'loaded' component instance to add. + + """ + self._set_component(component) + self.db_names.append(component.name) + self._add_component_tags(component) + component.at_added(self.host) + self.host.signals.add_object_listeners_and_responders(component)
+ +
[docs] def add_default(self, name): + """ + Method to add a Component initialized to default values on a host. + It will retrieve the proper component and instanciate it with 'default_create'. + It will cache this new component and add it to its list. + It will also call the component's 'at_added' method, passing its host. + + Args: + name (str): The name of the component class to add. + + """ + component = components.get_component_class(name) + if not component: + raise ComponentDoesNotExist(f"Component {name} does not exist.") + + new_component = component.default_create(self.host) + self._set_component(new_component) + self.db_names.append(name) + self._add_component_tags(new_component) + new_component.at_added(self.host) + self.host.signals.add_object_listeners_and_responders(new_component)
+ + def _add_component_tags(self, component): + """ + Private method that adds the Tags set on a Component via TagFields + It will also add the name of the component so objects can be filtered + by the components the implement. + + Args: + component (object): The component instance that is added. + """ + self.host.tags.add(component.name, category="components") + for tag_field_name in component.tag_field_names: + default_tag = type(component).__dict__[tag_field_name]._default + if default_tag: + setattr(component, tag_field_name, default_tag) + +
[docs] def remove(self, component): + """ + Method to remove a component instance from a host. + It removes the component from the cache and listing. + It will call the component's 'at_removed' method. + + Args: + component (object): The component instance to remove. + + """ + component_name = component.name + if component_name in self._loaded_components: + self._remove_component_tags(component) + component.at_removed(self.host) + self.db_names.remove(component_name) + self.host.signals.remove_object_listeners_and_responders(component) + del self._loaded_components[component_name] + else: + message = ( + f"Cannot remove {component_name} from {self.host.name} as it is not registered." + ) + raise ComponentIsNotRegistered(message)
+ +
[docs] def remove_by_name(self, name): + """ + Method to remove a component instance from a host. + It removes the component from the cache and listing. + It will call the component's 'at_removed' method. + + Args: + name (str): The name of the component to remove. + + """ + instance = self.get(name) + if not instance: + message = f"Cannot remove {name} from {self.host.name} as it is not registered." + raise ComponentIsNotRegistered(message) + + self._remove_component_tags(instance) + instance.at_removed(self.host) + self.host.signals.remove_object_listeners_and_responders(instance) + self.db_names.remove(name) + + del self._loaded_components[name]
+ + def _remove_component_tags(self, component): + """ + Private method that will remove the Tags set on a Component via TagFields + It will also remove the component name tag. + + Args: + component (object): The component instance that is removed. + """ + self.host.tags.remove(component.name, category="components") + for tag_field_name in component.tag_field_names: + delattr(component, tag_field_name) + +
[docs] def get(self, name): + """ + Method to retrieve a cached Component instance by its name. + + Args: + name (str): The name of the component to retrieve. + + """ + return self._loaded_components.get(name)
+ +
[docs] def has(self, name): + """ + Method to check if a component is registered and ready. + + Args: + name (str): The name of the component. + + """ + return name in self._loaded_components
+ +
[docs] def initialize(self): + """ + Method that loads and caches each component currently registered on the host. + It retrieves the names from the registered listing and calls 'load' on each + prototype class that can be found from this listing. + + """ + component_names = self.db_names + if not component_names: + return + + for component_name in component_names: + component = components.get_component_class(component_name) + if component: + component_instance = component.load(self.host) + self._set_component(component_instance) + self.host.signals.add_object_listeners_and_responders(component_instance) + else: + message = ( + f"Could not initialize runtime component {component_name} of {self.host.name}" + ) + raise ComponentDoesNotExist(message)
+ + def _set_component(self, component): + self._loaded_components[component.name] = component + + @property + def db_names(self): + """ + Property shortcut to retrieve the registered component names + + Returns: + component_names (iterable): The name of each component that is registered + + """ + return self.host.attributes.get("component_names") + + def __getattr__(self, name): + return self.get(name)
+ + +
[docs]class ComponentHolderMixin: + """ + Mixin to add component support to a typeclass + + Components are set on objects using the component.name as an object attribute. + All registered components are initialized on the typeclass. + They will be of None value if not present in the class components or runtime components. + """ + +
[docs] def at_init(self): + """ + Method that initializes the ComponentHandler. + """ + super(ComponentHolderMixin, self).at_init() + setattr(self, "_component_handler", ComponentHandler(self)) + setattr(self, "_signal_handler", signals.SignalsHandler(self)) + self.components.initialize() + self.signals.trigger("at_after_init")
+ +
[docs] def at_post_puppet(self, *args, **kwargs): + super().at_post_puppet(*args, **kwargs) + self.signals.trigger("at_post_puppet", *args, **kwargs)
+ +
[docs] def at_post_unpuppet(self, *args, **kwargs): + super().at_post_unpuppet(*args, **kwargs) + self.signals.trigger("at_post_unpuppet", *args, **kwargs)
+ +
[docs] def basetype_setup(self): + """ + Method that initializes the ComponentHandler, creates and registers all + components that were set on the typeclass using ComponentProperty. + """ + super().basetype_setup() + component_names = [] + setattr(self, "_component_handler", ComponentHandler(self)) + setattr(self, "_signal_handler", signals.SignalsHandler(self)) + class_components = getattr(self, "_class_components", ()) + for component_name, values in class_components: + component_class = components.get_component_class(component_name) + component = component_class.create(self, **values) + component_names.append(component_name) + self.components._loaded_components[component_name] = component + self.signals.add_object_listeners_and_responders(component) + + self.db.component_names = component_names + self.signals.trigger("at_basetype_setup")
+ +
[docs] def basetype_posthook_setup(self): + """ + Method that add component related tags that were set using ComponentProperty. + """ + super().basetype_posthook_setup() + for component in self.components._loaded_components.values(): + self.components._add_component_tags(component)
+ + @property + def components(self) -> ComponentHandler: + """ + Property getter to retrieve the component_handler. + Returns: + ComponentHandler: This Host's ComponentHandler + """ + return getattr(self, "_component_handler", None) + + @property + def cmp(self) -> ComponentHandler: + """ + Shortcut Property getter to retrieve the component_handler. + Returns: + ComponentHandler: This Host's ComponentHandler + """ + return self.components + + @property + def signals(self) -> signals.SignalsHandler: + return getattr(self, "_signal_handler", None)
+ + +
[docs]class ComponentDoesNotExist(Exception): + pass
+ + +
[docs]class ComponentIsNotRegistered(Exception): + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/components/signals.html b/docs/latest/_modules/evennia/contrib/base_systems/components/signals.html new file mode 100644 index 0000000000..3cb9efe667 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/components/signals.html @@ -0,0 +1,318 @@ + + + + + + + + evennia.contrib.base_systems.components.signals — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.components.signals

+"""
+Components - ChrisLR 2022
+
+This file contains classes functions related to signals.
+"""
+
+
+
[docs]def as_listener(func=None, signal_name=None): + """ + Decorator style function that marks a method to be connected as listener. + It will use the provided signal name and default to the decorated function name. + + Args: + func (callable): The method to mark as listener + signal_name (str): The name of the signal to listen to, defaults to function name. + """ + if not func and signal_name: + + def wrapper(func): + func._listener_signal_name = signal_name + return func + + return wrapper + + signal_name = func.__name__ + func._listener_signal_name = signal_name + return func
+ + +
[docs]def as_responder(func=None, signal_name=None): + """ + Decorator style function that marks a method to be connected as responder. + It will use the provided signal name and default to the decorated function name. + + Args: + func (callable): The method to mark as responder + signal_name (str): The name of the signal to respond to, defaults to function name. + """ + if not func and signal_name: + + def wrapper(func): + func._responder_signal_name = signal_name + return func + + return wrapper + + signal_name = func.__name__ + func._responder_signal_name = signal_name + return func
+ + +
[docs]class SignalsHandler(object): + """ + This object handles all about signals. + It holds the connected listeners and responders. + It allows triggering signals or querying responders. + """ + +
[docs] def __init__(self, host): + self.host = host + self.listeners = {} + self.responders = {} + self.add_object_listeners_and_responders(host)
+ +
[docs] def add_listener(self, signal_name, callback): + """ + Connect a listener to a specific signal. + + Args: + signal_name (str): The name of the signal to listen to + callback (callable): The callable that is called when the signal is triggered + """ + + signal_listeners = self.listeners.setdefault(signal_name, []) + if callback not in signal_listeners: + signal_listeners.append(callback)
+ +
[docs] def add_responder(self, signal_name, callback): + """ + Connect a responder to a specific signal. + + Args: + signal_name (str): The name of the signal to respond to + callback (callable): The callable that is called when the signal is queried + """ + + signal_responders = self.responders.setdefault(signal_name, []) + if callback not in signal_responders: + signal_responders.append(callback)
+ +
[docs] def remove_listener(self, signal_name, callback): + """ + Removes a listener for a specific signal. + + Args: + signal_name (str): The name of the signal to disconnect from + callback (callable): The callable that was used to connect + """ + + signal_listeners = self.listeners.get(signal_name) + if not signal_listeners: + return + + if callback in signal_listeners: + signal_listeners.remove(callback)
+ +
[docs] def remove_responder(self, signal_name, callback): + """ + Removes a responder for a specific signal. + + Args: + signal_name (str): The name of the signal to disconnect from + callback (callable): The callable that was used to connect + """ + signal_responders = self.responders.get(signal_name) + if not signal_responders: + return + + if callback in signal_responders: + signal_responders.remove(callback)
+ +
[docs] def trigger(self, signal_name, *args, **kwargs): + """ + Triggers a specific signal with specified args and kwargs + This method does not return anything + + Args: + signal_name (str): The name of the signal to trigger + """ + + callbacks = self.listeners.get(signal_name) + if not callbacks: + return + + for callback in callbacks: + callback(*args, **kwargs)
+ +
[docs] def query(self, signal_name, *args, default=None, aggregate_func=None, **kwargs): + """ + Queries a specific signal with specified args and kwargs + This method will return the responses from its connected responders. + If an aggregate_func is specified, it is called with the responses + and its result is returned instead. + + Args: + signal_name (str): The name of the signal to trigger + default (any): The value to use when no responses are given + It will be passed to aggregate_func if it is also given. + aggregate_func (callable): The function to process the results before returning. + + Returns: + list: An iterable of the responses + OR the aggregated result when aggregate_func is specified. + + """ + callbacks = self.responders.get(signal_name) + + if not callbacks: + default = [] if default is None else default + if aggregate_func: + return aggregate_func(default) + return default + + responses = [] + for callback in callbacks: + response = callback(*args, **kwargs) + if response is not None: + responses.append(response) + + if aggregate_func and responses: + return aggregate_func(responses) + + return responses
+ +
[docs] def add_object_listeners_and_responders(self, obj): + """ + This connects the methods marked as listener or responder from an object. + + Args: + obj (object): The instance of an object to connect to this handler. + """ + type_host = type(obj) + for att_name, att_obj in type_host.__dict__.items(): + listener_signal_name = getattr(att_obj, "_listener_signal_name", None) + if listener_signal_name: + callback = getattr(obj, att_name) + self.add_listener(signal_name=listener_signal_name, callback=callback) + + responder_signal_name = getattr(att_obj, "_responder_signal_name", None) + if responder_signal_name: + callback = getattr(obj, att_name) + self.add_responder(signal_name=responder_signal_name, callback=callback)
+ +
[docs] def remove_object_listeners_and_responders(self, obj): + """ + This disconnects the methods marked as listener or responder from an object. + + Args: + obj (object): The instance of an object to disconnect from this handler. + """ + type_host = type(obj) + for att_name, att_obj in type_host.__dict__.items(): + listener_signal_name = getattr(att_obj, "_listener_signal_name", None) + if listener_signal_name: + callback = getattr(obj, att_name) + self.remove_listener(signal_name=listener_signal_name, callback=callback) + + responder_signal_name = getattr(att_obj, "_responder_signal_name", None) + if responder_signal_name: + callback = getattr(obj, att_name) + self.remove_responder(signal_name=responder_signal_name, callback=callback)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/components/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/components/tests.html new file mode 100644 index 0000000000..b917c6ae35 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/components/tests.html @@ -0,0 +1,528 @@ + + + + + + + + evennia.contrib.base_systems.components.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.components.tests

+from evennia.contrib.base_systems.components import (
+    Component,
+    DBField,
+    TagField,
+    signals,
+)
+from evennia.contrib.base_systems.components.holder import (
+    ComponentHolderMixin,
+    ComponentProperty,
+)
+from evennia.contrib.base_systems.components.signals import as_listener
+from evennia.objects.objects import DefaultCharacter
+from evennia.utils import create
+from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTest
+
+
+
[docs]class ComponentTestA(Component): + name = "test_a" + my_int = DBField(default=1) + my_list = DBField(default=[])
+ + +
[docs]class ComponentTestB(Component): + name = "test_b" + my_int = DBField(default=1) + my_list = DBField(default=[]) + default_tag = TagField(default="initial_value") + single_tag = TagField(enforce_single=True) + multiple_tags = TagField() + default_single_tag = TagField(default="initial_value", enforce_single=True)
+ + +
[docs]class RuntimeComponentTestC(Component): + name = "test_c" + my_int = DBField(default=6) + my_dict = DBField(default={}) + added_tag = TagField(default="added_value")
+ + +
[docs]class CharacterWithComponents(ComponentHolderMixin, DefaultCharacter): + test_a = ComponentProperty("test_a") + test_b = ComponentProperty("test_b", my_int=3, my_list=[1, 2, 3])
+ + +
[docs]class InheritedTCWithComponents(CharacterWithComponents): + test_c = ComponentProperty("test_c")
+ + +
[docs]class TestComponents(EvenniaTest): + character_typeclass = CharacterWithComponents + +
[docs] def test_character_has_class_components(self): + assert self.char1.test_a + assert self.char1.test_b
+ +
[docs] def test_inherited_typeclass_does_not_include_child_class_components(self): + char_with_c = create.create_object( + InheritedTCWithComponents, key="char_with_c", location=self.room1, home=self.room1 + ) + assert self.char1.test_a + assert not self.char1.cmp.get("test_c") + assert char_with_c.test_c
+ +
[docs] def test_character_instances_components_properly(self): + assert isinstance(self.char1.test_a, ComponentTestA) + assert isinstance(self.char1.test_b, ComponentTestB)
+ +
[docs] def test_character_assigns_default_value(self): + assert self.char1.test_a.my_int == 1 + assert self.char1.test_a.my_list == []
+ +
[docs] def test_character_assigns_default_provided_values(self): + assert self.char1.test_b.my_int == 3 + assert self.char1.test_b.my_list == [1, 2, 3]
+ +
[docs] def test_character_can_register_runtime_component(self): + rct = RuntimeComponentTestC.create(self.char1) + self.char1.components.add(rct) + test_c = self.char1.components.get("test_c") + + assert test_c + assert test_c.my_int == 6 + assert test_c.my_dict == {}
+ +
[docs] def test_handler_can_add_default_component(self): + self.char1.components.add_default("test_c") + test_c = self.char1.components.get("test_c") + + assert test_c + assert test_c.my_int == 6
+ +
[docs] def test_handler_has_returns_true_for_any_components(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + + assert handler.has("test_a") + assert handler.has("test_b") + assert handler.has("test_c")
+ +
[docs] def test_can_remove_component(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + handler.remove(rct) + + assert handler.has("test_a") + assert handler.has("test_b") + assert not handler.has("test_c")
+ +
[docs] def test_can_remove_component_by_name(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + handler.remove_by_name("test_c") + + assert handler.has("test_a") + assert handler.has("test_b") + assert not handler.has("test_c")
+ +
[docs] def test_cannot_replace_component(self): + with self.assertRaises(Exception): + self.char1.test_a = None
+ +
[docs] def test_can_get_component(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + + assert handler.get("test_c") is rct
+ +
[docs] def test_can_access_component_regular_get(self): + assert self.char1.cmp.test_a is self.char1.components.get("test_a")
+ +
[docs] def test_returns_none_with_regular_get_when_no_attribute(self): + assert self.char1.cmp.does_not_exist is None
+ +
[docs] def test_host_has_class_component_tags(self): + assert self.char1.tags.has(key="test_a", category="components") + assert self.char1.tags.has(key="test_b", category="components") + assert self.char1.tags.has(key="initial_value", category="test_b::default_tag") + assert self.char1.test_b.default_tag == "initial_value" + assert not self.char1.tags.has(key="test_c", category="components") + assert not self.char1.tags.has(category="test_b::single_tag") + assert not self.char1.tags.has(category="test_b::multiple_tags")
+ +
[docs] def test_host_has_added_component_tags(self): + rct = RuntimeComponentTestC.create(self.char1) + self.char1.components.add(rct) + test_c = self.char1.components.get("test_c") + + assert self.char1.tags.has(key="test_c", category="components") + assert self.char1.tags.has(key="added_value", category="test_c::added_tag") + assert test_c.added_tag == "added_value"
+ +
[docs] def test_host_has_added_default_component_tags(self): + self.char1.components.add_default("test_c") + test_c = self.char1.components.get("test_c") + + assert self.char1.tags.has(key="test_c", category="components") + assert self.char1.tags.has(key="added_value", category="test_c::added_tag") + assert test_c.added_tag == "added_value"
+ +
[docs] def test_host_remove_component_tags(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + assert self.char1.tags.has(key="test_c", category="components") + handler.remove(rct) + + assert not self.char1.tags.has(key="test_c", category="components") + assert not self.char1.tags.has(key="added_value", category="test_c::added_tag")
+ +
[docs] def test_host_remove_by_name_component_tags(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + assert self.char1.tags.has(key="test_c", category="components") + handler.remove_by_name("test_c") + + assert not self.char1.tags.has(key="test_c", category="components") + assert not self.char1.tags.has(key="added_value", category="test_c::added_tag")
+ +
[docs] def test_component_tags_only_hold_one_value_when_enforce_single(self): + test_b = self.char1.components.get("test_b") + test_b.single_tag = "first_value" + test_b.single_tag = "second value" + + assert self.char1.tags.has(key="second value", category="test_b::single_tag") + assert test_b.single_tag == "second value" + assert not self.char1.tags.has(key="first_value", category="test_b::single_tag")
+ +
[docs] def test_component_tags_default_value_is_overridden_when_enforce_single(self): + test_b = self.char1.components.get("test_b") + test_b.default_single_tag = "second value" + + assert self.char1.tags.has(key="second value", category="test_b::default_single_tag") + assert test_b.default_single_tag == "second value" + assert not self.char1.tags.has(key="first_value", category="test_b::default_single_tag")
+ +
[docs] def test_component_tags_support_multiple_values_by_default(self): + test_b = self.char1.components.get("test_b") + test_b.multiple_tags = "first value" + test_b.multiple_tags = "second value" + test_b.multiple_tags = "third value" + + assert all( + val in test_b.multiple_tags for val in ("first value", "second value", "third value") + ) + assert self.char1.tags.has(key="first value", category="test_b::multiple_tags") + assert self.char1.tags.has(key="second value", category="test_b::multiple_tags") + assert self.char1.tags.has(key="third value", category="test_b::multiple_tags")
+ + +
[docs]class CharWithSignal(ComponentHolderMixin, DefaultCharacter): +
[docs] @signals.as_listener + def my_signal(self): + setattr(self, "my_signal_is_called", True)
+ +
[docs] @signals.as_listener + def my_other_signal(self): + setattr(self, "my_other_signal_is_called", True)
+ +
[docs] @signals.as_responder + def my_response(self): + return 1
+ +
[docs] @signals.as_responder + def my_other_response(self): + return 2
+ + +
[docs]class ComponentWithSignal(Component): + name = "test_signal_a" + +
[docs] @signals.as_listener + def my_signal(self): + setattr(self, "my_signal_is_called", True)
+ +
[docs] @signals.as_listener + def my_other_signal(self): + setattr(self, "my_other_signal_is_called", True)
+ +
[docs] @signals.as_responder + def my_response(self): + return 1
+ +
[docs] @signals.as_responder + def my_other_response(self): + return 2
+ +
[docs] @signals.as_responder + def my_component_response(self): + return 3
+ + +
[docs]class TestComponentSignals(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.char1 = create.create_object( + CharWithSignal, + key="Char", + )
+ +
[docs] def test_host_can_register_as_listener(self): + self.char1.signals.trigger("my_signal") + + assert self.char1.my_signal_is_called + assert not getattr(self.char1, "my_other_signal_is_called", None)
+ +
[docs] def test_host_can_register_as_responder(self): + responses = self.char1.signals.query("my_response") + + assert 1 in responses + assert 2 not in responses
+ +
[docs] def test_component_can_register_as_listener(self): + char = self.char1 + char.components.add(ComponentWithSignal.create(char)) + char.signals.trigger("my_signal") + + component = char.cmp.test_signal_a + assert component.my_signal_is_called + assert not getattr(component, "my_other_signal_is_called", None)
+ +
[docs] def test_component_can_register_as_responder(self): + char = self.char1 + char.components.add(ComponentWithSignal.create(char)) + responses = char.signals.query("my_response") + + assert 1 in responses + assert 2 not in responses
+ +
[docs] def test_signals_can_add_listener(self): + result = [] + + def my_fake_listener(): + result.append(True) + + self.char1.signals.add_listener("my_fake_signal", my_fake_listener) + self.char1.signals.trigger("my_fake_signal") + + assert result
+ +
[docs] def test_signals_can_add_responder(self): + def my_fake_responder(): + return 1 + + self.char1.signals.add_responder("my_fake_response", my_fake_responder) + responses = self.char1.signals.query("my_fake_response") + + assert 1 in responses
+ +
[docs] def test_signals_can_remove_listener(self): + result = [] + + def my_fake_listener(): + result.append(True) + + self.char1.signals.add_listener("my_fake_signal", my_fake_listener) + self.char1.signals.remove_listener("my_fake_signal", my_fake_listener) + self.char1.signals.trigger("my_fake_signal") + + assert not result
+ +
[docs] def test_signals_can_remove_responder(self): + def my_fake_responder(): + return 1 + + self.char1.signals.add_responder("my_fake_response", my_fake_responder) + self.char1.signals.remove_responder("my_fake_response", my_fake_responder) + responses = self.char1.signals.query("my_fake_response") + + assert not responses
+ +
[docs] def test_signals_can_trigger_with_args(self): + result = [] + + def my_fake_listener(arg1, kwarg1): + result.append((arg1, kwarg1)) + + self.char1.signals.add_listener("my_fake_signal", my_fake_listener) + self.char1.signals.trigger("my_fake_signal", 1, kwarg1=2) + + assert (1, 2) in result
+ +
[docs] def test_signals_can_query_with_args(self): + def my_fake_responder(arg1, kwarg1): + return (arg1, kwarg1) + + self.char1.signals.add_responder("my_fake_response", my_fake_responder) + responses = self.char1.signals.query("my_fake_response", 1, kwarg1=2) + + assert (1, 2) in responses
+ +
[docs] def test_signals_trigger_does_not_fail_without_listener(self): + self.char1.signals.trigger("some_unknown_signal")
+ +
[docs] def test_signals_query_does_not_fail_wihout_responders(self): + self.char1.signals.query("no_responders_allowed")
+ +
[docs] def test_signals_query_with_aggregate(self): + def my_fake_responder(arg1, kwarg1): + return (arg1, kwarg1) + + self.char1.signals.add_responder("my_fake_response", my_fake_responder) + responses = self.char1.signals.query("my_fake_response", 1, kwarg1=2) + + assert (1, 2) in responses
+ +
[docs] def test_signals_can_add_object_listeners_and_responders(self): + result = [] + + class FakeObj: + @as_listener + def my_signal(self): + result.append(True) + + self.char1.signals.add_object_listeners_and_responders(FakeObj()) + self.char1.signals.trigger("my_signal") + + assert result
+ +
[docs] def test_signals_can_remove_object_listeners_and_responders(self): + result = [] + + class FakeObj: + @as_listener + def my_signal(self): + result.append(True) + + obj = FakeObj() + self.char1.signals.add_object_listeners_and_responders(obj) + self.char1.signals.remove_object_listeners_and_responders(obj) + self.char1.signals.trigger("my_signal") + + assert not result
+ +
[docs] def test_component_handler_signals_connected_when_adding_default_component(self): + char = self.char1 + char.components.add_default("test_signal_a") + responses = char.signals.query("my_component_response") + + assert 3 in responses
+ +
[docs] def test_component_handler_signals_disconnected_when_removing_component(self): + char = self.char1 + comp = ComponentWithSignal.create(char) + char.components.add(comp) + char.components.remove(comp) + responses = char.signals.query("my_component_response") + + assert not responses
+ +
[docs] def test_component_handler_signals_disconnected_when_removing_component_by_name(self): + char = self.char1 + char.components.add_default("test_signal_a") + char.components.remove_by_name("test_signal_a") + responses = char.signals.query("my_component_response") + + assert not responses
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/custom_gametime/custom_gametime.html b/docs/latest/_modules/evennia/contrib/base_systems/custom_gametime/custom_gametime.html new file mode 100644 index 0000000000..d72d844229 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/custom_gametime/custom_gametime.html @@ -0,0 +1,437 @@ + + + + + + + + evennia.contrib.base_systems.custom_gametime.custom_gametime — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.custom_gametime.custom_gametime

+"""
+Custom gametime
+
+Contrib - Griatch 2017, vlgeoff 2017
+
+This implements the evennia.utils.gametime module but supporting
+a custom calendar for your game world. It allows for scheduling
+events to happen at given in-game times, taking this custom
+calendar into account.
+
+Usage:
+
+Use as the normal gametime module, that is by importing and using the
+helper functions in this module in your own code. The calendar can be
+customized by adding the `TIME_UNITS` dictionary to your settings
+file. This maps unit names to their length, expressed in the smallest
+unit. Here's the default as an example:
+
+    TIME_UNITS = {
+        "sec": 1,
+        "min": 60,
+        "hr": 60 * 60,
+        "hour": 60 * 60,
+        "day": 60 * 60 * 24,
+        "week": 60 * 60 * 24 * 7,
+        "month": 60 * 60 * 24 * 7 * 4,
+        "yr": 60 * 60 * 24 * 7 * 4 * 12,
+        "year": 60 * 60 * 24 * 7 * 4 * 12, }
+
+When using a custom calendar, these time unit names are used as kwargs to
+the converter functions in this module.
+
+"""
+
+# change these to fit your game world
+
+from django.conf import settings
+
+from evennia import DefaultScript
+from evennia.utils import gametime
+from evennia.utils.create import create_script
+
+# The game time speedup  / slowdown relative real time
+TIMEFACTOR = settings.TIME_FACTOR
+
+# These are the unit names understood by the scheduler.
+# Each unit must be consistent and expressed in seconds.
+UNITS = getattr(
+    settings,
+    "TIME_UNITS",
+    {
+        # default custom calendar
+        "sec": 1,
+        "min": 60,
+        "hr": 60 * 60,
+        "hour": 60 * 60,
+        "day": 60 * 60 * 24,
+        "week": 60 * 60 * 24 * 7,
+        "month": 60 * 60 * 24 * 7 * 4,
+        "yr": 60 * 60 * 24 * 7 * 4 * 12,
+        "year": 60 * 60 * 24 * 7 * 4 * 12,
+    },
+)
+
+
+
[docs]def time_to_tuple(seconds, *divisors): + """ + Helper function. Creates a tuple of even dividends given a range + of divisors. + + Args: + seconds (int): Number of seconds to format + *divisors (int): a sequence of numbers of integer dividends. The + number of seconds will be integer-divided by the first number in + this sequence, the remainder will be divided with the second and + so on. + Returns: + time (tuple): This tuple has length len(*args)+1, with the + last element being the last remaining seconds not evenly + divided by the supplied dividends. + + """ + results = [] + seconds = int(seconds) + for divisor in divisors: + results.append(seconds // divisor) + seconds %= divisor + results.append(seconds) + return tuple(results)
+ + +
[docs]def gametime_to_realtime(format=False, **kwargs): + """ + This method helps to figure out the real-world time it will take until an + in-game time has passed. E.g. if an event should take place a month later + in-game, you will be able to find the number of real-world seconds this + corresponds to (hint: Interval events deal with real life seconds). + + Keyword Args: + format (bool): Formatting the output. + days, month etc (int): These are the names of time units that must + match the `settings.TIME_UNITS` dict keys. + + Returns: + time (float or tuple): The realtime difference or the same + time split up into time units. + + Example: + gametime_to_realtime(days=2) -> number of seconds in real life from + now after which 2 in-game days will have passed. + + """ + # Dynamically creates the list of units based on kwarg names and UNITs list + rtime = 0 + for name, value in kwargs.items(): + # Allow plural names (like mins instead of min) + if name not in UNITS and name.endswith("s"): + name = name[:-1] + + if name not in UNITS: + raise ValueError("the unit {} isn't defined as a valid " "game time unit".format(name)) + rtime += value * UNITS[name] + rtime /= TIMEFACTOR + if format: + return time_to_tuple(rtime, 31536000, 2628000, 604800, 86400, 3600, 60) + return rtime
+ + +
[docs]def realtime_to_gametime(secs=0, mins=0, hrs=0, days=1, weeks=1, months=1, yrs=0, format=False): + """ + This method calculates how much in-game time a real-world time + interval would correspond to. This is usually a lot less + interesting than the other way around. + + Keyword Args: + times (int): The various components of the time. + format (bool): Formatting the output. + + Returns: + time (float or tuple): The gametime difference or the same + time split up into time units. + + Note: + days/weeks/months start from 1 (there is no day/week/month 0). This makes it + consistent with the real world datetime. + + Raises: + ValueError: If trying to add a days/weeks/months of <=0. + + Example: + realtime_to_gametime(days=2) -> number of game-world seconds + + """ + if days <= 0 or weeks <= 0 or months <= 0: + raise ValueError( + "realtime_to_gametime: days/weeks/months cannot be set <= 0, " "they start from 1." + ) + + # days/weeks/months start from 1, we need to adjust them to work mathematically. + days, weeks, months = days - 1, weeks - 1, months - 1 + + gtime = TIMEFACTOR * ( + secs + + mins * 60 + + hrs * 3600 + + days * 86400 + + weeks * 604800 + + months * 2628000 + + yrs * 31536000 + ) + if format: + units = sorted(set(UNITS.values()), reverse=True) + # Remove seconds from the tuple + del units[-1] + + return time_to_tuple(gtime, *units) + return gtime
+ + +
[docs]def custom_gametime(absolute=False): + """ + Return the custom game time as a tuple of units, as defined in settings. + + Args: + absolute (bool, optional): return the relative or absolute time. + + Returns: + The tuple describing the game time. The length of the tuple + is related to the number of unique units defined in the + settings. By default, the tuple would be (year, month, + week, day, hour, minute, second). + + """ + current = gametime.gametime(absolute=absolute) + units = sorted(set(UNITS.values()), reverse=True) + del units[-1] + return time_to_tuple(current, *units)
+ + +
[docs]def real_seconds_until(**kwargs): + """ + Return the real seconds until game time. + + If the game time is 5:00, TIME_FACTOR is set to 2 and you ask + the number of seconds until it's 5:10, then this function should + return 300 (5 minutes). + + Args: + times (str: int): the time units. + + Example: + real_seconds_until(hour=5, min=10, sec=0) + + Returns: + The number of real seconds before the given game time is up. + + Notes: + day/week/month start from 1, not from 0 (there is no month 0 for example) + + """ + current = gametime.gametime(absolute=True) + units = sorted(set(UNITS.values()), reverse=True) + # Remove seconds from the tuple + del units[-1] + divisors = list(time_to_tuple(current, *units)) + + # For each keyword, add in the unit's + units.append(1) + higher_unit = None + for unit, value in kwargs.items(): + if unit in ("day", "week", "month"): + # these start from 1 so we must adjust + value -= 1 + + # Get the unit's index + if unit not in UNITS: + raise ValueError(f"Unknown unit '{unit}'. Allowed: {', '.join(UNITS)}") + + seconds = UNITS[unit] + index = units.index(seconds) + divisors[index] = value + if higher_unit is None or higher_unit > index: + higher_unit = index + + # Check the projected time + # Note that it can be already passed (the given time may be in the past) + projected = 0 + for i, value in enumerate(divisors): + seconds = units[i] + projected += value * seconds + + if projected <= current: + # The time is in the past, increase the higher unit + if higher_unit: + divisors[higher_unit - 1] += 1 + else: + divisors[0] += 1 + + # Get the projected time again + projected = 0 + for i, value in enumerate(divisors): + seconds = units[i] + projected += value * seconds + + return (projected - current) / TIMEFACTOR
+ + +
[docs]def schedule(callback, repeat=False, **kwargs): + """ + Call the callback when the game time is up. + + Args: + callback (function): The callback function that will be called. This + must be a top-level function since the script will be persistent. + repeat (bool, optional): Should the callback be called regularly? + day, month, etc (str: int): The time units to call the callback; should + match the keys of TIME_UNITS. + + Returns: + script (Script): The created script. + + Examples: + schedule(func, min=5, sec=0) # Will call next hour at :05. + schedule(func, hour=2, min=30, sec=0) # Will call the next day at 02:30. + Notes: + This function will setup a script that will be called when the + time corresponds to the game time. If the game is stopped for + more than a few seconds, the callback may be called with a + slight delay. If `repeat` is set to True, the callback will be + called again next time the game time matches the given time. + The time is given in units as keyword arguments. + + """ + seconds = real_seconds_until(**kwargs) + script = create_script( + "evennia.contrib.base_systems.custom_gametime.GametimeScript", + key="GametimeScript", + desc="A timegame-sensitive script", + interval=seconds, + start_delay=True, + repeats=-1 if repeat else 1, + ) + script.db.callback = callback + script.db.gametime = kwargs + return script
+ + +# Scripts dealing in gametime (use `schedule` to create it) + + +
[docs]class GametimeScript(DefaultScript): + + """Gametime-sensitive script.""" + +
[docs] def at_script_creation(self): + """The script is created.""" + self.key = "unknown scr" + self.interval = 100 + self.start_delay = True + self.persistent = True
+ +
[docs] def at_repeat(self): + """Call the callback and reset interval.""" + + from evennia.utils.utils import calledby + + callback = self.db.callback + if callback: + callback() + + seconds = real_seconds_until(**self.db.gametime) + self.start(interval=seconds, force_restart=True)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/custom_gametime/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/custom_gametime/tests.html new file mode 100644 index 0000000000..99d34a6105 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/custom_gametime/tests.html @@ -0,0 +1,161 @@ + + + + + + + + evennia.contrib.base_systems.custom_gametime.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.custom_gametime.tests

+"""
+Testing custom game time
+
+"""
+
+# Testing custom_gametime
+from mock import Mock, patch
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .. import custom_gametime
+
+
+def _testcallback():
+    pass
+
+
+
[docs]@patch("evennia.utils.gametime.gametime", new=Mock(return_value=2975000898.46)) +class TestCustomGameTime(BaseEvenniaTest): +
[docs] def tearDown(self): + if hasattr(self, "timescript"): + self.timescript.stop()
+ +
[docs] def test_time_to_tuple(self): + self.assertEqual(custom_gametime.time_to_tuple(10000, 34, 2, 4, 6, 1), (294, 2, 0, 0, 0, 0)) + self.assertEqual(custom_gametime.time_to_tuple(10000, 3, 3, 4), (3333, 0, 0, 1)) + self.assertEqual(custom_gametime.time_to_tuple(100000, 239, 24, 3), (418, 4, 0, 2))
+ +
[docs] def test_gametime_to_realtime(self): + self.assertEqual(custom_gametime.gametime_to_realtime(days=2, mins=4), 86520.0) + self.assertEqual( + custom_gametime.gametime_to_realtime(format=True, days=2), (0, 0, 0, 1, 0, 0, 0) + )
+ +
[docs] def test_realtime_to_gametime(self): + self.assertEqual(custom_gametime.realtime_to_gametime(days=3, mins=34), 349680.0) + self.assertEqual( + custom_gametime.realtime_to_gametime(days=3, mins=34, format=True), + (0, 0, 0, 4, 1, 8, 0), + ) + self.assertEqual( + custom_gametime.realtime_to_gametime(format=True, days=3, mins=4), (0, 0, 0, 4, 0, 8, 0) + )
+ +
[docs] def test_custom_gametime(self): + self.assertEqual(custom_gametime.custom_gametime(), (102, 5, 2, 6, 21, 8, 18)) + self.assertEqual(custom_gametime.custom_gametime(absolute=True), (102, 5, 2, 6, 21, 8, 18))
+ +
[docs] def test_real_seconds_until(self): + self.assertEqual( + custom_gametime.real_seconds_until(year=2300, month=12, day=7), 31911667199.77 + )
+ +
[docs] def test_schedule(self): + self.timescript = custom_gametime.schedule(_testcallback, repeat=True, min=5, sec=0) + self.assertEqual(self.timescript.interval, 1700.7699999809265)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/email_login/email_login.html b/docs/latest/_modules/evennia/contrib/base_systems/email_login/email_login.html new file mode 100644 index 0000000000..caebdfc00d --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/email_login/email_login.html @@ -0,0 +1,424 @@ + + + + + + + + evennia.contrib.base_systems.email_login.email_login — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.email_login.email_login

+"""
+Email-based login system
+
+Evennia contrib - Griatch 2012
+
+
+This is a variant of the login system that requires an email-address
+instead of a username to login.
+
+This used to be the default Evennia login before replacing it with a
+more standard username + password system (having to supply an email
+for some reason caused a lot of confusion when people wanted to expand
+on it. The email is not strictly needed internally, nor is any
+confirmation email sent out anyway).
+
+
+Installation is simple:
+
+To your settings file, add/edit settings as follows:
+
+```python
+CMDSET_UNLOGGEDIN = "contrib.base_systems.email_login.email_login.UnloggedinCmdSet"
+CONNECTION_SCREEN_MODULE = "contrib.base_systems.email_login.connection_screens"
+
+```
+
+That's it. Reload the server and try to log in to see it.
+
+The initial login "graphic" will still not mention email addresses
+after this change. The login splashscreen is taken from strings in
+the module given by settings.CONNECTION_SCREEN_MODULE.
+
+"""
+
+from django.conf import settings
+
+from evennia.accounts.models import AccountDB
+from evennia.commands.cmdhandler import CMD_LOGINSTART
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.server.models import ServerConfig
+from evennia.utils import ansi, class_from_module, utils
+
+# limit symbol import for API
+__all__ = (
+    "CmdUnconnectedConnect",
+    "CmdUnconnectedCreate",
+    "CmdUnconnectedQuit",
+    "CmdUnconnectedLook",
+    "CmdUnconnectedHelp",
+)
+
+CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
+CONNECTION_SCREEN = ""
+try:
+    CONNECTION_SCREEN = ansi.parse_ansi(utils.random_string_from_module(CONNECTION_SCREEN_MODULE))
+except Exception:
+    # malformed connection screen or no screen given
+    pass
+if not CONNECTION_SCREEN:
+    CONNECTION_SCREEN = (
+        "\nEvennia: Error in CONNECTION_SCREEN MODULE"
+        " (randomly picked connection screen variable is not a string). \nEnter 'help' for aid."
+    )
+
+
+
[docs]class CmdUnconnectedConnect(MuxCommand): + """ + Connect to the game. + + Usage (at login screen): + connect <email> <password> + + Use the create command to first create an account before logging in. + """ + + key = "connect" + aliases = ["conn", "con", "co"] + locks = "cmd:all()" # not really needed + +
[docs] def func(self): + """ + Uses the Django admin api. Note that unlogged-in commands + have a unique position in that their `func()` receives + a session object instead of a `source_object` like all + other types of logged-in commands (this is because + there is no object yet before the account has logged in) + """ + + session = self.caller + arglist = self.arglist + + if not arglist or len(arglist) < 2: + session.msg("\n\r Usage (without <>): connect <email> <password>") + return + email = arglist[0] + password = arglist[1] + + # Match an email address to an account. + account = AccountDB.objects.get_account_from_email(email) + # No accountname match + if not account: + string = "The email '%s' does not match any accounts." % email + string += "\n\r\n\rIf you are new you should first create a new account " + string += "using the 'create' command." + session.msg(string) + return + # We have at least one result, so we can check the password. + if not account[0].check_password(password): + session.msg("Incorrect password.") + return + + # Check IP and/or name bans + bans = ServerConfig.objects.conf("server_bans") + if bans and ( + any(tup[0] == account.name for tup in bans) + or any(tup[2].match(session.address[0]) for tup in bans if tup[2]) + ): + # this is a banned IP or name! + string = "|rYou have been banned and cannot continue from here." + string += "\nIf you feel this ban is in error, please email an admin.|x" + session.msg(string) + session.execute_cmd("quit") + return + + # actually do the login. This will call all hooks. + session.sessionhandler.login(session, account)
+ + +
[docs]class CmdUnconnectedCreate(MuxCommand): + """ + Create a new account. + + Usage (at login screen): + create \"accountname\" <email> <password> + + This creates a new account account. + + """ + + key = "create" + aliases = ["cre", "cr"] + locks = "cmd:all()" + +
[docs] def parse(self): + """ + The parser must handle the multiple-word account + name enclosed in quotes: + connect "Long name with many words" my@myserv.com mypassw + """ + super().parse() + + self.accountinfo = [] + if len(self.arglist) < 3: + return + if len(self.arglist) > 3: + # this means we have a multi_word accountname. pop from the back. + password = self.arglist.pop() + email = self.arglist.pop() + # what remains is the username. + username = " ".join(self.arglist) + else: + username, email, password = self.arglist + + username = username.replace('"', "") # remove " + username = username.replace("'", "") + self.accountinfo = (username, email, password)
+ +
[docs] def func(self): + """Do checks and create account""" + + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + address = self.session.address + + session = self.caller + try: + username, email, password = self.accountinfo + except ValueError: + string = '\n\r Usage (without <>): create "<accountname>" <email> <password>' + session.msg(string) + return + if not email or not password: + session.msg("\n\r You have to supply an e-mail address followed by a password.") + return + if not utils.validate_email_address(email): + # check so the email at least looks ok. + session.msg("'%s' is not a valid e-mail address." % email) + return + + # pre-normalize username so the user know what they get + non_normalized_username = username + username = Account.normalize_username(username) + if non_normalized_username != username: + session.msg( + "Note: your username was normalized to strip spaces and remove characters " + "that could be visually confusing." + ) + + # have the user verify their new account was what they intended + answer = yield ( + f"You want to create an account '{username}' with email '{email}' and password " + f"'{password}'.\nIs this what you intended? [Y]/N?" + ) + if answer.lower() in ("n", "no"): + session.msg("Aborted. If your user name contains spaces, surround it by quotes.") + return + + # everything's ok. Create the new player account. + account, errors = Account.create( + username=username, email=email, password=password, ip=address, session=session + ) + if account: + # tell the caller everything went well. + string = "A new account '%s' was created. Welcome!" + if " " in username: + string += ( + "\n\nYou can now log in with the command 'connect \"%s\" <your password>'." + ) + else: + string += "\n\nYou can now log with the command 'connect %s <your password>'." + session.msg(string % (username, username)) + else: + session.msg("|R%s|n" % "\n".join(errors))
+ + +
[docs]class CmdUnconnectedQuit(MuxCommand): + """ + We maintain a different version of the `quit` command + here for unconnected accounts for the sake of simplicity. The logged in + version is a bit more complicated. + """ + + key = "quit" + aliases = ["q", "qu"] + locks = "cmd:all()" + +
[docs] def func(self): + """Simply close the connection.""" + session = self.caller + session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
+ + +
[docs]class CmdUnconnectedLook(MuxCommand): + """ + This is an unconnected version of the `look` command for simplicity. + + This is called by the server and kicks everything in gear. + All it does is display the connect screen. + """ + + key = CMD_LOGINSTART + aliases = ["look", "l"] + locks = "cmd:all()" + +
[docs] def func(self): + """Show the connect screen.""" + self.caller.msg(CONNECTION_SCREEN)
+ + +
[docs]class CmdUnconnectedHelp(MuxCommand): + """ + This is an unconnected version of the help command, + for simplicity. It shows a pane of info. + """ + + key = "help" + aliases = ["h", "?"] + locks = "cmd:all()" + +
[docs] def func(self): + """Shows help""" + + string = """ +You are not yet logged into the game. Commands available at this point: + |wcreate, connect, look, help, quit|n + +To login to the system, you need to do one of the following: + +|w1)|n If you have no previous account, you need to use the 'create' + command like this: + + |wcreate "Anna the Barbarian" anna@myemail.com c67jHL8p|n + + It's always a good idea (not only here, but everywhere on the net) + to not use a regular word for your password. Make it longer than + 3 characters (ideally 6 or more) and mix numbers and capitalization + into it. + +|w2)|n If you have an account already, either because you just created + one in |w1)|n above or you are returning, use the 'connect' command: + + |wconnect anna@myemail.com c67jHL8p|n + + This should log you in. Run |whelp|n again once you're logged in + to get more aid. Hope you enjoy your stay! + +You can use the |wlook|n command if you want to see the connect screen again. +""" + self.caller.msg(string)
+ + +# command set for the mux-like login + + +class UnloggedinCmdSet(CmdSet): + """ + Sets up the unlogged cmdset. + """ + + key = "Unloggedin" + priority = 0 + + def at_cmdset_creation(self): + """Populate the cmdset""" + self.add(CmdUnconnectedConnect()) + self.add(CmdUnconnectedCreate()) + self.add(CmdUnconnectedQuit()) + self.add(CmdUnconnectedLook()) + self.add(CmdUnconnectedHelp()) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/email_login/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/email_login/tests.html new file mode 100644 index 0000000000..7810d01335 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/email_login/tests.html @@ -0,0 +1,145 @@ + + + + + + + + evennia.contrib.base_systems.email_login.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.email_login.tests

+"""
+Test email login.
+
+"""
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+
+from . import email_login
+
+
+
[docs]class TestEmailLogin(BaseEvenniaCommandTest): +
[docs] def test_connect(self): + self.call( + email_login.CmdUnconnectedConnect(), + "mytest@test.com test", + "The email 'mytest@test.com' does not match any accounts.", + inputs=["Y"], + ) + self.call( + email_login.CmdUnconnectedCreate(), + '"mytest" mytest@test.com test11111', + "A new account 'mytest' was created. Welcome!", + inputs=["Y"], + ) + self.call( + email_login.CmdUnconnectedConnect(), + "mytest@test.com test11111", + "", + caller=self.account.sessions.get()[0], + inputs=["Y"], + )
+ +
[docs] def test_quit(self): + self.call(email_login.CmdUnconnectedQuit(), "", "", caller=self.account.sessions.get()[0])
+ +
[docs] def test_unconnectedlook(self): + self.call(email_login.CmdUnconnectedLook(), "", "==========")
+ +
[docs] def test_unconnectedhelp(self): + self.call(email_login.CmdUnconnectedHelp(), "", "You are not yet logged into the game.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.html b/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.html new file mode 100644 index 0000000000..051bc46f6e --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.html @@ -0,0 +1,189 @@ + + + + + + + + evennia.contrib.base_systems.godotwebsocket.test_text2bbcode — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.godotwebsocket.test_text2bbcode

+"""Tests for text2bbcode """
+
+import mock
+from django.test import TestCase
+
+from evennia.contrib.base_systems.godotwebsocket import text2bbcode
+from evennia.utils import ansi
+
+
+
[docs]class TestText2Bbcode(TestCase): +
[docs] def test_format_styles(self): + parser = text2bbcode.BBCODE_PARSER + self.assertEqual("foo", parser.format_styles("foo")) + self.assertEqual( + "[color=#800000]red[/color]foo", + parser.format_styles( + ansi.ANSI_UNHILITE + ansi.ANSI_RED + "red" + ansi.ANSI_NORMAL + "foo" + ), + ) + self.assertEqual( + "[bgcolor=#800000]red[/bgcolor]foo", + parser.format_styles(ansi.ANSI_BACK_RED + "red" + ansi.ANSI_NORMAL + "foo"), + ) + self.assertEqual( + "[bgcolor=#800000][color=#008000]red[/color][/bgcolor]foo", + parser.format_styles( + ansi.ANSI_BACK_RED + + ansi.ANSI_UNHILITE + + ansi.ANSI_GREEN + + "red" + + ansi.ANSI_NORMAL + + "foo" + ), + ) + self.assertEqual( + "a [u]red[/u]foo", + parser.format_styles("a " + ansi.ANSI_UNDERLINE + "red" + ansi.ANSI_NORMAL + "foo"), + ) + self.assertEqual( + "a [blink]red[/blink]foo", + parser.format_styles("a " + ansi.ANSI_BLINK + "red" + ansi.ANSI_NORMAL + "foo"), + ) + self.assertEqual( + "a [bgcolor=#c0c0c0][color=#000000]red[/color][/bgcolor]foo", + parser.format_styles("a " + ansi.ANSI_INVERSE + "red" + ansi.ANSI_NORMAL + "foo"), + )
+ +
[docs] def test_convert_urls(self): + parser = text2bbcode.BBCODE_PARSER + self.assertEqual("foo", parser.convert_urls("foo")) + self.assertEqual( + "a [url=http://redfoo]http://redfoo[/url] runs", + parser.convert_urls("a http://redfoo runs"), + )
+ + + +
[docs] def test_sub_text(self): + parser = text2bbcode.BBCODE_PARSER + + mocked_match = mock.Mock() + + mocked_match.groupdict.return_value = {"lineend": "foo"} + self.assertEqual("\n", parser.sub_text(mocked_match))
+ +
[docs] def test_parse_bbcode(self): + self.assertEqual("foo", text2bbcode.parse_to_bbcode("foo")) + self.maxDiff = None + self.assertEqual( + text2bbcode.parse_to_bbcode("|^|[CHello|n|u|rW|go|yr|bl|md|c!|[G!"), + "[blink][bgcolor=#008080]Hello[/bgcolor][/blink]" + "[u][color=#ff0000]W[/color][/u]" + "[u][color=#00ff00]o[/color][/u]" + "[u][color=#ffff00]r[/color][/u]" + "[u][color=#0000ff]l[/color][/u]" + "[u][color=#ff00ff]d[/color][/u]" + "[u][color=#00ffff]![/color][/u]" + "[u][bgcolor=#008000][color=#00ffff]![/color][/bgcolor][/u]", + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/text2bbcode.html b/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/text2bbcode.html new file mode 100644 index 0000000000..6cd4a4643c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/text2bbcode.html @@ -0,0 +1,1067 @@ + + + + + + + + evennia.contrib.base_systems.godotwebsocket.text2bbcode — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.godotwebsocket.text2bbcode

+"""
+Godot Websocket - ChrisLR 2022
+
+This file contains the necessary code and data to convert text with color tags to bbcode (For godot)
+"""
+from evennia.utils.ansi import *
+from evennia.utils.text2html import TextToHTMLparser
+
+# All xterm256 RGB equivalents
+
+XTERM256_FG = "\033[38;5;{}m"
+XTERM256_BG = "\033[48;5;{}m"
+
+COLOR_INDICE_TO_HEX = {
+    "color-000": "#000000",
+    "color-001": "#800000",
+    "color-002": "#008000",
+    "color-003": "#808000",
+    "color-004": "#000080",
+    "color-005": "#800080",
+    "color-006": "#008080",
+    "color-007": "#c0c0c0",
+    "color-008": "#808080",
+    "color-009": "#ff0000",
+    "color-010": "#00ff00",
+    "color-011": "#ffff00",
+    "color-012": "#0000ff",
+    "color-013": "#ff00ff",
+    "color-014": "#00ffff",
+    "color-015": "#ffffff",
+    "color-016": "#000000",
+    "color-017": "#00005f",
+    "color-018": "#000087",
+    "color-019": "#0000af",
+    "color-020": "#0000df",
+    "color-021": "#0000ff",
+    "color-022": "#005f00",
+    "color-023": "#005f5f",
+    "color-024": "#005f87",
+    "color-025": "#005faf",
+    "color-026": "#005fdf",
+    "color-027": "#005fff",
+    "color-028": "#008700",
+    "color-029": "#00875f",
+    "color-030": "#008787",
+    "color-031": "#0087af",
+    "color-032": "#0087df",
+    "color-033": "#0087ff",
+    "color-034": "#00af00",
+    "color-035": "#00af5f",
+    "color-036": "#00af87",
+    "color-037": "#00afaf",
+    "color-038": "#00afdf",
+    "color-039": "#00afff",
+    "color-040": "#00df00",
+    "color-041": "#00df5f",
+    "color-042": "#00df87",
+    "color-043": "#00dfaf",
+    "color-044": "#00dfdf",
+    "color-045": "#00dfff",
+    "color-046": "#00ff00",
+    "color-047": "#00ff5f",
+    "color-048": "#00ff87",
+    "color-049": "#00ffaf",
+    "color-050": "#00ffdf",
+    "color-051": "#00ffff",
+    "color-052": "#5f0000",
+    "color-053": "#5f005f",
+    "color-054": "#5f0087",
+    "color-055": "#5f00af",
+    "color-056": "#5f00df",
+    "color-057": "#5f00ff",
+    "color-058": "#5f5f00",
+    "color-059": "#5f5f5f",
+    "color-060": "#5f5f87",
+    "color-061": "#5f5faf",
+    "color-062": "#5f5fdf",
+    "color-063": "#5f5fff",
+    "color-064": "#5f8700",
+    "color-065": "#5f875f",
+    "color-066": "#5f8787",
+    "color-067": "#5f87af",
+    "color-068": "#5f87df",
+    "color-069": "#5f87ff",
+    "color-070": "#5faf00",
+    "color-071": "#5faf5f",
+    "color-072": "#5faf87",
+    "color-073": "#5fafaf",
+    "color-074": "#5fafdf",
+    "color-075": "#5fafff",
+    "color-076": "#5fdf00",
+    "color-077": "#5fdf5f",
+    "color-078": "#5fdf87",
+    "color-079": "#5fdfaf",
+    "color-080": "#5fdfdf",
+    "color-081": "#5fdfff",
+    "color-082": "#5fff00",
+    "color-083": "#5fff5f",
+    "color-084": "#5fff87",
+    "color-085": "#5fffaf",
+    "color-086": "#5fffdf",
+    "color-087": "#5fffff",
+    "color-088": "#870000",
+    "color-089": "#87005f",
+    "color-090": "#870087",
+    "color-091": "#8700af",
+    "color-092": "#8700df",
+    "color-093": "#8700ff",
+    "color-094": "#875f00",
+    "color-095": "#875f5f",
+    "color-096": "#875f87",
+    "color-097": "#875faf",
+    "color-098": "#875fdf",
+    "color-099": "#875fff",
+    "color-100": "#878700",
+    "color-101": "#87875f",
+    "color-102": "#878787",
+    "color-103": "#8787af",
+    "color-104": "#8787df",
+    "color-105": "#8787ff",
+    "color-106": "#87af00",
+    "color-107": "#87af5f",
+    "color-108": "#87af87",
+    "color-109": "#87afaf",
+    "color-110": "#87afdf",
+    "color-111": "#87afff",
+    "color-112": "#87df00",
+    "color-113": "#87df5f",
+    "color-114": "#87df87",
+    "color-115": "#87dfaf",
+    "color-116": "#87dfdf",
+    "color-117": "#87dfff",
+    "color-118": "#87ff00",
+    "color-119": "#87ff5f",
+    "color-120": "#87ff87",
+    "color-121": "#87ffaf",
+    "color-122": "#87ffdf",
+    "color-123": "#87ffff",
+    "color-124": "#af0000",
+    "color-125": "#af005f",
+    "color-126": "#af0087",
+    "color-127": "#af00af",
+    "color-128": "#af00df",
+    "color-129": "#af00ff",
+    "color-130": "#af5f00",
+    "color-131": "#af5f5f",
+    "color-132": "#af5f87",
+    "color-133": "#af5faf",
+    "color-134": "#af5fdf",
+    "color-135": "#af5fff",
+    "color-136": "#af8700",
+    "color-137": "#af875f",
+    "color-138": "#af8787",
+    "color-139": "#af87af",
+    "color-140": "#af87df",
+    "color-141": "#af87ff",
+    "color-142": "#afaf00",
+    "color-143": "#afaf5f",
+    "color-144": "#afaf87",
+    "color-145": "#afafaf",
+    "color-146": "#afafdf",
+    "color-147": "#afafff",
+    "color-148": "#afdf00",
+    "color-149": "#afdf5f",
+    "color-150": "#afdf87",
+    "color-151": "#afdfaf",
+    "color-152": "#afdfdf",
+    "color-153": "#afdfff",
+    "color-154": "#afff00",
+    "color-155": "#afff5f",
+    "color-156": "#afff87",
+    "color-157": "#afffaf",
+    "color-158": "#afffdf",
+    "color-159": "#afffff",
+    "color-160": "#df0000",
+    "color-161": "#df005f",
+    "color-162": "#df0087",
+    "color-163": "#df00af",
+    "color-164": "#df00df",
+    "color-165": "#df00ff",
+    "color-166": "#df5f00",
+    "color-167": "#df5f5f",
+    "color-168": "#df5f87",
+    "color-169": "#df5faf",
+    "color-170": "#df5fdf",
+    "color-171": "#df5fff",
+    "color-172": "#df8700",
+    "color-173": "#df875f",
+    "color-174": "#df8787",
+    "color-175": "#df87af",
+    "color-176": "#df87df",
+    "color-177": "#df87ff",
+    "color-178": "#dfaf00",
+    "color-179": "#dfaf5f",
+    "color-180": "#dfaf87",
+    "color-181": "#dfafaf",
+    "color-182": "#dfafdf",
+    "color-183": "#dfafff",
+    "color-184": "#dfdf00",
+    "color-185": "#dfdf5f",
+    "color-186": "#dfdf87",
+    "color-187": "#dfdfaf",
+    "color-188": "#dfdfdf",
+    "color-189": "#dfdfff",
+    "color-190": "#dfff00",
+    "color-191": "#dfff5f",
+    "color-192": "#dfff87",
+    "color-193": "#dfffaf",
+    "color-194": "#dfffdf",
+    "color-195": "#dfffff",
+    "color-196": "#ff0000",
+    "color-197": "#ff005f",
+    "color-198": "#ff0087",
+    "color-199": "#ff00af",
+    "color-200": "#ff00df",
+    "color-201": "#ff00ff",
+    "color-202": "#ff5f00",
+    "color-203": "#ff5f5f",
+    "color-204": "#ff5f87",
+    "color-205": "#ff5faf",
+    "color-206": "#ff5fdf",
+    "color-207": "#ff5fff",
+    "color-208": "#ff8700",
+    "color-209": "#ff875f",
+    "color-210": "#ff8787",
+    "color-211": "#ff87af",
+    "color-212": "#ff87df",
+    "color-213": "#ff87ff",
+    "color-214": "#ffaf00",
+    "color-215": "#ffaf5f",
+    "color-216": "#ffaf87",
+    "color-217": "#ffafaf",
+    "color-218": "#ffafdf",
+    "color-219": "#ffafff",
+    "color-220": "#ffdf00",
+    "color-221": "#ffdf5f",
+    "color-222": "#ffdf87",
+    "color-223": "#ffdfaf",
+    "color-224": "#ffdfdf",
+    "color-225": "#ffdfff",
+    "color-226": "#ffff00",
+    "color-227": "#ffff5f",
+    "color-228": "#ffff87",
+    "color-229": "#ffffaf",
+    "color-230": "#ffffdf",
+    "color-231": "#ffffff",
+    "color-232": "#080808",
+    "color-233": "#121212",
+    "color-234": "#1c1c1c",
+    "color-235": "#262626",
+    "color-236": "#303030",
+    "color-237": "#3a3a3a",
+    "color-238": "#444444",
+    "color-239": "#4e4e4e",
+    "color-240": "#585858",
+    "color-241": "#606060",
+    "color-242": "#666666",
+    "color-243": "#767676",
+    "color-244": "#808080",
+    "color-245": "#8a8a8a",
+    "color-246": "#949494",
+    "color-247": "#9e9e9e",
+    "color-248": "#a8a8a8",
+    "color-249": "#b2b2b2",
+    "color-250": "#bcbcbc",
+    "color-251": "#c6c6c6",
+    "color-252": "#d0d0d0",
+    "color-253": "#dadada",
+    "color-254": "#e4e4e4",
+    "color-255": "#eeeeee",
+    "bgcolor-000": "#000000",
+    "bgcolor-001": "#800000",
+    "bgcolor-002": "#008000",
+    "bgcolor-003": "#808000",
+    "bgcolor-004": "#000080",
+    "bgcolor-005": "#800080",
+    "bgcolor-006": "#008080",
+    "bgcolor-007": "#c0c0c0",
+    "bgcolor-008": "#808080",
+    "bgcolor-009": "#ff0000",
+    "bgcolor-010": "#00ff00",
+    "bgcolor-011": "#ffff00",
+    "bgcolor-012": "#0000ff",
+    "bgcolor-013": "#ff00ff",
+    "bgcolor-014": "#00ffff",
+    "bgcolor-015": "#ffffff",
+    "bgcolor-016": "#000000",
+    "bgcolor-017": "#00005f",
+    "bgcolor-018": "#000087",
+    "bgcolor-019": "#0000af",
+    "bgcolor-020": "#0000df",
+    "bgcolor-021": "#0000ff",
+    "bgcolor-022": "#005f00",
+    "bgcolor-023": "#005f5f",
+    "bgcolor-024": "#005f87",
+    "bgcolor-025": "#005faf",
+    "bgcolor-026": "#005fdf",
+    "bgcolor-027": "#005fff",
+    "bgcolor-028": "#008700",
+    "bgcolor-029": "#00875f",
+    "bgcolor-030": "#008787",
+    "bgcolor-031": "#0087af",
+    "bgcolor-032": "#0087df",
+    "bgcolor-033": "#0087ff",
+    "bgcolor-034": "#00af00",
+    "bgcolor-035": "#00af5f",
+    "bgcolor-036": "#00af87",
+    "bgcolor-037": "#00afaf",
+    "bgcolor-038": "#00afdf",
+    "bgcolor-039": "#00afff",
+    "bgcolor-040": "#00df00",
+    "bgcolor-041": "#00df5f",
+    "bgcolor-042": "#00df87",
+    "bgcolor-043": "#00dfaf",
+    "bgcolor-044": "#00dfdf",
+    "bgcolor-045": "#00dfff",
+    "bgcolor-046": "#00ff00",
+    "bgcolor-047": "#00ff5f",
+    "bgcolor-048": "#00ff87",
+    "bgcolor-049": "#00ffaf",
+    "bgcolor-050": "#00ffdf",
+    "bgcolor-051": "#00ffff",
+    "bgcolor-052": "#5f0000",
+    "bgcolor-053": "#5f005f",
+    "bgcolor-054": "#5f0087",
+    "bgcolor-055": "#5f00af",
+    "bgcolor-056": "#5f00df",
+    "bgcolor-057": "#5f00ff",
+    "bgcolor-058": "#5f5f00",
+    "bgcolor-059": "#5f5f5f",
+    "bgcolor-060": "#5f5f87",
+    "bgcolor-061": "#5f5faf",
+    "bgcolor-062": "#5f5fdf",
+    "bgcolor-063": "#5f5fff",
+    "bgcolor-064": "#5f8700",
+    "bgcolor-065": "#5f875f",
+    "bgcolor-066": "#5f8787",
+    "bgcolor-067": "#5f87af",
+    "bgcolor-068": "#5f87df",
+    "bgcolor-069": "#5f87ff",
+    "bgcolor-070": "#5faf00",
+    "bgcolor-071": "#5faf5f",
+    "bgcolor-072": "#5faf87",
+    "bgcolor-073": "#5fafaf",
+    "bgcolor-074": "#5fafdf",
+    "bgcolor-075": "#5fafff",
+    "bgcolor-076": "#5fdf00",
+    "bgcolor-077": "#5fdf5f",
+    "bgcolor-078": "#5fdf87",
+    "bgcolor-079": "#5fdfaf",
+    "bgcolor-080": "#5fdfdf",
+    "bgcolor-081": "#5fdfff",
+    "bgcolor-082": "#5fff00",
+    "bgcolor-083": "#5fff5f",
+    "bgcolor-084": "#5fff87",
+    "bgcolor-085": "#5fffaf",
+    "bgcolor-086": "#5fffdf",
+    "bgcolor-087": "#5fffff",
+    "bgcolor-088": "#870000",
+    "bgcolor-089": "#87005f",
+    "bgcolor-090": "#870087",
+    "bgcolor-091": "#8700af",
+    "bgcolor-092": "#8700df",
+    "bgcolor-093": "#8700ff",
+    "bgcolor-094": "#875f00",
+    "bgcolor-095": "#875f5f",
+    "bgcolor-096": "#875f87",
+    "bgcolor-097": "#875faf",
+    "bgcolor-098": "#875fdf",
+    "bgcolor-099": "#875fff",
+    "bgcolor-100": "#878700",
+    "bgcolor-101": "#87875f",
+    "bgcolor-102": "#878787",
+    "bgcolor-103": "#8787af",
+    "bgcolor-104": "#8787df",
+    "bgcolor-105": "#8787ff",
+    "bgcolor-106": "#87af00",
+    "bgcolor-107": "#87af5f",
+    "bgcolor-108": "#87af87",
+    "bgcolor-109": "#87afaf",
+    "bgcolor-110": "#87afdf",
+    "bgcolor-111": "#87afff",
+    "bgcolor-112": "#87df00",
+    "bgcolor-113": "#87df5f",
+    "bgcolor-114": "#87df87",
+    "bgcolor-115": "#87dfaf",
+    "bgcolor-116": "#87dfdf",
+    "bgcolor-117": "#87dfff",
+    "bgcolor-118": "#87ff00",
+    "bgcolor-119": "#87ff5f",
+    "bgcolor-120": "#87ff87",
+    "bgcolor-121": "#87ffaf",
+    "bgcolor-122": "#87ffdf",
+    "bgcolor-123": "#87ffff",
+    "bgcolor-124": "#af0000",
+    "bgcolor-125": "#af005f",
+    "bgcolor-126": "#af0087",
+    "bgcolor-127": "#af00af",
+    "bgcolor-128": "#af00df",
+    "bgcolor-129": "#af00ff",
+    "bgcolor-130": "#af5f00",
+    "bgcolor-131": "#af5f5f",
+    "bgcolor-132": "#af5f87",
+    "bgcolor-133": "#af5faf",
+    "bgcolor-134": "#af5fdf",
+    "bgcolor-135": "#af5fff",
+    "bgcolor-136": "#af8700",
+    "bgcolor-137": "#af875f",
+    "bgcolor-138": "#af8787",
+    "bgcolor-139": "#af87af",
+    "bgcolor-140": "#af87df",
+    "bgcolor-141": "#af87ff",
+    "bgcolor-142": "#afaf00",
+    "bgcolor-143": "#afaf5f",
+    "bgcolor-144": "#afaf87",
+    "bgcolor-145": "#afafaf",
+    "bgcolor-146": "#afafdf",
+    "bgcolor-147": "#afafff",
+    "bgcolor-148": "#afdf00",
+    "bgcolor-149": "#afdf5f",
+    "bgcolor-150": "#afdf87",
+    "bgcolor-151": "#afdfaf",
+    "bgcolor-152": "#afdfdf",
+    "bgcolor-153": "#afdfff",
+    "bgcolor-154": "#afff00",
+    "bgcolor-155": "#afff5f",
+    "bgcolor-156": "#afff87",
+    "bgcolor-157": "#afffaf",
+    "bgcolor-158": "#afffdf",
+    "bgcolor-159": "#afffff",
+    "bgcolor-160": "#df0000",
+    "bgcolor-161": "#df005f",
+    "bgcolor-162": "#df0087",
+    "bgcolor-163": "#df00af",
+    "bgcolor-164": "#df00df",
+    "bgcolor-165": "#df00ff",
+    "bgcolor-166": "#df5f00",
+    "bgcolor-167": "#df5f5f",
+    "bgcolor-168": "#df5f87",
+    "bgcolor-169": "#df5faf",
+    "bgcolor-170": "#df5fdf",
+    "bgcolor-171": "#df5fff",
+    "bgcolor-172": "#df8700",
+    "bgcolor-173": "#df875f",
+    "bgcolor-174": "#df8787",
+    "bgcolor-175": "#df87af",
+    "bgcolor-176": "#df87df",
+    "bgcolor-177": "#df87ff",
+    "bgcolor-178": "#dfaf00",
+    "bgcolor-179": "#dfaf5f",
+    "bgcolor-180": "#dfaf87",
+    "bgcolor-181": "#dfafaf",
+    "bgcolor-182": "#dfafdf",
+    "bgcolor-183": "#dfafff",
+    "bgcolor-184": "#dfdf00",
+    "bgcolor-185": "#dfdf5f",
+    "bgcolor-186": "#dfdf87",
+    "bgcolor-187": "#dfdfaf",
+    "bgcolor-188": "#dfdfdf",
+    "bgcolor-189": "#dfdfff",
+    "bgcolor-190": "#dfff00",
+    "bgcolor-191": "#dfff5f",
+    "bgcolor-192": "#dfff87",
+    "bgcolor-193": "#dfffaf",
+    "bgcolor-194": "#dfffdf",
+    "bgcolor-195": "#dfffff",
+    "bgcolor-196": "#ff0000",
+    "bgcolor-197": "#ff005f",
+    "bgcolor-198": "#ff0087",
+    "bgcolor-199": "#ff00af",
+    "bgcolor-200": "#ff00df",
+    "bgcolor-201": "#ff00ff",
+    "bgcolor-202": "#ff5f00",
+    "bgcolor-203": "#ff5f5f",
+    "bgcolor-204": "#ff5f87",
+    "bgcolor-205": "#ff5faf",
+    "bgcolor-206": "#ff5fdf",
+    "bgcolor-207": "#ff5fff",
+    "bgcolor-208": "#ff8700",
+    "bgcolor-209": "#ff875f",
+    "bgcolor-210": "#ff8787",
+    "bgcolor-211": "#ff87af",
+    "bgcolor-212": "#ff87df",
+    "bgcolor-213": "#ff87ff",
+    "bgcolor-214": "#ffaf00",
+    "bgcolor-215": "#ffaf5f",
+    "bgcolor-216": "#ffaf87",
+    "bgcolor-217": "#ffafaf",
+    "bgcolor-218": "#ffafdf",
+    "bgcolor-219": "#ffafff",
+    "bgcolor-220": "#ffdf00",
+    "bgcolor-221": "#ffdf5f",
+    "bgcolor-222": "#ffdf87",
+    "bgcolor-223": "#ffdfaf",
+    "bgcolor-224": "#ffdfdf",
+    "bgcolor-225": "#ffdfff",
+    "bgcolor-226": "#ffff00",
+    "bgcolor-227": "#ffff5f",
+    "bgcolor-228": "#ffff87",
+    "bgcolor-229": "#ffffaf",
+    "bgcolor-230": "#ffffdf",
+    "bgcolor-231": "#ffffff",
+    "bgcolor-232": "#080808",
+    "bgcolor-233": "#121212",
+    "bgcolor-234": "#1c1c1c",
+    "bgcolor-235": "#262626",
+    "bgcolor-236": "#303030",
+    "bgcolor-237": "#3a3a3a",
+    "bgcolor-238": "#444444",
+    "bgcolor-239": "#4e4e4e",
+    "bgcolor-240": "#585858",
+    "bgcolor-241": "#606060",
+    "bgcolor-242": "#666666",
+    "bgcolor-243": "#767676",
+    "bgcolor-244": "#808080",
+    "bgcolor-245": "#8a8a8a",
+    "bgcolor-246": "#949494",
+    "bgcolor-247": "#9e9e9e",
+    "bgcolor-248": "#a8a8a8",
+    "bgcolor-249": "#b2b2b2",
+    "bgcolor-250": "#bcbcbc",
+    "bgcolor-251": "#c6c6c6",
+    "bgcolor-252": "#d0d0d0",
+    "bgcolor-253": "#dadada",
+    "bgcolor-254": "#e4e4e4",
+    "bgcolor-255": "#eeeeee",
+}
+
+
+"""
+The classes below exist to properly encapsulate text and other tag classes
+because the order of how tags are opened and closed are important to display in godot.
+"""
+
+
+
[docs]class RootTag: + """ + The Root tag class made to contain other tags. + """ + + __slots__ = ("child",) + +
[docs] def __init__(self): + self.child = None
+ + def __str__(self): + return str(self.child) if self.child else ""
+ + +
[docs]class ChildTag: + """ + A node made to be contained. + """ + +
[docs] def __init__(self, parent): + self.parent = parent + if parent: + parent.child = self
+ +
[docs] def set_parent(self, parent): + self.parent = parent + if parent: + parent.child = self
+ + +
[docs]class TextTag(ChildTag): + """ + A BBCodeTag node to output regular text. + Output: SomeText + """ + + __slots__ = ("parent", "child", "text") + +
[docs] def __init__(self, parent, text): + super().__init__(parent) + self.text = text + self.child = None
+ + def __str__(self): + return f"{self.text}{self.child or ''}"
+ + +
[docs]class BBCodeTag(ChildTag): + """ + Base BBCodeTag node to encapsulate and be encapsulated. + """ + + __slots__ = ( + "parent", + "child", + ) + + code = "" + +
[docs] def __init__(self, parent): + super().__init__(parent) + self.child = None
+ + def __str__(self): + return f"[{self.code}]{self.child or ''}[/{self.code}]"
+ + +
[docs]class UnderlineTag(BBCodeTag): + """ + A BBCodeTag node for underlined text. + Output: [u]Underlined Text[/u] + """ + + code = "u"
+ + +
[docs]class BlinkTag(BBCodeTag): + """ + A BBCodeTag node for blinking text. + Output: [blink]Blinking Text[/blink] + """ + + code = "blink"
+ + +
[docs]class ColorTag(BBCodeTag): + """ + A BBCodeTag node for foreground color. + Output: [fgcolor=#000000]Colorized Text[/fgcolor] + """ + + __slots__ = ( + "parent", + "child", + "color_hex", + ) + + code = "color" + +
[docs] def __init__(self, parent, color_hex): + super().__init__(parent) + self.color_hex = color_hex
+ + def __str__(self): + return f"[{self.code}={self.color_hex}]{self.child or ''}[/{self.code}]"
+ + +
[docs]class BGColorTag(ColorTag): + """ + A BBCodeTag node for background color. + Output: [bgcolor=#000000]Colorized Text[/bgcolor] + """ + + code = "bgcolor"
+ + +
[docs]class UrlTag(BBCodeTag): + """ + A BBCodeTag node used for urls. + Output: [url=www.example.com]Child Text[/url] + + """ + + __slots__ = ( + "parent", + "child", + "url_data", + ) + + code = "url" + +
[docs] def __init__(self, parent, url_data=""): + super().__init__(parent) + self.url_data = url_data
+ + def __str__(self): + return f"[{self.code}={self.url_data}]{self.child or ''}[/{self.code}]"
+ + +
[docs]class TextToBBCODEparser(TextToHTMLparser): + """ + This class describes a parser for converting from ANSI to BBCode. + It inherits from the TextToHTMLParser and overrides the specifics for bbcode. + """ + +
[docs] def convert_urls(self, text): + """ + Converts urls within text to bbcode style + + Args: + text (str): Text to parse + + Returns: + text (str): Processed text + """ + # Converts to bbcode styled urls + return self.re_url.sub(r"[url=\1]\1[/url]\2", text)
+ + + +
[docs] def sub_mxp_urls(self, match): + """ + Helper method to be passed to re.sub, + replaces MXP links with bbcode. + Args: + match (re.Matchobject): Match for substitution. + Returns: + text (str): Processed text. + """ + + url, text = [grp.replace('"', "\\&quot;") for grp in match.groups()] + val = f"[url={url}]{text}[/url]" + + return val
+ +
[docs] def sub_text(self, match): + """ + Helper method to be passed to re.sub, + for handling all substitutions. + + Args: + match (re.Matchobject): Match for substitution. + + Returns: + text (str): Processed text. + + """ + cdict = match.groupdict() + if cdict["lineend"]: + return "\n" + + return None
+ +
[docs] def format_styles(self, text): + """ + Takes a string with parsed ANSI codes and replaces them with bbcode style tags + + Args: + text (str): The string to process. + + Returns: + text (str): Processed text. + """ + + # split out the ANSI codes and clean out any empty items + str_list = [substr for substr in self.re_style.split(text) if substr] + + inverse = False + # default color is light grey - unhilite + white + hilight = ANSI_UNHILITE + fg = ANSI_WHITE + # default bg is black + bg = ANSI_BACK_BLACK + previous_fg = None + previous_bg = None + blink = False + underline = False + new_style = False + + parts = [] + root_tag = RootTag() + current_tag = root_tag + + for i, substr in enumerate(str_list): + # reset all current styling + if substr == ANSI_NORMAL: + # close any existing span if necessary + parts.append(str(root_tag)) + root_tag = RootTag() + current_tag = root_tag + # reset to defaults + inverse = False + hilight = ANSI_UNHILITE + fg = ANSI_WHITE + bg = ANSI_BACK_BLACK + previous_fg = None + previous_bg = None + blink = False + underline = False + new_style = False + + # change color + elif substr in self.ansi_color_codes + self.xterm_fg_codes: + # set new color + fg = substr + new_style = True + + # change bg color + elif substr in self.ansi_bg_codes + self.xterm_bg_codes: + # set new bg + bg = substr + new_style = True + + # non-color codes + elif substr in self.style_codes: + new_style = True + + # hilight codes + if substr in (ANSI_HILITE, ANSI_UNHILITE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE): + # set new hilight status + hilight = ANSI_UNHILITE if substr == ANSI_UNHILITE else ANSI_HILITE + + # inversion codes + if substr in (ANSI_INVERSE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE): + inverse = True + + # blink codes + if substr in (ANSI_BLINK, ANSI_BLINK_HILITE, ANSI_INV_BLINK_HILITE) and not blink: + blink = True + current_tag = BlinkTag(current_tag) + + # underline + if substr == ANSI_UNDERLINE and not underline: + underline = True + current_tag = UnderlineTag(current_tag) + + else: + close_tags = False + color_tag = None + bgcolor_tag = None + # normal text, add text back to list + if new_style: + # prior entry was cleared, which means style change + # get indices for the fg and bg codes + bg_index = self.bglist.index(bg) + try: + color_index = self.colorlist.index(hilight + fg) + except ValueError: + # xterm256 colors don't have the hilight codes + color_index = self.colorlist.index(fg) + + if inverse: + # inverse means swap fg and bg indices + bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) + color_class = "color-{}".format(str(bg_index).rjust(3, "0")) + else: + # use fg and bg indices for classes + bg_class = "bgcolor-{}".format(str(bg_index).rjust(3, "0")) + color_class = "color-{}".format(str(color_index).rjust(3, "0")) + + # black bg is the default, don't explicitly style + if bg_class != "bgcolor-000": + color_hex = COLOR_INDICE_TO_HEX.get(bg_class) + bgcolor_tag = BGColorTag(None, color_hex=color_hex) + if previous_bg and previous_bg != color_hex: + close_tags = True + else: + previous_bg = color_hex + + # light grey text is the default, don't explicitly style + if color_class != "color-007": + color_hex = COLOR_INDICE_TO_HEX.get(color_class) + color_tag = ColorTag(None, color_hex=color_hex) + if previous_fg and previous_fg != color_hex: + close_tags = True + else: + previous_fg = color_hex + + new_tag = TextTag(None, substr) + if close_tags: + # Because the order is important, we need to close the tags and reopen those who shouldn't reset. + new_style = False + parts.append(str(root_tag)) + root_tag = RootTag() + current_tag = root_tag + if blink: + current_tag = BlinkTag(current_tag) + + if underline: + current_tag = UnderlineTag(current_tag) + + if bgcolor_tag: + bgcolor_tag.set_parent(current_tag) + current_tag = bgcolor_tag + + if color_tag: + color_tag.set_parent(current_tag) + current_tag = color_tag + + new_tag.set_parent(current_tag) + current_tag = new_tag + else: + if bgcolor_tag: + bgcolor_tag.set_parent(current_tag) + current_tag = bgcolor_tag + + if color_tag: + color_tag.set_parent(current_tag) + current_tag = color_tag + + new_tag.set_parent(current_tag) + current_tag = new_tag + + any_text = self._get_text_tag(root_tag) + if any_text: + # Only append tags if text was added. + last_part = str(root_tag) + parts.append(last_part) + + # recombine back into string + return "".join(parts)
+ + def _get_text_tag(self, root): + child = root.child + while child: + if isinstance(child, TextTag): + return child + else: + child = child.child + + return None + +
[docs] def parse(self, text, strip_ansi=False): + """ + Main access function, converts a text containing ANSI codes + into html statements. + + Args: + text (str): Text to process. + strip_ansi (bool, optional): + + Returns: + text (str): Parsed text. + + """ + # parse everything to ansi first + text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True) + # convert all ansi to html + result = re.sub(self.re_string, self.sub_text, text) + result = re.sub(self.re_mxplink, self.sub_mxp_links, result) + result = re.sub(self.re_mxpurl, self.sub_mxp_urls, result) + result = self.remove_bells(result) + result = self.format_styles(result) + result = self.remove_backspaces(result) + result = self.convert_urls(result) + + return result
+ + +BBCODE_PARSER = TextToBBCODEparser() + + +# +# Access function +# + + +
[docs]def parse_to_bbcode(string, strip_ansi=False, parser=BBCODE_PARSER): + """ + Parses a string, replace ANSI markup with bbcode + """ + return parser.parse(string, strip_ansi=strip_ansi)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/webclient.html b/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/webclient.html new file mode 100644 index 0000000000..424b78d9cf --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/godotwebsocket/webclient.html @@ -0,0 +1,186 @@ + + + + + + + + evennia.contrib.base_systems.godotwebsocket.webclient — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.godotwebsocket.webclient

+"""
+Godot Websocket - ChrisLR 2022
+
+This file contains the code necessary to dedicate a port to communicate with Godot via Websockets.
+It uses the plugin system and should be plugged via settings as detailed in the readme.
+"""
+import json
+
+from autobahn.twisted import WebSocketServerFactory
+from twisted.application import internet
+
+from evennia import settings
+from evennia.contrib.base_systems.godotwebsocket.text2bbcode import parse_to_bbcode
+from evennia.server.portal import webclient
+from evennia.settings_default import LOCKDOWN_MODE
+
+
+
[docs]class GodotWebSocketClient(webclient.WebSocketClient): + """ + Implements the server-side of the Websocket connection specific to Godot. + It inherits from the basic Websocket implementation and changes only what is necessary. + + """ + +
[docs] def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.protocol_key = "godotclient/websocket"
+ +
[docs] def send_text(self, *args, **kwargs): + """ + Send text data. This will pre-process the text for + color-replacement, conversion to bbcode etc. + + Args: + text (str): Text to send. + + Keyword Args: + options (dict): Options-dict with the following keys understood: + - nocolor (bool): Clean out all color. + - send_prompt (bool): Send a prompt with parsed bbcode + + """ + if args: + args = list(args) + text = args[0] + if text is None: + return + else: + return + + flags = self.protocol_flags + + options = kwargs.pop("options", {}) + nocolor = options.get("nocolor", flags.get("NOCOLOR", False)) + prompt = options.get("send_prompt", False) + + cmd = "prompt" if prompt else "text" + args[0] = parse_to_bbcode(text, strip_ansi=nocolor) + + # send to client on required form [cmdname, args, kwargs] + self.sendLine(json.dumps([cmd, args, kwargs]))
+ + +
[docs]def start_plugin_services(portal): + class GodotWebsocket(WebSocketServerFactory): + "Only here for better naming in logs" + pass + + factory = GodotWebsocket() + factory.noisy = False + factory.protocol = GodotWebSocketClient + from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS + + factory.sessionhandler = PORTAL_SESSIONS + + interface = "127.0.0.1" if LOCKDOWN_MODE else settings.GODOT_CLIENT_WEBSOCKET_CLIENT_INTERFACE + + port = settings.GODOT_CLIENT_WEBSOCKET_PORT + websocket_service = internet.TCPServer(port, factory, interface=interface) + websocket_service.setName("GodotWebSocket%s:%s" % (interface, port)) + portal.services.addService(websocket_service)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/callbackhandler.html b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/callbackhandler.html new file mode 100644 index 0000000000..55e0a235ec --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/callbackhandler.html @@ -0,0 +1,330 @@ + + + + + + + + evennia.contrib.base_systems.ingame_python.callbackhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.ingame_python.callbackhandler

+"""
+Module containing the CallbackHandler for individual objects.
+"""
+
+from collections import namedtuple
+
+
+
[docs]class CallbackHandler(object): + + """ + The callback handler for a specific object. + + The script that contains all callbacks will be reached through this + handler. This handler is therefore a shortcut to be used by + developers. This handler (accessible through `obj.callbacks`) is a + shortcut to manipulating callbacks within this object, getting, + adding, editing, deleting and calling them. + + """ + + script = None + +
[docs] def __init__(self, obj): + self.obj = obj
+ +
[docs] def all(self): + """ + Return all callbacks linked to this object. + + Returns: + All callbacks in a dictionary callback_name: callback}. The callback + is returned as a namedtuple to simplify manipulation. + + """ + callbacks = {} + handler = type(self).script + if handler: + dicts = handler.get_callbacks(self.obj) + for callback_name, in_list in dicts.items(): + new_list = [] + for callback in in_list: + callback = self.format_callback(callback) + new_list.append(callback) + + if new_list: + callbacks[callback_name] = new_list + + return callbacks
+ +
[docs] def get(self, callback_name): + """ + Return the callbacks associated with this name. + + Args: + callback_name (str): the name of the callback. + + Returns: + A list of callbacks associated with this object and of this name. + + Note: + This method returns a list of callback objects (namedtuple + representations). If the callback name cannot be found in the + object's callbacks, return an empty list. + + """ + return self.all().get(callback_name, [])
+ +
[docs] def get_variable(self, variable_name): + """ + Return the variable value or None. + + Args: + variable_name (str): the name of the variable. + + Returns: + Either the variable's value or None. + + """ + handler = type(self).script + if handler: + return handler.get_variable(variable_name) + + return None
+ +
[docs] def add(self, callback_name, code, author=None, valid=False, parameters=""): + """ + Add a new callback for this object. + + Args: + callback_name (str): the name of the callback to add. + code (str): the Python code associated with this callback. + author (Character or Account, optional): the author of the callback. + valid (bool, optional): should the callback be connected? + parameters (str, optional): optional parameters. + + Returns: + The callback definition that was added or None. + + """ + handler = type(self).script + if handler: + return self.format_callback( + handler.add_callback( + self.obj, callback_name, code, author=author, valid=valid, parameters=parameters + ) + )
+ +
[docs] def edit(self, callback_name, number, code, author=None, valid=False): + """ + Edit an existing callback bound to this object. + + Args: + callback_name (str): the name of the callback to edit. + number (int): the callback number to be changed. + code (str): the Python code associated with this callback. + author (Character or Account, optional): the author of the callback. + valid (bool, optional): should the callback be connected? + + Returns: + The callback definition that was edited or None. + + Raises: + RuntimeError if the callback is locked. + + """ + handler = type(self).script + if handler: + return self.format_callback( + handler.edit_callback( + self.obj, callback_name, number, code, author=author, valid=valid + ) + )
+ +
[docs] def remove(self, callback_name, number): + """ + Delete the specified callback bound to this object. + + Args: + callback_name (str): the name of the callback to delete. + number (int): the number of the callback to delete. + + Raises: + RuntimeError if the callback is locked. + + """ + handler = type(self).script + if handler: + handler.del_callback(self.obj, callback_name, number)
+ +
[docs] def call(self, callback_name, *args, **kwargs): + """ + Call the specified callback(s) bound to this object. + + Args: + callback_name (str): the callback name to call. + *args: additional variables for this callback. + + Keyword Args: + number (int, optional): call just a specific callback. + parameters (str, optional): call a callback with parameters. + locals (dict, optional): a locals replacement. + + Returns: + True to report the callback was called without interruption, + False otherwise. If the callbackHandler isn't found, return + None. + + """ + handler = type(self).script + if handler: + return handler.call(self.obj, callback_name, *args, **kwargs) + + return None
+ +
[docs] @staticmethod + def format_callback(callback): + """ + Return the callback namedtuple to represent the specified callback. + + Args: + callback (dict): the callback definition. + + The callback given in argument should be a dictionary containing + the expected fields for a callback (code, author, valid...). + + """ + if "obj" not in callback: + callback["obj"] = None + if "name" not in callback: + callback["name"] = "unknown" + if "number" not in callback: + callback["number"] = -1 + if "code" not in callback: + callback["code"] = "" + if "author" not in callback: + callback["author"] = None + if "valid" not in callback: + callback["valid"] = False + if "parameters" not in callback: + callback["parameters"] = "" + if "created_on" not in callback: + callback["created_on"] = None + if "updated_by" not in callback: + callback["updated_by"] = None + if "updated_on" not in callback: + callback["updated_on"] = None + + return Callback(**callback)
+ + +Callback = namedtuple( + "Callback", + ( + "obj", + "name", + "number", + "code", + "author", + "valid", + "parameters", + "created_on", + "updated_by", + "updated_on", + ), +) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/commands.html b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/commands.html new file mode 100644 index 0000000000..bd67a7b534 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/commands.html @@ -0,0 +1,688 @@ + + + + + + + + evennia.contrib.base_systems.ingame_python.commands — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.ingame_python.commands

+"""
+Module containing the commands of the in-game Python system.
+"""
+
+from datetime import datetime
+
+from django.conf import settings
+
+from evennia.contrib.base_systems.ingame_python.utils import get_event_handler
+from evennia.utils.ansi import raw
+from evennia.utils.eveditor import EvEditor
+from evennia.utils.evtable import EvTable
+from evennia.utils.utils import class_from_module, time_format
+
+COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+# Permissions
+WITH_VALIDATION = getattr(settings, "callbackS_WITH_VALIDATION", None)
+WITHOUT_VALIDATION = getattr(settings, "callbackS_WITHOUT_VALIDATION", "developer")
+VALIDATING = getattr(settings, "callbackS_VALIDATING", "developer")
+
+# Split help text
+BASIC_HELP = "Add, edit or delete callbacks."
+
+BASIC_USAGES = [
+    "@call <object name> [= <callback name>]",
+    "@call/add <object name> = <callback name> [parameters]",
+    "@call/edit <object name> = <callback name> [callback number]",
+    "@call/del <object name> = <callback name> [callback number]",
+    "@call/tasks [object name [= <callback name>]]",
+]
+
+BASIC_SWITCHES = [
+    "add    - add and edit a new callback",
+    "edit   - edit an existing callback",
+    "del    - delete an existing callback",
+    "tasks  - show the list of differed tasks",
+]
+
+VALIDATOR_USAGES = ["@call/accept [object name = <callback name> [callback number]]"]
+
+VALIDATOR_SWITCHES = ["accept - show callbacks to be validated or accept one"]
+
+BASIC_TEXT = """
+This command is used to manipulate callbacks.  A callback can be linked to
+an object, to fire at a specific moment.  You can use the command without
+switches to see what callbacks are active on an object:
+  @call self
+You can also specify a callback name if you want the list of callbacks
+associated with this object of this name:
+  @call north = can_traverse
+You can also add a number after the callback name to see details on one callback:
+  @call here = say 2
+You can also add, edit or remove callbacks using the add, edit or del switches.
+Additionally, you can see the list of differed tasks created by callbacks
+(chained events to be called) using the /tasks switch.
+"""
+
+VALIDATOR_TEXT = """
+You can also use this command to validate callbacks.  Depending on your game
+setting, some users might be allowed to add new callbacks, but these callbacks
+will not be fired until you accept them.  To see the callbacks needing
+validation, enter the /accept switch without argument:
+  @call/accept
+A table will show you the callbacks that are not validated yet, who created
+them and when.  You can then accept a specific callback:
+  @call here = enter 1
+Use the /del switch to remove callbacks that should not be connected.
+"""
+
+
+
[docs]class CmdCallback(COMMAND_DEFAULT_CLASS): + + """ + Command to edit callbacks. + """ + + key = "@call" + aliases = ["@callback", "@callbacks", "@calls"] + locks = "cmd:perm({})".format(VALIDATING) + if WITH_VALIDATION: + locks += " or perm({})".format(WITH_VALIDATION) + help_category = "Building" + +
[docs] def get_help(self, caller, cmdset): + """ + Return the help message for this command and this caller. + + The help text of this specific command will vary depending + on user permission. + + Args: + caller (Object or Account): the caller asking for help on the command. + cmdset (CmdSet): the command set (if you need additional commands). + + Returns: + docstring (str): the help text to provide the caller for this command. + + """ + lock = "perm({}) or perm(callbacks_validating)".format(VALIDATING) + validator = caller.locks.check_lockstring(caller, lock) + text = "\n" + BASIC_HELP + "\n\nUsages:\n " + + # Usages + text += "\n ".join(BASIC_USAGES) + if validator: + text += "\n " + "\n ".join(VALIDATOR_USAGES) + + # Switches + text += "\n\nSwitches:\n " + text += "\n ".join(BASIC_SWITCHES) + if validator: + text += "\n " + "\n ".join(VALIDATOR_SWITCHES) + + # Text + text += "\n" + BASIC_TEXT + if validator: + text += "\n" + VALIDATOR_TEXT + + return text
+ +
[docs] def func(self): + """Command body.""" + caller = self.caller + lock = "perm({}) or perm(events_validating)".format(VALIDATING) + validator = caller.locks.check_lockstring(caller, lock) + lock = "perm({}) or perm(events_without_validation)".format(WITHOUT_VALIDATION) + autovalid = caller.locks.check_lockstring(caller, lock) + + # First and foremost, get the callback handler and set other variables + self.handler = get_event_handler() + self.obj = None + rhs = self.rhs or "" + self.callback_name, sep, self.parameters = rhs.partition(" ") + self.callback_name = self.callback_name.lower() + self.is_validator = validator + self.autovalid = autovalid + if self.handler is None: + caller.msg("The event handler is not running, can't " "access the event system.") + return + + # Before the equal sign, there is an object name or nothing + if self.lhs: + self.obj = caller.search(self.lhs) + if not self.obj: + return + + # Switches are mutually exclusive + switch = self.switches and self.switches[0] or "" + if switch in ("", "add", "edit", "del") and self.obj is None: + caller.msg("Specify an object's name or #ID.") + return + + if switch == "": + self.list_callbacks() + elif switch == "add": + self.add_callback() + elif switch == "edit": + self.edit_callback() + elif switch == "del": + self.del_callback() + elif switch == "accept" and validator: + self.accept_callback() + elif switch in ["tasks", "task"]: + self.list_tasks() + else: + caller.msg("Mutually exclusive or invalid switches were " "used, cannot proceed.")
+ +
[docs] def list_callbacks(self): + """Display the list of callbacks connected to the object.""" + obj = self.obj + callback_name = self.callback_name + parameters = self.parameters + callbacks = self.handler.get_callbacks(obj) + types = self.handler.get_events(obj) + + if callback_name: + # Check that the callback name can be found in this object + created = callbacks.get(callback_name) + if created is None: + self.msg("No callback {} has been set on {}.".format(callback_name, obj)) + return + + if parameters: + # Check that the parameter points to an existing callback + try: + number = int(parameters) - 1 + assert number >= 0 + callback = callbacks[callback_name][number] + except (ValueError, AssertionError, IndexError): + self.msg( + "The callback {} {} cannot be found in {}.".format( + callback_name, parameters, obj + ) + ) + return + + # Display the callback's details + author = callback.get("author") + author = author.key if author else "|gUnknown|n" + updated_by = callback.get("updated_by") + updated_by = updated_by.key if updated_by else "|gUnknown|n" + created_on = callback.get("created_on") + created_on = ( + created_on.strftime("%Y-%m-%d %H:%M:%S") if created_on else "|gUnknown|n" + ) + updated_on = callback.get("updated_on") + updated_on = ( + updated_on.strftime("%Y-%m-%d %H:%M:%S") if updated_on else "|gUnknown|n" + ) + msg = "Callback {} {} of {}:".format(callback_name, parameters, obj) + msg += "\nCreated by {} on {}.".format(author, created_on) + msg += "\nUpdated by {} on {}".format(updated_by, updated_on) + + if self.is_validator: + if callback.get("valid"): + msg += "\nThis callback is |rconnected|n and active." + else: + msg += "\nThis callback |rhasn't been|n accepted yet." + + msg += "\nCallback code:\n" + msg += raw(callback["code"]) + self.msg(msg) + return + + # No parameter has been specified, display the table of callbacks + cols = ["Number", "Author", "Updated", "Param"] + if self.is_validator: + cols.append("Valid") + + table = EvTable(*cols, width=78) + table.reformat_column(0, align="r") + now = datetime.now() + for i, callback in enumerate(created): + author = callback.get("author") + author = author.key if author else "|gUnknown|n" + updated_on = callback.get("updated_on") + if updated_on is None: + updated_on = callback.get("created_on") + + if updated_on: + updated_on = "{} ago".format( + time_format((now - updated_on).total_seconds(), 4).capitalize() + ) + else: + updated_on = "|gUnknown|n" + parameters = callback.get("parameters", "") + + row = [str(i + 1), author, updated_on, parameters] + if self.is_validator: + row.append("Yes" if callback.get("valid") else "No") + table.add_row(*row) + + self.msg(str(table)) + else: + names = list(set(list(types.keys()) + list(callbacks.keys()))) + table = EvTable("Callback name", "Number", "Description", valign="t", width=78) + table.reformat_column(0, width=20) + table.reformat_column(1, width=10, align="r") + table.reformat_column(2, width=48) + for name in sorted(names): + number = len(callbacks.get(name, [])) + lines = sum(len(e["code"].splitlines()) for e in callbacks.get(name, [])) + no = "{} ({})".format(number, lines) + description = types.get(name, (None, "Chained event."))[1] + description = description.strip("\n").splitlines()[0] + table.add_row(name, no, description) + + self.msg(str(table))
+ +
[docs] def add_callback(self): + """Add a callback.""" + obj = self.obj + callback_name = self.callback_name + types = self.handler.get_events(obj) + + # Check that the callback exists + if not callback_name.startswith("chain_") and callback_name not in types: + self.msg( + "The callback name {} can't be found in {} of " + "typeclass {}.".format(callback_name, obj, type(obj)) + ) + return + + definition = types.get(callback_name, (None, "Chained event.")) + description = definition[1] + self.msg(raw(description.strip("\n"))) + + # Open the editor + callback = self.handler.add_callback( + obj, callback_name, "", self.caller, False, parameters=self.parameters + ) + + # Lock this callback right away + self.handler.db.locked.append((obj, callback_name, callback["number"])) + + # Open the editor for this callback + self.caller.db._callback = callback + EvEditor( + self.caller, + loadfunc=_ev_load, + savefunc=_ev_save, + quitfunc=_ev_quit, + key="Callback {} of {}".format(callback_name, obj), + persistent=True, + codefunc=_ev_save, + )
+ +
[docs] def edit_callback(self): + """Edit a callback.""" + obj = self.obj + callback_name = self.callback_name + parameters = self.parameters + callbacks = self.handler.get_callbacks(obj) + types = self.handler.get_events(obj) + + # If no callback name is specified, display the list of callbacks + if not callback_name: + self.list_callbacks() + return + + # Check that the callback exists + if callback_name not in callbacks: + self.msg("The callback name {} can't be found in {}.".format(callback_name, obj)) + return + + # If there's only one callback, just edit it + if len(callbacks[callback_name]) == 1: + number = 0 + callback = callbacks[callback_name][0] + else: + if not parameters: + self.msg("Which callback do you wish to edit? Specify a number.") + self.list_callbacks() + return + + # Check that the parameter points to an existing callback + try: + number = int(parameters) - 1 + assert number >= 0 + callback = callbacks[callback_name][number] + except (ValueError, AssertionError, IndexError): + self.msg( + "The callback {} {} cannot be found in {}.".format( + callback_name, parameters, obj + ) + ) + return + + # If caller can't edit without validation, forbid editing + # others' works + if not self.autovalid and callback["author"] is not self.caller: + self.msg("You cannot edit this callback created by someone else.") + return + + # If the callback is locked (edited by someone else) + if (obj, callback_name, number) in self.handler.db.locked: + self.msg("This callback is locked, you cannot edit it.") + return + + self.handler.db.locked.append((obj, callback_name, number)) + + # Check the definition of the callback + definition = types.get(callback_name, (None, "Chained event.")) + description = definition[1] + self.msg(raw(description.strip("\n"))) + + # Open the editor + callback = dict(callback) + self.caller.db._callback = callback + EvEditor( + self.caller, + loadfunc=_ev_load, + savefunc=_ev_save, + quitfunc=_ev_quit, + key="Callback {} of {}".format(callback_name, obj), + persistent=True, + codefunc=_ev_save, + )
+ +
[docs] def del_callback(self): + """Delete a callback.""" + obj = self.obj + callback_name = self.callback_name + parameters = self.parameters + callbacks = self.handler.get_callbacks(obj) + types = self.handler.get_events(obj) + + # If no callback name is specified, display the list of callbacks + if not callback_name: + self.list_callbacks() + return + + # Check that the callback exists + if callback_name not in callbacks: + self.msg("The callback name {} can't be found in {}.".format(callback_name, obj)) + return + + # If there's only one callback, just delete it + if len(callbacks[callback_name]) == 1: + number = 0 + callback = callbacks[callback_name][0] + else: + if not parameters: + self.msg("Which callback do you wish to delete? Specify " "a number.") + self.list_callbacks() + return + + # Check that the parameter points to an existing callback + try: + number = int(parameters) - 1 + assert number >= 0 + callback = callbacks[callback_name][number] + except (ValueError, AssertionError, IndexError): + self.msg( + "The callback {} {} cannot be found in {}.".format( + callback_name, parameters, obj + ) + ) + return + + # If caller can't edit without validation, forbid deleting + # others' works + if not self.autovalid and callback["author"] is not self.caller: + self.msg("You cannot delete this callback created by someone else.") + return + + # If the callback is locked (edited by someone else) + if (obj, callback_name, number) in self.handler.db.locked: + self.msg("This callback is locked, you cannot delete it.") + return + + # Delete the callback + self.handler.del_callback(obj, callback_name, number) + self.msg("The callback {}[{}] of {} was deleted.".format(callback_name, number + 1, obj))
+ +
[docs] def accept_callback(self): + """Accept a callback.""" + obj = self.obj + callback_name = self.callback_name + parameters = self.parameters + + # If no object, display the list of callbacks to be checked + if obj is None: + table = EvTable("ID", "Type", "Object", "Name", "Updated by", "On", width=78) + table.reformat_column(0, align="r") + now = datetime.now() + for obj, name, number in self.handler.db.to_valid: + callbacks = self.handler.get_callbacks(obj).get(name) + if callbacks is None: + continue + + try: + callback = callbacks[number] + except IndexError: + continue + + type_name = obj.typeclass_path.split(".")[-1] + by = callback.get("updated_by") + by = by.key if by else "|gUnknown|n" + updated_on = callback.get("updated_on") + if updated_on is None: + updated_on = callback.get("created_on") + + if updated_on: + updated_on = "{} ago".format( + time_format((now - updated_on).total_seconds(), 4).capitalize() + ) + else: + updated_on = "|gUnknown|n" + + table.add_row(obj.id, type_name, obj, name, by, updated_on) + self.msg(str(table)) + return + + # An object was specified + callbacks = self.handler.get_callbacks(obj) + types = self.handler.get_events(obj) + + # If no callback name is specified, display the list of callbacks + if not callback_name: + self.list_callbacks() + return + + # Check that the callback exists + if callback_name not in callbacks: + self.msg("The callback name {} can't be found in {}.".format(callback_name, obj)) + return + + if not parameters: + self.msg("Which callback do you wish to accept? Specify a number.") + self.list_callbacks() + return + + # Check that the parameter points to an existing callback + try: + number = int(parameters) - 1 + assert number >= 0 + callback = callbacks[callback_name][number] + except (ValueError, AssertionError, IndexError): + self.msg( + "The callback {} {} cannot be found in {}.".format(callback_name, parameters, obj) + ) + return + + # Accept the callback + if callback["valid"]: + self.msg("This callback has already been accepted.") + else: + self.handler.accept_callback(obj, callback_name, number) + self.msg( + "The callback {} {} of {} has been accepted.".format(callback_name, parameters, obj) + )
+ +
[docs] def list_tasks(self): + """List the active tasks.""" + obj = self.obj + callback_name = self.callback_name + handler = self.handler + tasks = [(k, v[0], v[1], v[2]) for k, v in handler.db.tasks.items()] + if obj: + tasks = [task for task in tasks if task[2] is obj] + if callback_name: + tasks = [task for task in tasks if task[3] == callback_name] + + tasks.sort() + table = EvTable("ID", "Object", "Callback", "In", width=78) + table.reformat_column(0, align="r") + now = datetime.now() + for task_id, future, obj, callback_name in tasks: + key = obj.get_display_name(self.caller) + delta = time_format((future - now).total_seconds(), 1) + table.add_row(task_id, key, callback_name, delta) + + self.msg(str(table))
+ + +# Private functions to handle editing + + +def _ev_load(caller): + return caller.db._callback and caller.db._callback.get("code", "") or "" + + +def _ev_save(caller, buf): + """Save and add the callback.""" + lock = "perm({}) or perm(events_without_validation)".format(WITHOUT_VALIDATION) + autovalid = caller.locks.check_lockstring(caller, lock) + callback = caller.db._callback + handler = get_event_handler() + if ( + not handler + or not callback + or not all(key in callback for key in ("obj", "name", "number", "valid")) + ): + caller.msg("Couldn't save this callback.") + return False + + if (callback["obj"], callback["name"], callback["number"]) in handler.db.locked: + handler.db.locked.remove((callback["obj"], callback["name"], callback["number"])) + + handler.edit_callback( + callback["obj"], callback["name"], callback["number"], buf, caller, valid=autovalid + ) + return True + + +def _ev_quit(caller): + callback = caller.db._callback + handler = get_event_handler() + if ( + not handler + or not callback + or not all(key in callback for key in ("obj", "name", "number", "valid")) + ): + caller.msg("Couldn't save this callback.") + return False + + if (callback["obj"], callback["name"], callback["number"]) in handler.db.locked: + handler.db.locked.remove((callback["obj"], callback["name"], callback["number"])) + + del caller.db._callback + caller.msg("Exited the code editor.") +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/eventfuncs.html b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/eventfuncs.html new file mode 100644 index 0000000000..69f515855d --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/eventfuncs.html @@ -0,0 +1,196 @@ + + + + + + + + evennia.contrib.base_systems.ingame_python.eventfuncs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.ingame_python.eventfuncs

+"""
+Module defining basic eventfuncs for the event system.
+
+Eventfuncs are just Python functions that can be used inside of calllbacks.
+
+"""
+
+from evennia import ObjectDB
+from evennia.contrib.base_systems.ingame_python.utils import InterruptEvent
+
+
+
[docs]def deny(): + """ + Deny, that is stop, the callback here. + + Notes: + This function will raise an exception to terminate the callback + in a controlled way. If you use this function in an event called + prior to a command, the command will be cancelled as well. Good + situations to use the `deny()` function are in events that begins + by `can_`, because they usually can be cancelled as easily as that. + + """ + raise InterruptEvent
+ + +
[docs]def get(**kwargs): + """ + Return an object with the given search option or None if None is found. + + Keyword Args: + Any searchable data or property (id, db_key, db_location...). + + Returns: + The object found that meet these criteria for research, or + None if none is found. + + Notes: + This function is very useful to retrieve objects with a specific + ID. You know that room #32 exists, but you don't have it in + the callback variables. Quite simple: + room = get(id=32) + + This function doesn't perform a search on objects, but a direct + search in the database. It's recommended to use it for objects + you know exist, using their IDs or other unique attributes. + Looking for objects by key is possible (use `db_key` as an + argument) but remember several objects can share the same key. + + """ + try: + object = ObjectDB.objects.get(**kwargs) + except ObjectDB.DoesNotExist: + object = None + + return object
+ + +
[docs]def call_event(obj, event_name, seconds=0): + """ + Call the specified event in X seconds. + + Args: + obj (Object): the typeclassed object containing the event. + event_name (str): the event name to be called. + seconds (int or float): the number of seconds to wait before calling + the event. + + Notes: + This eventfunc can be used to call other events from inside of an + event in a given time. This will create a pause between events. This + will not freeze the game, and you can expect characters to move + around (unless you prevent them from doing so). + + Variables that are accessible in your event using 'call()' will be + kept and passed on to the event to call. + + Chained callbacks are designed for this very purpose: they + are never called automatically by the game, rather, they need + to be called from inside another event. + + """ + script = type(obj.callbacks).script + if script: + # If seconds is 0, call the event immediately + if seconds == 0: + locals = dict(script.ndb.current_locals) + obj.callbacks.call(event_name, locals=locals) + else: + # Schedule the task + script.set_task(seconds, obj, event_name)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/scripts.html b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/scripts.html new file mode 100644 index 0000000000..6619e93162 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/scripts.html @@ -0,0 +1,778 @@ + + + + + + + + evennia.contrib.base_systems.ingame_python.scripts — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.ingame_python.scripts

+"""
+Scripts for the in-game Python system.
+"""
+
+import re
+import sys
+import traceback
+from datetime import datetime, timedelta
+from queue import Queue
+
+from django.conf import settings
+
+from evennia import ChannelDB, DefaultObject, DefaultScript, ScriptDB, logger
+from evennia.contrib.base_systems.ingame_python.callbackhandler import CallbackHandler
+from evennia.contrib.base_systems.ingame_python.utils import (
+    EVENTS,
+    InterruptEvent,
+    get_next_wait,
+)
+from evennia.utils.ansi import raw
+from evennia.utils.create import create_channel
+from evennia.utils.dbserialize import dbserialize
+from evennia.utils.utils import all_from_module, delay, pypath_to_realpath
+
+# Constants
+RE_LINE_ERROR = re.compile(r'^  File "\<string\>", line (\d+)')
+
+
+
[docs]class EventHandler(DefaultScript): + + """ + The event handler that contains all events in a global script. + + This script shouldn't be created more than once. It contains + event (in a non-persistent attribute) and callbacks (in a + persistent attribute). The script method would help adding, + editing and deleting these events and callbacks. + + """ + +
[docs] def at_script_creation(self): + """Hook called when the script is created.""" + self.key = "event_handler" + self.desc = "Global event handler" + self.persistent = True + + # Permanent data to be stored + self.db.callbacks = {} + self.db.to_valid = [] + self.db.locked = [] + + # Tasks + self.db.tasks = {} + self.at_server_start()
+ +
[docs] def at_server_start(self): + """Set up the event system when starting. + + Note that this hook is called every time the server restarts + (including when it's reloaded). This hook performs the following + tasks: + + - Create temporarily stored events. + - Generate locals (individual events' namespace). + - Load eventfuncs, including user-defined ones. + - Re-schedule tasks that aren't set to fire anymore. + - Effectively connect the handler to the main script. + + """ + self.ndb.events = {} + for typeclass, name, variables, help_text, custom_call, custom_add in EVENTS: + self.add_event(typeclass, name, variables, help_text, custom_call, custom_add) + + # Generate locals + self.ndb.current_locals = {} + self.ndb.fresh_locals = {} + addresses = ["evennia.contrib.base_systems.ingame_python.eventfuncs"] + addresses.extend(getattr(settings, "EVENTFUNCS_LOCATIONS", ["world.eventfuncs"])) + for address in addresses: + if pypath_to_realpath(address): + self.ndb.fresh_locals.update(all_from_module(address)) + + # Restart the delayed tasks + now = datetime.now() + for task_id, definition in tuple(self.db.tasks.items()): + future, obj, event_name, locals = definition + seconds = (future - now).total_seconds() + if seconds < 0: + seconds = 0 + + delay(seconds, complete_task, task_id) + + # Place the script in the CallbackHandler + from evennia.contrib.base_systems.ingame_python import typeclasses + + CallbackHandler.script = self + DefaultObject.callbacks = typeclasses.EventObject.callbacks + + # Create the channel if non-existent + try: + self.ndb.channel = ChannelDB.objects.get(db_key="everror") + except ChannelDB.DoesNotExist: + self.ndb.channel = create_channel( + "everror", + desc="Event errors", + locks="control:false();listen:perm(Builders);send:false()", + )
+ +
[docs] def get_events(self, obj): + """ + Return a dictionary of events on this object. + + Args: + obj (Object or typeclass): the connected object or a general typeclass. + + Returns: + A dictionary of the object's events. + + Notes: + Events would define what the object can have as + callbacks. Note, however, that chained callbacks will not + appear in events and are handled separately. + + You can also request the events of a typeclass, not a + connected object. This is useful to get the global list + of events for a typeclass that has no object yet. + + """ + events = {} + all_events = self.ndb.events + classes = Queue() + if isinstance(obj, type): + classes.put(obj) + else: + classes.put(type(obj)) + + invalid = [] + while not classes.empty(): + typeclass = classes.get() + typeclass_name = typeclass.__module__ + "." + typeclass.__name__ + for key, etype in all_events.get(typeclass_name, {}).items(): + if key in invalid: + continue + if etype[0] is None: # Invalidate + invalid.append(key) + continue + if key not in events: + events[key] = etype + + # Look for the parent classes + for parent in typeclass.__bases__: + classes.put(parent) + + return events
+ +
[docs] def get_variable(self, variable_name): + """ + Return the variable defined in the locals. + + This can be very useful to check the value of a variable that can be modified in an event, and whose value will be used in code. This system allows additional customization. + + Args: + variable_name (str): the name of the variable to return. + + Returns: + The variable if found in the locals. + None if not found in the locals. + + Note: + This will return the variable from the current locals. + Keep in mind that locals are shared between events. As + every event is called one by one, this doesn't pose + additional problems if you get the variable right after + an event has been executed. If, however, you differ, + there's no guarantee the variable will be here or will + mean the same thing. + + """ + return self.ndb.current_locals.get(variable_name)
+ +
[docs] def get_callbacks(self, obj): + """ + Return a dictionary of the object's callbacks. + + Args: + obj (Object): the connected objects. + + Returns: + A dictionary of the object's callbacks. + + Note: + This method can be useful to override in some contexts, + when several objects would share callbacks. + + """ + obj_callbacks = self.db.callbacks.get(obj, {}) + callbacks = {} + for callback_name, callback_list in obj_callbacks.items(): + new_list = [] + for i, callback in enumerate(callback_list): + callback = dict(callback) + callback["obj"] = obj + callback["name"] = callback_name + callback["number"] = i + new_list.append(callback) + + if new_list: + callbacks[callback_name] = new_list + + return callbacks
+ +
[docs] def add_callback(self, obj, callback_name, code, author=None, valid=False, parameters=""): + """ + Add the specified callback. + + Args: + obj (Object): the Evennia typeclassed object to be extended. + callback_name (str): the name of the callback to add. + code (str): the Python code associated with this callback. + author (Character or Account, optional): the author of the callback. + valid (bool, optional): should the callback be connected? + parameters (str, optional): optional parameters. + + Note: + This method doesn't check that the callback type exists. + + """ + obj_callbacks = self.db.callbacks.get(obj, {}) + if not obj_callbacks: + self.db.callbacks[obj] = {} + obj_callbacks = self.db.callbacks[obj] + + callbacks = obj_callbacks.get(callback_name, []) + if not callbacks: + obj_callbacks[callback_name] = [] + callbacks = obj_callbacks[callback_name] + + # Add the callback in the list + callbacks.append( + { + "created_on": datetime.now(), + "author": author, + "valid": valid, + "code": code, + "parameters": parameters, + } + ) + + # If not valid, set it in 'to_valid' + if not valid: + self.db.to_valid.append((obj, callback_name, len(callbacks) - 1)) + + # Call the custom_add if needed + custom_add = self.get_events(obj).get(callback_name, [None, None, None, None])[3] + if custom_add: + custom_add(obj, callback_name, len(callbacks) - 1, parameters) + + # Build the definition to return (a dictionary) + definition = dict(callbacks[-1]) + definition["obj"] = obj + definition["name"] = callback_name + definition["number"] = len(callbacks) - 1 + return definition
+ +
[docs] def edit_callback(self, obj, callback_name, number, code, author=None, valid=False): + """ + Edit the specified callback. + + Args: + obj (Object): the Evennia typeclassed object to be edited. + callback_name (str): the name of the callback to edit. + number (int): the callback number to be changed. + code (str): the Python code associated with this callback. + author (Character or Account, optional): the author of the callback. + valid (bool, optional): should the callback be connected? + + Raises: + RuntimeError if the callback is locked. + + Note: + This method doesn't check that the callback type exists. + + """ + obj_callbacks = self.db.callbacks.get(obj, {}) + if not obj_callbacks: + self.db.callbacks[obj] = {} + obj_callbacks = self.db.callbacks[obj] + + callbacks = obj_callbacks.get(callback_name, []) + if not callbacks: + obj_callbacks[callback_name] = [] + callbacks = obj_callbacks[callback_name] + + # If locked, don't edit it + if (obj, callback_name, number) in self.db.locked: + raise RuntimeError("this callback is locked.") + + # Edit the callback + callbacks[number].update( + {"updated_on": datetime.now(), "updated_by": author, "valid": valid, "code": code} + ) + + # If not valid, set it in 'to_valid' + if not valid and (obj, callback_name, number) not in self.db.to_valid: + self.db.to_valid.append((obj, callback_name, number)) + elif valid and (obj, callback_name, number) in self.db.to_valid: + self.db.to_valid.remove((obj, callback_name, number)) + + # Build the definition to return (a dictionary) + definition = dict(callbacks[number]) + definition["obj"] = obj + definition["name"] = callback_name + definition["number"] = number + return definition
+ +
[docs] def del_callback(self, obj, callback_name, number): + """ + Delete the specified callback. + + Args: + obj (Object): the typeclassed object containing the callback. + callback_name (str): the name of the callback to delete. + number (int): the number of the callback to delete. + + Raises: + RuntimeError if the callback is locked. + + """ + obj_callbacks = self.db.callbacks.get(obj, {}) + callbacks = obj_callbacks.get(callback_name, []) + + # If locked, don't edit it + if (obj, callback_name, number) in self.db.locked: + raise RuntimeError("this callback is locked.") + + # Delete the callback itself + try: + code = callbacks[number]["code"] + except IndexError: + return + else: + logger.log_info( + "Deleting callback {} {} of {}:\n{}".format(callback_name, number, obj, code) + ) + del callbacks[number] + + # Change IDs of callbacks to be validated + i = 0 + while i < len(self.db.to_valid): + t_obj, t_callback_name, t_number = self.db.to_valid[i] + if obj is t_obj and callback_name == t_callback_name: + if t_number == number: + # Strictly equal, delete the callback + del self.db.to_valid[i] + i -= 1 + elif t_number > number: + # Change the ID for this callback + self.db.to_valid.insert(i, (t_obj, t_callback_name, t_number - 1)) + del self.db.to_valid[i + 1] + i += 1 + + # Update locked callback + for i, line in enumerate(self.db.locked): + t_obj, t_callback_name, t_number = line + if obj is t_obj and callback_name == t_callback_name: + if number < t_number: + self.db.locked[i] = (t_obj, t_callback_name, t_number - 1) + + # Delete time-related callbacks associated with this object + for script in obj.scripts.all(): + if isinstance(script, TimecallbackScript): + if script.obj is obj and script.db.callback_name == callback_name: + if script.db.number == number: + script.stop() + elif script.db.number > number: + script.db.number -= 1
+ +
[docs] def accept_callback(self, obj, callback_name, number): + """ + Valid a callback. + + Args: + obj (Object): the object containing the callback. + callback_name (str): the name of the callback. + number (int): the number of the callback. + + """ + obj_callbacks = self.db.callbacks.get(obj, {}) + callbacks = obj_callbacks.get(callback_name, []) + + # Accept and connect the callback + callbacks[number].update({"valid": True}) + if (obj, callback_name, number) in self.db.to_valid: + self.db.to_valid.remove((obj, callback_name, number))
+ +
[docs] def call(self, obj, callback_name, *args, **kwargs): + """ + Call the connected callbacks. + + Args: + obj (Object): the Evennia typeclassed object. + callback_name (str): the callback name to call. + *args: additional variables for this callback. + + Keyword Args: + number (int, optional): call just a specific callback. + parameters (str, optional): call a callback with parameters. + locals (dict, optional): a locals replacement. + + Returns: + True to report the callback was called without interruption, + False otherwise. + + """ + # First, look for the callback type corresponding to this name + number = kwargs.get("number") + parameters = kwargs.get("parameters") + locals = kwargs.get("locals") + + # Errors should not pass silently + allowed = ("number", "parameters", "locals") + if any(k for k in kwargs if k not in allowed): + raise TypeError( + "Unknown keyword arguments were specified " "to call callbacks: {}".format(kwargs) + ) + + event = self.get_events(obj).get(callback_name) + if locals is None and not event: + logger.log_err( + "The callback {} for the object {} (typeclass " + "{}) can't be found".format(callback_name, obj, type(obj)) + ) + return False + + # Prepare the locals if necessary + if locals is None: + locals = self.ndb.fresh_locals.copy() + for i, variable in enumerate(event[0]): + try: + locals[variable] = args[i] + except IndexError: + logger.log_trace( + "callback {} of {} ({}): need variable " + "{} in position {}".format(callback_name, obj, type(obj), variable, i) + ) + return False + else: + locals = {key: value for key, value in locals.items()} + + callbacks = self.get_callbacks(obj).get(callback_name, []) + if event: + custom_call = event[2] + if custom_call: + callbacks = custom_call(callbacks, parameters) + + # Now execute all the valid callbacks linked at this address + self.ndb.current_locals = locals + for i, callback in enumerate(callbacks): + if not callback["valid"]: + continue + + if number is not None and callback["number"] != number: + continue + + try: + exec(callback["code"], locals, locals) + except InterruptEvent: + return False + except Exception: + etype, evalue, tb = sys.exc_info() + trace = traceback.format_exception(etype, evalue, tb) + self.handle_error(callback, trace) + + return True
+ +
[docs] def handle_error(self, callback, trace): + """ + Handle an error in a callback. + + Args: + callback (dict): the callback representation. + trace (list): the traceback containing the exception. + + Notes: + This method can be useful to override to change the default + handling of errors. By default, the error message is sent to + the character who last updated the callback, if connected. + If not, display to the everror channel. + + """ + callback_name = callback["name"] + number = callback["number"] + obj = callback["obj"] + oid = obj.id + logger.log_err( + "An error occurred during the callback {} of " + "{} (#{}), number {}\n{}".format(callback_name, obj, oid, number + 1, "\n".join(trace)) + ) + + # Create the error message + line = "|runknown|n" + lineno = "|runknown|n" + for error in trace: + if error.startswith(' File "<string>", line '): + res = RE_LINE_ERROR.search(error) + if res: + lineno = int(res.group(1)) + + # Try to extract the line + try: + line = raw(callback["code"].splitlines()[lineno - 1]) + except IndexError: + continue + else: + break + + exc = raw(trace[-1].strip("\n").splitlines()[-1]) + err_msg = "Error in {} of {} (#{})[{}], line {}:" " {}\n{}".format( + callback_name, obj, oid, number + 1, lineno, line, exc + ) + + # Inform the last updater if connected + updater = callback.get("updated_by") + if updater is None: + updater = callback["created_by"] + + if updater and updater.sessions.all(): + updater.msg(err_msg) + else: + err_msg = "Error in {} of {} (#{})[{}], line {}:" " {}\n {}".format( + callback_name, obj, oid, number + 1, lineno, line, exc + ) + self.ndb.channel.msg(err_msg)
+ +
[docs] def add_event(self, typeclass, name, variables, help_text, custom_call, custom_add): + """ + Add a new event for a defined typeclass. + + Args: + typeclass (str): the path leading to the typeclass. + name (str): the name of the event to add. + variables (list of str): list of variable names for this event. + help_text (str): the long help text of the event. + custom_call (callable or None): the function to be called + when the event fires. + custom_add (callable or None): the function to be called when + a callback is added. + + """ + if typeclass not in self.ndb.events: + self.ndb.events[typeclass] = {} + + events = self.ndb.events[typeclass] + if name not in events: + events[name] = (variables, help_text, custom_call, custom_add)
+ +
[docs] def set_task(self, seconds, obj, callback_name): + """ + Set and schedule a task to run. + + Args: + seconds (int, float): the delay in seconds from now. + obj (Object): the typecalssed object connected to the event. + callback_name (str): the callback's name. + + Notes: + This method allows to schedule a "persistent" task. + 'utils.delay' is called, but a copy of the task is kept in + the event handler, and when the script restarts (after reload), + the differed delay is called again. + The dictionary of locals is frozen and will be available + again when the task runs. This feature, however, is limited + by the database: all data cannot be saved. Lambda functions, + class methods, objects inside an instance and so on will + not be kept in the locals dictionary. + + """ + now = datetime.now() + delta = timedelta(seconds=seconds) + + # Choose a free task_id + used_ids = list(self.db.tasks.keys()) + task_id = 1 + while task_id in used_ids: + task_id += 1 + + # Collect and freeze current locals + locals = {} + for key, value in self.ndb.current_locals.items(): + try: + dbserialize(value) + except TypeError: + continue + else: + locals[key] = value + + self.db.tasks[task_id] = (now + delta, obj, callback_name, locals) + delay(seconds, complete_task, task_id)
+ + +# Script to call time-related events +
[docs]class TimeEventScript(DefaultScript): + + """Gametime-sensitive script.""" + +
[docs] def at_script_creation(self): + """The script is created.""" + self.start_delay = True + self.persistent = True + + # Script attributes + self.db.time_format = None + self.db.event_name = "time" + self.db.number = None
+ +
[docs] def at_repeat(self): + """ + Call the event and reset interval. + + It is necessary to restart the script to reset its interval + only twice after a reload. When the script has undergone + down time, there's usually a slight shift in game time. Once + the script restarts once, it will set the average time it + needs for all its future intervals and should not need to be + restarted. In short, a script that is created shouldn't need + to restart more than once, and a script that is reloaded should + restart only twice. + + """ + if self.db.time_format: + # If the 'usual' time is set, use it + seconds = self.ndb.usual + if seconds is None: + seconds, usual, details = get_next_wait(self.db.time_format) + self.ndb.usual = usual + + if self.interval != seconds: + self.restart(interval=seconds) + + if self.db.event_name and self.db.number is not None: + obj = self.obj + if not obj.callbacks: + return + + event_name = self.db.event_name + number = self.db.number + obj.callbacks.call(event_name, obj, number=number)
+ + +# Functions to manipulate tasks +
[docs]def complete_task(task_id): + """ + Mark the task in the event handler as complete. + + Args: + task_id (int): the task ID. + + Note: + This function should be called automatically for individual tasks. + + """ + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + logger.log_trace("Can't get the event handler.") + return + + if task_id not in script.db.tasks: + logger.log_err("The task #{} was scheduled, but it cannot be " "found".format(task_id)) + return + + delta, obj, callback_name, locals = script.db.tasks.pop(task_id) + script.call(obj, callback_name, locals=locals)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/tests.html new file mode 100644 index 0000000000..2cdb169b18 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/tests.html @@ -0,0 +1,680 @@ + + + + + + + + evennia.contrib.base_systems.ingame_python.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.ingame_python.tests

+"""
+Module containing the test cases for the in-game Python system.
+"""
+
+from textwrap import dedent
+
+from django.conf import settings
+from mock import Mock
+
+from evennia import ScriptDB
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.objects.objects import ExitCommand
+from evennia.utils import ansi, utils
+from evennia.utils.create import create_object, create_script
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .callbackhandler import CallbackHandler
+from .commands import CmdCallback
+
+# Force settings
+settings.EVENTS_CALENDAR = "standard"
+
+# Constants
+OLD_EVENTS = {}
+
+
+
[docs]class TestEventHandler(BaseEvenniaTest): + + """ + Test cases of the event handler to add, edit or delete events. + """ + +
[docs] def setUp(self): + """Create the event handler.""" + super().setUp() + self.handler = create_script( + "evennia.contrib.base_systems.ingame_python.scripts.EventHandler" + ) + + # Copy old events if necessary + if OLD_EVENTS: + self.handler.ndb.events = dict(OLD_EVENTS) + + # Alter typeclasses + self.char1.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventCharacter" + ) + self.char2.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventCharacter" + ) + self.room1.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventRoom" + ) + self.room2.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventRoom" + ) + self.exit.swap_typeclass("evennia.contrib.base_systems.ingame_python.typeclasses.EventExit")
+ +
[docs] def tearDown(self): + """Stop the event handler.""" + OLD_EVENTS.clear() + OLD_EVENTS.update(self.handler.ndb.events) + self.handler.delete() + CallbackHandler.script = None + super().tearDown()
+ +
[docs] def test_start(self): + """Simply make sure the handler runs with proper initial values.""" + self.assertEqual(self.handler.db.callbacks, {}) + self.assertEqual(self.handler.db.to_valid, []) + self.assertEqual(self.handler.db.locked, []) + self.assertEqual(self.handler.db.tasks, {}) + self.assertIsNotNone(self.handler.ndb.events)
+ +
[docs] def test_add_validation(self): + """Add a callback while needing validation.""" + author = self.char1 + self.handler.add_callback( + self.room1, "dummy", "character.db.strength = 40", author=author, valid=False + ) + callback = self.handler.get_callbacks(self.room1).get("dummy") + callback = callback[0] + self.assertIsNotNone(callback) + self.assertEqual(callback["author"], author) + self.assertEqual(callback["valid"], False) + + # Since this callback is not valid, it should appear in 'to_valid' + self.assertIn((self.room1, "dummy", 0), self.handler.db.to_valid) + + # Run this dummy callback (shouldn't do anything) + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 10)
+ +
[docs] def test_edit(self): + """Test editing a callback.""" + author = self.char1 + self.handler.add_callback( + self.room1, "dummy", "character.db.strength = 60", author=author, valid=True + ) + + # Edit it right away + self.handler.edit_callback( + self.room1, "dummy", 0, "character.db.strength = 65", author=self.char2, valid=True + ) + + # Check that the callback was written + callback = self.handler.get_callbacks(self.room1).get("dummy") + callback = callback[0] + self.assertIsNotNone(callback) + self.assertEqual(callback["author"], author) + self.assertEqual(callback["valid"], True) + self.assertEqual(callback["updated_by"], self.char2) + + # Run this dummy callback + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 65)
+ +
[docs] def test_edit_validation(self): + """Edit a callback when validation isn't automatic.""" + author = self.char1 + self.handler.add_callback( + self.room1, "dummy", "character.db.strength = 70", author=author, valid=True + ) + + # Edit it right away + self.handler.edit_callback( + self.room1, "dummy", 0, "character.db.strength = 80", author=self.char2, valid=False + ) + + # Run this dummy callback (shouldn't do anything) + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 10)
+ +
[docs] def test_del(self): + """Try to delete a callback.""" + # Add 3 callbacks + self.handler.add_callback( + self.room1, "dummy", "character.db.strength = 5", author=self.char1, valid=True + ) + self.handler.add_callback( + self.room1, "dummy", "character.db.strength = 8", author=self.char2, valid=False + ) + self.handler.add_callback( + self.room1, "dummy", "character.db.strength = 9", author=self.char1, valid=True + ) + + # Note that the second callback isn't valid + self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid) + + # Lock the third callback + self.handler.db.locked.append((self.room1, "dummy", 2)) + + # Delete the first callback + self.handler.del_callback(self.room1, "dummy", 0) + + # The callback #1 that was to valid should be #0 now + self.assertIn((self.room1, "dummy", 0), self.handler.db.to_valid) + self.assertNotIn((self.room1, "dummy", 1), self.handler.db.to_valid) + + # The lock has been updated too + self.assertIn((self.room1, "dummy", 1), self.handler.db.locked) + self.assertNotIn((self.room1, "dummy", 2), self.handler.db.locked) + + # Now delete the first (not valid) callback + self.handler.del_callback(self.room1, "dummy", 0) + self.assertEqual(self.handler.db.to_valid, []) + self.assertIn((self.room1, "dummy", 0), self.handler.db.locked) + self.assertNotIn((self.room1, "dummy", 1), self.handler.db.locked) + + # Call the remaining callback + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 9)
+ +
[docs] def test_accept(self): + """Accept an callback.""" + # Add 2 callbacks + self.handler.add_callback( + self.room1, "dummy", "character.db.strength = 5", author=self.char1, valid=True + ) + self.handler.add_callback( + self.room1, "dummy", "character.db.strength = 8", author=self.char2, valid=False + ) + + # Note that the second callback isn't valid + self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid) + + # Accept the second callback + self.handler.accept_callback(self.room1, "dummy", 1) + callback = self.handler.get_callbacks(self.room1).get("dummy") + callback = callback[1] + self.assertIsNotNone(callback) + self.assertEqual(callback["valid"], True) + + # Call the dummy callback + self.char1.db.strength = 10 + locals = {"character": self.char1} + self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals)) + self.assertEqual(self.char1.db.strength, 8)
+ +
[docs] def test_call(self): + """Test to call amore complex callback.""" + self.char1.key = "one" + self.char2.key = "two" + + # Add an callback + code = dedent( + """ + if character.key == "one": + character.db.health = 50 + else: + character.db.health = 0 + """.strip( + "\n" + ) + ) + self.handler.add_callback(self.room1, "dummy", code, author=self.char1, valid=True) + + # Call the dummy callback + self.assertTrue(self.handler.call(self.room1, "dummy", locals={"character": self.char1})) + self.assertEqual(self.char1.db.health, 50) + self.assertTrue(self.handler.call(self.room1, "dummy", locals={"character": self.char2})) + self.assertEqual(self.char2.db.health, 0)
+ +
[docs] def test_handler(self): + """Test the object handler.""" + self.assertIsNotNone(self.char1.callbacks) + + # Add an callback + callback = self.room1.callbacks.add("dummy", "pass", author=self.char1, valid=True) + self.assertEqual(callback.obj, self.room1) + self.assertEqual(callback.name, "dummy") + self.assertEqual(callback.code, "pass") + self.assertEqual(callback.author, self.char1) + self.assertEqual(callback.valid, True) + self.assertIn([callback], list(self.room1.callbacks.all().values())) + + # Edit this very callback + new = self.room1.callbacks.edit( + "dummy", 0, "character.db.say = True", author=self.char1, valid=True + ) + self.assertIn([new], list(self.room1.callbacks.all().values())) + self.assertNotIn([callback], list(self.room1.callbacks.all().values())) + + # Try to call this callback + self.assertTrue(self.room1.callbacks.call("dummy", locals={"character": self.char2})) + self.assertTrue(self.char2.db.say) + + # Delete the callback + self.room1.callbacks.remove("dummy", 0) + self.assertEqual(self.room1.callbacks.all(), {})
+ + +
[docs]class TestCmdCallback(BaseEvenniaCommandTest): + + """Test the @callback command.""" + +
[docs] def setUp(self): + """Create the callback handler.""" + super().setUp() + self.handler = create_script( + "evennia.contrib.base_systems.ingame_python.scripts.EventHandler" + ) + + # Copy old events if necessary + if OLD_EVENTS: + self.handler.ndb.events = dict(OLD_EVENTS) + + # Alter typeclasses + self.char1.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventCharacter" + ) + self.char2.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventCharacter" + ) + self.room1.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventRoom" + ) + self.room2.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventRoom" + ) + self.exit.swap_typeclass("evennia.contrib.base_systems.ingame_python.typeclasses.EventExit")
+ +
[docs] def tearDown(self): + """Stop the callback handler.""" + OLD_EVENTS.clear() + OLD_EVENTS.update(self.handler.ndb.events) + self.handler.delete() + for script in ScriptDB.objects.filter( + db_typeclass_path="evennia.contrib.base_systems.ingame_python.scripts.TimeEventScript" + ): + script.delete() + + CallbackHandler.script = None + super().tearDown()
+ +
[docs] def test_list(self): + """Test listing callbacks with different rights.""" + table = self.call(CmdCallback(), "out") + lines = table.splitlines()[3:-1] + self.assertNotEqual(lines, []) + + # Check that the second column only contains 0 (0) (no callback yet) + for line in lines: + cols = line.split("|") + self.assertIn(cols[2].strip(), ("0 (0)", "")) + + # Add some callback + self.handler.add_callback(self.exit, "traverse", "pass", author=self.char1, valid=True) + + # Try to obtain more details on a specific callback on exit + table = self.call(CmdCallback(), "out = traverse") + lines = table.splitlines()[3:-1] + self.assertEqual(len(lines), 1) + line = lines[0] + cols = line.split("|") + self.assertIn(cols[1].strip(), ("1", "")) + self.assertIn(cols[2].strip(), (str(self.char1), "")) + self.assertIn(cols[-1].strip(), ("Yes", "No", "")) + + # Run the same command with char2 + # char2 shouldn't see the last column (Valid) + table = self.call(CmdCallback(), "out = traverse", caller=self.char2) + lines = table.splitlines()[3:-1] + self.assertEqual(len(lines), 1) + line = lines[0] + cols = line.split("|") + self.assertEqual(cols[1].strip(), "1") + self.assertNotIn(cols[-1].strip(), ("Yes", "No")) + + # In any case, display the callback + # The last line should be "pass" (the callback code) + details = self.call(CmdCallback(), "out = traverse 1") + self.assertEqual(details.splitlines()[-1], "pass")
+ +
[docs] def test_add(self): + """Test to add an callback.""" + self.call(CmdCallback(), "/add out = traverse") + editor = self.char1.ndb._eveditor + self.assertIsNotNone(editor) + + # Edit the callback + editor.update_buffer( + dedent( + """ + if character.key == "one": + character.msg("You can pass.") + else: + character.msg("You can't pass.") + deny() + """.strip( + "\n" + ) + ) + ) + editor.save_buffer() + editor.quit() + callback = self.exit.callbacks.get("traverse")[0] + self.assertEqual(callback.author, self.char1) + self.assertEqual(callback.valid, True) + self.assertTrue(len(callback.code) > 0) + + # We're going to try the same thing but with char2 + # char2 being a player for our test, the callback won't be validated. + self.call(CmdCallback(), "/add out = traverse", caller=self.char2) + editor = self.char2.ndb._eveditor + self.assertIsNotNone(editor) + + # Edit the callback + editor.update_buffer( + dedent( + """ + character.msg("No way.") + """.strip( + "\n" + ) + ) + ) + editor.save_buffer() + editor.quit() + callback = self.exit.callbacks.get("traverse")[1] + self.assertEqual(callback.author, self.char2) + self.assertEqual(callback.valid, False) + self.assertTrue(len(callback.code) > 0)
+ +
[docs] def test_del(self): + """Add and remove an callback.""" + self.handler.add_callback(self.exit, "traverse", "pass", author=self.char1, valid=True) + + # Try to delete the callback + # char2 shouldn't be allowed to do so (that's not HIS callback) + self.call(CmdCallback(), "/del out = traverse 1", caller=self.char2) + self.assertTrue(len(self.handler.get_callbacks(self.exit).get("traverse", [])) == 1) + + # Now, char1 should be allowed to delete it + self.call(CmdCallback(), "/del out = traverse 1") + self.assertTrue(len(self.handler.get_callbacks(self.exit).get("traverse", [])) == 0)
+ +
[docs] def test_lock(self): + """Test the lock of multiple editing.""" + self.call(CmdCallback(), "/add here = time 8:00", caller=self.char2) + self.assertIsNotNone(self.char2.ndb._eveditor) + + # Now ask char1 to edit + line = self.call(CmdCallback(), "/edit here = time 1") + self.assertIsNone(self.char1.ndb._eveditor) + + # Try to delete this callback while char2 is editing it + line = self.call(CmdCallback(), "/del here = time 1")
+ +
[docs] def test_accept(self): + """Accept an callback.""" + self.call(CmdCallback(), "/add here = time 8:00", caller=self.char2) + editor = self.char2.ndb._eveditor + self.assertIsNotNone(editor) + + # Edit the callback + editor.update_buffer( + dedent( + """ + room.msg_contents("It's 8 PM, everybody up!") + """.strip( + "\n" + ) + ) + ) + editor.save_buffer() + editor.quit() + callback = self.room1.callbacks.get("time")[0] + self.assertEqual(callback.valid, False) + + # chars shouldn't be allowed to the callback + self.call(CmdCallback(), "/accept here = time 1", caller=self.char2) + callback = self.room1.callbacks.get("time")[0] + self.assertEqual(callback.valid, False) + + # char1 will accept the callback + self.call(CmdCallback(), "/accept here = time 1") + callback = self.room1.callbacks.get("time")[0] + self.assertEqual(callback.valid, True)
+ + +
[docs]class TestDefaultCallbacks(BaseEvenniaCommandTest): + + """Test the default callbacks.""" + +
[docs] def setUp(self): + """Create the callback handler.""" + super().setUp() + self.handler = create_script( + "evennia.contrib.base_systems.ingame_python.scripts.EventHandler" + ) + + # Copy old events if necessary + if OLD_EVENTS: + self.handler.ndb.events = dict(OLD_EVENTS) + + # Alter typeclasses + self.char1.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventCharacter" + ) + self.char2.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventCharacter" + ) + self.room1.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventRoom" + ) + self.room2.swap_typeclass( + "evennia.contrib.base_systems.ingame_python.typeclasses.EventRoom" + ) + self.exit.swap_typeclass("evennia.contrib.base_systems.ingame_python.typeclasses.EventExit")
+ +
[docs] def tearDown(self): + """Stop the callback handler.""" + OLD_EVENTS.clear() + OLD_EVENTS.update(self.handler.ndb.events) + self.handler.delete() + CallbackHandler.script = None + super().tearDown()
+ +
[docs] def test_exit(self): + """Test the callbacks of an exit.""" + self.char1.key = "char1" + code = dedent( + """ + if character.key == "char1": + character.msg("You can leave.") + else: + character.msg("You cannot leave.") + deny() + """.strip( + "\n" + ) + ) + # Enforce self.exit.destination since swapping typeclass lose it + self.exit.destination = self.room2 + + # Try the can_traverse callback + self.handler.add_callback(self.exit, "can_traverse", code, author=self.char1, valid=True) + + # Have char1 move through the exit + self.call(ExitCommand(), "", "You can leave.", obj=self.exit) + self.assertIs(self.char1.location, self.room2) + + # Have char2 move through this exit + self.call(ExitCommand(), "", "You cannot leave.", obj=self.exit, caller=self.char2) + self.assertIs(self.char2.location, self.room1) + + # Try the traverse callback + self.handler.del_callback(self.exit, "can_traverse", 0) + self.handler.add_callback( + self.exit, "traverse", "character.msg('Fine!')", author=self.char1, valid=True + ) + + # Have char2 move through the exit + self.call(ExitCommand(), "", obj=self.exit, caller=self.char2) + self.assertIs(self.char2.location, self.room2) + self.handler.del_callback(self.exit, "traverse", 0) + + # Move char1 and char2 back + self.char1.location = self.room1 + self.char2.location = self.room1 + + # Test msg_arrive and msg_leave + code = 'message = "{character} goes out."' + self.handler.add_callback(self.exit, "msg_leave", code, author=self.char1, valid=True) + + # Have char1 move through the exit + old_msg = self.char2.msg + try: + self.char2.msg = Mock() + self.call(ExitCommand(), "", obj=self.exit) + stored_msg = [ + args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs)) + for name, args, kwargs in self.char2.msg.mock_calls + ] + # Get the first element of a tuple if msg received a tuple instead of a string + stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] + returned_msg = ansi.parse_ansi("\n".join(stored_msg), strip_ansi=True) + self.assertEqual(returned_msg, "char1 goes out.") + finally: + self.char2.msg = old_msg + + # Create a return exit + back = create_object( + "evennia.objects.objects.DefaultExit", + key="in", + location=self.room2, + destination=self.room1, + ) + code = 'message = "{character} goes in."' + self.handler.add_callback(self.exit, "msg_arrive", code, author=self.char1, valid=True) + + # Have char1 move through the exit + old_msg = self.char2.msg + try: + self.char2.msg = Mock() + self.call(ExitCommand(), "", obj=back) + stored_msg = [ + args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs)) + for name, args, kwargs in self.char2.msg.mock_calls + ] + # Get the first element of a tuple if msg received a tuple instead of a string + stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] + returned_msg = ansi.parse_ansi("\n".join(stored_msg), strip_ansi=True) + self.assertEqual(returned_msg, "char1 goes in.") + finally: + self.char2.msg = old_msg
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/utils.html b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/utils.html new file mode 100644 index 0000000000..ec974b984c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/ingame_python/utils.html @@ -0,0 +1,368 @@ + + + + + + + + evennia.contrib.base_systems.ingame_python.utils — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.ingame_python.utils

+"""
+Functions to extend the event system.
+
+These functions are to be used by developers to customize events and callbacks.
+
+"""
+
+from django.conf import settings
+
+from evennia import ScriptDB, logger
+from evennia.contrib.base_systems.custom_gametime import UNITS, gametime_to_realtime
+from evennia.contrib.base_systems.custom_gametime import (
+    real_seconds_until as custom_rsu,
+)
+from evennia.utils.create import create_script
+from evennia.utils.gametime import real_seconds_until as standard_rsu
+from evennia.utils.utils import class_from_module
+
+# Temporary storage for events waiting for the script to be started
+EVENTS = []
+
+
+
[docs]def get_event_handler(): + """Return the event handler or None.""" + try: + script = ScriptDB.objects.get(db_key="event_handler") + except ScriptDB.DoesNotExist: + logger.log_trace("Can't get the event handler.") + script = None + + return script
+ + +
[docs]def register_events(path_or_typeclass): + """ + Register the events in this typeclass. + + Args: + path_or_typeclass (str or type): the Python path leading to the + class containing events, or the class itself. + + Returns: + The typeclass itself. + + Notes: + This function will read events from the `_events` class variable + defined in the typeclass given in parameters. It will add + the events, either to the script if it exists, or to some + temporary storage, waiting for the script to be initialized. + + """ + if isinstance(path_or_typeclass, str): + typeclass = class_from_module(path_or_typeclass) + else: + typeclass = path_or_typeclass + + typeclass_name = typeclass.__module__ + "." + typeclass.__name__ + try: + storage = ScriptDB.objects.get(db_key="event_handler") + assert storage.ndb.events is not None + except (ScriptDB.DoesNotExist, AssertionError): + storage = EVENTS + + # If the script is started, add the event directly. + # Otherwise, add it to the temporary storage. + for name, tup in getattr(typeclass, "_events", {}).items(): + if len(tup) == 4: + variables, help_text, custom_call, custom_add = tup + elif len(tup) == 3: + variables, help_text, custom_call = tup + custom_add = None + elif len(tup) == 2: + variables, help_text = tup + custom_call = None + custom_add = None + else: + variables = help_text = custom_call = custom_add = None + + if isinstance(storage, list): + storage.append((typeclass_name, name, variables, help_text, custom_call, custom_add)) + else: + storage.add_event(typeclass_name, name, variables, help_text, custom_call, custom_add) + + return typeclass
+ + +# Custom callbacks for specific event types + + +
[docs]def get_next_wait(format): + """ + Get the length of time in seconds before format. + + Args: + format (str): a time format matching the set calendar. + + Returns: + until (int or float): the number of seconds until the event. + usual (int or float): the usual number of seconds between events. + format (str): a string format representing the time. + + Notes: + The time format could be something like "2018-01-08 12:00". The + number of units set in the calendar affects the way seconds are + calculated. + + """ + calendar = getattr(settings, "EVENTS_CALENDAR", None) + if calendar is None: + logger.log_err( + "A time-related event has been set whereas " + "the gametime calendar has not been set in the settings." + ) + return + elif calendar == "standard": + rsu = standard_rsu + units = ["min", "hour", "day", "month", "year"] + elif calendar == "custom": + rsu = custom_rsu + back = dict([(value, name) for name, value in UNITS.items()]) + sorted_units = sorted(back.items()) + del sorted_units[0] + units = [n for v, n in sorted_units] + + params = {} + for delimiter in ("-", ":"): + format = format.replace(delimiter, " ") + + pieces = list(reversed(format.split())) + details = [] + i = 0 + for uname in units: + try: + piece = pieces[i] + except IndexError: + break + + if not piece.isdigit(): + logger.log_trace( + "The time specified '{}' in {} isn't " "a valid number".format(piece, format) + ) + return + + # Convert the piece to int + piece = int(piece) + params[uname] = piece + details.append("{}={}".format(uname, piece)) + if i < len(units): + next_unit = units[i + 1] + else: + next_unit = None + i += 1 + + params["sec"] = 0 + details = " ".join(details) + until = rsu(**params) + usual = -1 + if next_unit: + kwargs = {next_unit: 1} + usual = gametime_to_realtime(**kwargs) + return until, usual, details
+ + +
[docs]def time_event(obj, event_name, number, parameters): + """ + Create a time-related event. + + Args: + obj (Object): the object on which sits the event. + event_name (str): the event's name. + number (int): the number of the event. + parameters (str): the parameter of the event. + + """ + seconds, usual, key = get_next_wait(parameters) + script = create_script( + "evennia.contrib.base_systems.ingame_python.scripts.TimeEventScript", + interval=seconds, + obj=obj, + ) + script.key = key + script.desc = "event on {}".format(key) + script.db.time_format = parameters + script.db.number = number + script.ndb.usual = usual
+ + +
[docs]def keyword_event(callbacks, parameters): + """ + Custom call for events with keywords (like push, or pull, or turn...). + + Args: + callbacks (list of dict): the list of callbacks to be called. + parameters (str): the actual parameters entered to trigger the callback. + + Returns: + A list containing the callback dictionaries to be called. + + Notes: + This function should be imported and added as a custom_call + parameter to add the event when the event supports keywords + as parameters. Keywords in parameters are one or more words + separated by a comma. For instance, a 'push 1, one' callback can + be set to trigger when the player 'push 1' or 'push one'. + + """ + key = parameters.strip().lower() + to_call = [] + for callback in callbacks: + keys = callback["parameters"] + if not keys or key in [p.strip().lower() for p in keys.split(",")]: + to_call.append(callback) + + return to_call
+ + +
[docs]def phrase_event(callbacks, parameters): + """ + Custom call for events with keywords in sentences (like say or whisper). + + Args: + callbacks (list of dict): the list of callbacks to be called. + parameters (str): the actual parameters entered to trigger the callback. + + Returns: + A list containing the callback dictionaries to be called. + + Notes: + This function should be imported and added as a custom_call + parameter to add the event when the event supports keywords + in phrases as parameters. Keywords in parameters are one or more + words separated by a comma. For instance, a 'say yes, okay' callback + can be set to trigger when the player says something containing + either "yes" or "okay" (maybe 'say I don't like it, but okay'). + + """ + phrase = parameters.strip().lower() + # Remove punctuation marks + punctuations = ',.";?!' + for p in punctuations: + phrase = phrase.replace(p, " ") + words = phrase.split() + words = [w.strip("' ") for w in words if w.strip("' ")] + to_call = [] + for callback in callbacks: + keys = callback["parameters"] + if not keys or any(key.strip().lower() in words for key in keys.split(",")): + to_call.append(callback) + + return to_call
+ + +
[docs]class InterruptEvent(RuntimeError): + + """ + Interrupt the current event. + + You shouldn't have to use this exception directly, probably use the + `deny()` function that handles it instead. + + """ + + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/mux_comms_cmds/mux_comms_cmds.html b/docs/latest/_modules/evennia/contrib/base_systems/mux_comms_cmds/mux_comms_cmds.html new file mode 100644 index 0000000000..6b582f5bc2 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/mux_comms_cmds/mux_comms_cmds.html @@ -0,0 +1,650 @@ + + + + + + + + evennia.contrib.base_systems.mux_comms_cmds.mux_comms_cmds — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.mux_comms_cmds.mux_comms_cmds

+"""
+Legacy Comms-commands
+
+Griatch 2021
+
+In Evennia 1.0, the old Channel commands (originally inspired by MUX) were
+replaced by the single `channel` command that performs all these function.
+That command is still required to talk on channels. This contrib (extracted
+from Evennia 0.9.5) reuses the channel-management of the base Channel command
+but breaks out its functionality into separate Commands with MUX-familiar names.
+
+- `allcom` - `channel/all` and `channel`
+- `addcom` - `channel/alias`, `channel/sub` and `channel/unmute`
+- `delcom` - `channel/unalias`, `alias/unsub` and `channel/mute`
+- `cboot` - `channel/boot` (`channel/ban` and `/unban` not supported)
+- `cwho` - `channel/who`
+- `ccreate` - `channel/create`
+- `cdestroy` - `channel/destroy`
+- `clock` - `channel/lock`
+- `cdesc` - `channel/desc`
+
+Installation:
+
+- Import the `CmdSetLegacyComms` cmdset from this module into `mygame/commands/default_cmdsets.py`
+- Add it to the CharacterCmdSet's `at_cmdset_creation` method.
+- Reload the server.
+
+Example:
+
+```python
+# in mygame/commands/default_cmdsets.py
+
+# ..
+from evennia.contrib.base_systems.mux_comms_cmds import CmdSetLegacyComms   # <----
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(CmdSetLegacyComms)   # <----
+```
+
+"""
+from django.conf import settings
+
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.default.comms import CmdChannel
+from evennia.utils import logger
+
+CHANNEL_DEFAULT_TYPECLASS = settings.BASE_CHANNEL_TYPECLASS
+
+
+
[docs]class CmdAddCom(CmdChannel): + """ + Add a channel alias and/or subscribe to a channel + + Usage: + addcom [alias=] <channel> + + Joins a given channel. If alias is given, this will allow you to + refer to the channel by this alias rather than the full channel + name. Subsequent calls of this command can be used to add multiple + aliases to an already joined channel. + """ + + key = "addcom" + aliases = ["aliaschan", "chanalias"] + help_category = "Comms" + locks = "cmd:not pperm(channel_banned)" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """Implement the command""" + + caller = self.caller + args = self.args + + if not args: + self.msg("Usage: addcom [alias =] channelname.") + return + + if self.rhs: + # rhs holds the channelname + channelname = self.rhs + alias = self.lhs + else: + channelname = self.args + alias = None + + channel = self.search_channel(channelname) + if not channel: + return + + string = "" + if not channel.has_connection(caller): + # we want to connect as well. + success, err = self.sub_to_channel(channel) + if success: + # if this would have returned True, the account is connected + self.msg(f"You now listen to the channel {channel.key}") + else: + self.msg(f"{channel.key}: You are not allowed to join this channel.") + return + + if channel.unmute(caller): + self.msg(f"You unmute channel {channel.key}.") + else: + self.msg(f"You are already connected to channel {channel.key}.") + + if alias: + # create a nick and add it to the caller. + self.add_alias(channel, alias) + self.msg(f" You can now refer to the channel {channel} with the alias '{alias}'.") + else: + string += " No alias added." + self.msg(string)
+ + +
[docs]class CmdDelCom(CmdChannel): + """ + remove a channel alias and/or unsubscribe from channel + + Usage: + delcom <alias or channel> + delcom/all <channel> + + If the full channel name is given, unsubscribe from the + channel. If an alias is given, remove the alias but don't + unsubscribe. If the 'all' switch is used, remove all aliases + for that channel. + """ + + key = "delcom" + aliases = ["delaliaschan", "delchanalias"] + help_category = "Comms" + locks = "cmd:not perm(channel_banned)" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """Implementing the command.""" + + caller = self.caller + + if not self.args: + self.msg("Usage: delcom <alias or channel>") + return + ostring = self.args.lower().strip() + + channel = self.search_channel(ostring) + if not channel: + return + + if not channel.has_connection(caller): + self.msg("You are not listening to that channel.") + return + + if ostring == channel.key.lower(): + # an exact channel name - unsubscribe + delnicks = "all" in self.switches + # find all nicks linked to this channel and delete them + if delnicks: + aliases = self.get_channel_aliases(channel) + for alias in aliases: + self.remove_alias(alias) + success, err = self.unsub_from_channel(channel) + if success: + wipednicks = " Eventual aliases were removed." if delnicks else "" + self.msg(f"You stop listening to channel '{channel.key}'.{wipednicks}") + else: + self.msg(err) + return + else: + # we are removing a channel nick + self.remove_alias(ostring) + self.msg(f"Any alias '{ostring}' for channel {channel.key} was cleared.")
+ + +
[docs]class CmdAllCom(CmdChannel): + """ + perform admin operations on all channels + + Usage: + allcom [on | off | who | destroy] + + Allows the user to universally turn off or on all channels they are on, as + well as perform a 'who' for all channels they are on. Destroy deletes all + channels that you control. + + Without argument, works like comlist. + """ + + key = "allcom" + aliases = [] # important to not inherit parent's aliases + locks = "cmd: not pperm(channel_banned)" + help_category = "Comms" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """Runs the function""" + + caller = self.caller + args = self.args + if not args: + subscribed, available = self.list_channels() + self.msg("\n|wAvailable channels:\n{table}") + return + return + + if args == "on": + # get names of all channels available to listen to + # and activate them all + channels = [ + chan + for chan in CHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels() + if chan.access(caller, "listen") + ] + for channel in channels: + self.execute_cmd("addcom %s" % channel.key) + elif args == "off": + # get names all subscribed channels and disconnect from them all + channels = CHANNEL_DEFAULT_TYPECLASS.objects.get_subscriptions(caller) + for channel in channels: + self.execute_cmd("delcom %s" % channel.key) + elif args == "destroy": + # destroy all channels you control + channels = [ + chan + for chan in CHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels() + if chan.access(caller, "control") + ] + for channel in channels: + self.execute_cmd("cdestroy %s" % channel.key) + elif args == "who": + # run a who, listing the subscribers on visible channels. + string = "\n|CChannel subscriptions|n" + channels = [ + chan + for chan in CHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels() + if chan.access(caller, "listen") + ] + if not channels: + string += "No channels." + for channel in channels: + string += "\n|w%s:|n\n %s" % (channel.key, channel.wholist) + self.msg(string.strip()) + else: + # wrong input + self.msg("Usage: allcom on | off | who | clear")
+ + +
[docs]class CmdCdestroy(CmdChannel): + """ + destroy a channel you created + + Usage: + cdestroy <channel> + + Destroys a channel that you control. + """ + + key = "cdestroy" + aliases = [] + help_category = "Comms" + locks = "cmd: not pperm(channel_banned)" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """Destroy objects cleanly.""" + + caller = self.caller + + if not self.args: + self.msg("Usage: cdestroy <channelname>") + return + + channel = self.search_channel(self.args) + + if not channel: + self.msg("Could not find channel %s." % self.args) + return + if not channel.access(caller, "control"): + self.msg("You are not allowed to do that.") + return + channel_key = channel.key + message = f"{channel.key} is being destroyed. Make sure to change your aliases." + self.destroy_channel(channel, message) + self.msg("Channel '%s' was destroyed." % channel_key) + logger.log_sec( + "Channel Deleted: %s (Caller: %s, IP: %s)." + % (channel_key, caller, self.session.address) + )
+ + +
[docs]class CmdCBoot(CmdChannel): + """ + kick an account from a channel you control + + Usage: + cboot[/quiet] <channel> = <account> [:reason] + + Switch: + quiet - don't notify the channel + + Kicks an account or object from a channel you control. + + """ + + key = "cboot" + aliases = [] + switch_options = ("quiet",) + locks = "cmd: not pperm(channel_banned)" + help_category = "Comms" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """implement the function""" + + if not self.args or not self.rhs: + string = "Usage: cboot[/quiet] <channel> = <account> [:reason]" + self.msg(string) + return + + channel = self.search_channel(self.lhs) + if not channel: + return + + reason = "" + if ":" in self.rhs: + target, reason = self.rhs.rsplit(":", 1) + is_account = target.strip().startswith("*") + searchstring = target.lstrip("*") + else: + is_account = target.strip().startswith("*") + searchstring = self.rhs.lstrip("*") + + target = self.caller.search(searchstring, account=is_account) + if not target: + return + if reason: + reason = " (reason: %s)" % reason + if not channel.access(self.caller, "control"): + string = "You don't control this channel." + self.msg(string) + return + + success, err = self.boot_user(target, quiet="quiet" in self.switches) + if success: + self.msg(f"Booted {target.key} from {channel.key}") + logger.log_sec( + "Channel Boot: %s (Channel: %s, Reason: %s, Caller: %s, IP: %s)." + % (self.caller, channel, reason, self.caller, self.session.address) + ) + else: + self.msg(err)
+ + +
[docs]class CmdCWho(CmdChannel): + """ + show who is listening to a channel + + Usage: + cwho <channel> + + List who is connected to a given channel you have access to. + """ + + key = "cwho" + aliases = [] + locks = "cmd: not pperm(channel_banned)" + help_category = "Comms" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """implement function""" + + if not self.args: + string = "Usage: cwho <channel>" + self.msg(string) + return + + channel = self.search_channel(self.lhs) + if not channel: + return + if not channel.access(self.caller, "listen"): + string = "You can't access this channel." + self.msg(string) + return + string = "\n|CChannel subscriptions|n" + string += "\n|w%s:|n\n %s" % (channel.key, channel.wholist) + self.msg(string.strip())
+ + +
[docs]class CmdChannelCreate(CmdChannel): + """ + create a new channel + + Usage: + ccreate <new channel>[;alias;alias...] = description + + Creates a new channel owned by you. + """ + + key = "ccreate" + aliases = "channelcreate" + locks = "cmd:not pperm(channel_banned) and pperm(Player)" + help_category = "Comms" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """Implement the command""" + + if not self.args: + self.msg("Usage ccreate <channelname>[;alias;alias..] = description") + return + + description = "" + + if self.rhs: + description = self.rhs + lhs = self.lhs + channame = lhs + aliases = None + if ";" in lhs: + channame, aliases = lhs.split(";", 1) + aliases = [alias.strip().lower() for alias in aliases.split(";")] + + new_chan, err = self.create_channel(channame, description, aliases=aliases) + if new_chan: + self.msg(f"Created channel {new_chan.key} and connected to it.") + else: + self.msg(err)
+ + +
[docs]class CmdClock(CmdChannel): + """ + change channel locks of a channel you control + + Usage: + clock <channel> [= <lockstring>] + + Changes the lock access restrictions of a channel. If no + lockstring was given, view the current lock definitions. + """ + + key = "clock" + aliases = ["clock"] + locks = "cmd:not pperm(channel_banned) and perm(Admin)" + help_category = "Comms" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """run the function""" + + if not self.args: + string = "Usage: clock channel [= lockstring]" + self.msg(string) + return + + channel = self.search_channel(self.lhs) + if not channel: + return + + if not self.rhs: + # no =, so just view the current locks + self.msg(f"Current locks on {channel.key}\n{channel.locks}") + return + # we want to add/change a lock. + if not channel.access(self.caller, "control"): + string = "You don't control this channel." + self.msg(string) + return + # Try to add the lock + success, err = self.set_lock(channel, self.rhs) + if success: + self.msg(f"Lock(s) applied. Current locks on {channel.key}:\n{channel.locks}") + else: + self.msg(err)
+ + +
[docs]class CmdCdesc(CmdChannel): + """ + describe a channel you control + + Usage: + cdesc <channel> = <description> + + Changes the description of the channel as shown in + channel lists. + + """ + + key = "cdesc" + aliases = [] + locks = "cmd:not pperm(channel_banned)" + help_category = "Comms" + + # this is used by the COMMAND_DEFAULT_CLASS parent + account_caller = True + +
[docs] def func(self): + """Implement command""" + + caller = self.caller + + if not self.rhs: + self.msg("Usage: cdesc <channel> = <description>") + return + channel = self.search_channel(self.lhs) + if not channel: + return + # check permissions + if not channel.access(caller, "control"): + self.msg("You cannot admin this channel.") + return + self.set_desc(channel, self.rhs) + self.msg(f"Description of channel '{channel.key}' set to '{self.rhs}'.")
+ + +
[docs]class CmdSetLegacyComms(CmdSet): +
[docs] def at_cmdset_createion(self): + self.add(CmdAddCom()) + self.add(CmdAllCom()) + self.add(CmdDelCom()) + self.add(CmdCdestroy()) + self.add(CmdCBoot()) + self.add(CmdCWho()) + self.add(CmdChannelCreate()) + self.add(CmdClock()) + self.add(CmdCdesc())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/mux_comms_cmds/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/mux_comms_cmds/tests.html new file mode 100644 index 0000000000..2f5c798e80 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/mux_comms_cmds/tests.html @@ -0,0 +1,192 @@ + + + + + + + + evennia.contrib.base_systems.mux_comms_cmds.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.mux_comms_cmds.tests

+"""
+Legacy Mux comms tests (extracted from 0.9.5)
+
+"""
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+
+from . import mux_comms_cmds as comms
+
+
+
[docs]class TestLegacyMuxComms(BaseEvenniaCommandTest): + """ + Test the legacy comms contrib. + """ + +
[docs] def setUp(self): + super().setUp() + self.call( + comms.CmdChannelCreate(), + "testchan;test=Test Channel", + "Created channel testchan and connected to it.", + receiver=self.account, + )
+ +
[docs] def test_toggle_com(self): + self.call( + comms.CmdAddCom(), + "tc = testchan", + "You are already connected to channel testchan.| You can now", + receiver=self.account, + ) + self.call( + comms.CmdDelCom(), + "tc", + "Any alias 'tc' for channel testchan was cleared.", + receiver=self.account, + )
+ +
[docs] def test_all_com(self): + self.call( + comms.CmdAllCom(), + "", + "Available channels:", + receiver=self.account, + )
+ +
[docs] def test_clock(self): + self.call( + comms.CmdClock(), + "testchan=send:all()", + "Lock(s) applied. Current locks on testchan:", + receiver=self.account, + )
+ +
[docs] def test_cdesc(self): + self.call( + comms.CmdCdesc(), + "testchan = Test Channel", + "Description of channel 'testchan' set to 'Test Channel'.", + receiver=self.account, + )
+ +
[docs] def test_cwho(self): + self.call( + comms.CmdCWho(), + "testchan", + "Channel subscriptions\ntestchan:\n TestAccount", + receiver=self.account, + )
+ +
[docs] def test_cboot(self): + # No one else connected to boot + self.call( + comms.CmdCBoot(), + "", + "Usage: cboot[/quiet] <channel> = <account> [:reason]", + receiver=self.account, + )
+ +
[docs] def test_cdestroy(self): + self.call( + comms.CmdCdestroy(), + "testchan", + "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases." + "|Channel 'testchan' was destroyed.", + receiver=self.account, + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/unixcommand/tests.html b/docs/latest/_modules/evennia/contrib/base_systems/unixcommand/tests.html new file mode 100644 index 0000000000..90557ff742 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/unixcommand/tests.html @@ -0,0 +1,156 @@ + + + + + + + + evennia.contrib.base_systems.unixcommand.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.unixcommand.tests

+"""
+Test of the Unixcommand.
+
+"""
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+
+from .unixcommand import UnixCommand
+
+
+
[docs]class CmdDummy(UnixCommand): + + """A dummy UnixCommand.""" + + key = "dummy" + +
[docs] def init_parser(self): + """Fill out options.""" + self.parser.add_argument("nb1", type=int, help="the first number") + self.parser.add_argument("nb2", type=int, help="the second number") + self.parser.add_argument("-v", "--verbose", action="store_true")
+ +
[docs] def func(self): + nb1 = self.opts.nb1 + nb2 = self.opts.nb2 + result = nb1 * nb2 + verbose = self.opts.verbose + if verbose: + self.msg("{} times {} is {}".format(nb1, nb2, result)) + else: + self.msg("{} * {} = {}".format(nb1, nb2, result))
+ + +
[docs]class TestUnixCommand(BaseEvenniaCommandTest): +
[docs] def test_success(self): + """See the command parsing succeed.""" + self.call(CmdDummy(), "5 10", "5 * 10 = 50") + self.call(CmdDummy(), "5 10 -v", "5 times 10 is 50")
+ +
[docs] def test_failure(self): + """If not provided with the right info, should fail.""" + ret = self.call(CmdDummy(), "5") + lines = ret.splitlines() + self.assertTrue(any(lin.startswith("usage:") for lin in lines)) + self.assertTrue(any(lin.startswith("dummy: error:") for lin in lines)) + + # If we specify an incorrect number as parameter + ret = self.call(CmdDummy(), "five ten") + lines = ret.splitlines() + self.assertTrue(any(lin.startswith("usage:") for lin in lines)) + self.assertTrue(any(lin.startswith("dummy: error:") for lin in lines))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/base_systems/unixcommand/unixcommand.html b/docs/latest/_modules/evennia/contrib/base_systems/unixcommand/unixcommand.html new file mode 100644 index 0000000000..245da1d1dd --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/base_systems/unixcommand/unixcommand.html @@ -0,0 +1,403 @@ + + + + + + + + evennia.contrib.base_systems.unixcommand.unixcommand — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.base_systems.unixcommand.unixcommand

+"""
+Unix-like Command style parent
+
+Evennia contribution, Vincent Le Geoff 2017
+
+This module contains a command class that allows for unix-style command syntax in-game, using
+--options, positional arguments and stuff like -n 10 etc similarly to a unix command. It might not
+the best syntax for the average player but can be really useful for builders when they need to have
+a single command do many things with many options. It uses the ArgumentParser from Python's standard
+library under the hood.
+
+To use, inherit `UnixCommand` from this module from your own commands. You need
+to override two methods:
+
+- The `init_parser` method, which adds options to the parser. Note that you should normally
+    *not* override the normal `parse` method when inheriting from `UnixCommand`.
+- The `func` method, called to execute the command once parsed (like any Command).
+
+Here's a short example:
+
+```python
+from evennia.contrib.base_systems.unixcommand import UnixCommand
+
+
+class CmdPlant(UnixCommand):
+
+    '''
+    Plant a tree or plant.
+
+    This command is used to plant something in the room you are in.
+
+    Examples:
+      plant orange -a 8
+      plant strawberry --hidden
+      plant potato --hidden --age 5
+
+    '''
+
+    key = "plant"
+
+    def init_parser(self):
+        "Add the arguments to the parser."
+        # 'self.parser' inherits `argparse.ArgumentParser`
+        self.parser.add_argument("key",
+                help="the key of the plant to be planted here")
+        self.parser.add_argument("-a", "--age", type=int,
+                default=1, help="the age of the plant to be planted")
+        self.parser.add_argument("--hidden", action="store_true",
+                help="should the newly-planted plant be hidden to players?")
+
+    def func(self):
+        "func is called only if the parser succeeded."
+        # 'self.opts' contains the parsed options
+        key = self.opts.key
+        age = self.opts.age
+        hidden = self.opts.hidden
+        self.msg("Going to plant '{}', age={}, hidden={}.".format(
+                key, age, hidden))
+```
+
+To see the full power of argparse and the types of supported options, visit
+[the documentation of argparse](https://docs.python.org/2/library/argparse.html).
+
+"""
+
+import argparse
+import shlex
+from textwrap import dedent
+
+from evennia import Command, InterruptCommand
+from evennia.utils.ansi import raw
+
+
+
[docs]class ParseError(Exception): + + """An error occurred during parsing.""" + + pass
+ + +
[docs]class UnixCommandParser(argparse.ArgumentParser): + + """A modifier command parser for unix commands. + + This parser is used to replace `argparse.ArgumentParser`. It + is aware of the command calling it, and can more easily report to + the caller. Some features (like the "brutal exit" of the original + parser) are disabled or replaced. This parser is used by UnixCommand + and creating one directly isn't recommended nor necessary. Even + adding a sub-command will use this replaced parser automatically. + + """ + +
[docs] def __init__(self, prog, description="", epilog="", command=None, **kwargs): + """ + Build a UnixCommandParser with a link to the command using it. + + Args: + prog (str): the program name (usually the command key). + description (str): a very brief line to show in the usage text. + epilog (str): the epilog to show below options. + command (Command): the command calling the parser. + + Keyword Args: + Additional keyword arguments are directly sent to + `argparse.ArgumentParser`. You will find them on the + [parser's documentation](https://docs.python.org/2/library/argparse.html). + + Note: + It's doubtful you would need to create this parser manually. + The `UnixCommand` does that automatically. If you create + sub-commands, this class will be used. + + """ + prog = prog or command.key + super().__init__( + prog=prog, description=description, conflict_handler="resolve", add_help=False, **kwargs + ) + self.command = command + self.post_help = epilog + + def n_exit(code=None, msg=None): + raise ParseError(msg) + + self.exit = n_exit + + # Replace the -h/--help + self.add_argument( + "-h", "--help", nargs=0, action=HelpAction, help="display the command help" + )
+ +
[docs] def format_usage(self): + """Return the usage line. + + Note: + This method is present to return the raw-escaped usage line, + in order to avoid unintentional color codes. + + """ + return raw(super().format_usage())
+ +
[docs] def format_help(self): + """Return the parser help, including its epilog. + + Note: + This method is present to return the raw-escaped help, + in order to avoid unintentional color codes. Color codes + in the epilog (the command docstring) are supported. + + """ + autohelp = raw(super().format_help()) + return "\n" + autohelp + "\n" + self.post_help
+ +
[docs] def print_usage(self, file=None): + """Print the usage to the caller. + + Args: + file (file-object): not used here, the caller is used. + + Note: + This method will override `argparse.ArgumentParser`'s in order + to not display the help on stdout or stderr, but to the + command's caller. + + """ + if self.command: + self.command.msg(self.format_usage().strip())
+ +
[docs] def print_help(self, file=None): + """Print the help to the caller. + + Args: + file (file-object): not used here, the caller is used. + + Note: + This method will override `argparse.ArgumentParser`'s in order + to not display the help on stdout or stderr, but to the + command's caller. + + """ + if self.command: + self.command.msg(self.format_help().strip())
+ + +
[docs]class HelpAction(argparse.Action): + + """Override the -h/--help action in the default parser. + + Using the default -h/--help will call the exit function in different + ways, preventing the entire help message to be provided. Hence + this override. + + """ + + def __call__(self, parser, namespace, values, option_string=None): + """If asked for help, display to the caller.""" + if parser.command: + parser.command.msg(parser.format_help().strip()) + parser.exit(0, "")
+ + +
[docs]class UnixCommand(Command): + """ + Unix-type commands, supporting short and long options. + + This command syntax uses the Unix-style commands with short options + (-X) and long options (--something). The `argparse` module is + used to parse the command. + + In order to use it, you should override two methods: + - `init_parser`: this method is called when the command is created. + It can be used to set options in the parser. `self.parser` + contains the `argparse.ArgumentParser`, so you can add arguments + here. + - `func`: this method is called to execute the command, but after + the parser has checked the arguments given to it are valid. + You can access the namespace of valid arguments in `self.opts` + at this point. + + The help of UnixCommands is derived from the docstring, in a + slightly different way than usual: the first line of the docstring + is used to represent the program description (the very short + line at the top of the help message). The other lines below are + used as the program's "epilog", displayed below the options. It + means in your docstring, you don't have to write the options. + They will be automatically provided by the parser and displayed + accordingly. The `argparse` module provides a default '-h' or + '--help' option on the command. Typing |whelp commandname|n will + display the same as |wcommandname -h|n, though this behavior can + be changed. + + """ + +
[docs] def __init__(self, **kwargs): + """ + The lockhandler works the same as for objects. + optional kwargs will be set as properties on the Command at runtime, + overloading evential same-named class properties. + + """ + super().__init__(**kwargs) + + # Create the empty UnixCommandParser, inheriting argparse.ArgumentParser + lines = dedent(self.__doc__.strip("\n")).splitlines() + description = lines[0].strip() + epilog = "\n".join(lines[1:]).strip() + self.parser = UnixCommandParser(None, description, epilog, command=self) + + # Fill the argument parser + self.init_parser()
+ +
[docs] def init_parser(self): + """ + Configure the argument parser, adding in options. + + Note: + This method is to be overridden in order to add options + to the argument parser. Use `self.parser`, which contains + the `argparse.ArgumentParser`. You can, for instance, + use its `add_argument` method. + + """ + pass
+ +
[docs] def func(self): + """Override to handle the command execution.""" + pass
+ +
[docs] def get_help(self, caller, cmdset): + """ + Return the help message for this command and this caller. + + Args: + caller (Object or Player): the caller asking for help on the command. + cmdset (CmdSet): the command set (if you need additional commands). + + Returns: + docstring (str): the help text to provide the caller for this command. + + """ + return self.parser.format_help()
+ +
[docs] def parse(self): + """ + Process arguments provided in `self.args`. + + Note: + You should not override this method. Consider overriding + `init_parser` instead. + + """ + try: + self.opts = self.parser.parse_args(shlex.split(self.args)) + except ParseError as err: + msg = str(err) + if msg: + self.msg(msg) + raise InterruptCommand
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/full_systems/evscaperoom/commands.html b/docs/latest/_modules/evennia/contrib/full_systems/evscaperoom/commands.html new file mode 100644 index 0000000000..9f1705f4e4 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/full_systems/evscaperoom/commands.html @@ -0,0 +1,891 @@ + + + + + + + + evennia.contrib.full_systems.evscaperoom.commands — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.full_systems.evscaperoom.commands

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

Source code for evennia.contrib.full_systems.evscaperoom.menu

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

Source code for evennia.contrib.full_systems.evscaperoom.objects

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

Source code for evennia.contrib.full_systems.evscaperoom.room

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

Source code for evennia.contrib.full_systems.evscaperoom.scripts

+"""
+A simple cleanup script to wipe empty rooms
+
+(This can happen if users leave 'uncleanly', such as by closing their browser
+window)
+
+Just start this global script manually or at server creation.
+"""
+
+from evennia import DefaultScript
+
+from .room import EvscapeRoom
+
+
+
[docs]class CleanupScript(DefaultScript): +
[docs] def at_script_creation(self): + self.key = "evscaperoom_cleanup" + self.desc = "Cleans up empty evscaperooms" + + self.interval = 60 * 15 + + self.persistent = True
+ +
[docs] def at_repeat(self): + for room in EvscapeRoom.objects.all(): + if not room.get_all_characters(): + # this room is empty + room.log("END: Room cleaned by garbage collector.") + room.delete()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/full_systems/evscaperoom/state.html b/docs/latest/_modules/evennia/contrib/full_systems/evscaperoom/state.html new file mode 100644 index 0000000000..f81bdf9888 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/full_systems/evscaperoom/state.html @@ -0,0 +1,414 @@ + + + + + + + + evennia.contrib.full_systems.evscaperoom.state — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.full_systems.evscaperoom.state

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

Source code for evennia.contrib.full_systems.evscaperoom.tests

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

Source code for evennia.contrib.full_systems.evscaperoom.utils

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

Source code for evennia.contrib.game_systems.barter.barter

+"""
+Barter system
+
+Evennia contribution - Griatch 2012
+
+
+This implements a full barter system - a way for players to safely
+trade items between each other using code rather than simple free-form
+talking.  The advantage of this is increased buy/sell safety but it
+also streamlines the process and makes it faster when doing many
+transactions (since goods are automatically exchanged once both
+agree).
+
+This system is primarily intended for a barter economy, but can easily
+be used in a monetary economy as well -- just let the "goods" on one
+side be coin objects (this is more flexible than a simple "buy"
+command since you can mix coins and goods in your trade).
+
+In this module, a "barter" is generally referred to as a "trade".
+
+
+- Trade example
+
+A trade (barter) action works like this: A and B are the parties.
+
+1) opening a trade
+
+A: trade B: Hi, I have a nice extra sword. You wanna trade?
+B sees: A says: "Hi, I have a nice extra sword. You wanna trade?"
+   A wants to trade with you. Enter 'trade A <emote>' to accept.
+B: trade A: Hm, I could use a good sword ...
+A sees: B says: "Hm, I could use a good sword ...
+   B accepts the trade. Use 'trade help' for aid.
+B sees: You are now trading with A. Use 'trade help' for aid.
+
+2) negotiating
+
+A: offer sword: This is a nice sword. I would need some rations in trade.
+B sees: A says: "This is a nice sword. I would need some rations in trade."
+   [A offers Sword of might.]
+B evaluate sword
+B sees: <Sword's description and possibly stats>
+B: offer ration: This is a prime ration.
+A sees: B says: "This is a prime ration."
+  [B offers iron ration]
+A: say Hey, this is a nice sword, I need something more for it.
+B sees: A says: "Hey this is a nice sword, I need something more for it."
+B: offer sword,apple: Alright. I will also include a magic apple. That's my last offer.
+A sees: B says: "Alright, I will also include a magic apple. That's my last offer."
+  [B offers iron ration and magic apple]
+A accept: You are killing me here, but alright.
+B sees: A says: "You are killing me here, but alright."
+  [A accepts your offer. You must now also accept.]
+B accept: Good, nice making business with you.
+  You accept the deal. Deal is made and goods changed hands.
+A sees: B says: "Good, nice making business with you."
+  B accepts the deal. Deal is made and goods changed hands.
+
+At this point the trading system is exited and the negotiated items
+are automatically exchanged between the parties. In this example B was
+the only one changing their offer, but also A could have changed their
+offer until the two parties found something they could agree on. The
+emotes are optional but useful for RP-heavy worlds.
+
+- Technical info
+
+The trade is implemented by use of a TradeHandler. This object is a
+common place for storing the current status of negotiations. It is
+created on the object initiating the trade, and also stored on the
+other party once that party agrees to trade. The trade request times
+out after a certain time - this is handled by a Script. Once trade
+starts, the CmdsetTrade cmdset is initiated on both parties along with
+the commands relevant for the trading.
+
+- Ideas for NPC bartering:
+
+This module is primarily intended for trade between two players. But
+it can also in principle be used for a player negotiating with an
+AI-controlled NPC. If the NPC uses normal commands they can use it
+directly -- but more efficient is to have the NPC object send its
+replies directly through the tradehandler to the player. One may want
+to add some functionality to the decline command, so players can
+decline specific objects in the NPC offer (decline <object>) and allow
+the AI to maybe offer something else and make it into a proper
+barter.  Along with an AI that "needs" things or has some sort of
+personality in the trading, this can make bartering with NPCs at least
+moderately more interesting than just plain 'buy'.
+
+- Installation:
+
+Just import the CmdTrade command into (for example) the default
+cmdset. This will make the trade (or barter) command available
+in-game.
+
+"""
+
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.command import Command
+from evennia.scripts.scripts import DefaultScript
+
+TRADE_TIMEOUT = 60  # timeout for B to accept trade
+
+
+
[docs]class TradeTimeout(DefaultScript): + """ + This times out the trade request, in case player B did not reply in time. + """ + +
[docs] def at_script_creation(self): + """ + Called when script is first created + """ + self.key = "trade_request_timeout" + self.desc = "times out trade requests" + self.interval = TRADE_TIMEOUT + self.start_delay = True + self.repeats = 1 + self.persistent = False
+ +
[docs] def at_repeat(self): + """ + called once + """ + if self.ndb.tradeevent: + self.obj.ndb.tradeevent.finish(force=True) + self.obj.msg("Trade request timed out.")
+ +
[docs] def is_valid(self): + """ + Only valid if the trade has not yet started + """ + return self.obj.ndb.tradeevent and not self.obj.ndb.tradeevent.trade_started
+ + +
[docs]class TradeHandler(object): + """ + Objects of this class handles the ongoing trade, notably storing the current + offers from each side and wether both have accepted or not. + """ + +
[docs] def __init__(self, part_a, part_b): + """ + Initializes the trade. This is called when part A tries to + initiate a trade with part B. The trade will not start until + part B repeats this command (B will then call the self.join() + command) + + Args: + part_a (object): The party trying to start barter. + part_b (object): The party asked to barter. + + Notes: + We also store the back-reference from the respective party + to this object. + + """ + # parties + self.part_a = part_a + self.part_b = part_b + + self.part_a.cmdset.add(CmdsetTrade()) + self.trade_started = False + self.part_a.ndb.tradehandler = self + # trade variables + self.part_a_offers = [] + self.part_b_offers = [] + self.part_a_accepted = False + self.part_b_accepted = False
+ +
[docs] def msg_other(self, sender, string): + """ + Relay a message to the *other* party without needing to know + which party that is. This allows the calling command to not + have to worry about which party they are in the handler. + + Args: + sender (object): One of A or B. The method will figure + out the *other* party to send to. + string (str): Text to send. + """ + if self.part_a == sender: + self.part_b.msg(string) + elif self.part_b == sender: + self.part_a.msg(string) + else: + # no match, relay to oneself + sender.msg(string) if sender else self.part_a.msg(string)
+ +
[docs] def get_other(self, party): + """ + Returns the other party of the trade + + Args: + party (object): One of the parties of the negotiation + + Returns: + party_other (object): The other party, not the first party. + + """ + if self.part_a == party: + return self.part_b + if self.part_b == party: + return self.part_a + return None
+ +
[docs] def join(self, part_b): + """ + This is used once B decides to join the trade + + Args: + part_b (object): The party accepting the barter. + + """ + if self.part_b == part_b: + self.part_b.ndb.tradehandler = self + self.part_b.cmdset.add(CmdsetTrade()) + self.trade_started = True + return True + return False
+ +
[docs] def unjoin(self, part_b): + """ + This is used if B decides not to join the trade. + + Args: + part_b (object): The party leaving the barter. + + """ + if self.part_b == part_b: + self.finish(force=True) + return True + return False
+ +
[docs] def offer(self, party, *args): + """ + Change the current standing offer. We leave it up to the + command to do the actual checks that the offer consists + of real, valid, objects. + + Args: + party (object): Who is making the offer + args (objects or str): Offerings. + + """ + if self.trade_started: + # reset accept statements whenever an offer changes + self.part_a_accepted = False + self.part_b_accepted = False + if party == self.part_a: + self.part_a_offers = list(args) + elif party == self.part_b: + self.part_b_offers = list(args) + else: + raise ValueError
+ +
[docs] def list(self): + """ + List current offers. + + Returns: + offers (tuple): A tuple with two lists, (A_offers, B_offers). + + """ + return self.part_a_offers, self.part_b_offers
+ +
[docs] def search(self, offername): + """ + Search current offers. + + Args: + offername (str or int): Object to search for, or its index in + the list of offered items. + + Returns: + offer (object): An object on offer, based on the search criterion. + + """ + all_offers = self.part_a_offers + self.part_b_offers + if isinstance(offername, int): + # an index to return + if 0 <= offername < len(all_offers): + return all_offers[offername] + + all_keys = [offer.key for offer in all_offers] + try: + imatch = all_keys.index(offername) + return all_offers[imatch] + except ValueError: + for offer in all_offers: + if offer.aliases.get(offername): + return offer + return None
+ +
[docs] def accept(self, party): + """ + Accept the current offer. + + Args: + party (object): The party accepting the deal. + + Returns: + result (object): `True` if this closes the deal, `False` + otherwise + + Notes: + This will only close the deal if both parties have + accepted independently. This is done by calling the + `finish()` method. + + """ + if self.trade_started: + if party == self.part_a: + self.part_a_accepted = True + elif party == self.part_b: + self.part_b_accepted = True + else: + raise ValueError + return self.finish() # try to close the deal + return False
+ +
[docs] def decline(self, party): + """ + Decline the offer (or change one's mind). + + Args: + party (object): Party declining the deal. + + Returns: + did_decline (bool): `True` if there was really an + `accepted` status to change, `False` otherwise. + + Notes: + If previously having used the `accept` command, this + function will only work as long as the other party has not + yet accepted. + + """ + if self.trade_started: + if party == self.part_a: + if self.part_a_accepted: + self.part_a_accepted = False + return True + return False + elif party == self.part_b: + if self.part_b_accepted: + self.part_b_accepted = False + return True + return False + else: + raise ValueError + return False
+ +
[docs] def finish(self, force=False): + """ + Conclude trade - move all offers and clean up + + Args: + force (bool, optional): Force cleanup regardless of if the + trade was accepted or not (if not, no goods will change + hands but trading will stop anyway) + Returns: + result (bool): If the finish was successful. + + """ + fin = False + if self.trade_started and self.part_a_accepted and self.part_b_accepted: + # both accepted - move objects before cleanup + for obj in self.part_a_offers: + obj.location = self.part_b + for obj in self.part_b_offers: + obj.location = self.part_a + fin = True + if fin or force: + # cleanup + self.part_a.cmdset.delete("cmdset_trade") + self.part_b.cmdset.delete("cmdset_trade") + self.part_a_offers = None + self.part_b_offers = None + self.part_a.scripts.stop("trade_request_timeout") + # this will kill it also from B + del self.part_a.ndb.tradehandler + if self.part_b.ndb.tradehandler: + del self.part_b.ndb.tradehandler + return True + return False
+ + +# trading commands (will go into CmdsetTrade, initialized by the +# CmdTrade command further down). + + +
[docs]class CmdTradeBase(Command): + """ + Base command for Trade commands to inherit from. Implements the + custom parsing. + """ + +
[docs] def parse(self): + """ + Parse the relevant parts and make it easily + available to the command + """ + self.args = self.args.strip() + self.tradehandler = self.caller.ndb.tradehandler + self.part_a = self.tradehandler.part_a + self.part_b = self.tradehandler.part_b + + self.other = self.tradehandler.get_other(self.caller) + self.msg_other = self.tradehandler.msg_other + + self.trade_started = self.tradehandler.trade_started + self.emote = "" + self.str_caller = "Your trade action: %s" + self.str_other = "%s:s trade action: " % self.caller.key + "%s" + if ":" in self.args: + self.args, self.emote = [part.strip() for part in self.args.rsplit(":", 1)] + self.str_caller = 'You say, "' + self.emote + '"\n [%s]' + if self.caller.has_account: + self.str_other = '|c%s|n says, "' % self.caller.key + self.emote + '"\n [%s]' + else: + self.str_other = '%s says, "' % self.caller.key + self.emote + '"\n [%s]'
+ + +# trade help + + +
[docs]class CmdTradeHelp(CmdTradeBase): + """ + help command for the trade system. + + Usage: + trade help + + Displays help for the trade commands. + """ + + key = "trade help" + locks = "cmd:all()" + help_category = "Trade" + +
[docs] def func(self): + """Show the help""" + string = """ + Trading commands + + |woffer <objects> [:emote]|n + offer one or more objects for trade. The emote can be used for + RP/arguments. A new offer will require both parties to re-accept + it again. + |waccept [:emote]|n + accept the currently standing offer from both sides. Also 'agree' + works. Once both have accepted, the deal is finished and goods + will change hands. + |wdecline [:emote]|n + change your mind and remove a previous accept (until other + has also accepted) + |wstatus|n + show the current offers on each side of the deal. Also 'offers' + and 'deal' works. + |wevaluate <nr> or <offer>|n + examine any offer in the deal. List them with the 'status' command. + |wend trade|n + end the negotiations prematurely. No trade will take place. + + You can also use |wemote|n, |wsay|n etc to discuss + without making a decision or offer. + """ + self.caller.msg(string)
+ + +# offer + + +
[docs]class CmdOffer(CmdTradeBase): + """ + offer one or more items in trade. + + Usage: + offer <object> [, object2, ...][:emote] + + Offer objects in trade. This will replace the currently + standing offer. + """ + + key = "offer" + locks = "cmd:all()" + help_category = "Trading" + +
[docs] def func(self): + """implement the offer""" + + caller = self.caller + if not self.args: + caller.msg("Usage: offer <object> [, object2, ...] [:emote]") + return + if not self.trade_started: + caller.msg("Wait until the other party has accepted to trade with you.") + return + + # gather all offers + offers = [part.strip() for part in self.args.split(",")] + offerobjs = [] + for offername in offers: + obj = caller.search(offername) + if not obj: + return + offerobjs.append(obj) + self.tradehandler.offer(self.caller, *offerobjs) + + # output + if len(offerobjs) > 1: + objnames = ( + ", ".join("|w%s|n" % obj.key for obj in offerobjs[:-1]) + + " and |w%s|n" % offerobjs[-1].key + ) + else: + objnames = "|w%s|n" % offerobjs[0].key + + caller.msg(self.str_caller % ("You offer %s" % objnames)) + self.msg_other(caller, self.str_other % ("They offer %s" % objnames))
+ + +# accept + + +
[docs]class CmdAccept(CmdTradeBase): + """ + accept the standing offer + + Usage: + accept [:emote] + agreee [:emote] + + This will accept the current offer. The other party must also accept + for the deal to go through. You can use the 'decline' command to change + your mind as long as the other party has not yet accepted. You can inspect + the current offer using the 'offers' command. + """ + + key = "accept" + aliases = ["agree"] + locks = "cmd:all()" + help_category = "Trading" + +
[docs] def func(self): + """accept the offer""" + caller = self.caller + if not self.trade_started: + caller.msg("Wait until the other party has accepted to trade with you.") + return + if self.tradehandler.accept(self.caller): + # deal finished. Trade ended and cleaned. + caller.msg( + self.str_caller + % "You |gaccept|n the deal. |gDeal is made and goods changed hands.|n" + ) + self.msg_other( + caller, + self.str_other % "%s |gaccepts|n the deal." + " |gDeal is made and goods changed hands.|n" % caller.key, + ) + else: + # a one-sided accept. + caller.msg( + self.str_caller + % "You |Gaccept|n the offer. %s must now also accept." + % self.other.key + ) + self.msg_other( + caller, + self.str_other % "%s |Gaccepts|n the offer. You must now also accept." % caller.key, + )
+ + +# decline + + +
[docs]class CmdDecline(CmdTradeBase): + """ + decline the standing offer + + Usage: + decline [:emote] + + This will decline a previously 'accept'ed offer (so this allows you to + change your mind). You can only use this as long as the other party + has not yet accepted the deal. Also, changing the offer will automatically + decline the old offer. + """ + + key = "decline" + locks = "cmd:all()" + help_category = "Trading" + +
[docs] def func(self): + """decline the offer""" + caller = self.caller + if not self.trade_started: + caller.msg("Wait until the other party has accepted to trade with you.") + return + offer_a, offer_b = self.tradehandler.list() + if not offer_a or not offer_b: + caller.msg("No offers have been made yet, so there is nothing to decline.") + return + if self.tradehandler.decline(self.caller): + # changed a previous accept + caller.msg(self.str_caller % "You change your mind, |Rdeclining|n the current offer.") + self.msg_other( + caller, + self.str_other + % "%s changes their mind, |Rdeclining|n the current offer." + % caller.key, + ) + else: + # no acceptance to change + caller.msg(self.str_caller % "You |Rdecline|n the current offer.") + self.msg_other(caller, self.str_other % "%s declines the current offer." % caller.key)
+ + +# evaluate + +# Note: This version only shows the description. If your particular game +# lists other important properties of objects (such as weapon damage, weight, +# magical properties, ammo requirements or whatnot), then you need to add this +# here. + + +
[docs]class CmdEvaluate(CmdTradeBase): + """ + evaluate objects on offer + + Usage: + evaluate <offered object> + + This allows you to examine any object currently on offer, to + determine if it's worth your while. + """ + + key = "evaluate" + aliases = ["eval"] + locks = "cmd:all()" + help_category = "Trading" + +
[docs] def func(self): + """evaluate an object""" + caller = self.caller + if not self.args: + caller.msg("Usage: evaluate <offered object>") + return + # we also accept indices + try: + ind = int(self.args) + self.args = ind - 1 + except Exception: + # not a valid index - ignore + pass + + offer = self.tradehandler.search(self.args) + if not offer: + caller.msg("No offer matching '%s' was found." % self.args) + return + # show the description + caller.msg(offer.db.desc)
+ + +# status + + +
[docs]class CmdStatus(CmdTradeBase): + """ + show a list of the current deal + + Usage: + status + deal + offers + + Shows the currently suggested offers on each sides of the deal. To + accept the current deal, use the 'accept' command. Use 'offer' to + change your deal. You might also want to use 'say', 'emote' etc to + try to influence the other part in the deal. + """ + + key = "status" + aliases = ["offers", "deal"] + locks = "cmd:all()" + help_category = "Trading" + +
[docs] def func(self): + """Show the current deal""" + caller = self.caller + part_a_offers, part_b_offers = self.tradehandler.list() + count = 1 + part_a_offerlist = [] + for offer in part_a_offers: + part_a_offerlist.append("\n |w%i|n %s" % (count, offer.key)) + count += 1 + if not part_a_offerlist: + part_a_offerlist = "\n <nothing>" + part_b_offerlist = [] + for offer in part_b_offers: + part_b_offerlist.append("\n |w%i|n %s" % (count, offer.key)) + count += 1 + if not part_b_offerlist: + part_b_offerlist = "\n <nothing>" + + string = "|gOffered by %s:|n%s\n|yOffered by %s:|n%s" % ( + self.part_a.key, + "".join(part_a_offerlist), + self.part_b.key, + "".join(part_b_offerlist), + ) + accept_a = self.tradehandler.part_a_accepted and "|gYes|n" or "|rNo|n" + accept_b = self.tradehandler.part_b_accepted and "|gYes|n" or "|rNo|n" + string += "\n\n%s agreed: %s, %s agreed: %s" % ( + self.part_a.key, + accept_a, + self.part_b.key, + accept_b, + ) + string += "\n Use 'offer', 'eval' and 'accept'/'decline' to trade. See also 'trade help'." + caller.msg(string)
+ + +# finish + + +
[docs]class CmdFinish(CmdTradeBase): + """ + end the trade prematurely + + Usage: + end trade [:say] + finish trade [:say] + + This ends the trade prematurely. No trade will take place. + + """ + + key = "end trade" + aliases = "finish trade" + locks = "cmd:all()" + help_category = "Trading" + +
[docs] def func(self): + """end trade""" + caller = self.caller + self.tradehandler.finish(force=True) + caller.msg(self.str_caller % "You |raborted|n trade. No deal was made.") + self.msg_other( + caller, self.str_other % "%s |raborted|n trade. No deal was made." % caller.key + )
+ + +# custom Trading cmdset + + +
[docs]class CmdsetTrade(CmdSet): + """ + This cmdset is added when trade is initated. It is handled by the + trade event handler. + """ + + key = "cmdset_trade" + +
[docs] def at_cmdset_creation(self): + """Called when cmdset is created""" + self.add(CmdTradeHelp()) + self.add(CmdOffer()) + self.add(CmdAccept()) + self.add(CmdDecline()) + self.add(CmdEvaluate()) + self.add(CmdStatus()) + self.add(CmdFinish())
+ + +# access command - once both have given this, this will create the +# trading cmdset to start trade. + + +
[docs]class CmdTrade(Command): + """ + Initiate trade with another party + + Usage: + trade <other party> [:say] + trade <other party> accept [:say] + trade <other party> decline [:say] + + Initiate trade with another party. The other party needs to repeat + this command with trade accept/decline within a minute in order to + properly initiate the trade action. You can use the decline option + yourself if you want to retract an already suggested trade. The + optional say part works like the say command and allows you to add + info to your choice. + """ + + key = "trade" + aliases = ["barter"] + locks = "cmd:all()" + help_category = "General" + +
[docs] def func(self): + """Initiate trade""" + + if not self.args: + if self.caller.ndb.tradehandler and self.caller.ndb.tradeevent.trade_started: + self.caller.msg("You are already in a trade. Use 'end trade' to abort it.") + else: + self.caller.msg("Usage: trade <other party> [accept|decline] [:emote]") + return + self.args = self.args.strip() + + # handle the emote manually here + selfemote = "" + theiremote = "" + if ":" in self.args: + self.args, emote = [part.strip() for part in self.args.rsplit(":", 1)] + selfemote = 'You say, "%s"\n ' % emote + if self.caller.has_account: + theiremote = '|c%s|n says, "%s"\n ' % (self.caller.key, emote) + else: + theiremote = '%s says, "%s"\n ' % (self.caller.key, emote) + + # for the sake of this command, the caller is always part_a; this + # might not match the actual name in tradehandler (in the case of + # using this command to accept/decline a trade invitation). + part_a = self.caller + accept = "accept" in self.args + decline = "decline" in self.args + if accept: + part_b = self.args.rstrip("accept").strip() + elif decline: + part_b = self.args.rstrip("decline").strip() + else: + part_b = self.args + part_b = self.caller.search(part_b) + if not part_b: + return + if part_a == part_b: + part_a.msg("You play trader with yourself.") + return + + # messages + str_init_a = "You ask to trade with %s. They need to accept within %s secs." + str_init_b = "%s wants to trade with you. Use |wtrade %s accept/decline [:emote]|n to answer (within %s secs)." + str_noinit_a = "%s declines the trade" + str_noinit_b = "You decline trade with %s." + str_start_a = "%s starts to trade with you. See |wtrade help|n for aid." + str_start_b = "You start to trade with %s. See |wtrade help|n for aid." + + if not (accept or decline): + # initialization of trade + if self.caller.ndb.tradehandler: + # trying to start trade without stopping a previous one + if self.caller.ndb.tradehandler.trade_started: + string = "You are already in trade with %s. You need to end trade first." + else: + string = "You are already trying to initiate trade with %s. You need to decline that trade first." + self.caller.msg(string % part_b.key) + elif part_b.ndb.tradehandler and part_b.ndb.tradehandler.part_b == part_a: + # this is equivalent to part_a accepting a trade from part_b (so roles are reversed) + part_b.ndb.tradehandler.join(part_a) + part_b.msg(theiremote + str_start_a % part_a.key) + part_a.msg(selfemote + str_start_b % part_b.key) + else: + # initiate a new trade + TradeHandler(part_a, part_b) + part_a.msg(selfemote + str_init_a % (part_b.key, TRADE_TIMEOUT)) + part_b.msg(theiremote + str_init_b % (part_a.key, part_a.key, TRADE_TIMEOUT)) + part_a.scripts.add(TradeTimeout) + return + elif accept: + # accept a trade proposal from part_b (so roles are reversed) + if part_a.ndb.tradehandler: + # already in a trade + part_a.msg( + "You are already in trade with %s. You need to end that first." % part_b.key + ) + return + if part_b.ndb.tradehandler.join(part_a): + part_b.msg(theiremote + str_start_a % part_a.key) + part_a.msg(selfemote + str_start_b % part_b.key) + else: + part_a.msg("No trade proposal to accept.") + return + else: + # decline trade proposal from part_b (so roles are reversed) + if part_a.ndb.tradehandler and part_a.ndb.tradehandler.part_b == part_a: + # stopping an invite + part_a.ndb.tradehandler.finish(force=True) + part_b.msg(theiremote + "%s aborted trade attempt with you." % part_a) + part_a.msg(selfemote + "You aborted the trade attempt with %s." % part_b) + elif part_b.ndb.tradehandler and part_b.ndb.tradehandler.unjoin(part_a): + part_b.msg(theiremote + str_noinit_a % part_a.key) + part_a.msg(selfemote + str_noinit_b % part_b.key) + else: + part_a.msg("No trade proposal to decline.") + return
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/barter/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/barter/tests.html new file mode 100644 index 0000000000..791b3b8e9a --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/barter/tests.html @@ -0,0 +1,254 @@ + + + + + + + + evennia.contrib.game_systems.barter.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.barter.tests

+"""
+Test the contrib barter system
+"""
+
+from mock import Mock
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.utils.create import create_object
+
+from . import barter
+
+
+
[docs]class TestBarter(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.tradeitem1 = create_object(key="TradeItem1", location=self.char1) + self.tradeitem2 = create_object(key="TradeItem2", location=self.char1) + self.tradeitem3 = create_object(key="TradeItem3", location=self.char2)
+ +
[docs] def test_tradehandler_base(self): + self.char1.msg = Mock() + self.char2.msg = Mock() + # test all methods of the tradehandler + handler = barter.TradeHandler(self.char1, self.char2) + self.assertEqual(handler.part_a, self.char1) + self.assertEqual(handler.part_b, self.char2) + handler.msg_other(self.char1, "Want to trade?") + handler.msg_other(self.char2, "Yes!") + handler.msg_other(None, "Talking to myself...") + self.assertEqual(self.char2.msg.mock_calls[0][1][0], "Want to trade?") + self.assertEqual(self.char1.msg.mock_calls[0][1][0], "Yes!") + self.assertEqual(self.char1.msg.mock_calls[1][1][0], "Talking to myself...") + self.assertEqual(handler.get_other(self.char1), self.char2) + handler.finish(force=True)
+ +
[docs] def test_tradehandler_joins(self): + handler = barter.TradeHandler(self.char1, self.char2) + self.assertTrue(handler.join(self.char2)) + self.assertTrue(handler.unjoin(self.char2)) + self.assertFalse(handler.join(self.char1)) + self.assertFalse(handler.unjoin(self.char1)) + handler.finish(force=True)
+ +
[docs] def test_tradehandler_offers(self): + handler = barter.TradeHandler(self.char1, self.char2) + handler.join(self.char2) + handler.offer(self.char1, self.tradeitem1, self.tradeitem2) + self.assertEqual(handler.part_a_offers, [self.tradeitem1, self.tradeitem2]) + self.assertFalse(handler.part_a_accepted) + self.assertFalse(handler.part_b_accepted) + handler.offer(self.char2, self.tradeitem3) + self.assertEqual(handler.list(), ([self.tradeitem1, self.tradeitem2], [self.tradeitem3])) + self.assertEqual(handler.search("TradeItem2"), self.tradeitem2) + self.assertEqual(handler.search("TradeItem3"), self.tradeitem3) + self.assertEqual(handler.search("nonexisting"), None) + self.assertFalse(handler.finish()) # should fail since offer not yet accepted + handler.accept(self.char1) + handler.decline(self.char1) + handler.accept(self.char2) + handler.accept(self.char1) # should trigger handler.finish() automatically + self.assertEqual(self.tradeitem1.location, self.char2) + self.assertEqual(self.tradeitem2.location, self.char2) + self.assertEqual(self.tradeitem3.location, self.char1)
+ +
[docs] def test_cmdtrade(self): + self.call( + barter.CmdTrade(), + "Char2 : Hey wanna trade?", + 'You say, "Hey wanna trade?"', + caller=self.char1, + ) + self.call(barter.CmdTrade(), "Char decline : Nope!", 'You say, "Nope!"', caller=self.char2) + self.call( + barter.CmdTrade(), + "Char2 : Hey wanna trade?", + 'You say, "Hey wanna trade?"', + caller=self.char1, + ) + self.call(barter.CmdTrade(), "Char accept : Sure!", 'You say, "Sure!"', caller=self.char2) + self.call( + barter.CmdOffer(), + "TradeItem3", + "Your trade action: You offer TradeItem3", + caller=self.char2, + ) + self.call( + barter.CmdOffer(), + "TradeItem1 : Here's my offer.", + 'You say, "Here\'s my offer."\n [You offer TradeItem1]', + ) + self.call( + barter.CmdAccept(), + "", + "Your trade action: You accept the offer. Char2 must now also accept", + ) + self.call( + barter.CmdDecline(), + "", + "Your trade action: You change your mind, declining the current offer.", + ) + self.call( + barter.CmdAccept(), + ": Sounds good.", + 'You say, "Sounds good."\n' " [You accept the offer. Char must now also accept.", + caller=self.char2, + ) + self.call( + barter.CmdDecline(), + ":No way!", + 'You say, "No way!"\n [You change your mind, declining the current offer.]', + caller=self.char2, + ) + self.call( + barter.CmdOffer(), + "TradeItem1, TradeItem2 : My final offer!", + 'You say, "My final offer!"\n [You offer TradeItem1 and TradeItem2]', + ) + self.call( + barter.CmdAccept(), + "", + "Your trade action: You accept the offer. Char2 must now also accept.", + caller=self.char1, + ) + self.call(barter.CmdStatus(), "", "Offered by Char:", caller=self.char2) + self.tradeitem1.db.desc = "A great offer." + self.call(barter.CmdEvaluate(), "TradeItem1", "A great offer.") + self.call( + barter.CmdAccept(), + ":Ok then.", + 'You say, "Ok then."\n [You accept the deal.', + caller=self.char2, + ) + self.assertEqual(self.tradeitem1.location, self.char2) + self.assertEqual(self.tradeitem2.location, self.char2) + self.assertEqual(self.tradeitem3.location, self.char1)
+ +
[docs] def test_cmdtradehelp(self): + self.call( + barter.CmdTrade(), + "Char2 : Hey wanna trade?", + 'You say, "Hey wanna trade?"', + caller=self.char1, + ) + self.call(barter.CmdTradeHelp(), "", "Trading commands\n", caller=self.char1) + self.call( + barter.CmdFinish(), + ": Ending.", + 'You say, "Ending."\n [You aborted trade. No deal was made.]', + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/clothing/clothing.html b/docs/latest/_modules/evennia/contrib/game_systems/clothing/clothing.html new file mode 100644 index 0000000000..ea92267b41 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/clothing/clothing.html @@ -0,0 +1,812 @@ + + + + + + + + evennia.contrib.game_systems.clothing.clothing — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.clothing.clothing

+"""
+Clothing - Provides a typeclass and commands for wearable clothing,
+which is appended to a character's description when worn.
+
+Evennia contribution - Tim Ashley Jenkins 2017
+
+Clothing items, when worn, are added to the character's description
+in a list. For example, if wearing the following clothing items:
+
+    a thin and delicate necklace
+    a pair of regular ol' shoes
+    one nice hat
+    a very pretty dress
+
+A character's description may look like this:
+
+    Superuser(#1)
+    This is User #1.
+
+    Superuser is wearing one nice hat, a thin and delicate necklace,
+    a very pretty dress and a pair of regular ol' shoes.
+
+Characters can also specify the style of wear for their clothing - I.E.
+to wear a scarf 'tied into a tight knot around the neck' or 'draped
+loosely across the shoulders' - to add an easy avenue of customization.
+For example, after entering:
+
+    wear scarf draped loosely across the shoulders
+
+The garment appears like so in the description:
+
+    Superuser(#1)
+    This is User #1.
+
+    Superuser is wearing a fanciful-looking scarf draped loosely
+    across the shoulders.
+
+Items of clothing can be used to cover other items, and many options
+are provided to define your own clothing types and their limits and
+behaviors. For example, to have undergarments automatically covered
+by outerwear, or to put a limit on the number of each type of item
+that can be worn. The system as-is is fairly freeform - you
+can cover any garment with almost any other, for example - but it
+can easily be made more restrictive, and can even be tied into a
+system for armor or other equipment.
+
+To install, import this module and have your default character
+inherit from ClothedCharacter in your game's characters.py file:
+
+    from evennia.contrib.game_systems.clothing import ClothedCharacter
+
+    class Character(ClothedCharacter):
+
+And then add ClothedCharacterCmdSet in your character set in your
+game's commands/default_cmdsets.py:
+
+    from evennia.contrib.game_systems.clothing import ClothedCharacterCmdSet
+
+    class CharacterCmdSet(default_cmds.CharacterCmdSet):
+         ...
+         at_cmdset_creation(self):
+
+             super().at_cmdset_creation()
+             ...
+             self.add(ClothedCharacterCmdSet)    # <-- add this
+
+From here, you can use the default builder commands to create clothes
+with which to test the system:
+
+    @create a pretty shirt : evennia.contrib.game_systems.clothing.ContribClothing
+    @set shirt/clothing_type = 'top'
+    wear shirt
+
+"""
+from collections import defaultdict
+
+from django.conf import settings
+
+from evennia import DefaultCharacter, DefaultObject, default_cmds
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.utils import at_search_result, evtable, inherits_from, iter_to_str
+
+# Options start here.
+# Maximum character length of 'wear style' strings, or None for unlimited.
+WEARSTYLE_MAXLENGTH = getattr(settings, "CLOTHING_WEARSTYLE_MAXLENGTH", 50)
+
+# The rest of these options have to do with clothing types. ContribClothing types are optional,
+# but can be used to give better control over how different items of clothing behave. You
+# can freely add, remove, or change clothing types to suit the needs of your game and use
+# the options below to affect their behavior.
+
+# The order in which clothing types appear on the description. Untyped clothing or clothing
+# with a type not given in this list goes last.
+CLOTHING_TYPE_ORDER = getattr(
+    settings,
+    "CLOTHING_TYPE_ORDERED",
+    [
+        "hat",
+        "jewelry",
+        "top",
+        "undershirt",
+        "gloves",
+        "fullbody",
+        "bottom",
+        "underpants",
+        "socks",
+        "shoes",
+        "accessory",
+    ],
+)
+# The maximum number of each type of clothes that can be worn. Unlimited if untyped or not specified.
+CLOTHING_TYPE_LIMIT = getattr(
+    settings, "CLOTHING_TYPE_LIMIT", {"hat": 1, "gloves": 1, "socks": 1, "shoes": 1}
+)
+# The maximum number of clothing items that can be worn, or None for unlimited.
+CLOTHING_OVERALL_LIMIT = getattr(settings, "CLOTHING_OVERALL_LIMIT", 20)
+# What types of clothes will automatically cover what other types of clothes when worn.
+# Note that clothing only gets auto-covered if it's already worn when you put something
+# on that auto-covers it - for example, it's perfectly possible to have your underpants
+# showing if you put them on after your pants!
+CLOTHING_TYPE_AUTOCOVER = getattr(
+    settings,
+    "CLOTHING_TYPE_AUTOCOVER",
+    {
+        "top": ["undershirt"],
+        "bottom": ["underpants"],
+        "fullbody": ["undershirt", "underpants"],
+        "shoes": ["socks"],
+    },
+)
+# Types of clothes that can't be used to cover other clothes.
+CLOTHING_TYPE_CANT_COVER_WITH = getattr(settings, "CLOTHING_TYPE_AUTOCOVER", ["jewelry"])
+
+
+# HELPER FUNCTIONS START HERE
+
[docs]def order_clothes_list(clothes_list): + """ + Orders a given clothes list by the order specified in CLOTHING_TYPE_ORDER. + + Args: + clothes_list (list): List of clothing items to put in order + + Returns: + ordered_clothes_list (list): The same list as passed, but re-ordered + according to the hierarchy of clothing types + specified in CLOTHING_TYPE_ORDER. + """ + ordered_clothes_list = clothes_list + # For each type of clothing that exists... + for current_type in reversed(CLOTHING_TYPE_ORDER): + # Check each item in the given clothes list. + for clothes in clothes_list: + # If the item has a clothing type... + if clothes.db.clothing_type: + item_type = clothes.db.clothing_type + # And the clothing type matches the current type... + if item_type == current_type: + # Move it to the front of the list! + ordered_clothes_list.remove(clothes) + ordered_clothes_list.insert(0, clothes) + return ordered_clothes_list
+ + +
[docs]def get_worn_clothes(character, exclude_covered=False): + """ + Get a list of clothes worn by a given character. + + Args: + character (obj): The character to get a list of worn clothes from. + + Keyword Args: + exclude_covered (bool): If True, excludes clothes covered by other + clothing from the returned list. + + Returns: + ordered_clothes_list (list): A list of clothing items worn by the + given character, ordered according to + the CLOTHING_TYPE_ORDER option specified + in this module. + """ + clothes_list = [] + for thing in character.contents: + # If uncovered or not excluding covered items + if not thing.db.covered_by or exclude_covered is False: + # If 'worn' is True, add to the list + if thing.db.worn: + clothes_list.append(thing) + # Might as well put them in order here too. + ordered_clothes_list = order_clothes_list(clothes_list) + return ordered_clothes_list
+ + +
[docs]def clothing_type_count(clothes_list): + """ + Returns a dictionary of the number of each clothing type + in a given list of clothing objects. + + Args: + clothes_list (list): A list of clothing items from which + to count the number of clothing types + represented among them. + + Returns: + types_count (dict): A dictionary of clothing types represented + in the given list and the number of each + clothing type represented. + """ + types_count = {} + for garment in clothes_list: + if garment.db.clothing_type: + type = garment.db.clothing_type + if type not in list(types_count.keys()): + types_count[type] = 1 + else: + types_count[type] += 1 + return types_count
+ + +
[docs]def single_type_count(clothes_list, type): + """ + Returns an integer value of the number of a given type of clothing in a list. + + Args: + clothes_list (list): List of clothing objects to count from + type (str): Clothing type to count + + Returns: + type_count (int): Number of garments of the specified type in the given + list of clothing objects + """ + type_count = 0 + for garment in clothes_list: + if garment.db.clothing_type: + if garment.db.clothing_type == type: + type_count += 1 + return type_count
+ + +
[docs]class ContribClothing(DefaultObject): +
[docs] def wear(self, wearer, wearstyle, quiet=False): + """ + Sets clothes to 'worn' and optionally echoes to the room. + + Args: + wearer (obj): character object wearing this clothing object + wearstyle (True or str): string describing the style of wear or True for none + + Keyword Args: + quiet (bool): If false, does not message the room + + Notes: + Optionally sets db.worn with a 'wearstyle' that appends a short passage to + the end of the name of the clothing to describe how it's worn that shows + up in the wearer's desc - I.E. 'around his neck' or 'tied loosely around + her waist'. If db.worn is set to 'True' then just the name will be shown. + """ + # Set clothing as worn + self.db.worn = wearstyle + # Auto-cover appropriate clothing types + to_cover = [] + if clothing_type := self.db.clothing_type: + if autocover_types := CLOTHING_TYPE_AUTOCOVER.get(clothing_type): + to_cover.extend( + [ + garment + for garment in get_worn_clothes(wearer) + if garment.db.clothing_type in autocover_types + ] + ) + for garment in to_cover: + garment.db.covered_by = self + + # Echo a message to the room + if not quiet: + if type(wearstyle) is str: + message = f"$You() $conj(wear) {self.name} {wearstyle}" + else: + message = f"$You() $conj(put) on {self.name}" + if to_cover: + message += f", covering {iter_to_str(to_cover)}" + wearer.location.msg_contents(message + ".", from_obj=wearer)
+ +
[docs] def remove(self, wearer, quiet=False): + """ + Removes worn clothes and optionally echoes to the room. + + Args: + wearer (obj): character object wearing this clothing object + + Keyword Args: + quiet (bool): If false, does not message the room + """ + self.db.worn = False + uncovered_list = [] + + # Check to see if any other clothes are covered by this object. + for thing in wearer.contents: + if thing.db.covered_by == self: + thing.db.covered_by = False + uncovered_list.append(thing.name) + # Echo a message to the room + if not quiet: + remove_message = f"$You() $conj(remove) {self.name}" + if len(uncovered_list) > 0: + remove_message += f", revealing {iter_to_str(uncovered_list)}" + wearer.location.msg_contents(remove_message + ".", from_obj=wearer)
+ +
[docs] def at_get(self, getter): + """ + Makes absolutely sure clothes aren't already set as 'worn' + when they're picked up, in case they've somehow had their + location changed without getting removed. + """ + self.db.worn = False
+ +
[docs] def at_pre_move(self, destination, **kwargs): + """ + Called just before starting to move this object to + destination. Return False to abort move. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + """ + # Covered clothing cannot be removed, dropped, or otherwise relocated + if self.db.covered_by: + return False + return True
+ + +
[docs]class ClothedCharacter(DefaultCharacter): + """ + Character that displays worn clothing when looked at. You can also + just copy the return_appearance hook defined below to your own game's + character typeclass. + """ + +
[docs] def get_display_desc(self, looker, **kwargs): + """ + Get the 'desc' component of the object description. Called by `return_appearance`. + + Args: + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The desc display string. + """ + desc = self.db.desc + + outfit_list = [] + # Append worn, uncovered clothing to the description + for garment in get_worn_clothes(self, exclude_covered=True): + wearstyle = garment.db.worn + if type(wearstyle) is str: + outfit_list.append(f"{garment.name} {wearstyle}") + else: + outfit_list.append(garment.name) + + # Create outfit string + if outfit_list: + outfit = ( + f"{self.get_display_name(looker, **kwargs)} is wearing {iter_to_str(outfit_list)}." + ) + else: + outfit = f"{self.get_display_name(looker, **kwargs)} is wearing nothing." + + # Add on to base description + if desc: + desc += f"\n\n{outfit}" + else: + desc = outfit + + return desc
+ +
[docs] def get_display_things(self, looker, **kwargs): + """ + Get the 'things' component of the object's contents. Called by `return_appearance`. + + Args: + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: A string describing the things in object. + """ + + def _filter_visible(obj_list): + return ( + obj + for obj in obj_list + if obj != looker and obj.access(looker, "view") and not obj.db.worn + ) + + # sort and handle same-named things + things = _filter_visible(self.contents_get(content_type="object")) + + grouped_things = defaultdict(list) + for thing in things: + grouped_things[thing.get_display_name(looker, **kwargs)].append(thing) + + thing_names = [] + for thingname, thinglist in sorted(grouped_things.items()): + nthings = len(thinglist) + thing = thinglist[0] + singular, plural = thing.get_numbered_name(nthings, looker, key=thingname) + thing_names.append(singular if nthings == 1 else plural) + thing_names = iter_to_str(thing_names) + return ( + f"\n{self.get_display_name(looker, **kwargs)} is carrying {thing_names}" + if thing_names + else "" + )
+ + +# COMMANDS START HERE + + +
[docs]class CmdWear(MuxCommand): + """ + Puts on an item of clothing you are holding. + + Usage: + wear <obj> [=] [wear style] + + Examples: + wear red shirt + wear scarf wrapped loosely about the shoulders + wear blue hat = at a jaunty angle + + All the clothes you are wearing are appended to your description. + If you provide a 'wear style' after the command, the message you + provide will be displayed after the clothing's name. + """ + + key = "wear" + help_category = "clothing" + +
[docs] def func(self): + if not self.args: + self.caller.msg("Usage: wear <obj> [=] [wear style]") + return + if not self.rhs: + # check if the whole string is an object + clothing = self.caller.search(self.lhs, candidates=self.caller.contents, quiet=True) + if not clothing: + # split out the first word as the object and the rest as the wearstyle + argslist = self.lhs.split() + self.lhs = argslist[0] + self.rhs = " ".join(argslist[1:]) + clothing = self.caller.search(self.lhs, candidates=self.caller.contents) + else: + # pass the result through the search-result hook + clothing = at_search_result(clothing, self.caller, self.lhs) + + else: + # it had an explicit separator - just do a normal search for the lhs + clothing = self.caller.search(self.lhs, candidates=self.caller.contents) + + if not clothing: + return + if not inherits_from(clothing, ContribClothing): + self.caller.msg(f"{clothing.name} isn't something you can wear.") + return + + if clothing.db.worn: + if not self.rhs: + # If no wearstyle was provided and the clothing is already being worn, do nothing + self.caller.msg(f"You're already wearing your {clothing.name}.") + return + elif len(self.rhs) > WEARSTYLE_MAXLENGTH: + self.caller.msg( + f"Please keep your wear style message to less than {WEARSTYLE_MAXLENGTH} characters." + ) + return + else: + # Adjust the wearstyle + clothing.db.worn = self.rhs + self.caller.location.msg_contents( + f"$You() $conj(wear) {clothing.name} {self.rhs}.", from_obj=self.caller + ) + return + + already_worn = get_worn_clothes(self.caller) + + # Enforce overall clothing limit. + if CLOTHING_OVERALL_LIMIT and len(already_worn) >= CLOTHING_OVERALL_LIMIT: + self.caller.msg("You can't wear any more clothes.") + return + + # Apply individual clothing type limits. + if clothing_type := clothing.db.clothing_type: + if clothing_type in CLOTHING_TYPE_LIMIT: + type_count = single_type_count(already_worn, clothing_type) + if type_count >= CLOTHING_TYPE_LIMIT[clothing_type]: + self.caller.msg( + f"You can't wear any more clothes of the type '{clothing_type}'." + ) + return + + wearstyle = self.rhs or True + clothing.wear(self.caller, wearstyle)
+ + +
[docs]class CmdRemove(MuxCommand): + """ + Takes off an item of clothing. + + Usage: + remove <obj> + + Removes an item of clothing you are wearing. You can't remove + clothes that are covered up by something else - you must take + off the covering item first. + """ + + key = "remove" + help_category = "clothing" + +
[docs] def func(self): + clothing = self.caller.search(self.args, candidates=self.caller.contents) + if not clothing: + self.caller.msg("You don't have anything like that.") + return + if not clothing.db.worn: + self.caller.msg("You're not wearing that!") + return + if covered := clothing.db.covered_by: + self.caller.msg(f"You have to take off {covered} first.") + return + clothing.remove(self.caller)
+ + +
[docs]class CmdCover(MuxCommand): + """ + Covers a worn item of clothing with another you're holding or wearing. + + Usage: + cover <worn obj> with <obj> + + When you cover a clothing item, it is hidden and no longer appears in + your description until it's uncovered or the item covering it is removed. + You can't remove an item of clothing if it's covered. + """ + + key = "cover" + help_category = "clothing" + rhs_split = (" with ", "=") + +
[docs] def func(self): + if not len(self.args) or not self.rhs: + self.caller.msg("Usage: cover <worn clothing> with <clothing object>") + return + + to_cover = self.caller.search(self.lhs, candidates=get_worn_clothes(self.caller)) + cover_with = self.caller.search(self.rhs, candidates=self.caller.contents) + if not to_cover or not cover_with: + return + if to_cover == cover_with: + self.caller.msg("You can't cover an item with itself!") + return + + if not inherits_from(cover_with, ContribClothing): + self.caller.msg(f"{cover_with.name} isn't something you can wear.") + return + + if cover_with.db.clothing_type in CLOTHING_TYPE_CANT_COVER_WITH: + self.caller.msg(f"You can't cover anything with {cover_with.name}.") + return + + if covered_by := cover_with.db.covered_by: + self.caller.msg(f"{cover_with.name} is already covered by {covered_by.name}.") + return + if covered_by := to_cover.db.covered_by: + self.caller.msg(f"{to_cover.name} is already covered by {covered_by.name}.") + return + + # Put on the item to cover with if it's not on already + if not cover_with.db.worn: + cover_with.wear(self.caller, True) + to_cover.db.covered_by = cover_with + + self.caller.location.msg_contents( + f"$You() $conj(cover) {to_cover.name} with {cover_with.name}.", from_obj=self.caller + )
+ + +
[docs]class CmdUncover(MuxCommand): + """ + Reveals a worn item of clothing that's currently covered up. + + Usage: + uncover <obj> + + When you uncover an item of clothing, you allow it to appear in your + description without having to take off the garment that's currently + covering it. You can't uncover an item of clothing if the item covering + it is also covered by something else. + """ + + key = "uncover" + help_category = "clothing" + +
[docs] def func(self): + """ + This performs the actual command. + """ + + if not self.args: + self.caller.msg("Usage: uncover <worn clothing object>") + return + + clothing = self.caller.search(self.args, candidates=get_worn_clothes(self.caller)) + if not clothing: + return + if covered_by := clothing.db.covered_by: + if covered_by.db.covered_by: + self.caller.msg(f"{clothing.name} is under too many layers to uncover.") + return + clothing.db.covered_by = None + self.caller.location.msg_contents( + f"$You() $conj(uncover) {clothing.name}.", from_obj=self.caller + ) + + else: + self.caller.msg(f"{clothing.name} isn't covered by anything.") + return
+ + +
[docs]class CmdInventory(MuxCommand): + """ + view inventory + + Usage: + inventory + inv + + Shows your inventory. + """ + + # Alternate version of the inventory command which separates + # worn and carried items. + + key = "inventory" + aliases = ["inv", "i"] + locks = "cmd:all()" + arg_regex = r"$" + +
[docs] def func(self): + """check inventory""" + if not self.caller.contents: + self.caller.msg("You are not carrying or wearing anything.") + return + + message_list = [] + + items = self.caller.contents + + carry_table = evtable.EvTable(border="header") + wear_table = evtable.EvTable(border="header") + + carried = [obj for obj in items if not obj.db.worn] + worn = [obj for obj in items if obj.db.worn] + + message_list.append("|wYou are carrying:|n") + for item in carried: + carry_table.add_row( + item.get_display_name(self.caller), item.get_display_desc(self.caller) + ) + if carry_table.nrows == 0: + carry_table.add_row("Nothing.", "") + message_list.append(str(carry_table)) + + message_list.append("|wYou are wearing:|n") + for item in worn: + item_name = item.get_display_name(self.caller) + if item.db.covered_by: + item_name += " (hidden)" + wear_table.add_row(item_name, item.get_display_desc(self.caller)) + if wear_table.nrows == 0: + wear_table.add_row("Nothing.", "") + message_list.append(str(wear_table)) + + self.caller.msg("\n".join(message_list))
+ + +
[docs]class ClothedCharacterCmdSet(default_cmds.CharacterCmdSet): + """ + Command set for clothing, including new versions of 'give' and 'drop' + that take worn and covered clothing into account, as well as a new + version of 'inventory' that differentiates between carried and worn + items. + """ + + key = "DefaultCharacter" + +
[docs] def at_cmdset_creation(self): + """ + Populates the cmdset + """ + super().at_cmdset_creation() + # + # any commands you add below will overload the default ones. + # + self.add(CmdWear()) + self.add(CmdRemove()) + self.add(CmdCover()) + self.add(CmdUncover()) + self.add(CmdInventory())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/clothing/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/clothing/tests.html new file mode 100644 index 0000000000..fd1f8c9663 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/clothing/tests.html @@ -0,0 +1,255 @@ + + + + + + + + evennia.contrib.game_systems.clothing.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.clothing.tests

+"""
+Testing clothing contrib
+
+"""
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.objects.objects import DefaultRoom
+from evennia.utils.create import create_object
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import clothing
+
+
+
[docs]class TestClothingCmd(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.room = create_object(DefaultRoom, key="Room") + self.wearer = create_object(clothing.ClothedCharacter, key="Wearer") + self.wearer.location = self.room + # Make a test hat + self.test_hat = create_object(clothing.ContribClothing, key="test hat") + self.test_hat.db.clothing_type = "hat" + # Make a test scarf + self.test_scarf = create_object(clothing.ContribClothing, key="test scarf") + self.test_scarf.db.clothing_type = "accessory"
+ +
[docs] def test_clothingcommands(self): + # Test inventory command. + self.call( + clothing.CmdInventory(), + "", + "You are not carrying or wearing anything.", + caller=self.wearer, + ) + + # Test wear command + self.test_scarf.location = self.wearer + self.test_hat.location = self.wearer + self.call(clothing.CmdWear(), "", "Usage: wear <obj> [=] [wear style]", caller=self.wearer) + self.call(clothing.CmdWear(), "hat", "You put on test hat.", caller=self.wearer) + self.call( + clothing.CmdWear(), + "scarf stylishly", + "You wear test scarf stylishly.", + caller=self.wearer, + ) + # Test cover command. + self.call( + clothing.CmdCover(), + "", + "Usage: cover <worn clothing> with <clothing object>", + caller=self.wearer, + ) + self.call( + clothing.CmdCover(), + "hat with scarf", + "You cover test hat with test scarf.", + caller=self.wearer, + ) + # Test remove command. + self.call(clothing.CmdRemove(), "", "Could not find ''.", caller=self.wearer) + self.call( + clothing.CmdRemove(), + "hat", + "You have to take off test scarf first.", + caller=self.wearer, + ) + self.call( + clothing.CmdRemove(), + "scarf", + "You remove test scarf, revealing test hat.", + caller=self.wearer, + ) + # Test uncover command. + self.test_scarf.wear(self.wearer, True) + self.test_hat.db.covered_by = self.test_scarf + self.call( + clothing.CmdUncover(), "", "Usage: uncover <worn clothing object>", caller=self.wearer + ) + self.call(clothing.CmdUncover(), "hat", "You uncover test hat.", caller=self.wearer)
+ +
[docs] def test_clothing_limits(self): + """ + make sure clothing type limits are being enforced + """ + # change the scarf to a hat for convenience + # since the "hat" type is limited to 1 by default + self.test_scarf.db.clothing_type = "hat" + # move to wearer to be wearable + self.test_scarf.location = self.wearer + self.test_hat.location = self.wearer + # try wearing the hat and scarf-hat + self.call(clothing.CmdWear(), "hat", "You put on test hat.", caller=self.wearer) + self.call( + clothing.CmdWear(), + "scarf", + "You can't wear any more clothes of the type 'hat'.", + caller=self.wearer, + )
+ + +
[docs]class TestClothingFunc(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.room = create_object(DefaultRoom, key="Room") + self.wearer = create_object(clothing.ClothedCharacter, key="Wearer") + self.wearer.location = self.room + # Make a test hat + self.test_hat = create_object(clothing.ContribClothing, key="test hat") + self.test_hat.db.clothing_type = "hat" + self.test_hat.location = self.wearer + # Make a test shirt + self.test_shirt = create_object(clothing.ContribClothing, key="test shirt") + self.test_shirt.db.clothing_type = "top" + self.test_shirt.location = self.wearer + # Make test pants + self.test_pants = create_object(clothing.ContribClothing, key="test pants") + self.test_pants.db.clothing_type = "bottom" + self.test_pants.location = self.wearer
+ +
[docs] def test_clothingfunctions(self): + self.test_hat.wear(self.wearer, "on the head") + self.assertEqual(self.test_hat.db.worn, "on the head") + + self.test_hat.remove(self.wearer) + self.assertFalse(self.test_hat.db.worn) + + self.test_hat.db.worn = True + self.test_hat.at_get(self.wearer) + self.assertFalse(self.test_hat.db.worn) + + self.test_hat.db.covered_by = self.test_shirt + can_move = self.test_hat.at_pre_move(self.room) + self.assertFalse(can_move) + + clothes_list = [self.test_shirt, self.test_hat, self.test_pants] + self.assertEqual( + clothing.order_clothes_list(clothes_list), + [self.test_hat, self.test_shirt, self.test_pants], + ) + + self.test_hat.wear(self.wearer, True) + self.test_pants.wear(self.wearer, True) + self.assertEqual(clothing.get_worn_clothes(self.wearer), [self.test_hat, self.test_pants]) + + self.assertEqual( + clothing.clothing_type_count(clothes_list), {"hat": 1, "top": 1, "bottom": 1} + ) + + self.assertEqual(clothing.single_type_count(clothes_list, "hat"), 1)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/cooldowns/cooldowns.html b/docs/latest/_modules/evennia/contrib/game_systems/cooldowns/cooldowns.html new file mode 100644 index 0000000000..b623ac8a38 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/cooldowns/cooldowns.html @@ -0,0 +1,313 @@ + + + + + + + + evennia.contrib.game_systems.cooldowns.cooldowns — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.cooldowns.cooldowns

+"""
+Cooldown contrib module.
+
+Evennia contrib - owllex, 2021
+
+This contrib provides a simple cooldown handler that can be attached to any
+typeclassed Object or Account. A cooldown is a lightweight persistent
+asynchronous timer that you can query to see if it is ready.
+
+Cooldowns are good for modelling rate-limited actions, like how often a
+character can perform a given command.
+
+Cooldowns are completely asynchronous and must be queried to know their
+state. They do not fire callbacks, so are not a good fit for use cases
+where something needs to happen on a specific schedule (use delay or
+a TickerHandler for that instead).
+
+See also the evennia documentation for command cooldowns
+(https://github.com/evennia/evennia/wiki/Command-Cooldown) for more information
+about the concept.
+
+Installation:
+
+To use, simply add the following property to the typeclass definition of any
+object type that you want to support cooldowns. It will expose a new `cooldowns`
+property that persists data to the object's attribute storage. You can set this
+on your base `Object` typeclass to enable cooldown tracking on every kind of
+object, or just put it on your `Character` typeclass.
+
+By default the CooldownHandler will use the `cooldowns` property, but you can
+customize this if desired by passing a different value for the db_attribute
+parameter.
+
+    from evennia.contrib.game_systems.cooldowns import Cooldownhandler
+    from evennia.utils.utils import lazy_property
+
+    @lazy_property
+    def cooldowns(self):
+        return CooldownHandler(self, db_attribute="cooldowns")
+
+Example:
+
+Assuming you've installed cooldowns on your Character typeclasses, you can use a
+cooldown to limit how often you can perform a command. The following code
+snippet will limit the use of a Power Attack command to once every 10 seconds
+per character.
+
+class PowerAttack(Command):
+    def func(self):
+        if self.caller.cooldowns.ready("power attack"):
+            self.do_power_attack()
+            self.caller.cooldowns.add("power attack", 10)
+        else:
+            self.caller.msg("That's not ready yet!")
+
+"""
+
+import math
+import time
+
+
+
[docs]class CooldownHandler: + """ + Handler for cooldowns. This can be attached to any object that supports DB + attributes (like a Character or Account). + + A cooldown is a timer that is usually used to limit how often some action + can be performed or some effect can trigger. When a cooldown is first added, + it counts down from the amount of time provided back to zero, at which point + it is considered ready again. + + Cooldowns are named with an arbitrary string, and that string is used to + check on the progression of the cooldown. Each cooldown is tracked + separately and independently from other cooldowns on that same object. A + cooldown is unique per-object. + + Cooldowns are saved persistently, so they survive reboots. This module does + not register or provide callback functionality for when a cooldown becomes + ready again. Users of cooldowns are expected to query the state of any + cooldowns they are interested in. + + Methods: + - ready(name): Checks whether a given cooldown name is ready. + - time_left(name): Returns how much time is left on a cooldown. + - add(name, seconds): Sets a given cooldown to last for a certain + amount of time. Until then, ready() will return False for that + cooldown name. set() is an alias. + - extend(name, seconds): Like add(), but adds more time to the given + cooldown if it already exists. If it doesn't exist yet, calling + this is equivalent to calling add(). + - reset(cooldown): Resets a given cooldown, causing ready() to return + True for that cooldown immediately. + - clear(): Resets all cooldowns. + """ + + __slots__ = ("data", "db_attribute", "obj") + +
[docs] def __init__(self, obj, db_attribute="cooldowns"): + if not obj.attributes.has(db_attribute): + obj.attributes.add(db_attribute, {}) + + self.data = obj.attributes.get(db_attribute) + self.obj = obj + self.db_attribute = db_attribute + self.cleanup()
+ + @property + def all(self): + """ + Returns a list of all keys in this object. + """ + return list(self.data.keys()) + +
[docs] def ready(self, *args): + """ + Checks whether all of the provided cooldowns are ready (expired). If a + requested cooldown does not exist, it is considered ready. + + Args: + *args (str): One or more cooldown names to check. + Returns: + bool: True if each cooldown has expired or does not exist. + """ + return self.time_left(*args, use_int=True) <= 0
+ +
[docs] def time_left(self, *args, use_int=False): + """ + Returns the maximum amount of time left on one or more given cooldowns. + If a requested cooldown does not exist, it is considered to have 0 time + left. + + Args: + *args (str): One or more cooldown names to check. + use_int (bool): True to round the return value up to an int, + False (default) to return a more precise float. + Returns: + float or int: Number of seconds until all provided cooldowns are + ready. Returns 0 if all cooldowns are ready (or don't exist.) + """ + now = time.time() + cooldowns = [self.data[x] - now for x in args if x in self.data] + if not cooldowns: + return 0 if use_int else 0.0 + left = max(max(cooldowns), 0) + return math.ceil(left) if use_int else left
+ +
[docs] def add(self, cooldown, seconds): + """ + Adds/sets a given cooldown to last for a specific amount of time. + + If this cooldown already exits, this call replaces it. + + Args: + cooldown (str): The name of the cooldown. + seconds (float or int): The number of seconds before this cooldown + is ready again. + """ + now = time.time() + self.data[cooldown] = now + (max(seconds, 0) if seconds else 0)
+ + set = add + +
[docs] def extend(self, cooldown, seconds): + """ + Adds a specific amount of time to an existing cooldown. + + If this cooldown is already ready, this is equivalent to calling set. If + the cooldown is not ready, it will be extended by the provided duration. + + Args: + cooldown (str): The name of the cooldown. + seconds (float or int): The number of seconds to extend this cooldown. + Returns: + float: The number of seconds until the cooldown will be ready again. + """ + time_left = self.time_left(cooldown) + (seconds if seconds else 0) + self.set(cooldown, time_left) + return max(time_left, 0)
+ +
[docs] def reset(self, cooldown): + """ + Resets a given cooldown. + + Args: + cooldown (str): The name of the cooldown. + """ + if cooldown in self.data: + del self.data[cooldown]
+ +
[docs] def clear(self): + """ + Resets all cooldowns. + """ + self.data.clear()
+ +
[docs] def cleanup(self): + """ + Deletes all expired cooldowns. This helps keep attribute storage + requirements small. + """ + now = time.time() + cooldowns = dict(self.data) + keys = [x for x in cooldowns.keys() if cooldowns[x] - now < 0] + if keys: + for key in keys: + del cooldowns[key] + self.obj.attributes.add(self.db_attribute, cooldowns) + self.data = self.obj.attributes.get(self.db_attribute)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/cooldowns/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/cooldowns/tests.html new file mode 100644 index 0000000000..c61e8cc7a3 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/cooldowns/tests.html @@ -0,0 +1,254 @@ + + + + + + + + evennia.contrib.game_systems.cooldowns.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.cooldowns.tests

+"""
+Cooldowns tests.
+
+"""
+
+from mock import patch
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import cooldowns
+
+
+
[docs]@patch("evennia.contrib.game_systems.cooldowns.cooldowns.time.time", return_value=0.0) +class TestCooldowns(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.handler = cooldowns.CooldownHandler(self.char1)
+ +
[docs] def test_empty(self, mock_time): + self.assertEqual(self.handler.all, []) + self.assertTrue(self.handler.ready("a", "b", "c")) + self.assertEqual(self.handler.time_left("a", "b", "c"), 0)
+ +
[docs] def test_add(self, mock_time): + self.assertEqual(self.handler.add, self.handler.set) + self.handler.add("a", 10) + self.assertFalse(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 10) + mock_time.return_value = 9.0 + self.assertFalse(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 1) + mock_time.return_value = 10.0 + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0)
+ +
[docs] def test_add_float(self, mock_time): + self.assertEqual(self.handler.time_left("a"), 0) + self.assertEqual(self.handler.time_left("a", use_int=False), 0) + self.assertEqual(self.handler.time_left("a", use_int=True), 0) + self.handler.add("a", 5.5) + self.assertEqual(self.handler.time_left("a"), 5.5) + self.assertEqual(self.handler.time_left("a", use_int=False), 5.5) + self.assertEqual(self.handler.time_left("a", use_int=True), 6)
+ +
[docs] def test_add_multi(self, mock_time): + self.handler.add("a", 10) + self.handler.add("b", 5) + self.handler.add("c", 3) + self.assertFalse(self.handler.ready("a", "b", "c")) + self.assertEqual(self.handler.time_left("a", "b", "c"), 10) + self.assertEqual(self.handler.time_left("a", "b"), 10) + self.assertEqual(self.handler.time_left("a", "c"), 10) + self.assertEqual(self.handler.time_left("b", "c"), 5) + self.assertEqual(self.handler.time_left("c", "c"), 3)
+ +
[docs] def test_add_none(self, mock_time): + self.handler.add("a", None) + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0)
+ +
[docs] def test_add_negative(self, mock_time): + self.handler.add("a", -5) + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0)
+ +
[docs] def test_add_overwrite(self, mock_time): + self.handler.add("a", 5) + self.handler.add("a", 10) + self.handler.add("a", 3) + self.assertFalse(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 3)
+ +
[docs] def test_extend(self, mock_time): + self.assertEqual(self.handler.extend("a", 10), 10) + self.assertFalse(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 10) + self.assertEqual(self.handler.extend("a", 10), 20) + self.assertFalse(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 20)
+ +
[docs] def test_extend_none(self, mock_time): + self.assertEqual(self.handler.extend("a", None), 0) + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0) + self.handler.add("a", 10) + self.assertEqual(self.handler.extend("a", None), 10) + self.assertEqual(self.handler.time_left("a"), 10)
+ +
[docs] def test_extend_negative(self, mock_time): + self.assertEqual(self.handler.extend("a", -5), 0) + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0) + self.handler.add("a", 10) + self.assertEqual(self.handler.extend("a", -5), 5) + self.assertEqual(self.handler.time_left("a"), 5)
+ +
[docs] def test_extend_float(self, mock_time): + self.assertEqual(self.handler.extend("a", -5.5), 0) + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0.0) + self.assertEqual(self.handler.time_left("a", use_int=False), 0.0) + self.assertEqual(self.handler.time_left("a", use_int=True), 0) + self.handler.add("a", 10.5) + self.assertEqual(self.handler.extend("a", -5.25), 5.25) + self.assertEqual(self.handler.time_left("a"), 5.25) + self.assertEqual(self.handler.time_left("a", use_int=False), 5.25) + self.assertEqual(self.handler.time_left("a", use_int=True), 6)
+ +
[docs] def test_reset_non_existent(self, mock_time): + self.handler.reset("a") + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0)
+ +
[docs] def test_reset(self, mock_time): + self.handler.set("a", 10) + self.handler.reset("a") + self.assertTrue(self.handler.ready("a")) + self.assertEqual(self.handler.time_left("a"), 0)
+ +
[docs] def test_clear(self, mock_time): + self.handler.add("a", 10) + self.handler.add("b", 10) + self.handler.add("c", 10) + self.handler.clear() + self.assertTrue(self.handler.ready("a", "b", "c")) + self.assertEqual(self.handler.time_left("a", "b", "c"), 0)
+ +
[docs] def test_cleanup(self, mock_time): + self.handler.add("a", 10) + self.handler.add("b", 5) + self.handler.add("c", 5) + self.handler.add("d", 3.5) + mock_time.return_value = 6.0 + self.handler.cleanup() + self.assertEqual(self.handler.time_left("b", "c", "d"), 0) + self.assertEqual(self.handler.time_left("a"), 4) + self.assertEqual(list(self.handler.data.keys()), ["a"])
+ +
[docs] def test_cleanup_doesnt_delete_anything(self, mock_time): + self.handler.add("a", 10) + self.handler.add("b", 5) + self.handler.add("c", 5) + self.handler.add("d", 3.5) + mock_time.return_value = 1.0 + self.handler.cleanup() + self.assertEqual(self.handler.time_left("d"), 2.5) + self.assertEqual(self.handler.time_left("b", "c"), 4) + self.assertEqual(self.handler.time_left("a"), 9) + self.assertEqual(list(self.handler.data.keys()), ["a", "b", "c", "d"])
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/crafting/crafting.html b/docs/latest/_modules/evennia/contrib/game_systems/crafting/crafting.html new file mode 100644 index 0000000000..15b64171e2 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/crafting/crafting.html @@ -0,0 +1,1200 @@ + + + + + + + + evennia.contrib.game_systems.crafting.crafting — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.crafting.crafting

+"""
+Crafting - Griatch 2020
+
+This is a general crafting engine. The basic functionality of crafting is to
+combine any number of of items or tools in a 'recipe' to produce a new result.
+
+    item + item + item + tool + tool  -> recipe -> new result
+
+This is useful not only for traditional crafting but the engine is flexible
+enough to also be useful for puzzles or similar.
+
+## Installation
+
+- Add the `CmdCraft` Command from this module to your default cmdset. This
+  allows for crafting from in-game using a simple syntax.
+- Create a new module and add it to a new list in your settings file
+  (`server/conf/settings.py`) named `CRAFT_RECIPES_MODULES`, such as
+  `CRAFT_RECIPE_MODULES = ["world.recipes_weapons"]`.
+- In the new module(s), create one or more classes, each a child of
+  `CraftingRecipe` from this module. Each such class must have a unique `.name`
+  property. It also defines what inputs are required and what is created using
+  this recipe.
+- Objects to use for crafting should (by default) be tagged with tags using the
+  tag-category `crafting_material` or `crafting_tool`. The name of the object
+  doesn't matter, only its tag.
+
+## Crafting in game
+
+The default `craft` command handles all crafting needs.
+::
+
+    > craft spiked club from club, nails
+
+Here, `spiked club` specifies the recipe while `club` and `nails` are objects
+the crafter must have in their inventory. These will be consumed during
+crafting (by default only if crafting was successful).
+
+A recipe can also require *tools* (like the `hammer` above). These must be
+either in inventory *or* be in the current location. Tools are *not* consumed
+during the crafting process.
+::
+
+    > craft wooden doll from wood with knife
+
+## Crafting in code
+
+In code, you should use the helper function `craft` from this module. This
+specifies the name of the recipe to use and expects all suitable
+ingredients/tools as arguments (consumables and tools should be added together,
+tools will be identified before consumables).
+
+```python
+
+    from evennia.contrib.game_systems.crafting import crafting
+
+    spiked_club = crafting.craft(crafter, "spiked club", club, nails)
+
+```
+
+The result is always a list with zero or more objects. A fail leads to an empty
+list. The crafter should already have been notified of any error in this case
+(this should be handle by the recipe itself).
+
+## Recipes
+
+A *recipe* is a class that works like an input/output blackbox: you initialize
+it with consumables (and/or tools) if they match the recipe, a new
+result is spit out.  Consumables are consumed in the process while tools are not.
+
+This module contains a base class for making new ingredient types
+(`CraftingRecipeBase`) and an implementation of the most common form of
+crafting (`CraftingRecipe`) using objects and prototypes.
+
+Recipes are put in one or more modules added as a list to the
+`CRAFT_RECIPE_MODULES` setting, for example:
+
+```python
+
+    CRAFT_RECIPE_MODULES = ['world.recipes_weapons', 'world.recipes_potions']
+
+```
+
+Below is an example of a crafting recipe and how `craft` calls it under the
+hood. See the `CraftingRecipe` class for details of which properties and
+methods are available to override - the craft behavior can be modified
+substantially this way.
+
+```python
+
+    from evennia.contrib.game_systems.crafting.crafting import CraftingRecipe
+
+    class PigIronRecipe(CraftingRecipe):
+        # Pig iron is a high-carbon result of melting iron in a blast furnace.
+
+        name = "pig iron"  # this is what crafting.craft and CmdCraft uses
+        tool_tags = ["blast furnace"]
+        consumable_tags = ["iron ore", "coal", "coal"]
+        output_prototypes = [
+            {"key": "Pig Iron ingot",
+             "desc": "An ingot of crude pig iron.",
+             "tags": [("pig iron", "crafting_material")]}
+        ]
+
+    # for testing, conveniently spawn all we need based on the tags on the class
+    tools, consumables = PigIronRecipe.seed()
+
+    recipe = PigIronRecipe(caller, *(tools + consumables))
+    result = recipe.craft()
+
+```
+
+If the above class was added to a module in `CRAFT_RECIPE_MODULES`, it could be
+called using its `.name` property, as "pig iron".
+
+The [example_recipies](api:evennia.contrib.game_systems.crafting.example_recipes) module has
+a full example of the components for creating a sword from base components.
+
+----
+
+"""
+
+import functools
+from copy import copy
+
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.command import Command
+from evennia.prototypes.spawner import spawn
+from evennia.utils.create import create_object
+from evennia.utils.utils import (
+    callables_from_module,
+    inherits_from,
+    iter_to_str,
+    make_iter,
+)
+
+_RECIPE_CLASSES = {}
+
+
+def _load_recipes():
+    """
+    Delayed loading of recipe classes. This parses
+    `settings.CRAFT_RECIPE_MODULES`.
+
+    """
+    from django.conf import settings
+
+    global _RECIPE_CLASSES
+    if not _RECIPE_CLASSES:
+        paths = ["evennia.contrib.game_systems.crafting.example_recipes"]
+        if hasattr(settings, "CRAFT_RECIPE_MODULES"):
+            paths += make_iter(settings.CRAFT_RECIPE_MODULES)
+        for path in paths:
+            for cls in callables_from_module(path).values():
+                if inherits_from(cls, CraftingRecipeBase):
+                    _RECIPE_CLASSES[cls.name] = cls
+
+
+
[docs]class CraftingError(RuntimeError): + """ + Crafting error. + + """
+ + +
[docs]class CraftingValidationError(CraftingError): + """ + Error if crafting validation failed. + + """
+ + +
[docs]class CraftingRecipeBase: + """ + The recipe handles all aspects of performing a 'craft' operation. This is + the base of the crafting system, intended to be replace if you want to + adapt it for very different functionality - see the `CraftingRecipe` child + class for an implementation of the most common type of crafting using + objects. + + Example of usage: + :: + + recipe = CraftRecipe(crafter, obj1, obj2, obj3) + result = recipe.craft() + + Note that the most common crafting operation is that the inputs are + consumed - so in that case the recipe cannot be used a second time (doing so + will raise a `CraftingError`) + + Process: + + 1. `.craft(**kwargs)` - this starts the process on the initialized recipe. The kwargs + are optional but will be passed into all of the following hooks. + 2. `.pre_craft(**kwargs)` - this normally validates inputs and stores them in + `.validated_inputs.`. Raises `CraftingValidationError` otherwise. + 4. `.do_craft(**kwargs)` - should return the crafted item(s) or the empty list. Any + crafting errors should be immediately reported to user. + 5. `.post_craft(crafted_result, **kwargs)`- always called, even if `pre_craft` + raised a `CraftingError` or `CraftingValidationError`. + Should return `crafted_result` (modified or not). + + + """ + + name = "recipe base" + + # if set, allow running `.craft` more than once on the same instance. + # don't set this unless crafting inputs are *not* consumed by the crafting + # process (otherwise subsequent calls will fail). + allow_reuse = False + +
[docs] def __init__(self, crafter, *inputs, **kwargs): + """ + Initialize the recipe. + + Args: + crafter (Object): The one doing the crafting. + *inputs (any): The ingredients of the recipe to use. + **kwargs (any): Any other parameters that are relevant for + this recipe. + + """ + self.crafter = crafter + self.inputs = inputs + self.craft_kwargs = kwargs + self.allow_craft = True + self.validated_inputs = []
+ +
[docs] def msg(self, message, **kwargs): + """ + Send message to crafter. This is a central point to override if wanting + to change crafting return style in some way. + + Args: + message(str): The message to send. + **kwargs: Any optional properties relevant to this send. + + """ + self.crafter.msg(message, {"type": "crafting"})
+ +
[docs] def pre_craft(self, **kwargs): + """ + Hook to override. + + This is called just before crafting operation and is normally + responsible for validating the inputs, storing data on + `self.validated_inputs`. + + Args: + **kwargs: Optional extra flags passed during initialization or + `.craft(**kwargs)`. + + Raises: + CraftingValidationError: If validation fails. + + """ + if self.allow_craft: + self.validated_inputs = self.inputs[:] + else: + raise CraftingValidationError
+ +
[docs] def do_craft(self, **kwargs): + """ + Hook to override. + + This performs the actual crafting. At this point the inputs are + expected to have been verified already. If needed, the validated + inputs are available on this recipe instance. + + Args: + **kwargs: Any extra flags passed at initialization. + + Returns: + any: The result of crafting. + + """ + return None
+ +
[docs] def post_craft(self, crafting_result, **kwargs): + """ + Hook to override. + + This is called just after crafting has finished. A common use of this + method is to delete the inputs. + + Args: + crafting_result (any): The outcome of crafting, as returned by `do_craft`. + **kwargs: Any extra flags passed at initialization. + + Returns: + any: The final crafting result. + + """ + return crafting_result
+ +
[docs] def craft(self, raise_exception=False, **kwargs): + """ + Main crafting call method. Call this to produce a result and make + sure all hooks run correctly. + + Args: + raise_exception (bool): If crafting would return `None`, raise + exception instead. + **kwargs (any): Any other parameters that is relevant + for this particular craft operation. This will temporarily + override same-named kwargs given at the creation of this recipe + and be passed into all of the crafting hooks. + + Returns: + any: The result of the craft, or `None` if crafting failed. + + Raises: + CraftingValidationError: If recipe validation failed and + `raise_exception` is True. + CraftingError: On If trying to rerun a no-rerun recipe, or if crafting + would return `None` and raise_exception` is set. + + """ + craft_result = None + if self.allow_craft: + # override/extend craft_kwargs from initialization. + craft_kwargs = copy(self.craft_kwargs) + craft_kwargs.update(kwargs) + + try: + try: + # this assigns to self.validated_inputs + self.pre_craft(**craft_kwargs) + except (CraftingError, CraftingValidationError): + if raise_exception: + raise + else: + craft_result = self.do_craft(**craft_kwargs) + finally: + craft_result = self.post_craft(craft_result, **craft_kwargs) + except (CraftingError, CraftingValidationError): + if raise_exception: + raise + + # possibly turn off re-use depending on class setting + self.allow_craft = self.allow_reuse + elif not self.allow_reuse: + raise CraftingError("Cannot re-run crafting without re-initializing recipe first.") + if craft_result is None and raise_exception: + raise CraftingError(f"Crafting of {self.name} failed.") + return craft_result
+ + +
[docs]class NonExistentRecipe(CraftingRecipeBase): + """A recipe that does not exist and never produces anything.""" + + allow_craft = True + allow_reuse = True + +
[docs] def __init__(self, crafter, *inputs, name="", **kwargs): + super().__init__(crafter, *inputs, **kwargs) + self.name = name
+ +
[docs] def pre_craft(self, **kwargs): + msg = f"Unknown recipe '{self.name}'" + self.msg(msg) + raise CraftingError(msg)
+ + +
[docs]class CraftingRecipe(CraftingRecipeBase): + """ + The CraftRecipe implements the most common form of crafting: Combining (and + consuming) inputs to produce a new result. This type of recipe only works + with typeclassed entities as inputs and outputs, since it's based on Tags + and Prototypes. + + There are two types of crafting ingredients: 'tools' and 'consumables'. The + difference between them is that the former is not consumed in the crafting + process. So if you need a hammer and anvil to craft a sword, they are + 'tools' whereas the materials of the sword are 'consumables'. + + Examples: + :: + + class FlourRecipe(CraftRecipe): + name = "flour" + tool_tags = ['windmill'] + consumable_tags = ["wheat"] + output_prototypes = [ + {"key": "Bag of flour", + "typeclass": "typeclasses.food.Flour", + "desc": "A small bag of flour." + "tags": [("flour", "crafting_material")], + } + ] + + class BreadRecipe(CraftRecipe): + name = "bread" + tool_tags = ["roller", "owen"] + consumable_tags = ["flour", "egg", "egg", "salt", "water", "yeast"] + output_prototypes = [ + {"key": "bread", + "desc": "A tasty bread." + } + ] + + + ## Properties on the class level: + + - `name` (str): The name of this recipe. This should be globally unique. + + ### tools + + - `tool_tag_category` (str): What tag-category tools must use. Default is + 'crafting_tool'. + - `tool_tags` (list): Object-tags to use for tooling. If more than one instace + of a tool is needed, add multiple entries here. + - `tool_names` (list): Human-readable names for tools. These are used for informative + messages/errors. If not given, the tags will be used. If given, this list should + match the length of `tool_tags`.: + - `exact_tools` (bool, default True): Must have exactly the right tools, any extra + leads to failure. + - `exact_tool_order` (bool, default False): Tools must be added in exactly the + right order for crafting to pass. + + ### consumables + + - `consumable_tag_category` (str): What tag-category consumables must use. + Default is 'crafting_material'. + - `consumable_tags` (list): Tags for objects that will be consumed as part of + running the recipe. + - `consumable_names` (list): Human-readable names for consumables. Same as for tools. + - `exact_consumables` (bool, default True): Normally, adding more consumables + than needed leads to a a crafting error. If this is False, the craft will + still succeed (only the needed ingredients will be consumed). + - `exact_consumable_order` (bool, default False): Normally, the order in which + ingredients are added does not matter. With this set, trying to add consumables in + another order than given will lead to failing crafting. + - `consume_on_fail` (bool, default False): Normally, consumables remain if + crafting fails. With this flag, a failed crafting will still consume + consumables. Note that this will also consume any 'extra' consumables + added not part of the recipe! + + ### outputs (result of crafting) + + - `output_prototypes` (list): One or more prototypes (`prototype_keys` or + full dicts) describing how to create the result(s) of this recipe. + - `output_names` (list): Human-readable names for (prospective) prototypes. + This is used in error messages. If not given, this is extracted from the + prototypes' `key` if possible. + + ### custom error messages + + custom messages all have custom formatting markers. Many are empty strings + when not applicable. + :: + + {missing}: Comma-separated list of tool/consumable missing for missing/out of order errors. + {excess}: Comma-separated list of tool/consumable added in excess of recipe + {inputs}: Comma-separated list of any inputs (tools + consumables) involved in error. + {tools}: Comma-sepatated list of tools involved in error. + {consumables}: Comma-separated list of consumables involved in error. + {outputs}: Comma-separated list of (expected) outputs + {t0}..{tN-1}: Individual tools, same order as `.tool_names`. + {c0}..{cN-1}: Individual consumables, same order as `.consumable_names`. + {o0}..{oN-1}: Individual outputs, same order as `.output_names`. + + - `error_tool_missing_message`: "Could not craft {outputs} without {missing}." + - `error_tool_order_message`: + "Could not craft {outputs} since {missing} was added in the wrong order." + - `error_tool_excess_message`: "Could not craft {outputs} (extra {excess})." + - `error_consumable_missing_message`: "Could not craft {outputs} without {missing}." + - `error_consumable_order_message`: + "Could not craft {outputs} since {missing} was added in the wrong order." + - `error_consumable_excess_message`: "Could not craft {outputs} (excess {excess})." + - `success_message`: "You successfuly craft {outputs}!" + - `failure_message`: "" (this is handled by the other error messages by default) + + ## Hooks + + 1. Crafting starts by calling `.craft(**kwargs)` on the parent class. The + `**kwargs` are optional, extends any `**kwargs` passed to the class + constructor and will be passed into all the following hooks. + 3. `.pre_craft(**kwargs)` should handle validation of inputs. Results should + be stored in `validated_consumables/tools` respectively. Raises `CraftingValidationError` + otherwise. + 4. `.do_craft(**kwargs)` will not be called if validation failed. Should return + a list of the things crafted. + 5. `.post_craft(crafting_result, **kwargs)` is always called, also if validation + failed (`crafting_result` will then be falsy). It does any cleanup. By default + this deletes consumables. + + Use `.msg` to conveniently send messages to the crafter. Raise + `evennia.contrib.game_systems.crafting.crafting.CraftingError` exception to abort + crafting at any time in the sequence. If raising with a text, this will be + shown to the crafter automatically + + """ + + name = "crafting recipe" + + # this define the overall category all material tags must have + consumable_tag_category = "crafting_material" + # tag category for tool objects + tool_tag_category = "crafting_tool" + + # the tools needed to perform this crafting. Tools are never consumed (if they were, + # they'd need to be a consumable). If more than one instance of a tool is needed, + # there should be multiple entries in this list. + tool_tags = [] + # human-readable names for the tools. This will be used for informative messages + # or when usage fails. If empty, use tag-names. + tool_names = [] + # if we must have exactly the right tools, no more + exact_tools = True + # if the order of the tools matters + exact_tool_order = False + # error to show if missing tools + error_tool_missing_message = "Could not craft {outputs} without {missing}." + # error to show if tool-order matters and it was wrong. Missing is the first + # tool out of order + error_tool_order_message = ( + "Could not craft {outputs} since {missing} was added in the wrong order." + ) + # if .exact_tools is set and there are more than needed + error_tool_excess_message = ( + "Could not craft {outputs} without the exact tools (extra {excess})." + ) + + # a list of tag-keys (of the `tag_category`). If more than one of each type + # is needed, there should be multiple same-named entries in this list. + consumable_tags = [] + # these are human-readable names for the items to use. This is used for informative + # messages or when usage fails. If empty, the tag-names will be used. If given, this + # must have the same length as `consumable_tags`. + consumable_names = [] + # if True, consume valid inputs also if crafting failed (returned None) + consume_on_fail = False + # if True, having any wrong input result in failing the crafting. If False, + # extra components beyond the recipe are ignored. + exact_consumables = True + # if True, the exact order in which inputs are provided matters and must match + # the order of `consumable_tags`. If False, order doesn't matter. + exact_consumable_order = False + # error to show if missing consumables + error_consumable_missing_message = "Could not craft {outputs} without {missing}." + # error to show if consumable order matters and it was wrong. Missing is the first + # consumable out of order + error_consumable_order_message = ( + "Could not craft {outputs} since {missing} was added in the wrong order." + ) + # if .exact_consumables is set and there are more than needed + error_consumable_excess_message = ( + "Could not craft {outputs} without the exact ingredients (extra {excess})." + ) + + # this is a list of one or more prototypes (prototype_keys to existing + # prototypes or full prototype-dicts) to use to build the result. All of + # these will be returned (as a list) if crafting succeeded. + output_prototypes = [] + # human-readable name(s) for the (expected) result of this crafting. This will usually only + # be used for error messages (to report what would have been). If not given, the + # prototype's key or typeclass will be used. If given, this must have the same length + # as `output_prototypes`. + output_names = [] + # general craft-failure msg to show after other error-messages. + failure_message = "" + # show after a successful craft + success_message = "You successfully craft {outputs}!" + +
[docs] def __init__(self, crafter, *inputs, **kwargs): + """ + Args: + crafter (Object): The one doing the crafting. + *inputs (Object): The ingredients (+tools) of the recipe to use. The + The recipe will itself figure out (from tags) which is a tool and + which is a consumable. + **kwargs (any): Any other parameters that are relevant for + this recipe. These will be passed into the crafting hooks. + + Notes: + Internally, this class stores validated data in + `.validated_consumables` and `.validated_tools` respectively. The + `.validated_inputs` property (from parent) holds a list of everything + types in the order inserted to the class constructor. + + """ + + super().__init__(crafter, *inputs, **kwargs) + + self.validated_consumables = [] + self.validated_tools = [] + + # validate class properties + if self.consumable_names: + assert len(self.consumable_names) == len(self.consumable_tags), ( + f"Crafting {self.__class__}.consumable_names list must " + "have the same length as .consumable_tags." + ) + else: + self.consumable_names = self.consumable_tags + + if self.tool_names: + assert len(self.tool_names) == len(self.tool_tags), ( + f"Crafting {self.__class__}.tool_names list must " + "have the same length as .tool_tags." + ) + else: + self.tool_names = self.tool_tags + + assert isinstance( + self.output_prototypes, (list, tuple) + ), "Crafting {self.__class__}.output_prototypes must be a list or tuple." + + if self.output_names: + assert len(self.output_names) == len(self.output_prototypes), ( + f"Crafting {self.__class__}.output_names list must " + "have the same length as .output_prototypes." + ) + else: + self.output_names = [ + prot.get("key", prot.get("typeclass", "unnamed")) + if isinstance(prot, dict) + else str(prot) + for prot in self.output_prototypes + ] + + # don't allow reuse if we have consumables. If only tools we can reuse + # over and over since nothing changes. + self.allow_reuse = not bool(self.consumable_tags)
+ + def _format_message(self, message, **kwargs): + missing = iter_to_str(kwargs.get("missing", "")) + excess = iter_to_str(kwargs.get("excess", "")) + involved_tools = iter_to_str(kwargs.get("tools", "")) + involved_cons = iter_to_str(kwargs.get("consumables", "")) + + # build template context + mapping = {"missing": missing, "excess": excess} + mapping.update( + { + f"i{ind}": self.consumable_names[ind] + for ind, name in enumerate(self.consumable_names or self.consumable_tags) + } + ) + mapping.update( + {f"o{ind}": self.output_names[ind] for ind, name in enumerate(self.output_names)} + ) + mapping["tools"] = involved_tools + mapping["consumables"] = involved_cons + + mapping["inputs"] = iter_to_str(self.consumable_names) + mapping["outputs"] = iter_to_str(self.output_names) + + # populate template and return + return message.format_map(mapping) + +
[docs] @classmethod + def seed(cls, tool_kwargs=None, consumable_kwargs=None, location=None): + """ + This is a helper class-method for easy testing and application of this + recipe. When called, it will create simple dummy ingredients with names + and tags needed by this recipe. + + Args: + tool_kwargs (dict, optional): Will be passed as `**tool_kwargs` into the `create_object` + call for each tool. If not given, the matching + `tool_name` or `tool_tag` will be used for key. + consumable_kwargs (dict, optional): This will be passed as + `**consumable_kwargs` into the `create_object` call for each consumable. + If not given, matching `consumable_name` or `consumable_tag` + will be used for key. + location (Object, optional): If given, the created items will be created in this + location. This is a shortcut for adding {"location": <obj>} to both the + consumable/tool kwargs (and will *override* any such setting in those kwargs). + + Returns: + tuple: A tuple `(tools, consumables)` with newly created dummy + objects matching the recipe ingredient list. + + Example: + :: + tools, consumables = SwordRecipe.seed(location=caller) + recipe = SwordRecipe(caller, *(tools + consumables)) + result = recipe.craft() + + Notes: + If `key` is given in `consumable/tool_kwargs` then _every_ created item + of each type will have the same key. + + """ + if not tool_kwargs: + tool_kwargs = {} + if not consumable_kwargs: + consumable_kwargs = {} + + if location: + tool_kwargs["location"] = location + consumable_kwargs["location"] = location + + tool_key = tool_kwargs.pop("key", None) + cons_key = consumable_kwargs.pop("key", None) + tool_tags = tool_kwargs.pop("tags", []) + cons_tags = consumable_kwargs.pop("tags", []) + + tools = [] + for itag, tag in enumerate(cls.tool_tags): + tools.append( + create_object( + key=tool_key or (cls.tool_names[itag] if cls.tool_names else tag.capitalize()), + tags=[(tag, cls.tool_tag_category), *tool_tags], + **tool_kwargs, + ) + ) + consumables = [] + for itag, tag in enumerate(cls.consumable_tags): + consumables.append( + create_object( + key=cons_key + or (cls.consumable_names[itag] if cls.consumable_names else tag.capitalize()), + tags=[(tag, cls.consumable_tag_category), *cons_tags], + **consumable_kwargs, + ) + ) + return tools, consumables
+ +
[docs] def pre_craft(self, **kwargs): + """ + Do pre-craft checks, including input validation. + + Check so the given inputs are what is needed. This operates on + `self.inputs` which is set to the inputs added to the class + constructor. Validated data is stored as lists on `.validated_tools` + and `.validated_consumables` respectively. + + Args: + **kwargs: Any optional extra kwargs passed during initialization of + the recipe class. + + Raises: + CraftingValidationError: If validation fails. At this point the crafter + is expected to have been informed of the problem already. + + """ + + def _check_completeness( + tagmap, + taglist, + namelist, + exact_match, + exact_order, + error_missing_message, + error_order_message, + error_excess_message, + ): + """Compare tagmap (inputs) to taglist (required)""" + valids = [] + for itag, tagkey in enumerate(taglist): + found_obj = None + for obj, objtags in tagmap.items(): + if tagkey in objtags: + found_obj = obj + break + if exact_order: + # if we get here order is wrong + err = self._format_message( + error_order_message, missing=obj.get_display_name(looker=self.crafter) + ) + self.msg(err) + raise CraftingValidationError(err) + + # since we pop from the mapping, it gets ever shorter + match = tagmap.pop(found_obj, None) + if match: + valids.append(found_obj) + elif exact_match: + err = self._format_message( + error_missing_message, + missing=namelist[itag] if namelist else tagkey.capitalize(), + ) + self.msg(err) + raise CraftingValidationError(err) + + if exact_match and tagmap: + # something is left in tagmap, that means it was never popped and + # thus this is not an exact match + err = self._format_message( + error_excess_message, + excess=[obj.get_display_name(looker=self.crafter) for obj in tagmap], + ) + self.msg(err) + raise CraftingValidationError(err) + + return valids + + # get tools and consumables from self.inputs + tool_map = { + obj: obj.tags.get(category=self.tool_tag_category, return_list=True) + for obj in self.inputs + if obj + and hasattr(obj, "tags") + and inherits_from(obj, "evennia.objects.models.ObjectDB") + } + tool_map = {obj: tags for obj, tags in tool_map.items() if tags} + consumable_map = { + obj: obj.tags.get(category=self.consumable_tag_category, return_list=True) + for obj in self.inputs + if obj + and hasattr(obj, "tags") + and obj not in tool_map + and inherits_from(obj, "evennia.objects.models.ObjectDB") + } + consumable_map = {obj: tags for obj, tags in consumable_map.items() if tags} + + # we set these so they are available for error management at all times, + # they will be updated with the actual values at the end + self.validated_tools = [obj for obj in tool_map] + self.validated_consumables = [obj for obj in consumable_map] + + tools = _check_completeness( + tool_map, + self.tool_tags, + self.tool_names, + self.exact_tools, + self.exact_tool_order, + self.error_tool_missing_message, + self.error_tool_order_message, + self.error_tool_excess_message, + ) + consumables = _check_completeness( + consumable_map, + self.consumable_tags, + self.consumable_names, + self.exact_consumables, + self.exact_consumable_order, + self.error_consumable_missing_message, + self.error_consumable_order_message, + self.error_consumable_excess_message, + ) + + # regardless of flags, the tools/consumable lists much contain exactly + # all the recipe needs now. + if len(tools) != len(self.tool_tags): + raise CraftingValidationError( + f"Tools {tools}'s tags do not match expected tags {self.tool_tags}" + ) + if len(consumables) != len(self.consumable_tags): + raise CraftingValidationError( + f"Consumables {consumables}'s tags do not match " + f"expected tags {self.consumable_tags}" + ) + + self.validated_tools = tools + self.validated_consumables = consumables
+ +
[docs] def do_craft(self, **kwargs): + """ + Hook to override. This will not be called if validation in `pre_craft` + fails. + + This performs the actual crafting. At this point the inputs are + expected to have been verified already. + + Returns: + list: A list of spawned objects created from the inputs, or None + on a failure. + + Notes: + This method should use `self.msg` to inform the user about the + specific reason of failure immediately. + We may want to analyze the tools in some way here to affect the + crafting process. + + """ + return spawn(*self.output_prototypes)
+ +
[docs] def post_craft(self, craft_result, **kwargs): + """ + Hook to override. + This is called just after crafting has finished. A common use of + this method is to delete the inputs. + + Args: + craft_result (list): The crafted result, provided by `self.do_craft`. + **kwargs (any): Passed from `self.craft`. + + Returns: + list: The return(s) of the craft, possibly modified in this method. + + Notes: + This is _always_ called, also if validation in `pre_craft` fails + (`craft_result` will then be `None`). + + """ + if craft_result: + self.msg(self._format_message(self.success_message)) + elif self.failure_message: + self.msg(self._format_message(self.failure_message)) + + if craft_result or self.consume_on_fail: + # consume the inputs + for obj in self.validated_consumables: + obj.delete() + + return craft_result
+ + +# access function + + +
[docs]def craft(crafter, recipe_name, *inputs, raise_exception=False, **kwargs): + """ + Access function. Craft a given recipe from a source recipe module. A + recipe module is a Python module containing recipe classes. Note that this + requires `settings.CRAFT_RECIPE_MODULES` to be added to a list of one or + more python-paths to modules holding Recipe-classes. + + Args: + crafter (Object): The one doing the crafting. + recipe_name (str): The `CraftRecipe.name` to use. This uses fuzzy-matching + if the result is unique. + *inputs: Suitable ingredients and/or tools (Objects) to use in the crafting. + raise_exception (bool, optional): If crafting failed for whatever + reason, raise `CraftingError`. The user will still be informed by the + recipe. + **kwargs: Optional kwargs to pass into the recipe (will passed into + recipe.craft). + + Returns: + list: Crafted objects, if any. + + Raises: + CraftingError: If `raise_exception` is True and crafting failed to + produce an output. KeyError: If `recipe_name` failed to find a + matching recipe class (or the hit was not precise enough.) + + Notes: + If no recipe_module is given, will look for a list `settings.CRAFT_RECIPE_MODULES` and + lastly fall back to the example module + `"evennia.contrib.game_systems.crafting.example_recipes"` + + """ + # delayed loading/caching of recipes + _load_recipes() + + RecipeClass = _RECIPE_CLASSES.get(recipe_name, None) + if not RecipeClass: + # try a startswith fuzzy match + matches = [key for key in _RECIPE_CLASSES if key.startswith(recipe_name)] + if not matches: + # try in-match + matches = [key for key in _RECIPE_CLASSES if recipe_name in key] + if len(matches) == 1: + RecipeClass = matches[0] + + if not RecipeClass: + if raise_exception: + raise KeyError( + f"No recipe in settings.CRAFT_RECIPE_MODULES has a name matching {recipe_name}" + ) + else: + RecipeClass = functools.partial(NonExistentRecipe, name=recipe_name) + recipe = RecipeClass(crafter, *inputs, **kwargs) + return recipe.craft(raise_exception=raise_exception)
+ + +# craft command/cmdset + + +
[docs]class CraftingCmdSet(CmdSet): + """ + Store crafting command. + """ + + key = "Crafting cmdset" + +
[docs] def at_cmdset_creation(self): + self.add(CmdCraft())
+ + +
[docs]class CmdCraft(Command): + """ + Craft an item using ingredients and tools + + Usage: + craft <recipe> [from <ingredient>,...] [using <tool>, ...] + + Examples: + craft snowball from snow + craft puppet from piece of wood using knife + craft bread from flour, butter, water, yeast using owen, bowl, roller + craft fireball using wand, spellbook + + Notes: + Ingredients must be in the crafter's inventory. Tools can also be + things in the current location, like a furnace, windmill or anvil. + + """ + + key = "craft" + locks = "cmd:all()" + help_category = "General" + arg_regex = r"\s|$" + +
[docs] def parse(self): + """ + Handle parsing of: + :: + + <recipe> [FROM <ingredients>] [USING <tools>] + + Examples: + :: + + craft snowball from snow + craft puppet from piece of wood using knife + craft bread from flour, butter, water, yeast using owen, bowl, roller + craft fireball using wand, spellbook + + """ + self.args = args = self.args.strip().lower() + recipe, ingredients, tools = "", "", "" + + if "from" in args: + recipe, *rest = args.split(" from ", 1) + rest = rest[0] if rest else "" + ingredients, *tools = rest.split(" using ", 1) + elif "using" in args: + recipe, *tools = args.split(" using ", 1) + tools = tools[0] if tools else "" + + self.recipe = recipe.strip() + self.ingredients = [ingr.strip() for ingr in ingredients.split(",")] + self.tools = [tool.strip() for tool in tools.split(",")]
+ +
[docs] def func(self): + """ + Perform crafting. + + Will check the `craft` locktype. If a consumable/ingredient does not pass + this check, we will check for the 'crafting_consumable_err_msg' + Attribute, otherwise will use a default. If failing on a tool, will use + the `crafting_tool_err_msg` if available. + + """ + caller = self.caller + + if not self.args or not self.recipe: + self.caller.msg("Usage: craft <recipe> from <ingredient>, ... [using <tool>,...]") + return + + ingredients = [] + for ingr_key in self.ingredients: + if not ingr_key: + continue + obj = caller.search(ingr_key, location=self.caller) + # since ingredients are consumed we need extra check so we don't + # try to include characters or accounts etc. + if not obj: + return + if ( + not inherits_from(obj, "evennia.objects.models.ObjectDB") + or obj.sessions.all() + or not obj.access(caller, "craft", default=True) + ): + # We don't allow to include puppeted objects nor those with the + # 'negative' permission 'nocraft'. + caller.msg( + obj.attributes.get( + "crafting_consumable_err_msg", + default=f"{obj.get_display_name(looker=caller)} can't be used for this.", + ) + ) + return + ingredients.append(obj) + + tools = [] + for tool_key in self.tools: + if not tool_key: + continue + # tools are not consumed, can also exist in the current room + obj = caller.search(tool_key) + if not obj: + return None + if not obj.access(caller, "craft", default=True): + caller.msg( + obj.attributes.get( + "crafting_tool_err_msg", + default=f"{obj.get_display_name(looker=caller)} can't be used for this.", + ) + ) + return + tools.append(obj) + + # perform craft and make sure result is in inventory + # (the recipe handles all returns to caller) + result = craft(caller, self.recipe, *(tools + ingredients)) + if result: + for obj in result: + obj.location = caller
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/crafting/example_recipes.html b/docs/latest/_modules/evennia/contrib/game_systems/crafting/example_recipes.html new file mode 100644 index 0000000000..85297fe78e --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/crafting/example_recipes.html @@ -0,0 +1,644 @@ + + + + + + + + evennia.contrib.game_systems.crafting.example_recipes — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.crafting.example_recipes

+"""
+How to make a sword - example crafting tree for the crafting system.
+
+See the `SwordSmithingBaseRecipe` in this module for an example of extendng the
+recipe with a mocked 'skill' system (just random chance in our case). The skill
+system used is game-specific but likely to be needed for most 'real' crafting
+systems.
+
+Note that 'tools' are references to the tools used - they don't need to be in
+the inventory of the crafter. So when 'blast furnace' is given below, it is a
+reference to a blast furnace used, not suggesting the crafter is carrying it
+around with them.
+
+## Sword crafting tree
+
+::
+
+    # base materials (consumables)
+
+    iron ore, ash, sand, coal, oak wood, water, fur
+
+    # base tools (marked with [T] for clarity and assumed to already exist)
+
+    blast furnace[T], furnace[T], crucible[T], anvil[T],
+    hammer[T], knife[T], cauldron[T]
+
+    # recipes for making a sword
+
+    pig iron = iron ore + 2xcoal + blast furnace[T]
+    crucible_steel = pig iron + ash + sand + 2xcoal + crucible[T]
+    sword blade = crucible steel + hammer[T] + anvil[T] + furnace[T]
+    sword pommel = crucible steel + hammer[T] + anvil[T] + furnace[T]
+    sword guard = crucible steel + hammer[T] + anvil[T] + furnace[T]
+
+    rawhide = fur + knife[T]
+    oak bark + cleaned oak wood = oak wood + knife[T]
+    leather = rawhide + oak bark + water + cauldron[T]
+
+    sword handle = cleaned oak wood + knife[T]
+
+    sword = sword blade + sword guard + sword pommel
+            + sword handle + leather + knife[T] + hammer[T] + furnace[T]
+
+
+## Recipes used for spell casting
+
+This is a simple example modifying the base Recipe to use as a way
+to describe magical spells instead. It combines tools with
+a skill (an attribute on the caster) in order to produce a magical effect.
+
+The example `CmdCast` command can be added to the CharacterCmdset in
+`mygame/commands/default_cmdsets` to test it out. The 'effects' are
+just mocked for the example.
+
+::
+    # base tools (assumed to already exist)
+
+    spellbook[T], wand[T]
+
+    # skill (stored as Attribute on caster)
+
+    firemagic skill level10+
+
+    # recipe for fireball
+
+    fireball = spellbook[T] + wand[T] + [firemagic skill lvl10+]
+
+----
+
+"""
+
+from random import randint, random
+
+from evennia.commands.command import Command, InterruptCommand
+
+from .crafting import CraftingRecipe, CraftingValidationError, craft
+
+# ------------------------------------------------------------
+# Sword recipe
+# ------------------------------------------------------------
+
+
+
[docs]class PigIronRecipe(CraftingRecipe): + """ + Pig iron is a high-carbon result of melting iron in a blast furnace. + + """ + + name = "pig iron" + tool_tags = ["blast furnace"] + consumable_tags = ["iron ore", "coal", "coal"] + output_prototypes = [ + { + "key": "Pig Iron ingot", + "desc": "An ingot of crude pig iron.", + "tags": [("pig iron", "crafting_material")], + } + ]
+ + +
[docs]class CrucibleSteelRecipe(CraftingRecipe): + """ + Mixing pig iron with impurities like ash and sand and melting it in a + crucible produces a medieval level of steel (like damascus steel). + + """ + + name = "crucible steel" + tool_tags = ["crucible"] + consumable_tags = ["pig iron", "ash", "sand", "coal", "coal"] + output_prototypes = [ + { + "key": "Crucible steel ingot", + "desc": "An ingot of multi-colored crucible steel.", + "tags": [("crucible steel", "crafting_material")], + } + ]
+ + +class _SwordSmithingBaseRecipe(CraftingRecipe): + """ + A parent for all metallurgy sword-creation recipes. Those have a chance to + failure but since steel is not lost in the process you can always try + again. + + """ + + success_message = "Your smithing work bears fruit and you craft {outputs}!" + failed_message = ( + "You work and work but you are not happy with the result. You need to start over." + ) + + def craft(self, **kwargs): + """ + Making a sword blade takes skill. Here we emulate this by introducing a + random chance of failure (in a real game this could be a skill check + against a skill found on `self.crafter`). In this case you can always + start over since steel is not lost but can be re-smelted again for + another try. + + Args: + validated_inputs (list): all consumables/tools being used. + **kwargs: any extra kwargs passed during crafting. + + Returns: + any: The result of the craft, or None if a failure. + + Notes: + Depending on if we return a crafting result from this + method or not, `success_message` or `failure_message` + will be echoed to the crafter. + + (for more control we could also message directly and raise + crafting.CraftingError to abort craft process on failure). + + """ + if random.random() < 0.8: + # 80% chance of success. This will spawn the sword and show + # success-message. + return super().craft(**kwargs) + else: + # fail and show failed message + return None + + +
[docs]class SwordBladeRecipe(_SwordSmithingBaseRecipe): + """ + A [sword]blade requires hammering the steel out into shape using heat and + force. This also includes the tang, which is the base for the hilt (the + part of the sword you hold on to). + + """ + + name = "sword blade" + tool_tags = ["hammer", "anvil", "furnace"] + consumable_tags = ["crucible steel"] + output_prototypes = [ + { + "key": "Sword blade", + "desc": "A long blade that may one day become a sword.", + "tags": [("sword blade", "crafting_material")], + } + ]
+ + +
[docs]class SwordPommelRecipe(_SwordSmithingBaseRecipe): + """ + The pommel is the 'button' or 'ball' etc the end of the sword hilt, holding + it together. + + """ + + name = "sword pommel" + tool_tags = ["hammer", "anvil", "furnace"] + consumable_tags = ["crucible steel"] + output_prototypes = [ + { + "key": "Sword pommel", + "desc": "The pommel for a future sword.", + "tags": [("sword pommel", "crafting_material")], + } + ]
+ + +
[docs]class SwordGuardRecipe(_SwordSmithingBaseRecipe): + """ + The guard stops the hand from accidentally sliding off the hilt onto the + sword's blade and also protects the hand when parrying. + + """ + + name = "sword guard" + tool_tags = ["hammer", "anvil", "furnace"] + consumable_tags = ["crucible steel"] + output_prototypes = [ + { + "key": "Sword guard", + "desc": "The cross-guard for a future sword.", + "tags": [("sword guard", "crafting_material")], + } + ]
+ + +
[docs]class RawhideRecipe(CraftingRecipe): + """ + Rawhide is animal skin cleaned and stripped of hair. + + """ + + name = "rawhide" + tool_tags = ["knife"] + consumable_tags = ["fur"] + output_prototypes = [ + { + "key": "Rawhide", + "desc": "Animal skin, cleaned and with hair removed.", + "tags": [("rawhide", "crafting_material")], + } + ]
+ + +
[docs]class OakBarkRecipe(CraftingRecipe): + """ + The actual thing needed for tanning leather is Tannin, but we skip + the step of refining tannin from the bark and use the bark as-is. + + This produces two outputs - the bark and the cleaned wood. + """ + + name = "oak bark" + tool_tags = ["knife"] + consumable_tags = ["oak wood"] + output_prototypes = [ + { + "key": "Oak bark", + "desc": "Bark of oak, stripped from the core wood.", + "tags": [("oak bark", "crafting_material")], + }, + { + "key": "Oak Wood (cleaned)", + "desc": "Oakwood core, stripped of bark.", + "tags": [("cleaned oak wood", "crafting_material")], + }, + ]
+ + +
[docs]class LeatherRecipe(CraftingRecipe): + """ + Leather is produced by tanning rawhide in a process traditionally involving + the chemical Tannin. Here we abbreviate this process a bit. Maybe a + 'tanning rack' tool should be required too ... + + """ + + name = "leather" + tool_tags = ["cauldron"] + consumable_tags = ["rawhide", "oak bark", "water"] + output_prototypes = [ + { + "key": "Piece of Leather", + "desc": "A piece of leather.", + "tags": [("leather", "crafting_material")], + } + ]
+ + +
[docs]class SwordHandleRecipe(CraftingRecipe): + """ + The handle is the part of the hilt between the guard and the pommel where + you hold the sword. It consists of wooden pieces around the steel tang. It + is wrapped in leather, but that will be added at the end. + + """ + + name = "sword handle" + tool_tags = ["knife"] + consumable_tags = ["cleaned oak wood"] + output_prototypes = [ + { + "key": "Sword handle", + "desc": "Two pieces of wood to be be fitted onto a sword's tang as its handle.", + "tags": [("sword handle", "crafting_material")], + } + ]
+ + +
[docs]class SwordRecipe(_SwordSmithingBaseRecipe): + """ + A finished sword consists of a Blade ending in a non-sharp part called the + Tang. The cross Guard is put over the tang against the edge of the blade. + The Handle is put over the tang to give something easier to hold. The + Pommel locks everything in place. The handle is wrapped in leather + strips for better grip. + + This covers only a single 'sword' type. + + """ + + name = "sword" + tool_tags = ["hammer", "furnace", "knife"] + consumable_tags = ["sword blade", "sword guard", "sword pommel", "sword handle", "leather"] + output_prototypes = [ + { + "key": "Sword", + "desc": "A bladed weapon.", + # setting the tag as well - who knows if one can make something from this too! + "tags": [("sword", "crafting_material")], + } + # obviously there would be other properties of a 'sword' added here + # too, depending on how combat works in the your game! + ] + # this requires more precision + exact_consumable_order = True
+ + +# ------------------------------------------------------------ +# Recipes for spell casting +# ------------------------------------------------------------ + + +class _MagicRecipe(CraftingRecipe): + """ + A base 'recipe' to represent magical spells. + + We *could* treat this just like the sword above - by combining the wand and spellbook to make a + fireball object that the user can then throw with another command. For this example we instead + generate 'magical effects' as strings+values that we would then supposedly inject into a + combat system or other resolution system. + + We also assume that the crafter has skills set on itself as plain Attributes. + + """ + + name = "" + # all spells require a spellbook and a wand (so there!) + tool_tags = ["spellbook", "wand"] + + error_tool_missing_message = "Cannot cast spells without {missing}." + success_message = "You successfully cast the spell!" + # custom properties + skill_requirement = [] # this should be on the form [(skillname, min_level)] + skill_roll = "" # skill to roll for success + desired_effects = [] # on the form [(effect, value), ...] + failure_effects = [] # '' + error_too_low_skill_level = "Your skill {skill_name} is too low to cast {spell}." + error_no_skill_roll = "You must have the skill {skill_name} to cast the spell {spell}." + + def pre_craft(self, **kwargs): + """ + This is where we do input validation. We want to do the + normal validation of the tools, but also check for a skill + on the crafter. This must set the result on `self.validated_inputs`. + We also set the crafter's relevant skill value on `self.skill_roll_value`. + + Args: + **kwargs: Any optional extra kwargs passed during initialization of + the recipe class. + + Raises: + CraftingValidationError: If validation fails. At this point the crafter + is expected to have been informed of the problem already. + + """ + # this will check so the spellbook and wand are at hand. + super().pre_craft(**kwargs) + + # at this point we have the items available, let's also check for the skill. We + # assume the crafter has the skill available as an Attribute + # on itself. + + crafter = self.crafter + for skill_name, min_value in self.skill_requirements: + skill_value = crafter.attributes.get(skill_name) + + if skill_value is None or skill_value < min_value: + self.msg( + self.error_too_low_skill_level.format(skill_name=skill_name, spell=self.name) + ) + raise CraftingValidationError + + # get the value of the skill to roll + self.skill_roll_value = self.crafter.attributes.get(self.skill_roll) + if self.skill_roll_value is None: + self.msg(self.error_no_skill_roll.format(skill_name=self.skill_roll, spell=self.name)) + raise CraftingValidationError + + def do_craft(self, **kwargs): + """ + 'Craft' the magical effect. When we get to this point we already know we have all the + prequisite for creating the effect. In this example we will store the effect on the crafter; + maybe this enhances the crafter or makes a new attack available to them in combat. + + An alternative to this would of course be to spawn an actual object for the effect, like + creating a potion or an actual fireball-object to throw (this depends on how your combat + works). + + """ + # we do a simple skill check here. + if randint(1, 18) <= self.skill_roll_value: + # a success! + return True, self.desired_effects + else: + # a failure! + return False, self.failure_effects + + def post_craft(self, craft_result, **kwargs): + """ + Always called at the end of crafting, regardless of successful or not. + + Since we get a custom craft result (True/False, effects) we need to + wrap the original post_craft to output the error messages for us + correctly. + + """ + success = False + if craft_result: + success, _ = craft_result + # default post_craft just checks if craft_result is truthy or not. + # we don't care about its return value since we already have craft_result. + super().post_craft(success, **kwargs) + return craft_result + + +
[docs]class FireballRecipe(_MagicRecipe): + """ + A Fireball is a magical effect that can be thrown at a target to cause damage. + + Note that the magic-effects are just examples, an actual rule system would + need to be created to understand what they mean when used. + + """ + + name = "fireball" + skill_requirements = [("firemagic", 10)] # skill 'firemagic' lvl 10 or higher + skill_roll = "firemagic" + success_message = "A ball of flame appears!" + desired_effects = [("target_fire_damage", 25), ("ranged_attack", -2), ("mana_cost", 12)] + failure_effects = [("self_fire_damage", 5), ("mana_cost", 5)]
+ + +
[docs]class HealingRecipe(_MagicRecipe): + """ + Healing magic will restore a certain amount of health to the target over time. + + Note that the magic-effects are just examples, an actual rule system would + need to be created to understand what they mean. + + """ + + name = "heal" + skill_requirements = [("bodymagic", 5), ("empathy", 10)] + skill_roll = "bodymagic" + success_message = "You successfully extend your healing aura." + desired_effects = [("healing", 15), ("mana_cost", 5)] + failure_effects = []
+ + +
[docs]class CmdCast(Command): + """ + Cast a magical spell. + + Usage: + cast <spell> <target> + + """ + + key = "cast" + +
[docs] def parse(self): + """ + Simple parser, assuming spellname doesn't have spaces. + Stores result in self.target and self.spellname. + + """ + args = self.args.strip().lower() + target = None + if " " in args: + self.spellname, *target = args.split(" ", 1) + else: + self.spellname = args + + if not self.spellname: + self.caller.msg("You must specify a spell name.") + raise InterruptCommand + + if target: + self.target = self.caller.search(target[0].strip()) + if not self.target: + raise InterruptCommand + else: + self.target = self.caller
+ +
[docs] def func(self): + # all items carried by the caller could work + possible_tools = self.caller.contents + + try: + # if this completes without an exception, the caster will have + # a new magic_effect set on themselves, ready to use or apply in some way. + success, effects = craft( + self.caller, self.spellname, *possible_tools, raise_exception=True + ) + except CraftingValidationError: + return + except KeyError: + self.caller.msg(f"You don't know of a spell called '{self.spellname}'") + return + + # Applying the magical effect to target would happen below. + # self.caller.db.active_spells[self.spellname] holds all the effects + # of this particular prepared spell. For a fireball you could perform + # an attack roll here and apply damage if you hit. For healing you would heal the target + # (which could be yourself) by a number of health points given by the recipe. + effect_txt = ", ".join(f"{eff[0]}({eff[1]})" for eff in effects) + success_txt = "|gsucceeded|n" if success else "|rfailed|n" + self.caller.msg( + f"Casting the spell {self.spellname} on {self.target} {success_txt}, " + f"causing the following effects: {effect_txt}." + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/crafting/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/crafting/tests.html new file mode 100644 index 0000000000..f26fad1fbc --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/crafting/tests.html @@ -0,0 +1,807 @@ + + + + + + + + evennia.contrib.game_systems.crafting.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.crafting.tests

+"""
+Unit tests for the crafting system contrib.
+
+"""
+
+from unittest import mock
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.test import override_settings
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.utils.create import create_object
+from evennia.utils.test_resources import BaseEvenniaTestCase
+
+from . import crafting, example_recipes
+
+
+
[docs]class TestCraftUtils(BaseEvenniaTestCase): + """ + Test helper utils for crafting. + + """ + + maxDiff = None + +
[docs] @override_settings(CRAFT_RECIPE_MODULES=[]) + def test_load_recipes(self): + """This should only load the example module now""" + + crafting._load_recipes() + self.assertEqual( + crafting._RECIPE_CLASSES, + { + "crucible steel": example_recipes.CrucibleSteelRecipe, + "leather": example_recipes.LeatherRecipe, + "fireball": example_recipes.FireballRecipe, + "heal": example_recipes.HealingRecipe, + "oak bark": example_recipes.OakBarkRecipe, + "pig iron": example_recipes.PigIronRecipe, + "rawhide": example_recipes.RawhideRecipe, + "sword": example_recipes.SwordRecipe, + "sword blade": example_recipes.SwordBladeRecipe, + "sword guard": example_recipes.SwordGuardRecipe, + "sword handle": example_recipes.SwordHandleRecipe, + "sword pommel": example_recipes.SwordPommelRecipe, + }, + )
+ + +class _TestMaterial: + def __init__(self, name): + self.name = name + + def __repr__(self): + return self.name + + +
[docs]class TestCraftingRecipeBase(BaseEvenniaTestCase): + """ + Test the parent recipe class. + """ + +
[docs] def setUp(self): + self.crafter = mock.MagicMock() + self.crafter.msg = mock.MagicMock() + + self.inp1 = _TestMaterial("test1") + self.inp2 = _TestMaterial("test2") + self.inp3 = _TestMaterial("test3") + + self.kwargs = {"kw1": 1, "kw2": 2} + + self.recipe = crafting.CraftingRecipeBase( + self.crafter, self.inp1, self.inp2, self.inp3, **self.kwargs + )
+ +
[docs] def test_msg(self): + """Test messaging to crafter""" + + self.recipe.msg("message") + self.crafter.msg.assert_called_with("message", {"type": "crafting"})
+ +
[docs] def test_pre_craft(self): + """Test validating hook""" + self.recipe.pre_craft() + self.assertEqual(self.recipe.validated_inputs, (self.inp1, self.inp2, self.inp3))
+ +
[docs] def test_pre_craft_fail(self): + """Should rase error if validation fails""" + self.recipe.allow_craft = False + with self.assertRaises(crafting.CraftingValidationError): + self.recipe.pre_craft()
+ +
[docs] def test_craft_hook__succeed(self): + """Test craft hook, the main access method.""" + + expected_result = _TestMaterial("test_result") + self.recipe.do_craft = mock.MagicMock(return_value=expected_result) + + self.assertTrue(self.recipe.allow_craft) + + result = self.recipe.craft() + + # check result + self.assertEqual(result, expected_result) + self.recipe.do_craft.assert_called_with(kw1=1, kw2=2) + + # since allow_reuse is False, this usage should now be turned off + self.assertFalse(self.recipe.allow_craft) + # trying to re-run again should fail since rerun is False + with self.assertRaises(crafting.CraftingError): + self.recipe.craft()
+ +
[docs] def test_craft_hook__fail(self): + """Test failing the call""" + + self.recipe.do_craft = mock.MagicMock(return_value=None) + + # trigger exception + with self.assertRaises(crafting.CraftingError): + self.recipe.craft(raise_exception=True) + + # reset and try again without exception + self.recipe.allow_craft = True + result = self.recipe.craft() + self.assertEqual(result, None)
+ + +class _MockRecipe(crafting.CraftingRecipe): + name = "testrecipe" + tool_tags = ["tool1", "tool2"] + consumable_tags = ["cons1", "cons2", "cons3"] + output_prototypes = [ + { + "key": "Result1", + "prototype_key": "resultprot", + "tags": [("result1", "crafting_material")], + } + ] + + +
[docs]@override_settings(CRAFT_RECIPE_MODULES=[]) +class TestCraftingRecipe(BaseEvenniaTestCase): + """ + Test the CraftingRecipe class with one recipe + """ + + maxDiff = None + +
[docs] def setUp(self): + self.crafter = mock.MagicMock() + self.crafter.msg = mock.MagicMock() + + self.tool1 = create_object(key="tool1", tags=[("tool1", "crafting_tool")], nohome=True) + self.tool2 = create_object(key="tool2", tags=[("tool2", "crafting_tool")], nohome=True) + self.cons1 = create_object(key="cons1", tags=[("cons1", "crafting_material")], nohome=True) + self.cons2 = create_object(key="cons2", tags=[("cons2", "crafting_material")], nohome=True) + self.cons3 = create_object(key="cons3", tags=[("cons3", "crafting_material")], nohome=True)
+ +
[docs] def tearDown(self): + try: + self.tool1.delete() + self.tool2.delete() + self.cons1.delete() + self.cons2.delete() + self.cons3.delete() + except ObjectDoesNotExist: + pass
+ +
[docs] def test_error_format(self): + """Test the automatic error formatter""" + recipe = _MockRecipe( + self.crafter, self.tool1, self.tool2, self.cons1, self.cons2, self.cons3 + ) + + msg = "{missing},{tools},{consumables},{inputs},{outputs}" "{i0},{i1},{o0}" + kwargs = { + "missing": "foo", + "tools": ["bar", "bar2", "bar3"], + "consumables": ["cons1", "cons2"], + } + + expected = { + "missing": "foo", + "i0": "cons1", + "i1": "cons2", + "i2": "cons3", + "o0": "Result1", + "tools": "bar, bar2, and bar3", + "consumables": "cons1 and cons2", + "inputs": "cons1, cons2, and cons3", + "outputs": "Result1", + } + + result = recipe._format_message(msg, **kwargs) + self.assertEqual(result, msg.format_map(expected))
+ +
[docs] def test_craft__success(self): + """Test to create a result from the recipe""" + recipe = _MockRecipe( + self.crafter, self.tool1, self.tool2, self.cons1, self.cons2, self.cons3 + ) + + result = recipe.craft() + + self.assertEqual(result[0].key, "Result1") + self.assertEqual(result[0].tags.all(), ["result1", "resultprot"]) + self.crafter.msg.assert_called_with( + recipe.success_message.format(outputs="Result1"), {"type": "crafting"} + ) + + # make sure consumables are gone + self.assertIsNone(self.cons1.pk) + self.assertIsNone(self.cons2.pk) + self.assertIsNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_seed__success(self): + """Test seed helper classmethod""" + + # needed for other dbs to pass seed + homeroom = create_object(key="HomeRoom", nohome=True) + + # call classmethod directly + with override_settings(DEFAULT_HOME=f"#{homeroom.id}"): + tools, consumables = _MockRecipe.seed() + + # this should be a normal successful crafting + recipe = _MockRecipe(self.crafter, *(tools + consumables)) + + result = recipe.craft() + + self.assertEqual(result[0].key, "Result1") + self.assertEqual(result[0].tags.all(), ["result1", "resultprot"]) + self.crafter.msg.assert_called_with( + recipe.success_message.format(outputs="Result1"), {"type": "crafting"} + ) + + # make sure consumables are gone + for cons in consumables: + self.assertIsNone(cons.pk) + # make sure tools remain + for tool in tools: + self.assertIsNotNone(tool.pk)
+ +
[docs] def test_craft_missing_tool__fail(self): + """Fail craft by missing tool2""" + recipe = _MockRecipe(self.crafter, self.tool1, self.cons1, self.cons2, self.cons3) + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_tool_missing_message.format(outputs="Result1", missing="tool2"), + {"type": "crafting"}, + ) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_craft_missing_cons__fail(self): + """Fail craft by missing cons3""" + recipe = _MockRecipe(self.crafter, self.tool1, self.tool2, self.cons1, self.cons2) + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"), + {"type": "crafting"}, + ) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_craft_missing_cons__always_consume__fail(self): + """Fail craft by missing cons3, with always-consume flag""" + + cons4 = create_object(key="cons4", tags=[("cons4", "crafting_material")], nohome=True) + + recipe = _MockRecipe(self.crafter, self.tool1, self.tool2, self.cons1, self.cons2, cons4) + recipe.consume_on_fail = True + + result = recipe.craft() + + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"), + {"type": "crafting"}, + ) + + # make sure consumables are deleted even though we failed + self.assertIsNone(self.cons1.pk) + self.assertIsNone(self.cons2.pk) + # the extra should also be gone + self.assertIsNone(cons4.pk) + # but cons3 should be fine since it was not included + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain as normal + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_craft_wrong_tool__fail(self): + """Fail craft by including a wrong tool""" + + wrong = create_object(key="wrong", tags=[("wrongtool", "crafting_tool")], nohome=True) + + recipe = _MockRecipe(self.crafter, self.tool1, self.tool2, self.cons1, self.cons2, wrong) + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_tool_excess_message.format( + outputs="Result1", excess=wrong.get_display_name(looker=self.crafter) + ), + {"type": "crafting"}, + ) + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_craft_tool_excess__fail(self): + """Fail by too many consumables""" + + # note that this is a valid tag! + tool3 = create_object(key="tool3", tags=[("tool2", "crafting_tool")], nohome=True) + + recipe = _MockRecipe( + self.crafter, self.tool1, self.tool2, self.cons1, self.cons2, self.cons3, tool3 + ) + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_tool_excess_message.format( + outputs="Result1", excess=tool3.get_display_name(looker=self.crafter) + ), + {"type": "crafting"}, + ) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + self.assertIsNotNone(tool3.pk)
+ +
[docs] def test_craft_cons_excess__fail(self): + """Fail by too many consumables""" + + # note that this is a valid tag! + cons4 = create_object(key="cons4", tags=[("cons3", "crafting_material")], nohome=True) + + recipe = _MockRecipe( + self.crafter, self.tool1, self.tool2, self.cons1, self.cons2, self.cons3, cons4 + ) + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_consumable_excess_message.format( + outputs="Result1", excess=cons4.get_display_name(looker=self.crafter) + ), + {"type": "crafting"}, + ) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + self.assertIsNotNone(cons4.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_craft_tool_excess__sucess(self): + """Allow too many consumables""" + + tool3 = create_object(key="tool3", tags=[("tool2", "crafting_tool")], nohome=True) + + recipe = _MockRecipe( + self.crafter, self.tool1, self.tool2, self.cons1, self.cons2, self.cons3, tool3 + ) + recipe.exact_tools = False + result = recipe.craft() + self.assertTrue(result) + self.crafter.msg.assert_called_with( + recipe.success_message.format(outputs="Result1"), {"type": "crafting"} + ) + + # make sure consumables are gone + self.assertIsNone(self.cons1.pk) + self.assertIsNone(self.cons2.pk) + self.assertIsNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_craft_cons_excess__sucess(self): + """Allow too many consumables""" + + cons4 = create_object(key="cons4", tags=[("cons3", "crafting_material")], nohome=True) + + recipe = _MockRecipe( + self.crafter, self.tool1, self.tool2, self.cons1, self.cons2, self.cons3, cons4 + ) + recipe.exact_consumables = False + result = recipe.craft() + self.assertTrue(result) + self.crafter.msg.assert_called_with( + recipe.success_message.format(outputs="Result1"), {"type": "crafting"} + ) + + # make sure consumables are gone + self.assertIsNone(self.cons1.pk) + self.assertIsNone(self.cons2.pk) + self.assertIsNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_craft_tool_order__fail(self): + """Strict tool-order recipe fail""" + recipe = _MockRecipe( + self.crafter, self.tool2, self.tool1, self.cons1, self.cons2, self.cons3 + ) + recipe.exact_tool_order = True + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_tool_order_message.format( + outputs="Result1", missing=self.tool2.get_display_name(looker=self.crafter) + ), + {"type": "crafting"}, + ) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ +
[docs] def test_craft_cons_order__fail(self): + """Strict tool-order recipe fail""" + recipe = _MockRecipe( + self.crafter, self.tool1, self.tool2, self.cons3, self.cons2, self.cons1 + ) + recipe.exact_consumable_order = True + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_consumable_order_message.format( + outputs="Result1", missing=self.cons3.get_display_name(looker=self.crafter) + ), + {"type": "crafting"}, + ) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk)
+ + +
[docs]class TestCraftSword(BaseEvenniaTestCase): + """ + Test the `craft` function by crafting the example sword. + + """ + +
[docs] def setUp(self): + self.crafter = mock.MagicMock() + self.crafter.msg = mock.MagicMock()
+ +
[docs] @override_settings(CRAFT_RECIPE_MODULES=[], DEFAULT_HOME="#999999") + @mock.patch("evennia.contrib.game_systems.crafting.example_recipes.random") + def test_craft_sword(self, mockrandom): + """ + Craft example sword. For the test, every crafting works. + + """ + # make sure every craft succeeds + mockrandom.random = mock.MagicMock(return_value=0.2) + + def _co(key, tagkey, is_tool=False): + tagcat = "crafting_tool" if is_tool else "crafting_material" + return create_object(key=key, tags=[(tagkey, tagcat)], nohome=True) + + def _craft(recipe_name, *inputs): + """shortcut to shorten and return only one element""" + result = crafting.craft(self.crafter, recipe_name, *inputs, raise_exception=True) + return result[0] if len(result) == 1 else result + + # generate base materials + iron_ore1 = _co("Iron ore ingot", "iron ore") + iron_ore2 = _co("Iron ore ingot", "iron ore") + iron_ore3 = _co("Iron ore ingot", "iron ore") + + ash1 = _co("Pile of Ash", "ash") + ash2 = _co("Pile of Ash", "ash") + ash3 = _co("Pile of Ash", "ash") + + sand1 = _co("Pile of sand", "sand") + sand2 = _co("Pile of sand", "sand") + sand3 = _co("Pile of sand", "sand") + + coal01 = _co("Pile of coal", "coal") + coal02 = _co("Pile of coal", "coal") + coal03 = _co("Pile of coal", "coal") + coal04 = _co("Pile of coal", "coal") + coal05 = _co("Pile of coal", "coal") + coal06 = _co("Pile of coal", "coal") + coal07 = _co("Pile of coal", "coal") + coal08 = _co("Pile of coal", "coal") + coal09 = _co("Pile of coal", "coal") + coal10 = _co("Pile of coal", "coal") + coal11 = _co("Pile of coal", "coal") + coal12 = _co("Pile of coal", "coal") + + oak_wood = _co("Pile of oak wood", "oak wood") + water = _co("Bucket of water", "water") + fur = _co("Bundle of Animal fur", "fur") + + # tools + blast_furnace = _co("Blast furnace", "blast furnace", is_tool=True) + furnace = _co("Smithing furnace", "furnace", is_tool=True) + crucible = _co("Smelting crucible", "crucible", is_tool=True) + anvil = _co("Smithing anvil", "anvil", is_tool=True) + hammer = _co("Smithing hammer", "hammer", is_tool=True) + knife = _co("Working knife", "knife", is_tool=True) + cauldron = _co("Cauldron", "cauldron", is_tool=True) + + # making pig iron + inputs = [iron_ore1, coal01, coal02, blast_furnace] + pig_iron1 = _craft("pig iron", *inputs) + + inputs = [iron_ore2, coal03, coal04, blast_furnace] + pig_iron2 = _craft("pig iron", *inputs) + + inputs = [iron_ore3, coal05, coal06, blast_furnace] + pig_iron3 = _craft("pig iron", *inputs) + + # making crucible steel + inputs = [pig_iron1, ash1, sand1, coal07, coal08, crucible] + crucible_steel1 = _craft("crucible steel", *inputs) + + inputs = [pig_iron2, ash2, sand2, coal09, coal10, crucible] + crucible_steel2 = _craft("crucible steel", *inputs) + + inputs = [pig_iron3, ash3, sand3, coal11, coal12, crucible] + crucible_steel3 = _craft("crucible steel", *inputs) + + # smithing + inputs = [crucible_steel1, hammer, anvil, furnace] + sword_blade = _craft("sword blade", *inputs) + + inputs = [crucible_steel2, hammer, anvil, furnace] + sword_pommel = _craft("sword pommel", *inputs) + + inputs = [crucible_steel3, hammer, anvil, furnace] + sword_guard = _craft("sword guard", *inputs) + + # stripping fur + inputs = [fur, knife] + rawhide = _craft("rawhide", *inputs) + + # making bark (tannin) and cleaned wood + inputs = [oak_wood, knife] + oak_bark, cleaned_oak_wood = _craft("oak bark", *inputs) + + # leathermaking + inputs = [rawhide, oak_bark, water, cauldron] + leather = _craft("leather", *inputs) + + # sword handle + inputs = [cleaned_oak_wood, knife] + sword_handle = _craft("sword handle", *inputs) + + # sword (order matters) + inputs = [ + sword_blade, + sword_guard, + sword_pommel, + sword_handle, + leather, + knife, + hammer, + furnace, + ] + sword = _craft("sword", *inputs) + + self.assertEqual(sword.key, "Sword") + + # make sure all materials and intermediaries are deleted + self.assertIsNone(iron_ore1.pk) + self.assertIsNone(iron_ore2.pk) + self.assertIsNone(iron_ore3.pk) + self.assertIsNone(ash1.pk) + self.assertIsNone(ash2.pk) + self.assertIsNone(ash3.pk) + self.assertIsNone(sand1.pk) + self.assertIsNone(sand2.pk) + self.assertIsNone(sand3.pk) + self.assertIsNone(coal01.pk) + self.assertIsNone(coal02.pk) + self.assertIsNone(coal03.pk) + self.assertIsNone(coal04.pk) + self.assertIsNone(coal05.pk) + self.assertIsNone(coal06.pk) + self.assertIsNone(coal07.pk) + self.assertIsNone(coal08.pk) + self.assertIsNone(coal09.pk) + self.assertIsNone(coal10.pk) + self.assertIsNone(coal11.pk) + self.assertIsNone(coal12.pk) + self.assertIsNone(oak_wood.pk) + self.assertIsNone(water.pk) + self.assertIsNone(fur.pk) + self.assertIsNone(pig_iron1.pk) + self.assertIsNone(pig_iron2.pk) + self.assertIsNone(pig_iron3.pk) + self.assertIsNone(crucible_steel1.pk) + self.assertIsNone(crucible_steel2.pk) + self.assertIsNone(crucible_steel3.pk) + self.assertIsNone(sword_blade.pk) + self.assertIsNone(sword_pommel.pk) + self.assertIsNone(sword_guard.pk) + self.assertIsNone(rawhide.pk) + self.assertIsNone(oak_bark.pk) + self.assertIsNone(leather.pk) + self.assertIsNone(sword_handle.pk) + + # make sure all tools remain + self.assertIsNotNone(blast_furnace) + self.assertIsNotNone(furnace) + self.assertIsNotNone(crucible) + self.assertIsNotNone(anvil) + self.assertIsNotNone(hammer) + self.assertIsNotNone(knife) + self.assertIsNotNone(cauldron)
+ + +
[docs]@mock.patch("evennia.contrib.game_systems.crafting.crafting._load_recipes", new=mock.MagicMock()) +@mock.patch( + "evennia.contrib.game_systems.crafting.crafting._RECIPE_CLASSES", + new={"testrecipe": _MockRecipe}, +) +@override_settings(CRAFT_RECIPE_MODULES=[]) +class TestCraftCommand(BaseEvenniaCommandTest): + """Test the crafting command""" + +
[docs] def setUp(self): + super().setUp() + + tools, consumables = _MockRecipe.seed( + tool_kwargs={"location": self.char1}, consumable_kwargs={"location": self.char1} + )
+ +
[docs] def test_craft__success(self): + "Successfully craft using command" + self.call( + crafting.CmdCraft(), + "testrecipe from cons1, cons2, cons3 using tool1, tool2", + _MockRecipe.success_message.format(outputs="Result1"), + )
+ +
[docs] def test_craft__notools__failure(self): + "Craft fail no tools" + self.call( + crafting.CmdCraft(), + "testrecipe from cons1, cons2, cons3", + _MockRecipe.error_tool_missing_message.format(outputs="Result1", missing="tool1"), + )
+ +
[docs] def test_craft__nocons__failure(self): + self.call( + crafting.CmdCraft(), + "testrecipe using tool1, tool2", + _MockRecipe.error_consumable_missing_message.format(outputs="Result1", missing="cons1"), + )
+ +
[docs] def test_craft__unknown_recipe__failure(self): + self.call( + crafting.CmdCraft(), + "nonexistent from cons1, cons2, cons3 using tool1, tool2", + "Unknown recipe 'nonexistent'", + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/gendersub/gendersub.html b/docs/latest/_modules/evennia/contrib/game_systems/gendersub/gendersub.html new file mode 100644 index 0000000000..c08a3a8500 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/gendersub/gendersub.html @@ -0,0 +1,272 @@ + + + + + + + + evennia.contrib.game_systems.gendersub.gendersub — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.gendersub.gendersub

+"""
+Gendersub
+
+Griatch 2015
+
+This is a simple gender-aware Character class for allowing users to
+insert custom markers in their text to indicate gender-aware
+messaging. It relies on a modified msg() and is meant as an
+inspiration and starting point to how to do stuff like this.
+
+An object can have the following genders:
+ - male (he/his)
+ - female (her/hers)
+ - neutral (it/its)
+ - ambiguous (they/them/their/theirs)
+
+Usage
+
+When in use, messages can contain special tags to indicate pronouns gendered
+based on the one being addressed. Capitalization will be retained.
+
+- `|s`, `|S`: Subjective form: he, she, it, He, She, It, They
+- `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them
+- `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their
+- `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs
+
+For example,
+
+```
+char.msg("%s falls on |p face with a thud." % char.key)
+"Tom falls on his face with a thud"
+```
+
+The default gender is "ambiguous" (they/them/their/theirs).
+
+To use, have DefaultCharacter inherit from this, or change
+setting.DEFAULT_CHARACTER to point to this class.
+
+The `gender` command is used to set the gender. It needs to be added to the
+default cmdset before it becomes available.
+
+"""
+
+import re
+
+from evennia import Command, DefaultCharacter
+from evennia.utils import logger
+
+# gender maps
+
+_GENDER_PRONOUN_MAP = {
+    "male": {"s": "he", "o": "him", "p": "his", "a": "his"},
+    "female": {"s": "she", "o": "her", "p": "her", "a": "hers"},
+    "neutral": {"s": "it", "o": "it", "p": "its", "a": "its"},
+    "ambiguous": {"s": "they", "o": "them", "p": "their", "a": "theirs"},
+}
+_RE_GENDER_PRONOUN = re.compile(r"(?<!\|)\|(?!\|)[sSoOpPaA]")
+
+# in-game command for setting the gender
+
+
+
[docs]class SetGender(Command): + """ + Sets gender on yourself + + Usage: + @gender male || female || neutral || ambiguous + + """ + + key = "gender" + aliases = "sex" + locks = "call:all()" + +
[docs] def func(self): + """ + Implements the command. + """ + caller = self.caller + arg = self.args.strip().lower() + if arg not in ("male", "female", "neutral", "ambiguous"): + caller.msg("Usage: @gender male||female||neutral||ambiguous") + return + caller.db.gender = arg + caller.msg("Your gender was set to %s." % arg)
+ + +# Gender-aware character class + + +
[docs]class GenderCharacter(DefaultCharacter): + """ + This is a Character class aware of gender. + + """ + +
[docs] def at_object_creation(self): + """ + Called once when the object is created. + """ + super().at_object_creation() + self.db.gender = "ambiguous"
+ + def _get_pronoun(self, regex_match, source=None): + """ + Get pronoun from the pronoun marker in the text. This is used as + the callable for the re.sub function. + + Args: + regex_match (MatchObject): the regular expression match. + + Notes: + - `|s`, `|S`: Subjective form: he, she, it, He, She, It, They + - `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them + - `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their + - `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs + + """ + if not source: + source = self + typ = regex_match.group()[1] # "s", "O" etc + gender = source.attributes.get("gender", default="ambiguous") + gender = gender if gender in ("male", "female", "neutral") else "ambiguous" + pronoun = _GENDER_PRONOUN_MAP[gender][typ.lower()] + return pronoun.capitalize() if typ.isupper() else pronoun + +
[docs] def msg(self, text=None, from_obj=None, session=None, **kwargs): + """ + Emits something to a session attached to the object. + Overloads the default msg() implementation to include + gender-aware markers in output. + + Args: + text (str or tuple, optional): The message to send. This + is treated internally like any send-command, so its + value can be a tuple if sending multiple arguments to + the `text` oob command. + from_obj (obj, optional): object that is sending. If + given, at_msg_send will be called + session (Session or list, optional): session or list of + sessions to relay to, if any. If set, will + force send regardless of MULTISESSION_MODE. + Notes: + `at_msg_receive` will be called on this Object. + All extra kwargs will be passed on to the protocol. + + """ + if text is None: + super().msg(from_obj=from_obj, session=session, **kwargs) + return + + try: + if text and isinstance(text, tuple): + text = ( + _RE_GENDER_PRONOUN.sub( + lambda x: self._get_pronoun(x, source=from_obj), text[0] + ), + *text[1:], + ) + else: + text = _RE_GENDER_PRONOUN.sub(lambda x: self._get_pronoun(x, source=from_obj), text) + except TypeError: + pass + except Exception as e: + logger.log_trace(e) + + super().msg(text, from_obj=from_obj, session=session, **kwargs)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/gendersub/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/gendersub/tests.html new file mode 100644 index 0000000000..7ea203525a --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/gendersub/tests.html @@ -0,0 +1,170 @@ + + + + + + + + evennia.contrib.game_systems.gendersub.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.gendersub.tests

+"""
+Test gendersub contrib.
+
+"""
+
+
+from mock import patch
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.utils.create import create_object
+
+from . import gendersub
+
+
+
[docs]class TestGenderSub(BaseEvenniaCommandTest): +
[docs] def test_setgender(self): + self.call(gendersub.SetGender(), "male", "Your gender was set to male.") + self.call(gendersub.SetGender(), "ambiguous", "Your gender was set to ambiguous.") + self.call(gendersub.SetGender(), "Foo", "Usage: @gender")
+ +
[docs] def test_gendercharacter(self): + char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1) + txt = "Test |p gender" + self.assertEqual( + gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender" + ) + with patch( + "evennia.contrib.game_systems.gendersub.gendersub.DefaultCharacter.msg" + ) as mock_msg: + char.db.gender = "female" + char.msg(txt) + mock_msg.assert_called_with("Test her gender", from_obj=None, session=None)
+ +
[docs] def test_gendering_others(self): + """ensure characters see the gender of the sender, not themselves""" + fem = create_object( + gendersub.GenderCharacter, + key="Gendered", + location=self.room2, + attributes=[("gender", "female")], + ) + masc = create_object( + gendersub.GenderCharacter, + key="Gendered", + location=self.room2, + attributes=[("gender", "male")], + ) + txt = "Test |p gender" + + with patch( + "evennia.contrib.game_systems.gendersub.gendersub.DefaultCharacter.msg" + ) as mock_msg: + fem.msg(txt, from_obj=masc) + self.assertIn("Test his gender", mock_msg.call_args.args) + masc.msg(txt, from_obj=fem) + self.assertIn("Test her gender", mock_msg.call_args.args)
+ +
[docs] def test_ungendered_source(self): + char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1) + txt = "Test |p gender" + with patch( + "evennia.contrib.game_systems.gendersub.gendersub.DefaultCharacter.msg" + ) as mock_msg: + char.msg(txt, from_obj=self.char1) + self.assertIn("Test their gender", mock_msg.call_args.args)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/mail/mail.html b/docs/latest/_modules/evennia/contrib/game_systems/mail/mail.html new file mode 100644 index 0000000000..3c75cb8503 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/mail/mail.html @@ -0,0 +1,464 @@ + + + + + + + + evennia.contrib.game_systems.mail.mail — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.mail.mail

+"""
+In-Game Mail system
+
+Evennia Contribution - grungies1138 2016
+
+A simple Brandymail style @mail system that uses the Msg class from Evennia
+Core. It has two Commands, both of which can be used on their own:
+
+   - CmdMail - this should sit on the Account cmdset and makes the `mail` command
+    available both IC and OOC. Mails will always go to Accounts (other players).
+   - CmdMailCharacter - this should sit on the Character cmdset and makes the `mail`
+    command ONLY available when puppeting a character. Mails will be sent to other
+    Characters only and will not be available when OOC.
+   - If adding *both* commands to their respective cmdsets, you'll get two separate
+    IC and OOC mailing systems, with different lists of mail for IC and OOC modes.
+
+Installation:
+
+Install one or both of the following (see above):
+
+- CmdMail (IC + OOC mail, sent between players)
+
+    # mygame/commands/default_cmds.py
+
+    from evennia.contrib.game_systems import mail
+
+    # in AccountCmdSet.at_cmdset_creation:
+        self.add(mail.CmdMail())
+
+- CmdMailCharacter (optional, IC only mail, sent between characters)
+
+    # mygame/commands/default_cmds.py
+
+    from evennia.contrib.game_systems import mail
+
+    # in CharacterCmdSet.at_cmdset_creation:
+        self.add(mail.CmdMailCharacter())
+
+Once installed, use `help mail` in game for help with the mail command. Use
+ic/ooc to switch in and out of IC/OOC modes.
+
+"""
+
+import re
+
+from evennia import AccountDB, ObjectDB, default_cmds
+from evennia.comms.models import Msg
+from evennia.utils import create, datetime_format, evtable, inherits_from, make_iter
+
+_HEAD_CHAR = "|015-|n"
+_SUB_HEAD_CHAR = "-"
+_WIDTH = 78
+
+
+
[docs]class CmdMail(default_cmds.MuxAccountCommand): + """ + Communicate with others by sending mail. + + Usage: + @mail - Displays all the mail an account has in their mailbox + @mail <#> - Displays a specific message + @mail <accounts>=<subject>/<message> + - Sends a message to the comma separated list of accounts. + @mail/delete <#> - Deletes a specific message + @mail/forward <account list>=<#>[/<Message>] + - Forwards an existing message to the specified list of accounts, + original message is delivered with optional Message prepended. + @mail/reply <#>=<message> + - Replies to a message #. Prepends message to the original + message text. + Switches: + delete - deletes a message + forward - forward a received message to another object with an optional message attached. + reply - Replies to a received message, appending the original message to the bottom. + Examples: + @mail 2 + @mail Griatch=New mail/Hey man, I am sending you a message! + @mail/delete 6 + @mail/forward feend78 Griatch=4/You guys should read this. + @mail/reply 9=Thanks for the info! + + """ + + key = "@mail" + aliases = ["mail"] + lock = "cmd:all()" + help_category = "General" + +
[docs] def parse(self): + """ + Add convenience check to know if caller is an Account or not since this cmd + will be able to add to either Object- or Account level. + + """ + super().parse() + self.caller_is_account = bool( + inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount") + )
+ +
[docs] def search_targets(self, namelist): + """ + Search a list of targets of the same type as caller. + + Args: + caller (Object or Account): The type of object to search. + namelist (list): List of strings for objects to search for. + + Returns: + targetlist (Queryset): Any target matches. + + """ + nameregex = r"|".join(r"^%s$" % re.escape(name) for name in make_iter(namelist)) + if self.caller_is_account: + matches = AccountDB.objects.filter(username__iregex=nameregex) + else: + matches = ObjectDB.objects.filter(db_key__iregex=nameregex) + return matches
+ +
[docs] def get_all_mail(self): + """ + Returns a list of all the messages where the caller is a recipient. These + are all messages tagged with tags of the `mail` category. + + Returns: + messages (QuerySet): Matching Msg objects. + + """ + if self.caller_is_account: + return Msg.objects.get_by_tag(category="mail").filter(db_receivers_accounts=self.caller) + else: + return Msg.objects.get_by_tag(category="mail").filter(db_receivers_objects=self.caller)
+ +
[docs] def send_mail(self, recipients, subject, message, caller): + """ + Function for sending new mail. Also useful for sending notifications + from objects or systems. + + Args: + recipients (list): list of Account or Character objects to receive + the newly created mails. + subject (str): The header or subject of the message to be delivered. + message (str): The body of the message being sent. + caller (obj): The object (or Account or Character) that is sending the message. + + """ + for recipient in recipients: + recipient.msg("You have received a new @mail from %s" % caller) + new_message = create.create_message( + self.caller, message, receivers=recipient, header=subject + ) + new_message.tags.add("new", category="mail") + + if recipients: + caller.msg("You sent your message.") + return + else: + caller.msg("No valid target(s) found. Cannot send message.") + return
+ +
[docs] def func(self): + """ + Do the main command functionality + """ + + subject = "" + body = "" + + if self.switches or self.args: + if "delete" in self.switches or "del" in self.switches: + try: + if not self.lhs: + self.caller.msg("No Message ID given. Unable to delete.") + return + else: + all_mail = self.get_all_mail() + mind_max = max(0, all_mail.count() - 1) + mind = max(0, min(mind_max, int(self.lhs) - 1)) + if all_mail[mind]: + mail = all_mail[mind] + question = "Delete message {} ({}) [Y]/N?".format(mind + 1, mail.header) + ret = yield (question) + # handle not ret, it will be None during unit testing + if not ret or ret.strip().upper() not in ("N", "No"): + all_mail[mind].delete() + self.caller.msg("Message %s deleted" % (mind + 1,)) + else: + self.caller.msg("Message not deleted.") + else: + raise IndexError + except IndexError: + self.caller.msg("That message does not exist.") + except ValueError: + self.caller.msg("Usage: @mail/delete <message ID>") + elif "forward" in self.switches or "fwd" in self.switches: + try: + if not self.rhs: + self.caller.msg( + "Cannot forward a message without a target list. " "Please try again." + ) + return + elif not self.lhs: + self.caller.msg("You must define a message to forward.") + return + else: + all_mail = self.get_all_mail() + mind_max = max(0, all_mail.count() - 1) + if "/" in self.rhs: + message_number, message = self.rhs.split("/", 1) + mind = max(0, min(mind_max, int(message_number) - 1)) + + if all_mail[mind]: + old_message = all_mail[mind] + + self.send_mail( + self.search_targets(self.lhslist), + "FWD: " + old_message.header, + message + + "\n---- Original Message ----\n" + + old_message.message, + self.caller, + ) + self.caller.msg("Message forwarded.") + else: + raise IndexError + else: + mind = max(0, min(mind_max, int(self.rhs) - 1)) + if all_mail[mind]: + old_message = all_mail[mind] + self.send_mail( + self.search_targets(self.lhslist), + "FWD: " + old_message.header, + "\n---- Original Message ----\n" + old_message.message, + self.caller, + ) + self.caller.msg("Message forwarded.") + old_message.tags.remove("new", category="mail") + old_message.tags.add("fwd", category="mail") + else: + raise IndexError + except IndexError: + self.caller.msg("Message does not exist.") + except ValueError: + self.caller.msg("Usage: @mail/forward <account list>=<#>[/<Message>]") + elif "reply" in self.switches or "rep" in self.switches: + try: + if not self.rhs: + self.caller.msg("You must define a message to reply to.") + return + elif not self.lhs: + self.caller.msg("You must supply a reply message") + return + else: + all_mail = self.get_all_mail() + mind_max = max(0, all_mail.count() - 1) + mind = max(0, min(mind_max, int(self.lhs) - 1)) + if all_mail[mind]: + old_message = all_mail[mind] + self.send_mail( + old_message.senders, + "RE: " + old_message.header, + self.rhs + "\n---- Original Message ----\n" + old_message.message, + self.caller, + ) + old_message.tags.remove("new", category="mail") + old_message.tags.add("-", category="mail") + return + else: + raise IndexError + except IndexError: + self.caller.msg("Message does not exist.") + except ValueError: + self.caller.msg("Usage: @mail/reply <#>=<message>") + else: + # normal send + if self.rhs: + if "/" in self.rhs: + subject, body = self.rhs.split("/", 1) + else: + body = self.rhs + self.send_mail(self.search_targets(self.lhslist), subject, body, self.caller) + else: + all_mail = self.get_all_mail() + mind_max = max(0, all_mail.count() - 1) + try: + mind = max(0, min(mind_max, int(self.lhs) - 1)) + message = all_mail[mind] + except (ValueError, IndexError): + self.caller.msg("'%s' is not a valid mail id." % self.lhs) + return + + messageForm = [] + if message: + messageForm.append(_HEAD_CHAR * _WIDTH) + messageForm.append( + "|wFrom:|n %s" % (message.senders[0].get_display_name(self.caller)) + ) + # note that we cannot use %-d format here since Windows does not support it + day = message.db_date_created.day + messageForm.append( + "|wSent:|n %s" + % message.db_date_created.strftime(f"%b {day}, %Y - %H:%M:%S") + ) + messageForm.append("|wSubject:|n %s" % message.header) + messageForm.append(_SUB_HEAD_CHAR * _WIDTH) + messageForm.append(message.message) + messageForm.append(_HEAD_CHAR * _WIDTH) + self.caller.msg("\n".join(messageForm)) + message.tags.remove("new", category="mail") + message.tags.add("-", category="mail") + + else: + # list messages + messages = self.get_all_mail() + + if messages: + table = evtable.EvTable( + "|wID|n", + "|wFrom|n", + "|wSubject|n", + "|wArrived|n", + "", + table=None, + border="header", + header_line_char=_SUB_HEAD_CHAR, + width=_WIDTH, + ) + index = 1 + for message in messages: + status = str(message.db_tags.last().db_key.upper()) + if status == "NEW": + status = "|gNEW|n" + + table.add_row( + index, + message.senders[0].get_display_name(self.caller), + message.header, + datetime_format(message.db_date_created), + status, + ) + index += 1 + + table.reformat_column(0, width=6) + table.reformat_column(1, width=18) + table.reformat_column(2, width=34) + table.reformat_column(3, width=13) + table.reformat_column(4, width=7) + + self.caller.msg(_HEAD_CHAR * _WIDTH) + self.caller.msg(str(table)) + self.caller.msg(_HEAD_CHAR * _WIDTH) + else: + self.caller.msg("There are no messages in your inbox.")
+ + +# character - level version of the command + + +
[docs]class CmdMailCharacter(CmdMail): + account_caller = False
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/mail/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/mail/tests.html new file mode 100644 index 0000000000..f9307dd1e9 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/mail/tests.html @@ -0,0 +1,153 @@ + + + + + + + + evennia.contrib.game_systems.mail.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.mail.tests

+"""
+Test mail contrib
+
+"""
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+
+from . import mail
+
+
+
[docs]class TestMail(BaseEvenniaCommandTest): +
[docs] def test_mail(self): + self.call(mail.CmdMail(), "2", "'2' is not a valid mail id.", caller=self.account) + self.call(mail.CmdMail(), "test", "'test' is not a valid mail id.", caller=self.account) + self.call(mail.CmdMail(), "", "There are no messages in your inbox.", caller=self.account) + self.call( + mail.CmdMailCharacter(), + "Char=Message 1", + "You have received a new @mail from Char|You sent your message.", + caller=self.char1, + ) + self.call( + mail.CmdMailCharacter(), "Char=Message 2", "You sent your message.", caller=self.char2 + ) + self.call( + mail.CmdMail(), + "TestAccount2=Message 2", + "You have received a new @mail from TestAccount2", + caller=self.account2, + ) + self.call( + mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2 + ) + self.call( + mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2 + ) + self.call(mail.CmdMail(), "", "| ID From Subject", caller=self.account) + self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account) + self.call( + mail.CmdMail(), + "/forward TestAccount2 = 1/Forward message", + "You sent your message.|Message forwarded.", + caller=self.account, + ) + self.call( + mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account + ) + self.call(mail.CmdMail(), "/delete 2", "Message 2 deleted", caller=self.account)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/multidescer/multidescer.html b/docs/latest/_modules/evennia/contrib/game_systems/multidescer/multidescer.html new file mode 100644 index 0000000000..0117383ecc --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/multidescer/multidescer.html @@ -0,0 +1,374 @@ + + + + + + + + evennia.contrib.game_systems.multidescer.multidescer — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.multidescer.multidescer

+"""
+Evennia Multidescer
+
+Contrib - Griatch 2016
+
+A "multidescer" is a concept from the MUSH world. It allows for
+creating, managing and switching between multiple character
+descriptions. This multidescer will not require any changes to the
+Character class, rather it will use the `multidescs` Attribute (a
+list) and create it if it does not exist.
+
+This contrib also works well together with the rpsystem contrib (which
+also adds the short descriptions and the `sdesc` command).
+
+Installation:
+
+Edit `mygame/commands/default_cmdsets.py` and add
+`from evennia.contrib.game_systems.multidescer import CmdMultiDesc` to the top.
+
+Next, look up the `at_cmdset_create` method of the `CharacterCmdSet`
+class and add a line `self.add(CmdMultiDesc())` to the end
+of it.
+
+Reload the server and you should have the +desc command available (it
+will replace the default `desc` command).
+
+"""
+import re
+
+from evennia import default_cmds
+from evennia.utils.eveditor import EvEditor
+from evennia.utils.utils import crop
+
+# regex for the set functionality
+_RE_KEYS = re.compile(r"([\w\s]+)(?:\+*?)", re.U + re.I)
+
+
+# Helper functions for the Command
+
+
+
[docs]class DescValidateError(ValueError): + "Used for tracebacks from desc systems" + pass
+ + +def _update_store(caller, key=None, desc=None, delete=False, swapkey=None): + """ + Helper function for updating the database store. + + Args: + caller (Object): The caller of the command. + key (str): Description identifier + desc (str): Description text. + delete (bool): Delete given key. + swapkey (str): Swap list positions of `key` and this key. + + """ + if not caller.db.multidesc: + # initialize the multidesc attribute + caller.db.multidesc = [("caller", caller.db.desc or "")] + if not key: + return + lokey = key.lower() + match = [ind for ind, tup in enumerate(caller.db.multidesc) if tup[0] == lokey] + if match: + idesc = match[0] + if delete: + # delete entry + del caller.db.multidesc[idesc] + elif swapkey: + # swap positions + loswapkey = swapkey.lower() + swapmatch = [ind for ind, tup in enumerate(caller.db.multidesc) if tup[0] == loswapkey] + if swapmatch: + iswap = swapmatch[0] + if idesc == iswap: + raise DescValidateError("Swapping a key with itself does nothing.") + temp = caller.db.multidesc[idesc] + caller.db.multidesc[idesc] = caller.db.multidesc[iswap] + caller.db.multidesc[iswap] = temp + else: + raise DescValidateError("Description key '|w%s|n' not found." % swapkey) + elif desc: + # update in-place + caller.db.multidesc[idesc] = (lokey, desc) + else: + raise DescValidateError("No description was set.") + else: + # no matching key + if delete or swapkey: + raise DescValidateError("Description key '|w%s|n' not found." % key) + elif desc: + # insert new at the top of the stack + caller.db.multidesc.insert(0, (lokey, desc)) + else: + raise DescValidateError("No description was set.") + + +# eveditor save/load/quit functions + + +def _save_editor(caller, buffer): + "Called when the editor saves its contents" + key = caller.db._multidesc_editkey + _update_store(caller, key, buffer) + caller.msg("Saved description to key '%s'." % key) + return True + + +def _load_editor(caller): + "Called when the editor loads contents" + key = caller.db._multidesc_editkey + match = [ind for ind, tup in enumerate(caller.db.multidesc) if tup[0] == key] + if match: + return caller.db.multidesc[match[0]][1] + return "" + + +def _quit_editor(caller): + "Called when the editor quits" + del caller.db._multidesc_editkey + caller.msg("Exited editor.") + + +# The actual command class + + +
[docs]class CmdMultiDesc(default_cmds.MuxCommand): + """ + Manage multiple descriptions + + Usage: + +desc [key] - show current desc desc with <key> + +desc <key> = <text> - add/replace desc with <key> + +desc/list - list descriptions (abbreviated) + +desc/list/full - list descriptions (full texts) + +desc/edit <key> - add/edit desc <key> in line editor + +desc/del <key> - delete desc <key> + +desc/swap <key1>-<key2> - swap positions of <key1> and <key2> in list + +desc/set <key> [+key+...] - set desc as default or combine multiple descs + + Notes: + When combining multiple descs with +desc/set <key> + <key2> + ..., + any keys not matching an actual description will be inserted + as plain text. Use e.g. ansi line break ||/ to add a new + paragraph and + + or ansi space ||_ to add extra whitespace. + + """ + + key = "+desc" + aliases = ["desc"] + locks = "cmd:all()" + help_category = "General" + +
[docs] def func(self): + """ + Implements the multidescer. We will use `db.desc` for the + description in use and `db.multidesc` to store all descriptions. + """ + + caller = self.caller + args = self.args.strip() + switches = self.switches + + try: + if "list" in switches or "all" in switches: + # list all stored descriptions, either in full or cropped. + # Note that we list starting from 1, not from 0. + _update_store(caller) + do_crop = "full" not in switches + if do_crop: + outtext = [ + "|w%s:|n %s" % (key, crop(desc)) for key, desc in caller.db.multidesc + ] + else: + outtext = [ + "\n|w%s:|n|n\n%s\n%s" % (key, "-" * (len(key) + 1), desc) + for key, desc in caller.db.multidesc + ] + + caller.msg("|wStored descs:|n\n" + "\n".join(outtext)) + return + + elif "edit" in switches: + # Use the eveditor to edit/create the named description + if not args: + caller.msg("Usage: %s/edit key" % self.key) + return + + # this is used by the editor to know what to edit; it's deleted automatically + caller.db._multidesc_editkey = args + # start the editor + EvEditor( + caller, + loadfunc=_load_editor, + savefunc=_save_editor, + quitfunc=_quit_editor, + key="multidesc editor", + persistent=True, + ) + + elif "delete" in switches or "del" in switches: + # delete a multidesc entry. + if not args: + caller.msg("Usage: %s/delete key" % self.key) + return + _update_store(caller, args, delete=True) + caller.msg("Deleted description with key '%s'." % args) + + elif "swap" in switches or "switch" in switches or "reorder" in switches: + # Reorder list by swapping two entries. We expect numbers starting from 1 + keys = [arg for arg in args.split("-", 1)] + if not len(keys) == 2: + caller.msg("Usage: %s/swap key1-key2" % self.key) + return + key1, key2 = keys + # perform the swap + _update_store(caller, key1, swapkey=key2) + caller.msg("Swapped descs '%s' and '%s'." % (key1, key2)) + + elif "set" in switches: + # switches one (or more) of the multidescs to be the "active" description + _update_store(caller) + if not args: + caller.msg("Usage: %s/set key [+ key2 + key3 + ...]" % self.key) + return + new_desc = [] + multidesc = caller.db.multidesc + for key in args.split("+"): + notfound = True + lokey = key.strip().lower() + for mkey, desc in multidesc: + if lokey == mkey: + new_desc.append(desc) + notfound = False + continue + if notfound: + # if we get here, there is no desc match, we add it as a normal string + new_desc.append(key) + new_desc = "".join(new_desc) + caller.db.desc = new_desc + caller.msg("%s\n\n|wThe above was set as the current description.|n" % new_desc) + + elif self.rhs or "add" in switches: + # add text directly to a new entry or an existing one. + if not (self.lhs and self.rhs): + caller.msg("Usage: %s/add key = description" % self.key) + return + key, desc = self.lhs, self.rhs + _update_store(caller, key, desc) + caller.msg("Stored description '%s': \"%s\"" % (key, crop(desc))) + + else: + # display the current description or a numbered description + _update_store(caller) + if args: + key = args.lower() + multidesc = caller.db.multidesc + for mkey, desc in multidesc: + if key == mkey: + caller.msg("|wDecsription %s:|n\n%s" % (key, desc)) + return + caller.msg("Description key '%s' not found." % key) + else: + caller.msg("|wCurrent desc:|n\n%s" % caller.db.desc) + + except DescValidateError as err: + # This is triggered by _key_to_index + caller.msg(err)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/multidescer/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/multidescer/tests.html new file mode 100644 index 0000000000..8b4b57cf3f --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/multidescer/tests.html @@ -0,0 +1,146 @@ + + + + + + + + evennia.contrib.game_systems.multidescer.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.multidescer.tests

+"""
+Test multidescer contrib.
+
+"""
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+
+from . import multidescer
+
+
+
[docs]class TestMultidescer(BaseEvenniaCommandTest): +
[docs] def test_cmdmultidesc(self): + self.call(multidescer.CmdMultiDesc(), "/list", "Stored descs:\ncaller:") + self.call( + multidescer.CmdMultiDesc(), "test = Desc 1", "Stored description 'test': \"Desc 1\"" + ) + self.call( + multidescer.CmdMultiDesc(), "test2 = Desc 2", "Stored description 'test2': \"Desc 2\"" + ) + self.call( + multidescer.CmdMultiDesc(), "/swap test-test2", "Swapped descs 'test' and 'test2'." + ) + self.call( + multidescer.CmdMultiDesc(), + "test3 = Desc 3init", + "Stored description 'test3': \"Desc 3init\"", + ) + self.call( + multidescer.CmdMultiDesc(), + "/list", + "Stored descs:\ntest3: Desc 3init\ntest: Desc 1\ntest2: Desc 2\ncaller:", + ) + self.call( + multidescer.CmdMultiDesc(), "test3 = Desc 3", "Stored description 'test3': \"Desc 3\"" + ) + self.call( + multidescer.CmdMultiDesc(), + "/set test1 + test2 + + test3", + "test1 Desc 2 Desc 3\n\n" "The above was set as the current description.", + ) + self.assertEqual(self.char1.db.desc, "test1 Desc 2 Desc 3")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/puzzles/puzzles.html b/docs/latest/_modules/evennia/contrib/game_systems/puzzles/puzzles.html new file mode 100644 index 0000000000..61e0c12119 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/puzzles/puzzles.html @@ -0,0 +1,925 @@ + + + + + + + + evennia.contrib.game_systems.puzzles.puzzles — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.puzzles.puzzles

+"""
+Puzzles System - Provides a typeclass and commands for
+objects that can be combined (i.e. 'use'd) to produce
+new objects.
+
+Evennia contribution - Henddher 2018
+
+A Puzzle is a recipe of what objects (aka parts) must
+be combined by a player so a new set of objects
+(aka results) are automatically created.
+
+Installation:
+
+Add the PuzzleSystemCmdSet to all players (e.g. in their Character typeclass).
+
+Alternatively:
+
+    py self.cmdset.add('evennia.contrib.game_systems.puzzles.PuzzleSystemCmdSet')
+
+Usage:
+
+Consider this simple Puzzle:
+
+    orange, mango, yogurt, blender = fruit smoothie
+
+As a Builder:
+
+    create/drop orange
+    create/drop mango
+    create/drop yogurt
+    create/drop blender
+    create/drop fruit smoothie
+
+    puzzle smoothie, orange, mango, yogurt, blender = fruit smoothie
+    ...
+    Puzzle smoothie(#1234) created successfuly.
+
+    destroy/force orange, mango, yogurt, blender, fruit smoothie
+
+    armpuzzle #1234
+    Part orange is spawned at ...
+    Part mango is spawned at ...
+    ....
+    Puzzle smoothie(#1234) has been armed successfully
+
+As Player:
+
+    use orange, mango, yogurt, blender
+    ...
+    Genius, you blended all fruits to create a fruit smoothie!
+
+Details:
+
+Puzzles are created from existing objects. The given
+objects are introspected to create prototypes for the
+puzzle parts and results. These prototypes become the
+puzzle recipe. (See PuzzleRecipe and @puzzle
+command). Once the recipe is created, all parts and result
+can be disposed (i.e. destroyed).
+
+At a later time, a Builder or a Script can arm the puzzle
+and spawn all puzzle parts in their respective
+locations (See armpuzzle).
+
+A regular player can collect the puzzle parts and combine
+them (See use command). If player has specified
+all pieces, the puzzle is considered solved and all
+its puzzle parts are destroyed while the puzzle results
+are spawened on their corresponding location.
+
+
+"""
+
+import itertools
+from random import choice
+
+from evennia import (
+    CmdSet,
+    DefaultCharacter,
+    DefaultExit,
+    DefaultRoom,
+    DefaultScript,
+    create_script,
+)
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.prototypes.spawner import spawn
+from evennia.utils import logger, search, utils
+from evennia.utils.utils import inherits_from
+
+# Tag used by puzzles
+_PUZZLES_TAG_CATEGORY = "puzzles"
+_PUZZLES_TAG_RECIPE = "puzzle_recipe"
+# puzzle part and puzzle result
+_PUZZLES_TAG_MEMBER = "puzzle_member"
+
+_PUZZLE_DEFAULT_FAIL_USE_MESSAGE = "You try to utilize %s but nothing happens ... something amiss?"
+_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE = "You are a Genius!!!"
+_PUZZLE_DEFAULT_SUCCESS_USE_LOCATION_MESSAGE = "|c{caller}|n performs some kind of tribal dance and |y{result_names}|n seems to appear from thin air"
+
+# ----------- UTILITY FUNCTIONS ------------
+
+
+
[docs]def proto_def(obj, with_tags=True): + """ + Basic properties needed to spawn + and compare recipe with candidate part + """ + protodef = { + # TODO: Don't we need to honor ALL properties? attributes, contents, etc. + "prototype_key": "%s(%s)" % (obj.key, obj.dbref), + "key": obj.key, + "typeclass": obj.typeclass_path, + "desc": obj.db.desc, + "location": obj.location, + "home": obj.home, + "locks": ";".join(obj.locks.all()), + "permissions": obj.permissions.all()[:], + } + if with_tags: + tags = obj.tags.all(return_key_and_category=True) + tags = [(t[0], t[1], None) for t in tags] + tags.append((_PUZZLES_TAG_MEMBER, _PUZZLES_TAG_CATEGORY, None)) + protodef["tags"] = tags + return protodef
+ + +
[docs]def maskout_protodef(protodef, mask): + """ + Returns a new protodef after removing protodef values based on mask + """ + protodef = dict(protodef) + for m in mask: + if m in protodef: + protodef.pop(m) + return protodef
+ + +# Colorize the default success message +def _colorize_message(msg): + _i = 0 + _colors = ["|r", "|g", "|y"] + _msg = [] + for l in msg: + _msg += _colors[_i] + l + _i = (_i + 1) % len(_colors) + msg = "".join(_msg) + "|n" + return msg + + +_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE = _colorize_message(_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE) + +# ------------------------------------------ + + +
[docs]class PuzzleRecipe(DefaultScript): + """ + Definition of a Puzzle Recipe + """ + +
[docs] def save_recipe(self, puzzle_name, parts, results): + self.db.puzzle_name = str(puzzle_name) + self.db.parts = tuple(parts) + self.db.results = tuple(results) + self.db.mask = tuple() + self.tags.add(_PUZZLES_TAG_RECIPE, category=_PUZZLES_TAG_CATEGORY) + self.db.use_success_message = _PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE + self.db.use_success_location_message = _PUZZLE_DEFAULT_SUCCESS_USE_LOCATION_MESSAGE
+ + +
[docs]class CmdCreatePuzzleRecipe(MuxCommand): + """ + Creates a puzzle recipe. A puzzle consists of puzzle-parts that + the player can 'use' together to create a specified result. + + Usage: + @puzzle name,<part1[,part2,...>] = <result1[,result2,...]> + + Example: + create/drop balloon + create/drop glass of water + create/drop water balloon + @puzzle waterballon,balloon,glass of water = water balloon + @del ballon, glass of water, water balloon + @armpuzzle #1 + + Notes: + Each part and result are objects that must (temporarily) exist and be placed in their + corresponding location in order to create the puzzle. After the creation of the puzzle, + these objects are not needed anymore and can be deleted. Components of the puzzle + will be re-created by use of the `@armpuzzle` command later. + + """ + + key = "@puzzle" + aliases = "@puzzlerecipe" + locks = "cmd:perm(puzzle) or perm(Builder)" + help_category = "Puzzles" + + confirm = True + default_confirm = "no" + +
[docs] def func(self): + caller = self.caller + + if len(self.lhslist) < 2 or not self.rhs: + string = "Usage: @puzzle name,<part1[,...]> = <result1[,...]>" + caller.msg(string) + return + + puzzle_name = self.lhslist[0] + if len(puzzle_name) == 0: + caller.msg("Invalid puzzle name %r." % puzzle_name) + return + + # if there is another puzzle with same name + # warn user that parts and results will be + # interchangable + _puzzles = search.search_script_attribute(key="puzzle_name", value=puzzle_name) + _puzzles = list(filter(lambda p: isinstance(p, PuzzleRecipe), _puzzles)) + if _puzzles: + confirm = ( + "There are %d puzzles with the same name.\n" % len(_puzzles) + + "Its parts and results will be interchangeable.\n" + + "Continue yes/[no]? " + ) + answer = "" + while answer.strip().lower() not in ("y", "yes", "n", "no"): + answer = yield (confirm) + answer = self.default_confirm if answer == "" else answer + if answer.strip().lower() in ("n", "no"): + caller.msg("Cancelled: no puzzle created.") + return + + def is_valid_obj_location(obj): + valid = True + # Rooms are the only valid locations. + # TODO: other valid locations could be added here. + # Certain locations can be handled accordingly: e.g, + # a part is located in a character's inventory, + # perhaps will translate into the player character + # having the part in his/her inventory while being + # located in the same room where the builder was + # located. + # Parts and results may have different valid locations + if not inherits_from(obj.location, DefaultRoom): + caller.msg("Invalid location for %s" % (obj.key)) + valid = False + return valid + + def is_valid_part_location(part): + return is_valid_obj_location(part) + + def is_valid_result_location(part): + return is_valid_obj_location(part) + + def is_valid_inheritance(obj): + valid = ( + not inherits_from(obj, DefaultCharacter) + and not inherits_from(obj, DefaultRoom) + and not inherits_from(obj, DefaultExit) + ) + if not valid: + caller.msg("Invalid typeclass for %s" % (obj)) + return valid + + def is_valid_part(part): + return is_valid_inheritance(part) and is_valid_part_location(part) + + def is_valid_result(result): + return is_valid_inheritance(result) and is_valid_result_location(result) + + parts = [] + for objname in self.lhslist[1:]: + obj = caller.search(objname) + if not obj: + return + if not is_valid_part(obj): + return + parts.append(obj) + + results = [] + for objname in self.rhslist: + obj = caller.search(objname) + if not obj: + return + if not is_valid_result(obj): + return + results.append(obj) + + for part in parts: + caller.msg("Part %s(%s)" % (part.name, part.dbref)) + + for result in results: + caller.msg("Result %s(%s)" % (result.name, result.dbref)) + + proto_parts = [proto_def(obj) for obj in parts] + proto_results = [proto_def(obj) for obj in results] + + puzzle = create_script(PuzzleRecipe, key=puzzle_name, persistent=True) + puzzle.save_recipe(puzzle_name, proto_parts, proto_results) + puzzle.locks.add("control:id(%s) or perm(Builder)" % caller.dbref[1:]) + + caller.msg( + "Puzzle |y'%s' |w%s(%s)|n has been created |gsuccessfully|n." + % (puzzle.db.puzzle_name, puzzle.name, puzzle.dbref) + ) + + caller.msg( + "You may now dispose of all parts and results. \n" + "Use @puzzleedit #{dbref} to customize this puzzle further. \n" + "Use @armpuzzle #{dbref} to arm a new puzzle instance.".format(dbref=puzzle.dbref) + )
+ + +
[docs]class CmdEditPuzzle(MuxCommand): + """ + Edits puzzle properties + + Usage: + @puzzleedit[/delete] <#dbref> + @puzzleedit <#dbref>/use_success_message = <Custom message> + @puzzleedit <#dbref>/use_success_location_message = <Custom message from {caller} producing {result_names}> + @puzzleedit <#dbref>/mask = attr1[,attr2,...]> + @puzzleedit[/addpart] <#dbref> = <obj[,obj2,...]> + @puzzleedit[/delpart] <#dbref> = <obj[,obj2,...]> + @puzzleedit[/addresult] <#dbref> = <obj[,obj2,...]> + @puzzleedit[/delresult] <#dbref> = <obj[,obj2,...]> + + Switches: + addpart - adds parts to the puzzle + delpart - removes parts from the puzzle + addresult - adds results to the puzzle + delresult - removes results from the puzzle + delete - deletes the recipe. Existing parts and results aren't modified + + mask - attributes to exclude during matching (e.g. location, desc, etc.) + use_success_location_message containing {result_names} and {caller} will + automatically be replaced with correct values. Both are optional. + + When removing parts/results, it's possible to remove all. + + """ + + key = "@puzzleedit" + locks = "cmd:perm(puzzleedit) or perm(Builder)" + help_category = "Puzzles" + +
[docs] def func(self): + self._USAGE = "Usage: @puzzleedit[/switches] <dbref>[/attribute = <value>]" + caller = self.caller + + if not self.lhslist: + caller.msg(self._USAGE) + return + + if "/" in self.lhslist[0]: + recipe_dbref, attr = self.lhslist[0].split("/") + else: + recipe_dbref = self.lhslist[0] + + if not utils.dbref(recipe_dbref): + caller.msg("A puzzle recipe's #dbref must be specified.\n" + self._USAGE) + return + + puzzle = search.search_script(recipe_dbref) + if not puzzle or not inherits_from(puzzle[0], PuzzleRecipe): + caller.msg("%s(%s) is not a puzzle" % (puzzle[0].name, recipe_dbref)) + return + + puzzle = puzzle[0] + puzzle_name_id = "%s(%s)" % (puzzle.name, puzzle.dbref) + + if "delete" in self.switches: + if not (puzzle.access(caller, "control") or puzzle.access(caller, "delete")): + caller.msg("You don't have permission to delete %s." % puzzle_name_id) + return + + puzzle.delete() + caller.msg("%s was deleted" % puzzle_name_id) + return + + elif "addpart" in self.switches: + objs = self._get_objs() + if objs: + added = self._add_parts(objs, puzzle) + caller.msg("%s were added to parts" % (", ".join(added))) + return + + elif "delpart" in self.switches: + objs = self._get_objs() + if objs: + removed = self._remove_parts(objs, puzzle) + caller.msg("%s were removed from parts" % (", ".join(removed))) + return + + elif "addresult" in self.switches: + objs = self._get_objs() + if objs: + added = self._add_results(objs, puzzle) + caller.msg("%s were added to results" % (", ".join(added))) + return + + elif "delresult" in self.switches: + objs = self._get_objs() + if objs: + removed = self._remove_results(objs, puzzle) + caller.msg("%s were removed from results" % (", ".join(removed))) + return + + else: + # edit attributes + + if not (puzzle.access(caller, "control") or puzzle.access(caller, "edit")): + caller.msg("You don't have permission to edit %s." % puzzle_name_id) + return + + if attr == "use_success_message": + puzzle.db.use_success_message = self.rhs + caller.msg( + "%s use_success_message = %s\n" + % (puzzle_name_id, puzzle.db.use_success_message) + ) + return + elif attr == "use_success_location_message": + puzzle.db.use_success_location_message = self.rhs + caller.msg( + "%s use_success_location_message = %s\n" + % (puzzle_name_id, puzzle.db.use_success_location_message) + ) + return + elif attr == "mask": + puzzle.db.mask = tuple(self.rhslist) + caller.msg("%s mask = %r\n" % (puzzle_name_id, puzzle.db.mask)) + return
+ + def _get_objs(self): + if not self.rhslist: + self.caller.msg(self._USAGE) + return + objs = [] + for o in self.rhslist: + obj = self.caller.search(o) + if obj: + objs.append(obj) + return objs + + def _add_objs_to(self, objs, to): + """Adds propto objs to the given set (parts or results)""" + added = [] + toobjs = list(to[:]) + for obj in objs: + protoobj = proto_def(obj) + toobjs.append(protoobj) + added.append(obj.key) + return added, toobjs + + def _remove_objs_from(self, objs, frm): + """Removes propto objs from the given set (parts or results)""" + removed = [] + fromobjs = list(frm[:]) + for obj in objs: + protoobj = proto_def(obj) + if protoobj in fromobjs: + fromobjs.remove(protoobj) + removed.append(obj.key) + return removed, fromobjs + + def _add_parts(self, objs, puzzle): + added, toobjs = self._add_objs_to(objs, puzzle.db.parts) + puzzle.db.parts = tuple(toobjs) + return added + + def _remove_parts(self, objs, puzzle): + removed, fromobjs = self._remove_objs_from(objs, puzzle.db.parts) + puzzle.db.parts = tuple(fromobjs) + return removed + + def _add_results(self, objs, puzzle): + added, toobjs = self._add_objs_to(objs, puzzle.db.results) + puzzle.db.results = tuple(toobjs) + return added + + def _remove_results(self, objs, puzzle): + removed, fromobjs = self._remove_objs_from(objs, puzzle.db.results) + puzzle.db.results = tuple(fromobjs) + return removed
+ + +
[docs]class CmdArmPuzzle(MuxCommand): + """ + Arms a puzzle by spawning all its parts. + + Usage: + @armpuzzle <puzzle #dbref> + + Notes: + Create puzzles with `@puzzle`; get list of + defined puzzles using `@lspuzzlerecipes`. + + """ + + key = "@armpuzzle" + locks = "cmd:perm(armpuzzle) or perm(Builder)" + help_category = "Puzzles" + +
[docs] def func(self): + caller = self.caller + + if self.args is None or not utils.dbref(self.args): + caller.msg("A puzzle recipe's #dbref must be specified") + return + + puzzle = search.search_script(self.args) + if not puzzle or not inherits_from(puzzle[0], PuzzleRecipe): + caller.msg("Invalid puzzle %r" % (self.args)) + return + + puzzle = puzzle[0] + caller.msg( + "Puzzle Recipe %s(%s) '%s' found.\nSpawning %d parts ..." + % (puzzle.name, puzzle.dbref, puzzle.db.puzzle_name, len(puzzle.db.parts)) + ) + + for proto_part in puzzle.db.parts: + part = spawn(proto_part)[0] + caller.msg( + "Part %s(%s) spawned and placed at %s(%s)" + % (part.name, part.dbref, part.location, part.location.dbref) + ) + part.tags.add(puzzle.db.puzzle_name, category=_PUZZLES_TAG_CATEGORY) + part.db.puzzle_name = puzzle.db.puzzle_name + + caller.msg("Puzzle armed |gsuccessfully|n.")
+ + +def _lookups_parts_puzzlenames_protodefs(parts): + # Create lookup dicts by part's dbref and by puzzle_name(tags) + parts_dict = dict() + puzzlename_tags_dict = dict() + puzzle_ingredients = dict() + for part in parts: + parts_dict[part.dbref] = part + protodef = proto_def(part, with_tags=False) + # remove 'prototype_key' as it will prevent equality + del protodef["prototype_key"] + puzzle_ingredients[part.dbref] = protodef + tags_categories = part.tags.all(return_key_and_category=True) + for tag, category in tags_categories: + if category != _PUZZLES_TAG_CATEGORY: + continue + if tag not in puzzlename_tags_dict: + puzzlename_tags_dict[tag] = [] + puzzlename_tags_dict[tag].append(part.dbref) + return parts_dict, puzzlename_tags_dict, puzzle_ingredients + + +def _puzzles_by_names(names): + # Find all puzzles by puzzle name (i.e. tag name) + puzzles = [] + for puzzle_name in names: + _puzzles = search.search_script_attribute(key="puzzle_name", value=puzzle_name) + _puzzles = list(filter(lambda p: isinstance(p, PuzzleRecipe), _puzzles)) + if not _puzzles: + continue + else: + puzzles.extend(_puzzles) + return puzzles + + +def _matching_puzzles(puzzles, puzzlename_tags_dict, puzzle_ingredients): + # Check if parts can be combined to solve a puzzle + matched_puzzles = dict() + for puzzle in puzzles: + puzzle_protoparts = list(puzzle.db.parts[:]) + puzzle_mask = puzzle.db.mask[:] + # remove tags and prototype_key as they prevent equality + for i, puzzle_protopart in enumerate(puzzle_protoparts[:]): + del puzzle_protopart["tags"] + del puzzle_protopart["prototype_key"] + puzzle_protopart = maskout_protodef(puzzle_protopart, puzzle_mask) + puzzle_protoparts[i] = puzzle_protopart + + matched_dbrefparts = [] + parts_dbrefs = puzzlename_tags_dict[puzzle.db.puzzle_name] + for part_dbref in parts_dbrefs: + protopart = puzzle_ingredients[part_dbref] + protopart = maskout_protodef(protopart, puzzle_mask) + if protopart in puzzle_protoparts: + puzzle_protoparts.remove(protopart) + matched_dbrefparts.append(part_dbref) + else: + if len(puzzle_protoparts) == 0: + matched_puzzles[puzzle.dbref] = matched_dbrefparts + return matched_puzzles + + +
[docs]class CmdUsePuzzleParts(MuxCommand): + """ + Use an object, or a group of objects at once. + + + Example: + You look around you and see a pole, a long string, and a needle. + + use pole, long string, needle + + Genius! You built a fishing pole. + + + Usage: + use <obj1> [,obj2,...] + """ + + # Technical explanation + """ + Searches for all puzzles whose parts match the given set of objects. If there are matching + puzzles, the result objects are spawned in their corresponding location if all parts have been + passed in. + """ + + key = "use" + aliases = "combine" + locks = "cmd:pperm(use) or pperm(Player)" + help_category = "Puzzles" + +
[docs] def func(self): + caller = self.caller + + if not self.lhs: + caller.msg("Use what?") + return + + many = "these" if len(self.lhslist) > 1 else "this" + + # either all are parts, or abort finding matching puzzles + parts = [] + partnames = self.lhslist[:] + for partname in partnames: + part = caller.search( + partname, + multimatch_string="Which %s. There are many.\n" % (partname), + nofound_string="There is no %s around." % (partname), + ) + + if not part: + return + + if not part.tags.get(_PUZZLES_TAG_MEMBER, category=_PUZZLES_TAG_CATEGORY): + # not a puzzle part ... abort + caller.msg("You have no idea how %s can be used" % (many)) + return + + # a valid part + parts.append(part) + + # Create lookup dicts by part's dbref and by puzzle_name(tags) + parts_dict, puzzlename_tags_dict, puzzle_ingredients = _lookups_parts_puzzlenames_protodefs( + parts + ) + + # Find all puzzles by puzzle name (i.e. tag name) + puzzles = _puzzles_by_names(puzzlename_tags_dict.keys()) + + logger.log_info("PUZZLES %r" % ([(p.dbref, p.db.puzzle_name) for p in puzzles])) + + # Create lookup dict of puzzles by dbref + puzzles_dict = dict((puzzle.dbref, puzzle) for puzzle in puzzles) + # Check if parts can be combined to solve a puzzle + matched_puzzles = _matching_puzzles(puzzles, puzzlename_tags_dict, puzzle_ingredients) + + if len(matched_puzzles) == 0: + # TODO: we could use part.fail_message instead, if there was one + # random part falls and lands on your feet + # random part hits you square on the face + caller.msg(_PUZZLE_DEFAULT_FAIL_USE_MESSAGE % (many)) + return + + puzzletuples = sorted(matched_puzzles.items(), key=lambda t: len(t[1]), reverse=True) + + logger.log_info("MATCHED PUZZLES %r" % (puzzletuples)) + + # sort all matched puzzles and pick largest one(s) + puzzledbref, matched_dbrefparts = puzzletuples[0] + nparts = len(matched_dbrefparts) + puzzle = puzzles_dict[puzzledbref] + largest_puzzles = list(itertools.takewhile(lambda t: len(t[1]) == nparts, puzzletuples)) + + # if there are more than one, choose one at random. + # we could show the names of all those that can be resolved + # but that would give away that there are other puzzles that + # can be resolved with the same parts. + # just hint how many. + if len(largest_puzzles) > 1: + caller.msg( + "Your gears start turning and %d different ideas come to your mind ...\n" + % (len(largest_puzzles)) + ) + puzzletuple = choice(largest_puzzles) + puzzle = puzzles_dict[puzzletuple[0]] + caller.msg("You try %s ..." % (puzzle.db.puzzle_name)) + + # got one, spawn its results + result_names = [] + for proto_result in puzzle.db.results: + result = spawn(proto_result)[0] + result.tags.add(puzzle.db.puzzle_name, category=_PUZZLES_TAG_CATEGORY) + result.db.puzzle_name = puzzle.db.puzzle_name + result_names.append(result.name) + + # Destroy all parts used + for dbref in matched_dbrefparts: + parts_dict[dbref].delete() + + result_names = ", ".join(result_names) + caller.msg(puzzle.db.use_success_message) + caller.location.msg_contents( + puzzle.db.use_success_location_message.format(caller=caller, result_names=result_names), + exclude=(caller,), + )
+ + +
[docs]class CmdListPuzzleRecipes(MuxCommand): + """ + Searches for all puzzle recipes + + Usage: + @lspuzzlerecipes + """ + + key = "@lspuzzlerecipes" + locks = "cmd:perm(lspuzzlerecipes) or perm(Builder)" + help_category = "Puzzles" + +
[docs] def func(self): + caller = self.caller + + recipes = search.search_script_tag(_PUZZLES_TAG_RECIPE, category=_PUZZLES_TAG_CATEGORY) + + div = "-" * 60 + text = [div] + msgf_recipe = "Puzzle |y'%s' %s(%s)|n" + msgf_item = "%2s|c%15s|n: |w%s|n" + for recipe in recipes: + text.append(msgf_recipe % (recipe.db.puzzle_name, recipe.name, recipe.dbref)) + text.append("Success Caller message:\n" + recipe.db.use_success_message + "\n") + text.append( + "Success Location message:\n" + recipe.db.use_success_location_message + "\n" + ) + text.append("Mask:\n" + str(recipe.db.mask) + "\n") + text.append("Parts") + for protopart in recipe.db.parts[:]: + mark = "-" + for k, v in protopart.items(): + text.append(msgf_item % (mark, k, v)) + mark = "" + text.append("Results") + for protoresult in recipe.db.results[:]: + mark = "-" + for k, v in protoresult.items(): + text.append(msgf_item % (mark, k, v)) + mark = "" + else: + text.append(div) + text.append("Found |r%d|n puzzle(s)." % (len(recipes))) + text.append(div) + caller.msg("\n".join(text))
+ + +
[docs]class CmdListArmedPuzzles(MuxCommand): + """ + Searches for all armed puzzles + + Usage: + @lsarmedpuzzles + """ + + key = "@lsarmedpuzzles" + locks = "cmd:perm(lsarmedpuzzles) or perm(Builder)" + help_category = "Puzzles" + +
[docs] def func(self): + caller = self.caller + + armed_puzzles = search.search_tag(_PUZZLES_TAG_MEMBER, category=_PUZZLES_TAG_CATEGORY) + + armed_puzzles = dict( + (k, list(g)) for k, g in itertools.groupby(armed_puzzles, lambda ap: ap.db.puzzle_name) + ) + + div = "-" * 60 + msgf_pznm = "Puzzle name: |y%s|n" + msgf_item = "|m%25s|w(%s)|n at |c%25s|w(%s)|n" + text = [div] + for pzname, items in armed_puzzles.items(): + text.append(msgf_pznm % (pzname)) + for item in items: + text.append( + msgf_item % (item.name, item.dbref, item.location.name, item.location.dbref) + ) + else: + text.append(div) + text.append("Found |r%d|n armed puzzle(s)." % (len(armed_puzzles))) + text.append(div) + caller.msg("\n".join(text))
+ + +
[docs]class PuzzleSystemCmdSet(CmdSet): + """ + CmdSet to create, arm and resolve Puzzles + """ + +
[docs] def at_cmdset_creation(self): + super().at_cmdset_creation() + + self.add(CmdCreatePuzzleRecipe()) + self.add(CmdEditPuzzle()) + self.add(CmdArmPuzzle()) + self.add(CmdListPuzzleRecipes()) + self.add(CmdListArmedPuzzles()) + self.add(CmdUsePuzzleParts())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/puzzles/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/puzzles/tests.html new file mode 100644 index 0000000000..2a5e1efca6 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/puzzles/tests.html @@ -0,0 +1,1082 @@ + + + + + + + + evennia.contrib.game_systems.puzzles.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.puzzles.tests

+"""
+Testing puzzles.
+
+"""
+
+# Test of the Puzzles module
+
+import itertools
+import re
+
+from mock import Mock
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.utils import search
+from evennia.utils.create import create_object
+
+from . import puzzles
+
+
+
[docs]class TestPuzzles(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.steel = create_object(self.object_typeclass, key="steel", location=self.char1.location) + self.flint = create_object(self.object_typeclass, key="flint", location=self.char1.location) + self.fire = create_object(self.object_typeclass, key="fire", location=self.char1.location) + self.steel.tags.add("tag-steel") + self.steel.tags.add("tag-steel", category="tagcat") + self.flint.tags.add("tag-flint") + self.flint.tags.add("tag-flint", category="tagcat") + self.fire.tags.add("tag-fire") + self.fire.tags.add("tag-fire", category="tagcat")
+ + def _assert_msg_matched(self, msg, regexs, re_flags=0): + matches = [] + for regex in regexs: + m = re.search(regex, msg, re_flags) + self.assertIsNotNone(m, "%r didn't match %r" % (regex, msg)) + matches.append(m) + return matches + + def _assert_recipe(self, name, parts, results, and_destroy_it=True, expected_count=1): + def _keys(items): + return [item["key"] for item in items] + + recipes = search.search_script_tag("", category=puzzles._PUZZLES_TAG_CATEGORY) + self.assertEqual(expected_count, len(recipes)) + self.assertEqual(name, recipes[expected_count - 1].db.puzzle_name) + self.assertEqual(parts, _keys(recipes[expected_count - 1].db.parts)) + self.assertEqual(results, _keys(recipes[expected_count - 1].db.results)) + self.assertEqual( + puzzles._PUZZLES_TAG_RECIPE, + recipes[expected_count - 1].tags.get(category=puzzles._PUZZLES_TAG_CATEGORY), + ) + recipe_dbref = recipes[expected_count - 1].dbref + if and_destroy_it: + recipes[expected_count - 1].delete() + return recipe_dbref if not and_destroy_it else None + + def _assert_no_recipes(self): + self.assertEqual( + 0, len(search.search_script_tag("", category=puzzles._PUZZLES_TAG_CATEGORY)) + ) + + # good recipes + def _good_recipe(self, name, parts, results, and_destroy_it=True, expected_count=1): + regexs = [] + for p in parts: + regexs.append(r"^Part %s\(#\d+\)$" % (p)) + for r in results: + regexs.append(r"^Result %s\(#\d+\)$" % (r)) + regexs.append(r"^Puzzle '%s' %s\(#\d+\) has been created successfully.$" % (name, name)) + lhs = [name] + parts + cmdstr = ",".join(lhs) + "=" + ",".join(results) + msg = self.call(puzzles.CmdCreatePuzzleRecipe(), cmdstr, caller=self.char1) + recipe_dbref = self._assert_recipe(name, parts, results, and_destroy_it, expected_count) + self._assert_msg_matched(msg, regexs, re_flags=re.MULTILINE | re.DOTALL) + return recipe_dbref + + def _check_room_contents(self, expected, check_test_tags=False): + by_obj_key = lambda o: o.key + room1_contents = sorted(self.room1.contents, key=by_obj_key) + for key, grp in itertools.groupby(room1_contents, by_obj_key): + if key in expected: + grp = list(grp) + self.assertEqual( + expected[key], + len(grp), + "Expected %d but got %d for %s" % (expected[key], len(grp), key), + ) + if check_test_tags: + for gi in grp: + tags = gi.tags.all(return_key_and_category=True) + self.assertIn(("tag-" + gi.key, "tagcat"), tags) + + def _arm(self, recipe_dbref, name, parts): + regexs = [ + r"^Puzzle Recipe %s\(#\d+\) '%s' found.$" % (name, name), + r"^Spawning %d parts ...$" % (len(parts)), + ] + for p in parts: + regexs.append(r"^Part %s\(#\d+\) spawned .*$" % (p)) + regexs.append(r"^Puzzle armed successfully.$") + msg = self.call(puzzles.CmdArmPuzzle(), recipe_dbref, caller=self.char1) + self._assert_msg_matched(msg, regexs, re_flags=re.MULTILINE | re.DOTALL) + +
[docs] def test_cmdset_puzzle(self): + self.char1.cmdset.add("evennia.contrib.game_systems.puzzles.PuzzleSystemCmdSet")
+ # FIXME: testing nothing, this is just to bump up coverage + +
[docs] def test_cmd_puzzle(self): + self._assert_no_recipes() + + # bad syntax + def _bad_syntax(cmdstr): + self.call( + puzzles.CmdCreatePuzzleRecipe(), + cmdstr, + "Usage: @puzzle name,<part1[,...]> = <result1[,...]>", + caller=self.char1, + ) + + _bad_syntax("") + _bad_syntax("=") + _bad_syntax("nothing =") + _bad_syntax("= nothing") + _bad_syntax("nothing") + _bad_syntax(",nothing") + _bad_syntax("name, nothing") + _bad_syntax("name, nothing =") + + self._assert_no_recipes() + + self._good_recipe("makefire", ["steel", "flint"], ["fire", "steel", "flint"]) + self._good_recipe("hot steels", ["steel", "fire"], ["steel", "fire"]) + self._good_recipe( + "furnace", + ["steel", "steel", "fire"], + ["steel", "steel", "fire", "fire", "fire", "fire"], + ) + + # bad recipes + def _bad_recipe(name, parts, results, fail_regex): + cmdstr = ",".join([name] + parts) + "=" + ",".join(results) + msg = self.call(puzzles.CmdCreatePuzzleRecipe(), cmdstr, caller=self.char1) + self._assert_no_recipes() + self.assertIsNotNone(re.match(fail_regex, msg), msg) + + _bad_recipe("name", ["nothing"], ["neither"], r"Could not find 'nothing'.") + _bad_recipe("name", ["steel"], ["nothing"], r"Could not find 'nothing'.") + _bad_recipe("", ["steel", "fire"], ["steel", "fire"], r"^Invalid puzzle name ''.") + self.steel.location = self.char1 + _bad_recipe("name", ["steel"], ["fire"], r"^Invalid location for steel$") + _bad_recipe("name", ["flint"], ["steel"], r"^Invalid location for steel$") + _bad_recipe("name", ["self"], ["fire"], r"^Invalid typeclass for Char$") + _bad_recipe("name", ["here"], ["fire"], r"^Invalid typeclass for Room$") + + self._assert_no_recipes()
+ +
[docs] def test_cmd_armpuzzle(self): + # bad arms + self.call( + puzzles.CmdArmPuzzle(), + "1", + "A puzzle recipe's #dbref must be specified", + caller=self.char1, + ) + self.call(puzzles.CmdArmPuzzle(), "#1", "Invalid puzzle '#1'", caller=self.char1) + + recipe_dbref = self._good_recipe( + "makefire", ["steel", "flint"], ["fire", "steel", "flint"], and_destroy_it=False + ) + + # delete proto parts and proto result + self.steel.delete() + self.flint.delete() + self.fire.delete() + + # good arm + self._arm(recipe_dbref, "makefire", ["steel", "flint"]) + self._check_room_contents({"steel": 1, "flint": 1}, check_test_tags=True)
+ + def _use(self, cmdstr, expmsg): + msg = self.call(puzzles.CmdUsePuzzleParts(), cmdstr, expmsg, caller=self.char1) + return msg + +
[docs] def test_cmd_use(self): + self._use("", "Use what?") + self._use("something", "There is no something around.") + self._use("steel", "You have no idea how this can be used") + self._use("steel flint", "There is no steel flint around.") + self._use("steel, flint", "You have no idea how these can be used") + + recipe_dbref = self._good_recipe( + "makefire", ["steel", "flint"], ["fire"], and_destroy_it=False + ) + recipe2_dbref = self._good_recipe( + "makefire2", ["steel", "flint"], ["fire"], and_destroy_it=False, expected_count=2 + ) + + # although there is steel and flint + # those aren't valid puzzle parts because + # the puzzle hasn't been armed + self._use("steel", "You have no idea how this can be used") + self._use("steel, flint", "You have no idea how these can be used") + self._arm(recipe_dbref, "makefire", ["steel", "flint"]) + self._check_room_contents({"steel": 2, "flint": 2}, check_test_tags=True) + + # there are duplicated objects now + self._use("steel", "Which steel. There are many") + self._use("flint", "Which flint. There are many") + + # delete proto parts and proto results + self.steel.delete() + self.flint.delete() + self.fire.delete() + + # solve puzzle + self._use("steel, flint", "You are a Genius") + self.assertEqual( + 1, + len( + list( + filter( + lambda o: o.key == "fire" + and ("makefire", puzzles._PUZZLES_TAG_CATEGORY) + in o.tags.all(return_key_and_category=True) + and (puzzles._PUZZLES_TAG_MEMBER, puzzles._PUZZLES_TAG_CATEGORY) + in o.tags.all(return_key_and_category=True), + self.room1.contents, + ) + ) + ), + ) + self._check_room_contents({"steel": 0, "flint": 0, "fire": 1}, check_test_tags=True) + + # trying again will fail as it was resolved already + # and the parts were destroyed + self._use("steel, flint", "There is no steel around") + self._use("flint, steel", "There is no flint around") + + # arm same puzzle twice so there are duplicated parts + self._arm(recipe_dbref, "makefire", ["steel", "flint"]) + self._arm(recipe_dbref, "makefire", ["steel", "flint"]) + self._check_room_contents({"steel": 2, "flint": 2, "fire": 1}, check_test_tags=True) + + # try solving with multiple parts but incomplete set + self._use( + "steel-1, steel-2", "You try to utilize these but nothing happens ... something amiss?" + ) + + # arm the other puzzle. Their parts are identical + self._arm(recipe2_dbref, "makefire2", ["steel", "flint"]) + self._check_room_contents({"steel": 3, "flint": 3, "fire": 1}, check_test_tags=True) + + # solve with multiple parts for + # multiple puzzles. Both can be solved but + # only one is. + self._use( + "steel-1, flint-2, steel-3, flint-3", + "Your gears start turning and 2 different ideas come to your mind ... ", + ) + self._check_room_contents({"steel": 2, "flint": 2, "fire": 2}, check_test_tags=True) + + self.room1.msg_contents = Mock() + + # solve all + self._use("steel-1, flint-1", "You are a Genius") + self.room1.msg_contents.assert_called_once_with( + "|cChar|n performs some kind of tribal dance and |yfire|n seems to appear from thin air", + exclude=(self.char1,), + ) + self._use("steel, flint", "You are a Genius") + self._check_room_contents({"steel": 0, "flint": 0, "fire": 4}, check_test_tags=True)
+ +
[docs] def test_puzzleedit(self): + recipe_dbref = self._good_recipe( + "makefire", ["steel", "flint"], ["fire"], and_destroy_it=False + ) + + def _puzzleedit(swt, dbref, args, expmsg): + if (swt is None) and (dbref is None) and (args is None): + cmdstr = "" + else: + cmdstr = "%s %s%s" % (swt, dbref, args) + self.call(puzzles.CmdEditPuzzle(), cmdstr, expmsg, caller=self.char1) + + # delete proto parts and proto results + self.steel.delete() + self.flint.delete() + self.fire.delete() + + sid = self.script.id + # bad syntax + _puzzleedit( + None, None, None, "A puzzle recipe's #dbref must be specified.\nUsage: @puzzleedit" + ) + _puzzleedit("", "1", "", "A puzzle recipe's #dbref must be specified.\nUsage: @puzzleedit") + _puzzleedit("", "", "", "A puzzle recipe's #dbref must be specified.\nUsage: @puzzleedit") + _puzzleedit( + "", + recipe_dbref, + "dummy", + "A puzzle recipe's #dbref must be specified.\nUsage: @puzzleedit", + ) + _puzzleedit("", self.script.dbref, "", "Script(#{}) is not a puzzle".format(sid)) + + # edit use_success_message and use_success_location_message + _puzzleedit( + "", + recipe_dbref, + "/use_success_message = Yes!", + "makefire(%s) use_success_message = Yes!" % recipe_dbref, + ) + _puzzleedit( + "", + recipe_dbref, + "/use_success_location_message = {result_names} Yeah baby! {caller}", + "makefire(%s) use_success_location_message = {result_names} Yeah baby! {caller}" + % recipe_dbref, + ) + + self._arm(recipe_dbref, "makefire", ["steel", "flint"]) + self.room1.msg_contents = Mock() + self._use("steel, flint", "Yes!") + self.room1.msg_contents.assert_called_once_with( + "fire Yeah baby! Char", exclude=(self.char1,) + ) + self.room1.msg_contents.reset_mock() + + # edit mask: exclude location and desc during matching + _puzzleedit( + "", + recipe_dbref, + "/mask = location,desc", + "makefire(%s) mask = ('location', 'desc')" % recipe_dbref, + ) + + self._arm(recipe_dbref, "makefire", ["steel", "flint"]) + # change location and desc + self.char1.search("steel").db.desc = "A solid bar of steel" + self.char1.search("steel").location = self.char1 + self.char1.search("flint").db.desc = "A flint steel" + self.char1.search("flint").location = self.char1 + self._use("steel, flint", "Yes!") + self.room1.msg_contents.assert_called_once_with( + "fire Yeah baby! Char", exclude=(self.char1,) + ) + + # delete + _puzzleedit("/delete", recipe_dbref, "", "makefire(%s) was deleted" % recipe_dbref) + self._assert_no_recipes()
+ +
[docs] def test_puzzleedit_add_remove_parts_results(self): + recipe_dbref = self._good_recipe( + "makefire", ["steel", "flint"], ["fire"], and_destroy_it=False + ) + + def _puzzleedit(swt, dbref, rhslist, expmsg): + cmdstr = "%s %s = %s" % (swt, dbref, ", ".join(rhslist)) + self.call(puzzles.CmdEditPuzzle(), cmdstr, expmsg, caller=self.char1) + + red_steel = create_object( + self.object_typeclass, key="red steel", location=self.char1.location + ) + smoke = create_object(self.object_typeclass, key="smoke", location=self.char1.location) + + _puzzleedit("/addresult", recipe_dbref, ["smoke"], "smoke were added to results") + _puzzleedit( + "/addpart", recipe_dbref, ["red steel", "steel"], "red steel, steel were added to parts" + ) + + # create a box so we can put all objects in + # so that they can't be found during puzzle resolution + self.box = create_object(self.object_typeclass, key="box", location=self.char1.location) + + def _box_all(): + for o in self.room1.contents: + if o not in [self.char1, self.char2, self.exit, self.obj1, self.obj2, self.box]: + o.location = self.box + + _box_all() + + self._arm(recipe_dbref, "makefire", ["steel", "flint", "red steel", "steel"]) + self._check_room_contents({"steel": 2, "red steel": 1, "flint": 1}) + self._use( + "steel-1, flint", "You try to utilize these but nothing happens ... something amiss?" + ) + self._use("steel-1, flint, red steel, steel-2", "You are a Genius") + self._check_room_contents({"smoke": 1, "fire": 1}) + _box_all() + + self.fire.location = self.room1 + self.steel.location = self.room1 + + _puzzleedit("/delresult", recipe_dbref, ["fire"], "fire were removed from results") + _puzzleedit( + "/delpart", recipe_dbref, ["steel", "steel"], "steel, steel were removed from parts" + ) + + _box_all() + + self._arm(recipe_dbref, "makefire", ["flint", "red steel"]) + self._check_room_contents({"red steel": 1, "flint": 1}) + self._use("red steel, flint", "You are a Genius") + self._check_room_contents({"smoke": 1, "fire": 0})
+ +
[docs] def test_lspuzzlerecipes_lsarmedpuzzles(self): + msg = self.call(puzzles.CmdListPuzzleRecipes(), "", caller=self.char1) + self._assert_msg_matched( + msg, [r"^-+$", r"^Found 0 puzzle\(s\)\.$", r"-+$"], re.MULTILINE | re.DOTALL + ) + + recipe_dbref = self._good_recipe( + "makefire", ["steel", "flint"], ["fire"], and_destroy_it=False + ) + + msg = self.call(puzzles.CmdListPuzzleRecipes(), "", caller=self.char1) + self._assert_msg_matched( + msg, + [ + r"^-+$", + r"^Puzzle 'makefire'.*$", + r"^Success Caller message:$", + r"^Success Location message:$", + r"^Mask:$", + r"^Parts$", + r"^.*key: steel$", + r"^.*key: flint$", + r"^Results$", + r"^.*key: fire$", + r"^.*key: steel$", + r"^.*key: flint$", + r"^-+$", + r"^Found 1 puzzle\(s\)\.$", + r"^-+$", + ], + re.MULTILINE | re.DOTALL, + ) + + msg = self.call(puzzles.CmdListArmedPuzzles(), "", caller=self.char1) + self._assert_msg_matched( + msg, + [r"^-+$", r"^-+$", r"^Found 0 armed puzzle\(s\)\.$", r"^-+$"], + re.MULTILINE | re.DOTALL, + ) + + self._arm(recipe_dbref, "makefire", ["steel", "flint"]) + + msg = self.call(puzzles.CmdListArmedPuzzles(), "", caller=self.char1) + self._assert_msg_matched( + msg, + [ + r"^-+$", + r"^Puzzle name: makefire$", + r"^.*steel.* at \s+ Room.*$", + r"^.*flint.* at \s+ Room.*$", + r"^Found 1 armed puzzle\(s\)\.$", + r"^-+$", + ], + re.MULTILINE | re.DOTALL, + )
+ +
[docs] def test_e2e(self): + def _destroy_objs_in_room(keys): + for obj in self.room1.contents: + if obj.key in keys: + obj.delete() + + # parts don't survive resolution + # but produce a large result set + tree = create_object(self.object_typeclass, key="tree", location=self.char1.location) + axe = create_object(self.object_typeclass, key="axe", location=self.char1.location) + sweat = create_object(self.object_typeclass, key="sweat", location=self.char1.location) + dull_axe = create_object( + self.object_typeclass, key="dull axe", location=self.char1.location + ) + timber = create_object(self.object_typeclass, key="timber", location=self.char1.location) + log = create_object(self.object_typeclass, key="log", location=self.char1.location) + parts = ["tree", "axe"] + results = (["sweat"] * 10) + ["dull axe"] + (["timber"] * 20) + (["log"] * 50) + recipe_dbref = self._good_recipe("lumberjack", parts, results, and_destroy_it=False) + + _destroy_objs_in_room(set(parts + results)) + + sps = sorted(parts) + expected = {key: len(list(grp)) for key, grp in itertools.groupby(sps)} + expected.update({r: 0 for r in set(results)}) + + self._arm(recipe_dbref, "lumberjack", parts) + self._check_room_contents(expected) + + self._use(",".join(parts), "You are a Genius") + srs = sorted(set(results)) + expected = {(key, len(list(grp))) for key, grp in itertools.groupby(srs)} + expected.update({p: 0 for p in set(parts)}) + self._check_room_contents(expected) + + # parts also appear in results + # causing a new puzzle to be armed 'automatically' + # i.e. the puzzle is self-sustaining + hole = create_object(self.object_typeclass, key="hole", location=self.char1.location) + shovel = create_object(self.object_typeclass, key="shovel", location=self.char1.location) + dirt = create_object(self.object_typeclass, key="dirt", location=self.char1.location) + + parts = ["shovel", "hole"] + results = ["dirt", "hole", "shovel"] + recipe_dbref = self._good_recipe( + "digger", parts, results, and_destroy_it=False, expected_count=2 + ) + + _destroy_objs_in_room(set(parts + results)) + + nresolutions = 0 + + sps = sorted(set(parts)) + expected = {key: len(list(grp)) for key, grp in itertools.groupby(sps)} + expected.update({"dirt": nresolutions}) + + self._arm(recipe_dbref, "digger", parts) + self._check_room_contents(expected) + + for i in range(10): + self._use(",".join(parts), "You are a Genius") + nresolutions += 1 + expected.update({"dirt": nresolutions}) + self._check_room_contents(expected) + + # Uppercase puzzle name + balloon = create_object(self.object_typeclass, key="Balloon", location=self.char1.location) + parts = ["Balloon"] + results = ["Balloon"] + recipe_dbref = self._good_recipe( + "boom!!!", parts, results, and_destroy_it=False, expected_count=3 + ) + + _destroy_objs_in_room(set(parts + results)) + + sps = sorted(parts) + expected = {key: len(list(grp)) for key, grp in itertools.groupby(sps)} + + self._arm(recipe_dbref, "boom!!!", parts) + self._check_room_contents(expected) + + self._use(",".join(parts), "You are a Genius") + srs = sorted(set(results)) + expected = {(key, len(list(grp))) for key, grp in itertools.groupby(srs)} + self._check_room_contents(expected)
+ +
[docs] def test_e2e_accumulative(self): + flashlight = create_object( + self.object_typeclass, key="flashlight", location=self.char1.location + ) + flashlight_w_1 = create_object( + self.object_typeclass, key="flashlight-w-1", location=self.char1.location + ) + flashlight_w_2 = create_object( + self.object_typeclass, key="flashlight-w-2", location=self.char1.location + ) + flashlight_w_3 = create_object( + self.object_typeclass, key="flashlight-w-3", location=self.char1.location + ) + battery = create_object(self.object_typeclass, key="battery", location=self.char1.location) + + battery.tags.add("flashlight-1", category=puzzles._PUZZLES_TAG_CATEGORY) + battery.tags.add("flashlight-2", category=puzzles._PUZZLES_TAG_CATEGORY) + battery.tags.add("flashlight-3", category=puzzles._PUZZLES_TAG_CATEGORY) + + # TODO: instead of tagging each flashlight, + # arm and resolve each puzzle in order so they all + # are tagged correctly + # it will be necessary to add/remove parts/results because + # each battery is supposed to be consumed during resolution + # as the new flashlight has one more battery than before + flashlight_w_1.tags.add("flashlight-2", category=puzzles._PUZZLES_TAG_CATEGORY) + flashlight_w_2.tags.add("flashlight-3", category=puzzles._PUZZLES_TAG_CATEGORY) + + recipe_fl1_dbref = self._good_recipe( + "flashlight-1", + ["flashlight", "battery"], + ["flashlight-w-1"], + and_destroy_it=False, + expected_count=1, + ) + recipe_fl2_dbref = self._good_recipe( + "flashlight-2", + ["flashlight-w-1", "battery"], + ["flashlight-w-2"], + and_destroy_it=False, + expected_count=2, + ) + recipe_fl3_dbref = self._good_recipe( + "flashlight-3", + ["flashlight-w-2", "battery"], + ["flashlight-w-3"], + and_destroy_it=False, + expected_count=3, + ) + + # delete protoparts + for obj in [battery, flashlight, flashlight_w_1, flashlight_w_2, flashlight_w_3]: + obj.delete() + + def _group_parts(parts, excluding=set()): + group = dict() + dbrefs = dict() + for o in self.room1.contents: + if o.key in parts and o.dbref not in excluding: + if o.key not in group: + group[o.key] = [] + group[o.key].append(o.dbref) + dbrefs[o.dbref] = o + return group, dbrefs + + # arm each puzzle and group its parts + self._arm(recipe_fl1_dbref, "flashlight-1", ["battery", "flashlight"]) + fl1_parts, fl1_dbrefs = _group_parts(["battery", "flashlight"]) + self._arm(recipe_fl2_dbref, "flashlight-2", ["battery", "flashlight-w-1"]) + fl2_parts, fl2_dbrefs = _group_parts( + ["battery", "flashlight-w-1"], excluding=list(fl1_dbrefs.keys()) + ) + self._arm(recipe_fl3_dbref, "flashlight-3", ["battery", "flashlight-w-2"]) + fl3_parts, fl3_dbrefs = _group_parts( + ["battery", "flashlight-w-2"], + excluding=set(list(fl1_dbrefs.keys()) + list(fl2_dbrefs.keys())), + ) + + self._check_room_contents( + { + "battery": 3, + "flashlight": 1, + "flashlight-w-1": 1, + "flashlight-w-2": 1, + "flashlight-w-3": 0, + } + ) + + # all batteries have identical protodefs + battery_1 = fl1_dbrefs[fl1_parts["battery"][0]] + battery_2 = fl2_dbrefs[fl2_parts["battery"][0]] + battery_3 = fl3_dbrefs[fl3_parts["battery"][0]] + protodef_battery_1 = puzzles.proto_def(battery_1, with_tags=False) + del protodef_battery_1["prototype_key"] + protodef_battery_2 = puzzles.proto_def(battery_2, with_tags=False) + del protodef_battery_2["prototype_key"] + protodef_battery_3 = puzzles.proto_def(battery_3, with_tags=False) + del protodef_battery_3["prototype_key"] + assert protodef_battery_1 == protodef_battery_2 == protodef_battery_3 + + # each battery can be used in every other puzzle + + b1_parts_dict, b1_puzzlenames, b1_protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [battery_1] + ) + _puzzles = puzzles._puzzles_by_names(b1_puzzlenames.keys()) + assert set(["flashlight-1", "flashlight-2", "flashlight-3"]) == set( + [p.db.puzzle_name for p in _puzzles] + ) + matched_puzzles = puzzles._matching_puzzles(_puzzles, b1_puzzlenames, b1_protodefs) + assert 0 == len(matched_puzzles) + + b2_parts_dict, b2_puzzlenames, b2_protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [battery_2] + ) + _puzzles = puzzles._puzzles_by_names(b2_puzzlenames.keys()) + assert set(["flashlight-1", "flashlight-2", "flashlight-3"]) == set( + [p.db.puzzle_name for p in _puzzles] + ) + matched_puzzles = puzzles._matching_puzzles(_puzzles, b2_puzzlenames, b2_protodefs) + assert 0 == len(matched_puzzles) + b3_parts_dict, b3_puzzlenames, b3_protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [battery_3] + ) + _puzzles = puzzles._puzzles_by_names(b3_puzzlenames.keys()) + assert set(["flashlight-1", "flashlight-2", "flashlight-3"]) == set( + [p.db.puzzle_name for p in _puzzles] + ) + matched_puzzles = puzzles._matching_puzzles(_puzzles, b3_puzzlenames, b3_protodefs) + assert 0 == len(matched_puzzles) + + assert battery_1 == list(b1_parts_dict.values())[0] + assert battery_2 == list(b2_parts_dict.values())[0] + assert battery_3 == list(b3_parts_dict.values())[0] + assert b1_puzzlenames.keys() == b2_puzzlenames.keys() == b3_puzzlenames.keys() + for puzzle_name in ["flashlight-1", "flashlight-2", "flashlight-3"]: + assert puzzle_name in b1_puzzlenames + assert puzzle_name in b2_puzzlenames + assert puzzle_name in b3_puzzlenames + assert ( + list(b1_protodefs.values())[0] + == list(b2_protodefs.values())[0] + == list(b3_protodefs.values())[0] + == protodef_battery_1 + == protodef_battery_2 + == protodef_battery_3 + ) + + # all flashlights have similar protodefs except their key + flashlight_1 = fl1_dbrefs[fl1_parts["flashlight"][0]] + flashlight_2 = fl2_dbrefs[fl2_parts["flashlight-w-1"][0]] + flashlight_3 = fl3_dbrefs[fl3_parts["flashlight-w-2"][0]] + protodef_flashlight_1 = puzzles.proto_def(flashlight_1, with_tags=False) + del protodef_flashlight_1["prototype_key"] + assert protodef_flashlight_1["key"] == "flashlight" + del protodef_flashlight_1["key"] + protodef_flashlight_2 = puzzles.proto_def(flashlight_2, with_tags=False) + del protodef_flashlight_2["prototype_key"] + assert protodef_flashlight_2["key"] == "flashlight-w-1" + del protodef_flashlight_2["key"] + protodef_flashlight_3 = puzzles.proto_def(flashlight_3, with_tags=False) + del protodef_flashlight_3["prototype_key"] + assert protodef_flashlight_3["key"] == "flashlight-w-2" + del protodef_flashlight_3["key"] + assert protodef_flashlight_1 == protodef_flashlight_2 == protodef_flashlight_3 + + # each flashlight can only be used in its own puzzle + + f1_parts_dict, f1_puzzlenames, f1_protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [flashlight_1] + ) + _puzzles = puzzles._puzzles_by_names(f1_puzzlenames.keys()) + assert set(["flashlight-1"]) == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, f1_puzzlenames, f1_protodefs) + assert 0 == len(matched_puzzles) + f2_parts_dict, f2_puzzlenames, f2_protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [flashlight_2] + ) + _puzzles = puzzles._puzzles_by_names(f2_puzzlenames.keys()) + assert set(["flashlight-2"]) == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, f2_puzzlenames, f2_protodefs) + assert 0 == len(matched_puzzles) + f3_parts_dict, f3_puzzlenames, f3_protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [flashlight_3] + ) + _puzzles = puzzles._puzzles_by_names(f3_puzzlenames.keys()) + assert set(["flashlight-3"]) == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, f3_puzzlenames, f3_protodefs) + assert 0 == len(matched_puzzles) + + assert flashlight_1 == list(f1_parts_dict.values())[0] + assert flashlight_2 == list(f2_parts_dict.values())[0] + assert flashlight_3 == list(f3_parts_dict.values())[0] + for puzzle_name in set( + list(f1_puzzlenames.keys()) + list(f2_puzzlenames.keys()) + list(f3_puzzlenames.keys()) + ): + assert puzzle_name in ["flashlight-1", "flashlight-2", "flashlight-3", "puzzle_member"] + protodef_flashlight_1["key"] = "flashlight" + assert list(f1_protodefs.values())[0] == protodef_flashlight_1 + protodef_flashlight_2["key"] = "flashlight-w-1" + assert list(f2_protodefs.values())[0] == protodef_flashlight_2 + protodef_flashlight_3["key"] = "flashlight-w-2" + assert list(f3_protodefs.values())[0] == protodef_flashlight_3 + + # each battery can be matched with every other flashlight + # to potentially resolve each puzzle + for batt in [battery_1, battery_2, battery_3]: + parts_dict, puzzlenames, protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [batt, flashlight_1] + ) + assert set([batt.dbref, flashlight_1.dbref]) == set(puzzlenames["flashlight-1"]) + assert set([batt.dbref]) == set(puzzlenames["flashlight-2"]) + assert set([batt.dbref]) == set(puzzlenames["flashlight-3"]) + _puzzles = puzzles._puzzles_by_names(puzzlenames.keys()) + assert set(["flashlight-1", "flashlight-2", "flashlight-3"]) == set( + [p.db.puzzle_name for p in _puzzles] + ) + matched_puzzles = puzzles._matching_puzzles(_puzzles, puzzlenames, protodefs) + assert 1 == len(matched_puzzles) + parts_dict, puzzlenames, protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [batt, flashlight_2] + ) + assert set([batt.dbref]) == set(puzzlenames["flashlight-1"]) + assert set([batt.dbref, flashlight_2.dbref]) == set(puzzlenames["flashlight-2"]) + assert set([batt.dbref]) == set(puzzlenames["flashlight-3"]) + _puzzles = puzzles._puzzles_by_names(puzzlenames.keys()) + assert set(["flashlight-1", "flashlight-2", "flashlight-3"]) == set( + [p.db.puzzle_name for p in _puzzles] + ) + matched_puzzles = puzzles._matching_puzzles(_puzzles, puzzlenames, protodefs) + assert 1 == len(matched_puzzles) + parts_dict, puzzlenames, protodefs = puzzles._lookups_parts_puzzlenames_protodefs( + [batt, flashlight_3] + ) + assert set([batt.dbref]) == set(puzzlenames["flashlight-1"]) + assert set([batt.dbref]) == set(puzzlenames["flashlight-2"]) + assert set([batt.dbref, flashlight_3.dbref]) == set(puzzlenames["flashlight-3"]) + _puzzles = puzzles._puzzles_by_names(puzzlenames.keys()) + assert set(["flashlight-1", "flashlight-2", "flashlight-3"]) == set( + [p.db.puzzle_name for p in _puzzles] + ) + matched_puzzles = puzzles._matching_puzzles(_puzzles, puzzlenames, protodefs) + assert 1 == len(matched_puzzles) + + # delete all parts + for part in ( + list(fl1_dbrefs.values()) + list(fl2_dbrefs.values()) + list(fl3_dbrefs.values()) + ): + part.delete() + + self._check_room_contents( + { + "battery": 0, + "flashlight": 0, + "flashlight-w-1": 0, + "flashlight-w-2": 0, + "flashlight-w-3": 0, + } + ) + + # arm first puzzle 3 times and group its parts so we can solve + # all puzzles with the parts from the 1st armed + for i in range(3): + self._arm(recipe_fl1_dbref, "flashlight-1", ["battery", "flashlight"]) + fl1_parts, fl1_dbrefs = _group_parts(["battery", "flashlight"]) + + # delete the 2 extra flashlights so we can start solving + for flashlight_dbref in fl1_parts["flashlight"][1:]: + fl1_dbrefs[flashlight_dbref].delete() + + self._check_room_contents( + { + "battery": 3, + "flashlight": 1, + "flashlight-w-1": 0, + "flashlight-w-2": 0, + "flashlight-w-3": 0, + } + ) + + self._use("battery-1, flashlight", "You are a Genius") + self._check_room_contents( + { + "battery": 2, + "flashlight": 0, + "flashlight-w-1": 1, + "flashlight-w-2": 0, + "flashlight-w-3": 0, + } + ) + + self._use("battery-1, flashlight-w-1", "You are a Genius") + self._check_room_contents( + { + "battery": 1, + "flashlight": 0, + "flashlight-w-1": 0, + "flashlight-w-2": 1, + "flashlight-w-3": 0, + } + ) + + self._use("battery, flashlight-w-2", "You are a Genius") + self._check_room_contents( + { + "battery": 0, + "flashlight": 0, + "flashlight-w-1": 0, + "flashlight-w-2": 0, + "flashlight-w-3": 1, + } + )
+ +
[docs] def test_e2e_interchangeable_parts_and_results(self): + # Parts and Results can be used in multiple puzzles + egg = create_object(self.object_typeclass, key="egg", location=self.char1.location) + flour = create_object(self.object_typeclass, key="flour", location=self.char1.location) + boiling_water = create_object( + self.object_typeclass, key="boiling water", location=self.char1.location + ) + boiled_egg = create_object( + self.object_typeclass, key="boiled egg", location=self.char1.location + ) + dough = create_object(self.object_typeclass, key="dough", location=self.char1.location) + pasta = create_object(self.object_typeclass, key="pasta", location=self.char1.location) + + # Three recipes: + # 1. breakfast: egg + boiling water = boiled egg & boiling water + # 2. dough: egg + flour = dough + # 3. entree: dough + boiling water = pasta & boiling water + # tag interchangeable parts according to their puzzles' name + egg.tags.add("breakfast", category=puzzles._PUZZLES_TAG_CATEGORY) + egg.tags.add("dough", category=puzzles._PUZZLES_TAG_CATEGORY) + dough.tags.add("entree", category=puzzles._PUZZLES_TAG_CATEGORY) + boiling_water.tags.add("breakfast", category=puzzles._PUZZLES_TAG_CATEGORY) + boiling_water.tags.add("entree", category=puzzles._PUZZLES_TAG_CATEGORY) + + # create recipes + recipe1_dbref = self._good_recipe( + "breakfast", + ["egg", "boiling water"], + ["boiled egg", "boiling water"], + and_destroy_it=False, + ) + recipe2_dbref = self._good_recipe( + "dough", ["egg", "flour"], ["dough"], and_destroy_it=False, expected_count=2 + ) + recipe3_dbref = self._good_recipe( + "entree", + ["dough", "boiling water"], + ["pasta", "boiling water"], + and_destroy_it=False, + expected_count=3, + ) + + # delete protoparts + for obj in [egg, flour, boiling_water, boiled_egg, dough, pasta]: + obj.delete() + + # arm each puzzle and group its parts + def _group_parts(parts, excluding=set()): + group = dict() + dbrefs = dict() + for o in self.room1.contents: + if o.key in parts and o.dbref not in excluding: + if o.key not in group: + group[o.key] = [] + group[o.key].append(o.dbref) + dbrefs[o.dbref] = o + return group, dbrefs + + self._arm(recipe1_dbref, "breakfast", ["egg", "boiling water"]) + breakfast_parts, breakfast_dbrefs = _group_parts(["egg", "boiling water"]) + self._arm(recipe2_dbref, "dough", ["egg", "flour"]) + dough_parts, dough_dbrefs = _group_parts( + ["egg", "flour"], excluding=list(breakfast_dbrefs.keys()) + ) + self._arm(recipe3_dbref, "entree", ["dough", "boiling water"]) + entree_parts, entree_dbrefs = _group_parts( + ["dough", "boiling water"], + excluding=set(list(breakfast_dbrefs.keys()) + list(dough_dbrefs.keys())), + ) + + # create a box so we can put all objects in + # so that they can't be found during puzzle resolution + self.box = create_object(self.object_typeclass, key="box", location=self.char1.location) + + def _box_all(): + # print "boxing all\n", "-"*20 + for o in self.room1.contents: + if o not in [self.char1, self.char2, self.exit, self.obj1, self.obj2, self.box]: + o.location = self.box + # print o.key, o.dbref, "boxed" + else: + # print "skipped", o.key, o.dbref + pass + + def _unbox(dbrefs): + # print "unboxing", dbrefs, "\n", "-"*20 + for o in self.box.contents: + if o.dbref in dbrefs: + o.location = self.room1 + # print "unboxed", o.key, o.dbref + + # solve dough puzzle using breakfast's egg + # and dough's flour. A new dough will be created + _box_all() + _unbox(breakfast_parts.pop("egg") + dough_parts.pop("flour")) + self._use("egg, flour", "You are a Genius") + + # solve entree puzzle with newly created dough + # and breakfast's boiling water. A new + # boiling water and pasta will be created + _unbox(breakfast_parts.pop("boiling water")) + self._use("boiling water, dough", "You are a Genius") + + # solve breakfast puzzle with dough's egg + # and newly created boiling water. A new + # boiling water and boiled egg will be created + _unbox(dough_parts.pop("egg")) + self._use("boiling water, egg", "You are a Genius") + + # solve entree puzzle using entree's dough + # and newly created boiling water. A new + # boiling water and pasta will be created + _unbox(entree_parts.pop("dough")) + self._use("boiling water, dough", "You are a Genius") + + self._check_room_contents({"boiling water": 1, "pasta": 2, "boiled egg": 1})
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_basic.html b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_basic.html new file mode 100644 index 0000000000..d02932d54d --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_basic.html @@ -0,0 +1,910 @@ + + + + + + + + evennia.contrib.game_systems.turnbattle.tb_basic — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.turnbattle.tb_basic

+"""
+Simple turn-based combat system
+
+Contrib - Tim Ashley Jenkins 2017, Refactor by Griatch 2022
+
+This is a framework for a simple turn-based combat system, similar
+to those used in D&D-style tabletop role playing games. It allows
+any character to start a fight in a room, at which point initiative
+is rolled and a turn order is established. Each participant in combat
+has a limited time to decide their action for that turn (30 seconds by
+default), and combat progresses through the turn order, looping through
+the participants until the fight ends.
+
+Only simple rolls for attacking are implemented here, but this system
+is easily extensible and can be used as the foundation for implementing
+the rules from your turn-based tabletop game of choice or making your
+own battle system.
+
+To install and test, import this module's TBBasicCharacter object into
+your game's character.py module:
+
+    from evennia.contrib.game_systems.turnbattle.tb_basic import TBBasicCharacter
+
+And change your game's character typeclass to inherit from TBBasicCharacter
+instead of the default:
+
+    class Character(TBBasicCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+    from evennia.contrib.game_systems.turnbattle import tb_basic
+
+And add the battle command set to your default command set:
+
+    #
+    # any commands you add below will overload the default ones.
+    #
+    self.add(tb_basic.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+
+from evennia import Command, DefaultCharacter, DefaultScript, default_cmds
+from evennia.commands.default.help import CmdHelp
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT = 30  # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 1  # Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]class BasicCombatRules: + """ + Stores all combat rules and helper methods. + + """ + +
[docs] def roll_init(self, character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000)
+ +
[docs] def get_attack(self, attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, returns a random integer from 1 to 100 without using any + properties from either the attacker or defender. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + return attack_value
+ +
[docs] def get_defense(self, attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value
+ +
[docs] def get_damage(self, attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value
+ +
[docs] def apply_damage(self, defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0
+ +
[docs] def at_defeat(self, defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated)
+ +
[docs] def resolve_attack(self, attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = self.get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = self.get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) + else: + damage_value = self.get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents( + "%s hits %s for %i damage!" % (attacker, defender, damage_value) + ) + self.apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + self.at_defeat(defender)
+ +
[docs] def combat_cleanup(self, character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it!
+ +
[docs] def is_in_combat(self, character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler)
+ +
[docs] def is_turn(self, character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar)
+ +
[docs] def spend_action(self, character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Keyword Args: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.combat_lastaction = action_name + if actions == "all": # If spending all actions + character.db.combat_actionsleft = 0 # Set actions to 0 + else: + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
+ + +COMBAT_RULES = BasicCombatRules() + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +
[docs]class TBBasicCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + rules = COMBAT_RULES + +
[docs] def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """
+ +
[docs] def at_pre_move(self, destination, move_type="move", **kwargs): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if self.rules.is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True
+ + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class TBBasicTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + rules = COMBAT_RULES + +
[docs] def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine + # turn order. The initiative roll is determined by the roll_init method and can be + # customized easily. + ordered_by_roll = sorted(self.db.fighters, key=self.rules.roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
+ +
[docs] def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + if fighter: + # Clean up the combat attributes for every fighter. + self.rules.combat_cleanup(fighter) + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
+ +
[docs] def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[ + self.db.turn + ] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + self.rules.spend_action( + currentchar, "all", action_name="disengage" + ) # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True
+ +
[docs] def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + # Clean up leftover combat attributes beforehand, just in case. + self.rules.combat_cleanup(character) + character.db.combat_actionsleft = ( + 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + ) + character.db.combat_turnhandler = ( + self # Add a reference to this turn handler script to the character + ) + character.db.combat_lastaction = "null" # Track last action taken in combat
+ +
[docs] def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
+ +
[docs] def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if ( + fighter.db.combat_lastaction != "disengage" + ): # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + self.delete() + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == ( + len(self.db.fighters) - 1 + ): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + self.delete() + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn.
+ +
[docs] def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return
+ +
[docs] def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character)
+ + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + + key = "fight" + help_category = "combat" + + rules = COMBAT_RULES + combat_handler_class = TBBasicTurnHandler + +
[docs] def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if self.rules.is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.combat_turnhandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add(self.command_handler_class)
+ + +
[docs]class CmdAttack(Command): + """ + Attacks another character. + + Usage: + attack <target> + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not self.rules.is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not self.rules.is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + "If everything checks out, call the attack resolving function." + self.rules.resolve_attack(attacker, defender) + self.rules.spend_action(self.caller, 1, action_name="attack") # Use up one action.
+ + +
[docs]class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + """ + This performs the actual command. + """ + if not self.rules.is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not self.rules.is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents( + "%s takes no further action, passing the turn." % self.caller + ) + # Spend all remaining actions. + self.rules.spend_action(self.caller, "all", action_name="pass")
+ + +
[docs]class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + """ + This performs the actual command. + """ + if not self.rules.is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not self.rules.is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + # Spend all remaining actions. + self.rules.spend_action(self.caller, "all", action_name="disengage") + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """
+ + +
[docs]class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + "This performs the actual command." + + if self.rules.is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """
+ + +
[docs]class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help <topic or command> + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + + rules = COMBAT_RULES + combat_help_text = ( + "Available combat commands:|/" + "|wAttack:|n Attack a target, attempting to deal damage.|/" + "|wPass:|n Pass your turn without further action.|/" + "|wDisengage:|n End your turn and attempt to end combat.|/" + ) + + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + +
[docs] def func(self): + # In combat and entered 'help' alone + if self.rules.is_in_combat(self.caller) and not self.args: + self.caller.msg(self.combat_help_text) + else: + super().func() # Call the default help command
+ + +
[docs]class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + + key = "DefaultCharacter" + +
[docs] def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_equip.html b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_equip.html new file mode 100644 index 0000000000..f580f0481b --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_equip.html @@ -0,0 +1,837 @@ + + + + + + + + evennia.contrib.game_systems.turnbattle.tb_equip — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.turnbattle.tb_equip

+"""
+Simple turn-based combat system with equipment
+
+Contrib - Tim Ashley Jenkins 2017, Refactor by Griatch 2022
+
+This is a version of the 'turnbattle' contrib with a basic system for
+weapons and armor implemented. Weapons can have unique damage ranges
+and accuracy modifiers, while armor can reduce incoming damage and
+change one's chance of getting hit. The 'wield' command is used to
+equip weapons and the 'don' command is used to equip armor.
+
+Some prototypes are included at the end of this module - feel free to
+copy them into your game's prototypes.py module in your 'world' folder
+and create them with the @spawn command. (See the tutorial for using
+the @spawn command for details.)
+
+For the example equipment given, heavier weapons deal more damage
+but are less accurate, while light weapons are more accurate but
+deal less damage. Similarly, heavy armor reduces incoming damage by
+a lot but increases your chance of getting hit, while light armor is
+easier to dodge in but reduces incoming damage less. Light weapons are
+more effective against lightly armored opponents and heavy weapons are
+more damaging against heavily armored foes, but heavy weapons and armor
+are slightly better than light weapons and armor overall.
+
+This is a fairly bare implementation of equipment that is meant to be
+expanded to fit your game - weapon and armor slots, damage types and
+damage bonuses, etc. should be fairly simple to implement according to
+the rules of your preferred system or the needs of your own game.
+
+To install and test, import this module's TBEquipCharacter object into
+your game's character.py module:
+
+    from evennia.contrib.game_systems.turnbattle.tb_equip import TBEquipCharacter
+
+And change your game's character typeclass to inherit from TBEquipCharacter
+instead of the default:
+
+    class Character(TBEquipCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+    from evennia.contrib.game_systems.turnbattle import tb_equip
+
+And add the battle command set to your default command set:
+
+    #
+    # any commands you add below will overload the default ones.
+    #
+    self.add(tb_equip.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+
+from evennia import Command, DefaultObject, default_cmds
+
+from . import tb_basic
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT = 30  # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 1  # Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]class EquipmentCombatRules(tb_basic.BasicCombatRules): + """ + Has all the methods of the basic combat, with the addition of equipment. + + """ + +
[docs] def get_attack(self, attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + In this example, a weapon's accuracy bonus is factored into the attack + roll. Lighter weapons are more accurate but less damaging, and heavier + weapons are less accurate but deal more damage. Of course, you can + change this paradigm completely in your own game. + """ + # Start with a roll from 1 to 100. + attack_value = randint(1, 100) + accuracy_bonus = 0 + # If armed, add weapon's accuracy bonus. + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + accuracy_bonus += weapon.db.accuracy_bonus + # If unarmed, use character's unarmed accuracy bonus. + else: + accuracy_bonus += attacker.db.unarmed_accuracy + # Add the accuracy bonus to the attack roll. + attack_value += accuracy_bonus + return attack_value
+ +
[docs] def get_defense(self, attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + Characters are given a default defense value of 50 which can be + modified up or down by armor. In this example, wearing armor actually + makes you a little easier to hit, but reduces incoming damage. + """ + # Start with a defense value of 50 for a 50/50 chance to hit. + defense_value = 50 + # Modify this value based on defender's armor. + if defender.db.worn_armor: + armor = defender.db.worn_armor + defense_value += armor.db.defense_modifier + return defense_value
+ +
[docs] def get_damage(self, attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + Damage is determined by the attacker's wielded weapon, or the attacker's + unarmed damage range if no weapon is wielded. Incoming damage is reduced + by the defender's armor. + """ + damage_value = 0 + # Generate a damage value from wielded weapon if armed + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + # Roll between minimum and maximum damage + damage_value = randint(weapon.db.damage_range[0], weapon.db.damage_range[1]) + # Use attacker's unarmed damage otherwise + else: + damage_value = randint( + attacker.db.unarmed_damage_range[0], attacker.db.unarmed_damage_range[1] + ) + # If defender is armored, reduce incoming damage + if defender.db.worn_armor: + armor = defender.db.worn_armor + damage_value -= armor.db.damage_reduction + # Make sure minimum damage is 0 + if damage_value < 0: + damage_value = 0 + return damage_value
+ +
[docs] def resolve_attack(self, attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get the attacker's weapon type to reference in combat messages. + attackers_weapon = "attack" + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + attackers_weapon = weapon.db.weapon_type_name + # Get an attack roll from the attacker. + if not attack_value: + attack_value = self.get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = self.get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents( + "%s's %s misses %s!" % (attacker, attackers_weapon, defender) + ) + else: + damage_value = self.get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + if damage_value > 0: + attacker.location.msg_contents( + "%s's %s strikes %s for %i damage!" + % (attacker, attackers_weapon, defender, damage_value) + ) + else: + attacker.location.msg_contents( + "%s's %s bounces harmlessly off %s!" % (attacker, attackers_weapon, defender) + ) + self.apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + self.at_defeat(defender)
+ + +COMBAT_RULES = EquipmentCombatRules() + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class TBEquipTurnHandler(tb_basic.TBBasicTurnHandler): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + rules = COMBAT_RULES
+ + +""" +---------------------------------------------------------------------------- +TYPECLASSES START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class TBEWeapon(DefaultObject): + """ + A weapon which can be wielded in combat with the 'wield' command. + + """ + + rules = COMBAT_RULES + +
[docs] def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.damage_range = (15, 25) # Minimum and maximum damage on hit + self.db.accuracy_bonus = 0 # Bonus to attack rolls (or penalty if negative) + self.db.weapon_type_name = ( + "weapon" # Single word for weapon - I.E. "dagger", "staff", "scimitar" + )
+ +
[docs] def at_drop(self, dropper): + """ + Stop being wielded if dropped. + """ + if dropper.db.wielded_weapon == self: + dropper.db.wielded_weapon = None + dropper.location.msg_contents("%s stops wielding %s." % (dropper, self))
+ +
[docs] def at_give(self, giver, getter): + """ + Stop being wielded if given. + """ + if giver.db.wielded_weapon == self: + giver.db.wielded_weapon = None + giver.location.msg_contents("%s stops wielding %s." % (giver, self))
+ + +
[docs]class TBEArmor(DefaultObject): + """ + A set of armor which can be worn with the 'don' command. + """ + +
[docs] def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.damage_reduction = 4 # Amount of incoming damage reduced by armor + self.db.defense_modifier = ( + -4 + ) # Amount to modify defense value (pos = harder to hit, neg = easier)
+ +
[docs] def at_pre_drop(self, dropper): + """ + Can't drop in combat. + """ + if self.rules.is_in_combat(dropper): + dropper.msg("You can't doff armor in a fight!") + return False + return True
+ +
[docs] def at_drop(self, dropper): + """ + Stop being wielded if dropped. + """ + if dropper.db.worn_armor == self: + dropper.db.worn_armor = None + dropper.location.msg_contents("%s removes %s." % (dropper, self))
+ +
[docs] def at_pre_give(self, giver, getter): + """ + Can't give away in combat. + """ + if self.rules.is_in_combat(giver): + dropper.msg("You can't doff armor in a fight!") + return False + return True
+ +
[docs] def at_give(self, giver, getter): + """ + Stop being wielded if given. + """ + if giver.db.worn_armor == self: + giver.db.worn_armor = None + giver.location.msg_contents("%s removes %s." % (giver, self))
+ + +
[docs]class TBEquipCharacter(tb_basic.TBBasicCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + +
[docs] def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.wielded_weapon = None # Currently used weapon + self.db.worn_armor = None # Currently worn armor + self.db.unarmed_damage_range = (5, 15) # Minimum and maximum unarmed damage + self.db.unarmed_accuracy = 30 # Accuracy bonus for unarmed attacks + + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """
+ + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class CmdFight(tb_basic.CmdFight): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + + key = "fight" + help_category = "combat" + + rules = COMBAT_RULES + command_handler_class = TBEquipTurnHandler
+ + +
[docs]class CmdAttack(tb_basic.CmdAttack): + """ + Attacks another character. + + Usage: + attack <target> + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdPass(tb_basic.CmdPass): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdDisengage(tb_basic.CmdDisengage): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdRest(tb_basic.CmdRest): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdCombatHelp(tb_basic.CmdCombatHelp): + """ + View help or a list of topics + + Usage: + help <topic or command> + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + + rules = COMBAT_RULES
+ + +
[docs]class CmdWield(Command): + """ + Wield a weapon you are carrying + + Usage: + wield <weapon> + + Select a weapon you are carrying to wield in combat. If + you are already wielding another weapon, you will switch + to the weapon you specify instead. Using this command in + combat will spend your action for your turn. Use the + "unwield" command to stop wielding any weapon you are + currently wielding. + """ + + key = "wield" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + """ + This performs the actual command. + """ + # If in combat, check to see if it's your turn. + if self.rules.is_in_combat(self.caller): + if not self.rules.is_turn(self.caller): + self.caller.msg("You can only do that on your turn.") + return + if not self.args: + self.caller.msg("Usage: wield <obj>") + return + weapon = self.caller.search(self.args, candidates=self.caller.contents) + if not weapon: + return + if not weapon.is_typeclass( + "evennia.contrib.game_systems.turnbattle.tb_equip.TBEWeapon", exact=True + ): + self.caller.msg("That's not a weapon!") + # Remember to update the path to the weapon typeclass if you move this module! + return + + if not self.caller.db.wielded_weapon: + self.caller.db.wielded_weapon = weapon + self.caller.location.msg_contents("%s wields %s." % (self.caller, weapon)) + else: + old_weapon = self.caller.db.wielded_weapon + self.caller.db.wielded_weapon = weapon + self.caller.location.msg_contents( + "%s lowers %s and wields %s." % (self.caller, old_weapon, weapon) + ) + # Spend an action if in combat. + if self.rules.is_in_combat(self.caller): + self.rules.spend_action(self.caller, 1, action_name="wield") # Use up one action.
+ + +
[docs]class CmdUnwield(Command): + """ + Stop wielding a weapon. + + Usage: + unwield + + After using this command, you will stop wielding any + weapon you are currently wielding and become unarmed. + """ + + key = "unwield" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + """ + This performs the actual command. + """ + # If in combat, check to see if it's your turn. + if self.rules.is_in_combat(self.caller): + if not self.rules.is_turn(self.caller): + self.caller.msg("You can only do that on your turn.") + return + if not self.caller.db.wielded_weapon: + self.caller.msg("You aren't wielding a weapon!") + else: + old_weapon = self.caller.db.wielded_weapon + self.caller.db.wielded_weapon = None + self.caller.location.msg_contents("%s lowers %s." % (self.caller, old_weapon))
+ + +
[docs]class CmdDon(Command): + """ + Don armor that you are carrying + + Usage: + don <armor> + + Select armor to wear in combat. You can't use this + command in the middle of a fight. Use the "doff" + command to remove any armor you are wearing. + """ + + key = "don" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + """ + This performs the actual command. + """ + # Can't do this in combat + if self.rules.is_in_combat(self.caller): + self.caller.msg("You can't don armor in a fight!") + return + if not self.args: + self.caller.msg("Usage: don <obj>") + return + armor = self.caller.search(self.args, candidates=self.caller.contents) + if not armor: + return + if not armor.is_typeclass( + "evennia.contrib.game_systems.turnbattle.tb_equip.TBEArmor", exact=True + ): + self.caller.msg("That's not armor!") + # Remember to update the path to the armor typeclass if you move this module! + return + + if not self.caller.db.worn_armor: + self.caller.db.worn_armor = armor + self.caller.location.msg_contents("%s dons %s." % (self.caller, armor)) + else: + old_armor = self.caller.db.worn_armor + self.caller.db.worn_armor = armor + self.caller.location.msg_contents( + "%s removes %s and dons %s." % (self.caller, old_armor, armor) + )
+ + +
[docs]class CmdDoff(Command): + """ + Stop wearing armor. + + Usage: + doff + + After using this command, you will stop wearing any + armor you are currently using and become unarmored. + You can't use this command in combat. + """ + + key = "doff" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + """ + This performs the actual command. + """ + # Can't do this in combat + if self.rules.is_in_combat(self.caller): + self.caller.msg("You can't doff armor in a fight!") + return + if not self.caller.db.worn_armor: + self.caller.msg("You aren't wearing any armor!") + else: + old_armor = self.caller.db.worn_armor + self.caller.db.worn_armor = None + self.caller.location.msg_contents("%s removes %s." % (self.caller, old_armor))
+ + +
[docs]class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + + key = "DefaultCharacter" + +
[docs] def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) + self.add(CmdWield()) + self.add(CmdUnwield()) + self.add(CmdDon()) + self.add(CmdDoff())
+ + +""" +---------------------------------------------------------------------------- +PROTOTYPES START HERE +---------------------------------------------------------------------------- +""" + +BASEWEAPON = {"typeclass": "evennia.contrib.game_systems.turnbattle.tb_equip.TBEWeapon"} + +BASEARMOR = {"typeclass": "evennia.contrib.game_systems.turnbattle.tb_equip.TBEArmor"} + +DAGGER = { + "prototype": "BASEWEAPON", + "damage_range": (10, 20), + "accuracy_bonus": 30, + "key": "a thin steel dagger", + "weapon_type_name": "dagger", +} + +BROADSWORD = { + "prototype": "BASEWEAPON", + "damage_range": (15, 30), + "accuracy_bonus": 15, + "key": "an iron broadsword", + "weapon_type_name": "broadsword", +} + +GREATSWORD = { + "prototype": "BASEWEAPON", + "damage_range": (20, 40), + "accuracy_bonus": 0, + "key": "a rune-etched greatsword", + "weapon_type_name": "greatsword", +} + +LEATHERARMOR = { + "prototype": "BASEARMOR", + "damage_reduction": 2, + "defense_modifier": -2, + "key": "a suit of leather armor", +} + +SCALEMAIL = { + "prototype": "BASEARMOR", + "damage_reduction": 4, + "defense_modifier": -4, + "key": "a suit of scale mail", +} + +PLATEMAIL = { + "prototype": "BASEARMOR", + "damage_reduction": 6, + "defense_modifier": -6, + "key": "a suit of plate mail", +} +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_items.html b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_items.html new file mode 100644 index 0000000000..31dc955405 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_items.html @@ -0,0 +1,1142 @@ + + + + + + + + evennia.contrib.game_systems.turnbattle.tb_items — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.turnbattle.tb_items

+"""
+Simple turn-based combat system with items and status effects
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' combat system that includes
+conditions and usable items, which can instill these conditions, cure
+them, or do just about anything else.
+
+Conditions are stored on characters as a dictionary, where the key
+is the name of the condition and the value is a list of two items:
+an integer representing the number of turns left until the condition
+runs out, and the character upon whose turn the condition timer is
+ticked down. Unlike most combat-related attributes, conditions aren't
+wiped once combat ends - if out of combat, they tick down in real time
+instead.
+
+This module includes a number of example conditions:
+
+    Regeneration: Character recovers HP every turn
+    Poisoned: Character loses HP every turn
+    Accuracy Up: +25 to character's attack rolls
+    Accuracy Down: -25 to character's attack rolls
+    Damage Up: +5 to character's damage
+    Damage Down: -5 to character's damage
+    Defense Up: +15 to character's defense
+    Defense Down: -15 to character's defense
+    Haste: +1 action per turn
+    Paralyzed: No actions per turn
+    Frightened: Character can't use the 'attack' command
+
+Since conditions can have a wide variety of effects, their code is
+scattered throughout the other functions wherever they may apply.
+
+Items aren't given any sort of special typeclass - instead, whether or
+not an object counts as an item is determined by its attributes. To make
+an object into an item, it must have the attribute 'item_func', with
+the value given as a callable - this is the function that will be called
+when an item is used. Other properties of the item, such as how many
+uses it has, whether it's destroyed when its uses are depleted, and such
+can be specified on the item as well, but they are optional.
+
+To install and test, import this module's TBItemsCharacter object into
+your game's character.py module:
+
+    from evennia.contrib.game_systems.turnbattle.tb_items import TBItemsCharacter
+
+And change your game's character typeclass to inherit from TBItemsCharacter
+instead of the default:
+
+    class Character(TBItemsCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+    from evennia.contrib.game_systems.turnbattle import tb_items
+
+And add the battle command set to your default command set:
+
+    #
+    # any commands you add below will overload the default ones.
+    #
+    self.add(tb_items.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+
+from evennia import TICKER_HANDLER as tickerhandler
+from evennia import Command, default_cmds
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.prototypes.spawner import spawn
+
+from . import tb_basic
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT = 30  # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 1  # Number of actions allowed per turn
+NONCOMBAT_TURN_TIME = 30  # Time per turn count out of combat
+
+# Condition options start here.
+# If you need to make changes to how your conditions work later,
+# it's best to put the easily tweakable values all in one place!
+
+REGEN_RATE = (4, 8)  # Min and max HP regen for Regeneration
+POISON_RATE = (4, 8)  # Min and max damage for Poisoned
+ACC_UP_MOD = 25  # Accuracy Up attack roll bonus
+ACC_DOWN_MOD = -25  # Accuracy Down attack roll penalty
+DMG_UP_MOD = 5  # Damage Up damage roll bonus
+DMG_DOWN_MOD = -5  # Damage Down damage roll penalty
+DEF_UP_MOD = 15  # Defense Up defense bonus
+DEF_DOWN_MOD = -15  # Defense Down defense penalty
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]class ItemCombatRules(tb_basic.BasicCombatRules): +
[docs] def get_attack(self, attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + This is where conditions affecting attack rolls are applied, as well. + Accuracy Up and Accuracy Down are also accounted for in itemfunc_attack(), + so that attack items' accuracy is affected as well. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + # Add to the roll if the attacker has the "Accuracy Up" condition. + if "Accuracy Up" in attacker.db.conditions: + attack_value += ACC_UP_MOD + # Subtract from the roll if the attack has the "Accuracy Down" condition. + if "Accuracy Down" in attacker.db.conditions: + attack_value += ACC_DOWN_MOD + return attack_value
+ +
[docs] def get_defense(self, attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + This is where conditions affecting defense are accounted for. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + # Add to defense if the defender has the "Defense Up" condition. + if "Defense Up" in defender.db.conditions: + defense_value += DEF_UP_MOD + # Subtract from defense if the defender has the "Defense Down" condition. + if "Defense Down" in defender.db.conditions: + defense_value += DEF_DOWN_MOD + return defense_value
+ +
[docs] def get_damage(self, attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + This is where conditions affecting damage are accounted for. Since attack items + roll their own damage in itemfunc_attack(), their damage is unaffected by any + conditions. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + # Add to damage roll if attacker has the "Damage Up" condition. + if "Damage Up" in attacker.db.conditions: + damage_value += DMG_UP_MOD + # Subtract from the roll if the attacker has the "Damage Down" condition. + if "Damage Down" in attacker.db.conditions: + damage_value += DMG_DOWN_MOD + return damage_value
+ +
[docs] def resolve_attack( + self, + attacker, + defender, + attack_value=None, + defense_value=None, + damage_value=None, + inflict_condition=[], + ): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Options: + attack_value (int): Override for attack roll + defense_value (int): Override for defense value + damage_value (int): Override for damage value + inflict_condition (list): Conditions to inflict upon hit, a + list of tuples formated as (condition(str), duration(int)) + + Notes: + This function is called by normal attacks as well as attacks + made with items. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = self.get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = self.get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) + else: + if not damage_value: + damage_value = self.get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents( + "%s hits %s for %i damage!" % (attacker, defender, damage_value) + ) + self.apply_damage(defender, damage_value) + # Inflict conditions on hit, if any specified + for condition in inflict_condition: + self.add_condition(defender, attacker, condition[0], condition[1]) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + self.at_defeat(defender)
+ +
[docs] def spend_item_use(self, item, user): + """ + Spends one use on an item with limited uses. + + Args: + item (obj): Item being used + user (obj): Character using the item + + Notes: + If item.db.item_consumable is 'True', the item is destroyed if it + runs out of uses - if it's a string instead of 'True', it will also + spawn a new object as residue, using the value of item.db.item_consumable + as the name of the prototype to spawn. + """ + item.db.item_uses -= 1 # Spend one use + + if item.db.item_uses > 0: # Has uses remaining + # Inform the player + user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) + + else: # All uses spent + if not item.db.item_consumable: # Item isn't consumable + # Just inform the player that the uses are gone + user.msg("%s has no uses remaining." % item.key.capitalize()) + + else: # If item is consumable + # If the value is 'True', just destroy the item + if item.db.item_consumable: + user.msg("%s has been consumed." % item.key.capitalize()) + item.delete() # Delete the spent item + + else: # If a string, use value of item_consumable to spawn an object in its place + residue = spawn({"prototype": item.db.item_consumable})[0] # Spawn the residue + # Move the residue to the same place as the item + residue.location = item.location + user.msg("After using %s, you are left with %s." % (item, residue)) + item.delete() # Delete the spent item
+ +
[docs] def use_item(self, user, item, target): + """ + Performs the action of using an item. + + Args: + user (obj): Character using the item + item (obj): Item being used + target (obj): Target of the item use + """ + # If item is self only and no target given, set target to self. + if item.db.item_selfonly and target is None: + target = user + + # If item is self only, abort use if used on others. + if item.db.item_selfonly and user != target: + user.msg("%s can only be used on yourself." % item) + return + + # Set kwargs to pass to item_func + kwargs = {} + if item.db.item_kwargs: + kwargs = item.db.item_kwargs + + # Match item_func string to function + try: + item_func = ITEMFUNCS[item.db.item_func] + except KeyError: # If item_func string doesn't match to a function in ITEMFUNCS + user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) + return + + # Call the item function - abort if it returns False, indicating an error. + # This performs the actual action of using the item. + # Regardless of what the function returns (if anything), it's still executed. + if not item_func(item, user, target, **kwargs): + return + + # If we haven't returned yet, we assume the item was used successfully. + # Spend one use if item has limited uses + if item.db.item_uses: + self.spend_item_use(item, user) + + # Spend an action if in combat + if self.is_in_combat(user): + self.spend_action(user, 1, action_name="item")
+ +
[docs] def condition_tickdown(self, character, turnchar): + """ + Ticks down the duration of conditions on a character at the start of a given character's + turn. + + Args: + character (obj): Character to tick down the conditions of + turnchar (obj): Character whose turn it currently is + + Notes: + In combat, this is called on every fighter at the start of every character's turn. Out + of combat, it's instead called when a character's at_update() hook is called, which is + every 30 seconds by default. + """ + + for key in character.db.conditions: + # The first value is the remaining turns - the second value is whose turn to count down + # on. + condition_duration = character.db.conditions[key][0] + condition_turnchar = character.db.conditions[key][1] + # If the duration is 'True', then the condition doesn't tick down - it lasts + # indefinitely. + if condition_duration is not True: + # Count down if the given turn character matches the condition's turn character. + if condition_turnchar == turnchar: + character.db.conditions[key][0] -= 1 + if character.db.conditions[key][0] <= 0: + # If the duration is brought down to 0, remove the condition and inform + # everyone. + character.location.msg_contents( + "%s no longer has the '%s' condition." % (str(character), str(key)) + ) + del character.db.conditions[key]
+ +
[docs] def add_condition(self, character, turnchar, condition, duration): + """ + Adds a condition to a fighter. + + Args: + character (obj): Character to give the condition to + turnchar (obj): Character whose turn to tick down the condition on in combat + condition (str): Name of the condition + duration (int or True): Number of turns the condition lasts, or True for indefinite + """ + # The first value is the remaining turns - the second value is whose turn to count down on. + character.db.conditions.update({condition: [duration, turnchar]}) + # Tell everyone! + character.location.msg_contents("%s gains the '%s' condition." % (character, condition))
+ + # ---------------------------------------------------------------------------- + # ITEM FUNCTIONS START HERE + # ---------------------------------------------------------------------------- + + # These functions carry out the action of using an item - every item should + # contain a db entry "item_func", with its value being a string that is + # matched to one of these functions in the ITEMFUNCS dictionary below. + + # Every item function must take the following arguments: + # item (obj): The item being used + # user (obj): The character using the item + # target (obj): The target of the item use + + # Item functions must also accept **kwargs - these keyword arguments can be + # used to define how different items that use the same function can have + # different effects (for example, different attack items doing different + # amounts of damage). + + # Each function below contains a description of what kwargs the function will + # take and the effect they have on the result. + +
[docs] def itemfunc_heal(self, item, user, target, **kwargs): + """ + Item function that heals HP. + + kwargs: + min_healing(int): Minimum amount of HP recovered + max_healing(int): Maximum amount of HP recovered + """ + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Has no HP to speak of + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + if target.db.hp >= target.db.max_hp: + user.msg("%s is already at full health." % target) + return False + + min_healing = 20 + max_healing = 40 + + # Retrieve healing range from kwargs, if present + if "healing_range" in kwargs: + min_healing = kwargs["healing_range"][0] + max_healing = kwargs["healing_range"][1] + + to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp + if target.db.hp + to_heal > target.db.max_hp: + to_heal = target.db.max_hp - target.db.hp # Cap healing to max HP + target.db.hp += to_heal + + user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal))
+ +
[docs] def itemfunc_add_condition(self, item, user, target, **kwargs): + """ + Item function that gives the target one or more conditions. + + kwargs: + conditions (list): Conditions added by the item + formatted as a list of tuples: (condition (str), duration (int or True)) + + Notes: + Should mostly be used for beneficial conditions - use itemfunc_attack + for an item that can give an enemy a harmful condition. + """ + conditions = [("Regeneration", 5)] + + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Is not a fighter + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + # Retrieve condition / duration from kwargs, if present + if "conditions" in kwargs: + conditions = kwargs["conditions"] + + user.location.msg_contents("%s uses %s!" % (user, item)) + + # Add conditions to the target + for condition in conditions: + self.add_condition(target, user, condition[0], condition[1])
+ +
[docs] def itemfunc_cure_condition(self, item, user, target, **kwargs): + """ + Item function that'll remove given conditions from a target. + + kwargs: + to_cure(list): List of conditions (str) that the item cures when used + """ + to_cure = ["Poisoned"] + + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Is not a fighter + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + # Retrieve condition(s) to cure from kwargs, if present + if "to_cure" in kwargs: + to_cure = kwargs["to_cure"] + + item_msg = "%s uses %s! " % (user, item) + + for key in target.db.conditions: + if key in to_cure: + # If condition specified in to_cure, remove it. + item_msg += "%s no longer has the '%s' condition. " % (str(target), str(key)) + del target.db.conditions[key] + + user.location.msg_contents(item_msg)
+ +
[docs] def itemfunc_attack(self, item, user, target, **kwargs): + """ + Item function that attacks a target. + + kwargs: + min_damage(int): Minimum damage dealt by the attack + max_damage(int): Maximum damage dealth by the attack + accuracy(int): Bonus / penalty to attack accuracy roll + inflict_condition(list): List of conditions inflicted on hit, + formatted as a (str, int) tuple containing condition name + and duration. + + Notes: + Calls resolve_attack at the end. + """ + if not self.is_in_combat(user): + user.msg("You can only use that in combat.") + return False # Returning false aborts the item use + + if not target: + user.msg("You have to specify a target to use %s! (use <item> = <target>)" % item) + return False + + if target == user: + user.msg("You can't attack yourself!") + return False + + if not target.db.hp: # Has no HP + user.msg("You can't use %s on that." % item) + return False + + min_damage = 20 + max_damage = 40 + accuracy = 0 + inflict_condition = [] + + # Retrieve values from kwargs, if present + if "damage_range" in kwargs: + min_damage = kwargs["damage_range"][0] + max_damage = kwargs["damage_range"][1] + if "accuracy" in kwargs: + accuracy = kwargs["accuracy"] + if "inflict_condition" in kwargs: + inflict_condition = kwargs["inflict_condition"] + + # Roll attack and damage + attack_value = randint(1, 100) + accuracy + damage_value = randint(min_damage, max_damage) + + # Account for "Accuracy Up" and "Accuracy Down" conditions + if "Accuracy Up" in user.db.conditions: + attack_value += 25 + if "Accuracy Down" in user.db.conditions: + attack_value -= 25 + + user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) + self.resolve_attack( + user, + target, + attack_value=attack_value, + damage_value=damage_value, + inflict_condition=inflict_condition, + )
+ + +COMBAT_RULES = ItemCombatRules() + + +# Match strings to item functions here. We can't store callables on +# prototypes, so we store a string instead, matching that string to +# a callable in this dictionary. +ITEMFUNCS = { + "heal": COMBAT_RULES.itemfunc_heal, + "attack": COMBAT_RULES.itemfunc_attack, + "add_condition": COMBAT_RULES.itemfunc_add_condition, + "cure_condition": COMBAT_RULES.itemfunc_cure_condition, +} + +""" +---------------------------------------------------------------------------- +PROTOTYPES START HERE +---------------------------------------------------------------------------- + +You can paste these prototypes into your game's prototypes.py module in your +/world/ folder, and use the spawner to create them - they serve as examples +of items you can make and a handy way to demonstrate the system for +conditions as well. + +Items don't have any particular typeclass - any object with a db entry +"item_func" that references one of the functions given above can be used as +an item with the 'use' command. + +Only "item_func" is required, but item behavior can be further modified by +specifying any of the following: + + item_uses (int): If defined, item has a limited number of uses + + item_selfonly (bool): If True, user can only use the item on themself + + item_consumable(True or str): If True, item is destroyed when it runs + out of uses. If a string is given, the item will spawn a new + object as it's destroyed, with the string specifying what prototype + to spawn. + + item_kwargs (dict): Keyword arguments to pass to the function defined in + item_func. Unique to each function, and can be used to make multiple + items using the same function work differently. +""" + +MEDKIT = { + "key": "a medical kit", + "aliases": ["medkit"], + "desc": "A standard medical kit. It can be used a few times to heal wounds.", + "item_func": "heal", + "item_uses": 3, + "item_consumable": True, + "item_kwargs": {"healing_range": (15, 25)}, +} + +GLASS_BOTTLE = {"key": "a glass bottle", "desc": "An empty glass bottle."} + +HEALTH_POTION = { + "key": "a health potion", + "desc": "A glass bottle full of a mystical potion that heals wounds when used.", + "item_func": "heal", + "item_uses": 1, + "item_consumable": "GLASS_BOTTLE", + "item_kwargs": {"healing_range": (35, 50)}, +} + +REGEN_POTION = { + "key": "a regeneration potion", + "desc": "A glass bottle full of a mystical potion that regenerates wounds over time.", + "item_func": "add_condition", + "item_uses": 1, + "item_consumable": "GLASS_BOTTLE", + "item_kwargs": {"conditions": [("Regeneration", 10)]}, +} + +HASTE_POTION = { + "key": "a haste potion", + "desc": "A glass bottle full of a mystical potion that hastens its user.", + "item_func": "add_condition", + "item_uses": 1, + "item_consumable": "GLASS_BOTTLE", + "item_kwargs": {"conditions": [("Haste", 10)]}, +} + +BOMB = { + "key": "a rotund bomb", + "desc": "A large black sphere with a fuse at the end. Can be used on enemies in combat.", + "item_func": "attack", + "item_uses": 1, + "item_consumable": True, + "item_kwargs": {"damage_range": (25, 40), "accuracy": 25}, +} + +POISON_DART = { + "key": "a poison dart", + "desc": "A thin dart coated in deadly poison. Can be used on enemies in combat", + "item_func": "attack", + "item_uses": 1, + "item_consumable": True, + "item_kwargs": { + "damage_range": (5, 10), + "accuracy": 25, + "inflict_condition": [("Poisoned", 10)], + }, +} + +TASER = { + "key": "a taser", + "desc": "A device that can be used to paralyze enemies in combat.", + "item_func": "attack", + "item_kwargs": { + "damage_range": (10, 20), + "accuracy": 0, + "inflict_condition": [("Paralyzed", 1)], + }, +} + +GHOST_GUN = { + "key": "a ghost gun", + "desc": "A gun that fires scary ghosts at people. Anyone hit by a ghost becomes frightened.", + "item_func": "attack", + "item_uses": 6, + "item_kwargs": { + "damage_range": (5, 10), + "accuracy": 15, + "inflict_condition": [("Frightened", 1)], + }, +} + +ANTIDOTE_POTION = { + "key": "an antidote potion", + "desc": "A glass bottle full of a mystical potion that cures poison when used.", + "item_func": "cure_condition", + "item_uses": 1, + "item_consumable": "GLASS_BOTTLE", + "item_kwargs": {"to_cure": ["Poisoned"]}, +} + +AMULET_OF_MIGHT = { + "key": "The Amulet of Might", + "desc": "The one who holds this amulet can call upon its power to gain great strength.", + "item_func": "add_condition", + "item_selfonly": True, + "item_kwargs": {"conditions": [("Damage Up", 3), ("Accuracy Up", 3), ("Defense Up", 3)]}, +} + +AMULET_OF_WEAKNESS = { + "key": "The Amulet of Weakness", + "desc": "The one who holds this amulet can call upon its power to gain great weakness. " + "It's not a terribly useful artifact.", + "item_func": "add_condition", + "item_selfonly": True, + "item_kwargs": {"conditions": [("Damage Down", 3), ("Accuracy Down", 3), ("Defense Down", 3)]}, +} + + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +
[docs]class TBItemsCharacter(tb_basic.TBBasicCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + rules = ItemCombatRules() + +
[docs] def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions + # Subscribe character to the ticker handler + tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update, idstring="update") + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + An empty dictionary is created to store conditions later, + and the character is subscribed to the Ticker Handler, which + will call at_update() on the character, with the interval + specified by NONCOMBAT_TURN_TIME above. This is used to tick + down conditions out of combat. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """
+ +
[docs] def at_turn_start(self): + """ + Hook called at the beginning of this character's turn in combat. + """ + # Prompt the character for their turn and give some information. + self.msg("|wIt's your turn! You have %i HP remaining.|n" % self.db.hp) + + # Apply conditions that fire at the start of each turn. + self.apply_turn_conditions()
+ +
[docs] def apply_turn_conditions(self): + """ + Applies the effect of conditions that occur at the start of each + turn in combat, or every 30 seconds out of combat. + """ + # Regeneration: restores 4 to 8 HP at the start of character's turn + if "Regeneration" in self.db.conditions: + to_heal = randint(REGEN_RATE[0], REGEN_RATE[1]) # Restore HP + if self.db.hp + to_heal > self.db.max_hp: + to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP + self.db.hp += to_heal + self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal)) + + # Poisoned: does 4 to 8 damage at the start of character's turn + if "Poisoned" in self.db.conditions: + to_hurt = randint(POISON_RATE[0], POISON_RATE[1]) # Deal damage + self.rules.apply_damage(self, to_hurt) + self.location.msg_contents("%s takes %i damage from being Poisoned." % (self, to_hurt)) + if self.db.hp <= 0: + # Call at_defeat if poison defeats the character + self.rules.at_defeat(self) + + # Haste: Gain an extra action in combat. + if self.rules.is_in_combat(self) and "Haste" in self.db.conditions: + self.db.combat_actionsleft += 1 + self.msg("You gain an extra action this turn from Haste!") + + # Paralyzed: Have no actions in combat. + if self.rules.is_in_combat(self) and "Paralyzed" in self.db.conditions: + self.db.combat_actionsleft = 0 + self.location.msg_contents("%s is Paralyzed, and can't act this turn!" % self) + self.db.combat_turnhandler.turn_end_check(self)
+ +
[docs] def at_update(self): + """ + Fires every 30 seconds. + """ + if not self.rules.is_in_combat(self): # Not in combat + # Change all conditions to update on character's turn. + for key in self.db.conditions: + self.db.conditions[key][1] = self + # Apply conditions that fire every turn + self.apply_turn_conditions() + # Tick down condition durations + self.rules.condition_tickdown(self, self)
+ + +
[docs]class TBItemsCharacterTest(TBItemsCharacter): + """ + Just like the TBItemsCharacter, but doesn't subscribe to the TickerHandler. + This makes it easier to run unit tests on. + """ + +
[docs] def at_object_creation(self): + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions
+ + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class TBItemsTurnHandler(tb_basic.TBBasicTurnHandler): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + rules = COMBAT_RULES + +
[docs] def next_turn(self): + """ + Advances to the next character in the turn order. + """ + super().next_turn() + + # Count down condition timers. + next_fighter = self.db.fighters[self.db.turn] + for fighter in self.db.fighters: + self.rules.condition_tickdown(fighter, next_fighter)
+ + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class CmdFight(tb_basic.CmdFight): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + + key = "fight" + help_category = "combat" + + rules = COMBAT_RULES + combat_handler_class = TBItemsTurnHandler
+ + +
[docs]class CmdAttack(tb_basic.CmdAttack): + """ + Attacks another character. + + Usage: + attack <target> + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + rules = COMBAT_RULES
+ + +
[docs]class CmdPass(tb_basic.CmdPass): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + rules = COMBAT_RULES
+ + +
[docs]class CmdDisengage(tb_basic.CmdDisengage): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdRest(tb_basic.CmdRest): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdCombatHelp(tb_basic.CmdCombatHelp): + """ + View help or a list of topics + + Usage: + help <topic or command> + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + + rules = COMBAT_RULES + combat_help_text = ( + "Available combat commands:|/" + "|wAttack:|n Attack a target, attempting to deal damage.|/" + "|wPass:|n Pass your turn without further action.|/" + "|wDisengage:|n End your turn and attempt to end combat.|/" + "|wUse:|n Use an item you're carrying." + )
+ + +
[docs]class CmdUse(MuxCommand): + """ + Use an item. + + Usage: + use <item> [= target] + + An item can have various function - looking at the item may + provide information as to its effects. Some items can be used + to attack others, and as such can only be used in combat. + """ + + key = "use" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + """ + This performs the actual command. + """ + # Search for item + item = self.caller.search(self.lhs, candidates=self.caller.contents) + if not item: + return + + # Search for target, if any is given + target = None + if self.rhs: + target = self.caller.search(self.rhs) + if not target: + return + + # If in combat, can only use items on your turn + if self.rules.is_in_combat(self.caller): + if not self.rules.is_turn(self.caller): + self.caller.msg("You can only use items on your turn.") + return + + if not item.db.item_func: # Object has no item_func, not usable + self.caller.msg("'%s' is not a usable item." % item.key.capitalize()) + return + + if item.attributes.has("item_uses"): # Item has limited uses + if item.db.item_uses <= 0: # Limited uses are spent + self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) + return + + # If everything checks out, call the use_item function + self.rules.use_item(self.caller, item, target)
+ + +
[docs]class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + + key = "DefaultCharacter" + +
[docs] def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) + self.add(CmdUse())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_magic.html b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_magic.html new file mode 100644 index 0000000000..b158174e50 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_magic.html @@ -0,0 +1,962 @@ + + + + + + + + evennia.contrib.game_systems.turnbattle.tb_magic — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.turnbattle.tb_magic

+"""
+Simple turn-based combat system with spell casting
+
+Contrib - Tim Ashley Jenkins 2017, Refactor by Griatch, 2022
+
+This is a version of the 'turnbattle' contrib that includes a basic,
+expandable framework for a 'magic system', whereby players can spend
+a limited resource (MP) to achieve a wide variety of effects, both in
+and out of combat. This does not have to strictly be a system for
+magic - it can easily be re-flavored to any other sort of resource
+based mechanic, like psionic powers, special moves and stamina, and
+so forth.
+
+In this system, spells are learned by name with the 'learnspell'
+command, and then used with the 'cast' command. Spells can be cast in or
+out of combat - some spells can only be cast in combat, some can only be
+cast outside of combat, and some can be cast any time. However, if you
+are in combat, you can only cast a spell on your turn, and doing so will
+typically use an action (as specified in the spell's funciton).
+
+Spells are defined at the end of the module in a database that's a
+dictionary of dictionaries - each spell is matched by name to a function,
+along with various parameters that restrict when the spell can be used and
+what the spell can be cast on. Included is a small variety of spells that
+damage opponents and heal HP, as well as one that creates an object.
+
+Because a spell can call any function, a spell can be made to do just
+about anything at all. The SPELLS dictionary at the bottom of the module
+even allows kwargs to be passed to the spell function, so that the same
+function can be re-used for multiple similar spells.
+
+Spells in this system work on a very basic resource: MP, which is spent
+when casting spells and restored by resting. It shouldn't be too difficult
+to modify this system to use spell slots, some physical fuel or resource,
+or whatever else your game requires.
+
+To install and test, import this module's TBMagicCharacter object into
+your game's character.py module:
+
+    from evennia.contrib.game_systems.turnbattle.tb_magic import TBMagicCharacter
+
+And change your game's character typeclass to inherit from TBMagicCharacter
+instead of the default:
+
+    class Character(TBMagicCharacter):
+
+Note: If your character already existed you need to also make sure
+to re-run the creation hooks on it to set the needed Attributes.
+Use `update self` to try on yourself or use py to call `at_object_creation()`
+on all existing Characters.
+
+
+Next, import this module into your default_cmdsets.py module:
+
+    from evennia.contrib.game_systems.turnbattle import tb_magic
+
+And add the battle command set to your default command set:
+
+    #
+    # any commands you add below will overload the default ones.
+    #
+    self.add(tb_magic.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+
+from evennia import Command, DefaultScript, create_object, default_cmds
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.utils.logger import log_trace
+
+from . import tb_basic
+
+"""
+----------------------------------------------------------------------------
+SPELL FUNCTIONS START HERE
+----------------------------------------------------------------------------
+
+These are the functions that are called by the 'cast' command to perform the
+effects of various spells. Which spells execute which functions and what
+parameters are passed to them are specified at the bottom of the module, in
+the 'SPELLS' dictionary.
+
+All of these functions take the same arguments:
+    caster (obj): Character casting the spell
+    spell_name (str): Name of the spell being cast
+    targets (list): List of objects targeted by the spell
+    cost (int): MP cost of casting the spell
+
+These functions also all accept **kwargs, and how these are used is specified
+in the docstring for each function.
+"""
+
+
+
[docs]class MagicCombatRules(tb_basic.BasicCombatRules): +
[docs] def spell_healing(self, caster, spell_name, targets, cost, **kwargs): + """ + Spell that restores HP to a target or targets. + + kwargs: + healing_range (tuple): Minimum and maximum amount healed to + each target. (20, 40) by default. + """ + spell_msg = "%s casts %s!" % (caster, spell_name) + + min_healing = 20 + max_healing = 40 + + # Retrieve healing range from kwargs, if present + if "healing_range" in kwargs: + min_healing = kwargs["healing_range"][0] + max_healing = kwargs["healing_range"][1] + + for character in targets: + to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp + if character.db.hp + to_heal > character.db.max_hp: + to_heal = character.db.max_hp - character.db.hp # Cap healing to max HP + character.db.hp += to_heal + spell_msg += " %s regains %i HP!" % (character, to_heal) + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents(spell_msg) # Message the room with spell results + + if self.is_in_combat(caster): # Spend action if in combat + self.spend_action(caster, 1, action_name="cast")
+ +
[docs] def spell_attack(self, caster, spell_name, targets, cost, **kwargs): + """ + Spell that deals damage in combat. Similar to resolve_attack. + + kwargs: + attack_name (tuple): Single and plural describing the sort of + attack or projectile that strikes each enemy. + damage_range (tuple): Minimum and maximum damage dealt by the + spell. (10, 20) by default. + accuracy (int): Modifier to the spell's attack roll, determining + an increased or decreased chance to hit. 0 by default. + attack_count (int): How many individual attacks are made as part + of the spell. If the number of attacks exceeds the number of + targets, the first target specified will be attacked more + than once. Just 1 by default - if the attack_count is less + than the number targets given, each target will only be + attacked once. + """ + spell_msg = "%s casts %s!" % (caster, spell_name) + + atkname_single = "The spell" + atkname_plural = "spells" + min_damage = 10 + max_damage = 20 + accuracy = 0 + attack_count = 1 + + # Retrieve some variables from kwargs, if present + if "attack_name" in kwargs: + atkname_single = kwargs["attack_name"][0] + atkname_plural = kwargs["attack_name"][1] + if "damage_range" in kwargs: + min_damage = kwargs["damage_range"][0] + max_damage = kwargs["damage_range"][1] + if "accuracy" in kwargs: + accuracy = kwargs["accuracy"] + if "attack_count" in kwargs: + attack_count = kwargs["attack_count"] + + to_attack = [] + # If there are more attacks than targets given, attack first target multiple times + if len(targets) < attack_count: + to_attack = to_attack + targets + extra_attacks = attack_count - len(targets) + for n in range(extra_attacks): + to_attack.insert(0, targets[0]) + else: + to_attack = to_attack + targets + + # Set up dictionaries to track number of hits and total damage + total_hits = {} + total_damage = {} + for fighter in targets: + total_hits.update({fighter: 0}) + total_damage.update({fighter: 0}) + + # Resolve attack for each target + for fighter in to_attack: + attack_value = randint(1, 100) + accuracy # Spell attack roll + defense_value = self.get_defense(caster, fighter) + if attack_value >= defense_value: + spell_dmg = randint(min_damage, max_damage) # Get spell damage + total_hits[fighter] += 1 + total_damage[fighter] += spell_dmg + + for fighter in targets: + # Construct combat message + if total_hits[fighter] == 0: + spell_msg += " The spell misses %s!" % fighter + elif total_hits[fighter] > 0: + attack_count_str = atkname_single + " hits" + if total_hits[fighter] > 1: + attack_count_str = "%i %s hit" % (total_hits[fighter], atkname_plural) + spell_msg += " %s %s for %i damage!" % ( + attack_count_str, + fighter, + total_damage[fighter], + ) + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents(spell_msg) # Message the room with spell results + + for fighter in targets: + # Apply damage + self.apply_damage(fighter, total_damage[fighter]) + # If fighter HP is reduced to 0 or less, call at_defeat. + if fighter.db.hp <= 0: + self.at_defeat(fighter) + + if self.is_in_combat(caster): # Spend action if in combat + self.spend_action(caster, 1, action_name="cast")
+ +
[docs] def spell_conjure(self, caster, spell_name, targets, cost, **kwargs): + """ + Spell that creates an object. + + kwargs: + obj_key (str): Key of the created object. + obj_desc (str): Desc of the created object. + obj_typeclass (str): Typeclass path of the object. + + If you want to make more use of this particular spell funciton, + you may want to modify it to use the spawner (in evennia.utils.spawner) + instead of creating objects directly. + """ + + obj_key = "a nondescript object" + obj_desc = "A perfectly generic object." + obj_typeclass = "evennia.objects.objects.DefaultObject" + + # Retrieve some variables from kwargs, if present + if "obj_key" in kwargs: + obj_key = kwargs["obj_key"] + if "obj_desc" in kwargs: + obj_desc = kwargs["obj_desc"] + if "obj_typeclass" in kwargs: + obj_typeclass = kwargs["obj_typeclass"] + + conjured_obj = create_object( + obj_typeclass, key=obj_key, location=caster.location + ) # Create object + conjured_obj.db.desc = obj_desc # Add object desc + + caster.db.mp -= cost # Deduct MP cost + + # Message the room to announce the creation of the object + caster.location.msg_contents( + "%s casts %s, and %s appears!" % (caster, spell_name, conjured_obj) + )
+ + +COMBAT_RULES = MagicCombatRules() + + +""" +---------------------------------------------------------------------------- +SPELL DEFINITIONS START HERE +---------------------------------------------------------------------------- +In this section, each spell is matched to a function, and given parameters +that determine its MP cost, valid type and number of targets, and what +function casting the spell executes. + +This data is given as a dictionary of dictionaries - the key of each entry +is the spell's name, and the value is a dictionary of various options and +parameters, some of which are required and others which are optional. + +Required values for spells: + + cost (int): MP cost of casting the spell + target (str): Valid targets for the spell. Can be any of: + "none" - No target needed + "self" - Self only + "any" - Any object + "anyobj" - Any object that isn't a character + "anychar" - Any character + "other" - Any object excluding the caster + "otherchar" - Any character excluding the caster + spellfunc (callable): Function that performs the action of the spell. + Must take the following arguments: caster (obj), spell_name (str), + targets (list), and cost (int), as well as **kwargs. + +Optional values for spells: + + combat_spell (bool): If the spell can be cast in combat. True by default. + noncombat_spell (bool): If the spell can be cast out of combat. True by default. + max_targets (int): Maximum number of objects that can be targeted by the spell. + 1 by default - unused if target is "none" or "self" + +Any other values specified besides the above will be passed as kwargs to 'spellfunc'. +You can use kwargs to effectively re-use the same function for different but similar +spells - for example, 'magic missile' and 'flame shot' use the same function, but +behave differently, as they have different damage ranges, accuracy, amount of attacks +made as part of the spell, and so forth. If you make your spell functions flexible +enough, you can make a wide variety of spells just by adding more entries to this +dictionary. +""" + +SPELLS = { + "magic missile": { + "spellfunc": COMBAT_RULES.spell_attack, + "target": "otherchar", + "cost": 3, + "noncombat_spell": False, + "max_targets": 3, + "attack_name": ("A bolt", "bolts"), + "damage_range": (4, 7), + "accuracy": 999, + "attack_count": 3, + }, + "flame shot": { + "spellfunc": COMBAT_RULES.spell_attack, + "target": "otherchar", + "cost": 3, + "noncombat_spell": False, + "attack_name": ("A jet of flame", "jets of flame"), + "damage_range": (25, 35), + }, + "cure wounds": {"spellfunc": COMBAT_RULES.spell_healing, "target": "anychar", "cost": 5}, + "mass cure wounds": { + "spellfunc": COMBAT_RULES.spell_healing, + "target": "anychar", + "cost": 10, + "max_targets": 5, + }, + "full heal": { + "spellfunc": COMBAT_RULES.spell_healing, + "target": "anychar", + "cost": 12, + "healing_range": (100, 100), + }, + "cactus conjuration": { + "spellfunc": COMBAT_RULES.spell_conjure, + "target": "none", + "cost": 2, + "combat_spell": False, + "obj_key": "a cactus", + "obj_desc": "An ordinary green cactus with little spines.", + }, +} + + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +
[docs]class TBMagicCharacter(tb_basic.TBBasicCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, access to combat commands and magic. + + """ + + rules = COMBAT_RULES + +
[docs] def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.spells_known = [] # Set empty spells known list + self.db.max_mp = 20 # Set maximum MP to 20 + self.db.mp = self.db.max_mp # Set current MP to maximum
+ + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class TBMagicTurnHandler(tb_basic.TBBasicTurnHandler): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + rules = COMBAT_RULES
+ + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class CmdFight(tb_basic.CmdFight): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + + key = "fight" + help_category = "combat" + + rules = COMBAT_RULES + combat_handler_class = TBMagicTurnHandler
+ + +
[docs]class CmdAttack(tb_basic.CmdAttack): + """ + Attacks another character. + + Usage: + attack <target> + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdPass(tb_basic.CmdPass): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdDisengage(tb_basic.CmdDisengage): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdLearnSpell(Command): + """ + Learn a magic spell. + + Usage: + learnspell <spell name> + + Adds a spell by name to your list of spells known. + + The following spells are provided as examples: + + |wmagic missile|n (3 MP): Fires three missiles that never miss. Can target + up to three different enemies. + + |wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target. + + |wcure wounds|n (5 MP): Heals damage on one target. + + |wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5 + targets at once. + + |wfull heal|n (12 MP): Heals one target back to full HP. + + |wcactus conjuration|n (2 MP): Creates a cactus. + """ + + key = "learnspell" + help_category = "magic" + +
[docs] def func(self): + """ + This performs the actual command. + """ + spell_list = sorted(SPELLS.keys()) + args = self.args.lower() + args = args.strip(" ") + caller = self.caller + spell_to_learn = [] + + if not args or len(args) < 3: # No spell given + caller.msg("Usage: learnspell <spell name>") + return + + for spell in spell_list: # Match inputs to spells + if args in spell.lower(): + spell_to_learn.append(spell) + + if spell_to_learn == []: # No spells matched + caller.msg("There is no spell with that name.") + return + if len(spell_to_learn) > 1: # More than one match + matched_spells = ", ".join(spell_to_learn) + caller.msg("Which spell do you mean: %s?" % matched_spells) + return + + if len(spell_to_learn) == 1: # If one match, extract the string + spell_to_learn = spell_to_learn[0] + + if spell_to_learn not in self.caller.db.spells_known: # If the spell isn't known... + caller.db.spells_known.append(spell_to_learn) # ...then add the spell to the character + caller.msg("You learn the spell '%s'!" % spell_to_learn) + return + if spell_to_learn in self.caller.db.spells_known: # Already has the spell specified + caller.msg("You already know the spell '%s'!" % spell_to_learn) + """ + You will almost definitely want to replace this with your own system + for learning spells, perhaps tied to character advancement or finding + items in the game world that spells can be learned from. + """
+ + +
[docs]class CmdCast(MuxCommand): + """ + Cast a magic spell that you know, provided you have the MP + to spend on its casting. + + Usage: + cast <spellname> [= <target1>, <target2>, etc...] + + Some spells can be cast on multiple targets, some can be cast + on only yourself, and some don't need a target specified at all. + Typing 'cast' by itself will give you a list of spells you know. + """ + + key = "cast" + help_category = "magic" + + rules = COMBAT_RULES + +
[docs] def func(self): + """ + This performs the actual command. + + Note: This is a quite long command, since it has to cope with all + the different circumstances in which you may or may not be able + to cast a spell. None of the spell's effects are handled by the + command - all the command does is verify that the player's input + is valid for the spell being cast and then call the spell's + function. + """ + caller = self.caller + + if not self.lhs or len(self.lhs) < 3: # No spell name given + caller.msg("Usage: cast <spell name> = <target>, <target2>, ...") + if not caller.db.spells_known: + caller.msg("You don't know any spells.") + return + else: + caller.db.spells_known = sorted(caller.db.spells_known) + spells_known_msg = "You know the following spells:|/" + "|/".join( + caller.db.spells_known + ) + caller.msg(spells_known_msg) # List the spells the player knows + return + + spellname = self.lhs.lower() # noqa - not used but potentially useful + spell_to_cast = [] + spell_targets = [] + + if not self.rhs: + spell_targets = [] + elif self.rhs.lower() in ["me", "self", "myself"]: + spell_targets = [caller] + elif len(self.rhs) > 2: + spell_targets = self.rhslist + + for spell in caller.db.spells_known: # Match inputs to spells + if self.lhs in spell.lower(): + spell_to_cast.append(spell) + + if spell_to_cast == []: # No spells matched + caller.msg("You don't know a spell of that name.") + return + if len(spell_to_cast) > 1: # More than one match + matched_spells = ", ".join(spell_to_cast) + caller.msg("Which spell do you mean: %s?" % matched_spells) + return + + if len(spell_to_cast) == 1: # If one match, extract the string + spell_to_cast = spell_to_cast[0] + + if spell_to_cast not in SPELLS: # Spell isn't defined + caller.msg("ERROR: Spell %s is undefined" % spell_to_cast) + return + + # Time to extract some info from the chosen spell! + spelldata = SPELLS[spell_to_cast] + + # Add in some default data if optional parameters aren't specified + if "combat_spell" not in spelldata: + spelldata.update({"combat_spell": True}) + if "noncombat_spell" not in spelldata: + spelldata.update({"noncombat_spell": True}) + if "max_targets" not in spelldata: + spelldata.update({"max_targets": 1}) + + # Store any superfluous options as kwargs to pass to the spell function + kwargs = {} + spelldata_opts = [ + "spellfunc", + "target", + "cost", + "combat_spell", + "noncombat_spell", + "max_targets", + ] + for key in spelldata: + if key not in spelldata_opts: + kwargs.update({key: spelldata[key]}) + + # If caster doesn't have enough MP to cover the spell's cost, give error and return + if spelldata["cost"] > caller.db.mp: + caller.msg("You don't have enough MP to cast '%s'." % spell_to_cast) + return + + # If in combat and the spell isn't a combat spell, give error message and return + if spelldata["combat_spell"] is False and self.rules.is_in_combat(caller): + caller.msg("You can't use the spell '%s' in combat." % spell_to_cast) + return + + # If not in combat and the spell isn't a non-combat spell, error ms and return. + if spelldata["noncombat_spell"] is False and self.rules.is_in_combat(caller) is False: + caller.msg("You can't use the spell '%s' outside of combat." % spell_to_cast) + return + + # If spell takes no targets and one is given, give error message and return + if len(spell_targets) > 0 and spelldata["target"] == "none": + caller.msg("The spell '%s' isn't cast on a target." % spell_to_cast) + return + + # If no target is given and spell requires a target, give error message + if spelldata["target"] not in ["self", "none"]: + if len(spell_targets) == 0: + caller.msg("The spell '%s' requires a target." % spell_to_cast) + return + + # If more targets given than maximum, give error message + if len(spell_targets) > spelldata["max_targets"]: + targplural = "target" + if spelldata["max_targets"] > 1: + targplural = "targets" + caller.msg( + "The spell '%s' can only be cast on %i %s." + % (spell_to_cast, spelldata["max_targets"], targplural) + ) + return + + # Set up our candidates for targets + target_candidates = [] + + # If spell targets 'any' or 'other', any object in caster's inventory or location + # can be targeted by the spell. + if spelldata["target"] in ["any", "other"]: + target_candidates = caller.location.contents + caller.contents + + # If spell targets 'anyobj', only non-character objects can be targeted. + if spelldata["target"] == "anyobj": + prefilter_candidates = caller.location.contents + caller.contents + for thing in prefilter_candidates: + if not thing.attributes.has("max_hp"): # Has no max HP, isn't a fighter + target_candidates.append(thing) + + # If spell targets 'anychar' or 'otherchar', only characters can be targeted. + if spelldata["target"] in ["anychar", "otherchar"]: + prefilter_candidates = caller.location.contents + for thing in prefilter_candidates: + if thing.attributes.has("max_hp"): # Has max HP, is a fighter + target_candidates.append(thing) + + # Now, match each entry in spell_targets to an object in the search candidates + matched_targets = [] + for target in spell_targets: + match = caller.search(target, candidates=target_candidates) + matched_targets.append(match) + spell_targets = matched_targets + + # If no target is given and the spell's target is 'self', set target to self + if len(spell_targets) == 0 and spelldata["target"] == "self": + spell_targets = [caller] + + # Give error message if trying to cast an "other" target spell on yourself + if spelldata["target"] in ["other", "otherchar"]: + if caller in spell_targets: + caller.msg("You can't cast '%s' on yourself." % spell_to_cast) + return + + # Return if "None" in target list, indicating failed match + if None in spell_targets: + # No need to give an error message, as 'search' gives one by default. + return + + # Give error message if repeats in target list + if len(spell_targets) != len(set(spell_targets)): + caller.msg("You can't specify the same target more than once!") + return + + # Finally, we can cast the spell itself. Note that MP is not deducted here! + try: + spelldata["spellfunc"]( + caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs + ) + except Exception: + log_trace("Error in callback for spell: %s." % spell_to_cast)
+ + +
[docs]class CmdRest(Command): + """ + Recovers damage and restores MP. + + Usage: + rest + + Resting recovers your HP and MP to their maximum, but you can + only rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + "This performs the actual command." + + if self.rules.is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.db.mp = self.caller.db.max_mp # Set current MP to maximum + self.caller.location.msg_contents("%s rests to recover HP and MP." % self.caller)
+ # You'll probably want to replace this with your own system for recovering HP and MP. + + +
[docs]class CmdStatus(Command): + """ + Gives combat information. + + Usage: + status + + Shows your current and maximum HP and your distance from + other targets in combat. + """ + + key = "status" + help_category = "combat" + +
[docs] def func(self): + "This performs the actual command." + char = self.caller + + if not char.db.max_hp: # Character not initialized, IE in unit tests + char.db.hp = 100 + char.db.max_hp = 100 + char.db.spells_known = [] + char.db.max_mp = 20 + char.db.mp = char.db.max_mp + + char.msg( + "You have %i / %i HP and %i / %i MP." + % (char.db.hp, char.db.max_hp, char.db.mp, char.db.max_mp) + )
+ + +
[docs]class CmdCombatHelp(tb_basic.CmdCombatHelp): + """ + View help or a list of topics + + Usage: + help <topic or command> + help list + help all + + This will search for help on commands and other + topics related to the game. + """
+ + +
[docs]class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + + key = "DefaultCharacter" + +
[docs] def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) + self.add(CmdLearnSpell()) + self.add(CmdCast()) + self.add(CmdStatus())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_range.html b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_range.html new file mode 100644 index 0000000000..9216bb6297 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tb_range.html @@ -0,0 +1,1160 @@ + + + + + + + + evennia.contrib.game_systems.turnbattle.tb_range — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.turnbattle.tb_range

+"""
+Simple turn-based combat system with range and movement
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' contrib that includes a system
+for abstract movement and positioning in combat, including distinction
+between melee and ranged attacks. In this system, a fighter or object's
+exact position is not recorded - only their relative distance to other
+actors in combat.
+
+In this example, the distance between two objects in combat is expressed
+as an integer value: 0 for "engaged" objects that are right next to each
+other, 1 for "reach" which is for objects that are near each other but
+not directly adjacent, and 2 for "range" for objects that are far apart.
+
+When combat starts, all fighters are at reach with each other and other
+objects, and at range from any exits. On a fighter's turn, they can use
+the "approach" command to move closer to an object, or the "withdraw"
+command to move further away from an object, either of which takes an
+action in combat. In this example, fighters are given two actions per
+turn, allowing them to move and attack in the same round, or to attack
+twice or move twice.
+
+When you move toward an object, you will also move toward anything else
+that's close to your target - the same goes for moving away from a target,
+which will also move you away from anything close to your target. Moving
+toward one target may also move you away from anything you're already
+close to, but withdrawing from a target will never inadvertently bring
+you closer to anything else.
+
+In this example, there are two attack commands. 'Attack' can only hit
+targets that are 'engaged' (range 0) with you. 'Shoot' can hit any target
+on the field, but cannot be used if you are engaged with any other fighters.
+In addition, strikes made with the 'attack' command are more accurate than
+'shoot' attacks. This is only to provide an example of how melee and ranged
+attacks can be made to work differently - you can, of course, modify this
+to fit your rules system.
+
+When in combat, the ranges of objects are also accounted for - you can't
+pick up an object unless you're engaged with it, and can't give an object
+to another fighter without being engaged with them either. Dropped objects
+are automatically assigned a range of 'engaged' with the fighter who dropped
+them. Additionally, giving or getting an object will take an action in combat.
+Dropping an object does not take an action, but can only be done on your turn.
+
+When combat ends, all range values are erased and all restrictions on getting
+or getting objects are lifted - distances are no longer tracked and objects in
+the same room can be considered to be in the same space, as is the default
+behavior of Evennia and most MUDs.
+
+This system allows for strategies in combat involving movement and
+positioning to be implemented in your battle system without the use of
+a 'grid' of coordinates, which can be difficult and clunky to navigate
+in text and disadvantageous to players who use screen readers. This loose,
+narrative method of tracking position is based around how the matter is
+handled in tabletop RPGs played without a grid - typically, a character's
+exact position in a room isn't important, only their relative distance to
+other actors.
+
+You may wish to expand this system with a method of distinguishing allies
+from enemies (to prevent allied characters from blocking your ranged attacks)
+as well as some method by which melee-focused characters can prevent enemies
+from withdrawing or punish them from doing so, such as by granting "attacks of
+opportunity" or something similar. If you wish, you can also expand the breadth
+of values allowed for range - rather than just 0, 1, and 2, you can allow ranges
+to go up to much higher values, and give attacks and movements more varying
+values for distance for a more granular system. You may also want to implement
+a system for fleeing or changing rooms in combat by approaching exits, which
+are objects placed in the range field like any other.
+
+To install and test, import this module's TBRangeCharacter object into
+your game's character.py module:
+
+    from evennia.contrib.game_systems.turnbattle.tb_range import TBRangeCharacter
+
+And change your game's character typeclass to inherit from TBRangeCharacter
+instead of the default:
+
+    class Character(TBRangeCharacter):
+
+Do the same thing in your game's objects.py module for TBRangeObject:
+
+    from evennia.contrib.game_systems.turnbattle.tb_range import TBRangeObject
+    class Object(TBRangeObject):
+
+Next, import this module into your default_cmdsets.py module:
+
+    from evennia.contrib.game_systems.turnbattle import tb_range
+
+And add the battle command set to your default command set:
+
+    #
+    # any commands you add below will overload the default ones.
+    #
+    self.add(tb_range.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+
+from evennia import Command, DefaultObject, DefaultScript, default_cmds
+from evennia.commands.default.help import CmdHelp
+
+from . import tb_basic
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT = 30  # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 2  # Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+
[docs]class RangedCombatRules(tb_basic.BasicCombatRules): +
[docs] def get_attack(self, attacker, defender, attack_type): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack ('melee' or 'ranged') + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, generates a random integer from 1 to 100 without using any + properties from either the attacker or defender, and modifies the result + based on whether it's for a melee or ranged attack. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + # Make melee attacks more accurate, ranged attacks less accurate + if attack_type == "melee": + attack_value += 15 + if attack_type == "ranged": + attack_value -= 15 + return attack_value
+ +
[docs] def get_defense(self, attacker, defender, attack_type="melee"): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack ('melee' or 'ranged') + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value
+ +
[docs] def get_range(self, obj1, obj2): + """ + Gets the combat range between two objects. + + Args: + obj1 (obj): First object + obj2 (obj): Second object + + Returns: + range (int or None): Distance between two objects or None if not applicable + """ + # Return None if not applicable. + if not obj1.db.combat_range: + return None + if not obj2.db.combat_range: + return None + if obj1 not in obj2.db.combat_range: + return None + if obj2 not in obj1.db.combat_range: + return None + # Return the range between the two objects. + return obj1.db.combat_range[obj2]
+ +
[docs] def distance_inc(self, mover, target): + """ + Function that increases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + """ + mover.db.combat_range[target] += 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # Set a cap of 2: + if self.get_range(mover, target) > 2: + target.db.combat_range[mover] = 2 + mover.db.combat_range[target] = 2
+ +
[docs] def distance_dec(self, mover, target): + """ + Helper function that decreases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + """ + mover.db.combat_range[target] -= 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # If this brings mover to range 0 (Engaged): + if self.get_range(mover, target) <= 0: + # Reset range to each other to 0 and copy target's ranges to mover. + target.db.combat_range[mover] = 0 + mover.db.combat_range = target.db.combat_range + # Assure everything else has the same distance from the mover and target, now that + # they're together + for thing in mover.location.contents: + if thing != mover and thing != target: + thing.db.combat_range[mover] = thing.db.combat_range[target]
+ +
[docs] def approach(self, mover, target): + """ + Manages a character's whole approach, including changes in ranges to other characters. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + + Notes: + The mover will also automatically move toward any objects that are closer to the + target than the mover is. The mover will also move away from anything they started + out close to. + """ + + contents = mover.location.contents + + for thing in contents: + if thing != mover and thing != target: + # Move closer to each object closer to the target than you. + if self.get_range(mover, thing) > self.get_range(target, thing): + self.distance_dec(mover, thing) + # Move further from each object that's further from you than from the target. + if self.get_range(mover, thing) < self.get_range(target, thing): + self.distance_inc(mover, thing) + # Lastly, move closer to your target. + self.distance_dec(mover, target)
+ +
[docs] def withdraw(self, mover, target): + """ + Manages a character's whole withdrawal, including changes in ranges to other characters. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + + Notes: + The mover will also automatically move away from objects that are close to the target + of their withdrawl. The mover will never inadvertently move toward anything else while + withdrawing - they can be considered to be moving to open space. + """ + + contents = mover.location.contents + + for thing in contents: + if thing != mover and thing != target: + # Move away from each object closer to the target than you, if it's also closer to + # you than you are to the target. + if self.get_range(mover, thing) >= self.get_range(target, thing) and self.get_range( + mover, thing + ) < self.get_range(mover, target): + self.distance_inc(mover, thing) + # Move away from anything your target is engaged with + if self.get_range(target, thing) == 0: + self.distance_inc(mover, thing) + # Move away from anything you're engaged with. + if self.get_range(mover, thing) == 0: + self.distance_inc(mover, thing) + # Then, move away from your target. + self.distance_inc(mover, target)
+ +
[docs] def resolve_attack( + self, attacker, defender, attack_value=None, defense_value=None, attack_type="melee" + ): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack (melee or ranged) + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = self.get_attack(attacker, defender, attack_type) + # Get a defense value from the defender. + if not defense_value: + defense_value = self.get_defense(attacker, defender, attack_type) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents( + "%s's %s attack misses %s!" % (attacker, attack_type, defender) + ) + else: + damage_value = self.get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents( + "%s hits %s with a %s attack for %i damage!" + % (attacker, defender, attack_type, damage_value) + ) + self.apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + self.at_defeat(defender)
+ +
[docs] def combat_status_message(self, fighter): + """ + Sends a message to a player with their current HP and + distances to other fighters and objects. Called at turn + start and by the 'status' command. + """ + if not fighter.db.max_hp: + fighter.db.hp = 100 + fighter.db.max_hp = 100 + + status_msg = "HP Remaining: %i / %i" % (fighter.db.hp, fighter.db.max_hp) + + if not self.is_in_combat(fighter): + fighter.msg(status_msg) + return + + engaged_obj = [] + reach_obj = [] + range_obj = [] + + for thing in fighter.db.combat_range: + if thing != fighter: + if fighter.db.combat_range[thing] == 0: + engaged_obj.append(thing) + if fighter.db.combat_range[thing] == 1: + reach_obj.append(thing) + if fighter.db.combat_range[thing] > 1: + range_obj.append(thing) + + if engaged_obj: + status_msg += "|/Engaged targets: %s" % ", ".join(obj.key for obj in engaged_obj) + if reach_obj: + status_msg += "|/Reach targets: %s" % ", ".join(obj.key for obj in reach_obj) + if range_obj: + status_msg += "|/Ranged targets: %s" % ", ".join(obj.key for obj in range_obj) + + fighter.msg(status_msg)
+ + +COMBAT_RULES = RangedCombatRules() + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class TBRangeTurnHandler(tb_basic.TBBasicTurnHandler): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' + command. + """ + + rules = COMBAT_RULES + +
[docs] def init_range(self, to_init): + """ + Initializes range values for an object at the start of a fight. + + Args: + to_init (object): Object to initialize range field for. + """ + rangedict = {} + # Get a list of objects in the room. + objectlist = self.obj.contents + for thing in objectlist: + # Object always at distance 0 from itself + if thing == to_init: + rangedict.update({thing: 0}) + else: + if thing.destination or to_init.destination: + # Start exits at range 2 to put them at the 'edges' + rangedict.update({thing: 2}) + else: + # Start objects at range 1 from other objects + rangedict.update({thing: 1}) + to_init.db.combat_range = rangedict
+ +
[docs] def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): + """ + Adds a new object to the range field of a fight in progress. + + Args: + to_init (object): Object to initialize range field for. + Keyword Args: + anchor_obj (object): Object to copy range values from, or None for a random object. + add_distance (int): Distance to put between to_init object and anchor object. + + """ + # Get a list of room's contents without to_init object. + contents = self.obj.contents + contents.remove(to_init) + # If no anchor object given, pick one in the room at random. + if not anchor_obj: + anchor_obj = contents[randint(0, (len(contents) - 1))] + # Copy the range values from the anchor object. + to_init.db.combat_range = anchor_obj.db.combat_range + # Add the new object to everyone else's ranges. + for thing in contents: + new_objects_range = thing.db.combat_range[anchor_obj] + thing.db.combat_range.update({to_init: new_objects_range}) + # Set the new object's range to itself to 0. + to_init.db.combat_range.update({to_init: 0}) + # Add additional distance from anchor object, if any. + for n in range(add_distance): + self.rules.withdraw(to_init, anchor_obj)
+ +
[docs] def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + In this example, characters are given two actions per turn. This allows + characters to both move and attack in the same turn (or, alternately, + move twice or attack twice). + """ + super().start_turn(character) + character.db.combat_actionsleft = ACTIONS_PER_TURN
+ +
[docs] def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + # Add the character to the rangefield, at range from everyone, if they're not on it already. + if not character.db.combat_range: + self.join_rangefield(character, add_distance=2)
+ + +""" +---------------------------------------------------------------------------- +TYPECLASSES START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class TBRangeCharacter(tb_basic.TBBasicCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + rules = COMBAT_RULES
+ + +
[docs]class TBRangeObject(DefaultObject): + """ + An object that is assigned range values in combat. Getting, giving, and dropping + the object has restrictions in combat - you must be next to an object to get it, + must be next to your target to give them something, and can only interact with + objects on your own turn. + """ + +
[docs] def at_pre_drop(self, dropper): + """ + Called by the default `drop` command before this object has been + dropped. + + Args: + dropper (Object): The object which will drop this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shoulddrop (bool): If the object should be dropped or not. + + Notes: + If this method returns False/None, the dropping is cancelled + before it is even started. + + """ + # Can't drop something if in combat and it's not your turn + if self.rules.is_in_combat(dropper) and not self.rules.is_turn(dropper): + dropper.msg("You can only drop things on your turn!") + return False + return True
+ +
[docs] def at_drop(self, dropper): + """ + Called by the default `drop` command when this object has been + dropped. + + Args: + dropper (Object): The object which just dropped this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the drop from happening. Use + permissions or the at_pre_drop() hook for that. + + """ + # If dropper is currently in combat + if dropper.location.db.combat_turnhandler: + # Object joins the range field + self.db.combat_range = {} + dropper.location.db.combat_turnhandler.join_rangefield(self, anchor_obj=dropper)
+ +
[docs] def at_pre_get(self, getter): + """ + Called by the default `get` command before this object has been + picked up. + + Args: + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldget (bool): If the object should be gotten or not. + + Notes: + If this method returns False/None, the getting is cancelled + before it is even started. + """ + # Restrictions for getting in combat + if self.rules.is_in_combat(getter): + if not self.rules.is_turn(getter): # Not your turn + getter.msg("You can only get things on your turn!") + return False + if self.rules.get_range(self, getter) > 0: # Too far away + getter.msg("You aren't close enough to get that! (see: help approach)") + return False + return True
+ +
[docs] def at_get(self, getter): + """ + Called by the default `get` command when this object has been + picked up. + + Args: + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the pickup from happening. Use + permissions or the at_pre_get() hook for that. + + """ + # If gotten, erase range values + if self.db.combat_range: + del self.db.combat_range + # Remove this object from everyone's range fields + for thing in getter.location.contents: + if thing.db.combat_range: + if self in thing.db.combat_range: + thing.db.combat_range.pop(self, None) + # If in combat, getter spends an action + if self.rules.is_in_combat(getter): + self.rules.spend_action(getter, 1, action_name="get") # Use up one action.
+ +
[docs] def at_pre_give(self, giver, getter): + """ + Called by the default `give` command before this object has been + given. + + Args: + giver (Object): The object about to give this object. + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldgive (bool): If the object should be given or not. + + Notes: + If this method returns False/None, the giving is cancelled + before it is even started. + + """ + # Restrictions for giving in combat + if self.rules.is_in_combat(giver): + if not self.rules.is_turn(giver): # Not your turn + giver.msg("You can only give things on your turn!") + return False + if self.rules.get_range(giver, getter) > 0: # Too far away from target + giver.msg( + "You aren't close enough to give things to %s! (see: help approach)" % getter + ) + return False + return True
+ +
[docs] def at_give(self, giver, getter): + """ + Called by the default `give` command when this object has been + given. + + Args: + giver (Object): The object giving this object. + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the give from happening. Use + permissions or the at_pre_give() hook for that. + + """ + # Spend an action if in combat + if self.rules.is_in_combat(giver): + self.rules.spend_action(giver, 1, action_name="give") # Use up one action.
+ + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +
[docs]class CmdFight(tb_basic.CmdFight): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + + key = "fight" + help_category = "combat" + + rules = COMBAT_RULES + combat_handler_class = TBRangeTurnHandler
+ + +
[docs]class CmdAttack(tb_basic.CmdAttack): + """ + Attacks another character in melee. + + Usage: + attack <target> + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. You can only + attack engaged targets - that is, targets that are right next to + you. Use the 'approach' command to get closer to a target. + """ + + key = "attack" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not self.rules.is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not self.rules.is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + if not self.rules.get_range(attacker, defender) == 0: # Target isn't in melee + self.caller.msg( + "%s is too far away to attack - you need to get closer! (see: help approach)" + % defender + ) + return + + "If everything checks out, call the attack resolving function." + self.rules.resolve_attack(attacker, defender, "melee") + self.rules.spend_action(self.caller, 1, action_name="attack") # Use up one action.
+ + +
[docs]class CmdShoot(Command): + """ + Attacks another character from range. + + Usage: + shoot <target> + + When in a fight, you may shoot another character. The attack has + a chance to hit, and if successful, will deal damage. You can attack + any target in combat by shooting, but can't shoot if there are any + targets engaged with you. Use the 'withdraw' command to retreat from + nearby enemies. + """ + + key = "shoot" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not self.rules.is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not self.rules.is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + # Test to see if there are any nearby enemy targets. + in_melee = [] + for target in attacker.db.combat_range: + # Object is engaged and has HP + if ( + self.rules.get_range(attacker, defender) == 0 + and target.db.hp + and target != self.caller + ): + in_melee.append(target) # Add to list of targets in melee + + if len(in_melee) > 0: + self.caller.msg( + "You can't shoot because there are fighters engaged with you (%s) - you need " + "to retreat! (see: help withdraw)" % ", ".join(obj.key for obj in in_melee) + ) + return + + "If everything checks out, call the attack resolving function." + self.rules.resolve_attack(attacker, defender, "ranged") + self.rules.spend_action(self.caller, 1, action_name="attack") # Use up one action.
+ + +
[docs]class CmdApproach(Command): + """ + Approaches an object. + + Usage: + approach <target> + + Move one space toward a character or object. You can only attack + characters you are 0 spaces away from. + """ + + key = "approach" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + "This performs the actual command." + + if not self.rules.is_in_combat(self.caller): # If not in combat, can't approach. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not self.rules.is_turn(self.caller): # If it's not your turn, can't approach. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't approach if you have no HP. + self.caller.msg("You can't move, you've been defeated.") + return + + mover = self.caller + target = self.caller.search(self.args) + + if not target: # No valid target given. + return + + if not target.db.combat_range: # Target object is not on the range field + self.caller.msg("You can't move toward that!") + return + + if mover == target: # Target and mover are the same + self.caller.msg("You can't move toward yourself!") + return + + if self.rules.get_range(mover, target) <= 0: # Already engaged with target + self.caller.msg("You're already next to that target!") + return + + # If everything checks out, call the approach resolving function. + self.rules.approach(mover, target) + mover.location.msg_contents("%s moves toward %s." % (mover, target)) + self.rules.spend_action(self.caller, 1, action_name="move") # Use up one action.
+ + +
[docs]class CmdWithdraw(Command): + """ + Moves away from an object. + + Usage: + withdraw <target> + + Move one space away from a character or object. + """ + + key = "withdraw" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + "This performs the actual command." + + if not self.rules.is_in_combat(self.caller): # If not in combat, can't withdraw. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not self.rules.is_turn(self.caller): # If it's not your turn, can't withdraw. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't withdraw if you have no HP. + self.caller.msg("You can't move, you've been defeated.") + return + + mover = self.caller + target = self.caller.search(self.args) + + if not target: # No valid target given. + return + + if not target.db.combat_range: # Target object is not on the range field + self.caller.msg("You can't move away from that!") + return + + if mover == target: # Target and mover are the same + self.caller.msg("You can't move away from yourself!") + return + + if mover.db.combat_range[target] >= 3: # Already at maximum distance + self.caller.msg("You're as far as you can get from that target!") + return + + # If everything checks out, call the approach resolving function. + self.rules.withdraw(mover, target) + mover.location.msg_contents("%s moves away from %s." % (mover, target)) + self.rules.spend_action(self.caller, 1, action_name="move") # Use up one action.
+ + +
[docs]class CmdPass(tb_basic.CmdPass): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdDisengage(tb_basic.CmdDisengage): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdRest(tb_basic.CmdRest): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + rules = COMBAT_RULES
+ + +
[docs]class CmdStatus(Command): + """ + Gives combat information. + + Usage: + status + + Shows your current and maximum HP and your distance from + other targets in combat. + """ + + key = "status" + help_category = "combat" + + rules = COMBAT_RULES + +
[docs] def func(self): + "This performs the actual command." + self.rules.combat_status_message(self.caller)
+ + +
[docs]class CmdCombatHelp(tb_basic.CmdCombatHelp): + """ + View help or a list of topics + + Usage: + help <topic or command> + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + rules = COMBAT_RULES + combat_help_text = ( + "Available combat commands:|/" + "|wAttack:|n Attack an engaged target, attempting to deal damage.|/" + "|wShoot:|n Attack from a distance, if not engaged with other fighters.|/" + "|wApproach:|n Move one step cloer to a target.|/" + "|wWithdraw:|n Move one step away from a target.|/" + "|wPass:|n Pass your turn without further action.|/" + "|wStatus:|n View current HP and ranges to other targets.|/" + "|wDisengage:|n End your turn and attempt to end combat.|/" + )
+ + +
[docs]class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + + key = "DefaultCharacter" + +
[docs] def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdShoot()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdApproach()) + self.add(CmdWithdraw()) + self.add(CmdStatus()) + self.add(CmdCombatHelp())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tests.html b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tests.html new file mode 100644 index 0000000000..bf84c872e2 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/game_systems/turnbattle/tests.html @@ -0,0 +1,720 @@ + + + + + + + + evennia.contrib.game_systems.turnbattle.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.game_systems.turnbattle.tests

+"""
+Turnbattle tests.
+
+"""
+
+from mock import MagicMock, patch
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.objects.objects import DefaultRoom
+from evennia.utils.create import create_object
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import tb_basic, tb_equip, tb_items, tb_magic, tb_range
+
+
+
[docs]class TestTurnBattleBasicCmd(BaseEvenniaCommandTest): + # Test basic combat commands +
[docs] def test_turnbattlecmd(self): + self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.")
+ + +
[docs]class TestTurnBattleEquipCmd(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") + self.testarmor = create_object(tb_equip.TBEArmor, key="test armor") + self.testweapon.move_to(self.char1) + self.testarmor.move_to(self.char1)
+ + # Test equipment commands +
[docs] def test_turnbattleequipcmd(self): + # Start with equip module specific commands. + self.call(tb_equip.CmdWield(), "weapon", "Char wields test weapon.") + self.call(tb_equip.CmdUnwield(), "", "Char lowers test weapon.") + self.call(tb_equip.CmdDon(), "armor", "Char dons test armor.") + self.call(tb_equip.CmdDoff(), "", "Char removes test armor.") + # Also test the commands that are the same in the basic module + self.call(tb_equip.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_equip.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.")
+ + +
[docs]class TestTurnBattleRangeCmd(BaseEvenniaCommandTest): + # Test range commands +
[docs] def test_turnbattlerangecmd(self): + # Start with range module specific commands. + self.call(tb_range.CmdShoot(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdApproach(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdWithdraw(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdStatus(), "", "HP Remaining: 100 / 100") + # Also test the commands that are the same in the basic module + self.call(tb_range.CmdFight(), "", "There's nobody here to fight!") + self.call(tb_range.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdRest(), "", "Char rests to recover HP.")
+ + +
[docs]class TestTurnBattleItemsCmd(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.testitem = create_object(key="test item") + self.testitem.move_to(self.char1)
+ + # Test item commands +
[docs] def test_turnbattleitemcmd(self): + self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.") + # Also test the commands that are the same in the basic module + self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_items.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdRest(), "", "Char rests to recover HP.")
+ + +
[docs]class TestTurnBattleMagicCmd(BaseEvenniaCommandTest): + # Test magic commands +
[docs] def test_turnbattlemagiccmd(self): + self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.") + self.call(tb_magic.CmdLearnSpell(), "test spell", "There is no spell with that name.") + self.call(tb_magic.CmdCast(), "", "Usage: cast <spell name> = <target>, <target2>") + # Also test the commands that are the same in the basic module + self.call(tb_magic.CmdFight(), "", "There's nobody here to fight!") + self.call(tb_magic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.")
+ + +
[docs]class TestTurnBattleBasicFunc(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object( + tb_basic.TBBasicCharacter, key="Attacker", location=self.testroom + ) + self.defender = create_object( + tb_basic.TBBasicCharacter, key="Defender", location=self.testroom + ) + self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner", location=None)
+ +
[docs] def tearDown(self): + super().tearDown() + self.turnhandler.stop() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete()
+ + # Test combat functions +
[docs] def test_tbbasicfunc(self): + # Initiative roll + initiative = tb_basic.COMBAT_RULES.roll_init(self.attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_basic.COMBAT_RULES.get_attack(self.attacker, self.defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_basic.COMBAT_RULES.get_defense(self.attacker, self.defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_basic.COMBAT_RULES.get_damage(self.attacker, self.defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + self.defender.db.hp = 10 + tb_basic.COMBAT_RULES.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) + # Resolve attack + self.defender.db.hp = 40 + tb_basic.COMBAT_RULES.resolve_attack( + self.attacker, self.defender, attack_value=20, defense_value=10 + ) + self.assertTrue(self.defender.db.hp < 40) + # Combat cleanup + self.attacker.db.Combat_attribute = True + tb_basic.COMBAT_RULES.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_basic.COMBAT_RULES.is_in_combat(self.attacker)) + # Set up turn handler script for further tests + self.attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) + self.turnhandler = self.attacker.db.combat_turnHandler + self.assertTrue(self.attacker.db.combat_turnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + self.turnhandler.interval = 10000 + # Force turn order + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_basic.COMBAT_RULES.is_turn(self.attacker)) + # Spend actions + self.attacker.db.Combat_ActionsLeft = 1 + tb_basic.COMBAT_RULES.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") + # Initialize for combat + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") + # Start turn + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) + # Next turn + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) + # Turn end check + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) + # Join fight + self.joiner.location = self.testroom + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
+ + +
[docs]class TestTurnBattleEquipFunc(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object( + tb_equip.TBEquipCharacter, key="Attacker", location=self.testroom + ) + self.defender = create_object( + tb_equip.TBEquipCharacter, key="Defender", location=self.testroom + ) + self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner", location=None)
+ +
[docs] def tearDown(self): + super().tearDown() + self.turnhandler.stop() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete()
+ + # Test the combat functions in tb_equip too. They work mostly the same. +
[docs] def test_tbequipfunc(self): + # Initiative roll + initiative = tb_equip.COMBAT_RULES.roll_init(self.attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_equip.COMBAT_RULES.get_attack(self.attacker, self.defender) + self.assertTrue(attack_roll >= -50 and attack_roll <= 150) + # Defense roll + defense_roll = tb_equip.COMBAT_RULES.get_defense(self.attacker, self.defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_equip.COMBAT_RULES.get_damage(self.attacker, self.defender) + self.assertTrue(damage_roll >= 0 and damage_roll <= 50) + # Apply damage + self.defender.db.hp = 10 + tb_equip.COMBAT_RULES.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) + # Resolve attack + self.defender.db.hp = 40 + tb_equip.COMBAT_RULES.resolve_attack( + self.attacker, self.defender, attack_value=20, defense_value=10 + ) + self.assertTrue(self.defender.db.hp < 40) + # Combat cleanup + self.attacker.db.Combat_attribute = True + tb_equip.COMBAT_RULES.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_equip.COMBAT_RULES.is_in_combat(self.attacker)) + # Set up turn handler script for further tests + self.attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) + self.turnhandler = self.attacker.db.combat_turnHandler + self.assertTrue(self.attacker.db.combat_turnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + self.turnhandler.interval = 10000 + # Force turn order + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_equip.COMBAT_RULES.is_turn(self.attacker)) + # Spend actions + self.attacker.db.Combat_ActionsLeft = 1 + tb_equip.COMBAT_RULES.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") + # Initialize for combat + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") + # Start turn + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) + # Next turn + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) + # Turn end check + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) + # Join fight + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
+ + +
[docs]class TestTurnBattleRangeFunc(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object( + tb_range.TBRangeCharacter, key="Attacker", location=self.testroom + ) + self.defender = create_object( + tb_range.TBRangeCharacter, key="Defender", location=self.testroom + ) + self.joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=self.testroom)
+ +
[docs] def tearDown(self): + super().tearDown() + self.turnhandler.stop() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete()
+ + # Test combat functions in tb_range too. +
[docs] def test_tbrangefunc(self): + # Initiative roll + initiative = tb_range.COMBAT_RULES.roll_init(self.attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_range.COMBAT_RULES.get_attack( + self.attacker, self.defender, attack_type="test" + ) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_range.COMBAT_RULES.get_defense( + self.attacker, self.defender, attack_type="test" + ) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_range.COMBAT_RULES.get_damage(self.attacker, self.defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + self.defender.db.hp = 10 + tb_range.COMBAT_RULES.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) + # Resolve attack + self.defender.db.hp = 40 + tb_range.COMBAT_RULES.resolve_attack( + self.attacker, self.defender, attack_type="test", attack_value=20, defense_value=10 + ) + self.assertTrue(self.defender.db.hp < 40) + # Combat cleanup + self.attacker.db.Combat_attribute = True + tb_range.COMBAT_RULES.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_range.COMBAT_RULES.is_in_combat(self.attacker)) + # Set up turn handler script for further tests + self.attacker.location.scripts.add(tb_range.TBRangeTurnHandler) + self.turnhandler = self.attacker.db.combat_turnHandler + self.assertTrue(self.attacker.db.combat_turnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + self.turnhandler.interval = 10000 + # Force turn order + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_range.COMBAT_RULES.is_turn(self.attacker)) + # Spend actions + self.attacker.db.Combat_ActionsLeft = 1 + tb_range.COMBAT_RULES.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") + # Initialize for combat + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") + # Set up ranges again, since initialize_for_combat clears them + self.attacker.db.combat_range = {} + self.attacker.db.combat_range[self.attacker] = 0 + self.attacker.db.combat_range[self.defender] = 1 + self.defender.db.combat_range = {} + self.defender.db.combat_range[self.defender] = 0 + self.defender.db.combat_range[self.attacker] = 1 + # Start turn + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertEqual(self.defender.db.Combat_ActionsLeft, 2) + # Next turn + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) + # Turn end check + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) + # Join fight + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) + # Now, test for approach/withdraw functions + self.assertTrue(tb_range.COMBAT_RULES.get_range(self.attacker, self.defender) == 1) + # Approach + tb_range.COMBAT_RULES.approach(self.attacker, self.defender) + self.assertTrue(tb_range.COMBAT_RULES.get_range(self.attacker, self.defender) == 0) + # Withdraw + tb_range.COMBAT_RULES.withdraw(self.attacker, self.defender) + self.assertTrue(tb_range.COMBAT_RULES.get_range(self.attacker, self.defender) == 1)
+ + +
[docs]class TestTurnBattleItemsFunc(BaseEvenniaTest): +
[docs] @patch("evennia.contrib.game_systems.turnbattle.tb_items.tickerhandler", new=MagicMock()) + def setUp(self): + super().setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object( + tb_items.TBItemsCharacter, key="Attacker", location=self.testroom + ) + self.defender = create_object( + tb_items.TBItemsCharacter, key="Defender", location=self.testroom + ) + self.joiner = create_object(tb_items.TBItemsCharacter, key="Joiner", location=self.testroom) + self.user = create_object(tb_items.TBItemsCharacter, key="User", location=self.testroom) + self.test_healpotion = create_object(key="healing potion") + self.test_healpotion.db.item_func = "heal" + self.test_healpotion.db.item_uses = 3
+ +
[docs] def tearDown(self): + super().tearDown() + self.turnhandler.stop() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.user.delete() + self.testroom.delete()
+ + # Test functions in tb_items. +
[docs] def test_tbitemsfunc(self): + # Initiative roll + initiative = tb_items.COMBAT_RULES.roll_init(self.attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_items.COMBAT_RULES.get_attack(self.attacker, self.defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_items.COMBAT_RULES.get_defense(self.attacker, self.defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_items.COMBAT_RULES.get_damage(self.attacker, self.defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + self.defender.db.hp = 10 + tb_items.COMBAT_RULES.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) + # Resolve attack + self.defender.db.hp = 40 + tb_items.COMBAT_RULES.resolve_attack( + self.attacker, self.defender, attack_value=20, defense_value=10 + ) + self.assertTrue(self.defender.db.hp < 40) + # Combat cleanup + self.attacker.db.Combat_attribute = True + tb_items.COMBAT_RULES.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_items.COMBAT_RULES.is_in_combat(self.attacker)) + # Set up turn handler script for further tests + self.attacker.location.scripts.add(tb_items.TBItemsTurnHandler) + self.turnhandler = self.attacker.db.combat_turnHandler + self.assertTrue(self.attacker.db.combat_turnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + self.turnhandler.interval = 10000 + # Force turn order + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_items.COMBAT_RULES.is_turn(self.attacker)) + # Spend actions + self.attacker.db.Combat_ActionsLeft = 1 + tb_items.COMBAT_RULES.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") + # Initialize for combat + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") + # Start turn + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) + # Next turn + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) + # Turn end check + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) + # Join fight + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) + # Now time to test item stuff. + # Spend item use + tb_items.COMBAT_RULES.spend_item_use(self.test_healpotion, self.user) + self.assertTrue(self.test_healpotion.db.item_uses == 2) + # Use item + self.user.db.hp = 2 + tb_items.COMBAT_RULES.use_item(self.user, self.test_healpotion, self.user) + self.assertTrue(self.user.db.hp > 2) + # Add contition + tb_items.COMBAT_RULES.add_condition(self.user, self.user, "Test", 5) + self.assertTrue(self.user.db.conditions == {"Test": [5, self.user]}) + # Condition tickdown + tb_items.COMBAT_RULES.condition_tickdown(self.user, self.user) + self.assertEqual(self.user.db.conditions, {"Test": [4, self.user]}) + # Test item functions now! + # Item heal + self.user.db.hp = 2 + tb_items.COMBAT_RULES.itemfunc_heal(self.test_healpotion, self.user, self.user) + # Item add condition + self.user.db.conditions = {} + tb_items.COMBAT_RULES.itemfunc_add_condition(self.test_healpotion, self.user, self.user) + self.assertTrue(self.user.db.conditions == {"Regeneration": [5, self.user]}) + # Item cure condition + self.user.db.conditions = {"Poisoned": [5, self.user]} + tb_items.COMBAT_RULES.itemfunc_cure_condition(self.test_healpotion, self.user, self.user) + self.assertTrue(self.user.db.conditions == {})
+ + +
[docs]class TestTurnBattleMagicFunc(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object( + tb_magic.TBMagicCharacter, key="Attacker", location=self.testroom + ) + self.defender = create_object( + tb_magic.TBMagicCharacter, key="Defender", location=self.testroom + ) + self.joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner", location=self.testroom)
+ +
[docs] def tearDown(self): + super().tearDown() + self.turnhandler.stop() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete()
+ + # Test combat functions in tb_magic. +
[docs] def test_tbbasicfunc(self): + # Initiative roll + initiative = tb_magic.COMBAT_RULES.roll_init(self.attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_magic.COMBAT_RULES.get_attack(self.attacker, self.defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_magic.COMBAT_RULES.get_defense(self.attacker, self.defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_magic.COMBAT_RULES.get_damage(self.attacker, self.defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + self.defender.db.hp = 10 + tb_magic.COMBAT_RULES.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) + # Resolve attack + self.defender.db.hp = 40 + tb_magic.COMBAT_RULES.resolve_attack( + self.attacker, self.defender, attack_value=20, defense_value=10 + ) + self.assertTrue(self.defender.db.hp < 40) + # Combat cleanup + self.attacker.db.Combat_attribute = True + tb_magic.COMBAT_RULES.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_magic.COMBAT_RULES.is_in_combat(self.attacker)) + # Set up turn handler script for further tests + self.attacker.location.scripts.add(tb_magic.TBMagicTurnHandler) + self.turnhandler = self.attacker.db.combat_turnHandler + self.assertTrue(self.attacker.db.combat_turnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + self.turnhandler.interval = 10000 + # Force turn order + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_magic.COMBAT_RULES.is_turn(self.attacker)) + # Spend actions + self.attacker.db.Combat_ActionsLeft = 1 + tb_magic.COMBAT_RULES.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") + # Initialize for combat + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") + # Start turn + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) + # Next turn + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) + # Turn end check + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) + # Join fight + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/extended_room/extended_room.html b/docs/latest/_modules/evennia/contrib/grid/extended_room/extended_room.html new file mode 100644 index 0000000000..8da4b25346 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/extended_room/extended_room.html @@ -0,0 +1,1100 @@ + + + + + + + + evennia.contrib.grid.extended_room.extended_room — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.extended_room.extended_room

+"""
+Extended Room
+
+Evennia Contribution - Griatch 2012, vincent-lg 2019, Griatch 2023
+
+This is an extended Room typeclass for Evennia, supporting descriptions that vary
+by season, time-of-day or arbitrary states (like burning). It has details, embedded
+state tags, support for repeating random messages as well as a few extra commands.
+
+- The room description can be set to change depending on the season or time of day.
+- Parts of the room description can be set to change depending on arbitrary states (like burning).
+- Details can be added to the room, which can be looked at like objects.
+- Alternative text sections can be added to the room description, which will only show if
+  the room is in a given state.
+- Random messages can be set to repeat at a given rate.
+
+
+Installation/testing:
+
+Adding the `ExtendedRoomCmdset` to the default character cmdset will add all
+new commands for use.
+
+In more detail, in mygame/commands/default_cmdsets.py:
+
+```
+...
+from evennia.contrib import extended_room   # <---
+
+class CharacterCmdset(default_cmds.Character_CmdSet):
+    ...
+    def at_cmdset_creation(self):
+        ...
+        self.add(extended_room.ExtendedRoomCmdSet)  # <---
+
+```
+
+Then reload to make the bew commands available. Note that they only work
+on rooms with the typeclass `ExtendedRoom`. Create new rooms with the right
+typeclass or use the `typeclass` command to swap existing rooms.
+
+"""
+
+import datetime
+import random
+import re
+from collections import deque
+
+from django.conf import settings
+from django.db.models import Q
+from evennia import (
+    CmdSet,
+    DefaultRoom,
+    EvEditor,
+    FuncParser,
+    InterruptCommand,
+    default_cmds,
+    gametime,
+    utils,
+)
+from evennia.typeclasses.attributes import AttributeProperty
+from evennia.utils.utils import list_to_string, repeat
+
+# error return function, needed by Extended Look command
+_AT_SEARCH_RESULT = utils.variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1))
+
+
+# funcparser callable for the ExtendedRoom
+
+
+
[docs]def func_state(roomstate, *args, looker=None, room=None, **kwargs): + """ + Usage: $state(roomstate, text) + + Funcparser callable for ExtendedRoom. This is called by the FuncParser when it + returns the description of the room. Use 'default' for a default text when no + other states are set. + + Args: + roomstate (str): A roomstate, like "morning", "raining". This is case insensitive. + *args: All these will be combined into one string separated by commas. + + Keyword Args: + looker (Object): The object looking at the room. Unused by default. + room (ExtendedRoom): The room being looked at. + + Example: + + $state(morning, It is a beautiful morning!) + + Notes: + We try to merge all args into one text, since this function doesn't require more than one + argument. That way, one may be able to get away without using quotes. + + """ + roomstate = str(roomstate).lower() + text = ", ".join(args) + # make sure we have a room and a caller and not something parsed from the string + if not (roomstate and looker and room) or isinstance(looker, str) or isinstance(room, str): + return "" + + try: + if roomstate in room.room_states or roomstate == room.get_time_of_day(): + return text + if roomstate == "default" and not room.room_states: + # return this if no roomstate is set + return text + except AttributeError: + # maybe used on a non-ExtendedRoom? + pass + return ""
+ + +
[docs]class ExtendedRoom(DefaultRoom): + """ + An Extended Room + + Room states: + A room state is set as a Tag with category "roomstate" and tagkey "on_fire" or "flooded" + etc). + + Alternative descriptions: + - Add an Attribute `desc_<roomstate>` to the room, where <roomstate> is the name of the + roomstate to use this for, like `desc_on_fire` or `desc_flooded`. If not given, seasonal + descriptions given in desc_spring/summer/autumn/winter will be used, and last the + regular `desc` Attribute. + + Alternative text sections + - Used to add alternative text sections to the room description. These are embedded in the + description by adding `$state(roomstate, txt)`. They will show only if the room is in the + given roomstate. These are managed via the add/remove/get_alt_text methods. + + Details: + - This is set as an Attribute `details` (a dict) on the room, with the detail name as key. + When looking at this room, the detail name can be used as a target to look at without having + to add an actual database object for it. The `detail` command is used to add/remove details. + + Room messages + - Set `room_message_rate > 0` and add a list of `room_messages`. These will be randomly + echoed to the room at the given rate. + + """ + + # fallback description if nothing else is set + fallback_desc = "You see nothing special." + + # tag room_state category + room_state_tag_category = "room_state" + + # time setup + months_per_year = 12 + hours_per_day = 24 + + # seasons per year, given as (start, end) boundaries, each a fraction of a year. These + # will change the description. The last entry should wrap around to the first. + seasons_per_year = { + "spring": (3 / months_per_year, 6 / months_per_year), # March - May + "summer": (6 / months_per_year, 9 / months_per_year), # June - August + "autumn": (9 / months_per_year, 12 / months_per_year), # September - November + "winter": (12 / months_per_year, 3 / months_per_year), # December - February + } + + # time-dependent room descriptions (these must match the `seasons_per_year` above). + desc_spring = AttributeProperty("", autocreate=False) + desc_summer = AttributeProperty("", autocreate=False) + desc_autumn = AttributeProperty("", autocreate=False) + desc_winter = AttributeProperty("", autocreate=False) + + # time-dependent embedded descriptions, usable as $timeofday(morning, text) + # (start, end) boundaries, each a fraction of a day. The last one should + # end at 0 (not 24) to wrap around to midnight. + times_of_day = { + "night": (0, 6 / hours_per_day), # midnight - 6AM + "morning": (6 / hours_per_day, 12 / hours_per_day), # 6AM - noon + "afternoon": (12 / hours_per_day, 18 / hours_per_day), # noon - 6PM + "evening": (18 / hours_per_day, 0), # 6PM - midnight + } + + # normal vanilla description if no other `*_desc` matches or are set. + desc = AttributeProperty("", autocreate=False) + + # look-targets without database objects + details = AttributeProperty(dict, autocreate=False) + + # messages to send to the room + room_message_rate = 0 # set >0s to enable + room_messages = AttributeProperty(list, autocreate=False) + + # Broadcast message + + def _get_funcparser(self, looker): + return FuncParser( + {"state": func_state}, + looker=looker, + room=self, + ) + + def _start_broadcast_repeat_task(self): + if self.room_message_rate and self.room_messages and not self.ndb.broadcast_repeat_task: + self.ndb.broadcast_repeat_task = repeat( + self.room_message_rate, self.repeat_broadcast_msg_to_room, persistent=False + ) + +
[docs] def at_init(self): + """Evennia hook. Start up repeating function whenever object loads into memory.""" + self._start_broadcast_repeat_task()
+ +
[docs] def start_repeat_broadcast_messages(self): + """ + Start repeating the broadcast messages. Only needs to be called if adding messages + and not having reloaded the server. + + """ + self._start_broadcast_repeat_task()
+ +
[docs] def repeat_broadcast_message_to_room(self): + """ + Send a message to the room at room_message_rate. By default + we will randomize which one to send. + + """ + self.msg_contents(random.choice(self.room_messages))
+ +
[docs] def get_time_of_day(self): + """ + Get the current time of day. + + Override to customize. + + Returns: + str: The time of day, such as 'morning', 'afternoon', 'evening' or 'night'. + + """ + timestamp = gametime.gametime(absolute=True) + datestamp = datetime.datetime.fromtimestamp(timestamp) + timeslot = float(datestamp.hour) / self.hours_per_day + + for time_of_day, (start, end) in self.times_of_day.items(): + if start < end and start <= timeslot < end: + return time_of_day + return time_of_day # final back to the beginning
+ +
[docs] def get_season(self): + """ + Get the current season. + + Override to customize. + + Returns: + str: The season, such as 'spring', 'summer', 'autumn' or 'winter'. + + """ + timestamp = gametime.gametime(absolute=True) + datestamp = datetime.datetime.fromtimestamp(timestamp) + timeslot = float(datestamp.month) / self.months_per_year + + for season_of_year, (start, end) in self.seasons_per_year.items(): + if start < end and start <= timeslot < end: + return season_of_year + return season_of_year # final step is back to beginning
+ + # manipulate room states + + @property + def room_states(self): + """ + Get all room_states set on this room. + + """ + return list(sorted(self.tags.get(category=self.room_state_tag_category, return_list=True))) + +
[docs] def add_room_state(self, *room_states): + """ + Set a room-state or room-states to the room. + + Args: + *room_state (str): A room state like 'on_fire' or 'flooded'. This will affect + what `desc_*` and `roomstate_*` descriptions/inlines are used. You can add + more than one at a time. + + Notes: + You can also set time-based room_states this way, like 'morning' or 'spring'. This + can be useful to force a particular description, but while this state is + set this way, that state will be unaffected by the passage of time. Remove + the state to let the current game time determine this type of states. + + """ + self.tags.batch_add(*((state, self.room_state_tag_category) for state in room_states))
+ +
[docs] def remove_room_state(self, *room_states): + """ + Remove a roomstate from the room. + + Args: + *room_state (str): A roomstate like 'on_fire' or 'flooded'. If the + room did not have this state, nothing happens.You can remove more than one at a time. + + """ + for room_state in room_states: + self.tags.remove(room_state, category=self.room_state_tag_category)
+ +
[docs] def clear_room_state(self): + """ + Clear all room states. + + Note that fallback time-of-day and seasonal states are not affected by this, only + custom states added with `.add_room_state()`. + + """ + self.tags.clear(category="room_state")
+ + # control the available room descriptions + +
[docs] def add_desc(self, desc, room_state=None): + """ + Add a custom description, matching a particular room state. + + Args: + desc (str): The description to use when this roomstate is active. + roomstate (str, None): The roomstate to match, like 'on_fire', 'flooded', or "spring". + If `None`, set the default `desc` fallback. + + """ + if room_state is None: + self.attributes.add("desc", desc) + else: + self.attributes.add(f"desc_{room_state}", desc)
+ +
[docs] def remove_desc(self, room_state): + """ + Remove a custom description. + + Args: + room_state (str): The room-state description to remove. + + """ + self.attributes.remove(f"desc_{room_state}")
+ +
[docs] def all_desc(self): + """ + Get all available descriptions. + + Returns: + dict: A mapping of roomstate to description. The `None` key indicates the + base subscription (stored in the `desc` Attribute). + + """ + return { + **{None: self.db.desc or ""}, + **{ + attr.key[5:]: attr.value + for attr in self.db_attributes.filter(db_key__startswith="desc_").order_by("db_key") + }, + }
+ +
[docs] def get_stateful_desc(self): + """ + Get the currently active room description based on the current roomstate. + + Returns: + str: The current description. + + Note: + Only one description can be active at a time. Priority order is as follows: + + Priority order is as follows: + + 1. Room-states set by `add_roomstate()` that are not seasons. + If multiple room_states are set, the first one is used, sorted alphabetically. + 2. Seasons set by `add_room_state()`. This allows to 'pin' a season. + 3. Time-based seasons based on the current in-game time. + 4. None, if no seasons are defined in `.seasons_per_year`. + + If either of the above is found, but doesn't have a matching `desc_<roomstate>` + description, we move on to the next priority. If no matches are found, the `desc` + Attribute is used. + + """ + + room_states = self.room_states + seasons = self.seasons_per_year.keys() + seasonal_room_states = [] + + # get all available descriptions on this room + # note: *_desc is the old form, we support it for legacy + descriptions = dict( + self.db_attributes.filter( + Q(db_key__startswith="desc_") | Q(db_key__endswith="_desc") + ).values_list("db_key", "db_value") + ) + + for roomstate in sorted(room_states): + if roomstate not in seasons: + # if we have a roomstate that is not a season, use it + if desc := descriptions.get(f"desc_{roomstate}") or descriptions.get( + "{roomstate}_desc" + ): + return desc + else: + seasonal_room_states.append(roomstate) + + if not seasons: + # no seasons defined, so just return the default desc + return self.attributes.get("desc") + + for seasonal_roomstate in seasonal_room_states: + # explicit setting of season outside of automatic time keeping + if desc := descriptions.get(f"desc_{seasonal_roomstate}"): + return desc + + # no matching room_states, use time-based seasons. Also support legacy *_desc form + season = self.get_season() + if desc := descriptions.get(f"desc_{season}") or descriptions.get(f"{season}_desc"): + return desc + + # fallback to normal desc Attribute + return self.attributes.get("desc", self.fallback_desc)
+ +
[docs] def replace_legacy_time_of_day_markup(self, desc): + """ + Filter description by legacy markup like `<morning>...</morning>`. Filter + out all such markings that does not match the current time. Supports + 'morning', 'afternoon', 'evening' and 'night'. + + Args: + desc (str): The unmodified description. + + Returns: + str: A possibly modified description. + + Notes: + This is legacy. Use the $state markup for new rooms instead. + + """ + desc = desc or "" + time_of_day = self.get_time_of_day() + + # regexes for in-desc replacements (gets cached) + if not hasattr(self, "legacy_timeofday_regex_map"): + timeslots = deque() + for time_of_day in self.times_of_day: + timeslots.append( + ( + time_of_day, + re.compile(rf"<{time_of_day}>(.*?)</{time_of_day}>", re.IGNORECASE), + ) + ) + + # map the regexes cyclically, so each one is first once + self.legacy_timeofday_regex_map = {} + for i in range(len(timeslots)): + # mapping {"morning": [morning_regex, ...], ...} + self.legacy_timeofday_regex_map[timeslots[0][0]] = [tup[1] for tup in timeslots] + timeslots.rotate(-1) + + # do the replacement + regextuple = self.legacy_timeofday_regex_map[time_of_day] + desc = regextuple[0].sub(r"\1", desc) + desc = regextuple[1].sub("", desc) + desc = regextuple[2].sub("", desc) + return regextuple[3].sub("", desc)
+ +
[docs] def get_display_desc(self, looker, **kwargs): + """ + Evennia standard hook. Dynamically get the 'desc' component of the object description. This + is called by the return_appearance method and in turn by the 'look' command. + + Args: + looker (Object): Object doing the looking (unused by default). + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The desc display string. + + """ + # get the current description based on the roomstate + desc = self.get_stateful_desc() + # parse for legacy <morning>...</morning> markers + desc = self.replace_legacy_time_of_day_markup(desc) + # apply funcparser + desc = self._get_funcparser(looker).parse(desc, **kwargs) + return desc
+ + # manipulate details + +
[docs] def add_detail(self, key, description): + """ + This sets a new detail, using an Attribute "details". + + Args: + detailkey (str): The detail identifier to add (for + aliases you need to add multiple keys to the + same description). Case-insensitive. + description (str): The text to return when looking + at the given detailkey. This can contain funcparser directives. + + """ + if not self.details: + self.details = {} # causes it to be created as real attribute + self.details[key.lower()] = description
+ + set_detail = add_detail # legacy name + +
[docs] def remove_detail(self, key, *args): + """ + Delete a detail. + + Args: + key (str): the detail to remove (case-insensitive). + *args: Unused (backwards compatibility) + + The description is only included for compliance but is completely + ignored. Note that this method doesn't raise any exception if + the detail doesn't exist in this room. + + """ + self.details.pop(key.lower(), None)
+ + del_detail = remove_detail # legacy alias + +
[docs] def get_detail(self, key, looker=None): + """ + This will attempt to match a "detail" to look for in the room. + This will do a lower-case match followed by a startsby match. This + is called by the new `look` Command. + + Args: + key (str): A detail identifier. + looker (Object, optional): The one looking. + + Returns: + detail (str or None): A detail matching the given key, or `None` if + it was not found. + + Notes: + A detail is a way to offer more things to look at in a room + without having to add new objects. For this to work, we + require a custom `look` command that allows for `look <detail>` + - the look command should defer to this method on + the current location (if it exists) before giving up on + finding the target. + + """ + key = key.lower() + detail_keys = tuple(self.details.keys()) + + detail = None + if key in detail_keys: + # exact match + detail = self.details[key] + else: + # find closest match starting with key (shortest difference in length) + lkey = len(key) + startswith_matches = sorted( + ( + (detail_key, abs(lkey - len(detail_key))) + for detail_key in detail_keys + if detail_key.startswith(key) + ), + key=lambda tup: tup[1], + ) + if startswith_matches: + # use the matching startswith-detail with the shortest difference in length + detail = self.details[startswith_matches[0][0]] + + if detail: + detail = self._get_funcparser(looker).parse(detail) + + return detail
+ + return_detail = get_detail # legacy name
+ + +# Custom Look command supporting Room details. Add this to +# the Default cmdset to use. + + +
[docs]class CmdExtendedRoomLook(default_cmds.CmdLook): + """ + look + + Usage: + look + look <obj> + look <room detail> + look *<account> + + Observes your location, details at your location or objects in your vicinity. + """ + +
[docs] def look_detail(self): + """ + Look for detail on room. + """ + caller = self.caller + if hasattr(self.caller.location, "get_detail"): + detail = self.caller.location.get_detail(self.args, looker=self.caller) + if detail: + caller.location.msg_contents( + f"$You() $conj(look) closely at {self.args}.\n", + from_obj=caller, + exclude=caller, + ) + caller.msg(detail) + return True + return False
+ +
[docs] def func(self): + """ + Handle the looking. + """ + caller = self.caller + if not self.args: + target = caller.location + if not target: + caller.msg("You have no location to look at!") + return + else: + # search, waiting to return errors so we can also check details + target = caller.search(self.args, quiet=True) + # if there's no target, check details + if not target: + # no target AND no detail means run the normal no-results message + if not self.look_detail(): + _AT_SEARCH_RESULT(target, caller, self.args, quiet=False) + return + # otherwise, run normal search result handling + target = _AT_SEARCH_RESULT(target, caller, self.args, quiet=False) + if not target: + return + desc = caller.at_look(target) + # add the type=look to the outputfunc to make it + # easy to separate this output in client. + self.msg(text=(desc, {"type": "look"}), options=None)
+ + +# Custom build commands for setting seasonal descriptions +# and detailing extended rooms. + + +def _desc_load(caller): + return caller.db.eveditor_target.db.desc or "" + + +def _desc_save(caller, buf): + """ + Save line buffer to the desc prop. This should + return True if successful and also report its status to the user. + """ + roomstates = caller.db.eveditor_roomstates + target = caller.db.eveditor_target + + if not roomstates or not hasattr(target, "add_desc"): + # normal description + target.db.desc = buf + elif roomstates: + for roomstate in roomstates: + target.add_desc(buf, room_state=roomstate) + else: + target.db.desc = buf + + caller.msg("Saved.") + return True + + +def _desc_quit(caller): + caller.attributes.remove("eveditor_target") + caller.msg("Exited editor.") + + +
[docs]class CmdExtendedRoomDesc(default_cmds.CmdDesc): + """ + describe an object or the current room. + + Usage: + @desc[/switch] [<obj> =] <description> + + Switches: + edit - Open up a line editor for more advanced editing. + del - Delete the description of an object. If another state is given, its description + will be deleted. + spring|summer|autumn|winter - room description to use in respective in-game season + <other> - room description to use with an arbitrary room state. + + Sets the description an object. If an object is not given, + describe the current room, potentially showing any additional stateful descriptions. The room + states only work with rooms. + + Examples: + @desc/winter A cold winter scene. + @desc/edit/summer + @desc/burning This room is burning! + @desc A normal room with no state. + @desc/del/burning + + Rooms will automatically change season as the in-game time changes. You can + set a specific room-state with the |wroomstate|n command. + + """ + + key = "@desc" + switch_options = None + locks = "cmd:perm(desc) or perm(Builder)" + help_category = "Building" + +
[docs] def parse(self): + super().parse() + + self.delete_mode = "del" in self.switches + self.edit_mode = not self.delete_mode and "edit" in self.switches + + self.object_mode = "=" in self.args + + # all other switches are names of room-states + self.roomstates = [state for state in self.switches if state not in ("edit", "del")]
+ +
[docs] def edit_handler(self): + if self.rhs: + self.msg("|rYou may specify a value, or use the edit switch, but not both.|n") + return + if self.args: + obj = self.caller.search(self.args) + else: + obj = self.caller.location or self.msg("|rYou can't describe oblivion.|n") + if not obj: + return + + if not (obj.access(self.caller, "control") or obj.access(self.caller, "edit")): + self.caller.msg(f"You don't have permission to edit the description of {obj.key}.") + return + + self.caller.db.eveditor_target = obj + self.caller.db.eveditor_roomstates = self.roomstates + # launch the editor + EvEditor( + self.caller, + loadfunc=_desc_load, + savefunc=_desc_save, + quitfunc=_desc_quit, + key="desc", + persistent=True, + ) + return
+ +
[docs] def show_stateful_descriptions(self): + location = self.caller.location + room_states = location.room_states + season = location.get_season() + time_of_day = location.get_time_of_day() + stateful_descs = location.all_desc() + + output = [ + f"Room {location.get_display_name(self.caller)} " + f"Season: {season}. Time: {time_of_day}. " + f"States: {', '.join(room_states) if room_states else 'None'}" + ] + other_active = False + for state, desc in stateful_descs.items(): + if state is None: + continue + if state == season or state in room_states: + output.append(f"Room state |w{state}|n |g(active)|n:\n{desc}") + other_active = True + else: + output.append(f"Room state |w{state}|n:\n{desc}") + + active = " |g(active)|n" if not other_active else "" + output.append(f"Room state |w(default)|n{active}:\n{location.db.desc}") + + sep = "\n" + "-" * 78 + "\n" + self.caller.msg(sep.join(output))
+ +
[docs] def func(self): + caller = self.caller + if not self.args and "edit" not in self.switches and "del" not in self.switches: + if caller.location: + # show stateful descs on the room + self.show_stateful_descriptions() + return + else: + caller.msg("You have no location to describe!") + return + + if self.edit_mode: + self.edit_handler() + return + + if self.object_mode: + # We are describing an object + target = caller.search(self.lhs) + if not target: + return + desc = self.rhs or "" + else: + # we are describing the current room + target = caller.location or self.msg("|rYou don't have a location to describe.|n") + if not target: + return + desc = self.args + + roomstates = self.roomstates + if target.access(self.caller, "control") or target.access(self.caller, "edit"): + if not roomstates or not hasattr(target, "add_desc"): + # normal description + target.db.desc = desc + elif roomstates: + for roomstate in roomstates: + if self.delete_mode: + target.remove_desc(roomstate) + caller.msg(f"The {roomstate}-description was deleted, if it existed.") + else: + target.add_desc(desc, room_state=roomstate) + caller.msg( + f"The {roomstate}-description was set on" + f" {target.get_display_name(caller)}." + ) + else: + target.db.desc = desc + caller.msg(f"The description was set on {target.get_display_name(caller)}.") + else: + caller.msg( + "You don't have permission to edit the description " + f"of {target.get_display_name(caller)}." + )
+ + +
[docs]class CmdExtendedRoomDetail(default_cmds.MuxCommand): + + """ + sets a detail on a room + + Usage: + @detail[/del] <key> [= <description>] + @detail <key>;<alias>;... = description + + Example: + @detail + @detail walls = The walls are covered in ... + @detail castle;ruin;tower = The distant ruin ... + @detail/del wall + @detail/del castle;ruin;tower + + This command allows to show the current room details if you enter it + without any argument. Otherwise, sets or deletes a detail on the current + room, if this room supports details like an extended room. To add new + detail, just use the @detail command, specifying the key, an equal sign + and the description. You can assign the same description to several + details using the alias syntax (replace key by alias1;alias2;alias3;...). + To remove one or several details, use the @detail/del switch. + + """ + + key = "@detail" + locks = "cmd:perm(Builder)" + help_category = "Building" + +
[docs] def func(self): + location = self.caller.location + if not self.args: + details = location.db.details + if not details: + self.msg( + f"|rThe room {location.get_display_name(self.caller)} doesn't have any" + " details.|n" + ) + else: + details = sorted(["|y{}|n: {}".format(key, desc) for key, desc in details.items()]) + self.msg("Details on Room:\n" + "\n".join(details)) + return + + if not self.rhs and "del" not in self.switches: + detail = location.return_detail(self.lhs) + if detail: + self.msg("Detail '|y{}|n' on Room:\n{}".format(self.lhs, detail)) + else: + self.msg("Detail '{}' not found.".format(self.lhs)) + return + + method = "add_detail" if "del" not in self.switches else "remove_detail" + if not hasattr(location, method): + self.caller.msg("Details cannot be set on %s." % location) + return + for key in self.lhs.split(";"): + # loop over all aliases, if any (if not, this will just be + # the one key to loop over) + getattr(location, method)(key, self.rhs) + if "del" in self.switches: + self.caller.msg(f"Deleted detail '{self.lhs}', if it existed.") + else: + self.caller.msg(f"Set detail '{self.lhs}': '{self.rhs}'")
+ + +
[docs]class CmdExtendedRoomState(default_cmds.MuxCommand): + """ + Toggle and view room state for the current room. + + Usage: + @roomstate [<roomstate>] + + Examples: + @roomstate spring + @roomstate burning + @roomstate burning (a second time toggles it off) + + If the roomstate was already set, it will be disabled. Use + without arguments to see the roomstates on the current room. + + """ + + key = "@roomstate" + locks = "cmd:perm(Builder)" + help_category = "Building" + +
[docs] def parse(self): + super().parse() + self.room = self.caller.location + if not self.room or not hasattr(self.room, "room_states"): + self.caller.msg("You have no current location, or it doesn't support room states.") + raise InterruptCommand() + + self.room_state = self.args.strip().lower()
+ +
[docs] def func(self): + caller = self.caller + room = self.room + room_state = self.room_state + + if room_state: + # toggle room state + if room_state in room.room_states: + room.remove_room_state(room_state) + caller.msg(f"Cleared room state '{room_state}' from this room.") + else: + room.add_room_state(room_state) + caller.msg(f"Added room state '{room_state}' to this room.") + else: + # view room states + room_states = list_to_string( + [f"'{state}'" for state in room.room_states] if room.room_states else ("None",) + ) + caller.msg( + "Room states (not counting automatic time/season) on" + f" {room.get_display_name(caller)}:\n {room_states}" + )
+ + +
[docs]class CmdExtendedRoomGameTime(default_cmds.MuxCommand): + """ + Check the game time. + + Usage: + time + + Shows the current in-game time and season. + + """ + + key = "time" + locks = "cmd:all()" + help_category = "General" + +
[docs] def parse(self): + location = self.caller.location + if ( + not location + or not hasattr(location, "get_time_of_day") + or not hasattr(location, "get_season") + ): + self.caller.msg("No location available - you are outside time.") + raise InterruptCommand() + self.location = location
+ +
[docs] def func(self): + location = self.location + + season = location.get_season() + timeslot = location.get_time_of_day() + + prep = "an" if season == "autumn" else "a" + self.caller.msg(f"It's {prep} {season} day, in the {timeslot}.")
+ + +# CmdSet for easily install all commands + + +
[docs]class ExtendedRoomCmdSet(CmdSet): + """ + Groups the extended-room commands. + + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdExtendedRoomLook()) + self.add(CmdExtendedRoomDesc()) + self.add(CmdExtendedRoomDetail()) + self.add(CmdExtendedRoomState()) + self.add(CmdExtendedRoomGameTime())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/extended_room/tests.html b/docs/latest/_modules/evennia/contrib/grid/extended_room/tests.html new file mode 100644 index 0000000000..c0219d965d --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/extended_room/tests.html @@ -0,0 +1,504 @@ + + + + + + + + evennia.contrib.grid.extended_room.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.extended_room.tests

+"""
+Testing of ExtendedRoom contrib
+
+"""
+
+import datetime
+
+from django.conf import settings
+from mock import Mock, patch
+from parameterized import parameterized
+
+from evennia import create_object
+from evennia.utils.test_resources import BaseEvenniaCommandTest, EvenniaTestCase
+
+from . import extended_room
+
+
+def _get_timestamp(season, time_of_day):
+    """
+    Utility to get a timestamp for a given season and time of day.
+
+    """
+    # grab a month / time given a season and time of day
+    seasons = {"spring": 3, "summer": 6, "autumn": 9, "winter": 12}
+    times_of_day = {"morning": 6, "afternoon": 12, "evening": 18, "night": 0}
+    # return a datetime object for the 1st of the month at the given hour
+    return datetime.datetime(2064, seasons[season], 1, times_of_day[time_of_day]).timestamp()
+
+
+
[docs]class TestExtendedRoom(EvenniaTestCase): + """ + Test Extended Room typeclass. + + """ + + base_room_desc = "Base room description." + +
[docs] def setUp(self): + super().setUp() + self.room = create_object(extended_room.ExtendedRoom, key="Test Room") + self.room.desc = self.base_room_desc
+ +
[docs] def tearDown(self): + super().tearDown() + self.room.delete()
+ +
[docs] def test_room_description(self): + """ + Test that the vanilla room description is returned as expected. + """ + room_desc = self.room.get_display_desc(None) + self.assertEqual(room_desc, self.base_room_desc)
+ + @parameterized.expand( + [ + ("spring", "Spring room description."), + ("summer", "Summer room description."), + ("autumn", "Autumn room description."), + ("winter", "Winter room description."), + ] + ) + @patch("evennia.utils.gametime.gametime") + def test_seasonal_room_descriptions(self, season, desc, mock_gametime): + """ + Test that the room description changes with the season. + """ + mock_gametime.return_value = _get_timestamp(season, "morning") + self.room.add_desc(desc, room_state=season) + + room_desc = self.room.get_display_desc(None) + self.assertEqual(room_desc, desc) + + @parameterized.expand( + [ + ("morning", "Morning room description."), + ("afternoon", "Afternoon room description."), + ("evening", "Evening room description."), + ("night", "Night room description."), + ] + ) + @patch("evennia.utils.gametime.gametime") + def test_get_time_of_day_tags(self, time_of_day, desc, mock_gametime): + """ + Test room with $ + """ + mock_gametime.return_value = _get_timestamp("spring", time_of_day) + room_time_of_day = self.room.get_time_of_day() + self.assertEqual(room_time_of_day, time_of_day) + + self.room.add_desc( + "$state(morning, Morning room description.)" + "$state(afternoon, Afternoon room description.)" + "$state(evening, Evening room description.)" + "$state(night, Night room description.)" + " What a great day!" + ) + char = Mock() + room_desc = self.room.get_display_desc(char) + self.assertEqual(room_desc, f"{desc} What a great day!") + +
[docs] def test_room_states(self): + """ + Test rooms with custom game states. + + """ + self.room.add_desc( + "$state(under_construction, This room is under construction.)" + " $state(under_repair, This room is under repair.)" + ) + self.room.add_room_state("under_construction") + self.assertEqual(self.room.room_states, ["under_construction"]) + char = Mock() + self.assertEqual(self.room.get_display_desc(char), "This room is under construction. ") + + self.room.add_room_state("under_repair") + self.assertEqual(set(self.room.room_states), set(["under_construction", "under_repair"])) + self.assertEqual( + self.room.get_display_desc(char), + "This room is under construction. This room is under repair.", + ) + + self.room.remove_room_state("under_construction") + self.assertEqual( + self.room.get_display_desc(char), + " This room is under repair.", + )
+ +
[docs] def test_alternative_descs(self): + """ + Test rooms with alternate descriptions. + + """ + from evennia import ObjectDB + + ObjectDB.objects.all() # TODO - fixes an issue with home FK missing + + self.room.add_desc("The room is burning!", room_state="burning") + self.room.add_desc("The room is flooding!", room_state="flooding") + self.assertEqual(self.room.get_display_desc(None), self.base_room_desc) + + self.room.add_room_state("burning") + self.assertEqual(self.room.get_display_desc(None), "The room is burning!") + + self.room.add_room_state("flooding") + self.room.remove_room_state("burning") + self.assertEqual(self.room.get_display_desc(None), "The room is flooding!") + + self.room.clear_room_state() + self.assertEqual(self.room.get_display_desc(None), self.base_room_desc)
+ +
[docs] def test_details(self): + """ + Test room details. + + """ + self.room.add_detail("test", "Test detail.") + self.room.add_detail("test2", "Test detail 2.") + self.room.add_detail("window", "Window detail.") + self.room.add_detail("window pane", "Window Pane detail.") + + self.assertEqual(self.room.get_detail("test"), "Test detail.") + self.assertEqual(self.room.get_detail("test2"), "Test detail 2.") + self.assertEqual(self.room.get_detail("window"), "Window detail.") + self.assertEqual(self.room.get_detail("window pane"), "Window Pane detail.") + self.assertEqual(self.room.get_detail("win"), "Window detail.") + self.assertEqual(self.room.get_detail("window p"), "Window Pane detail.") + + self.room.remove_detail("test") + self.assertEqual(self.room.get_detail("test"), "Test detail 2.") # finding nearest + self.room.remove_detail("test2") + self.assertEqual(self.room.get_detail("test"), None) # all test* gone
+ + +
[docs]class TestExtendedRoomCommands(BaseEvenniaCommandTest): + """ + Test the ExtendedRoom commands. + + """ + + base_room_desc = "Base room description." + +
[docs] def setUp(self): + super().setUp() + self.room1.swap_typeclass("evennia.contrib.grid.extended_room.ExtendedRoom") + self.room1.desc = self.base_room_desc
+ +
[docs] @patch("evennia.utils.gametime.gametime") + def test_cmd_desc(self, mock_gametime): + """Test new desc command""" + + mock_gametime.return_value = _get_timestamp("autumn", "afternoon") + + # view base desc + self.call( + extended_room.CmdExtendedRoomDesc(), + "", + f""" +Room Room(#{self.room1.id}) Season: autumn. Time: afternoon. States: None + +Room state (default) (active): +Base room description. + """.strip(), + ) + + # add spring desc + self.call( + extended_room.CmdExtendedRoomDesc(), + "/spring Spring description.", + "The spring-description was set on Room", + ) + self.call( + extended_room.CmdExtendedRoomDesc(), + "/burning Burning description.", + "The burning-description was set on Room", + ) + + self.call( + extended_room.CmdExtendedRoomDesc(), + "", + f""" +Room Room(#{self.room1.id}) Season: autumn. Time: afternoon. States: None + +Room state burning: +Burning description. + +Room state spring: +Spring description. + +Room state (default) (active): +Base room description. + """.strip(), + ) + + # remove a desc + self.call( + extended_room.CmdExtendedRoomDesc(), + "/del/burning/spring", + "The burning-description was deleted, if it existed.|The spring-description was" + " deleted, if it existed", + ) + # add autumn, which should be active + self.call( + extended_room.CmdExtendedRoomDesc(), + "/autumn Autumn description.", + "The autumn-description was set on Room", + ) + self.call( + extended_room.CmdExtendedRoomDesc(), + "", + f""" +Room Room(#{self.room1.id}) Season: autumn. Time: afternoon. States: None + +Room state autumn (active): +Autumn description. + +Room state (default): +Base room description. + """.strip(), + )
+ +
[docs] def test_cmd_detail(self): + """Test adding details""" + self.call( + extended_room.CmdExtendedRoomDetail(), + "test=Test detail.", + "Set detail 'test': 'Test detail.'", + ) + + self.call( + extended_room.CmdExtendedRoomDetail(), + "", + """ +Details on Room: +test: Test detail. + """.strip(), + ) + + # remove a detail + self.call( + extended_room.CmdExtendedRoomDetail(), + "/del test", + "Deleted detail 'test', if it existed.", + ) + + self.call( + extended_room.CmdExtendedRoomDetail(), + "", + f""" +The room Room(#{self.room1.id}) doesn't have any details. + """.strip(), + )
+ +
[docs] @patch("evennia.utils.gametime.gametime") + def test_cmd_roomstate(self, mock_gametime): + """ + Test the roomstate command + + """ + + mock_gametime.return_value = _get_timestamp("autumn", "afternoon") + + # show existing room states (season/time doesn't count) + + self.assertEqual(self.room1.room_states, []) + + self.call( + extended_room.CmdExtendedRoomState(), + "", + f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n None", + ) + + # add room states + self.call( + extended_room.CmdExtendedRoomState(), + "burning", + "Added room state 'burning' to this room.", + ) + self.call( + extended_room.CmdExtendedRoomState(), + "windy", + "Added room state 'windy' to this room.", + ) + self.call( + extended_room.CmdExtendedRoomState(), + "", + f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n " + "'burning' and 'windy'", + ) + # toggle windy + self.call( + extended_room.CmdExtendedRoomState(), + "windy", + "Cleared room state 'windy' from this room.", + ) + self.call( + extended_room.CmdExtendedRoomState(), + "", + f"Room states (not counting automatic time/season) on Room(#{self.room1.id}):\n " + "'burning'", + ) + # add a autumn state and make sure we override it + self.room1.add_desc("Autumn description.", room_state="autumn") + self.room1.add_desc("Spring description.", room_state="spring") + + self.assertEqual(self.room1.get_stateful_desc(), "Autumn description.") + self.call( + extended_room.CmdExtendedRoomState(), + "spring", + "Added room state 'spring' to this room.", + ) + self.assertEqual(self.room1.get_stateful_desc(), "Spring description.")
+ +
[docs] @patch("evennia.utils.gametime.gametime") + def test_cmd_roomtime(self, mock_gametime): + """ + Test the time command + """ + + mock_gametime.return_value = _get_timestamp("autumn", "afternoon") + + self.call( + extended_room.CmdExtendedRoomGameTime(), "", "It's an autumn day, in the afternoon." + )
+ +
[docs] @patch("evennia.utils.gametime.gametime") + def test_cmd_look(self, mock_gametime): + """ + Test the look command. + """ + mock_gametime.return_value = _get_timestamp("autumn", "afternoon") + + autumn_desc = ( + "This is a nice autumnal forest." + "$state(morning,|_The morning sun is just rising)" + "$state(afternoon,|_The afternoon sun is shining through the trees)" + "$state(burning,|_and this place is on fire!)" + "$state(afternoon, .)" + "$state(flooded, and it's raining heavily!)" + ) + self.room1.add_desc(autumn_desc, room_state="autumn") + + self.call( + extended_room.CmdExtendedRoomLook(), + "", + f"Room(#{self.room1.id})\nThis is a nice autumnal forest.", + ) + self.call( + extended_room.CmdExtendedRoomLook(), + "", + f"Room(#{self.room1.id})\nThis is a nice autumnal forest. The afternoon sun is" + " shining through the trees.", + ) + self.room1.add_room_state("burning") + self.call( + extended_room.CmdExtendedRoomLook(), + "", + f"Room(#{self.room1.id})\nThis is a nice autumnal forest. The afternoon sun is" + " shining through the trees and this place is on fire!", + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/ingame_map_display/ingame_map_display.html b/docs/latest/_modules/evennia/contrib/grid/ingame_map_display/ingame_map_display.html new file mode 100644 index 0000000000..b8728fc3b0 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/ingame_map_display/ingame_map_display.html @@ -0,0 +1,433 @@ + + + + + + + + evennia.contrib.grid.ingame_map_display.ingame_map_display — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.ingame_map_display.ingame_map_display

+"""
+Basic Map - helpme 2022
+
+This adds an ascii `map` to a given room which can be viewed with the `map` command.
+You can easily alter it to add special characters, room colors etc. The map shown is
+dynamically generated on use, and supports all compass directions and up/down. Other
+directions are ignored.
+
+If you don't expect the map to be updated frequently, you could choose to save the
+calculated map as a .ndb value on the room and render that instead of running mapping
+calculations anew each time.
+
+An example map:
+```
+       |
+     -[-]-
+       |
+       |
+-[-]--[-]--[-]--[-]
+  |    |    |    |
+       |    |    |
+     -[-]--[-]  [-]
+       | \/ |    |
+     \ | /\ |
+     -[-]--[-]
+```
+
+Installation:
+
+Adding the `MapDisplayCmdSet` to the default character cmdset will add the `map` command.
+
+Specifically, in `mygame/commands/default_cmdsets.py`:
+
+```
+...
+from evennia.contrib.grid.ingame_map_display import MapDisplayCmdSet  # <---
+
+class CharacterCmdset(default_cmds.Character_CmdSet):
+    ...
+    def at_cmdset_creation(self):
+        ...
+        self.add(MapDisplayCmdSet)  # <---
+
+```
+
+Then `reload` to make the new commands available.
+
+Additional Settings:
+
+In order to change your default map size, you can add to `mygame/server/settings.py`:
+
+BASIC_MAP_SIZE = 5
+
+This changes the default map width/height. 2-5 for most clients is sensible.
+
+If you don't want the player to be able to specify the size of the map, ignore any
+arguments passed into the Map command.
+"""
+import time
+
+from django.conf import settings
+
+from evennia import CmdSet
+from evennia.commands.default.muxcommand import MuxCommand
+
+_BASIC_MAP_SIZE = settings.BASIC_MAP_SIZE if hasattr(settings, "BASIC_MAP_SIZE") else 2
+_MAX_MAP_SIZE = settings.BASIC_MAP_SIZE if hasattr(settings, "MAX_MAP_SIZE") else 10
+
+# _COMPASS_DIRECTIONS specifies which way to move the pointer on the x/y axes and what characters to use to depict the exits on the map.
+_COMPASS_DIRECTIONS = {
+    "north": (0, -3, " | "),
+    "south": (0, 3, " | "),
+    "east": (3, 0, "-"),
+    "west": (-3, 0, "-"),
+    "northeast": (3, -3, "/"),
+    "northwest": (-3, -3, "\\"),
+    "southeast": (3, 3, "\\"),
+    "southwest": (-3, 3, "/"),
+    "up": (0, 0, "^"),
+    "down": (0, 0, "v"),
+}
+
+
+
[docs]class Map(object): +
[docs] def __init__(self, caller, size=_BASIC_MAP_SIZE, location=None): + """ + Initializes the map. + + Args: + caller (object): Any object, though generally a puppeted character. + size (int): The seed size of the map, which will be multiplied to get the final grid size. + location (object): The location at the map's center (will default to caller.location if none provided). + """ + self.start_time = time.time() + self.caller = caller + self.max_width = int(size * 2 + 1) * 5 # This must be an odd number + self.max_length = int(size * 2 + 1) * 3 # This must be an odd number + self.has_mapped = {} + self.curX = None + self.curY = None + self.size = size + self.location = location or caller.location
+ +
[docs] def create_grid(self): + """ + Create the empty grid for the map based on the configured size + + Returns: + list: The created grid, a list of lists. + """ + board = [] + for row in range(self.max_length): + board.append([]) + for column in range(int(self.max_width / 5)): + board[row].extend([" ", " ", " "]) + return board
+ +
[docs] def exit_name_as_ordinal(self, ex): + """ + Get the exit name as a compass direction if possible + + Args: + ex (Exit): The current exit being mapped. + Returns: + string: The exit name as a compass direction or an empty string. + """ + exit_name = ex.name + if exit_name not in _COMPASS_DIRECTIONS: + compass_aliases = [ + direction in ex.aliases.all() for direction in _COMPASS_DIRECTIONS.keys() + ] + if compass_aliases[0]: + exit_name = compass_aliases[0] + if exit_name not in _COMPASS_DIRECTIONS: + return "" + return exit_name
+ +
[docs] def update_pos(self, room, exit_name): + """ + Update the position pointer. + + Args: + room (Room): The current location. + exit_name (str): The name of the exit to to use in this room. This must + be a valid compass direction, or an error will be raised. + Raises: + KeyError: If providing a non-compass exit name. + """ + # Update the pointer + self.curX, self.curY = self.has_mapped[room][0], self.has_mapped[room][1] + + # Move the pointer depending on which direction the exit lies + # exit_name has already been validated as an ordinal direction at this point + self.curY += _COMPASS_DIRECTIONS[exit_name][0] + self.curX += _COMPASS_DIRECTIONS[exit_name][1]
+ +
[docs] def has_drawn(self, room): + """ + Checks if the given room has already been drawn or not + + Args: + room (Room): Room to check. + Returns: + bool: Whether or not the room has been drawn. + """ + return True if room in self.has_mapped.keys() else False
+ +
[docs] def draw_room_on_map(self, room, max_distance): + """ + Draw the room and its exits on the map recursively + + Args: + room (Room): The room to draw out. + max_distance (int): How extensive the map is. + """ + self.draw(room) + self.draw_exits(room) + + if max_distance == 0: + return + + # Check if the caller has access to the room in question. If not, don't draw it. + # Additionally, if the name of the exit is not ordinal but an alias of it is, use that. + for ex in [x for x in room.exits if x.access(self.caller, "traverse")]: + ex_name = self.exit_name_as_ordinal(ex) + if not ex_name or ex_name in ["up", "down"]: + continue + if self.has_drawn(ex.destination): + continue + + self.update_pos(room, ex_name.lower()) + self.draw_room_on_map(ex.destination, max_distance - 1)
+ +
[docs] def draw_exits(self, room): + """ + Draw a given room's exit paths + + Args: + room (Room): The room to draw exits of. + """ + x, y = self.curX, self.curY + for ex in room.exits: + ex_name = self.exit_name_as_ordinal(ex) + if not ex_name: + continue + + ex_character = _COMPASS_DIRECTIONS[ex_name][2] + delta_x = int(_COMPASS_DIRECTIONS[ex_name][1] / 3) + delta_y = int(_COMPASS_DIRECTIONS[ex_name][0] / 3) + + # Make modifications if the exit has BOTH up and down exits + if ex_name == "up": + if "v" in self.grid[x][y]: + self.render_room(room, x, y, p1="^", p2="v") + else: + self.render_room(room, x, y, here="^") + elif ex_name == "down": + if "^" in self.grid[x][y]: + self.render_room(room, x, y, p1="^", p2="v") + else: + self.render_room(room, x, y, here="v") + else: + self.grid[x + delta_x][y + delta_y] = ex_character
+ +
[docs] def draw(self, room): + """ + Draw the map starting from a given room and add it to the cache of mapped rooms + + Args: + room (Room): The room to render. + """ + # draw initial caller location on map first! + if room == self.location: + self.start_loc_on_grid(room) + self.has_mapped[room] = [self.curX, self.curY] + else: + # map all other rooms + self.has_mapped[room] = [self.curX, self.curY] + self.render_room(room, self.curX, self.curY)
+ +
[docs] def render_room(self, room, x, y, p1="[", p2="]", here=None): + """ + Draw a given room with ascii characters + + Args: + room (Room): The room to render. + x (int): The x-value of the room on the grid (horizontally, east/west). + y (int): The y-value of the room on the grid (vertically, north/south). + p1 (str): The first character of the 3-character room depiction. + p2 (str): The last character of the 3-character room depiction. + here (str): Defaults to none, a special character depicting the room. + """ + # Note: This is where you would set colors, symbols etc. + # Render the room + you = list("[ ]") + + you[0] = f"{p1}|n" + you[1] = f"{here if here else you[1]}" + if room == self.caller.location: + you[1] = "|[x|co|n" # Highlight the location you are currently in + you[2] = f"{p2}|n" + + self.grid[x][y] = "".join(you)
+ +
[docs] def start_loc_on_grid(self, room): + """ + Set the starting location on the grid based on the maximum width and length + + Args: + room (Room): The room to begin with. + """ + x = int((self.max_width * 0.6 - 1) / 2) + y = int((self.max_length - 1) / 2) + + self.render_room(room, x, y) + self.curX, self.curY = x, y
+ +
[docs] def show_map(self, debug=False): + """ + Create and show the map, piecing it all together in the end + + Args: + debug (bool): Whether or not to return the time taken to build the map. + """ + map_string = "" + self.grid = self.create_grid() + self.draw_room_on_map(self.location, self.size) + + for row in self.grid: + map_row = "".join(row) + if map_row.strip() != "": + map_string += f"{map_row}\n" + + elapsed = time.time() - self.start_time + if debug: + map_string += f"\nTook {elapsed}ms to render the map.\n" + + return "%s" % map_string
+ + +
[docs]class CmdMap(MuxCommand): + """ + Check the local map around you. + + Usage: map (optional size) + """ + + key = "map" + +
[docs] def func(self): + size = _BASIC_MAP_SIZE + max_size = _MAX_MAP_SIZE + if self.args.isnumeric(): + size = min(max_size, int(self.args)) + + # You can run show_map(debug=True) to see how long it takes. + map_here = Map(self.caller, size=size).show_map() + self.caller.msg((map_here, {"type": "map"}))
+ + +# CmdSet for easily install all commands +
[docs]class MapDisplayCmdSet(CmdSet): + """ + The map command. + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdMap)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/simpledoor/simpledoor.html b/docs/latest/_modules/evennia/contrib/grid/simpledoor/simpledoor.html new file mode 100644 index 0000000000..f415b6394f --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/simpledoor/simpledoor.html @@ -0,0 +1,299 @@ + + + + + + + + evennia.contrib.grid.simpledoor.simpledoor — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.simpledoor.simpledoor

+"""
+SimpleDoor
+
+Contribution - Griatch 2016
+
+A simple two-way exit that represents a door that can be opened and
+closed. Can easily be expanded from to make it lockable, destroyable
+etc.  Note that the simpledoor is based on Evennia locks, so it will
+not work for a superuser (which bypasses all locks) - the superuser
+will always appear to be able to close/open the door over and over
+without the locks stopping you. To use the door, use `@quell` or a
+non-superuser account.
+
+Installation:
+
+
+Import this module in mygame/commands/default_cmdsets and add
+the SimpleDoorCmdSet to your CharacterCmdSet:
+
+```python
+# in mygame/commands/default_cmdsets.py
+
+from evennia.contrib.grid import simpledoor  <---
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(simpledoor.SimpleDoorCmdSet)
+
+```
+
+Usage:
+
+To try it out, `dig` a new room and then use the (overloaded) `@open`
+commmand to open a new doorway to it like this:
+
+    @open doorway:contrib.grid.simpledoor.SimpleDoor = otherroom
+
+You can then use `open doorway' and `close doorway` to change the open
+state. If you are not superuser (`@quell` yourself) you'll find you
+cannot pass through either side of the door once it's closed from the
+other side.
+
+"""
+
+from evennia import DefaultExit, default_cmds
+from evennia.commands.cmdset import CmdSet
+from evennia.utils.utils import inherits_from
+
+
+
[docs]class SimpleDoor(DefaultExit): + """ + A two-way exit "door" with some methods for affecting both "sides" + of the door at the same time. For example, set a lock on either of the two + sides using `exitname.setlock("traverse:false())` + + """ + +
[docs] def at_object_creation(self): + """ + Called the very first time the door is created. + + """ + self.db.return_exit = None
+ +
[docs] def setlock(self, lockstring): + """ + Sets identical locks on both sides of the door. + + Args: + lockstring (str): A lockstring, like `"traverse:true()"`. + + """ + self.locks.add(lockstring) + self.db.return_exit.locks.add(lockstring)
+ +
[docs] def setdesc(self, description): + """ + Sets identical descs on both sides of the door. + + Args: + setdesc (str): A description. + + """ + self.db.desc = description + self.db.return_exit.db.desc = description
+ +
[docs] def delete(self): + """ + Deletes both sides of the door. + + """ + # we have to be careful to avoid a delete-loop. + if self.db.return_exit: + super().delete() + super().delete() + return True
+ +
[docs] def at_failed_traverse(self, traversing_object): + """ + Called when door traverse: lock fails. + + Args: + traversing_object (Typeclassed entity): The object + attempting the traversal. + + """ + traversing_object.msg("%s is closed." % self.key)
+ + +
[docs]class CmdOpen(default_cmds.CmdOpen): + __doc__ = default_cmds.CmdOpen.__doc__ + # overloading parts of the default CmdOpen command to support doors. + +
[docs] def create_exit(self, exit_name, location, destination, exit_aliases=None, typeclass=None): + """ + Simple wrapper for the default CmdOpen.create_exit + """ + # create a new exit as normal + new_exit = super().create_exit( + exit_name, location, destination, exit_aliases=exit_aliases, typeclass=typeclass + ) + if hasattr(self, "return_exit_already_created"): + # we don't create a return exit if it was already created (because + # we created a door) + del self.return_exit_already_created + return new_exit + if inherits_from(new_exit, SimpleDoor): + # a door - create its counterpart and make sure to turn off the default + # return-exit creation of CmdOpen + self.caller.msg( + "Note: A door-type exit was created - ignored eventual custom return-exit type." + ) + self.return_exit_already_created = True + back_exit = self.create_exit( + exit_name, destination, location, exit_aliases=exit_aliases, typeclass=typeclass + ) + new_exit.db.return_exit = back_exit + back_exit.db.return_exit = new_exit + return new_exit
+ + +# A simple example of a command making use of the door exit class' +# functionality. One could easily expand it with functionality to +# operate on other types of open-able objects as needed. + + +
[docs]class CmdOpenCloseDoor(default_cmds.MuxCommand): + """ + Open and close a door + + Usage: + open <door> + close <door> + + """ + + key = "open" + aliases = ["close"] + locks = "cmd:all()" + help_category = "General" + +
[docs] def func(self): + "implement the door functionality" + if not self.args: + self.caller.msg("Usage: open||close <door>") + return + + door = self.caller.search(self.args) + if not door: + return + if not inherits_from(door, SimpleDoor): + self.caller.msg("This is not a door.") + return + + if self.cmdstring == "open": + if door.locks.check(self.caller, "traverse"): + self.caller.msg("%s is already open." % door.key) + else: + door.setlock("traverse:true()") + self.caller.msg("You open %s." % door.key) + else: # close + if not door.locks.check(self.caller, "traverse"): + self.caller.msg("%s is already closed." % door.key) + else: + door.setlock("traverse:false()") + self.caller.msg("You close %s." % door.key)
+ + +
[docs]class SimpleDoorCmdSet(CmdSet): +
[docs] def at_cmdset_creation(self): + self.add(CmdOpen()) + self.add(CmdOpenCloseDoor())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/simpledoor/tests.html b/docs/latest/_modules/evennia/contrib/grid/simpledoor/tests.html new file mode 100644 index 0000000000..74747206a1 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/simpledoor/tests.html @@ -0,0 +1,135 @@ + + + + + + + + evennia.contrib.grid.simpledoor.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.simpledoor.tests

+"""
+Tests of simpledoor.
+
+"""
+
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+
+from . import simpledoor
+
+
+
[docs]class TestSimpleDoor(BaseEvenniaCommandTest): +
[docs] def test_cmdopen(self): + self.call( + simpledoor.CmdOpen(), + "newdoor;door:contrib.grid.simpledoor.SimpleDoor,backdoor;door = Room2", + "Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A door-type exit was " + "created - ignored eventual custom return-exit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).", + ) + self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You close newdoor.", cmdstring="close") + self.call( + simpledoor.CmdOpenCloseDoor(), + "newdoor", + "newdoor is already closed.", + cmdstring="close", + ) + self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You open newdoor.", cmdstring="open") + self.call( + simpledoor.CmdOpenCloseDoor(), "newdoor", "newdoor is already open.", cmdstring="open" + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/slow_exit/slow_exit.html b/docs/latest/_modules/evennia/contrib/grid/slow_exit/slow_exit.html new file mode 100644 index 0000000000..d1c23e99a3 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/slow_exit/slow_exit.html @@ -0,0 +1,278 @@ + + + + + + + + evennia.contrib.grid.slow_exit.slow_exit — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.slow_exit.slow_exit

+"""
+Slow Exit typeclass
+
+Contribution - Griatch 2014
+
+
+This is an example of an Exit-type that delays its traversal. This
+simulates slow movement, common in many different types of games. The
+contrib also contains two commands, `CmdSetSpeed` and CmdStop for changing
+the movement speed and abort an ongoing traversal, respectively.
+
+## Installation:
+
+To try out an exit of this type, you could connect two existing rooms
+using something like this:
+
+@open north:contrib.grid.slow_exit.SlowExit = <destination>
+
+To make this your new default exit, modify `mygame/typeclasses/exits.py`
+to import this module and change the default `Exit` class to inherit
+from `SlowExit` instead.
+
+```
+# in mygame/typeclasses/exits.py
+
+from evennia.contrib.grid.slowexit import SlowExit
+
+class Exit(SlowExit):
+    # ...
+
+```
+
+To get the ability to change your speed and abort your movement, import
+
+```python
+# in mygame/commands/default_cmdsets.py
+
+from evennia.contrib.grid import slow_exit  <---
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(slow_exit.SlowDoorCmdSet)  <---
+
+```
+
+## Notes:
+
+This implementation is efficient but not persistent; so incomplete
+movement will be lost in a server reload. This is acceptable for most
+game types - to simulate longer travel times (more than the couple of
+seconds assumed here), a more persistent variant using Scripts or the
+TickerHandler might be better.
+
+"""
+
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.command import Command
+from evennia.objects.objects import DefaultExit
+from evennia.utils import utils
+
+MOVE_DELAY = {"stroll": 6, "walk": 4, "run": 2, "sprint": 1}
+
+
+
[docs]class SlowExit(DefaultExit): + """ + This overloads the way moving happens. + """ + +
[docs] def at_traverse(self, traversing_object, target_location): + """ + Implements the actual traversal, using utils.delay to delay the move_to. + """ + + # if the traverser has an Attribute move_speed, use that, + # otherwise default to "walk" speed + move_speed = traversing_object.db.move_speed or "walk" + move_delay = MOVE_DELAY.get(move_speed, 4) + + def move_callback(): + "This callback will be called by utils.delay after move_delay seconds." + source_location = traversing_object.location + if traversing_object.move_to(target_location, move_type="traverse"): + self.at_post_traverse(traversing_object, source_location) + else: + if self.db.err_traverse: + # if exit has a better error message, let's use it. + self.caller.msg(self.db.err_traverse) + else: + # No shorthand error message. Call hook. + self.at_failed_traverse(traversing_object) + + traversing_object.msg("You start moving %s at a %s." % (self.key, move_speed)) + # create a delayed movement + t = utils.delay(move_delay, move_callback) + # we store the deferred on the character, this will allow us + # to abort the movement. We must use an ndb here since + # deferreds cannot be pickled. + traversing_object.ndb.currently_moving = t
+ + +# +# set speed - command +# + +SPEED_DESCS = {"stroll": "strolling", "walk": "walking", "run": "running", "sprint": "sprinting"} + + +
[docs]class CmdSetSpeed(Command): + """ + set your movement speed + + Usage: + setspeed stroll|walk|run|sprint + + This will set your movement speed, determining how long time + it takes to traverse exits. If no speed is set, 'walk' speed + is assumed. + """ + + key = "setspeed" + +
[docs] def func(self): + """ + Simply sets an Attribute used by the SlowExit above. + """ + speed = self.args.lower().strip() + if speed not in SPEED_DESCS: + self.caller.msg("Usage: setspeed stroll||walk||run||sprint") + elif self.caller.db.move_speed == speed: + self.caller.msg("You are already %s." % SPEED_DESCS[speed]) + else: + self.caller.db.move_speed = speed + self.caller.msg("You are now %s." % SPEED_DESCS[speed])
+ + +# +# stop moving - command +# + + +
[docs]class CmdStop(Command): + """ + stop moving + + Usage: + stop + + Stops the current movement, if any. + """ + + key = "stop" + +
[docs] def func(self): + """ + This is a very simple command, using the + stored deferred from the exit traversal above. + """ + currently_moving = self.caller.ndb.currently_moving + if currently_moving and not currently_moving.called: + currently_moving.cancel() + self.caller.msg("You stop moving.") + for observer in self.caller.location.contents_get(self.caller): + observer.msg("%s stops." % self.caller.get_display_name(observer)) + else: + self.caller.msg("You are not moving.")
+ + +
[docs]class SlowExitCmdSet(CmdSet): +
[docs] def at_cmdset_creation(self): + self.add(CmdSetSpeed()) + self.add(CmdStop())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/slow_exit/tests.html b/docs/latest/_modules/evennia/contrib/grid/slow_exit/tests.html new file mode 100644 index 0000000000..4a8a8e9976 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/slow_exit/tests.html @@ -0,0 +1,134 @@ + + + + + + + + evennia.contrib.grid.slow_exit.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.slow_exit.tests

+"""
+Slow exit tests.
+
+"""
+
+from mock import Mock, patch
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.utils.create import create_object
+
+from . import slow_exit
+
+slow_exit.MOVE_DELAY = {"stroll": 0, "walk": 0, "run": 0, "sprint": 0}
+
+
+def _cancellable_mockdelay(time, callback, *args, **kwargs):
+    callback(*args, **kwargs)
+    return Mock()
+
+
+
[docs]class TestSlowExit(BaseEvenniaCommandTest): +
[docs] @patch("evennia.utils.delay", _cancellable_mockdelay) + def test_exit(self): + exi = create_object( + slow_exit.SlowExit, key="slowexit", location=self.room1, destination=self.room2 + ) + exi.at_traverse(self.char1, self.room2) + self.call(slow_exit.CmdSetSpeed(), "walk", "You are now walking.") + self.call(slow_exit.CmdStop(), "", "You stop moving.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/wilderness/tests.html b/docs/latest/_modules/evennia/contrib/grid/wilderness/tests.html new file mode 100644 index 0000000000..e8c42054d0 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/wilderness/tests.html @@ -0,0 +1,270 @@ + + + + + + + + evennia.contrib.grid.wilderness.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.wilderness.tests

+"""
+Test wilderness
+
+"""
+
+from evennia import DefaultCharacter
+from evennia.utils.create import create_object
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import wilderness
+
+
+
[docs]class TestWilderness(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.char1 = create_object(DefaultCharacter, key="char1") + self.char2 = create_object(DefaultCharacter, key="char2")
+ +
[docs] def get_wilderness_script(self, name="default"): + w = wilderness.WildernessScript.objects.get("default") + return w
+ +
[docs] def test_create_wilderness_default_name(self): + wilderness.create_wilderness() + w = self.get_wilderness_script() + self.assertIsNotNone(w)
+ +
[docs] def test_create_wilderness_custom_name(self): + name = "customname" + wilderness.create_wilderness(name) + w = self.get_wilderness_script(name) + self.assertIsNotNone(w)
+ +
[docs] def test_enter_wilderness(self): + wilderness.create_wilderness() + wilderness.enter_wilderness(self.char1) + self.assertIsInstance(self.char1.location, wilderness.WildernessRoom) + w = self.get_wilderness_script() + self.assertEqual(w.itemcoordinates[self.char1], (0, 0))
+ +
[docs] def test_enter_wilderness_custom_coordinates(self): + wilderness.create_wilderness() + wilderness.enter_wilderness(self.char1, coordinates=(1, 2)) + self.assertIsInstance(self.char1.location, wilderness.WildernessRoom) + w = self.get_wilderness_script() + self.assertEqual(w.itemcoordinates[self.char1], (1, 2))
+ +
[docs] def test_enter_wilderness_custom_name(self): + name = "customnname" + wilderness.create_wilderness(name) + wilderness.enter_wilderness(self.char1, name=name) + self.assertIsInstance(self.char1.location, wilderness.WildernessRoom)
+ +
[docs] def test_wilderness_correct_exits(self): + wilderness.create_wilderness() + wilderness.enter_wilderness(self.char1) + + # By default we enter at a corner (0, 0), so only a few exits should + # be visible / traversable + exits = [ + i + for i in self.char1.location.contents + if i.destination and (i.access(self.char1, "view") or i.access(self.char1, "traverse")) + ] + + self.assertEqual(len(exits), 3) + exitsok = ["north", "northeast", "east"] + for each_exit in exitsok: + self.assertTrue(any([e for e in exits if e.key == each_exit])) + + # If we move to another location not on an edge, then all directions + # should be visible / traversable + wilderness.enter_wilderness(self.char1, coordinates=(1, 1)) + exits = [ + i + for i in self.char1.location.contents + if i.destination and (i.access(self.char1, "view") or i.access(self.char1, "traverse")) + ] + self.assertEqual(len(exits), 8) + exitsok = [ + "north", + "northeast", + "east", + "southeast", + "south", + "southwest", + "west", + "northwest", + ] + for each_exit in exitsok: + self.assertTrue(any([e for e in exits if e.key == each_exit]))
+ +
[docs] def test_room_creation(self): + # Pretend that both char1 and char2 are connected... + self.char1.sessions.add(1) + self.char2.sessions.add(1) + self.assertTrue(self.char1.has_account) + self.assertTrue(self.char2.has_account) + + wilderness.create_wilderness() + w = self.get_wilderness_script() + + # We should have no unused room after moving the first account in. + self.assertEqual(len(w.db.unused_rooms), 0) + w.move_obj(self.char1, (0, 0)) + self.assertEqual(len(w.db.unused_rooms), 0) + + # And also no unused room after moving the second one in. + w.move_obj(self.char2, (1, 1)) + self.assertEqual(len(w.db.unused_rooms), 0) + + # But if char2 moves into char1's room, we should have one unused room + # Which should be char2's old room that got created. + w.move_obj(self.char2, (0, 0)) + self.assertEqual(len(w.db.unused_rooms), 1) + self.assertEqual(self.char1.location, self.char2.location) + + # And if char2 moves back out, that unused room should be put back to + # use again. + w.move_obj(self.char2, (1, 1)) + self.assertNotEqual(self.char1.location, self.char2.location) + self.assertEqual(len(w.db.unused_rooms), 0)
+ +
[docs] def test_get_new_coordinates(self): + loc = (1, 1) + directions = { + "north": (1, 2), + "northeast": (2, 2), + "east": (2, 1), + "southeast": (2, 0), + "south": (1, 0), + "southwest": (0, 0), + "west": (0, 1), + "northwest": (0, 2), + } + for direction, correct_loc in directions.items(): + new_loc = wilderness.get_new_coordinates(loc, direction) + self.assertEqual(new_loc, correct_loc, direction)
+ +
[docs] def test_preserve_items(self): + wilderness.create_wilderness() + w = self.get_wilderness_script() + + # move char and obj to wilderness + wilderness.enter_wilderness(self.char1) + wilderness.enter_wilderness(self.obj1) + + # move to a new room + w.move_obj(self.char1, (1, 1)) + # the room should be remapped and 0,0 should not exist + self.assertTrue((0, 0) not in w.db.rooms) + self.assertEqual(1, len(w.db.rooms)) + # verify obj1 moved to None + self.assertIsNone(self.obj1.location) + + # now change to preserve items + w.preserve_items = True + wilderness.enter_wilderness(self.obj1, (1, 1)) + # move the character again + w.move_obj(self.char1, (0, 1)) + # check that the previous room was preserved + self.assertIn((1, 1), w.db.rooms) + self.assertEqual(2, len(w.db.rooms)) + # and verify that obj1 is still at 1,1 + self.assertEqual(self.obj1.location, w.db.rooms[(1, 1)])
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/wilderness/wilderness.html b/docs/latest/_modules/evennia/contrib/grid/wilderness/wilderness.html new file mode 100644 index 0000000000..b4cdef8138 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/wilderness/wilderness.html @@ -0,0 +1,918 @@ + + + + + + + + evennia.contrib.grid.wilderness.wilderness — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.wilderness.wilderness

+"""
+Wilderness system
+
+Evennia contrib - titeuf87 2017
+
+This contrib provides a wilderness map. This is an area that can be huge where
+the rooms are mostly similar, except for some small cosmetic changes like the
+room name.
+
+## Usage
+
+This contrib does not provide any new commands. Instead the default `py` command
+is used.
+
+A wilderness map needs to created first. There can be different maps, all
+with their own name. If no name is provided, then a default one is used. Internally,
+the wilderness is stored as a Script with the name you specify. If you don't
+specify the name, a script named "default" will be created and used.
+
+    py from evennia.contrib.grid import wilderness; wilderness.create_wilderness()
+
+Once created, it is possible to move into that wilderness map:
+
+    py from evennia.contrib.grid import wilderness; wilderness.enter_wilderness(me)
+
+All coordinates used by the wilderness map are in the format of `(x, y)`
+tuples. x goes from left to right and y goes from bottom to top. So `(0, 0)`
+is the bottom left corner of the map.
+
+
+## Customisation
+
+The defaults, while useable, are meant to be customised. When creating a
+new wilderness map it is possible to give a "map provider": this is a
+python object that is smart enough to create the map.
+
+The default provider, `WildernessMapProvider`, creates a grid area that
+is unlimited in size.
+
+`WildernessMapProvider` can be subclassed to create more interesting maps
+and also to customize the room/exit typeclass used.
+
+There is also no command that allows players to enter the wilderness. This
+still needs to be added: it can be a command or an exit, depending on your
+needs.
+
+## Example
+
+    To give an example of how to customize, we will create a very simple (and
+    small) wilderness map that is shaped like a pyramid. The map will be
+    provided as a string: a "." symbol is a location we can walk on.
+
+    Let's create a file world/pyramid.py:
+
+```python
+map_str = '''
+     .
+    ...
+   .....
+  .......
+'''
+
+from evennia.contrib.grid import wilderness
+
+class PyramidMapProvider(wilderness.WildernessMapProvider):
+
+    def is_valid_coordinates(self, wilderness, coordinates):
+        "Validates if these coordinates are inside the map"
+        x, y = coordinates
+        try:
+            lines = map_str.split("\n")
+            # The reverse is needed because otherwise the pyramid will be
+            # upside down
+            lines.reverse()
+            line = lines[y]
+            column = line[x]
+            return column == "."
+        except IndexError:
+            return False
+
+    def get_location_name(self, coordinates):
+        "Set the location name"
+        x, y = coordinates
+        if y == 3:
+            return "Atop the pyramid."
+        else:
+            return "Inside a pyramid."
+
+    def at_prepare_room(self, coordinates, caller, room):
+        "Any other changes done to the room before showing it"
+        x, y = coordinates
+        desc = "This is a room in the pyramid."
+        if y == 3 :
+            desc = "You can see far and wide from the top of the pyramid."
+        room.ndb.active_desc = desc
+```
+
+Note that the currently active description is stored as `.ndb.active_desc`. When
+looking at the room, this is what will be pulled and shown.
+
+> Exits on a room are always present, but locks hide those not used for a
+> location. So make sure to `quell` if you are a superuser (since the superuser ignores
+> locks, those exits will otherwise not be hidden)
+
+Now we can use our new pyramid-shaped wilderness map. From inside Evennia we
+create a new wilderness (with the name "default") but using our new map provider:
+
+    py from world import pyramid as p; p.wilderness.create_wilderness(mapprovider=p.PyramidMapProvider())
+    py from evennia.contrib import wilderness; wilderness.enter_wilderness(me, coordinates=(4, 1))
+
+## Implementation details
+
+    When a character moves into the wilderness, they get their own room. If
+    they move, instead of moving the character, the room changes to match the
+    new coordinates.
+
+    If a character meets another character in the wilderness, then their room
+    merges. When one of the character leaves again, they each get their own
+    separate rooms.
+
+    Rooms are created as needed. Unneeded rooms are stored away to avoid the
+    overhead cost of creating new rooms again in the future.
+
+"""
+
+from evennia import (
+    DefaultExit,
+    DefaultRoom,
+    DefaultScript,
+    create_object,
+    create_script,
+)
+from evennia.typeclasses.attributes import AttributeProperty
+from evennia.utils import inherits_from
+
+
+
[docs]def create_wilderness(name="default", mapprovider=None, preserve_items=False): + """ + Creates a new wilderness map. Does nothing if a wilderness map already + exists with the same name. + + Args: + name (str, optional): the name to use for that wilderness map + mapprovider (WildernessMap instance, optional): an instance of a + WildernessMap class (or subclass) that will be used to provide the + layout of this wilderness map. If none is provided, the default + infinite grid map will be used. + + """ + if WildernessScript.objects.filter(db_key=name).exists(): + # Don't create two wildernesses with the same name + return + + if not mapprovider: + mapprovider = WildernessMapProvider() + script = create_script(WildernessScript, key=name) + script.db.mapprovider = mapprovider + if preserve_items: + script.preserve_items = True
+ + +
[docs]def enter_wilderness(obj, coordinates=(0, 0), name="default"): + """ + Moves obj into the wilderness. The wilderness needs to exist first and the + provided coordinates needs to be valid inside that wilderness. + + Args: + obj (object): the object to move into the wilderness + coordinates (tuple), optional): the coordinates to move obj to into + the wilderness. If not provided, defaults (0, 0) + name (str, optional): name of the wilderness map, if not using the + default one + + Returns: + bool: True if obj successfully moved into the wilderness. + """ + script = WildernessScript.objects.filter(db_key=name) + if not script.exists(): + return False + else: + script = script[0] + + if script.is_valid_coordinates(coordinates): + script.move_obj(obj, coordinates) + return True + else: + return False
+ + +
[docs]def get_new_coordinates(coordinates, direction): + """ + Returns the coordinates of direction applied to the provided coordinates. + + Args: + coordinates: tuple of (x, y) + direction: a direction string (like "northeast") + + Returns: + tuple: tuple of (x, y) coordinates + """ + x, y = coordinates + + if direction in ("north", "northwest", "northeast"): + y += 1 + if direction in ("south", "southwest", "southeast"): + y -= 1 + if direction in ("northwest", "west", "southwest"): + x -= 1 + if direction in ("northeast", "east", "southeast"): + x += 1 + + return (x, y)
+ + +
[docs]class WildernessScript(DefaultScript): + """ + This is the main "handler" for the wilderness system: inside here the + coordinates of every item currently inside the wilderness is stored. This + script is responsible for creating rooms as needed and storing rooms away + into storage when they are not needed anymore. + """ + + # Stores the MapProvider class + mapprovider = AttributeProperty() + + # Stores a dictionary of items on the map with their coordinates + # The key is the item, the value are the coordinates as (x, y) tuple. + itemcoordinates = AttributeProperty() + + # Determines whether or not rooms are recycled despite containing non-player objects + # True means that leaving behind a non-player object will prevent the room from being recycled + # in order to preserve the object + preserve_items = AttributeProperty(default=False) + +
[docs] def at_script_creation(self): + """ + Only called once, when the script is created. This is a default Evennia + hook. + """ + self.persistent = True + + # Store the coordinates of every item that is inside the wilderness + # Key: object, Value: (x, y) + self.db.itemcoordinates = {} + + # Store the rooms that are used as views into the wilderness + # Key: (x, y), Value: room object + self.db.rooms = {} + + # Created rooms that are not needed anymore are stored there. This + # allows quick retrieval if a new room is needed without having to + # create it. + self.db.unused_rooms = []
+ +
[docs] def at_server_start(self): + """ + Called after the server is started or reloaded. + """ + for coordinates, room in self.db.rooms.items(): + room.ndb.wildernessscript = self + room.ndb.active_coordinates = coordinates + for item in self.db.itemcoordinates.keys(): + # Items deleted while in the wilderness can leave None-type 'ghosts' + # These need to be cleaned up + if item is None: + del self.db.itemcoordinates[item] + continue + item.ndb.wilderness = self
+ +
[docs] def is_valid_coordinates(self, coordinates): + """ + Returns True if coordinates are valid (and can be travelled to). + Otherwise returns False + + Args: + coordinates (tuple): coordinates as (x, y) tuple + + Returns: + bool: True if the coordinates are valid + """ + return self.mapprovider.is_valid_coordinates(self, coordinates)
+ +
[docs] def get_obj_coordinates(self, obj): + """ + Returns the coordinates of obj in the wilderness. + + Returns (x, y) + + Args: + obj (object): an object inside the wilderness + + Returns: + tuple: (x, y) tuple of where obj is located + """ + return self.itemcoordinates[obj]
+ +
[docs] def get_objs_at_coordinates(self, coordinates): + """ + Returns a list of every object at certain coordinates. + + Imeplementation detail: this uses a naive iteration through every + object inside the wilderness which could cause slow downs when there + are a lot of objects in the map. + + Args: + coordinates (tuple): a coordinate tuple like (x, y) + + Returns: + [Object, ]: list of Objects at coordinates + """ + result = [ + item + for item, item_coords in self.itemcoordinates.items() + if item_coords == coordinates and item is not None + ] + return list(result)
+ +
[docs] def move_obj(self, obj, new_coordinates): + """ + Moves obj to new coordinates in this wilderness. + + Args: + obj (object): the object to move + new_coordinates (tuple): tuple of (x, y) where to move obj to. + """ + # Update the position of this obj in the wilderness + self.itemcoordinates[obj] = new_coordinates + old_room = obj.location + + # Remove the obj's location. This is needed so that the object does not + # appear in its old room should that room be deleted. + obj.location = None + + # By default, we'll assume we won't be making a new room and change this flag if necessary. + create_room = False + + # See if we already have a room for that location + if room := self.db.rooms.get(new_coordinates): + # There is. Try to destroy the old_room if it is not needed anymore + self._destroy_room(old_room) + else: + # There is no room yet at new_location + # Is the old room in a wilderness? + if hasattr(old_room, "wilderness"): + # Yes. Is it in THIS wilderness? + if old_room.wilderness == self: + # Should we preserve rooms with any objects? + if self.preserve_items: + # Yes - check if ANY objects besides the exits are in old_room + if len( + [ + ob + for ob in old_room.contents + if not inherits_from(ob, WildernessExit) + ] + ): + # There is, so we'll create a new room + room = self._create_room(new_coordinates, obj) + else: + # The room is empty, so we'll reuse it + room = old_room + else: + # Only preserve rooms if there are players behind + if len([ob for ob in old_room.contents if ob.has_account]): + # There is still a player there; create a new room + room = self._create_room(new_coordinates, obj) + else: + # The room is empty of players, so we'll reuse it + room = old_room + + # It's in a different wilderness + else: + # It does, so we make sure to leave the other wilderness properly + old_room.wilderness.at_post_object_leave(obj) + # We'll also need to create a new room in this wilderness + room = self._create_room(new_coordinates, obj) + + else: + # Obj comes from outside the wilderness entirely + # We need to make a new room + room = self._create_room(new_coordinates, obj) + + # Set `room` to the new coordinates, however it was made + room.set_active_coordinates(new_coordinates, obj) + + # Put obj back, now in the correct room + obj.location = room + obj.ndb.wilderness = self
+ + def _create_room(self, coordinates, report_to): + """ + Gets a new WildernessRoom to be used for the provided coordinates. + + It first tries to retrieve a room out of storage. If there are no rooms + left a new one will be created. + + Args: + coordinates (tuple): coordinate tuple of (x, y) + report_to (object): the obj to return error messages to + """ + if self.db.unused_rooms: + # There is still unused rooms stored in storage, let's get one of + # those + room = self.db.unused_rooms.pop() + else: + # No more unused rooms...time to make a new one. + + # First, create the room + room = create_object( + typeclass=self.mapprovider.room_typeclass, key="Wilderness", report_to=report_to + ) + + # Then the exits + exits = [ + ("north", "n"), + ("northeast", "ne"), + ("east", "e"), + ("southeast", "se"), + ("south", "s"), + ("southwest", "sw"), + ("west", "w"), + ("northwest", "nw"), + ] + for key, alias in exits: + create_object( + typeclass=self.mapprovider.exit_typeclass, + key=key, + aliases=[alias], + location=room, + destination=room, + report_to=report_to, + ) + + room.ndb.active_coordinates = coordinates + room.ndb.wildernessscript = self + self.db.rooms[coordinates] = room + + return room + + def _destroy_room(self, room): + """ + Moves a room back to storage. If room is not a WildernessRoom or there + is something left inside the room, then this does nothing. + + Implementation note: If `preserve_items` is False (the default) then any + objects left in the rooms will be moved to None. You may want to implement + your own cleanup or recycling routine for these objects. + + Args: + room (WildernessRoom): the room to put in storage + """ + if not room or not inherits_from(room, WildernessRoom): + return + + # Check the contents of the room before recycling + for item in room.contents: + if item.has_account: + # There is still a player in this room, we can't delete it yet. + return + + if not (item.destination and item.destination == room): + # There is still a non-exit object in the room. Should we preserve it? + if self.preserve_items: + # Yes, so we can't get rid of the room just yet + return + + # If we get here, the room can be recycled + # Clear the location of any objects left in that room first + for item in room.contents: + if item.destination and item.destination == room: + # Ignore the exits, they stay in the room + continue + item.location = None + + # Then delete its coordinate reference + del self.db.rooms[room.ndb.active_coordinates] + # And finally put this room away in storage + self.db.unused_rooms.append(room) + +
[docs] def at_post_object_leave(self, obj): + """ + Called after an object left this wilderness map. Used for cleaning up. + + Args: + obj (object): the object that left + """ + # Try removing the object from the coordinates system + if loc := self.db.itemcoordinates.pop(obj, None): + # The object was removed successfully + # Make sure there was a room at that location + if room := self.db.rooms.get(loc): + # If so, try to clean up the room + self._destroy_room(room)
+ + +
[docs]class WildernessRoom(DefaultRoom): + """ + This is a single room inside the wilderness. This room provides a "view" + into the wilderness map. When an account moves around, instead of going to + another room as with traditional rooms, they stay in the same room but the + room itself changes to display another area of the wilderness. + """ + + @property + def wilderness(self): + """ + Shortcut property to the wilderness script this room belongs to. + + Returns: + WildernessScript: the WildernessScript attached to this room + """ + return self.ndb.wildernessscript + + @property + def location_name(self): + """ + Returns the name of the wilderness at this room's coordinates. + + Returns: + name (str) + """ + return self.wilderness.mapprovider.get_location_name(self.coordinates) + + @property + def coordinates(self): + """ + Returns the coordinates of this room into the wilderness. + + Returns: + tuple: (x, y) coordinates of where this room is inside the + wilderness. + """ + return self.ndb.active_coordinates + +
[docs] def at_object_receive(self, moved_obj, source_location): + """ + Called after an object has been moved into this object. This is a + default Evennia hook. + + Args: + moved_obj (Object): The object moved into this one. + source_location (Object): Where `moved_obj` came from. + """ + if isinstance(moved_obj, WildernessExit): + # Ignore exits looping back to themselves: those are the regular + # n, ne, ... exits. + return + + itemcoords = self.wilderness.itemcoordinates + if moved_obj in itemcoords: + # This object was already in the wilderness. We need to make sure + # it goes to the correct room it belongs to. + coordinates = itemcoords[moved_obj] + # Setting the location to None is important here so that we always + # get a "fresh" room if it was in the wrong place + moved_obj.location = None + self.wilderness.move_obj(moved_obj, coordinates) + else: + # This object wasn't in the wilderness yet. Let's add it. + itemcoords[moved_obj] = self.coordinates
+ +
[docs] def at_object_leave(self, moved_obj, target_location, move_type="move", **kwargs): + """ + Called just before an object leaves from inside this object. This is a + default Evennia hook. + + Args: + moved_obj (Object): The object leaving + target_location (Object): Where `moved_obj` is going. + + """ + self.wilderness.at_post_object_leave(moved_obj)
+ +
[docs] def set_active_coordinates(self, new_coordinates, obj): + """ + Changes this room to show the wilderness map from other coordinates. + + Args: + new_coordinates (tuple): coordinates as tuple of (x, y) + obj (Object): the object that moved into this room and caused the + coordinates to change + """ + # Remove any reference for the old coordinates... + rooms = self.wilderness.db.rooms + if self.coordinates: + del rooms[self.coordinates] + # ...and add it for the new coordinates. + self.ndb.active_coordinates = new_coordinates + rooms[self.coordinates] = self + + # Any object inside this room will get its location set to None + # unless it's a wilderness exit + for item in self.contents: + if not item.destination or item.destination != item.location: + item.location = None + # And every obj matching the new coordinates will get its location set + # to this room + for item in self.wilderness.get_objs_at_coordinates(new_coordinates): + item.location = self + + # Fix the lockfuncs for the exit so we can't go where we're not + # supposed to go + for exit in self.exits: + if exit.destination != self: + continue + x, y = get_new_coordinates(new_coordinates, exit.key) + valid = self.wilderness.is_valid_coordinates((x, y)) + + if valid: + exit.locks.add("traverse:true();view:true()") + else: + exit.locks.add("traverse:false();view:false()") + + # Finally call the at_prepare_room hook to give a chance to further + # customise it + self.wilderness.mapprovider.at_prepare_room(new_coordinates, obj, self)
+ +
[docs] def get_display_name(self, looker, **kwargs): + """ + Displays the name of the object in a viewer-aware manner. + This is a core evennia hook. + + Args: + looker (TypedObject): The object or account that is looking + at/getting inforamtion for this object. + + Returns: + name (str): A string containing the name of the object, + including the DBREF if this user is privileged to control + said object and also its coordinates into the wilderness map. + + Notes: + This function could be extended to change how object names + appear to users in character, but be wary. This function + does not change an object's keys or aliases when + searching, and is expected to produce something useful for + builders. + """ + if self.locks.check_lockstring(looker, "perm(Builder)"): + name = "{}(#{})".format(self.location_name, self.id) + else: + name = self.location_name + + name += " {0}".format(self.coordinates) + return name
+ +
[docs] def get_display_desc(self, looker, **kwargs): + """ + Displays the description of the room. This is a core evennia hook. + + Allows the room's description to be customized in an ndb value, + avoiding having to write to the database on moving. + """ + # Check if a new description was prepared by the map provider + if self.ndb.active_desc: + # There is one: use it + return self.ndb.active_desc + + # Otherwise, use the normal description hook. + return super().get_display_desc(looker, **kwargs)
+ + +
[docs]class WildernessExit(DefaultExit): + """ + This is an Exit object used inside a WildernessRoom. Instead of changing + the location of an Object traversing through it (like a traditional exit + would do) it changes the coordinates of that traversing Object inside + the wilderness map. + """ + + @property + def wilderness(self): + """ + Shortcut property to the wilderness script. + + Returns: + WildernessScript: the WildernessScript attached to this exit's room + """ + return self.location.wilderness + + @property + def mapprovider(self): + """ + Shortcut property to the map provider. + + Returns: + MapProvider object: the mapprovider object used with this + wilderness map. + """ + return self.wilderness.mapprovider + +
[docs] def at_traverse_coordinates(self, traversing_object, current_coordinates, new_coordinates): + """ + Called when an object wants to travel from one place inside the + wilderness to another place inside the wilderness. + + If this returns True, then the traversing can happen. Otherwise it will + be blocked. + + This method is similar how the `at_traverse` works on normal exits. + + Args: + traversing_object (Object): The object doing the travelling. + current_coordinates (tuple): (x, y) coordinates where + `traversing_object` currently is. + new_coordinates (tuple): (x, y) coordinates of where + `traversing_object` wants to travel to. + + Returns: + bool: True if traversing_object is allowed to traverse + """ + return True
+ +
[docs] def at_traverse(self, traversing_object, target_location): + """ + This implements the actual traversal. The traverse lock has + already been checked (in the Exit command) at this point. + + Args: + traversing_object (Object): Object traversing us. + target_location (Object): Where target is going. + + Returns: + bool: True if the traverse is allowed to happen + + """ + itemcoordinates = self.location.wilderness.db.itemcoordinates + + current_coordinates = itemcoordinates[traversing_object] + new_coordinates = get_new_coordinates(current_coordinates, self.key) + + if not self.at_traverse_coordinates( + traversing_object, current_coordinates, new_coordinates + ): + return False + + if not traversing_object.at_pre_move(None): + return False + traversing_object.location.msg_contents( + "{} leaves to {}".format(traversing_object.key, new_coordinates), + exclude=[traversing_object], + ) + + self.location.wilderness.move_obj(traversing_object, new_coordinates) + + traversing_object.location.msg_contents( + "{} arrives from {}".format(traversing_object.key, current_coordinates), + exclude=[traversing_object], + ) + + traversing_object.at_post_move(None) + return True
+ + +
[docs]class WildernessMapProvider(object): + """ + Default Wilderness Map provider. + + This is a simple provider that just creates an infinite large grid area. + """ + + room_typeclass = WildernessRoom + exit_typeclass = WildernessExit + +
[docs] def is_valid_coordinates(self, wilderness, coordinates): + """Returns True if coordinates is valid and can be walked to. + + Args: + wilderness: the wilderness script + coordinates (tuple): the coordinates to check as (x, y) tuple. + + Returns: + bool: True if the coordinates are valid + """ + x, y = coordinates + if x < 0: + return False + if y < 0: + return False + + return True
+ +
[docs] def get_location_name(self, coordinates): + """ + Returns a name for the position at coordinates. + + Args: + coordinates (tuple): the coordinates as (x, y) tuple. + + Returns: + name (str) + """ + return "The wilderness"
+ +
[docs] def at_prepare_room(self, coordinates, caller, room): + """ + Called when a room gets activated for certain coordinates. This happens + after every object is moved in it. + This can be used to set a custom room desc for instance or run other + customisations on the room. + + Args: + coordinates (tuple): the coordinates as (x, y) where room is + located at + caller (Object): the object that moved into this room + room (WildernessRoom): the room object that will be used at that + wilderness location + Example: + An example use of this would to plug in a randomizer to show different + descriptions for different coordinates, or place a treasure at a special + coordinate. + """ + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/commands.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/commands.html new file mode 100644 index 0000000000..ef4c2672c8 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/commands.html @@ -0,0 +1,691 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.commands — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.commands

+"""
+
+XYZ-aware commands
+
+Just add the XYZGridCmdSet to the default character cmdset to override
+the commands with XYZ-aware equivalents.
+
+"""
+
+from collections import namedtuple
+
+from django.conf import settings
+
+from evennia import CmdSet, InterruptCommand, default_cmds
+from evennia.commands.default import building
+from evennia.contrib.grid.xyzgrid.xyzgrid import get_xyzgrid
+from evennia.contrib.grid.xyzgrid.xyzroom import XYZRoom
+from evennia.utils import ansi
+from evennia.utils.utils import class_from_module, delay, list_to_string
+
+COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
+
+
+# temporary store of goto/path data when using the auto-stepper
+PathData = namedtuple("PathData", ("target", "xymap", "directions", "step_sequence", "task"))
+
+
+
[docs]class CmdXYZTeleport(building.CmdTeleport): + """ + teleport object to another location + + Usage: + tel/switch [<object> to||=] <target location> + tel/switch [<object> to||=] (X,Y[,Z]) + + Examples: + tel Limbo + tel/quiet box = Limbo + tel/tonone box + tel (3, 3, the small cave) + tel (4, 1) # on the same map + tel/map Z|mapname + + Switches: + quiet - don't echo leave/arrive messages to the source/target + locations for the move. + intoexit - if target is an exit, teleport INTO + the exit object instead of to its destination + tonone - if set, teleport the object to a None-location. If this + switch is set, <target location> is ignored. + Note that the only way to retrieve + an object from a None location is by direct #dbref + reference. A puppeted object cannot be moved to None. + loc - teleport object to the target's location instead of its contents + map - show coordinate map of given Zcoord/mapname. + + Teleports an object somewhere. If no object is given, you yourself are + teleported to the target location. If (X,Y) or (X,Y,Z) coordinates + are given, the target is a location on the XYZGrid. + + """ + + def _search_by_xyz(self, inp): + inp = inp.strip("()") + X, Y, *Z = inp.split(",", 2) + if Z: + # Z was specified + Z = Z[0] + else: + # use current location's Z, if it exists + try: + xyz = self.caller.location.xyz + except AttributeError: + self.caller.msg( + "Z-coordinate is also required since you are not currently " + "in a room with a Z coordinate of its own." + ) + raise InterruptCommand + else: + Z = xyz[2] + # search by coordinate + X, Y, Z = str(X).strip(), str(Y).strip(), str(Z).strip() + try: + self.destination = XYZRoom.objects.get_xyz(xyz=(X, Y, Z)) + except XYZRoom.DoesNotExist: + self.caller.msg(f"Found no target XYZRoom at ({X},{Y},{Z}).") + raise InterruptCommand + +
[docs] def parse(self): + default_cmds.MuxCommand.parse(self) + self.obj_to_teleport = self.caller + self.destination = None + + if self.rhs: + self.obj_to_teleport = self.caller.search(self.lhs, global_search=True) + if not self.obj_to_teleport: + self.caller.msg("Did not find object to teleport.") + raise InterruptCommand + if all(char in self.rhs for char in ("(", ")", ",")): + # search by (X,Y) or (X,Y,Z) + self._search_by_xyz(self.rhs) + else: + # fallback to regular search by name/alias + self.destination = self.caller.search(self.rhs, global_search=True) + + elif self.lhs: + if all(char in self.lhs for char in ("(", ")", ",")): + self._search_by_xyz(self.lhs) + else: + self.destination = self.caller.search(self.lhs, global_search=True)
+ + +
[docs]class CmdXYZOpen(building.CmdOpen): + """ + open a new exit from the current room + + Usage: + open <new exit>[;alias;..][:typeclass] [,<return exit>[;alias;..][:typeclass]]] = <destination> + open <new exit>[;alias;..][:typeclass] [,<return exit>[;alias;..][:typeclass]]] = (X,Y,Z) + + Handles the creation of exits. If a destination is given, the exit + will point there. The destination can also be given as an (X,Y,Z) coordinate on the + XYZGrid - this command is used to link non-grid rooms to the grid and vice-versa. + + The <return exit> argument sets up an exit at the destination leading back to the current room. + Apart from (X,Y,Z) coordinate, destination name can be given both as a #dbref and a name, if + that name is globally unique. + + Examples: + open kitchen = Kitchen + open north, south = Town Center + open cave mouth;cave = (3, 4, the small cave) + + """ + +
[docs] def parse(self): + building.ObjManipCommand.parse(self) + + self.location = self.caller.location + if not self.args or not self.rhs: + self.caller.msg( + "Usage: open <new exit>[;alias...][:typeclass]" + "[,<return exit>[;alias..][:typeclass]]] " + "= <destination or (X,Y,Z)>" + ) + raise InterruptCommand + if not self.location: + self.caller.msg("You cannot create an exit from a None-location.") + raise InterruptCommand + + if all(char in self.rhs for char in ("(", ")", ",")): + # search by (X,Y) or (X,Y,Z) + inp = self.rhs.strip("()") + X, Y, *Z = inp.split(",", 2) + if not Z: + self.caller.msg("A full (X,Y,Z) coordinate must be given for the destination.") + raise InterruptCommand + Z = Z[0] + # search by coordinate + X, Y, Z = str(X).strip(), str(Y).strip(), str(Z).strip() + try: + self.destination = XYZRoom.objects.get_xyz(xyz=(X, Y, Z)) + except XYZRoom.DoesNotExist: + self.caller.msg(f"Found no target XYZRoom at ({X},{Y},{Z}).") + raise InterruptCommand + else: + # regular search query + self.destination = self.caller.search(self.rhs, global_search=True) + if not self.destination: + raise InterruptCommand + + self.exit_name = self.lhs_objs[0]["name"] + self.exit_aliases = self.lhs_objs[0]["aliases"] + self.exit_typeclass = self.lhs_objs[0]["option"]
+ + +
[docs]class CmdGoto(COMMAND_DEFAULT_CLASS): + """ + Go to a named location in this area via the shortest path. + + Usage: + path <location> - find shortest path to target location (don't move) + goto <location> - auto-move to target location, using shortest path + path - show current target location and shortest path + goto - abort current goto, otherwise show current path + path clear - clear current path + + Finds the shortest route to a location in your current area and + can then automatically walk you there. + + Builders can optionally specify a specific grid coordinate (X,Y) to go to. + + """ + + key = "goto" + aliases = "path" + help_category = "General" + locks = "cmd:all()" + + # how quickly to step (seconds) + auto_step_delay = 2 + default_xyz_path_interrupt_msg = "Pathfinding interrupted here." + + def _search_by_xyz(self, inp, xyz_start): + inp = inp.strip("()") + X, Y = inp.split(",", 2) + Z = xyz_start[2] + # search by coordinate + X, Y, Z = str(X).strip(), str(Y).strip(), str(Z).strip() + try: + return XYZRoom.objects.get_xyz(xyz=(X, Y, Z)) + except XYZRoom.DoesNotExist: + self.caller.msg(f"Could not find a room at ({X},{Y}) (Z={Z}).") + return None + + def _search_by_key_and_alias(self, inp, xyz_start): + Z = xyz_start[2] + candidates = list(XYZRoom.objects.filter_xyz(xyz=("*", "*", Z))) + return self.caller.search(inp, candidates=candidates) + + def _auto_step( + self, + caller, + session, + target=None, + xymap=None, + directions=None, + step_sequence=None, + step=True, + ): + path_data = caller.ndb.xy_path_data + + if target: + # start/replace an old path if we provide the data for it + if path_data and path_data.task and path_data.task.active(): + # stop any old task in its tracks + path_data.task.cancel() + path_data = caller.ndb.xy_path_data = PathData( + target=target, + xymap=xymap, + directions=directions, + step_sequence=step_sequence, + task=None, + ) + + if step and path_data: + step_sequence = path_data.step_sequence + + try: + direction = path_data.directions.pop(0) + current_node = path_data.step_sequence.pop(0) + first_link = path_data.step_sequence.pop(0) + except IndexError: + caller.msg("Target reached.", session=session) + caller.ndb.xy_path_data = None + return + + # verfy our current location against the expected location + expected_xyz = (current_node.X, current_node.Y, current_node.Z) + location = caller.location + try: + xyz_start = location.xyz + except AttributeError: + caller.ndb.xy_path_data = None + caller.msg("Goto aborted - outside of area.", session=session) + return + + if xyz_start != expected_xyz: + # we are not where we expected to be (maybe the user moved + # manually) - we must recalculate the path to target + caller.msg("Path changed - recalculating ('goto' to abort)", session=session) + + try: + xyz_end = path_data.target.xyz + except AttributeError: + caller.ndb.xy_path_data = None + caller.msg("Goto aborted - target outside of area.", session=session) + return + + if xyz_start[2] != xyz_end[2]: + # can't go to another map + caller.ndb.xy_path_data = None + caller.msg("Goto aborted - target outside of area.", session=session) + return + + # recalculate path + xy_start = xyz_start[:2] + xy_end = xyz_end[:2] + directions, step_sequence = path_data.xymap.get_shortest_path(xy_start, xy_end) + + # try again with this path, rebuilding the data + try: + direction = directions.pop(0) + current_node = step_sequence.pop(0) + first_link = step_sequence.pop(0) + except IndexError: + caller.msg("Target reached.", session=session) + caller.ndb.xy_path_data = None + return + + path_data = caller.ndb.xy_path_data = PathData( + target=path_data.target, + xymap=path_data.xymap, + directions=directions, + step_sequence=step_sequence, + task=None, + ) + # the map can itself tell the stepper to stop the auto-step prematurely + interrupt_node_or_link = None + + # pop any extra links up until the next node - these are + # not useful when dealing with exits + while step_sequence: + if not interrupt_node_or_link and step_sequence[0].interrupt_path: + interrupt_node_or_link = step_sequence[0] + if hasattr(step_sequence[0], "node_index"): + break + step_sequence.pop(0) + + # the exit name does not need to be the same as the cardinal direction! + exit_name, *_ = first_link.spawn_aliases.get( + direction, current_node.direction_spawn_defaults.get(direction, ("unknown",)) + ) + + exit_obj = caller.search(exit_name) + if not exit_obj: + # extra safety measure to avoid trying to walk over and over + # if there's something wrong with the exit's name + caller.msg(f"No exit '{exit_name}' found at current location. Aborting goto.") + caller.ndb.xy_path_data = None + return + + if interrupt_node_or_link: + # premature stop of pathfind-step because of map node/link of interrupt type + if hasattr(interrupt_node_or_link, "node_index"): + message = exit_obj.destination.attributes.get( + "xyz_path_interrupt_msg", default=self.default_xyz_path_interrupt_msg + ) + # we move into the node/room and then stop + caller.execute_cmd(exit_name, session=session) + else: + # if the link is interrupted we don't cross it at all + message = exit_obj.attributes.get( + "xyz_path_interrupt_msg", default=self.default_xyz_path_interrupt_msg + ) + caller.msg(message) + return + + # do the actual move - we use the command to allow for more obvious overrides + caller.execute_cmd(exit_name, session=session) + + # namedtuples are unmutables, so we recreate and store + # with the new task + caller.ndb.xy_path_data = PathData( + target=path_data.target, + xymap=path_data.xymap, + directions=path_data.directions, + step_sequence=path_data.step_sequence, + task=delay(self.auto_step_delay, self._auto_step, caller, session), + ) + +
[docs] def func(self): + """ + Implement command + """ + + caller = self.caller + goto_mode = self.cmdname == "goto" + + # check if we have an existing path + path_data = caller.ndb.xy_path_data + + if not self.args: + if path_data: + target_name = path_data.target.get_display_name(caller) + task = path_data.task + if goto_mode: + if task and task.active(): + task.cancel() + caller.msg(f"Aborted auto-walking to {target_name}.") + return + # goto/path-command will show current path + current_path = list_to_string([f"|w{step}|n" for step in path_data.directions]) + moving = "(moving)" if task and task.active() else "" + caller.msg(f"Path to {target_name}{moving}: {current_path}") + else: + caller.msg("Usage: goto|path [<location>]") + return + + if not goto_mode and self.args == "clear" and path_data: + # in case there is a target location 'clear', this is only + # used if path data already exists. + caller.ndb.xy_path_data = None + caller.msg("Cleared goto-path.") + return + + # find target + xyzgrid = get_xyzgrid() + try: + xyz_start = caller.location.xyz + except AttributeError: + self.caller.msg("Cannot path-find since the current location is not on the grid.") + return + + allow_xyz_query = caller.locks.check_lockstring(caller, "perm(Builder)") + if allow_xyz_query and all(char in self.args for char in ("(", ")", ",")): + # search by (X,Y) + target = self._search_by_xyz(self.args, xyz_start) + if not target: + return + else: + # search by normal key/alias + target = self._search_by_key_and_alias(self.args, xyz_start) + if not target: + return + try: + xyz_end = target.xyz + except AttributeError: + self.caller.msg("Target location is not on the grid and cannot be auto-walked to.") + return + + xymap = xyzgrid.get_map(xyz_start[2]) + # we only need the xy coords once we have the map + xy_start = xyz_start[:2] + xy_end = xyz_end[:2] + directions, step_sequence = xymap.get_shortest_path(xy_start, xy_end) + + caller.msg( + f"There are {len(directions)} steps to {target.get_display_name(caller)}: " + f"|w{list_to_string(directions, endsep='|n, and finally|w')}|n" + ) + + # create data for display and start stepping if we used goto + self._auto_step( + caller, + self.session, + target=target, + xymap=xymap, + directions=directions, + step_sequence=step_sequence, + step=goto_mode, + )
+ + +
[docs]class CmdMap(COMMAND_DEFAULT_CLASS): + """ + Show a map of an area + + Usage: + map [Zcoord] + map list + + This is a builder-command. + + """ + + key = "map" + locks = "cmd:perm(Builders)" + +
[docs] def func(self): + """Implement command""" + + xyzgrid = get_xyzgrid() + Z = None + + if not self.args: + # show current area's map + location = self.caller.location + try: + xyz = location.xyz + except AttributeError: + self.caller.msg("Your current location is not on the grid.") + return + Z = xyz[2] + + elif self.args.strip().lower() == "list": + xymaps = "\n ".join(str(repr(xymap)) for xymap in xyzgrid.all_maps()) + self.caller.msg(f"Maps (Z coords) on the grid:\n |w{xymaps}") + return + + else: + Z = self.args + + xymap = xyzgrid.get_map(Z) + if not xymap: + self.caller.msg( + f"XYMap '{Z}' is not found on the grid. Try 'map list' to see " + "available maps/Zcoords." + ) + return + + self.caller.msg(ansi.raw(xymap.mapstring))
+ + +
[docs]class XYZGridCmdSet(CmdSet): + """ + Cmdset for easily adding the above cmds to the character cmdset. + + """ + + key = "xyzgrid_cmdset" + +
[docs] def at_cmdset_creation(self): + self.add(CmdXYZTeleport()) + self.add(CmdXYZOpen()) + self.add(CmdGoto()) + self.add(CmdMap())
+ + +# Optional fly/dive commands to move between maps (enable +# full 3D-grid movements) + + +
[docs]class CmdFlyAndDive(COMMAND_DEFAULT_CLASS): + """ + Fly or Dive up and down. + + Usage: + fly + dive + + Will fly up one room or dive down one room at your current position. If + there is no room above/below you, your movement will fail. + + """ + + key = "fly or dive" + aliases = ("fly", "dive") + +
[docs] def func(self): + caller = self.caller + + action = self.cmdname + + try: + xyz_start = caller.location.xyz + except AttributeError: + caller.msg(f"You cannot {action} here.") + return + try: + zcoord = int(xyz_start[2]) + except ValueError: + caller.msg(f"You cannot {action} here.") + return + + if action == "fly": + diff = 1 + direction = "upwards" + from_direction = "below" + error_message = "Can't fly here - you'd hit your head." + elif action == "dive": + diff = -1 + direction = "downwards" + from_direction = "above" + error_message = "Can't dive here - you'd just fall flat on the ground." + else: + caller.msg("You must decide if you want to |wfly|n up or |wdive|n down.") + return + + target_coord = (str(xyz_start[0]), str(xyz_start[1]), zcoord + diff) + try: + target = XYZRoom.objects.get_xyz(xyz=(target_coord)) + except XYZRoom.DoesNotExist: + # no available room above/below to fly/dive to + caller.msg(error_message) + return + # action succeeds, we have a target. One could picture being able to + # lock certain rooms from flight/dive, here we allow it as long as there + # is a suitable room above/below. + caller.location.msg_contents(f"$You() {action} {direction}.", from_obj=caller) + caller.move_to(target, quiet=True) + target.msg_contents( + f"$You() {action} from {from_direction}.", from_obj=caller, exclude=[caller] + )
+ + +
[docs]class XYZGridFlyDiveCmdSet(CmdSet): + """ + Optional cmdset if you want the fly/dive commands to move in a 3D environment. + + """ + + key = "xyzgrid_flydive_cmdset" + +
[docs] def at_cmdset_creation(self): + self.add(CmdFlyAndDive())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/example.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/example.html new file mode 100644 index 0000000000..4883785460 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/example.html @@ -0,0 +1,371 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.example — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.example

+"""
+Example xymaps to use with the XYZgrid contrib. Build outside of the game using
+the `evennia xyzgrid` launcher command.
+
+First add the launcher extension in your mygame/server/conf/settings.py:
+
+    EXTRA_LAUNCHER_COMMANDS['xyzgrid'] = 'evennia.contrib.grid.xyzgrid.launchcmd.xyzcommand'
+
+Then
+
+    evennia xyzgrid init
+    evennia xyzgrid add evennia.contrib.grid.xyzgrid.map_example
+    evennia xyzgrid build
+
+
+
+
+"""
+
+from evennia.contrib.grid.xyzgrid import xymap_legend
+
+# default prototype parent. It's important that
+# the typeclass inherits from the XYZRoom (or XYZExit)
+# if adding the evennia.contrib.grid.xyzgrid.prototypes to
+# settings.PROTOTYPE_MODULES, one could just set the
+# prototype_parent to 'xyz_room' and 'xyz_exit' here
+# instead.
+
+ROOM_PARENT = {
+    "key": "An empty room",
+    "prototype_key": "xyz_exit_prototype",
+    # "prototype_parent": "xyz_room",
+    "typeclass": "evennia.contrib.grid.xyzgrid.xyzroom.XYZRoom",
+    "desc": "An empty room.",
+}
+
+EXIT_PARENT = {
+    "prototype_key": "xyz_exit_prototype",
+    # "prototype_parent": "xyz_exit",
+    "typeclass": "evennia.contrib.grid.xyzgrid.xyzroom.XYZExit",
+    "desc": "A path to the next location.",
+}
+
+
+# ---------------------------------------- map1
+# The large tree
+#
+# this exemplifies the various map symbols
+# but is not heavily prototyped
+
+MAP1 = r"""
+                       1
+ + 0 1 2 3 4 5 6 7 8 9 0
+
+ 8   #-------#-#-------I
+      \               /
+ 7     #-#---#     t-#
+       |\    |
+ 6   #i#-#b--#-t
+       |     |
+ 5     o-#---#
+          \ /
+ 4     o---#-#
+      /    d
+ 3   #-----+-------#
+           |       d
+ 2         |       |
+           v       u
+ 1         #---#>#-#
+          /
+ 0       #-T
+
+ + 0 1 2 3 4 5 6 7 8 9 0
+                       1
+"""
+
+
+
[docs]class TransitionToCave(xymap_legend.TransitionMapNode): + """ + A transition from 'the large tree' to 'the small cave' map. This node is never spawned + into a room but only acts as a target for finding the exit's destination. + + """ + + symbol = "T" + target_map_xyz = (1, 0, "the small cave")
+ + +# extends the default legend +LEGEND_MAP1 = {"T": TransitionToCave} + + +# link coordinates to rooms +PROTOTYPES_MAP1 = { + # node/room prototypes + (3, 0): { + "key": "Dungeon Entrance", + "desc": "To the east, a narrow opening leads into darkness.", + }, + (4, 1): { + "key": "Under the foilage of a giant tree", + "desc": "High above the branches of a giant tree blocks out the sunlight. A slide " + "leading down from the upper branches ends here.", + }, + (4, 4): { + "key": "The slide", + "desc": "A slide leads down to the ground from here. It looks like a one-way trip.", + }, + (6, 1): { + "key": "Thorny path", + "desc": "To the east is a pathway of thorns. If you get through, you don't think you'll be " + "able to get back here the same way.", + }, + (8, 1): {"key": "By a large tree", "desc": "You are standing at the root of a great tree."}, + (8, 3): {"key": "At the top of the tree", "desc": "You are at the top of the tree."}, + (3, 7): { + "key": "Dense foilage", + "desc": "The foilage to the east is extra dense. It will take forever to get through it.", + }, + (5, 6): { + "key": "On a huge branch", + "desc": "To the east is a glowing light, may be a teleporter to a higher branch.", + }, + (9, 7): { + "key": "On an enormous branch", + "desc": "To the west is a glowing light. It may be a teleporter to a lower branch.", + }, + (10, 8): { + "key": "A gorgeous view", + "desc": "The view from here is breathtaking, showing the forest stretching far and wide.", + }, + # default rooms + ("*", "*"): { + "key": "Among the branches of a giant tree", + "desc": "These branches are wide enough to easily walk on. There's green all around.", + }, + # directional prototypes + (3, 0, "e"): {"desc": "A dark passage into the underworld."}, +} + +for key, prot in PROTOTYPES_MAP1.items(): + if len(key) == 2: + # we don't want to give exits the room typeclass! + prot["prototype_parent"] = ROOM_PARENT + else: + prot["prototype_parent"] = EXIT_PARENT + + +XYMAP_DATA_MAP1 = { + "zcoord": "the large tree", + "map": MAP1, + "legend": LEGEND_MAP1, + "prototypes": PROTOTYPES_MAP1, +} + +# -------------------------------------- map2 +# The small cave +# this gives prototypes for every room + +MAP2 = r""" ++ 0 1 2 3 + +3 #-#-# + |x| +2 #-#-# + | \ +1 #---# + | / +0 T-#-# + ++ 0 1 2 3 + +""" + + +# custom map node +
[docs]class TransitionToLargeTree(xymap_legend.TransitionMapNode): + """ + A transition from 'the small cave' to 'the large tree' map. This node is never spawned + into a room by only acts as a target for finding the exit's destination. + + """ + + symbol = "T" + target_map_xyz = (3, 0, "the large tree")
+ + +# this extends the default legend (that defines #,-+ etc) +LEGEND_MAP2 = {"T": TransitionToLargeTree} + +# prototypes for specific locations +PROTOTYPES_MAP2 = { + # node/rooms prototype overrides + (1, 0): { + "key": "The entrance", + "desc": "This is the entrance to a small cave leading into the ground. " + "Light sifts in from the outside, while cavernous passages disappear " + "into darkness.", + }, + (2, 0): { + "key": "A gruesome sight.", + "desc": "Something was killed here recently. The smell is unbearable.", + }, + (1, 1): { + "key": "A dark pathway", + "desc": "The path splits three ways here. To the north a faint light can be seen.", + }, + (3, 2): { + "key": "Stagnant water", + "desc": "A pool of stagnant, black water dominates this small chamber. To the nortwest " + "a faint light can be seen.", + }, + (0, 2): {"key": "A dark alcove", "desc": "This alcove is empty."}, + (1, 2): { + "key": "South-west corner of the atrium", + "desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout " + "between the stones.", + }, + (2, 2): { + "key": "South-east corner of the atrium", + "desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout " + "between the stones.", + }, + (1, 3): { + "key": "North-west corner of the atrium", + "desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout " + "between the stones.", + }, + (2, 3): { + "key": "North-east corner of the atrium", + "desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout " + "between the stones. To the east is a dark passage.", + }, + (3, 3): { + "key": "Craggy crevice", + "desc": "This is the deepest part of the dungeon. The path shrinks away and there " + "is no way to continue deeper.", + }, + # default fallback for undefined nodes + ("*", "*"): {"key": "A dark room", "desc": "A dark, but empty, room."}, + # directional prototypes + (1, 0, "w"): {"desc": "A narrow path to the fresh air of the outside world."}, + # directional fallbacks for unset directions + ("*", "*", "*"): {"desc": "A dark passage"}, +} + +# this is required by the prototypes, but we add it all at once so we don't +# need to add it to every line above +for key, prot in PROTOTYPES_MAP2.items(): + if len(key) == 2: + # we don't want to give exits the room typeclass! + prot["prototype_parent"] = ROOM_PARENT + else: + prot["prototype_parent"] = EXIT_PARENT + + +XYMAP_DATA_MAP2 = { + "map": MAP2, + "zcoord": "the small cave", + "legend": LEGEND_MAP2, + "prototypes": PROTOTYPES_MAP2, + "options": {"map_visual_range": 1, "map_mode": "scan"}, +} + +# This is read by the parser +XYMAP_DATA_LIST = [XYMAP_DATA_MAP1, XYMAP_DATA_MAP2] +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/launchcmd.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/launchcmd.html new file mode 100644 index 0000000000..7fb023d645 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/launchcmd.html @@ -0,0 +1,555 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.launchcmd — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.launchcmd

+"""
+Custom Evennia launcher command option for maintaining the grid in a separate process than the main
+server (since this can be slow).
+
+To use, add to the settings:
+::
+
+    EXTRA_LAUNCHER_COMMANDS.update({'xyzgrid': 'evennia.contrib.grid.xyzgrid.launchcmd.xyzcommand'})
+
+You should now be able to do
+::
+
+    evennia xyzgrid <options>
+
+Use `evennia xyzgrid help` for usage help.
+
+"""
+
+from os.path import join as pathjoin
+
+import evennia
+from django.conf import settings
+from evennia.contrib.grid.xyzgrid.xyzgrid import get_xyzgrid
+from evennia.utils import ansi
+
+_HELP_SHORT = """
+evennia xyzgrid help | list | init | add | spawn | initpath | delete [<options>]
+ Manages the XYZ grid. Use 'xyzgrid help <option>' for documentation.
+"""
+
+_HELP_HELP = """
+evennia xyzgrid <command> [<options>]
+Manages the XYZ grid.
+
+help <command>   - get help about each command:
+    list            - show list
+    init            - initialize grid (only one time)
+    add             - add new maps to grid
+    spawn           - spawn added maps into actual db-rooms/exits
+    initpath        - (re)creates pathfinder matrices
+    delete          - delete part or all of grid
+"""
+
+_HELP_LIST = """
+list
+
+    Lists the map grid structure and any loaded maps.
+
+list <Z|mapname>
+
+    Display the given XYmap in more detail. Also 'show' works. Use quotes around
+    map-names with spaces.
+
+Examples:
+
+    evennia xyzgrid list
+    evennia xyzgrid list mymap
+    evennia xyzgrid list "the small cave"
+"""
+
+_HELP_INIT = """
+init
+
+    First start of the grid. This will create the XYZGrid global script. No maps are loaded yet!
+    It's safe to run this command multiple times; the grid will only be initialized once.
+
+Example:
+
+    evennia xyzgrid init
+"""
+
+
+_HELP_ADD = """
+add <path.to.xymap.module> [<path> <path>,...]
+
+    Add path(s) to one or more modules containing XYMap definitions. The module will be parsed
+    for
+
+    - a XYMAP_DATA - a dict on this form:
+        {"map": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}
+        describing one single XYmap, or
+    - a XYMAP_DATA_LIST - a list of multiple dicts on the XYMAP_DATA form. This allows for
+        embedding multiple maps in the same module. See evennia/contrib/grid/xyzgrid/example.py
+        for an example of how this looks.
+
+    Note that adding a map does *not* spawn it. If maps are linked to one another, you should
+    add all linked maps before running 'spawn', or you'll get errors when creating transitional
+    exits between maps.
+
+Examples:
+
+    evennia xyzgrid add evennia.contrib.grid.xyzgrid.example
+    evennia xyzgrid add world.mymap1 world.mymap2 world.mymap3
+"""
+
+_HELP_SPAWN = """
+spawn
+
+    spawns/updates the entire database grid based on the added maps. For a new grid, this will
+    spawn all new rooms/exits (and may take a good while!). For updating, rooms may be
+    removed/spawned if a map changed since the last spawn.
+
+spawn "(X,Y,Z|mapname)"
+
+    spawns/updates only a part of the grid. Remember the quotes around the coordinate (this
+    is mostly because shells don't like them)! Use '*' as a wild card for XY coordinates.
+    This should usually only be used if the full grid has already been built once - otherwise
+    inter-map transitions may fail! Z is the name/z-coordinate of the map to spawn.
+
+Examples:
+
+    evennia xyzgrid spawn                  - spawn all
+    evennia xyzgrid "(*, *, mymap1)"       - spawn everything of map/zcoord mymap1
+    evennia xyzgrid "(12, 5, mymap1)"      - spawn only coordinate (12, 5) on map/zcoord mymap1
+"""
+
+_HELP_INITPATH = """
+initpath
+
+    Recreates the pathfinder matrices for the entire grid. These are used for all shortest-path
+    calculations. The result will be cached to disk (in mygame/server/.cache/). If not run, each
+    map will run this automatically first time it's used. Running this will always force to
+    respawn the cache.
+
+initpath Z|mapname
+
+    recreate the pathfinder matrix for a specific map only. Z is the name/z-coordinate of the
+    map. If the map name has spaces in it, use quotes.
+
+Examples:
+
+    evennia xyzgrid initpath
+    evennia xyzgrid initpath mymap1
+    evennia xyzgrid initpath "the small cave"
+"""
+
+_HELP_DELETE = """
+delete
+
+    WARNING: This will delete the entire xyz-grid (all maps), and *all* rooms/exits built to
+    match it (they serve no purpose without the grid). You will be asked to confirm before
+    continuing with this operation.
+
+delete Z|mapname
+
+    Remove a previously added XYmap with the name/z-coordinate Z. If the map was built, this
+    will also wipe all its spawned rooms/exits. You will be asked to confirm before continuing
+    with this operation. Use quotes if the Z/mapname contains spaces.
+
+Examples:
+
+    evennia xyzgrid delete
+    evennia xyzgrid delete mymap1
+    evennia xyzgrid delete "the small cave"
+"""
+
+_TOPICS_MAP = {
+    "list": _HELP_LIST,
+    "init": _HELP_INIT,
+    "add": _HELP_ADD,
+    "spawn": _HELP_SPAWN,
+    "initpath": _HELP_INITPATH,
+    "delete": _HELP_DELETE,
+}
+
+evennia._init()
+
+
+def _option_help(*suboptions):
+    """
+    Show help <command> aid.
+
+    """
+    if not suboptions:
+        topic = _HELP_HELP
+    else:
+        topic = _TOPICS_MAP.get(suboptions[0], _HELP_HELP)
+    print(topic.strip())
+
+
+def _option_list(*suboptions):
+    """
+    List/view grid.
+
+    """
+
+    xyzgrid = get_xyzgrid()
+
+    # override grid's logger to echo directly to console
+    def _log(msg):
+        print(msg)
+
+    xyzgrid.log = _log
+
+    xymap_data = xyzgrid.grid
+    if not xymap_data:
+        if xyzgrid.db.map_data:
+            print("Grid could not load due to errors.")
+        else:
+            print("The XYZgrid is currently empty. Use 'add' to add paths to your map data.")
+        return
+
+    if not suboptions:
+        print("XYMaps stored in grid:")
+        for zcoord, xymap in sorted(xymap_data.items(), key=lambda tup: tup[0]):
+            print("\n" + str(repr(xymap)) + ":\n")
+            print(ansi.parse_ansi(str(xymap)))
+        return
+
+    zcoord = " ".join(suboptions)
+    xymap = xyzgrid.get_map(zcoord)
+    if not xymap:
+        print(f"No XYMap with Z='{zcoord}' was found on grid.")
+    else:
+        nrooms = xyzgrid.get_room(("*", "*", zcoord)).count()
+        nnodes = len(xymap.node_index_map)
+        print("\n" + str(repr(xymap)) + ":\n")
+        checkwarning = True
+        if not nrooms:
+            print(f"{nrooms} / {nnodes} rooms are spawned.")
+            checkwarning = False
+        elif nrooms < nnodes:
+            print(
+                f"{nrooms} / {nnodes} rooms are spawned\n"
+                "Note: Transitional nodes are *not* spawned (they just point \n"
+                "to another map), so the 'missing room(s)' may just be from such nodes."
+            )
+        elif nrooms > nnodes:
+            print(
+                f"{nrooms} / {nnodes} rooms are spawned\n"
+                "Note: Maybe some rooms were removed from map. Run 'spawn' to re-sync."
+            )
+        else:
+            print(f"{nrooms} / {nnodes} rooms are spawned\n")
+
+        if checkwarning:
+            print(
+                "Note: This check is not complete; it does not consider changed map "
+                "topology\nlike relocated nodes/rooms and new/removed links/exits - this "
+                "is calculated only during a spawn."
+            )
+        print("\nDisplayed map (as appearing in-game):\n\n" + ansi.parse_ansi(str(xymap)))
+        print(
+            "\nRaw map string (including axes and invisible nodes/links):\n" + str(xymap.mapstring)
+        )
+        print(f"\nCustom map options: {xymap.options}\n")
+        legend = []
+        for key, node_or_link in xymap.legend.items():
+            legend.append(f"{key} - {node_or_link.__doc__.strip()}")
+        print("Legend (all elements may not be present on map):\n " + "\n ".join(legend))
+
+
+def _option_init(*suboptions):
+    """
+    Initialize a new grid. Will fail if a Grid already exists.
+
+    """
+    grid = get_xyzgrid()
+    print(f"The grid is initalized as the Script '{grid.key}'({grid.dbref})")
+
+
+def _option_add(*suboptions):
+    """
+    Add one or more map to the grid. Supports `add path,path,path,...`
+
+    """
+    grid = get_xyzgrid()
+
+    # override grid's logger to echo directly to console
+    def _log(msg):
+        print(msg)
+
+    grid.log = _log
+
+    xymap_data_list = []
+    for path in suboptions:
+        maps = grid.maps_from_module(path)
+        if not maps:
+            print(f"No maps found with the path {path}.\nSeparate multiple paths with spaces. ")
+            return
+        mapnames = "\n ".join(f"'{m['zcoord']}'" for m in maps)
+        print(f" XYMaps from {path}:\n {mapnames}")
+        xymap_data_list.extend(maps)
+    grid.add_maps(*xymap_data_list)
+    try:
+        grid.reload()
+    except Exception as err:
+        print(err)
+    else:
+        print(f"Added (or readded) {len(xymap_data_list)} XYMaps to grid.")
+
+
+def _option_spawn(*suboptions):
+    """
+    spawn the grid or part of it.
+
+    """
+    grid = get_xyzgrid()
+
+    # override grid's logger to echo directly to console
+    def _log(msg):
+        print(msg)
+
+    grid.log = _log
+
+    if suboptions:
+        opts = "".join(suboptions).strip("()")
+        # coordinate tuple
+        try:
+            x, y, z = (part.strip() for part in opts.split(","))
+        except ValueError:
+            print(
+                "spawn coordinate must be given as (X, Y, Z) tuple, where '*' act "
+                "wild cards and Z is the mapname/z-coord of the map to load."
+            )
+            return
+    else:
+        x, y, z = "*", "*", "*"
+
+    if x == y == z == "*":
+        inp = input(
+            "This will (re)spawn the entire grid. If it was built before, it may spawn \n"
+            "new rooms or delete rooms that no longer matches the grid.\nDo you want to "
+            "continue? [Y]/N? "
+        )
+    else:
+        inp = input(
+            "This will spawn/delete objects in the database matching grid coordinates \n"
+            f"({x},{y},{z}) (where '*' is a wildcard).\nDo you want to continue? [Y]/N? "
+        )
+    if inp.lower() in ("no", "n"):
+        print("Aborted.")
+        return
+
+    print("Starting spawn ...")
+    grid.spawn(xyz=(x, y, z))
+    print(
+        "... spawn complete!\nIt's recommended to reload the server to refresh caches if this "
+        "modified an existing grid."
+    )
+
+
+def _option_initpath(*suboptions):
+    """
+    (Re)Initialize the pathfinding matrices for grid or part of it.
+
+    """
+    grid = get_xyzgrid()
+
+    # override grid's logger to echo directly to console
+    def _log(msg):
+        print(msg)
+
+    grid.log = _log
+
+    xymaps = grid.all_maps()
+    nmaps = len(xymaps)
+    for inum, xymap in enumerate(xymaps):
+        print(f"(Re)building pathfinding matrix for xymap Z={xymap.Z} ({inum+1}/{nmaps}) ...")
+        xymap.calculate_path_matrix(force=True)
+
+    cachepath = pathjoin(settings.GAME_DIR, "server", ".cache")
+    print(f"... done. Data cached to {cachepath}.")
+
+
+def _option_delete(*suboptions):
+    """
+    Delete the grid or parts of it. Allows mapname,mapname, ...
+
+    """
+
+    grid = get_xyzgrid()
+
+    # override grid's logger to echo directly to console
+    def _log(msg):
+        print(msg)
+
+    grid.log = _log
+
+    if not suboptions:
+        repl = input(
+            "WARNING: This will delete the ENTIRE Grid and wipe all rooms/exits!"
+            "\nObjects/Chars inside deleted rooms will be moved to their home locations."
+            "\nThis can't be undone. Are you sure you want to continue? Y/[N]? "
+        )
+        if repl.lower() not in ("yes", "y"):
+            print("Aborted.")
+            return
+        print("Deleting grid ...")
+        grid.delete()
+        print(
+            "... done.\nPlease reload the server now; otherwise removed rooms may linger in cache."
+        )
+        return
+
+    zcoords = (part.strip() for part in suboptions)
+    err = False
+    for zcoord in zcoords:
+        if not grid.get_map(zcoord):
+            print(f"Mapname/zcoord {zcoord} is not a part of the grid.")
+            err = True
+    if err:
+        print("Valid mapnames/zcoords are\n:", "\n ".join(xymap.Z for xymap in grid.all_maps()))
+        return
+    repl = input(
+        "This will delete map(s) {', '.join(zcoords)} and wipe all corresponding\n"
+        "rooms/exits!"
+        "\nObjects/Chars inside deleted rooms will be moved to their home locations."
+        "\nThis can't be undone. Are you sure you want to continue? Y/[N]? "
+    )
+    if repl.lower() not in ("yes", "y"):
+        print("Aborted.")
+        return
+
+    print("Deleting selected xymaps ...")
+    grid.remove_map(*zcoords, remove_objects=True)
+    print(
+        "... done.\nPlease reload the server to refresh room caches."
+        "\nAlso remember to remove any links from remaining maps pointing to deleted maps."
+    )
+
+
+
[docs]def xyzcommand(*args): + """ + Evennia launcher command. This is made available as `evennia xyzgrid` on the command line, + once added to `settings.EXTRA_LAUNCHER_COMMANDS`. + + """ + if not args: + print(_HELP_SHORT.strip()) + return + + option, *suboptions = args + + if option in ("help", "h"): + _option_help(*suboptions) + elif option in ("list", "show"): + _option_list(*suboptions) + elif option == "init": + _option_init(*suboptions) + elif option == "add": + _option_add(*suboptions) + elif option == "spawn": + _option_spawn(*suboptions) + elif option == "initpath": + _option_initpath(*suboptions) + elif option == "delete": + _option_delete(*suboptions) + else: + print(f"Unknown option '{option}'. Use 'evennia xyzgrid help' for valid arguments.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/tests.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/tests.html new file mode 100644 index 0000000000..9a9defc82c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/tests.html @@ -0,0 +1,1708 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.tests

+"""
+
+Tests for the XYZgrid system.
+
+"""
+from random import randint
+from unittest import mock
+
+from django.test import TestCase
+from evennia.utils.test_resources import BaseEvenniaCommandTest, BaseEvenniaTest
+from parameterized import parameterized
+
+from . import commands, xymap, xymap_legend, xyzgrid, xyzroom
+
+MAP1 = """
+
+ + 0 1 2
+
+ 1 #-#
+   | |
+ 0 #-#
+
+ + 0 1 2
+
+"""
+
+MAP1_DISPLAY = """
+#-#
+| |
+#-#
+""".strip()
+
+
+MAP2 = """
+
+ + 0 1 2 3 4 5
+
+ 5 #-#-#-#-#
+       |   |
+ 4     #---#
+       |   |
+ 3 #   |   #-#
+   |   |     |
+ 2 #-#-#-#---#
+   |   |
+ 1 #-#-#---#-#
+     |     |
+ 0   #-#-#-#
+
+ + 0 1 2 3 4 5
+
+"""
+
+MAP2_DISPLAY = """
+#-#-#-#-#
+    |   |
+    #---#
+    |   |
+#   |   #-#
+|   |     |
+#-#-#-#---#
+|   |
+#-#-#---#-#
+  |     |
+  #-#-#-#
+""".strip()
+
+MAP3 = r"""
+
+   + 0 1 2 3 4 5
+
+   5 #-#---#   #
+       |  / \ /
+   4   # /   #
+       |/    |
+   3   #     #
+       |\   / \
+   2   # #-#   #
+       |/   \ /
+   1   #     #
+      / \    |
+   0 #   #---#-#
+
+   + 0 1 2 3 4 5
+
+"""
+
+MAP3_DISPLAY = r"""
+#-#---#   #
+  |  / \ /
+  # /   #
+  |/    |
+  #     #
+  |\   / \
+  # #-#   #
+  |/   \ /
+  #     #
+ / \    |
+#   #---#-#
+""".strip()
+
+MAP4 = r"""
+
+ + 0 1 2 3 4
+
+ 4 #-# #---#
+      x   /
+ 3   #-#-#
+     |x x|
+ 2 #-#-#-#
+   | |   |
+ 1 #-+-#-+-#
+     |   |
+ 0   #---#
+
+ + 0 1 2 3 4
+
+"""
+
+MAP4_DISPLAY = r"""
+#-# #---#
+   x   /
+  #-#-#
+  |x x|
+#-#-#-#
+| |   |
+#-+-#-+-#
+  |   |
+  #---#
+""".strip()
+
+MAP5 = r"""
+
++ 0 1 2
+
+2 #-#
+  | |
+1 #>#
+
+0 #>#
+
++ 0 1 2
+
+"""
+
+MAP5_DISPLAY = r"""
+#-#
+| |
+#>#
+
+#>#
+""".strip()
+
+MAP6 = r"""
+
+ + 0 1 2 3 4
+
+ 4 #-#-#-#
+     ^   |
+ 3   |   #>#
+     |   | |
+ 2   #-#-#-#
+     ^   v
+ 1   #---#-#
+     |   | |
+ 0 #-#>#-#<#
+
+ + 0 1 2 3 4
+
+"""
+
+MAP6_DISPLAY = r"""
+#-#-#-#
+  ^   |
+  |   #>#
+  |   | |
+  #-#-#-#
+  ^   v
+  #---#-#
+  |   | |
+#-#>#-#<#
+""".strip()
+
+
+MAP7 = r"""
++ 0 1 2
+
+2 #-#
+    |
+1 #-o-#
+    |
+0   #-#
+
++ 0 1 2
+
+"""
+
+MAP7_DISPLAY = r"""
+#-#
+  |
+#-o-#
+  |
+  #-#
+""".strip()
+
+
+MAP8 = r"""
++ 0 1 2 3 4 5
+
+4 #-#-o o o-o
+  |  \|/| | |
+3 #-o-o-# o-#
+  |  /|\    |
+2 o-o-#-#   o
+    | |    /
+1   #-o-#-o-#
+      |  /
+0 #---#-o
+
++ 0 1 2 3 4 5
+
+"""
+
+MAP8_DISPLAY = r"""
+#-#-o o o-o
+|  \|/| | |
+#-o-o-# o-#
+|  /|\    |
+o-o-#-#   o
+  | |    /
+  #-o-#-o-#
+    |  /
+#---#-o
+""".strip()
+
+
+MAP9 = r"""
++ 0 1 2 3
+
+3 #-#-#-#
+    d d d
+2   | | |
+    u u u
+1 #-# #-#
+  u   d
+0 #d# #u#
+
++ 0 1 2 3
+
+"""
+
+MAP9_DISPLAY = r"""
+#-#-#-#
+  d d d
+  | | |
+  u u u
+#-# #-#
+u   d
+#d# #u#
+""".strip()
+
+
+MAP10 = r"""
+
+ + 0 1 2 3
+
+ 4 #---#-#
+      b  |
+ 3 #i#---#
+   |/|
+ 2 # #-I-#
+     |
+ 1 #-#b#-#
+   | |   b
+ 0 #b#-#-#
+
+ + 0 1 2 3
+
+"""
+
+# note that I,i,b are invisible
+MAP10_DISPLAY = r"""
+#---#-#
+   /  |
+#-#---#
+|/|
+# #-#-#
+  |
+#-#-#-#
+| |   |
+#-#-#-#
+""".strip()
+
+
+MAP11 = r"""
+
++ 0 1 2 3
+
+2 #-#
+   \
+1   t t
+       \
+0     #-#
+
++ 0 1 2 3
+
+"""
+
+
+MAP11_DISPLAY = r"""
+#-#
+ \
+
+     \
+    #-#
+""".strip()
+
+MAP12a = r"""
+
++ 0 1
+
+1 #-T
+  |
+0 #-#
+
++ 0 1
+
+"""
+
+
+MAP12b = r"""
+
++ 0 1
+
+1 #-#
+    |
+0 T-#
+
++ 0 1
+
+"""
+
+MAP13a = r"""
+
++ 0 1
+
+1 #-#
+  |
+0 #
+
++ 0 1
+
+"""
+
+MAP13b = r"""
+
++ 0 1
+
+1   #
+
+0
+
++ 0 1
+
+"""
+
+MAP13c = r"""
+
++ 0 1
+
+1   #
+
+0
+
++ 0 1
+
+"""
+
+MAP13d = r"""
+
++ 0 1
+
+1 #-#
+  |
+0 #
+
++ 0 1
+
+"""
+
+
+class _MapTest(BaseEvenniaTest):
+    """
+    Parent for map tests
+
+    """
+
+    map_data = {"map": MAP1, "zcoord": "map1"}
+    map_display = MAP1_DISPLAY
+
+    def setUp(self):
+        """Set up grid and map"""
+        super().setUp()
+        self.grid, err = xyzgrid.XYZGrid.create("testgrid")
+        self.grid.add_maps(self.map_data)
+        self.map = self.grid.get_map(self.map_data["zcoord"])
+
+        # output to console
+        # def _log(msg):
+        #     print(msg)
+        # self.grid.log = _log
+
+    def tearDown(self):
+        self.grid.delete()
+        xyzroom.XYZRoom.objects.all().delete()
+        xyzroom.XYZExit.objects.all().delete()
+
+
+
[docs]class TestMap1(_MapTest): + """ + Test the Map class with a simple 4-node map + + """ + +
[docs] def test_str_output(self): + """Check the display_map""" + self.assertEqual(str(self.map).replace("||", "|").strip(), MAP1_DISPLAY)
+ +
[docs] def test_node_from_coord(self): + node = self.map.get_node_from_coord((1, 1)) + self.assertEqual(node.X, 1) + self.assertEqual(node.x, 2) + self.assertEqual(node.X, 1) + self.assertEqual(node.y, 2)
+ +
[docs] def test_get_shortest_path(self): + directions, path = self.map.get_shortest_path((0, 0), (1, 1)) + self.assertEqual(directions, ["e", "n"]) + self.assertEqual( + [str(node) for node in path], + [ + str(self.map.node_index_map[0]), + "<LinkNode '-' XY=(0.5,0)>", + str(self.map.node_index_map[1]), + "<LinkNode '|' XY=(1,0.5)>", + str(self.map.node_index_map[3]), + ], + )
+ + @parameterized.expand( + [ + ((0, 0), "| \n#-", [["|", " "], ["#", "-"]]), + ((1, 0), " |\n-#", [[" ", "|"], ["-", "#"]]), + ((0, 1), "#-\n| ", [["#", "-"], ["|", " "]]), + ((1, 1), "-#\n |", [["-", "#"], [" ", "|"]]), + ] + ) + def test_get_visual_range__scan(self, coord, expectstr, expectlst): + """ + Test displaying a part of the map around a central point. + + """ + mapstr = self.map.get_visual_range(coord, dist=1, mode="scan", character=None) + maplst = self.map.get_visual_range( + coord, dist=1, mode="scan", return_str=False, character=None + ) + maplst = [[part.replace("||", "|") for part in partlst] for partlst in maplst] + self.assertEqual(expectstr, mapstr.replace("||", "|")) + self.assertEqual(expectlst, maplst[::-1]) + + @parameterized.expand( + [ + ((0, 0), "| \n@-", [["|", " "], ["@", "-"]]), + ((1, 0), " |\n-@", [[" ", "|"], ["-", "@"]]), + ((0, 1), "@-\n| ", [["@", "-"], ["|", " "]]), + ((1, 1), "-@\n |", [["-", "@"], [" ", "|"]]), + ] + ) + def test_get_visual_range__scan__character(self, coord, expectstr, expectlst): + """ + Test displaying a part of the map around a central point, showing the + character @-symbol in that spot. + + """ + mapstr = self.map.get_visual_range(coord, dist=1, mode="scan", character="@") + maplst = self.map.get_visual_range( + coord, dist=1, mode="scan", return_str=False, character="@" + ) + maplst = [[part.replace("||", "|") for part in partlst] for partlst in maplst] + self.assertEqual(expectstr, mapstr.replace("||", "|")) + self.assertEqual(expectlst, maplst[::-1]) # flip y-axis for print + + @parameterized.expand( + [ + ((0, 0), 1, "# \n| \n@-#"), + ((0, 1), 1, "@-#\n| \n# "), + ((1, 0), 1, " #\n |\n#-@"), + ((1, 1), 1, "#-@\n |\n #"), + ((0, 0), 2, "#-#\n| |\n@-#"), + ] + ) + def test_get_visual_range__nodes__character(self, coord, dist, expected): + """ + Get sub-part of map with node-mode. + + """ + mapstr = self.map.get_visual_range(coord, dist=dist, mode="nodes", character="@") + self.assertEqual(expected, mapstr.replace("||", "|")) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 4) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 8)
+ + +
[docs]class TestMap2(_MapTest): + """ + Test with Map2 - a bigger map with multi-step links + + """ + + map_data = {"map": MAP2, "zcoord": "map2"} + map_display = MAP2_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + # strip the leftover spaces on the right to better + # work with text editor stripping this automatically ... + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(stripped_map.replace("||", "|"), MAP2_DISPLAY)
+ +
[docs] def test_node_from_coord(self): + for mapnode in self.map.node_index_map.values(): + node = self.map.get_node_from_coord((mapnode.X, mapnode.Y)) + self.assertEqual(node, mapnode) + self.assertEqual(node.x // 2, node.X) + self.assertEqual(node.y // 2, node.Y)
+ + @parameterized.expand( + [ + ((1, 0), (4, 0), ("e", "e", "e")), # straight path + ((1, 0), (5, 1), ("n", "e", "e", "e")), # shortcut over long link + ((2, 2), (2, 5), ("n", "n")), # shortcut over long link (vertical) + ((4, 4), (0, 5), ("w", "n", "w", "w")), # shortcut over long link (vertical) + ((4, 0), (0, 5), ("n", "w", "n", "n", "n", "w", "w")), # across entire grid + ((4, 0), (0, 5), ("n", "w", "n", "n", "n", "w", "w")), # across entire grid + ((5, 3), (0, 3), ("s", "w", "w", "w", "w", "n")), # down and back + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + Test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + + @parameterized.expand( + [ + ((1, 0), "#-#-#-#\n| | \n#-#-#--\n | \n @-#-#"), + ( + (2, 2), + ( + " #---#\n | |\n# | #\n| | \n#-#-@-#--\n| " + "| \n#-#-#---#\n | |\n #-#-#-#" + ), + ), + ((4, 5), "#-#-@ \n| | \n#---# \n| | \n| #-#"), + ((5, 2), "--# \n | \n #-#\n |\n#---@\n \n--#-#\n | \n#-# "), + ] + ) + def test_get_visual_range__scan__character(self, coord, expected): + """ + Test showing smaller part of grid, showing @-character in the middle. + + """ + mapstr = self.map.get_visual_range(coord, dist=4, mode="scan", character="@") + self.assertEqual(expected, mapstr.replace("||", "|")) + +
[docs] def test_extended_path_tracking__horizontal(self): + """ + Crossing multi-gridpoint links should be tracked properly. + + """ + node = self.map.get_node_from_coord((4, 1)) + self.assertEqual( + { + direction: [step.symbol for step in steps] + for direction, steps in node.xy_steps_to_node.items() + }, + {"e": ["-"], "s": ["|"], "w": ["-", "-", "-"]}, + )
+ +
[docs] def test_extended_path_tracking__vertical(self): + """ + Testing multi-gridpoint links in the vertical direction. + + """ + node = self.map.get_node_from_coord((2, 2)) + self.assertEqual( + { + direction: [step.symbol for step in steps] + for direction, steps in node.xy_steps_to_node.items() + }, + {"n": ["|", "|", "|"], "e": ["-"], "s": ["|"], "w": ["-"]}, + )
+ + @parameterized.expand( + [ + ((0, 0), 2, None, "@"), # outside of any known node + ((4, 5), 0, None, "@"), # 0 distance + ((1, 0), 2, None, "#-#-# \n | \n @-#-#"), + ((0, 5), 1, None, "@-#"), + ( + (0, 5), + 4, + None, + "@-#-#-#-#\n | \n #---#\n | \n | \n | \n # ", + ), + ((5, 1), 3, None, " # \n | \n#-#---#-@\n | \n #-# "), + ( + (2, 2), + 2, + None, + ( + " # \n | \n #---# \n | \n | \n | \n" + "#-#-@-#---#\n | \n #-#---# " + ), + ), + ((2, 2), 2, (5, 5), " | \n | \n#-@-#\n | \n#-#--"), # limit display size + ((2, 2), 4, (3, 3), " | \n-@-\n | "), + ((2, 2), 4, (1, 1), "@"), + ] + ) + def test_get_visual_range__nodes__character(self, coord, dist, max_size, expected): + """ + Get sub-part of map with node-mode. + + """ + mapstr = self.map.get_visual_range( + coord, dist=dist, mode="nodes", character="@", max_size=max_size + ) + self.assertEqual(expected, mapstr.replace("||", "|")) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 24) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 54)
+ + +
[docs]class TestMap3(_MapTest): + """ + Test Map3 - Map with diagonal links + + """ + + map_data = {"map": MAP3, "zcoord": "map3"} + map_display = MAP3_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP3_DISPLAY, stripped_map.replace("||", "|"))
+ + @parameterized.expand( + [ + ((0, 0), (1, 0), ()), # no node at (1, 0)! + ((2, 0), (5, 0), ("e", "e")), # straight path + ((0, 0), (1, 1), ("ne",)), + ((4, 1), (4, 3), ("nw", "ne")), + ((4, 1), (4, 3), ("nw", "ne")), + ((2, 2), (3, 5), ("nw", "ne")), + ((2, 2), (1, 5), ("nw", "n", "n")), + ((5, 5), (0, 0), ("sw", "s", "sw", "w", "sw", "sw")), + ((5, 5), (0, 0), ("sw", "s", "sw", "w", "sw", "sw")), + ((5, 2), (1, 2), ("sw", "nw", "w", "nw", "s")), + ((4, 1), (1, 1), ("s", "w", "nw")), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + Test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + + @parameterized.expand( + [ + ( + (2, 2), + 2, + None, + ( + " # \n / \n # / \n |/ \n # #\n |\\ / \n # @-# \n" + " |/ \\ \n # #\n / \\ \n# # " + ), + ), + ((5, 2), 2, None, " # \n | \n # \n / \\ \n# @\n \\ / \n # \n | \n # "), + ] + ) + def test_get_visual_range__nodes__character(self, coord, dist, max_size, expected): + """ + Get sub-part of map with node-mode. + + """ + mapstr = self.map.get_visual_range( + coord, dist=dist, mode="nodes", character="@", max_size=max_size + ) + self.assertEqual(expected, mapstr.replace("||", "|")) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 18) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 44)
+ + +
[docs]class TestMap4(_MapTest): + """ + Test Map4 - Map with + and x crossing links + + """ + + map_data = {"map": MAP4, "zcoord": "map4"} + map_display = MAP4_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP4_DISPLAY, stripped_map.replace("||", "|"))
+ + @parameterized.expand( + [ + ((1, 0), (1, 2), ("n",)), # cross + vertically + ((0, 1), (2, 1), ("e",)), # cross + horizontally + ((4, 1), (1, 0), ("w", "w", "n", "e", "s")), + ((1, 2), (2, 3), ("ne",)), # cross x + ((1, 2), (2, 3), ("ne",)), + ((2, 2), (0, 4), ("w", "ne", "nw", "w")), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + Test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 16) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 44)
+ + +
[docs]class TestMap5(_MapTest): + """ + Test Map5 - Small map with one-way links + + """ + + map_data = {"map": MAP5, "zcoord": "map5"} + map_display = MAP5_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP5_DISPLAY, stripped_map.replace("||", "|"))
+ + @parameterized.expand( + [ + ((0, 0), (1, 0), ("e",)), # cross one-way + ((1, 0), (0, 0), ()), # blocked + ((0, 1), (1, 1), ("e",)), # should still take shortest + ((1, 1), (0, 1), ("n", "w", "s")), # take long way around + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + Test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 6) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 8)
+ + +
[docs]class TestMap6(_MapTest): + """ + Test Map6 - Bigger map with one-way links in different directions + + """ + + map_data = {"map": MAP6, "zcoord": "map6"} + map_display = MAP6_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP6_DISPLAY, stripped_map.replace("||", "|"))
+ + @parameterized.expand( + [ + ((0, 0), (2, 0), ("e", "e")), # cross one-way + ((2, 0), (0, 0), ("e", "n", "w", "s", "w")), # blocked, long way around + ((4, 0), (3, 0), ("w",)), + ((3, 0), (4, 0), ("n", "e", "s")), + ((1, 1), (1, 2), ("n",)), + ((1, 2), (1, 1), ("e", "e", "s", "w")), + ((3, 1), (1, 4), ("w", "n", "n")), + ((0, 4), (0, 0), ("e", "e", "e", "s", "s", "s", "w", "s", "w")), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + Test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 18) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 38)
+ + +
[docs]class TestMap7(_MapTest): + """ + Test Map7 - Small test of dynamic link node + + """ + + map_data = {"map": MAP7, "zcoord": "map7"} + map_display = MAP7_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP7_DISPLAY, stripped_map.replace("||", "|"))
+ + @parameterized.expand( + [ + ((1, 0), (1, 2), ("n",)), + ((1, 2), (1, 0), ("s",)), + ((0, 1), (2, 1), ("e",)), + ((2, 1), (0, 1), ("w",)), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 6) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 8)
+ + +
[docs]class TestMap8(_MapTest): + """ + Test Map8 - Small test of dynamic link node + + """ + + map_data = {"map": MAP8, "zcoord": "map8"} + map_display = MAP8_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP8_DISPLAY, stripped_map.replace("||", "|"))
+ + @parameterized.expand( + [ + ((2, 0), (2, 2), ("n",)), + ((0, 0), (5, 3), ("e", "e")), + ((5, 1), (0, 3), ("w", "w", "n", "w")), + ((1, 1), (2, 2), ("n", "w", "s")), + ((5, 3), (5, 3), ()), + ((5, 3), (0, 4), ("s", "n", "w", "n")), + ((1, 4), (3, 3), ("e", "w", "e")), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + + @parameterized.expand( + [ + ( + (2, 2), + 1, + None, + " #-o \n | \n# o \n| | \no-o-@-#\n | \n o \n | \n # ", + ), + ] + ) + def test_get_visual_range__nodes__character(self, coord, dist, max_size, expected): + """ + Get sub-part of map with node-mode. + + """ + mapstr = self.map.get_visual_range( + coord, dist=dist, mode="nodes", character="@", max_size=max_size + ) + self.assertEqual(expected, mapstr.replace("||", "|")) + + @parameterized.expand( + [ + ( + (2, 2), + (3, 2), + 1, + None, + " #-o \n | \n# o \n| | \no-o-@..\n | \n o \n | \n # ", + ), + ( + (2, 2), + (5, 3), + 1, + None, + " #-o \n | \n# o \n| | \no-o-@-#\n . \n . \n . \n ...", + ), + ( + (2, 2), + (5, 3), + 2, + None, + ( + "#-#-o \n| \\| \n#-o-o-# .\n| |\\ .\no-o-@-" + "# .\n . . \n . . \n . . \n#---... " + ), + ), + ((5, 3), (2, 2), 2, (13, 7), " o-o\n | |\n o-@\n .\n. .\n. . "), + ( + (5, 3), + (1, 1), + 2, + None, + ( + " o-o\n | |\n o-@\n. .\n..... " + ".\n . . \n . . \n . . \n#---... " + ), + ), + ] + ) + def test_get_visual_range_with_path(self, coord, target, dist, max_size, expected): + """ + Get visual range with a path-to-target marked. + + """ + mapstr = self.map.get_visual_range( + coord, + dist=dist, + mode="nodes", + target=target, + target_path_style=".", + character="@", + max_size=max_size, + ) + self.assertEqual(expected, mapstr.replace("||", "|")) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 12) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 28)
+ + +
[docs]class TestMap9(_MapTest): + """ + Test Map9 - a map with up/down links. + + """ + + map_data = {"map": MAP9, "zcoord": "map9"} + map_display = MAP9_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP9_DISPLAY, stripped_map.replace("||", "|"))
+ + @parameterized.expand( + [ + ((0, 0), (0, 1), ("u",)), + ((0, 0), (1, 0), ("d",)), + ((1, 0), (2, 1), ("d", "u", "e", "u", "e", "d")), + ((2, 1), (0, 1), ("u", "w", "d", "w")), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 12) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 24)
+ + +
[docs]class TestMap10(_MapTest): + """ + Test Map10 - a map with blocked- and interrupt links/nodes. These are + 'invisible' nodes and won't show up in the map display. + + """ + + map_data = {"map": MAP10, "zcoord": "map10"} + map_display = MAP10_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP10_DISPLAY, stripped_map.replace("||", "|"))
+ + # interrupts are only relevant to the auto-stepper + @parameterized.expand( + [ + ((0, 0), (1, 0), ("n", "e", "s")), + ((3, 0), (3, 1), ()), # the blockage hinders this + ((1, 3), (0, 4), ("e", "n", "w", "w")), + ((0, 1), (3, 2), ("e", "n", "e", "e")), + ((0, 1), (0, 3), ("e", "n", "n", "w")), + ((1, 3), (0, 3), ("w",)), + ((3, 2), (2, 2), ("w",)), + ((3, 2), (1, 2), ("w", "w")), + ((3, 3), (0, 3), ("w", "w")), + ((2, 2), (3, 2), ("e",)), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + + @parameterized.expand( + [ + ((2, 2), (3, 2), ("e",), ((2, 2), (2.5, 2), (3, 2))), + ( + (3, 3), + (0, 3), + ("w", "w"), + ((3, 3), (2.5, 3.0), (2.0, 3.0), (1.5, 3.0), (1, 3), (0.5, 3), (0, 3)), + ), + ] + ) + def test_paths(self, startcoord, endcoord, expected_directions, expected_path): + """ + Test path locations. + + """ + directions, path = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + strpositions = [(step.X, step.Y) for step in path] + self.assertEqual(expected_path, tuple(strpositions)) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 18) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 42)
+ + +
[docs]class TestMap11(_MapTest): + """ + Test Map11 - a map teleporter links. + + """ + + map_data = {"map": MAP11, "zcoord": "map11"} + map_display = MAP11_DISPLAY + +
[docs] def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split("\n")) + self.assertEqual(MAP11_DISPLAY, stripped_map.replace("||", "|"))
+ + @parameterized.expand( + [ + ((2, 0), (1, 2), ("e", "nw", "e")), + ((1, 2), (2, 0), ("w", "se", "w")), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + + @parameterized.expand( + [ + ( + (3, 0), + (0, 2), + ("nw",), + ((3, 0), (2.5, 0.5), (2.0, 1.0), (1.0, 1.0), (0.5, 1.5), (0, 2)), + ), + ( + (0, 2), + (3, 0), + ("se",), + ((0, 2), (0.5, 1.5), (1.0, 1.0), (2.0, 1.0), (2.5, 0.5), (3, 0)), + ), + ] + ) + def test_paths(self, startcoord, endcoord, expected_directions, expected_path): + """ + Test path locations. + + """ + directions, path = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + strpositions = [(step.X, step.Y) for step in path] + self.assertEqual(expected_path, tuple(strpositions)) + + @parameterized.expand( + [ + ((2, 0), (1, 2), 3, None, "... \n . \n . . \n . \n @.."), + ((1, 2), (2, 0), 3, None, "..@ \n . \n . . \n . \n ..."), + ] + ) + def test_get_visual_range_with_path(self, coord, target, dist, max_size, expected): + """ + Get visual range with a path-to-target marked. + + """ + mapstr = self.map.get_visual_range( + coord, + dist=dist, + mode="nodes", + target=target, + target_path_style=".", + character="@", + max_size=max_size, + ) + + self.assertEqual(expected, mapstr) + +
[docs] def test_spawn(self): + """ + Spawn the map into actual objects. + + """ + self.grid.spawn() + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 4) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 6)
+ + +
[docs]class TestMapStressTest(TestCase): + """ + Performance test of map patfinder and visualizer. + + #-#-#-#-#.... + |x|x|x|x| + #-#-#-#-# + |x|x|x|x| + #-#-#-#-# + |x|x|x|x| + #-#-#-#-# + ... + + This should be a good stress-testing scenario because most each internal node has a maxiumum + number of connections and options to consider. + + """ + + def _get_grid(self, Xsize, Ysize): + edge = f"+ {' ' * Xsize * 2}" + l1 = f"\n {'#-' * Xsize}#" + l2 = f"\n {'|x' * Xsize}|" + + return f"{edge}\n{(l1 + l2) * Ysize}{l1}\n\n{edge}" + + @parameterized.expand( + [ + ((10, 10), 0.03), + ((100, 100), 5), + ] + ) + def test_grid_creation(self, gridsize, max_time): + """ + Test of grid-creataion performance for Nx, Ny grid. + + """ + # import cProfile + Xmax, Ymax = gridsize + grid = self._get_grid(Xmax, Ymax) + mapobj = xymap.XYMap({"map": grid}, Z="testmap") + # t0 = time() + mapobj.parse() + # cProfile.runctx('mapobj.parse()', globals(), locals()) + # t1 = time() + # if (t1 - t0 > max_time): + # print(f"Map creation of ({Xmax}x{Ymax}) grid slower " + # f"than expected {max_time}s.") + + @parameterized.expand( + [ + ((10, 10), 10**-3), + ((20, 20), 10**-3), + ] + ) + def test_grid_pathfind(self, gridsize, max_time): + """ + Test pathfinding performance for Nx, Ny grid. + + """ + Xmax, Ymax = gridsize + grid = self._get_grid(Xmax, Ymax) + mapobj = xymap.XYMap({"map": grid}, Z="testmap") + mapobj.parse() + + # t0 = time() + mapobj.calculate_path_matrix() + # t1 = time() + # print(f"pathfinder matrix for grid {Xmax}x{Ymax}: {t1 - t0}s") + + # get the maximum distance and 9 other random points in the grid + start_end_points = [((0, 0), (Xmax - 1, Ymax - 1))] + for _ in range(9): + start_end_points.append( + ((randint(0, Xmax), randint(0, Ymax)), (randint(0, Xmax), randint(0, Ymax))) + ) + + # t0 = time() + for startcoord, endcoord in start_end_points: + mapobj.get_shortest_path(startcoord, endcoord) + # t1 = time() + # if (t1 - t0) / 10 > max_time: + # print(f"Pathfinding for ({Xmax}x{Ymax}) grid slower " + # f"than expected {max_time}s.") + + @parameterized.expand( + [ + ((10, 10), 4, 0.01), + ((20, 20), 4, 0.01), + ] + ) + def test_grid_visibility(self, gridsize, dist, max_time): + """ + Test grid visualization performance for Nx, Ny grid for + different visibility distances. + + """ + Xmax, Ymax = gridsize + grid = self._get_grid(Xmax, Ymax) + mapobj = xymap.XYMap({"map": grid}, Z="testmap") + mapobj.parse() + + # t0 = time() + mapobj.calculate_path_matrix() + # t1 = time() + # print(f"pathfinder matrix for grid {Xmax}x{Ymax}: {t1 - t0}s") + + # get random center points in grid and a range of targets to visualize the + # path to + start_end_points = [((0, 0), (Xmax - 1, Ymax - 1))] # include max distance + for _ in range(9): + start_end_points.append( + ((randint(0, Xmax), randint(0, Ymax)), (randint(0, Xmax), randint(0, Ymax))) + ) + + # t0 = time() + for coord, target in start_end_points: + mapobj.get_visual_range(coord, dist=dist, mode="nodes", character="@", target=target)
+ # t1 = time() + # if (t1 - t0) / 10 > max_time: + # print(f"Visual Range calculation for ({Xmax}x{Ymax}) grid " + # f"slower than expected {max_time}s.") + + +
[docs]class TestXYZGrid(BaseEvenniaTest): + """ + Test base grid class with a single map, including spawning objects. + + """ + + zcoord = "map1" + +
[docs] def setUp(self): + self.grid, err = xyzgrid.XYZGrid.create("testgrid") + + self.map_data1 = {"map": MAP1, "zcoord": self.zcoord} + + self.grid.add_maps(self.map_data1)
+ +
[docs] def tearDown(self): + self.grid.delete()
+ +
[docs] def test_str_output(self): + """Check the display_map""" + xymap = self.grid.get_map(self.zcoord) + stripped_map = "\n".join(line.rstrip() for line in str(xymap).split("\n")) + self.assertEqual(MAP1_DISPLAY, stripped_map.replace("||", "|"))
+ +
[docs] def test_spawn(self): + """Spawn objects for the grid""" + self.grid.spawn() + # import sys + # sys.stderr.write("\nrooms: " + repr(xyzroom.XYZRoom.objects.all())) + # sys.stderr.write("\n\nexits: " + repr(xyzroom.XYZExit.objects.all()) + "\n") + + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 4) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 8)
+ + +# map transitions +
[docs]class Map12aTransition(xymap_legend.TransitionMapNode): + symbol = "T" + target_map_xyz = (1, 0, "map12b")
+ + +
[docs]class Map12bTransition(xymap_legend.TransitionMapNode): + symbol = "T" + target_map_xyz = (0, 1, "map12a")
+ + +
[docs]class TestXYZGridTransition(BaseEvenniaTest): + """ + Test the XYZGrid class and transitions between maps. + + """ + +
[docs] def setUp(self): + super().setUp() + self.grid, err = xyzgrid.XYZGrid.create("testgrid") + + self.map_data12a = {"map": MAP12a, "zcoord": "map12a", "legend": {"T": Map12aTransition}} + self.map_data12b = {"map": MAP12b, "zcoord": "map12b", "legend": {"T": Map12bTransition}} + + self.grid.add_maps(self.map_data12a, self.map_data12b)
+ +
[docs] def tearDown(self): + self.grid.delete()
+ + @parameterized.expand( + [ + ((1, 0), (1, 1), ("w", "n", "e")), + ((1, 1), (1, 0), ("w", "s", "e")), + ] + ) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + test shortest-path calculations throughout the grid. + + """ + directions, _ = self.grid.get_map("map12a").get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + +
[docs] def test_spawn(self): + """ + Spawn the two maps into actual objects. + + """ + self.grid.spawn() + + self.assertEqual(xyzroom.XYZRoom.objects.all().count(), 6) + self.assertEqual(xyzroom.XYZExit.objects.all().count(), 10) + + room1 = xyzroom.XYZRoom.objects.get_xyz(xyz=(0, 1, "map12a")) + room2 = xyzroom.XYZRoom.objects.get_xyz(xyz=(1, 0, "map12b")) + east_exit = [exi for exi in room1.exits if exi.db_key == "east"][0] + west_exit = [exi for exi in room2.exits if exi.db_key == "west"][0] + + # make sure exits traverse the maps + self.assertEqual(east_exit.db_destination, room2) + self.assertEqual(west_exit.db_destination, room1)
+ + +
[docs]class TestBuildExampleGrid(BaseEvenniaTest): + """ + Test building the map-example (this takes about 30s) + + """ + +
[docs] def setUp(self): + # build and populate grid + super().setUp() + self.grid, err = xyzgrid.XYZGrid.create("testgrid")
+ + # def _log(msg): + # print(msg) + # self.grid.log = _log + +
[docs] def tearDown(self): + self.grid.delete()
+ +
[docs] def test_build(self): + """ + Build the map example. + + """ + mapdatas = self.grid.maps_from_module("evennia.contrib.grid.xyzgrid.example") + self.assertEqual(len(mapdatas), 2) + + self.grid.add_maps(*mapdatas) + self.grid.spawn() + + # testing + room1a = xyzroom.XYZRoom.objects.get_xyz(xyz=(3, 0, "the large tree")) + room1b = xyzroom.XYZRoom.objects.get_xyz(xyz=(10, 8, "the large tree")) + room2a = xyzroom.XYZRoom.objects.get_xyz(xyz=(1, 0, "the small cave")) + room2b = xyzroom.XYZRoom.objects.get_xyz(xyz=(1, 3, "the small cave")) + + self.assertEqual(room1a.key, "Dungeon Entrance") + self.assertTrue(room1a.db.desc.startswith("To the east")) + self.assertEqual(room1b.key, "A gorgeous view") + self.assertTrue(room1b.db.desc.startswith("The view from here is breathtaking,")) + self.assertEqual(room2a.key, "The entrance") + self.assertTrue(room2a.db.desc.startswith("This is the entrance to")) + self.assertEqual(room2b.key, "North-west corner of the atrium") + self.assertTrue(room2b.db.desc.startswith("Sunlight sifts down"))
+ + +mock_room_callbacks = mock.MagicMock() +mock_exit_callbacks = mock.MagicMock() + + +
[docs]class TestXyzRoom(xyzroom.XYZRoom): +
[docs] def at_object_creation(self): + mock_room_callbacks.at_object_creation()
+ + +
[docs]class TestXyzExit(xyzroom.XYZExit): +
[docs] def at_object_creation(self): + mock_exit_callbacks.at_object_creation()
+ + +MAP_DATA = { + "map": """ + + + 0 1 + + 0 #-# + + + 0 1 + + """, + "zcoord": "map1", + "prototypes": { + ("*", "*"): { + "key": "room", + "desc": "A room.", + "prototype_parent": "xyz_room", + }, + ("*", "*", "*"): { + "desc": "A passage.", + "prototype_parent": "xyz_exit", + }, + }, + "options": { + "map_visual_range": 1, + "map_mode": "scan", + }, +} + + +
[docs]class TestCallbacks(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + mock_room_callbacks.reset_mock() + mock_exit_callbacks.reset_mock()
+ +
[docs] def setup_grid(self, map_data): + self.grid, err = xyzgrid.XYZGrid.create("testgrid") + + def _log(msg): + print(msg) + + self.grid.log = _log + + self.map_data = map_data + self.grid.add_maps(map_data)
+ +
[docs] def tearDown(self): + super().tearDown() + self.grid.delete()
+ +
[docs] def test_typeclassed_xyzroom_and_xyzexit_with_at_object_creation_are_called(self): + map_data = dict(MAP_DATA) + for prototype_key, prototype_value in map_data["prototypes"].items(): + if len(prototype_key) == 2: + prototype_value["typeclass"] = "evennia.contrib.grid.xyzgrid.tests.TestXyzRoom" + if len(prototype_key) == 3: + prototype_value["typeclass"] = "evennia.contrib.grid.xyzgrid.tests.TestXyzExit" + self.setup_grid(map_data) + + self.grid.spawn() + + # Two rooms and 2 exits, Each one should have gotten one `at_object_creation` callback. + self.assertEqual( + mock_room_callbacks.at_object_creation.mock_calls, [mock.call(), mock.call()] + ) + self.assertEqual( + mock_exit_callbacks.at_object_creation.mock_calls, [mock.call(), mock.call()] + )
+ + +
[docs]class TestFlyDiveCommand(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + + self.grid, err = xyzgrid.XYZGrid.create("testgrid") + + self.map_data13a = {"map": MAP13a, "zcoord": -2} + self.map_data13b = {"map": MAP13b, "zcoord": -1} + self.map_data13c = {"map": MAP13c, "zcoord": 0} + self.map_data13d = {"map": MAP13d, "zcoord": 1} # not contiguous + + self.grid.add_maps(self.map_data13a, self.map_data13b, self.map_data13c, self.map_data13d) + self.grid.spawn()
+ +
[docs] def tearDown(self): + self.grid.delete()
+ + @parameterized.expand( + [ + # startcoord, cmd, succeed?, endcoord + ((0, 0, -2), "fly", False, (0, 0, -2)), + ((1, 1, -2), "fly", True, (1, 1, -1)), + ((1, 1, -1), "fly", True, (1, 1, 0)), + ((1, 1, 0), "fly", True, (1, 1, 1)), + ((1, 1, 1), "fly", False, (1, 1, 1)), + ((0, 0, 1), "fly", False, (0, 0, 1)), + ((0, 0, 1), "dive", False, (0, 0, 1)), + ((1, 1, 1), "dive", True, (1, 1, 0)), + ((1, 1, 0), "dive", True, (1, 1, -1)), + ((1, 1, -1), "dive", True, (1, 1, -2)), + ((1, 1, -2), "dive", False, (1, 1, -2)), + ] + ) + def test_fly_and_dive(self, startcoord, cmdstring, success, endcoord): + """ + Test flying up and down and seeing if it works at different locations. + + """ + start_room = xyzgrid.XYZRoom.objects.get_xyz(xyz=startcoord) + self.char1.move_to(start_room) + + self.call(commands.CmdFlyAndDive(), "", "You" if success else "Can't", cmdstring=cmdstring) + + self.assertEqual(self.char1.location.xyz, endcoord)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/utils.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/utils.html new file mode 100644 index 0000000000..b0008b8eba --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/utils.html @@ -0,0 +1,163 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.utils — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.utils

+"""
+
+Helpers and resources for the map system.
+
+"""
+
+BIGVAL = 999999999999
+
+REVERSE_DIRECTIONS = {
+    "n": "s",
+    "ne": "sw",
+    "e": "w",
+    "se": "nw",
+    "s": "n",
+    "sw": "ne",
+    "w": "e",
+    "nw": "se",
+}
+
+MAPSCAN = {
+    "n": (0, 1),
+    "ne": (1, 1),
+    "e": (1, 0),
+    "se": (1, -1),
+    "s": (0, -1),
+    "sw": (-1, -1),
+    "w": (-1, 0),
+    "nw": (-1, 1),
+}
+
+# errors for Map system
+
+
+
[docs]class MapError(RuntimeError): +
[docs] def __init__(self, error="", node_or_link=None): + prefix = "" + if node_or_link: + prefix = ( + f"{node_or_link.__class__.__name__} '{node_or_link.symbol}' " + f"at XYZ=({node_or_link.X:g},{node_or_link.Y:g},{node_or_link.Z}) " + ) + self.node_or_link = node_or_link + self.message = f"{prefix}{error}" + super().__init__(self.message)
+ + +
[docs]class MapParserError(MapError): + pass
+ + +
[docs]class MapTransition(RuntimeWarning): + """ + Used when signaling to the parser that a link + leads to another map. + + """ + + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xymap.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xymap.html new file mode 100644 index 0000000000..63ff8e66f7 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xymap.html @@ -0,0 +1,1095 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.xymap — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.xymap

+r"""
+# XYMap
+
+The `XYMap` class represents one XY-grid of interconnected map-legend components. It's built from an
+ASCII representation, where unique characters represents each type of component. The Map parses the
+map into an internal graph that can be efficiently used for pathfinding the shortest route between
+any two nodes (rooms).
+
+Each room (MapNode) can have exits (links) in 8 cardinal directions (north, northwest etc) as well
+as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', 'w',
+'nw', 'u' and 'd'.
+
+
+```python
+    # in module passed to 'Map' class
+
+    MAP = r'''
+                           1
+     + 0 1 2 3 4 5 6 7 8 9 0
+
+    10 #   # # #     #-I-#
+        \  i i i     d
+     9   #-#-#-#     |
+         |\    |     u
+     8   #-#-#-#-----#b----o
+         |     |           |
+     7   #-#---#-#-#-#-#   |
+         |         |x|x|   |
+     6   o-#-#-#   #-#-#-#b#
+            \      |x|x|
+     5   o---#-#<--#-#-#
+        /    |
+     4 #-----+-# #---#
+        \    | |  \ /
+     3   #b#-#-#   x   #
+             | |  / \ u
+     2       #-#-#---#
+             ^       d
+     1       #-#     #
+             |
+     0 #-#---o
+
+     + 0 1 2 3 4 5 6 7 8 9 1
+                           0
+
+    '''
+
+
+    LEGEND = {'#': xyzgrid.MapNode, '|': xyzgrid.NSMapLink,...}
+
+    # read by parser if XYMAP_DATA_LIST doesn't exist
+    XYMAP_DATA = {
+        "map": MAP,
+        "legend": LEGEND,
+        "zcoord": "City of Foo",
+        "prototypes": {
+            (0,1): { ... },
+            (1,3): { ... },
+            ...
+        }
+
+    }
+
+    # will be parsed first, allows for multiple map-data dicts from one module
+    XYMAP_DATA_LIST = [
+        XYMAP_DATA
+    ]
+
+```
+
+The two `+` signs  in the upper/lower left corners are required and marks the edge of the map area.
+The origo of the grid is always two steps right and two up from the bottom test marker and the grid
+extends to two lines below the top-left marker. Anything outside the grid is ignored, so numbering
+the coordinate axes is optional but recommended for readability.
+
+The XY positions represent coordinates positions in the game world. When existing, they are usually
+represented by Rooms in-game. The links between nodes would normally represent Exits, but the length
+of links on the map have no in-game equivalence except that traversing a multi-step link will place
+you in a location with an XY coordinate different from what you'd expect by a single step (most
+games don't relay the XY position to the player anyway).
+
+In the map string, every XY coordinate must have exactly one spare space/line between them - this is
+used for node linkings. This finer grid which has 2x resolution of the `XYgrid` is only used by the
+mapper and is referred to as the `xygrid` (small xy) internally. Note that an XY position can also
+be held by a link (for example a passthrough).
+
+The nodes and links can be customized by add your own implementation of `MapNode` or `MapLink` to
+the LEGEND dict, mapping them to a particular character symbol. A `MapNode` can only be added
+on an even XY coordinate while `MapLink`s can be added anywhere on the xygrid.
+
+See `./example.py` for a full grid example.
+
+----
+"""
+import pickle
+from collections import defaultdict
+from os import mkdir
+from os.path import isdir, isfile
+from os.path import join as pathjoin
+
+try:
+    from scipy import zeros
+    from scipy.sparse import csr_matrix
+    from scipy.sparse.csgraph import dijkstra
+except ImportError as err:
+    raise ImportError(
+        f"{err}\nThe XYZgrid contrib requires "
+        "the SciPy package. Install with `pip install scipy'."
+    )
+from django.conf import settings
+
+from evennia.prototypes import prototypes as protlib
+from evennia.prototypes.spawner import flatten_prototype
+from evennia.utils import logger
+from evennia.utils.utils import is_iter, mod_import, variable_from_module
+
+from . import xymap_legend
+from .utils import BIGVAL, MapError, MapParserError
+
+_NO_DB_PROTOTYPES = True
+if hasattr(settings, "XYZGRID_USE_DB_PROTOTYPES"):
+    _NO_DB_PROTOTYPES = not settings.XYZGRID_USE_DB_PROTOTYPES
+
+_CACHE_DIR = settings.CACHE_DIR
+_LOADED_PROTOTYPES = None
+_XYZROOMCLASS = None
+
+MAP_DATA_KEYS = ["zcoord", "map", "legend", "prototypes", "options", "module_path"]
+
+DEFAULT_LEGEND = xymap_legend.LEGEND
+
+# --------------------------------------------
+# Map parser implementation
+
+
+
[docs]class XYMap: + r""" + This represents a single map of interconnected nodes/rooms, parsed from a ASCII map + representation. + + Each room is connected to each other as a directed graph with optional 'weights' between the the + connections. It is created from a map string with symbols describing the topological layout. It + also provides pathfinding using the Dijkstra algorithm. + + The map-string is read from a string or from a module. The grid area of the string is marked by + two `+` characters - one in the top left of the area and the other in the bottom left. + The grid starts two spaces/lines in from the 'open box' created by these two markers and extend + any width to the right. + Any other markers or comments can be added outside of the grid - they will be ignored. Every + grid coordinate must always be separated by exactly one space/line since the space between + are used for links. + :: + ''' + 1 1 1 + + 0 1 2 3 4 5 6 7 8 9 0 1 2 ... + + 4 # # # + | \ / + 3 #-#-# # # + | \ / + 2 #-#-# # + |x|x| | + 1 #-#-#-#-#-#-# + / + 0 #-# + + + 0 1 2 3 4 5 6 7 8 9 1 1 1 ... + 0 1 2 + ''' + + So origo (0,0) is in the bottom-left and north is +y movement, south is -y movement + while east/west is +/- x movement as expected. Adding numbers to axes is optional + but recommended for readability! + + """ + mapcorner_symbol = "+" + max_pathfinding_length = 500 + empty_symbol = " " + # we normally only accept one single character for the legend key + legend_key_exceptions = "\\" + +
[docs] def __init__(self, map_module_or_dict, Z="map", xyzgrid=None): + """ + Initialize the map parser by feeding it the map. + + Args: + map_module_or_dict (str, module or dict): Path or module pointing to a map. If a dict, + this should be a dict with a MAP_DATA key 'map' and optionally a 'legend' + dicts to specify the map structure. + Z (int or str, optional): Name or Z-coord for for this map. Needed if the game uses + more than one map. If not given, it can also be embedded in the + `map_module_or_dict`. Used when referencing this map during map transitions, + baking of pathfinding matrices etc. + xyzgrid (.xyzgrid.XYZgrid): A top-level grid this map is a part of. + + Notes: + Interally, the map deals with two sets of coordinate systems: + - grid-coordinates x,y are the character positions in the map string. + - world-coordinates X,Y are the in-world coordinates of nodes/rooms. + There are fewer of these since they ignore the 'link' spaces between + the nodes in the grid, s + + X = x // 2 + Y = y // 2 + + - The Z-coordinate, if given, is only used when transitioning between maps + on the supplied `grid`. + + """ + global _LOADED_PROTOTYPES + if not _LOADED_PROTOTYPES: + # inject default prototypes, but don't override prototype-keys loaded from + # settings, if they exist (that means the user wants to replace the defaults) + protlib.load_module_prototypes( + "evennia.contrib.grid.xyzgrid.prototypes", override=False + ) + _LOADED_PROTOTYPES = True + + self.Z = Z + self.xyzgrid = xyzgrid + + self.mapstring = "" + self.raw_mapstring = "" + + # store so we can reload + self.map_module_or_dict = map_module_or_dict + + self.prototypes = None + self.options = None + + # transitional mapping + self.symbol_map = None + + # map setup + self.xygrid = None + self.XYgrid = None + self.display_map = None + self.max_x = 0 + self.max_y = 0 + self.max_X = 0 + self.max_Y = 0 + + # Dijkstra algorithm variables + self.node_index_map = None + self.dist_matrix = None + self.pathfinding_routes = None + + self.pathfinder_baked_filename = None + if Z: + if not isdir(_CACHE_DIR): + mkdir(_CACHE_DIR) + self.pathfinder_baked_filename = pathjoin(_CACHE_DIR, f"{Z}.P") + + # load data and parse it + self.reload()
+ + def __str__(self): + """ + Print the string representation of the map. + Since the y-axes origo is at the bottom, we must flip the + y-axis before printing (since printing is always top-to-bottom). + + """ + return "\n".join("".join(line) for line in self.display_map[::-1]) + + def __repr__(self): + nnodes = 0 + if self.node_index_map: + nnodes = len(self.node_index_map) + return f"<XYMap(Z={self.Z}), {self.max_X + 1}x{self.max_Y + 1}, {nnodes} nodes>" + +
[docs] def log(self, msg): + if self.xyzgrid: + self.xyzgrid.log(msg) + else: + logger.log_info(msg)
+ +
[docs] def reload(self, map_module_or_dict=None): + """ + (Re)Load a map. + + Args: + map_module_or_dict (str, module or dict, optional): See description for the variable + in the class' `__init__` function. If given, replace the already loaded + map with a new one. If not given, the existing one given on class creation + will be reloaded. + parse (bool, optional): If set, auto-run `.parse()` on the newly loaded data. + + Notes: + This will both (re)load the data and parse it into a new map structure, replacing any + existing one. The valid mapstructure is: + :: + + { + "map": <str>, + "zcoord": <int or str>, # optional + "legend": <dict>, # optional + "prototypes": <dict> # optional + "options": <dict> # optional + } + + """ + if not map_module_or_dict: + map_module_or_dict = self.map_module_or_dict + + mapdata = {} + if isinstance(map_module_or_dict, dict): + # map-structure provided directly + mapdata = map_module_or_dict + else: + # read from contents of module + mod = mod_import(map_module_or_dict) + mapdata_list = variable_from_module(mod, "XYMAP_DATA_LIST") + if mapdata_list and self.Z: + # use the stored Z value to figure out which map data we want + mapping = {mapdata.get("zcoord") for mapdata in mapdata_list} + mapdata = mapping.get(self.Z, {}) + + if not mapdata: + mapdata = variable_from_module(mod, "XYMAP_DATA") + + if not mapdata: + raise MapError( + "No valid XYMAP_DATA or XYMAP_DATA_LIST could be found from " + f"{map_module_or_dict}." + ) + + # validate + if any(key for key in mapdata if key not in MAP_DATA_KEYS): + raise MapError( + f"Mapdata has keys {list(mapdata)}, but only " f"keys {MAP_DATA_KEYS} are allowed." + ) + + for key in mapdata.get("legend", DEFAULT_LEGEND): + if not key or len(key) > 1: + if key not in self.legend_key_exceptions: + raise MapError( + f"Map-legend key '{key}' is invalid: All keys must " + "be exactly one character long. Use the node/link's " + "`.display_symbol` property to change how it is " + "displayed." + ) + if "map" not in mapdata or not mapdata["map"]: + raise MapError("No map found. Add 'map' key to map-data dict.") + for key, prototype in mapdata.get("prototypes", {}).items(): + if not (is_iter(key) and (2 <= len(key) <= 3)): + raise MapError( + f"Prototype override key {key} is malformed: It must be a " + "coordinate (X, Y) for nodes or (X, Y, direction) for links; " + "where direction is a supported direction string ('n', 'ne', etc)." + ) + + # store/update result + self.Z = mapdata.get("zcoord", self.Z) + self.mapstring = mapdata["map"] + self.prototypes = mapdata.get("prototypes", {}) + self.options = mapdata.get("options", {}) + + # merge the custom legend onto the default legend to allow easily + # overriding only parts of it + self.legend = {**DEFAULT_LEGEND, **map_module_or_dict.get("legend", DEFAULT_LEGEND)} + + # initialize any prototypes on the legend entities + for char, node_or_link_class in self.legend.items(): + prototype = node_or_link_class.prototype + if not prototype or isinstance(prototype, dict): + # nothing more to do + continue + # we need to load the prototype dict onto each for ease of access. Note that + proto = protlib.search_prototype( + prototype, require_single=True, no_db=_NO_DB_PROTOTYPES + )[0] + node_or_link_class.prototype = proto
+ +
[docs] def parse(self): + """ + Parses the numerical grid from the string. The first pass means parsing out + all nodes. The linking-together of nodes is not happening until the second pass + (the reason for this is that maps can also link to other maps, so all maps need + to have gone through their first parsing-passes before they can be linked together). + + See the class docstring for details of how the grid should be defined. + + Notes: + In this parsing, the 'xygrid' is the full range of chraracters read from + the string. The `XYgrid` is used to denote the game-world coordinates + (which doesn't include the links) + + """ + mapcorner_symbol = self.mapcorner_symbol + # this allows for string-based [x][y] mapping with arbitrary objects + xygrid = defaultdict(dict) + # mapping nodes to real X,Y positions + XYgrid = defaultdict(dict) + # needed by pathfinder + node_index_map = {} + # used by transitions + symbol_map = defaultdict(list) + + mapstring = self.mapstring + if mapstring.count(mapcorner_symbol) < 2: + raise MapParserError( + f"The mapstring must have at least two '{mapcorner_symbol}' " + "symbols marking the upper- and bottom-left corners of the " + "grid area." + ) + + # find the the position (in the string as a whole) of the top-left corner-marker + maplines = mapstring.split("\n") + topleft_marker_x, topleft_marker_y = -1, -1 + for topleft_marker_y, line in enumerate(maplines): + topleft_marker_x = line.find(mapcorner_symbol) + if topleft_marker_x != -1: + break + if -1 in (topleft_marker_x, topleft_marker_y): + raise MapParserError(f"No top-left corner-marker ({mapcorner_symbol}) found!") + + # find the position (in the string as a whole) of the bottom-left corner-marker + # this is always in a stright line down from the first marker + botleft_marker_x, botleft_marker_y = topleft_marker_x, -1 + for botleft_marker_y, line in enumerate(maplines[topleft_marker_y + 1 :]): + if line.find(mapcorner_symbol) == topleft_marker_x: + break + if botleft_marker_y == -1: + raise MapParserError( + f"No bottom-left corner-marker ({mapcorner_symbol}) found! " + "Make sure it lines up with the top-left corner-marker " + f"(found at column {topleft_marker_x} of the string)." + ) + # the actual coordinate is dy below the topleft marker so we need to shift + botleft_marker_y += topleft_marker_y + 1 + + # in-string_position of the top- and bottom-left grid corners (2 steps in from marker) + # the bottom-left corner is also the origo (0,0) of the grid. + topleft_y = topleft_marker_y + 2 + origo_x, origo_y = botleft_marker_x + 2, botleft_marker_y - 1 + + # highest actually filled grid points + max_x = 0 + max_y = 0 + max_X = 0 + max_Y = 0 + node_index = -1 + + # first pass: read string-grid (left-right, bottom-up) and parse all grid points + for iy, line in enumerate(reversed(maplines[topleft_y:origo_y])): + even_iy = iy % 2 == 0 + for ix, char in enumerate(line[origo_x:]): + # from now on, coordinates are on the xygrid. + + if char == self.empty_symbol: + continue + + # only set this if there's actually something on the line + max_x, max_y = max(max_x, ix), max(max_y, iy) + + mapnode_or_link_class = self.legend.get(char) + if not mapnode_or_link_class: + raise MapParserError( + f"Symbol '{char}' on XY=({ix / 2:g},{iy / 2:g}) " "is not found in LEGEND." + ) + if hasattr(mapnode_or_link_class, "node_index"): + # A mapnode. Mapnodes can only be placed on even grid positions, where + # there are integer X,Y coordinates defined. + + if not (even_iy and ix % 2 == 0): + raise MapParserError( + f"Symbol '{char}' on XY=({ix / 2:g},{iy / 2:g}) marks a " + "MapNode but is located between integer (X,Y) positions (only " + "Links can be placed between coordinates)!" + ) + + # save the node to several different maps for different uses + # in both coordinate systems + iX, iY = ix // 2, iy // 2 + max_X, max_Y = max(max_X, iX), max(max_Y, iY) + node_index += 1 + + xygrid[ix][iy] = XYgrid[iX][iY] = node_index_map[ + node_index + ] = mapnode_or_link_class( + x=ix, y=iy, Z=self.Z, node_index=node_index, symbol=char, xymap=self + ) + + else: + # we have a link at this xygrid position (this is ok everywhere) + xygrid[ix][iy] = mapnode_or_link_class( + x=ix, y=iy, Z=self.Z, symbol=char, xymap=self + ) + + # store the symbol mapping for transition lookups + symbol_map[char].append(xygrid[ix][iy]) + + # store before building links + self.max_x, self.max_y = max_x, max_y + self.max_X, self.max_Y = max_X, max_Y + self.xygrid = xygrid + self.XYgrid = XYgrid + self.node_index_map = node_index_map + self.symbol_map = symbol_map + + # build all links + for node in node_index_map.values(): + node.build_links() + + # build display map + display_map = [[" "] * (max_x + 1) for _ in range(max_y + 1)] + for ix, ydct in xygrid.items(): + for iy, node_or_link in ydct.items(): + display_map[iy][ix] = node_or_link.get_display_symbol() + + for node in node_index_map.values(): + # override node-prototypes, ignore if no prototype + # is defined (some nodes should not be spawned) + if node.prototype: + node_coord = (node.X, node.Y) + # load prototype from override, or use default + try: + node.prototype = flatten_prototype( + self.prototypes.get( + node_coord, self.prototypes.get(("*", "*"), node.prototype) + ), + no_db=_NO_DB_PROTOTYPES, + ) + except Exception as err: + raise MapParserError(f"Room prototype malformed: {err}", node) + # do the same for links (x, y, direction) coords + for direction, maplink in node.first_links.items(): + try: + maplink.prototype = flatten_prototype( + self.prototypes.get( + node_coord + (direction,), + self.prototypes.get(("*", "*", "*"), maplink.prototype), + ), + no_db=_NO_DB_PROTOTYPES, + ) + except Exception as err: + raise MapParserError(f"Exit prototype malformed: {err}", maplink) + + # store + self.display_map = display_map
+ + def _get_topology_around_coord(self, xy, dist=2): + """ + Get all links and nodes up to a certain distance from an XY coordinate. + + Args: + xy (tuple), the X,Y coordinate of the center point. + dist (int): How many nodes away from center point to find paths for. + + Returns: + tuple: A tuple of 5 elements `(xy_coords, xmin, xmax, ymin, ymax)`, where the + first element is a list of xy-coordinates (on xygrid) for all linked nodes within + range. This is meant to be used with the xygrid for extracting a subset + for display purposes. The others are the minimum size of the rectangle + surrounding the area containing `xy_coords`. + + Notes: + This performs a depth-first pass down the the given dist. + + """ + + def _scan_neighbors( + start_node, points, dist=2, xmin=BIGVAL, ymin=BIGVAL, xmax=0, ymax=0, depth=0 + ): + x0, y0 = start_node.x, start_node.y + points.append((x0, y0)) + xmin, xmax = min(xmin, x0), max(xmax, x0) + ymin, ymax = min(ymin, y0), max(ymax, y0) + + if depth < dist: + # keep stepping + for direction, end_node in start_node.links.items(): + x, y = x0, y0 + for link in start_node.xy_steps_to_node[direction]: + x, y = link.x, link.y + points.append((x, y)) + xmin, xmax = min(xmin, x), max(xmax, x) + ymin, ymax = min(ymin, y), max(ymax, y) + + points, xmin, xmax, ymin, ymax = _scan_neighbors( + end_node, + points, + dist=dist, + xmin=xmin, + ymin=ymin, + xmax=xmax, + ymax=ymax, + depth=depth + 1, + ) + + return points, xmin, xmax, ymin, ymax + + center_node = self.get_node_from_coord(xy) + points, xmin, xmax, ymin, ymax = _scan_neighbors(center_node, [], dist=dist) + return list(set(points)), xmin, xmax, ymin, ymax + +
[docs] def calculate_path_matrix(self, force=False): + """ + Solve the pathfinding problem using Dijkstra's algorithm. This will try to + load the solution from disk if possible. + + Args: + force (bool, optional): If the cache should always be rebuilt. + + """ + if not force and self.pathfinder_baked_filename and isfile(self.pathfinder_baked_filename): + # check if the solution for this grid was already solved previously. + + mapstr, dist_matrix, pathfinding_routes = "", None, None + with open(self.pathfinder_baked_filename, "rb") as fil: + try: + mapstr, dist_matrix, pathfinding_routes = pickle.load(fil) + except Exception: + logger.log_trace() + if ( + mapstr == self.mapstring + and dist_matrix is not None + and pathfinding_routes is not None + ): + # this is important - it means the map hasn't changed so + # we can re-use the stored data! + self.dist_matrix = dist_matrix + self.pathfinding_routes = pathfinding_routes + + # build a matrix representing the map graph, with 0s as impassable areas + + nnodes = len(self.node_index_map) + pathfinding_graph = zeros((nnodes, nnodes)) + for inode, node in self.node_index_map.items(): + pathfinding_graph[inode, :] = node.linkweights(nnodes) + + # create a sparse matrix to represent link relationships from each node + pathfinding_matrix = csr_matrix(pathfinding_graph) + + # solve using Dijkstra's algorithm + self.dist_matrix, self.pathfinding_routes = dijkstra( + pathfinding_matrix, + directed=True, + return_predecessors=True, + limit=self.max_pathfinding_length, + ) + + if self.pathfinder_baked_filename: + # try to cache the results + with open(self.pathfinder_baked_filename, "wb") as fil: + pickle.dump( + (self.mapstring, self.dist_matrix, self.pathfinding_routes), fil, protocol=4 + )
+ +
[docs] def spawn_nodes(self, xy=("*", "*")): + """ + Convert the nodes of this XYMap into actual in-world rooms by spawning their + related prototypes in the correct coordinate positions. This must be done *first* + before spawning links (with `spawn_links` because exits require the target destination + to exist. It's also possible to only spawn a subset of the map + + Args: + xy (tuple, optional): An (X,Y) coordinate of node(s). `'*'` acts as a wildcard. + + Examples: + - `xy=(1, 3) - spawn (1,3) coordinate only. + - `xy=('*', 1) - spawn all nodes in the first row of the map only. + - `xy=('*', '*')` - spawn all nodes + + Returns: + list: A list of nodes that were spawned. + + """ + global _XYZROOMCLASS + if not _XYZROOMCLASS: + from evennia.contrib.grid.xyzgrid.xyzroom import XYZRoom as _XYZROOMCLASS + x, y = xy + wildcard = "*" + spawned = [] + + # find existing nodes, in case some rooms need to be removed + map_coords = [ + (node.X, node.Y) + for node in sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X)) + ] + for existing_room in _XYZROOMCLASS.objects.filter_xyz(xyz=(x, y, self.Z)): + roomX, roomY, _ = existing_room.xyz + if (roomX, roomY) not in map_coords: + self.log(f" deleting room at {existing_room.xyz} (not found on map).") + existing_room.delete() + + # (re)build nodes (will not build already existing rooms) + for node in sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X)): + if (x in (wildcard, node.X)) and (y in (wildcard, node.Y)): + node.spawn() + spawned.append(node) + return spawned
+ + + +
[docs] def get_node_from_coord(self, xy): + """ + Get a MapNode from a coordinate. + + Args: + xy (tuple): X,Y coordinate on XYgrid. + + Returns: + MapNode: The node found at the given coordinates. Returns + `None` if there is no mapnode at the given coordinate. + + Raises: + MapError: If trying to specify an iX,iY outside + of the grid's maximum bounds. + + """ + if not self.XYgrid: + self.parse() + + iX, iY = xy + if not ((0 <= iX <= self.max_X) and (0 <= iY <= self.max_Y)): + raise MapError( + f"get_node_from_coord got coordinate {xy} which is " + f"outside the grid size of (0,0) - ({self.max_X}, {self.max_Y})." + ) + try: + return self.XYgrid[iX][iY] + except KeyError: + return None
+ +
[docs] def get_components_with_symbol(self, symbol): + """ + Find all map components (nodes, links) with a given symbol in this map. + + Args: + symbol (char): A single character-symbol to search for. + + Returns: + list: A list of MapNodes and/or MapLinks found with the matching symbol. + + """ + return self.symbol_map.get(symbol, [])
+ +
[docs] def get_shortest_path(self, start_xy, end_xy): + """ + Get the shortest route between two points on the grid. + + Args: + start_xy (tuple): A starting (X,Y) coordinate on the XYgrid (in-game coordinate) for + where we start from. + end_xy (tuple or MapNode): The end (X,Y) coordinate on the XYgrid (in-game coordinate) + we want to find the shortest route to. + + Returns: + tuple: Two lists, first containing the list of directions as strings (n, ne etc) and + the second is a mixed list of MapNodes and all MapLinks in a sequence describing + the full path including the start- and end-node. + + """ + startnode = self.get_node_from_coord(start_xy) + endnode = self.get_node_from_coord(end_xy) + + if not (startnode and endnode): + # no node at given coordinate. No path is possible. + return [], [] + + try: + istartnode = startnode.node_index + inextnode = endnode.node_index + except AttributeError: + raise MapError( + f"Map.get_shortest_path received start/end nodes {startnode} and " + f"{endnode}. They must both be MapNodes (not Links)" + ) + + if self.pathfinding_routes is None: + self.calculate_path_matrix() + + pathfinding_routes = self.pathfinding_routes + node_index_map = self.node_index_map + + path = [endnode] + directions = [] + + while pathfinding_routes[istartnode, inextnode] != -9999: + # the -9999 is set by algorithm for unreachable nodes or if trying + # to go a node we are already at (the start node in this case since + # we are working backwards). + inextnode = pathfinding_routes[istartnode, inextnode] + nextnode = node_index_map[inextnode] + shortest_route_to = nextnode.shortest_route_to_node[path[-1].node_index] + + directions.append(shortest_route_to[0]) + path.extend(shortest_route_to[1][::-1] + [nextnode]) + + # we have the path - reverse to get the correct order + directions = directions[::-1] + path = path[::-1] + + return directions, path
+ +
[docs] def get_visual_range( + self, + xy, + dist=2, + mode="nodes", + character="@", + target=None, + target_path_style="|y{display_symbol}|n", + max_size=None, + indent=0, + return_str=True, + ): + """ + Get a part of the grid centered on a specific point and extended a certain number + of nodes or grid points in every direction. + + Args: + xy (tuple): (X,Y) in-world coordinate location. If this is not the location + of a node on the grid, the `character` or the empty-space symbol (by default + an empty space) will be shown. + dist (int, optional): Number of gridpoints distance to show. Which + grid to use depends on the setting of `only_nodes`. Set to `None` to + always show the entire grid. + mode (str, optional): One of 'scan' or 'nodes'. In 'scan' mode, dist measure + number of xy grid points in all directions and doesn't care about if visible + nodes are reachable or not. If 'nodes', distance measure how many linked nodes + away from the center coordinate to display. + character (str, optional): Place this symbol at the `xy` position + of the displayed map. The center node's symbol is shown if this is falsy. + target (tuple, optional): A target XY coordinate to go to. The path to this + (or the beginning of said path, if outside of visual range) will be + marked according to `target_path_style`. + target_path_style (str or callable, optional): This is use for marking the path + found when `target` is given. If a string, it accepts a formatting marker + `display_symbol` which will be filled with the `display_symbol` of each node/link + the path passes through. This allows e.g. to color the path. If a callable, this + will receive the MapNode or MapLink object for every step of the path and and + must return the suitable string to display at the position of the node/link. + max_size (tuple, optional): A max `(width, height)` to crop the displayed + return to. Make both odd numbers to get a perfect center. Set either of + the tuple values to `None` to make that coordinate unlimited. Set entire + tuple to None let display-size able to grow up to full size of grid. + indent (int, optional): How far to the right to indent the map area (only + applies to `return_str=True`). + return_str (bool, optional): Return result as an already formatted string + or a 2D list. + + Returns: + str or list: Depending on value of `return_str`. If a list, + this is 2D grid of lines, [[str,str,str,...], [...]] where + each element is a single character in the display grid. To + extract a character at (ix,iy) coordinate from it, use + indexing `outlist[iy][ix]` in that order. + + Notes: + If outputting a list, the y-axis must first be reversed before printing since printing + happens top-bottom and the y coordinate system goes bottom-up. This can be done simply + with this before building the final string to send/print. + + printable_order_list = outlist[::-1] + + If mode='nodes', a `dist` of 2 will give the following result in a row of nodes: + + #-#-@----------#-# + + This display may thus visually grow much bigger than expected (both horizontally and + vertically). consider setting `max_size` if wanting to restrict the display size. Also + note that link 'weights' are *included* in this estimate, so if links have weights > 1, + fewer nodes may be found for a given `dist`. + + If mode=`scan`, a dist of 2 on the above example would instead give + + #-@-- + + This mode simply shows a cut-out subsection of the map you are on. The `dist` is + measured on xygrid, so two steps per XY coordinate. It does not consider links or + weights and may also show nodes not actually reachable at the moment: + + | | + # @-# + + """ + iX, iY = xy + # convert inputs to xygrid + width, height = self.max_x + 1, self.max_y + 1 + ix, iy = max(0, min(iX * 2, width)), max(0, min(iY * 2, height)) + display_map = self.display_map + xmin, xmax, ymin, ymax = 0, width - 1, 0, height - 1 + + if dist is None: + # show the entire grid + gridmap = self.display_map + ixc, iyc = ix, iy + + elif dist is None or dist <= 0 or not self.get_node_from_coord(xy): + # There is no node at these coordinates. Show + # nothing but ourselves or emptiness + return character if character else self.empty_symbol + + elif mode == "nodes": + # dist measures only full, reachable nodes. + points, xmin, xmax, ymin, ymax = self._get_topology_around_coord(xy, dist=dist) + + ixc, iyc = ix - xmin, iy - ymin + # note - override width/height here since our grid is + # now different from the original for future cropping + width, height = xmax - xmin + 1, ymax - ymin + 1 + gridmap = [[" "] * width for _ in range(height)] + for ix0, iy0 in points: + gridmap[iy0 - ymin][ix0 - xmin] = display_map[iy0][ix0] + + elif mode == "scan": + # scan-mode - dist measures individual grid points + + xmin, xmax = max(0, ix - dist), min(width, ix + dist + 1) + ymin, ymax = max(0, iy - dist), min(height, iy + dist + 1) + ixc, iyc = ix - xmin, iy - ymin + gridmap = [line[xmin:xmax] for line in display_map[ymin:ymax]] + + else: + raise MapError( + f"Map.get_visual_range 'mode' was '{mode}' " + "- it must be either 'scan' or 'nodes'." + ) + if character: + gridmap[iyc][ixc] = character # correct indexing; it's a list of lines + + if target: + # stylize path to target + + def _default_callable(node): + return target_path_style.format(display_symbol=node.get_display_symbol()) + + if callable(target_path_style): + _target_path_style = target_path_style + else: + _target_path_style = _default_callable + + _, path = self.get_shortest_path(xy, target) + + maxstep = dist if mode == "nodes" else dist / 2 + nsteps = 0 + for node_or_link in path[1:]: + if hasattr(node_or_link, "node_index"): + nsteps += 1 + if nsteps > maxstep: + break + # don't decorate current (character?) location + ix, iy = node_or_link.x, node_or_link.y + if xmin <= ix <= xmax and ymin <= iy <= ymax: + gridmap[iy - ymin][ix - xmin] = _target_path_style(node_or_link) + + if max_size: + # crop grid to make sure it doesn't grow too far + max_x, max_y = max_size + max_x = self.max_x if max_x is None else max_x + max_y = self.max_y if max_y is None else max_y + xmin, xmax = max(0, ixc - max_x // 2), min(width, ixc + max_x // 2 + 1) + ymin, ymax = max(0, iyc - max_y // 2), min(height, iyc + max_y // 2 + 1) + gridmap = [line[xmin:xmax] for line in gridmap[ymin:ymax]] + + if return_str: + # we must flip the y-axis before returning the string + indent = indent * " " + return indent + f"\n{indent}".join("".join(line) for line in gridmap[::-1]) + else: + return gridmap
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xymap_legend.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xymap_legend.html new file mode 100644 index 0000000000..ef3714ec0f --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xymap_legend.html @@ -0,0 +1,1477 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.xymap_legend — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.xymap_legend

+"""
+# Map legend components
+
+Each map-legend component is either a 'mapnode' - something that represents and actual in-game
+location (usually a room) or a 'maplink' - something connecting nodes together. The start of a link
+usually shows as an Exit, but the length of the link has no in-game equivalent.
+
+----
+
+"""
+
+try:
+    from scipy import zeros
+except ImportError as err:
+    raise ImportError(
+        f"{err}\nThe XYZgrid contrib requires the SciPy package. Install with `pip install scipy'."
+    )
+
+import uuid
+from collections import defaultdict
+
+from django.core import exceptions as django_exceptions
+
+from evennia.prototypes import spawner
+from evennia.utils.utils import class_from_module
+
+from .utils import BIGVAL, MAPSCAN, REVERSE_DIRECTIONS, MapError, MapParserError
+
+NodeTypeclass = None
+ExitTypeclass = None
+
+
+UUID_XYZ_NAMESPACE = uuid.uuid5(uuid.UUID(int=0), "xyzgrid")
+
+
+# Nodes/Links
+
+
+
[docs]class MapNode: + """ + This represents a 'room' node on the map. Note that the map system deals with two grids, the + finer `xygrid`, which is the per-character grid on the map, and the `XYgrid` which contains only + the even-integer coordinates and also represents in-game coordinates/rooms. MapNodes are always + located on even X,Y coordinates on the map grid and in-game. + + MapNodes will also handle the syncing of themselves and all outgoing links to the grid. + + Attributes on the node class: + + - `symbol` (str) - The character to parse from the map into this node. By default this + is '#' and must be a single character, with the exception of `\\ + - `display_symbol` (str or `None`) - This is what is used to visualize this node later. This + symbol must still only have a visual size of 1, but you could e.g. use some fancy unicode + character (be aware of encodings to different clients though) or, commonly, add color + tags around it. For further customization, the `.get_display_symbol` method + can return a dynamically determined display symbol. If set to `None`, the `symbol` is used. + - `interrupt_path` (bool): If this is set, the shortest-path algorithm will include this + node as normally, but the auto-stepper will stop when reaching it, even if not having reached + its target yet. This is useful for marking 'points of interest' along a route, or places where + you are not expected to be able to continue without some further in-game action not covered by + the map (such as a guard or locked gate etc). + - `prototype` (dict) - The default `prototype` dict to use for reproducing this map component + on the game grid. This is used if not overridden specifically for this coordinate. If this + is not given, nothing will be spawned for this coordinate (a 'virtual' node can be useful + for various reasons, mostly map-transitions). + + """ + + # symbol used to identify this link on the map + symbol = "#" + # if printing this node should show another symbol. If set + # to the empty string, use `symbol`. + display_symbol = None + # this will interrupt a shortest-path step (useful for 'points' of interest, stop before + # a door etc). + interrupt_path = False + # the prototype to use for mapping this to the grid. + prototype = None + + # internal use. Set during generation, but is also used for identification of the node + node_index = None + # this should always be left True for Nodes and avoids inifinite loops during querying. + multilink = True + # default values to use if the exit doesn't have a 'spawn_aliases' iterable + direction_spawn_defaults = { + "n": ("north", "n"), + "ne": ("northeast", "ne", "north-east"), + "e": ("east", "e"), + "se": ("southeast", "se", "south-east"), + "s": ("south", "s"), + "sw": ("southwest", "sw", "south-west"), + "w": ("west", "w"), + "nw": ("northwest", "nw", "north-west"), + "d": ("down", "d", "do"), + "u": ("up", "u"), + } + +
[docs] def __init__(self, x, y, Z, node_index=0, symbol=None, xymap=None): + """ + Initialize the mapnode. + + Args: + x (int): Coordinate on xygrid. + y (int): Coordinate on xygrid. + Z (int or str): Name/Z-pos of this map. + node_index (int): This identifies this node with a running + index number required for pathfinding. This is used + internally and should not be set manually. + symbol (str, optional): Set during parsing - allows to override + the symbol based on what's set in the legend. + xymap (XYMap, optional): The map object this sits on. + + """ + + self.x = x + self.y = y + + # map name, usually + self.xymap = xymap + + # XYgrid coordinate + self.X = x // 2 + self.Y = y // 2 + self.Z = Z + + self.node_index = node_index + if symbol is not None: + self.symbol = symbol + + # this indicates linkage in 8 cardinal directions on the string-map, + # n,ne,e,se,s,sw,w,nw and link that to a node (always) + self.links = {} + # first MapLink in each direction - used by grid syncing + self.first_links = {} + # this maps + self.weights = {} + # lowest direction to a given neighbor + self.shortest_route_to_node = {} + # maps the directions (on the xygrid NOT on XYgrid!) taken if stepping + # out from this node in a given direction until you get to the end node. + # This catches eventual longer link chains that would otherwise be lost + # {startdirection: [direction, ...], ...} + # where the directional path-lists also include the start-direction + self.xy_steps_to_node = {} + # direction-names of the closest neighbors to the node + self.closest_neighbor_names = {}
+ + def __str__(self): + return f"<MapNode '{self.symbol}' {self.node_index} XY=({self.X},{self.Y})" + + def __repr__(self): + return str(self) + +
[docs] def log(self, msg): + """log messages using the xygrid parent""" + self.xymap.log(msg)
+ +
[docs] def generate_prototype_key(self): + """ + Generate a deterministic prototype key to allow for users to apply prototypes without + needing a separate new name for every one. + + """ + return str(uuid.uuid5(UUID_XYZ_NAMESPACE, str((self.X, self.Y, self.Z))))
+ + + +
[docs] def linkweights(self, nnodes): + """ + Retrieve all the weights for the direct links to all other nodes. This is + used for the efficient generation of shortest-paths. + + Args: + nnodes (int): The total number of nodes + + Returns: + scipy.array: Array of weights of the direct links to other nodes. + The weight will be 0 for nodes not directly connected to one another. + + Notes: + A node can at most have 8 connections (the cardinal directions). + + """ + link_graph = zeros(nnodes) + for node_index, weight in self.weights.items(): + link_graph[node_index] = weight + return link_graph
+ +
[docs] def get_display_symbol(self): + """ + Hook to override for customizing how the display_symbol is determined. + + Returns: + str: The display-symbol to use. This must visually be a single character + but could have color markers, use a unicode font etc. + + Notes: + By default, just setting .display_symbol is enough. + + """ + return self.symbol if self.display_symbol is None else self.display_symbol
+ +
[docs] def get_spawn_xyz(self): + """ + This should return the XYZ-coordinates for spawning this node. This normally + the XYZ of the current map, but for traversal-nodes, it can also be the location + on another map. + + Returns: + tuple: The (X, Y, Z) coords to spawn this node at. + """ + return self.X, self.Y, self.Z
+ +
[docs] def get_exit_spawn_name(self, direction, return_aliases=True): + """ + Retrieve the spawn name for the exit being created by this link. + + Args: + direction (str): The cardinal direction (n,ne etc) the want the + exit name/aliases for. + return_aliases (bool, optional): Also return all aliases. + + Returns: + str or tuple: The key of the spawned exit, or a tuple (key, alias, alias, ...) + + """ + key, *aliases = self.first_links[direction].spawn_aliases.get( + direction, self.direction_spawn_defaults.get(direction, ("unknown",)) + ) + if return_aliases: + return (key, *aliases) + return key
+ +
[docs] def spawn(self): + """ + Build an actual in-game room from this node. + + This should be called as part of the node-sync step of the map sync. The reason is + that the exits (next step) requires all nodes to exist before they can link up + to their destinations. + + """ + global NodeTypeclass + if not NodeTypeclass: + from .xyzroom import XYZRoom as NodeTypeclass + + if not self.prototype: + # no prototype means we can't spawn anything - + # a 'virtual' node. + return + + xyz = self.get_spawn_xyz() + + try: + nodeobj = NodeTypeclass.objects.get_xyz(xyz=xyz) + except django_exceptions.ObjectDoesNotExist: + # create a new entity, using the specified typeclass (if there's one) and + # with proper coordinates etc + typeclass = self.prototype.get("typeclass") + if typeclass is None: + raise MapError( + f"The prototype {self.prototype} for this node has no 'typeclass' key.", self + ) + self.log(f" spawning room at xyz={xyz} ({typeclass})") + Typeclass = class_from_module(typeclass) + nodeobj, err = Typeclass.create(self.prototype.get("key", "An empty room"), xyz=xyz) + if err: + raise RuntimeError(err) + else: + self.log(f" updating existing room (if changed) at xyz={xyz}") + + if not self.prototype.get("prototype_key"): + # make sure there is a prototype_key in prototype + self.prototype["prototype_key"] = self.generate_prototype_key() + + # apply prototype to node. This will not override the XYZ tags since + # these are not in the prototype and exact=False + spawner.batch_update_objects_with_prototype(self.prototype, objects=[nodeobj], exact=False)
+ + + +
[docs] def unspawn(self): + """ + Remove all spawned objects related to this node and all links. + + """ + global NodeTypeclass + if not NodeTypeclass: + from .room import XYZRoom as NodeTypeclass + + xyz = (self.X, self.Y, self.Z) + + try: + nodeobj = NodeTypeclass.objects.get_xyz(xyz=xyz) + except django_exceptions.ObjectDoesNotExist: + # no object exists + pass + else: + nodeobj.delete()
+ + +
[docs]class TransitionMapNode(MapNode): + """ + This node acts as an end-node for a link that actually leads to a specific node on another + map. It is not actually represented by a separate room in-game. + + This teleportation is not understood by the pathfinder, so why it will be possible to pathfind + to this node, it really represents a map transition. Only a single link must ever be connected + to this node. + + Properties: + - `target_map_xyz` (tuple) - the (X, Y, Z) coordinate of a node on the other map to teleport + to when moving to this node. This should not be another TransitionMapNode (see below for + how to make a two-way link). + + Examples: + :: + + map1 map2 + + #-T #- - one-way transition from map1 -> map2. + #-T T-# - two-way. Both TransitionMapNodes links to the coords of the + actual rooms (`#`) on the other map (NOT to the `T`s)! + + """ + + symbol = "T" + display_symbol = " " + # X,Y,Z coordinates of target node + taget_map_xyz = (None, None, None) + +
[docs] def get_spawn_xyz(self): + """ + Make sure to return the coord of the *target* - this will be used when building + the exit to this node (since the prototype is None, this node itself will not be built). + + """ + if any(True for coord in self.target_map_xyz if coord in (None, "unset")): + raise MapParserError( + f"(Z={self.xymap.Z}) has not defined its " + "`.target_map_xyz` property. It must point " + "to another valid xymap (Z coordinate).", + self, + ) + + return self.target_map_xyz
+ +
+ + + + + + + + + + + + + + + + + +# ---------------------------------- +# Default nodes and link classes + + +
[docs]class BasicMapNode(MapNode): + """A map node/room""" + + symbol = "#" + prototype = "xyz_room"
+ + +
[docs]class InterruptMapNode(MapNode): + """A point of interest node/room. Pathfinder will ignore but auto-stepper will + stop here if passing through. Starting from here is fine.""" + + symbol = "I" + display_symbol = "#" + interrupt_path = True + prototype = "xyz_room"
+ + +
[docs]class MapTransitionNode(TransitionMapNode): + """Transition-target node to other map. This is not actually spawned in-game.""" + + symbol = "T" + display_symbol = " " + prototype = None # important to leave None! + target_map_xyz = (None, None, None) # must be set manually
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +# all map components; used as base if not overridden +LEGEND = { + # nodes + "#": BasicMapNode, + "T": MapTransitionNode, + "I": InterruptMapNode, + # links + "|": NSMapLink, + "-": EWMapLink, + "/": NESWMapLink, + "\\": SENWMapLink, + "x": CrossMapLink, + "+": PlusMapLink, + "v": NSOneWayMapLink, + "^": SNOneWayMapLink, + "<": EWOneWayMapLink, + ">": WEOneWayMapLink, + "o": RouterMapLink, + "u": UpMapLink, + "d": DownMapLink, + "b": BlockedMapLink, + "i": InterruptMapLink, + "t": TeleporterMapLink, +} +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xyzgrid.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xyzgrid.html new file mode 100644 index 0000000000..81ea42fa12 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xyzgrid.html @@ -0,0 +1,416 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.xyzgrid — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.xyzgrid

+"""
+The grid
+
+This represents the full XYZ grid, which consists of
+
+- 2D `Map`-objects parsed from Map strings and Map-legend components. Each represents one
+  Z-coordinate or location.
+- `Prototypes` for how to build each XYZ component into 'real' rooms and exits.
+- Actual in-game rooms and exits, mapped to the game based on Map data.
+
+The grid has three main functions:
+- Building new rooms/exits from scratch based on one or more Maps.
+- Updating the rooms/exits tied to an existing Map when the Map string
+  of that map changes.
+- Fascilitate communication between the in-game entities and their Map.
+
+
+"""
+from evennia.scripts.scripts import DefaultScript
+from evennia.utils import logger
+from evennia.utils.utils import variable_from_module
+
+from .xymap import XYMap
+from .xyzroom import XYZExit, XYZRoom
+
+
+
[docs]class XYZGrid(DefaultScript): + """ + Main grid class. This organizes the Maps based on their name/Z-coordinate. + + """ + +
[docs] def at_script_creation(self): + """ + What we store persistently is data used to create each map (the legends, names etc) + + """ + self.db.map_data = {} + self.desc = "Manages maps for XYZ-grid"
+ + @property + def grid(self): + if self.ndb.grid is None: + self.reload() + return self.ndb.grid + +
[docs] def get_map(self, zcoord): + """ + Get a specific xymap. + + Args: + zcoord (str): The name/zcoord of the xymap. + + Returns: + XYMap: Or None if no map was found. + + """ + return self.grid.get(zcoord)
+ +
[docs] def all_maps(self): + """ + Get all xymaps stored in the grid. + + Returns: + list: All initialized xymaps stored with this grid. + + """ + return list(self.grid.values())
+ +
[docs] def log(self, msg): + logger.log_info(f"|grid| {msg}")
+ +
[docs] def get_room(self, xyz, **kwargs): + """ + Get one or more room objects from XYZ coordinate. + + Args: + xyz (tuple): X,Y,Z coordinate of room to fetch. '*' acts + as wild cards. + + Returns: + Queryset: A queryset of XYZRoom(s) found. + + Raises: + XYZRoom.DoesNotExist: If room is not found. + + Notes: + This assumes the room was previously built. + + """ + return XYZRoom.objects.filter_xyz(xyz=xyz, **kwargs)
+ +
[docs] def get_exit(self, xyz, name="north", **kwargs): + """ + Get one or more exit object at coordinate. + + Args: + xyz (tuple): X,Y,Z coordinate of the room the + exit leads out of. '*' acts as a wildcard. + name (str): The full name of the exit, e.g. 'north' or 'northwest'. + The '*' acts as a wild card. + + Returns: + Queryset: A queryset of XYZExit(s) found. + + """ + kwargs["db_key"] = name + return XYZExit.objects.filter_xyz_exit(xyz=xyz, **kwargs)
+ +
[docs] def maps_from_module(self, module_path): + """ + Load map data from module. The loader will look for a dict XYMAP_DATA or a list of + XYMAP_DATA_LIST (a list of XYMAP_DATA dicts). Each XYMAP_DATA dict should contain + `{"xymap": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}`. + + Args: + module_path (module_path): A python-path to a module containing + map data as either `XYMAP_DATA` or `XYMAP_DATA_LIST` variables. + + Returns: + list: List of zero, one or more xy-map data dicts loaded from the module. + + """ + map_data_list = variable_from_module(module_path, "XYMAP_DATA_LIST") + if not map_data_list: + map_data_list = [variable_from_module(module_path, "XYMAP_DATA")] + # inject the python path in the map data + for mapdata in map_data_list: + if not mapdata: + self.log(f"Could not find or load map from {module_path}.") + return + mapdata["module_path"] = module_path + return map_data_list
+ +
[docs] def reload(self): + """ + Reload and rebuild the grid. This is done on a server reload. + + """ + self.log("(Re)loading grid ...") + self.ndb.grid = {} + nmaps = 0 + loaded_mapdata = {} + changed = [] + mapdata = self.db.map_data + + if not mapdata: + self.db.mapdata = mapdata = {} + + # generate all Maps - this will also initialize their components + # and bake any pathfinding paths (or load from disk-cache) + for zcoord, old_mapdata in mapdata.items(): + self.log(f"Loading map '{zcoord}'...") + + # we reload the map from module + new_mapdata = loaded_mapdata.get(zcoord) + if not new_mapdata: + if "module_path" in old_mapdata: + for mapdata in self.maps_from_module(old_mapdata["module_path"]): + loaded_mapdata[mapdata["zcoord"]] = mapdata + else: + # nowhere to reload from - use what we have + loaded_mapdata[zcoord] = old_mapdata + + new_mapdata = loaded_mapdata.get(zcoord) + + if new_mapdata != old_mapdata: + self.log(f" XYMap data for Z='{zcoord}' has changed.") + changed.append(zcoord) + + xymap = XYMap(dict(new_mapdata), Z=zcoord, xyzgrid=self) + xymap.parse() + xymap.calculate_path_matrix() + self.ndb.grid[zcoord] = xymap + nmaps += 1 + + # re-store changed data + for zcoord in changed: + self.db.map_data[zcoord] = loaded_mapdata[zcoord] + + # store + self.log(f"Loaded and linked {nmaps} map(s).") + self.ndb.loaded = True
+ +
[docs] def add_maps(self, *mapdatas): + """ + Add map or maps to the grid. + + Args: + *mapdatas (dict): Each argument is a dict structure + `{"map": <mapstr>, "legend": <legenddict>, "name": <name>, + "prototypes": <dict-of-dicts>, "module_path": <str>}`. The `prototypes are + coordinate-specific overrides for nodes/links on the map, keyed with their + (X,Y) coordinate within that map. The `module_path` is injected automatically + by self.maps_from_module. + + Raises: + RuntimeError: If mapdata is malformed. + + """ + for mapdata in mapdatas: + zcoord = mapdata.get("zcoord") + if not zcoord is not None: + raise RuntimeError("XYZGrid.add_map data must contain 'zcoord'.") + + self.db.map_data[zcoord] = mapdata
+ +
[docs] def remove_map(self, *zcoords, remove_objects=True): + """ + Remove an XYmap from the grid. + + Args: + *zoords (str): The zcoords/XYmaps to remove. + remove_objects (bool, optional): If the synced database objects (rooms/exits) should + be removed alongside this map. + """ + # from evennia import set_trace;set_trace() + for zcoord in zcoords: + if zcoord in self.db.map_data: + self.db.map_data.pop(zcoord) + if remove_objects: + # we can't batch-delete because we want to run the .delete + # method that also wipes exits and moves content to save locations + for xyzroom in XYZRoom.objects.filter_xyz(xyz=("*", "*", zcoord)): + xyzroom.delete() + self.reload()
+ +
[docs] def delete(self): + """ + Clear the entire grid, including database entities, then the grid too. + + """ + mapdata = self.db.map_data + if mapdata: + self.remove_map(*(zcoord for zcoord in self.db.map_data), remove_objects=True) + super().delete()
+ +
[docs] def spawn(self, xyz=("*", "*", "*"), directions=None): + """ + Create/recreate/update the in-game grid based on the stored Maps or for a specific Map + or coordinate. + + Args: + xyz (tuple, optional): An (X,Y,Z) coordinate, where Z is the name of the map. `'*'` + acts as a wildcard. + directions (list, optional): A list of cardinal directions ('n', 'ne' etc). + Spawn exits only the given direction. If unset, all needed directions are spawned. + + Examples: + - `xyz=('*', '*', '*')` (default) - spawn/update all maps. + - `xyz=(1, 3, 'foo')` - sync a specific element of map 'foo' only. + - `xyz=('*', '*', 'foo') - sync all elements of map 'foo' + - `xyz=(1, 3, '*') - sync all (1,3) coordinates on all maps (rarely useful) + - `xyz=(1, 3, 'foo')`, `direction='ne'` - sync only the north-eastern exit + out of the specific node on map 'foo'. + + """ + x, y, z = xyz + wildcard = "*" + + if z == wildcard: + xymaps = self.grid + elif self.ndb.grid and z in self.ndb.grid: + xymaps = {z: self.grid[z]} + else: + raise RuntimeError(f"The 'z' coordinate/name '{z}' is not found on the grid.") + + # first build all nodes/rooms + for zcoord, xymap in xymaps.items(): + self.log(f"spawning/updating nodes for Z='{zcoord}' ...") + xymap.spawn_nodes(xy=(x, y)) + + # next build all links between nodes (including between maps) + for zcoord, xymap in xymaps.items(): + self.log(f"spawning/updating links for Z='{zcoord}' ...") + xymap.spawn_links(xy=(x, y), directions=directions)
+ + +
[docs]def get_xyzgrid(print_errors=True): + """ + Helper for getting the grid. This will create the XYZGrid global script if it didn't + previously exist. + + Args: + print_errors (bool, optional): Print errors directly to console rather than to log. + + """ + xyzgrid = XYZGrid.objects.all() + if not xyzgrid: + # create a new one + xyzgrid, err = XYZGrid.create("XYZGrid") + if err: + raise RuntimeError(err) + xyzgrid.reload() + return xyzgrid + elif len(xyzgrid) > 1: + print( + "Warning: More than one XYZGrid instance were found. This is an error and " + "only the first one will be used. Delete the other one(s) manually." + ) + xyzgrid = xyzgrid[0] + try: + if not xyzgrid.ndb.loaded: + xyzgrid.reload() + except Exception as err: + # raise # debug + if print_errors: + print(err) + else: + xyzgrid.log(str(err)) + return xyzgrid
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xyzroom.html b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xyzroom.html new file mode 100644 index 0000000000..4a0816a045 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/grid/xyzgrid/xyzroom.html @@ -0,0 +1,742 @@ + + + + + + + + evennia.contrib.grid.xyzgrid.xyzroom — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.grid.xyzgrid.xyzroom

+"""
+XYZ-aware rooms and exits.
+
+These are intended to be used with the XYZgrid - which interprets the `Z` 'coordinate' as
+different (named) 2D XY  maps. But if not wanting to use the XYZgrid gridding, these can also be
+used as stand-alone XYZ-coordinate-aware rooms.
+
+"""
+
+from django.conf import settings
+from django.db.models import Q
+from evennia.objects.manager import ObjectManager
+from evennia.objects.objects import DefaultExit, DefaultRoom
+
+# name of all tag categories. Note that the Z-coordinate is
+# the `map_name` of the XYZgrid
+MAP_X_TAG_CATEGORY = "room_x_coordinate"
+MAP_Y_TAG_CATEGORY = "room_y_coordinate"
+MAP_Z_TAG_CATEGORY = "room_z_coordinate"
+
+MAP_XDEST_TAG_CATEGORY = "exit_dest_x_coordinate"
+MAP_YDEST_TAG_CATEGORY = "exit_dest_y_coordinate"
+MAP_ZDEST_TAG_CATEGORY = "exit_dest_z_coordinate"
+
+GET_XYZGRID = None
+
+CLIENT_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+
+
+
[docs]class XYZManager(ObjectManager): + """ + This is accessed as `.objects` on the coordinate-aware typeclasses (`XYZRoom`, `XYZExit`). It + has all the normal Object/Room manager methods (filter/get etc) but also special helpers for + efficiently querying the room in the database based on XY coordinates. + + """ + +
[docs] def filter_xyz(self, xyz=("*", "*", "*"), **kwargs): + """ + Filter queryset based on XYZ position on the grid. The Z-position is the name of the XYMap + Set a coordinate to `'*'` to act as a wildcard (setting all coords to `*` will thus find + *all* XYZ rooms). This will also find children of XYZRooms on the given coordinates. + + Kwargs: + xyz (tuple, optional): A coordinate tuple (X, Y, Z) where each element is either + an `int` or `str`. The character `'*'` acts as a wild card. Note that + the `Z`-coordinate is the name of the map (case-sensitive) in the XYZgrid contrib. + **kwargs: All other kwargs are passed on to the query. + + Returns: + django.db.queryset.Queryset: A queryset that can be combined + with further filtering. + + """ + x, y, z = xyz + wildcard = "*" + + return ( + self.filter_family(**kwargs) + .filter( + Q() + if x == wildcard + else Q(db_tags__db_key=str(x), db_tags__db_category=MAP_X_TAG_CATEGORY) + ) + .filter( + Q() + if y == wildcard + else Q(db_tags__db_key=str(y), db_tags__db_category=MAP_Y_TAG_CATEGORY) + ) + .filter( + Q() + if z == wildcard + else Q(db_tags__db_key__iexact=str(z), db_tags__db_category=MAP_Z_TAG_CATEGORY) + ) + )
+ +
[docs] def get_xyz(self, xyz=(0, 0, "map"), **kwargs): + """ + Always return a single matched entity directly. This accepts no `*`-wildcards. + This will also find children of XYZRooms on the given coordinates. + + Kwargs: + xyz (tuple): A coordinate tuple of `int` or `str` (not `'*'`, no wildcards are + allowed in get). The `Z`-coordinate acts as the name (case-sensitive) of the map in + the XYZgrid contrib. + **kwargs: All other kwargs are passed on to the query. + + Returns: + XYRoom: A single room instance found at the combination of x, y and z given. + + Raises: + XYZRoom.DoesNotExist: If no matching query was found. + XYZRoom.MultipleObjectsReturned: If more than one match was found (which should not + possible with a unique combination of x,y,z). + + """ + # filter by tags, then figure out of we got a single match or not + query = self.filter_xyz(xyz=xyz, **kwargs) + ncount = query.count() + if ncount == 1: + return query.first() + + # error - mimic default get() behavior but with a little more info + x, y, z = xyz + inp = f"Query: xyz=({x},{y},{z}), " + ",".join( + f"{key}={val}" for key, val in kwargs.items() + ) + if ncount > 1: + raise self.model.MultipleObjectsReturned(inp) + else: + raise self.model.DoesNotExist(inp)
+ + +
[docs]class XYZExitManager(XYZManager): + """ + Used by Exits. + Manager that also allows searching for destinations based on XY coordinates. + + """ + +
[docs] def filter_xyz_exit(self, xyz=("*", "*", "*"), xyz_destination=("*", "*", "*"), **kwargs): + """ + Used by exits (objects with a source and -destination property). + Find all exits out of a source or to a particular destination. This will also find + children of XYZExit on the given coords.. + + Kwargs: + xyz (tuple, optional): A coordinate (X, Y, Z) for the source location. Each + element is either an `int` or `str`. The character `'*'` is used as a wildcard - + so setting all coordinates to the wildcard will return *all* XYZExits. + the `Z`-coordinate is the name of the map (case-sensitive) in the XYZgrid contrib. + xyz_destination (tuple, optional): Same as `xyz` but for the destination of the + exit. + **kwargs: All other kwargs are passed on to the query. + + Returns: + django.db.queryset.Queryset: A queryset that can be combined + with further filtering. + + Notes: + Depending on what coordinates are set to `*`, this can be used to + e.g. find all exits in a room, or leading to a room or even to rooms + in a particular X/Y row/column. + + In the XYZgrid, `z_source != z_destination` means a _transit_ between different maps. + + """ + x, y, z = xyz + xdest, ydest, zdest = xyz_destination + wildcard = "*" + + return ( + self.filter_family(**kwargs) + .filter( + Q() + if x == wildcard + else Q(db_tags__db_key=str(x), db_tags__db_category=MAP_X_TAG_CATEGORY) + ) + .filter( + Q() + if y == wildcard + else Q(db_tags__db_key=str(y), db_tags__db_category=MAP_Y_TAG_CATEGORY) + ) + .filter( + Q() + if z == wildcard + else Q(db_tags__db_key__iexact=str(z), db_tags__db_category=MAP_Z_TAG_CATEGORY) + ) + .filter( + Q() + if xdest == wildcard + else Q(db_tags__db_key=str(xdest), db_tags__db_category=MAP_XDEST_TAG_CATEGORY) + ) + .filter( + Q() + if ydest == wildcard + else Q(db_tags__db_key=str(ydest), db_tags__db_category=MAP_YDEST_TAG_CATEGORY) + ) + .filter( + Q() + if zdest == wildcard + else Q( + db_tags__db_key__iexact=str(zdest), db_tags__db_category=MAP_ZDEST_TAG_CATEGORY + ) + ) + )
+ +
[docs] def get_xyz_exit(self, xyz=(0, 0, "map"), xyz_destination=(0, 0, "map"), **kwargs): + """ + Used by exits (objects with a source and -destination property). Get a single + exit. All source/destination coordinates (as well as the map's name) are required. + This will also find children of XYZExits on the given coords. + + Kwargs: + xyz (tuple, optional): A coordinate (X, Y, Z) for the source location. Each + element is either an `int` or `str` (not `*`, no wildcards are allowed for get). + the `Z`-coordinate is the name of the map (case-sensitive) in the XYZgrid contrib. + xyz_destination_coord (tuple, optional): Same as the `xyz` but for the destination of + the exit. + **kwargs: All other kwargs are passed on to the query. + + Returns: + XYZExit: A single exit instance found at the combination of x, y and xgiven. + + Raises: + XYZExit.DoesNotExist: If no matching query was found. + XYZExit.MultipleObjectsReturned: If more than one match was found (which should not + be possible with a unique combination of x,y,x). + + Notes: + All coordinates are required. + + """ + x, y, z = xyz + xdest, ydest, zdest = xyz_destination + # mimic get_family + paths = [self.model.path] + [ + "%s.%s" % (cls.__module__, cls.__name__) for cls in self._get_subclasses(self.model) + ] + kwargs["db_typeclass_path__in"] = paths + + try: + return ( + self.filter(db_tags__db_key__iexact=str(z), db_tags__db_category=MAP_Z_TAG_CATEGORY) + .filter(db_tags__db_key=str(x), db_tags__db_category=MAP_X_TAG_CATEGORY) + .filter(db_tags__db_key=str(y), db_tags__db_category=MAP_Y_TAG_CATEGORY) + .filter(db_tags__db_key=str(xdest), db_tags__db_category=MAP_XDEST_TAG_CATEGORY) + .filter(db_tags__db_key=str(ydest), db_tags__db_category=MAP_YDEST_TAG_CATEGORY) + .filter( + db_tags__db_key__iexact=str(zdest), db_tags__db_category=MAP_ZDEST_TAG_CATEGORY + ) + .get(**kwargs) + ) + except self.model.DoesNotExist: + inp = f"xyz=({x},{y},{z}),xyz_destination=({xdest},{ydest},{zdest})," + ",".join( + f"{key}={val}" for key, val in kwargs.items() + ) + raise self.model.DoesNotExist( + f"{self.model.__name__} matching query {inp} does not exist." + )
+ + +
[docs]class XYZRoom(DefaultRoom): + """ + A game location aware of its XYZ-position. + + Special properties: + map_display (bool): If the return_appearance of the room should + show the map or not. + map_mode (str): One of 'nodes' or 'scan'. See `return_apperance` + for examples of how they differ. + map_visual_range (int): How far on the map one can see. This is a + fixed value here, but could also be dynamic based on skills, + light etc. + map_character_symbol (str): The character symbol to use to show + the character position. Can contain color info. Default is + the @-character. + map_area_client (bool): If True, map area will always fill the entire + client width. If False, the map area's width will vary with the + width of the currently displayed location description. + map_fill_all (bool): I the map area should fill the client width or not. + map_separator_char (str): The char to use to separate the map area from + the room description. + + """ + + # makes the `room.objects.filter_xymap` available + objects = XYZManager() + + # default settings for map visualization + map_display = True + map_mode = "nodes" # or 'scan' + map_visual_range = 2 + map_character_symbol = "|g@|n" + map_align = "c" + map_target_path_style = "|y{display_symbol}|n" + map_fill_all = True + map_separator_char = "|x~|n" + + def __str__(self): + return repr(self) + + def __repr__(self): + x, y, z = self.xyz + return f"<XYZRoom '{self.db_key}', XYZ=({x},{y},{z})>" + + @property + def xyz(self): + if not hasattr(self, "_xyz"): + x = self.tags.get(category=MAP_X_TAG_CATEGORY, return_list=False) + y = self.tags.get(category=MAP_Y_TAG_CATEGORY, return_list=False) + z = self.tags.get(category=MAP_Z_TAG_CATEGORY, return_list=False) + if x is None or y is None or z is None: + # don't cache unfinished coordinate (probably tags have not finished saving) + return tuple( + int(coord) if coord is not None and coord.lstrip("-").isdigit() else coord + for coord in (x, y, z) + ) + # cache result, convert to correct types (tags are strings) + self._xyz = tuple( + int(coord) if coord.lstrip("-").isdigit() else coord for coord in (x, y, z) + ) + + return self._xyz + + @property + def xyzgrid(self): + global GET_XYZGRID + if not GET_XYZGRID: + from evennia.contrib.grid.xyzgrid.xyzgrid import get_xyzgrid as GET_XYZGRID + return GET_XYZGRID() + + @property + def xymap(self): + if not hasattr(self, "_xymap"): + xyzgrid = self.xyzgrid + _, _, Z = self.xyz + self._xymap = xyzgrid.get_map(Z) + return self._xymap + +
[docs] @classmethod + def create(cls, key, account=None, xyz=(0, 0, "map"), **kwargs): + """ + Creation method aware of XYZ coordinates. + + Args: + key (str): New name of object to create. + account (Account, optional): Any Account to tie to this entity (usually not used for + rooms). + xyz (tuple, optional): A 3D coordinate (X, Y, Z) for this room's location on a + map grid. Each element can theoretically be either `int` or `str`, but for the + XYZgrid, the X, Y are always integers while the `Z` coordinate is used for the + map's name. + **kwargs: Will be passed into the normal `DefaultRoom.create` method. + + Returns: + room (Object): A newly created Room of the given typeclass. + errors (list): A list of errors in string form, if any. + + Notes: + The (X, Y, Z) coordinate must be unique across the game. If trying to create + a room at a coordinate that already exists, an error will be returned. + + """ + try: + x, y, z = xyz + except ValueError: + return None, [ + f"XYRroom.create got `xyz={xyz}` - needs a valid (X,Y,Z) " + "coordinate of ints/strings." + ] + + existing_query = cls.objects.filter_xyz(xyz=(x, y, z)) + if existing_query.exists(): + existing_room = existing_query.first() + return None, [ + f"XYRoom XYZ=({x},{y},{z}) already exists " + f"(existing room is named '{existing_room.db_key}')!" + ] + + tags = ( + (str(x), MAP_X_TAG_CATEGORY), + (str(y), MAP_Y_TAG_CATEGORY), + (str(z), MAP_Z_TAG_CATEGORY), + ) + + return DefaultRoom.create(key, account=account, tags=tags, typeclass=cls, **kwargs)
+ +
[docs] def get_display_name(self, looker, **kwargs): + """ + Shows both the #dbref and the xyz coord to staff. + + Args: + looker (TypedObject): The object or account that is looking + at/getting inforamtion for this object. + + Returns: + name (str): A string containing the name of the object, + including the DBREF and XYZ coord if this user is + privileged to control the room. + + """ + if self.locks.check_lockstring(looker, "perm(Builder)"): + x, y, z = self.xyz + return f"{self.name}[#{self.id}({x},{y},{z})]" + return self.name
+ +
[docs] def return_appearance(self, looker, **kwargs): + """ + Displays the map in addition to the room description + + Args: + looker (Object): The one looking. + + Keyword Args: + map_display (bool): Turn on/off map display. + map_visual_range (int): How 'far' one can see on the map. For + 'nodes' mode, this is how many connected nodes away, for + 'scan' mode, this is number of characters away on the map. + Default is a visual range of 2 (nodes). + map_mode (str): One of 'node' (default) or 'scan'. + map_character_symbol (str): The character symbol to use. Defaults to '@'. + This can also be colored with standard color tags. Set to `None` + to just show the current node. + + Examples: + + Assume this is the full map (where '@' is the character location): + :: + #----------------# + | | + | | + # @------------#-# + | | + #----------------# + + This is how it will look in 'nodes' mode with `visual_range=2`: + :: + @------------#-# + + And in 'scan' mode with `visual_range=2`: + :: + | + | + # @-- + | + #---- + + Notes: + The map kwargs default to values with the same names set on the + XYZRoom class; these can be changed by overriding the room. + + We return the map display as a separate msg() call here, in order + to make it easier to break this out into a client pane etc. The + map is tagged with type='xymap'. + + """ + + # normal get_appearance of a room + room_desc = super().return_appearance(looker, **kwargs) + + # get current xymap + xyz = self.xyz + xymap = self.xyzgrid.get_map(xyz[2]) + + if xymap and kwargs.get("map_display", xymap.options.get("map_display", self.map_display)): + # show the near-area map. + map_character_symbol = kwargs.get( + "map_character_symbol", + xymap.options.get("map_character_symbol", self.map_character_symbol), + ) + map_visual_range = kwargs.get( + "map_visual_range", xymap.options.get("map_visual_range", self.map_visual_range) + ) + map_mode = kwargs.get("map_mode", xymap.options.get("map_mode", self.map_mode)) + map_align = kwargs.get("map_align", xymap.options.get("map_align", self.map_align)) + map_target_path_style = kwargs.get( + "map_target_path_style", + xymap.options.get("map_target_path_style", self.map_target_path_style), + ) + map_area_client = kwargs.get( + "map_fill_all", xymap.options.get("map_fill_all", self.map_fill_all) + ) + map_separator_char = kwargs.get( + "map_separator_char", + xymap.options.get("map_separator_char", self.map_separator_char), + ) + + sessions = looker.sessions.get() + if sessions: + client_width, _ = sessions[0].get_client_size() + else: + client_width = CLIENT_DEFAULT_WIDTH + + map_width = xymap.max_x + + if map_area_client: + display_width = client_width + else: + display_width = max(map_width, max(len(line) for line in room_desc.split("\n"))) + + # align map + map_indent = 0 + sep_width = display_width + if map_align == "r": + map_indent = max(0, display_width - map_width) + elif map_align == "c": + map_indent = max(0, (display_width - map_width) // 2) + + # data set by the goto/path-command, for displaying the shortest path + path_data = looker.ndb.xy_path_data + target_xy = path_data.target.xyz[:2] if path_data else None + + # get visual range display from map + map_display = xymap.get_visual_range( + (xyz[0], xyz[1]), + dist=map_visual_range, + mode=map_mode, + target=target_xy, + target_path_style=map_target_path_style, + character=map_character_symbol, + max_size=(display_width, None), + indent=map_indent, + ) + sep = map_separator_char * sep_width + map_display = f"{sep}|n\n{map_display}\n{sep}" + + # echo directly to make easier to separate in client + looker.msg(text=(map_display, {"type": "xymap"}), options=None) + + return room_desc
+ + +
[docs]class XYZExit(DefaultExit): + """ + An exit that is aware of the XYZ coordinate system. + + """ + + objects = XYZExitManager() + + def __str__(self): + return repr(self) + + def __repr__(self): + x, y, z = self.xyz + xd, yd, zd = self.xyz_destination + return f"<XYZExit '{self.db_key}', XYZ=({x},{y},{z})->({xd},{yd},{zd})>" + + @property + def xyzgrid(self): + global GET_XYZGRID + if not GET_XYZGRID: + from evennia.contrib.grid.xyzgrid.xyzgrid import get_xyzgrid as GET_XYZGRID + return GET_XYZGRID() + + @property + def xyz(self): + if not hasattr(self, "_xyz"): + x = self.tags.get(category=MAP_X_TAG_CATEGORY, return_list=False) + y = self.tags.get(category=MAP_Y_TAG_CATEGORY, return_list=False) + z = self.tags.get(category=MAP_Z_TAG_CATEGORY, return_list=False) + if x is None or y is None or z is None: + # don't cache yet unfinished coordinate + return (x, y, z) + # cache result + self._xyz = (x, y, z) + return self._xyz + + @property + def xyz_destination(self): + if not hasattr(self, "_xyz_destination"): + xd = self.tags.get(category=MAP_XDEST_TAG_CATEGORY, return_list=False) + yd = self.tags.get(category=MAP_YDEST_TAG_CATEGORY, return_list=False) + zd = self.tags.get(category=MAP_ZDEST_TAG_CATEGORY, return_list=False) + if xd is None or yd is None or zd is None: + # don't cache unfinished coordinate + return (xd, yd, zd) + # cache result + self._xyz_destination = (xd, yd, zd) + return self._xyz_destination + +
[docs] @classmethod + def create( + cls, + key, + account=None, + xyz=(0, 0, "map"), + xyz_destination=(0, 0, "map"), + location=None, + destination=None, + **kwargs, + ): + """ + Creation method aware of coordinates. + + Args: + key (str): New name of object to create. + account (Account, optional): Any Account to tie to this entity (unused for exits). + xyz (tuple or None, optional): A 3D coordinate (X, Y, Z) for this room's location + on a map grid. Each element can theoretically be either `int` or `str`, but for the + XYZgrid contrib, the X, Y are always integers while the `Z` coordinate is used for + the map's name. Set to `None` if instead using a direct room reference with + `location`. + xyz_destination (tuple, optional): The XYZ coordinate of the place the exit + leads to. Will be ignored if `destination` is given directly. + location (Object, optional): If given, overrides `xyz` coordinate. This can be used + to place this exit in any room, including non-XYRoom type rooms. + destination (Object, optional): If given, overrides `xyz_destination`. This can + be any room (including non-XYRooms) and is not checked for XYZ coordinates. + **kwargs: Will be passed into the normal `DefaultRoom.create` method. + + Returns: + tuple: A tuple `(exit, errors)`, where the errors is a list containing all found + errors (in which case the returned exit will be `None`). + + """ + tags = [] + if location: + source = location + else: + try: + x, y, z = xyz + except ValueError: + return None, ["XYExit.create need either `xyz=(X,Y,Z)` coordinate or a `location`."] + else: + source = XYZRoom.objects.get_xyz(xyz=(x, y, z)) + tags.extend( + ( + (str(x), MAP_X_TAG_CATEGORY), + (str(y), MAP_Y_TAG_CATEGORY), + (str(z), MAP_Z_TAG_CATEGORY), + ) + ) + if destination: + dest = destination + else: + try: + xdest, ydest, zdest = xyz_destination + except ValueError: + return None, [ + "XYExit.create need either `xyz_destination=(X,Y,Z)` coordinate " + "or a `destination`." + ] + else: + dest = XYZRoom.objects.get_xyz(xyz=(xdest, ydest, zdest)) + tags.extend( + ( + (str(xdest), MAP_XDEST_TAG_CATEGORY), + (str(ydest), MAP_YDEST_TAG_CATEGORY), + (str(zdest), MAP_ZDEST_TAG_CATEGORY), + ) + ) + + return DefaultExit.create( + key, source, dest, account=account, tags=tags, typeclass=cls, **kwargs + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/buffs/buff.html b/docs/latest/_modules/evennia/contrib/rpg/buffs/buff.html new file mode 100644 index 0000000000..77cfb23d1c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/buffs/buff.html @@ -0,0 +1,1319 @@ + + + + + + + + evennia.contrib.rpg.buffs.buff — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.buffs.buff

+"""
+Buffs - Tegiminis 2022
+
+A buff is a timed object, attached to a game entity, that modifies values, triggers 
+code, or both. It is a common design pattern in RPGs, particularly action games.
+
+This contrib gives you a buff handler to apply to your objects, a buff class to extend them,
+a sample property class to show how to automatically check modifiers, some sample buffs to learn from,
+and a command which applies buffs.
+
+## Installation
+Assign the handler to a property on the object, like so.
+
+```python
+@lazy_property
+def buffs(self) -> BuffHandler:
+    return BuffHandler(self)```
+
+## Using the Handler
+
+To make use of the handler, you will need:
+
+- Some buffs to add. You can create these by extending the `BaseBuff` class from this module. You can see some examples in `samplebuffs.py`.
+- A way to add buffs to the handler. You can see a basic example of this in the `CmdBuff` command in this module.
+
+### Applying a Buff
+
+Call the handler `add(BuffClass)` method. This requires a class reference, and also contains a number of 
+optional arguments to customize the buff's duration, stacks, and so on.
+
+```python
+self.buffs.add(StrengthBuff)    # A single stack of StrengthBuff with normal duration
+self.buffs.add(DexBuff, stacks=3, duration=60)  # Three stacks of DexBuff, with a duration of 60 seconds
+self.buffs.add(ReflectBuff, to_cache={'reflect': 0.5})  # A single stack of ReflectBuff, with an extra cache value
+```
+
+### Modify
+
+Call the handler `check(value, stat)` method wherever you want to see the modified value. 
+This will return the value, modified by and relevant buffs on the handler's owner (identified by 
+the `stat` string). For example:
+
+```python
+# The method we call to damage ourselves
+def take_damage(self, source, damage):
+    _damage = self.buffs.check(damage, 'taken_damage')
+    self.db.health -= _damage
+```
+
+### Trigger
+
+Call the handler `trigger(triggerstring)` method wherever you want an event call. This 
+will call the `at_trigger` hook method on all buffs with the relevant trigger.
+
+```python
+def Detonate(BaseBuff):
+    ...
+    triggers = ['take_damage']
+    def at_trigger(self, trigger, *args, **kwargs)
+        self.owner.take_damage(100)
+        self.remove()
+
+def Character(Character):
+    ...
+    def take_damage(self, source, damage):
+        self.buffs.trigger('take_damage')
+        self.db.health -= _damage
+```
+
+### Tick
+
+Ticking a buff happens automatically once applied, as long as the buff's `tickrate` is more than 0.
+
+```python
+def Poison(BaseBuff):
+    ...
+    tickrate = 5
+    def at_tick(self, initial=True, *args, **kwargs):
+        _dmg = self.dmg * self.stacks
+        if not initial:
+            self.owner.location.msg_contents(
+                "Poison courses through {actor}'s body, dealing {damage} damage.".format(
+                    actor=self.owner.named, damage=_dmg
+                )
+            )
+```
+
+## Buffs
+
+A buff is a class which contains a bunch of immutable data about itself - such as tickrate, triggers, refresh rules, and
+so on - and which merges mutable data in from the cache when called.
+
+Buffs are always instanced when they are called for a method. To access a buff's properties and methods, you should do so through
+this instance, rather than directly manipulating the buff cache on the object. You can modify a buff's cache through various handler
+methods instead.
+
+You can see all the features of the `BaseBuff` class below, or browse `samplebuffs.py` to see how to create some common buffs. Buffs have
+many attributes and hook methods you can overload to create complex, interrelated buffs.
+
+"""
+import time
+from random import random
+
+from evennia import Command
+from evennia.server import signals
+from evennia.typeclasses.attributes import AttributeProperty
+from evennia.utils import search, utils
+
+
+
[docs]class BaseBuff: + key = "template" # The buff's unique key. Will be used as the buff's key in the handler + name = "Template" # The buff's name. Used for user messaging + flavor = "Template" # The buff's flavor text. Used for user messaging + visible = True # If the buff is considered "visible" to the "view" method + + triggers = [] # The effect's trigger strings, used for functions. + + handler = None + start = 0 + + duration = -1 # Default buff duration; -1 for permanent, 0 for "instant", >0 normal + playtime = False # Does this buff autopause when owning object is unpuppeted? + + refresh = True # Does the buff refresh its timer on application? + unique = True # Does the buff overwrite existing buffs with the same key on the same target? + maxstacks = 1 # The maximum number of stacks the buff can have. If >1, this buff will stack. + stacks = 1 # Used as the default when applying this buff if no or negative stacks were specified (min: 1) + tickrate = 0 # How frequent does this buff tick, in seconds (cannot be lower than 1) + + mods = [] # List of mod objects. See Mod class below for more detail + cache = {} + + @property + def ticknum(self): + """Returns how many ticks this buff has gone through as an integer.""" + x = (time.time() - self.start) / max(1, self.tickrate) + return int(x) + + @property + def owner(self): + """Return this buff's owner (the object its handler is attached to)""" + if not self.handler: + return None + return self.handler.owner + + @property + def timeleft(self): + """Returns how much time this buff has left. If -1, it is permanent.""" + _tl = 0 + if not self.start: + _tl = self.duration + else: + _tl = max(-1, self.duration - (time.time() - self.start)) + return _tl + + @property + def ticking(self) -> bool: + """Returns if this buff ticks or not (tickrate => 1)""" + return self.tickrate >= 1 + + @property + def stacking(self) -> bool: + """Returns if this buff stacks or not (maxstacks > 1)""" + return self.maxstacks > 1 + +
[docs] def __init__(self, handler, buffkey, cache) -> None: + """ + Args: + handler: The handler this buff is attached to + buffkey: The key this buff uses on the cache + cache: The cache dictionary (what you get if you use `handler.buffcache.get(key)`) + """ + required = {"handler": handler, "buffkey": buffkey, "cache": cache} + self.__dict__.update(cache) + self.__dict__.update(required) + # Init hook + self.at_init()
+ + def __setattr__(self, attr, value): + if attr in self.cache: + if attr == "tickrate": + value = max(0, value) + self.handler.buffcache[self.buffkey][attr] = value + super().__setattr__(attr, value) + +
[docs] def conditional(self, *args, **kwargs): + """Hook function for conditional evaluation. + + This must return True for a buff to apply modifiers, trigger effects, or tick.""" + return True
+ + # region helper methods +
[docs] def remove(self, loud=True, expire=False, context=None): + """Helper method which removes this buff from its handler. Use dispel if you are dispelling it instead. + + Args: + loud: (optional) Whether to call at_remove or not (default: True) + expire: (optional) Whether to call at_expire or not (default: False) + delay: (optional) How long you want to delay the remove call for + context: (optional) A dictionary you wish to pass to the at_remove/at_expire method as kwargs + """ + if not context: + context = {} + self.handler.remove(self.buffkey, loud=loud, expire=expire, context=context)
+ +
[docs] def dispel(self, loud=True, delay=0, context=None): + """Helper method which dispels this buff (removes and calls at_dispel). + + Args: + loud: (optional) Whether to call at_remove or not (default: True) + delay: (optional) How long you want to delay the remove call for + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel method as kwargs + """ + if not context: + context = {} + self.handler.remove(self.buffkey, loud=loud, dispel=True, delay=delay, context=context)
+ +
[docs] def pause(self, context=None): + """Helper method which pauses this buff on its handler. + + Args: + context: (optional) A dictionary you wish to pass to the at_pause method as kwargs""" + if not context: + context = {} + self.handler.pause(self.buffkey, context)
+ +
[docs] def unpause(self, context=None): + """Helper method which unpauses this buff on its handler. + + Args: + context: (optional) A dictionary you wish to pass to the at_unpause method as kwargs + """ + if not context: + context = {} + self.handler.unpause(self.buffkey, context)
+ +
[docs] def reset(self): + """Resets the buff start time as though it were just applied; functionally identical to a refresh""" + self.start = time.time() + self.handler.buffcache[self.buffkey]["start"] = time.time()
+ +
[docs] def update_cache(self, to_cache: dict): + """Updates this buff's cache using the given values, both internally (this instance) and on the handler. + + Args: + to_cache: The dictionary of values you want to add to the cache""" + if not isinstance(to_cache, dict): + raise TypeError + _cache = dict(self.handler.buffcache[self.buffkey]) + _cache.update(to_cache) + self.cache = _cache + self.handler.buffcache[self.buffkey] = _cache
+ + # endregion + + # region hook methods +
[docs] def at_init(self, *args, **kwargs): + """Hook function called when this buff object is initialized.""" + pass
+ +
[docs] def at_apply(self, *args, **kwargs): + """Hook function to run when this buff is applied to an object.""" + pass
+ +
[docs] def at_remove(self, *args, **kwargs): + """Hook function to run when this buff is removed from an object.""" + pass
+ +
[docs] def at_dispel(self, *args, **kwargs): + """Hook function to run when this buff is dispelled from an object (removed by someone other than the buff holder).""" + pass
+ +
[docs] def at_expire(self, *args, **kwargs): + """Hook function to run when this buff expires from an object.""" + pass
+ +
[docs] def at_pre_check(self, *args, **kwargs): + """Hook function to run before this buff's modifiers are checked.""" + pass
+ +
[docs] def at_post_check(self, *args, **kwargs): + """Hook function to run after this buff's mods are checked.""" + pass
+ +
[docs] def at_trigger(self, trigger: str, *args, **kwargs): + """Hook for the code you want to run whenever the effect is triggered. + Passes the trigger string to the function, so you can have multiple + triggers on one buff.""" + pass
+ +
[docs] def at_tick(self, initial: bool, *args, **kwargs): + """Hook for actions that occur per-tick, a designer-set sub-duration. + `initial` tells you if it's the first tick that happens (when a buff is applied).""" + pass
+ +
[docs] def at_pause(self, *args, **kwargs): + """Hook for when this buff is paused""" + pass
+ +
[docs] def at_unpause(self, *args, **kwargs): + """Hook for when this buff is unpaused.""" + pass
+ + # endregion + + +
[docs]class Mod: + """A single stat mod object. One buff or trait can hold multiple mods, for the same or different stats.""" + + stat = "null" # The stat string that is checked to see if this mod should be applied + value = 0 # Buff's value + perstack = 0 # How much additional value is added to the buff per stack + modifier = "add" # The modifier the buff applies. 'add' or 'mult' + +
[docs] def __init__(self, stat: str, modifier: str, value, perstack=0.0) -> None: + """ + Args: + stat: The stat the buff affects. Normally matches the object attribute name + mod: The modifier the buff applies. "add" for add/sub or "mult" for mult/div + value: The value of the modifier + perstack: How much is added to the base, per stack (including first).""" + self.stat = stat + self.modifier = modifier + self.value = value + self.perstack = perstack
+ + +
[docs]class BuffHandler: + ownerref = None + dbkey = "buffs" + autopause = False + _owner = None + +
[docs] def __init__(self, owner, dbkey=dbkey, autopause=autopause): + """ + Args: + owner: The object this handler is attached to + dbkey: (optional) The string key of the db attribute to use for the buff cache + autopause: (optional) Whether this handler autopauses playtime buffs on owning object's unpuppet + """ + self.ownerref = owner.dbref + self.dbkey = dbkey + self.autopause = autopause + if autopause: + self._validate_state() + signals.SIGNAL_OBJECT_POST_UNPUPPET.connect(self._pause_playtime) + signals.SIGNAL_OBJECT_POST_PUPPET.connect(self._unpause_playtime)
+ + # region properties + @property + def owner(self): + """The object this handler is attached to.""" + if self.ownerref: + _owner = search.search_object(self.ownerref) + if _owner: + return _owner[0] + else: + return None + + @property + def buffcache(self): + """The object attribute we use for the buff cache. Auto-creates if not present.""" + if not self.owner: + return {} + if not self.owner.attributes.has(self.dbkey): + self.owner.attributes.add(self.dbkey, {}) + return self.owner.attributes.get(self.dbkey) + + @property + def traits(self): + """All buffs on this handler that modify a stat.""" + _cache = self.all + _t = {k: buff for k, buff in _cache.items() if buff.mods} + return _t + + @property + def effects(self): + """All buffs on this handler that trigger off an event.""" + _cache = self.all + _e = {k: buff for k, buff in _cache.items() if buff.triggers} + return _e + + @property + def playtime(self): + """All buffs on this handler that only count down during active playtime.""" + _cache = self.all + _pt = {k: buff for k, buff in _cache.items() if buff.playtime} + return _pt + + @property + def paused(self): + """All buffs on this handler that are paused.""" + _cache = self.all + _p = {k: buff for k, buff in _cache.items() if buff.paused} + return _p + + @property + def expired(self): + """All buffs on this handler that have expired (no duration or no stacks).""" + _cache = self.all + _e = { + k: buff + for k, buff in _cache.items() + if not buff.paused + if buff.duration > -1 + if buff.duration < time.time() - buff.start + } + _nostacks = {k: buff for k, buff in _cache.items() if buff.stacks <= 0} + _e.update(_nostacks) + return _e + + @property + def visible(self): + """All buffs on this handler that are visible.""" + _cache = self.all + _v = {k: buff for k, buff in _cache.items() if buff.visible} + return _v + + @property + def all(self): + """Returns dictionary of instanced buffs equivalent to ALL buffs on this handler, + regardless of state, type, or anything else.""" + _a = self.get_all() + return _a + + # endregion + + # region methods +
[docs] def add( + self, + buff: BaseBuff, + key: str = None, + stacks=0, + duration=None, + source=None, + to_cache=None, + context=None, + *args, + **kwargs, + ): + """Add a buff to this object, respecting all stacking/refresh/reapplication rules. Takes + a number of optional parameters to allow for customization. + + Args: + buff: The buff class type you wish to add + key: (optional) The key you wish to use for this buff; overrides defaults + stacks: (optional) The number of stacks you want to add, if the buff is stacking + duration: (optional) The amount of time, in seconds, you want the buff to last; overrides defaults + source: (optional) The source of this buff. (default: None) + to_cache: (optional) A dictionary to store in the buff's cache; does not overwrite default cache keys + context: (optional) A dictionary you wish to pass to the at_apply method as kwargs + """ + if not isinstance(buff, type): + raise ValueError + if not context: + context = {} + b = {} + _context = dict(context) + + # Initial cache updating, starting with the class cache attribute and/or to_cache + if buff.cache: + b = dict(buff.cache) + if to_cache: + b.update(dict(to_cache)) + + # Guarantees we stack either at least 1 stack or whatever the class stacks attribute is + if stacks < 1: + stacks = min(1, buff.stacks) + + # Create the buff dict that holds a reference and all runtime information. + b.update( + { + "ref": buff, + "start": time.time(), + "duration": buff.duration, + "tickrate": buff.tickrate, + "prevtick": time.time(), + "paused": False, + "stacks": stacks, + "source": source, + } + ) + + # Generate the buffkey from the object's dbref and the default buff key. + # This is the actual key the buff uses on the dictionary + buffkey = key + if not buffkey: + if source: + mix = str(source.dbref).replace("#", "") + elif not (buff.unique or buff.refresh) or not source: + mix = "_ufrf" + str(int((random() * 999999) * 100000)) + + buffkey = buff.key if buff.unique is True else buff.key + mix + + # Rules for applying over an existing buff + if buffkey in self.buffcache.keys(): + existing = dict(self.buffcache[buffkey]) + # Stacking + if buff.maxstacks > 1: + b["stacks"] = min(existing["stacks"] + stacks, buff.maxstacks) + elif buff.maxstacks < 1: + b["stacks"] = existing["stacks"] + stacks + # refresh rule for uniques + if not buff.refresh: + b["duration"] = existing["duration"] + # Carrying over old arbitrary cache values + cur_cache = {k: v for k, v in existing.items() if k not in b.keys()} + b.update(cur_cache) + # Setting overloaded duration + if duration: + b["duration"] = duration + + # Apply the buff! + self.buffcache[buffkey] = b + + # Create the buff instance and run the on-application hook method + instance: BaseBuff = buff(self, buffkey, b) + instance.at_apply(**_context) + if instance.ticking: + tick_buff(self, buffkey, _context) + + # Clean up the buff at the end of its duration through a delayed cleanup call + if b["duration"] > -1: + utils.delay(b["duration"], self.cleanup, persistent=True)
+ + # region removers +
[docs] def remove(self, key, stacks=0, loud=True, dispel=False, expire=False, context=None): + """Remove a buff or effect with matching key from this object. Normally calls at_remove, + calls at_expire if the buff expired naturally, and optionally calls at_dispel. Can also + remove stacks instead of the entire buff (still calls at_remove). Typically called via a helper method + on the buff instance, or other methods on the handler. + + Args: + key: The buff key + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + if not context: + context = {} + if key not in self.buffcache: + return + + buff: BaseBuff = self.buffcache[key] + instance: BaseBuff = buff["ref"](self, key, buff) + + if loud: + if dispel: + instance.at_dispel(**context) + elif expire: + instance.at_expire(**context) + instance.at_remove(**context) + + del instance + if not stacks: + del self.buffcache[key] + elif stacks: + self.buffcache[key]["stacks"] -= stacks + if self.buffcache[key]["stacks"] <= 0: + del self.buffcache[key]
+ +
[docs] def remove_by_type( + self, + bufftype: BaseBuff, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs of a specified type from this object. Functionally similar to remove, but takes a type instead. + + Args: + bufftype: The buff class to remove + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_type(bufftype) + if not _remove: + return + self._remove_via_dict(_remove, loud, dispel, expire, context)
+ +
[docs] def remove_by_stat( + self, + stat, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs modifying the specified stat from this object. + + Args: + stat: The stat string to search for + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_stat(stat) + if not _remove: + return + self._remove_via_dict(_remove, loud, dispel, expire, context)
+ +
[docs] def remove_by_trigger( + self, + trigger, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs with the specified trigger from this object. + + Args: + trigger: The stat string to search for + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_trigger(trigger) + if not _remove: + return + self._remove_via_dict(_remove, loud, dispel, expire, context)
+ +
[docs] def remove_by_source( + self, + source, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs from the specified source from this object. + + Args: + source: The source to search for + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_source(source) + if not _remove: + return + self._remove_via_dict(_remove, loud, dispel, expire, context)
+ +
[docs] def remove_by_cachevalue( + self, + key, + value=None, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs with the cachevalue from this object. Functionally similar to remove, but checks the buff's cache values instead. + + Args: + key: The key of the cache value to check + value: (optional) The value to match to. If None, merely checks to see if the value exists + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_cachevalue(key, value) + if not _remove: + return + self._remove_via_dict(_remove, loud, dispel, expire, context)
+ +
[docs] def clear(self, loud=True, dispel=False, expire=False, context=None): + """Removes all buffs on this handler""" + cache = self.all + self._remove_via_dict(cache, loud, dispel, expire, context)
+ + # endregion + # region getters +
[docs] def get(self, key: str): + """If the specified key is on this handler, return the instanced buff. Otherwise return None. + You should delete this when you're done with it, so that garbage collection doesn't have to. + + Args: + key: The key for the buff you wish to get""" + buff = self.buffcache.get(key) + if buff: + return buff["ref"](self, key, buff) + else: + return None
+ +
[docs] def get_all(self): + """Returns a dictionary of instanced buffs (all of them) on this handler in the format {buffkey: instance}""" + _cache = dict(self.buffcache) + if not _cache: + return {} + return {k: buff["ref"](self, k, buff) for k, buff in _cache.items()}
+ +
[docs] def get_by_type(self, buff: BaseBuff, to_filter=None): + """Finds all buffs matching the given type. + + Args: + buff: The buff class to search for + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs of the specified type in the format {buffkey: instance}. + """ + _cache = self.get_all() if not to_filter else to_filter + return {k: _buff for k, _buff in _cache.items() if isinstance(_buff, buff)}
+ +
[docs] def get_by_stat(self, stat: str, to_filter=None): + """Finds all buffs which contain a Mod object that modifies the specified stat. + + Args: + stat: The string identifier to find relevant mods + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs which modify the specified stat in the format {buffkey: instance}. + """ + _cache = self.traits if not to_filter else to_filter + buffs = {k: buff for k, buff in _cache.items() for m in buff.mods if m.stat == stat} + return buffs
+ +
[docs] def get_by_trigger(self, trigger: str, to_filter=None): + """Finds all buffs with the matching string in their triggers. + + Args: + trigger: The string identifier to find relevant buffs + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs which fire off the designated trigger, in the format {buffkey: instance}. + """ + _cache = self.effects if not to_filter else to_filter + buffs = {k: buff for k, buff in _cache.items() if trigger in buff.triggers} + return buffs
+ +
[docs] def get_by_source(self, source, to_filter=None): + """Find all buffs with the matching source. + + Args: + source: The source you want to filter buffs by + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs which came from the provided source, in the format {buffkey: instance}. + """ + _cache = self.all if not to_filter else to_filter + buffs = {k: buff for k, buff in _cache.items() if buff.source == source} + return buffs
+ +
[docs] def get_by_cachevalue(self, key, value=None, to_filter=None): + """Find all buffs with a matching {key: value} pair in its cache. Allows you to search buffs by arbitrary cache values + + Args: + key: The key of the cache value to check + value: (optional) The value to match to. If None, merely checks to see if the value exists + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs with cache values matching the specified value, in the format {buffkey: instance}. + """ + _cache = self.all if not to_filter else to_filter + if not value: + buffs = {k: buff for k, buff in _cache.items() if buff.cache.get(key)} + elif value: + buffs = {k: buff for k, buff in _cache.items() if buff.cache.get(key) == value} + return buffs
+ + # endregion + +
[docs] def has(self, buff=None) -> bool: + """Checks if the specified buff type or key exists on the handler. + + Args: + buff: The buff to search for. This can be a string (the key) or a class reference (the buff type) + + Returns a bool. If no buff and no key is specified, returns False.""" + if not buff: + return False + if not (isinstance(buff, type) or isinstance(buff, str)): + raise TypeError + + if isinstance(buff, str): + for k in self.buffcache.keys(): + if k == buff: + return True + if isinstance(buff, type): + for b in self.buffcache.values(): + if b.get("ref") == buff: + return True + return False
+ +
[docs] def check( + self, value: float, stat: str, loud=True, context=None, trigger=False, strongest=False + ): + """Finds all buffs and perks related to a stat and applies their effects. + + Args: + value: The value you intend to modify + stat: The string that designates which stat buffs you want + loud: (optional) Call the buff's at_post_check method after checking (default: True) + context: (optional) A dictionary you wish to pass to the at_pre_check/at_post_check and conditional methods as kwargs + trigger: (optional) Trigger buffs with the `stat` string as well. (default: False) + strongest: (optional) Applies only the strongest mods of the corresponding stat value (default: False) + + Returns the value modified by relevant buffs.""" + # Buff cleanup to make sure all buffs are valid before processing + self.cleanup() + + # Find all buffs and traits related to the specified stat. + if not context: + context = {} + applied = self.get_by_stat(stat) + if not applied: + return value + + # Run pre-check hooks on related buffs + for buff in applied.values(): + buff.at_pre_check(**context) + + # Sift out buffs that won't be applying their mods (paused, conditional) + applied = { + k: buff for k, buff in applied.items() if buff.conditional(**context) if not buff.paused + } + + # The mod totals + calc = self._calculate_mods(stat, applied) + + # The calculated final value + final = self._apply_mods(value, calc, strongest=strongest) + + # Run the "after check" functions on all relevant buffs + for buff in applied.values(): + buff: BaseBuff + if loud: + buff.at_post_check(**context) + del buff + + # If you want to, also trigger buffs with the same stat string + if trigger: + self.trigger(stat, context) + + return final
+ +
[docs] def trigger(self, trigger: str, context: dict = None): + """Calls the at_trigger method on all buffs with the matching trigger. + + Args: + trigger: The string identifier to find relevant buffs. Passed to the at_trigger method. + context: (optional) A dictionary you wish to pass to the at_trigger method as kwargs + """ + self.cleanup() + _effects = self.get_by_trigger(trigger) + if not _effects: + return + if not context: + context = {} + + _to_trigger = { + k: buff + for k, buff in _effects.items() + if buff.conditional(**context) + if not buff.paused + if trigger in buff.triggers + } + + # Trigger all buffs whose trigger matches the trigger string + for buff in _to_trigger.values(): + buff: BaseBuff + buff.at_trigger(trigger, **context)
+ +
[docs] def pause(self, key: str, context=None): + """Pauses the buff. This excludes it from being checked for mods, triggered, or cleaned up. Used to make buffs 'playtime' instead of 'realtime'. + + Args: + key: The key for the buff you wish to pause + context: (optional) A dictionary you wish to pass to the at_pause method as kwargs + """ + if key in self.buffcache.keys(): + # Mark the buff as paused + buff = dict(self.buffcache.get(key)) + if buff["paused"]: + return + if not context: + context = {} + buff["paused"] = True + + # Math assignments + current = time.time() # Current Time + start = buff["start"] # Start + duration = buff["duration"] # Duration + prevtick = buff["prevtick"] # Previous tick timestamp + tickrate = buff["tickrate"] # Buff's tick rate + end = start + duration # End + + # Setting "tickleft" + if buff["ref"].ticking: + buff["tickleft"] = max(1, tickrate - (current - prevtick)) + + # Setting the new duration (if applicable) + if duration > -1: + newduration = end - current # New duration + if newduration > 0: + buff["duration"] = newduration + else: + self.remove(key) + + # Apply new cache info, call pause hook + self.buffcache[key] = buff + instance: BaseBuff = buff["ref"](self, key, buff) + instance.at_pause(**context)
+ +
[docs] def unpause(self, key: str, context=None): + """Unpauses a buff. This makes it visible to the various buff systems again. + + Args: + key: The key for the buff you wish to pause + context: (optional) A dictionary you wish to pass to the at_unpause method as kwargs + """ + if key in self.buffcache.keys(): + # Mark the buff as unpaused + buff = dict(self.buffcache.get(key)) + if not buff["paused"]: + return + if not context: + context = {} + buff["paused"] = False + + # Math assignments + tickrate = buff["ref"].tickrate + if buff["ref"].ticking: + tickleft = buff["tickleft"] + current = time.time() # Current Time + + # Start our new timer, adjust prevtick + buff["start"] = current + if buff["ref"].ticking: + buff["prevtick"] = current - (tickrate - tickleft) + + # Apply new cache info, call hook + self.buffcache[key] = buff + instance: BaseBuff = buff["ref"](self, key, buff) + instance.at_unpause(**context) + + # Set up typical delays (cleanup/ticking) + if instance.duration > -1: + utils.delay(buff["duration"], cleanup_buffs, self, persistent=True) + if instance.ticking: + utils.delay( + tickrate, tick_buff, handler=self, buffkey=key, initial=False, persistent=True + )
+ +
[docs] def view(self, to_filter=None) -> dict: + """Returns a buff flavor text as a dictionary of tuples in the format {key: (name, flavor)}. Common use for this is a buff readout of some kind. + + Args: + to_filter: (optional) The dictionary of buffs to iterate over. If none is provided, returns all buffs (default: None) + """ + if not isinstance(to_filter, dict): + raise TypeError + self.cleanup() + _cache = self.visible if not to_filter else to_filter + _flavor = {k: (buff.name, buff.flavor) for k, buff in _cache.items()} + return _flavor
+ +
[docs] def view_modifiers(self, stat: str, context=None): + """Checks all modifiers of the specified stat without actually applying them. Hits the conditional hook for relevant buffs. + + Args: + stat: The mod identifier string to search for + context: (optional) A dictionary you wish to pass to the conditional hooks as kwargs + + Returns a nested dictionary. The first layer's keys represent the type of modifier ('add' and 'mult'), + and the second layer's keys represent the type of value ('total' and 'strongest').""" + # Buff cleanup to make sure all buffs are valid before processing + self.cleanup() + + # Find all buffs and traits related to the specified stat. + if not context: + context = {} + applied = self.get_by_stat(stat) + if not applied: + return None + + # Sift out buffs that won't be applying their mods (paused, conditional) + applied = { + k: buff for k, buff in applied.items() if buff.conditional(**context) if not buff.paused + } + + # Calculate and return our values dictionary + calc = self._calculate_mods(stat, applied) + return calc
+ +
[docs] def cleanup(self): + """Removes expired buffs, ensures pause state is respected.""" + self._validate_state() + cleanup_buffs(self)
+ + # region private methods + def _validate_state(self): + """Validates the state of paused/unpaused playtime buffs.""" + if not self.autopause: + return + if self.owner.has_account: + self._unpause_playtime() + elif not self.owner.has_account: + self._pause_playtime() + + def _pause_playtime(self, sender=owner, **kwargs): + """Pauses all playtime buffs when attached object is unpuppeted.""" + if sender != self.owner: + return + buffs = self.playtime + if not buffs: + return + for buff in buffs.values(): + buff.pause() + + def _unpause_playtime(self, sender=owner, **kwargs): + """Unpauses all playtime buffs when attached object is puppeted.""" + if sender != self.owner: + return + buffs = self.playtime + if not buffs: + return + for buff in buffs.values(): + buff.unpause() + pass + + def _calculate_mods(self, stat: str, buffs: dict): + """Calculates the total value of applicable mods. + + Args: + stat: The string identifier to search mods for + buffs: The dictionary of buffs to calculate mods from + + Returns a nested dictionary. The first layer's keys represent the type of modifier ('add' and 'mult'), + and the second layer's keys represent the type of value ('total' and 'strongest').""" + + # The base return dictionary. If you update how modifiers are calculated, make sure to update this too, or you will get key errors! + calculated = { + "add": {"total": 0, "strongest": 0}, + "mult": {"total": 0, "strongest": 0}, + "div": {"total": 0, "strongest": 0}, + } + if not buffs: + return calculated + + for buff in buffs.values(): + for mod in buff.mods: + buff: BaseBuff + mod: Mod + if mod.stat == stat: + _modval = mod.value + ((buff.stacks) * mod.perstack) + calculated[mod.modifier]["total"] += _modval + if _modval > calculated[mod.modifier]["strongest"]: + calculated[mod.modifier]["strongest"] = _modval + return calculated + + def _apply_mods(self, value, calc: dict, strongest=False): + """Applies modifiers to a value. + + Args: + value: The value to modify + calc: The dictionary of calculated modifier values (see _calculate_mods) + strongest: (optional) Applies only the strongest mods of the corresponding stat value (default: False) + + Returns value modified by the relevant mods.""" + final = value + if strongest: + final = ( + (value + calc["add"]["strongest"]) + / max(1, 1.0 + calc["div"]["strongest"]) + * max(0, 1.0 + calc["mult"]["strongest"]) + ) + else: + final = ( + (value + calc["add"]["total"]) + / max(1, 1.0 + calc["div"]["total"]) + * max(0, 1.0 + calc["mult"]["total"]) + ) + return final + + def _remove_via_dict(self, buffs: dict, loud=True, dispel=False, expire=False, context=None): + """Removes buffs within the provided dictionary from this handler. Used for remove methods besides the basic remove.""" + if not context: + context = {} + if not buffs: + return + for k, instance in buffs.items(): + instance: BaseBuff + if loud: + if dispel: + instance.at_dispel(**context) + elif expire: + instance.at_expire(**context) + instance.at_remove(**context) + del instance + del self.buffcache[k]
+ + # endregion + # endregion + + +
[docs]class BuffableProperty(AttributeProperty): + """An example of a way you can extend AttributeProperty to create properties that automatically check buffs for you.""" + +
[docs] def at_get(self, value, obj): + _value = obj.buffs.check(value, self._key) + return _value
+ + +
[docs]class CmdBuff(Command): + """ + Buff a target. + + Usage: + buff <target> <buff> + + Applies the specified buff to the target. All buffs are defined in the bufflist dictionary on this command. + """ + + key = "buff" + aliases = ["buff"] + help_category = "builder" + + bufflist = {"foo": BaseBuff} + +
[docs] def parse(self): + self.args = self.args.split()
+ +
[docs] def func(self): + caller = self.caller + target = None + now = time.time() + + if self.args: + target = caller.search(self.args[0]) + caller.ndb.target = target + elif caller.ndb.target: + target = caller.ndb.target + else: + caller.msg("You need to pick a target to buff.") + return + + if self.args[1] not in self.bufflist.keys(): + caller.msg("You must pick a valid buff.") + return + + if target: + target.buffs.add(self.bufflist[self.args[1]], source=caller) + pass
+ + +
[docs]def cleanup_buffs(handler: BuffHandler): + """Cleans up all expired buffs from a handler.""" + _remove = handler.expired + for v in _remove.values(): + v.remove(expire=True)
+ + +
[docs]def tick_buff(handler: BuffHandler, buffkey: str, context=None, initial=True): + """Ticks a buff. If a buff's tickrate is 1 or larger, this is called when the buff is applied, and then once per tick cycle. + + Args: + handler: The handler managing the ticking buff + buffkey: The key of the ticking buff + context: (optional) A dictionary you wish to pass to the at_tick method as kwargs + initial: (optional) Whether this tick_buff call is the first one. Starts True, changes to False for future ticks + """ + # Cache a reference and find the buff on the object + if buffkey not in handler.buffcache.keys(): + return + if not context: + context = {} + + # Instantiate the buff and tickrate + buff: BaseBuff = handler.get(buffkey) + tr = max(1, buff.tickrate) + + # This stops the old ticking process if you refresh/stack the buff + if (tr > time.time() - buff.prevtick and initial != True) or buff.paused: + return + + # Only fire the at_tick methods if the conditional is truthy + if buff.conditional(): + # Always tick this buff on initial + if initial: + buff.at_tick(initial, **context) + + # Tick this buff one last time, then remove + if buff.duration > -1 and buff.duration <= time.time() - buff.start: + if tr < time.time() - buff.prevtick: + buff.at_tick(initial, **context) + buff.remove(expire=True) + return + + # Tick this buff on-time + if tr <= time.time() - buff.prevtick: + buff.at_tick(initial, **context) + + handler.buffcache[buffkey]["prevtick"] = time.time() + tr = max(1, buff.tickrate) + + # Recur this function at the tickrate interval, if it didn't stop/fail + utils.delay( + tr, + tick_buff, + handler=handler, + buffkey=buffkey, + context=context, + initial=False, + persistent=True, + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/buffs/samplebuffs.html b/docs/latest/_modules/evennia/contrib/rpg/buffs/samplebuffs.html new file mode 100644 index 0000000000..19b584e07b --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/buffs/samplebuffs.html @@ -0,0 +1,247 @@ + + + + + + + + evennia.contrib.rpg.buffs.samplebuffs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.buffs.samplebuffs

+import random
+
+from .buff import BaseBuff, Mod
+
+
+
[docs]class Exploit(BaseBuff): + key = "exploit" + name = "Exploit" + flavor = "You are learning your opponent's weaknesses." + + duration = -1 + maxstacks = 20 + + triggers = ["hit"] + + stack_msg = { + 1: "You begin to notice flaws in your opponent's defense.", + 10: "You've begun to match the battle's rhythm.", + 20: "You've found a gap in the guard!", + } + +
[docs] def conditional(self, *args, **kwargs): + if self.handler.get_by_type(Exploited): + return False + return True
+ +
[docs] def at_trigger(self, trigger: str, *args, **kwargs): + chance = self.stacks / 20 + roll = random.random() + + if chance > roll: + self.handler.add(Exploited) + self.owner.msg("An opportunity presents itself!") + elif chance < roll: + self.handler.add(Exploit) + + if self.stacks in self.stack_msg: + self.owner.msg(self.stack_msg[self.stacks])
+ + +
[docs]class Exploited(BaseBuff): + key = "exploited" + name = "Exploited" + flavor = "You have sensed your target's vulnerability, and are poised to strike." + + duration = 30 + + mods = [Mod("damage", "add", 100)] + +
[docs] def at_post_check(self, *args, **kwargs): + self.owner.msg("You ruthlessly exploit your target's weakness!") + self.remove(quiet=True)
+ +
[docs] def at_remove(self, *args, **kwargs): + self.owner.msg("You have waited too long; the opportunity passes.")
+ + +
[docs]class Leeching(BaseBuff): + key = "leeching" + name = "Leeching" + flavor = "Attacking this target fills you with vigor." + + duration = 30 + + triggers = ["taken_damage"] + +
[docs] def at_trigger(self, trigger: str, attacker=None, damage=None, *args, **kwargs): + if not attacker or not damage: + return + attacker.msg("You have been healed for {heal} life!".format(heal=damage * 0.1))
+ + +
[docs]class Poison(BaseBuff): + key = "poison" + name = "Poison" + flavor = "A poison wracks this body with painful spasms." + + duration = 120 + + maxstacks = 5 + tickrate = 5 + dmg = 5 + + playtime = True + +
[docs] def at_pause(self, *args, **kwargs): + self.owner.db.prelogout_location.msg_contents( + "{actor} stops twitching, their flesh a deathly pallor.".format(actor=self.owner) + )
+ +
[docs] def at_unpause(self, *args, **kwargs): + self.owner.location.msg_contents( + "{actor} begins to twitch again, their cheeks flushing red with blood.".format( + actor=self.owner + ) + )
+ +
[docs] def at_tick(self, initial=True, *args, **kwargs): + _dmg = self.dmg * self.stacks + if not initial: + self.owner.location.msg_contents( + "Poison courses through {actor}'s body, dealing {damage} damage.".format( + actor=self.owner, damage=_dmg + ) + )
+ + +
[docs]class Sated(BaseBuff): + key = "sated" + name = "Sated" + flavor = "You have eaten a great meal!" + + duration = 180 + maxstacks = 3 + + mods = [Mod("mood", "add", 15)]
+ + +
[docs]class StatBuff(BaseBuff): + """Customize the stat this buff affects by feeding a list in the order [stat, mod, base, perstack] to the cache argument when added""" + + key = "statbuff" + name = "statbuff" + flavor = "This buff affects the following stats: {stats}" + + maxstacks = 0 + refresh = True + unique = False + + cache = {"modgen": ["foo", "add", 0, 0]} + +
[docs] def __init__(self, handler, buffkey, cache={}) -> None: + super().__init__(handler, buffkey, cache) + # Finds our "modgen" cache value, which we pass on application + modgen = list(self.cache.get("modgen")) + if modgen: + self.mods = [Mod(*modgen)] + msg = "" + _msg = [mod.stat for mod in self.mods] + for stat in _msg: + msg += stat + self.flavor = self.flavor.format(stats=msg)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/buffs/tests.html b/docs/latest/_modules/evennia/contrib/rpg/buffs/tests.html new file mode 100644 index 0000000000..081ffb3a0f --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/buffs/tests.html @@ -0,0 +1,531 @@ + + + + + + + + evennia.contrib.rpg.buffs.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.buffs.tests

+"""
+Tests for the buff system contrib
+"""
+from unittest.mock import Mock, call, patch
+
+from evennia import DefaultObject, create_object
+from evennia.contrib.rpg.buffs import buff
+from evennia.utils import create
+from evennia.utils.test_resources import EvenniaTest
+from evennia.utils.utils import lazy_property
+
+from .buff import BaseBuff, BuffableProperty, BuffHandler, Mod
+from .samplebuffs import StatBuff
+
+
+class _EmptyBuff(BaseBuff):
+    key = "empty"
+    pass
+
+
+class _TestDivBuff(BaseBuff):
+    key = "tdb"
+    name = "tdb"
+    flavor = "divverbuff"
+    mods = [Mod("stat1", "div", 1)]
+
+
+class _TestModBuff(BaseBuff):
+    key = "tmb"
+    name = "tmb"
+    flavor = "modderbuff"
+    maxstacks = 5
+    mods = [Mod("stat1", "add", 10, 5), Mod("stat2", "mult", 0.5)]
+
+
+class _TestModBuff2(BaseBuff):
+    key = "tmb2"
+    name = "tmb2"
+    flavor = "modderbuff2"
+    maxstacks = 1
+    mods = [Mod("stat1", "mult", 1.0), Mod("stat1", "add", 10)]
+
+
+class _TestTrigBuff(BaseBuff):
+    key = "ttb"
+    name = "ttb"
+    flavor = "triggerbuff"
+    triggers = ["test1", "test2"]
+
+    def at_trigger(self, trigger: str, *args, **kwargs):
+        if trigger == "test1":
+            self.owner.db.triggertest1 = True
+        if trigger == "test2":
+            self.owner.db.triggertest2 = True
+
+
+class _TestConBuff(BaseBuff):
+    key = "tcb"
+    name = "tcb"
+    flavor = "condbuff"
+    triggers = ["condtest"]
+
+    def conditional(self, *args, **kwargs):
+        return self.owner.db.cond1
+
+    def at_trigger(self, trigger: str, attacker=None, defender=None, damage=0, *args, **kwargs):
+        defender.db.att, defender.db.dmg = attacker, damage
+
+
+class _TestComplexBuff(BaseBuff):
+    key = "tcomb"
+    name = "complex"
+    flavor = "combuff"
+    triggers = ["comtest", "complextest"]
+
+    mods = [
+        Mod("com1", "add", 0, 10),
+        Mod("com1", "add", 15),
+        Mod("com1", "mult", 2.0),
+        Mod("com2", "add", 100),
+    ]
+
+    def conditional(self, cond=False, *args, **kwargs):
+        return not cond
+
+    def at_trigger(self, trigger: str, *args, **kwargs):
+        if trigger == "comtest":
+            self.owner.db.comtext = {"cond": True}
+        else:
+            self.owner.db.comtext = {}
+
+
+class _TestTimeBuff(BaseBuff):
+    key = "ttib"
+    name = "ttib"
+    flavor = "timerbuff"
+    maxstacks = 1
+    tickrate = 1
+    duration = 5
+    mods = [Mod("timetest", "add", 665)]
+
+    def at_tick(self, initial=True, *args, **kwargs):
+        self.owner.db.ticktest = True
+
+
+
[docs]class BuffableObject(DefaultObject): + stat1 = BuffableProperty(10) + +
[docs] @lazy_property + def buffs(self) -> BuffHandler: + return BuffHandler(self)
+ +
[docs] def at_init(self): + self.stat1, self.buffs + return super().at_init()
+ + +
[docs]class TestBuffsAndHandler(EvenniaTest): + "This tests a number of things about buffs." + +
[docs] def setUp(self): + super().setUp() + self.testobj = create.create_object(BuffableObject, key="testobj")
+ +
[docs] def tearDown(self): + """done after every test_* method below""" + self.testobj.buffs.clear() + del self.testobj + super().tearDown()
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_addremove(self): + """tests adding and removing buffs""" + # setup + handler: BuffHandler = self.testobj.buffs + # add + handler.add(_TestModBuff, to_cache={"cachetest": True}) + handler.add(_TestTrigBuff) + self.assertEqual(self.testobj.db.buffs["tmb"]["ref"], _TestModBuff) + self.assertTrue(self.testobj.db.buffs["tmb"].get("cachetest")) + self.assertFalse(self.testobj.db.buffs["ttb"].get("cachetest")) + # has + self.assertTrue(handler.has(_TestModBuff)) + self.assertTrue(handler.has("tmb")) + self.assertFalse(handler.has(_EmptyBuff)) + # remove + handler.remove("tmb") + self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove stacks + handler.add(_TestModBuff, stacks=3) + handler.remove("tmb", stacks=3) + self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove by type + handler.add(_TestModBuff) + handler.remove_by_type(_TestModBuff) + self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove by buff instance + handler.add(_TestModBuff) + handler.all["tmb"].remove() + self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove by source + handler.add(_TestModBuff) + handler.remove_by_source(None) + self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove by cachevalue + handler.add(_TestModBuff) + handler.remove_by_cachevalue("failure", True) + self.assertTrue(self.testobj.db.buffs.get("tmb")) + # remove all + handler.add(_TestModBuff) + handler.clear() + self.assertFalse(self.testobj.buffs.all)
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_getters(self): + """tests all built-in getters""" + # setup + handler: BuffHandler = self.testobj.buffs + handler.add(_TestModBuff, source=self.obj2) + handler.add(_TestTrigBuff, to_cache={"ttbcache": True}) + # normal getter + self.assertTrue(isinstance(handler.get("tmb"), _TestModBuff)) + # stat getters + self.assertTrue(isinstance(handler.get_by_stat("stat1")["tmb"], _TestModBuff)) + self.assertFalse(handler.get_by_stat("nullstat")) + # trigger getters + self.assertTrue("ttb" in handler.get_by_trigger("test1").keys()) + self.assertFalse("ttb" in handler.get_by_trigger("nulltrig").keys()) + # type getters + self.assertTrue("tmb" in handler.get_by_type(_TestModBuff)) + self.assertFalse("tmb" in handler.get_by_type(_EmptyBuff)) + # source getter + self.assertTrue("tmb" in handler.get_by_source(self.obj2)) + self.assertFalse("ttb" in handler.get_by_source(self.obj2)) + # cachevalue getter + self.assertFalse("tmb" in handler.get_by_cachevalue("ttbcache")) + self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache")) + self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache", True))
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_details(self): + """tests that buff details like name and flavor are correct; also test modifier viewing""" + handler: BuffHandler = self.testobj.buffs + handler.add(_TestModBuff) + handler.add(_TestTrigBuff) + self.assertEqual(handler.get("tmb").flavor, "modderbuff") + self.assertEqual(handler.get("ttb").name, "ttb") + mods = handler.view_modifiers("stat1") + _testmods = { + "add": {"total": 15, "strongest": 15}, + "mult": {"total": 0, "strongest": 0}, + "div": {"total": 0, "strongest": 0}, + } + self.assertDictEqual(mods, _testmods)
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_modify(self): + """tests to ensure that values are modified correctly, and stack across mods""" + # setup + handler: BuffHandler = self.testobj.buffs + _stat1, _stat2 = 0, 10 + handler.add(_TestModBuff) + # stat1 and 2 basic mods + self.assertEqual(handler.check(_stat1, "stat1"), 15) + self.assertEqual(handler.check(_stat2, "stat2"), 15) + # checks can take any base value + self.assertEqual(handler.check(_stat1, "stat2"), 0) + self.assertEqual(handler.check(_stat2, "stat1"), 25) + # change to base stat reflected in check + _stat1 += 5 + self.assertEqual(handler.check(_stat1, "stat1"), 20) + _stat2 += 10 + self.assertEqual(handler.check(_stat2, "stat2"), 30) + # test stacking; single stack, multiple stack, max stacks + handler.add(_TestModBuff) + self.assertEqual(handler.check(_stat1, "stat1"), 25) + handler.add(_TestModBuff, stacks=3) + self.assertEqual(handler.check(_stat1, "stat1"), 40) + handler.add(_TestModBuff, stacks=5) + self.assertEqual(handler.check(_stat1, "stat1"), 40) + # stat2 mod doesn't stack + self.assertEqual(handler.check(_stat2, "stat2"), 30) + # layers with second mod + handler.add(_TestModBuff2) + self.assertEqual(handler.check(_stat1, "stat1"), 100) + self.assertEqual(handler.check(_stat2, "stat2"), 30) + # apply only the strongest value + self.assertEqual(handler.check(_stat1, "stat1", strongest=True), 80) + # removing mod properly reduces value, doesn't affect other mods + handler.remove_by_type(_TestModBuff) + self.assertEqual(handler.check(_stat1, "stat1"), 30) + self.assertEqual(handler.check(_stat2, "stat2"), 20) + # divider mod test + handler.add(_TestDivBuff) + self.assertEqual(handler.check(_stat1, "stat1"), 15)
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_trigger(self): + """tests to ensure triggers correctly fire""" + # setup + handler: BuffHandler = self.testobj.buffs + handler.add(_TestTrigBuff) + # trigger buffs + handler.trigger("nulltest") + self.assertFalse(self.testobj.db.triggertest1) + self.assertFalse(self.testobj.db.triggertest2) + handler.trigger("test1") + self.assertTrue(self.testobj.db.triggertest1) + self.assertFalse(self.testobj.db.triggertest2) + handler.trigger("test2") + self.assertTrue(self.testobj.db.triggertest1) + self.assertTrue(self.testobj.db.triggertest2)
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_context_conditional(self): + """tests to ensure context is passed to buffs, and also tests conditionals""" + # setup + handler: BuffHandler = self.testobj.buffs + handler.add(_TestConBuff) + self.testobj.db.cond1, self.testobj.db.att, self.testobj.db.dmg = False, None, 0 + # context to pass, containing basic event data and a little extra to be ignored + _testcontext = { + "attacker": self.obj2, + "defender": self.testobj, + "damage": 5, + "overflow": 10, + } + # test negative conditional + self.assertEqual( + handler.get_by_type(_TestConBuff)["tcb"].conditional(**_testcontext), False + ) + handler.trigger("condtest", _testcontext) + self.assertEqual(self.testobj.db.att, None) + self.assertEqual(self.testobj.db.dmg, 0) + # test positive conditional + context passing + self.testobj.db.cond1 = True + self.assertEqual(handler.get_by_type(_TestConBuff)["tcb"].conditional(**_testcontext), True) + handler.trigger("condtest", _testcontext) + self.assertEqual(self.testobj.db.att, self.obj2) + self.assertEqual(self.testobj.db.dmg, 5)
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_complex(self): + """tests a complex mod (conditionals, multiple triggers/mods)""" + # setup + handler: BuffHandler = self.testobj.buffs + self.testobj.db.comone, self.testobj.db.comtwo, self.testobj.db.comtext = 10, 0, {} + handler.add(_TestComplexBuff) + # stat checks work correctly and separately + self.assertEqual(self.testobj.db.comtext, {}) + self.assertEqual(handler.check(self.testobj.db.comone, "com1"), 105) + self.assertEqual(handler.check(self.testobj.db.comtwo, "com2"), 100) + # stat checks don't happen if the conditional is true + handler.trigger("comtest", self.testobj.db.comtext) + self.assertEqual(self.testobj.db.comtext, {"cond": True}) + self.assertEqual( + handler.get_by_type(_TestComplexBuff)["tcomb"].conditional(**self.testobj.db.comtext), + False, + ) + self.assertEqual( + handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 10 + ) + self.assertEqual( + handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 0 + ) + handler.trigger("complextest", self.testobj.db.comtext) + self.assertEqual( + handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 10 + ) + self.assertEqual( + handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 0 + ) + # separate trigger follows different codepath + self.testobj.db.comtext = {"cond": False} + handler.trigger("complextest", self.testobj.db.comtext) + self.assertEqual(self.testobj.db.comtext, {}) + self.assertEqual( + handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 105 + ) + self.assertEqual( + handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 100 + )
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay") + def test_timing(self, mock_delay: Mock): + """tests timing-related features, such as ticking and duration""" + # setup + handler: BuffHandler = self.testobj.buffs + mock_delay.side_effect = [None, handler.cleanup] + handler.add(_TestTimeBuff) + calls = [ + call( + 1, + buff.tick_buff, + handler=handler, + buffkey="ttib", + context={}, + initial=False, + persistent=True, + ), + call(5, handler.cleanup, persistent=True), + ] + mock_delay.assert_has_calls(calls) + self.testobj.db.timetest, self.testobj.db.ticktest = 1, False + # test duration and ticking + _instance = handler.get("ttib") + self.assertTrue(_instance.ticking) + self.assertEqual(_instance.duration, 5) + _instance.at_tick() + self.assertTrue(self.testobj.db.ticktest) + # test duration modification and cleanup + _instance.duration = 0 + self.assertEqual(handler.get("ttib").duration, 0) + handler.cleanup() + self.assertFalse(handler.get("ttib"), None)
+ + + +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_buffableproperty(self): + """tests buffable properties""" + # setup + self.testobj.buffs.add(_TestModBuff) + self.assertEqual(self.testobj.stat1, 25) + self.testobj.buffs.remove("tmb") + self.assertEqual(self.testobj.stat1, 10)
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_stresstest(self): + """tests large amounts of buffs, and related removal methods""" + # setup + for x in range(1, 20): + self.testobj.buffs.add(_TestModBuff, key="test" + str(x)) + self.testobj.buffs.add(_TestTrigBuff, key="trig" + str(x)) + self.assertEqual(self.testobj.stat1, 295) + self.testobj.buffs.trigger("test1") + self.testobj.buffs.remove_by_type(_TestModBuff) + self.assertEqual(self.testobj.stat1, 10) + self.testobj.buffs.clear() + self.assertFalse(self.testobj.buffs.all)
+ +
[docs] @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_modgen(self): + """test generating mods on the fly""" + # setup + handler: BuffHandler = self.testobj.buffs + self.testobj.db.gentest = 5 + self.assertEqual(self.testobj.db.gentest, 5) + tc = {"modgen": ["gentest", "add", 5, 0]} + handler.add(StatBuff, key="gentest", to_cache=tc) + self.assertEqual(handler.check(self.testobj.db.gentest, "gentest"), 10) + tc = {"modgen": ["gentest", "add", 10, 0]} + handler.add(StatBuff, key="gentest", to_cache=tc) + self.assertEqual(handler.check(self.testobj.db.gentest, "gentest"), 15) + self.assertEqual( + handler.get("gentest").flavor, "This buff affects the following stats: gentest" + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/character_creator/character_creator.html b/docs/latest/_modules/evennia/contrib/rpg/character_creator/character_creator.html new file mode 100644 index 0000000000..6d7112bc80 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/character_creator/character_creator.html @@ -0,0 +1,306 @@ + + + + + + + + evennia.contrib.rpg.character_creator.character_creator — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.character_creator.character_creator

+"""
+Character Creator contrib, by InspectorCaracal
+
+# Features
+
+The primary feature of this contrib is defining the name and attributes
+of a new character through an EvMenu. It provides an alternate `charcreate`
+command as well as a modified `at_look` method for your Account class.
+
+# Usage
+
+In order to use the contrib, you will need to create your own chargen
+EvMenu. The included `example_menu.py` gives a number of useful techniques
+and examples, including how to allow players to choose and confirm
+character names from within the menu.
+
+"""
+import string
+from random import choices
+
+from django.conf import settings
+
+from evennia import DefaultAccount
+from evennia.commands.default.muxcommand import MuxAccountCommand
+from evennia.objects.models import ObjectDB
+from evennia.utils import create, search
+from evennia.utils.evmenu import EvMenu
+
+_CHARACTER_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS
+try:
+    _CHARGEN_MENU = settings.CHARGEN_MENU
+except AttributeError:
+    _CHARGEN_MENU = "evennia.contrib.rpg.character_creator.example_menu"
+
+
+
[docs]class ContribCmdCharCreate(MuxAccountCommand): + """ + create a new character + + Begin creating a new character, or resume character creation for + an existing in-progress character. + + You can stop character creation at any time and resume where + you left off later. + """ + + key = "charcreate" + locks = "cmd:pperm(Player) and is_ooc()" + help_category = "General" + +
[docs] def func(self): + "create the new character" + account = self.account + session = self.session + + # only one character should be in progress at a time, so we check for WIPs first + in_progress = [chara for chara in account.characters if chara.db.chargen_step] + + if len(in_progress): + # we're continuing chargen for a WIP character + new_character = in_progress[0] + else: + # we're making a new character + charmax = settings.MAX_NR_CHARACTERS + + if not account.is_superuser and ( + account.characters and len(account.characters) >= charmax + ): + plural = "" if charmax == 1 else "s" + self.msg(f"You may only create a maximum of {charmax} character{plural}.") + return + + # create the new character object, with default settings + # start_location = ObjectDB.objects.get_id(settings.START_LOCATION) + default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME) + permissions = settings.PERMISSION_ACCOUNT_DEFAULT + # generate a randomized key so the player can choose a character name later + key = "".join(choices(string.ascii_letters + string.digits, k=10)) + new_character = create.create_object( + _CHARACTER_TYPECLASS, + key=key, + location=None, + home=default_home, + permissions=permissions, + ) + # only allow creator (and developers) to puppet this char + new_character.locks.add( + f"puppet:pid({account.id}) or perm(Developer) or" + f" pperm(Developer);delete:id({account.id}) or perm(Admin)" + ) + # initalize the new character to the beginning of the chargen menu + new_character.db.chargen_step = "menunode_welcome" + account.characters.add(new_character) + + # set the menu node to start at to the character's last saved step + startnode = new_character.db.chargen_step + # attach the character to the session, so the chargen menu can access it + session.new_char = new_character + + # this gets called every time the player exits the chargen menu + def finish_char_callback(session, menu): + char = session.new_char + if not char.db.chargen_step: + # this means character creation was completed - start playing! + # execute the ic command to start puppeting the character + account.execute_cmd("ic {}".format(char.key)) + + EvMenu(session, _CHARGEN_MENU, startnode=startnode, cmd_on_exit=finish_char_callback)
+ + +
[docs]class ContribChargenAccount(DefaultAccount): + """ + A modified Account class that makes minor changes to the OOC look + output, to incorporate in-progress characters. + """ + +
[docs] def at_look(self, target=None, session=None, **kwargs): + """ + Called by the OOC look command. It displays a list of playable + characters and should be mostly identical to the core method. + + Args: + target (Object or list, optional): An object or a list + objects to inspect. + session (Session, optional): The session doing this look. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + look_string (str): A prepared look string, ready to send + off to any recipient (usually to ourselves) + """ + + # list of targets - make list to disconnect from db + characters = list(tar for tar in target if tar) if target else [] + sessions = self.sessions.all() + is_su = self.is_superuser + + # text shown when looking in the ooc area + result = [f"Account |g{self.key}|n (you are Out-of-Character)"] + + nsess = len(sessions) + if nsess == 1: + result.append("\n\n|wConnected session:|n") + elif nsess > 1: + result.append(f"\n\n|wConnected sessions ({nsess}):|n") + for isess, sess in enumerate(sessions): + csessid = sess.sessid + addr = "{protocol} ({address})".format( + protocol=sess.protocol_key, + address=isinstance(sess.address, tuple) + and str(sess.address[0]) + or str(sess.address), + ) + if session.sessid == csessid: + result.append(f"\n |w* {isess+1}|n {addr}") + else: + result.append(f"\n {isess+1} {addr}") + + result.append("\n\n |whelp|n - more commands") + result.append("\n |wpublic <Text>|n - talk on public channel") + + charmax = settings.MAX_NR_CHARACTERS + + if is_su or len(characters) < charmax: + result.append("\n |wcharcreate|n - create a new character") + + if characters: + result.append("\n |wchardelete <name>|n - delete a character (cannot be undone!)") + plural = "" if len(characters) == 1 else "s" + result.append("\n |wic <character>|n - enter the game (|wooc|n to return here)") + if is_su: + result.append(f"\n\nAvailable character{plural} ({len(characters)}/unlimited):") + else: + result.append(f"\n\nAvailable character{plural} ({len(characters)}/{charmax}):") + + for char in characters: + if char.db.chargen_step: + # currently in-progress character; don't display placeholder names + result.append("\n - |Yin progress|n (|wcharcreate|n to continue)") + continue + csessions = char.sessions.all() + if csessions: + for sess in csessions: + # character is already puppeted + sid = sess in sessions and sessions.index(sess) + 1 + if sess and sid: + result.append( + f"\n - |G{char.key}|n [{', '.join(char.permissions.all())}] (played by" + f" you in session {sid})" + ) + else: + result.append( + f"\n - |R{char.key}|n [{', '.join(char.permissions.all())}] (played by" + " someone else)" + ) + else: + # character is available + result.append(f"\n - {char.key} [{', '.join(char.permissions.all())}]") + look_string = ("-" * 68) + "\n" + "".join(result) + "\n" + ("-" * 68) + return look_string
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/character_creator/tests.html b/docs/latest/_modules/evennia/contrib/rpg/character_creator/tests.html new file mode 100644 index 0000000000..f2cc2c9d59 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/character_creator/tests.html @@ -0,0 +1,150 @@ + + + + + + + + evennia.contrib.rpg.character_creator.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.character_creator.tests

+from unittest.mock import patch
+
+from django.conf import settings
+from django.test import override_settings
+
+from evennia import DefaultCharacter
+from evennia.commands.default import account
+from evennia.utils import inherits_from
+from evennia.utils.test_resources import BaseEvenniaCommandTest
+
+from . import character_creator
+
+
+
[docs]class TestCharacterCreator(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.account.swap_typeclass(character_creator.ContribChargenAccount)
+ +
[docs] def test_ooc_look(self): + self.account.characters.add(self.char1) + self.account.unpuppet_all() + + self.char1.db.chargen_step = "start" + + with patch("evennia.commands.default.account._AUTO_PUPPET_ON_LOGIN", new=False): + # check that correct output is returning + output = self.call( + account.CmdOOCLook(), + "", + "Account TestAccount (you are Out-of-Character)", + caller=self.account, + ) + # check that char1 is recognized as in progress + self.assertIn("in progress", output)
+ +
[docs] @override_settings(CHARGEN_MENU="evennia.contrib.rpg.character_creator.example_menu") + def test_char_create(self): + self.call( + character_creator.ContribCmdCharCreate(), + "", + caller=self.account, + ) + menu = self.session.ndb._menutree + self.assertNotEqual(menu, None) + self.assertTrue(inherits_from(self.session.new_char, DefaultCharacter))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/dice/dice.html b/docs/latest/_modules/evennia/contrib/rpg/dice/dice.html new file mode 100644 index 0000000000..7e242b3672 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/dice/dice.html @@ -0,0 +1,463 @@ + + + + + + + + evennia.contrib.rpg.dice.dice — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.dice.dice

+"""
+# Dice
+
+Rolls dice for roleplaying, in-game gambling or GM:ing
+
+Evennia contribution - Griatch 2012
+
+This module implements a a dice-roller and a `dice`/`roll` command
+to go with it. It uses standard RPG 'd'-syntax (e.g. 2d6 to roll two
+six-sided die) and also supports modifiers such as 3d6 + 5.
+
+    > roll 1d20 + 2
+
+One can also specify a standard Python operator in order to specify
+eventual target numbers and get results in a fair and guaranteed
+unbiased way.  For example a GM could (using the dice command) from
+the start define the roll as 2d6 < 8 to show that a roll below 8 is
+required to succeed. The command will normally echo this result to all
+parties (although it also has options for hidden and secret rolls).
+
+
+# Installation:
+
+
+Add the `CmdDice` command from this module to your character's cmdset
+(and then restart the server):
+
+```python
+# in mygame/commands/default_cmdsets.py
+
+# ...
+from evennia.contrib.rpg import dice  <---
+
+class CharacterCmdSet(default_cmds.CharacterCmdSet):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(dice.CmdDice())  # <---
+
+```
+
+# Usage:
+
+    roll 1d100 + 10
+
+To roll dice in code, use the `roll` function from this module:
+
+    from evennia.contrib.rpg import dice
+
+    dice.roll("3d10 + 2")
+
+
+If your system generates the dice dynamically you can also enter each part
+of the roll separately:
+
+    dice.roll(3, 10, ("+", 2))  # 3d10 + 2
+
+
+"""
+import re
+from ast import literal_eval
+from random import randint
+
+from evennia import CmdSet, default_cmds
+from evennia.utils.utils import simple_eval
+
+
+
[docs]def roll( + dice, + dicetype=6, + modifier=None, + conditional=None, + return_tuple=False, + max_dicenum=10, + max_dicetype=1000, +): + """ + This is a standard dice roller. + + Args: + dice (int or str): If an `int`, this is the number of dice to roll, and `dicetype` is used + to determine the type. If a `str`, it should be on the form `NdM` where `N` is the number + of dice and `M` is the number of sides on each die. Also + `NdM [modifier] [number] [conditional]` is understood, e.g. `1d6 + 3` + or `2d10 / 2 > 10`. + dicetype (int, optional): Number of sides of the dice to be rolled. Ignored if + `dice` is a string. + modifier (tuple, optional): A tuple `(operator, value)`, where operator is + one of `"+"`, `"-"`, `"/"` or `"*"`. The result of the dice + roll(s) will be modified by this value. Ignored if `dice` is a string. + conditional (tuple, optional): A tuple `(conditional, value)`, where + conditional is one of `"=="`,`"<"`,`">"`,`">="`,`"<=`" or "`!=`". + Ignored if `dice` is a string. + return_tuple (bool): Return a tuple with all individual roll + results or not. + max_dicenum (int): The max number of dice to allow to be rolled. + max_dicetype (int): The max number of sides on the dice to roll. + + Returns: + int, bool or tuple : By default, this is the result of the roll + modifiers. If + `conditional` is given, or `dice` is a string defining a conditional, then a True/False + value is returned. Finally, if `return_tuple` is set, this is a tuple + `(result, outcome, diff, rolls)`, where, `result` is the the normal result of the + roll + modifiers, `outcome` and `diff` are the boolean absolute difference between the roll + and the `conditional` input; both will be will be `None` if `conditional` is not set. + The `rolls` a tuple holding all the individual rolls (one or more depending on how many + dice were rolled). + + Raises: + TypeError if non-supported modifiers or conditionals are given. + + Notes: + All input numbers are converted to integers. + + Examples: + :: + # string form + print roll("3d6 + 2") + 10 + print roll("2d10 + 2 > 10") + True + print roll("2d20 - 2 >= 10") + (8, False, 2, (4, 6)) # roll was 4 + 6 - 2 = 8 + + # explicit arguments + print roll(2, 6) # 2d6 + 7 + print roll(1, 100, ('+', 5) # 1d100 + 5 + 4 + print roll(1, 20, conditional=('<', 10) # let'say we roll 3 + True + print roll(3, 10, return_tuple=True) + (11, None, None, (2, 5, 4)) + print roll(2, 20, ('-', 2), conditional=('>=', 10), return_tuple=True) + (8, False, 2, (4, 6)) # roll was 4 + 6 - 2 = 8 + + """ + + modifier_string = "" + conditional_string = "" + conditional_value = None + if isinstance(dice, str) and "d" in dice.lower(): + # A string is given, parse it as NdM dice notation + roll_string = dice.lower() + + # split to get the NdM syntax + dicenum, rest = roll_string.split("d", 1) + + # parse packwards right-to-left + if any(True for cond in ("==", "<", ">", "!=", "<=", ">=") if cond in rest): + # split out any conditionals, like '< 12' + rest, *conditionals = re.split(r"(==|<=|>=|<|>|!=)", rest, maxsplit=1) + try: + conditional_value = int(conditionals[1]) + except ValueError: + raise TypeError( + f"Conditional '{conditionals[-1]}' was not recognized. Must be a number." + ) + conditional_string = "".join(conditionals) + + if any(True for op in ("+", "-", "*", "/") if op in rest): + # split out any modifiers, like '+ 2' + rest, *modifiers = re.split(r"(\+|-|/|\*)", rest, maxsplit=1) + modifier_string = "".join(modifiers) + + # whatever is left is the dice type + dicetype = rest + + else: + # an integer is given - explicit modifiers and conditionals as part of kwargs + dicenum = int(dice) + dicetype = int(dicetype) + if modifier: + modifier_string = "".join(str(part) for part in modifier) + if conditional: + conditional_value = int(conditional[1]) + conditional_string = "".join(str(part) for part in conditional) + + try: + dicenum = int(dicenum) + dicetype = int(dicetype) + except Exception: + raise TypeError( + f"The number of dice and dice-size must both be numerical. Got '{dicenum}' " + f"and '{dicetype}'." + ) + if 0 < dicenum > max_dicenum: + raise TypeError(f"Invalid number of dice rolled (must be between 1 and {max_dicenum}).") + if 0 < dicetype > max_dicetype: + raise TypeError(f"Invalid die-size used (must be between 1 and {max_dicetype} sides).") + + # roll all dice, remembering each roll + rolls = tuple([randint(1, dicetype) for _ in range(dicenum)]) + result = sum(rolls) + + if modifier_string: + result = simple_eval(f"{result} {modifier_string}") + + outcome, diff = None, None + if conditional_string and conditional_value: + outcome = simple_eval(f"{result} {conditional_string}") + diff = abs(result - conditional_value) + + if return_tuple: + return result, outcome, diff, rolls + elif conditional or (conditional_string and conditional_value): + return outcome # True|False + else: + return result # integer
+ + +# legacy alias +roll_dice = roll + + +RE_PARTS = re.compile(r"(d|\+|-|/|\*|<|>|<=|>=|!=|==)") +RE_MOD = re.compile(r"(\+|-|/|\*)") +RE_COND = re.compile(r"(<|>|<=|>=|!=|==)") + + +
[docs]class CmdDice(default_cmds.MuxCommand): + """ + roll dice + + Usage: + dice[/switch] <nr>d<sides> [modifier] [success condition] + + Switch: + hidden - tell the room the roll is being done, but don't show the result + secret - don't inform the room about neither roll nor result + + Examples: + dice 3d6 + 4 + dice 1d100 - 2 < 50 + + This will roll the given number of dice with given sides and modifiers. + So e.g. 2d6 + 3 means to 'roll a 6-sided die 2 times and add the result, + then add 3 to the total'. + Accepted modifiers are +, -, * and /. + A success condition is given as normal Python conditionals + (<,>,<=,>=,==,!=). So e.g. 2d6 + 3 > 10 means that the roll will succeed + only if the final result is above 8. If a success condition is given, the + outcome (pass/fail) will be echoed along with how much it succeeded/failed + with. The hidden/secret switches will hide all or parts of the roll from + everyone but the person rolling. + """ + + key = "dice" + aliases = ["roll", "@dice"] + locks = "cmd:all()" + +
[docs] def func(self): + """Mostly parsing for calling the dice roller function""" + + if not self.args: + self.caller.msg("Usage: @dice <nr>d<sides> [modifier] [conditional]") + return + argstring = "".join(str(arg) for arg in self.args) + + parts = [part for part in RE_PARTS.split(self.args) if part] + len_parts = len(parts) + modifier = None + conditional = None + + if len_parts < 3 or parts[1] != "d": + self.caller.msg( + "You must specify the die roll(s) as <nr>d<sides>." + " For example, 2d6 means rolling a 6-sided die 2 times." + ) + return + + # Limit the number of dice and sides a character can roll to prevent server slow down and crashes + ndicelimit = 10000 # Maximum number of dice + nsidelimit = 10000 # Maximum number of sides + if int(parts[0]) > ndicelimit or int(parts[2]) > nsidelimit: + self.caller.msg("The maximum roll allowed is %sd%s." % (ndicelimit, nsidelimit)) + return + + ndice, nsides = parts[0], parts[2] + if len_parts == 3: + # just something like 1d6 + pass + elif len_parts == 5: + # either e.g. 1d6 + 3 or something like 1d6 > 3 + if parts[3] in ("+", "-", "*", "/"): + modifier = (parts[3], parts[4]) + else: # assume it is a conditional + conditional = (parts[3], parts[4]) + elif len_parts == 7: + # the whole sequence, e.g. 1d6 + 3 > 5 + modifier = (parts[3], parts[4]) + conditional = (parts[5], parts[6]) + else: + # error + self.caller.msg("You must specify a valid die roll") + return + # do the roll + try: + result, outcome, diff, rolls = roll_dice( + ndice, nsides, modifier=modifier, conditional=conditional, return_tuple=True + ) + except ValueError: + self.caller.msg( + "You need to enter valid integer numbers, modifiers and operators." + " |w%s|n was not understood." % self.args + ) + return + # format output + if len(rolls) > 1: + rolls = ", ".join(str(roll) for roll in rolls[:-1]) + " and " + str(rolls[-1]) + else: + rolls = rolls[0] + if outcome is None: + outcomestring = "" + elif outcome: + outcomestring = " This is a |gsuccess|n (by %s)." % diff + else: + outcomestring = " This is a |rfailure|n (by %s)." % diff + yourollstring = "You roll %s%s." + roomrollstring = "%s rolls %s%s." + resultstring = " Roll(s): %s. Total result is |w%s|n." + + if "secret" in self.switches: + # don't echo to the room at all + string = yourollstring % (argstring, " (secret, not echoed)") + string += "\n" + resultstring % (rolls, result) + string += outcomestring + " (not echoed)" + self.caller.msg(string) + elif "hidden" in self.switches: + # announce the roll to the room, result only to caller + string = yourollstring % (argstring, " (hidden)") + self.caller.msg(string) + string = roomrollstring % (self.caller.key, argstring, " (hidden)") + self.caller.location.msg_contents(string, exclude=self.caller) + # handle result + string = resultstring % (rolls, result) + string += outcomestring + " (not echoed)" + self.caller.msg(string) + else: + # normal roll + string = yourollstring % (argstring, "") + self.caller.msg(string) + string = roomrollstring % (self.caller.key, argstring, "") + self.caller.location.msg_contents(string, exclude=self.caller) + string = resultstring % (rolls, result) + string += outcomestring + self.caller.location.msg_contents(string)
+ + +
[docs]class DiceCmdSet(CmdSet): + """ + a small cmdset for testing purposes. + Add with @py self.cmdset.add("contrib.dice.DiceCmdSet") + """ + +
[docs] def at_cmdset_creation(self): + """Called when set is created""" + self.add(CmdDice())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/dice/tests.html b/docs/latest/_modules/evennia/contrib/rpg/dice/tests.html new file mode 100644 index 0000000000..01b75e3d2c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/dice/tests.html @@ -0,0 +1,142 @@ + + + + + + + + evennia.contrib.rpg.dice.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.dice.tests

+"""
+Testing of TestDice.
+
+"""
+
+from mock import patch
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+
+from . import dice
+
+
+
[docs]@patch("evennia.contrib.rpg.dice.dice.randint", return_value=5) +class TestDice(BaseEvenniaCommandTest): +
[docs] def test_roll_dice(self, mocked_randint): + self.assertEqual(dice.roll(6, 6, modifier=("+", 4)), mocked_randint() * 6 + 4) + self.assertEqual(dice.roll(6, 6, conditional=("<", 35)), True) + self.assertEqual(dice.roll(6, 6, conditional=(">", 33)), False)
+ +
[docs] def test_cmddice(self, mocked_randint): + self.call( + dice.CmdDice(), "3d6 + 4", "You roll 3d6 + 4.| Roll(s): 5, 5 and 5. Total result is 19." + ) + self.call(dice.CmdDice(), "100000d1000", "The maximum roll allowed is 10000d10000.") + self.call(dice.CmdDice(), "/secret 3d6 + 4", "You roll 3d6 + 4 (secret, not echoed).")
+ +
[docs] def test_string_form(self, mocked_randint): + self.assertEqual(dice.roll("6d6 + 4"), mocked_randint() * 6 + 4) + self.assertEqual(dice.roll("6d6 < 35"), True) + self.assertEqual(dice.roll("6d6 > 35"), False) + self.assertEqual(dice.roll("2d10 + 5 >= 14"), True)
+ +
[docs] def test_maxvals(self, mocked_randint): + with self.assertRaises(TypeError): + dice.roll(11, 1001, max_dicenum=10, max_dicetype=1000) + with self.assertRaises(TypeError): + dice.roll(10, 1001, max_dicenum=10, max_dicetype=1000)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/health_bar/health_bar.html b/docs/latest/_modules/evennia/contrib/rpg/health_bar/health_bar.html new file mode 100644 index 0000000000..6f089bfb08 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/health_bar/health_bar.html @@ -0,0 +1,240 @@ + + + + + + + + evennia.contrib.rpg.health_bar.health_bar — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.health_bar.health_bar

+"""
+Health Bar
+
+Contrib - Tim Ashley Jenkins 2017
+
+The function provided in this module lets you easily display visual
+bars or meters - "health bar" is merely the most obvious use for this,
+though these bars are highly customizable and can be used for any sort
+of appropriate data besides player health.
+
+Today's players may be more used to seeing statistics like health,
+stamina, magic, and etc. displayed as bars rather than bare numerical
+values, so using this module to present this data this way may make it
+more accessible. Keep in mind, however, that players may also be using
+a screen reader to connect to your game, which will not be able to
+represent the colors of the bar in any way. By default, the values
+represented are rendered as text inside the bar which can be read by
+screen readers.
+
+The health bar will account for current values above the maximum or
+below 0, rendering them as a completely full or empty bar with the
+values displayed within.
+
+No installation, just import and use `display_meter` from this
+module:
+
+```python
+    from evennia.contrib.rpg.health_bar import display_meter
+
+    health_bar = display_meter(23, 100)
+    caller.msg(prompt=health_bar)
+```
+
+"""
+
+
+
[docs]def display_meter( + cur_value, + max_value, + length=30, + fill_color=["R", "Y", "G"], + empty_color="B", + text_color="w", + align="left", + pre_text="", + post_text="", + show_values=True, +): + """ + Represents a current and maximum value given as a "bar" rendered with + ANSI or xterm256 background colors. + + Args: + cur_value (int): Current value to display + max_value (int): Maximum value to display + + Keyword Arguments: + length (int): Length of meter returned, in characters + fill_color (list): List of color codes for the full portion + of the bar, sans any sort of prefix - both ANSI and xterm256 + colors are usable. When the bar is empty, colors toward the + start of the list will be chosen - when the bar is full, colors + towards the end are picked. You can adjust the 'weights' of + the changing colors by adding multiple entries of the same + color - for example, if you only want the bar to change when + it's close to empty, you could supply ['R','Y','G','G','G'] + empty_color (str): Color code for the empty portion of the bar. + text_color (str): Color code for text inside the bar. + align (str): "left", "right", or "center" - alignment of text in the bar + pre_text (str): Text to put before the numbers in the bar + post_text (str): Text to put after the numbers in the bar + show_values (bool): If true, shows the numerical values represented by + the bar. It's highly recommended you keep this on, especially if + there's no info given in pre_text or post_text, as players on screen + readers will be unable to read the graphical aspect of the bar. + + Returns: + str: The display bar to show. + + """ + # Start by building the base string. + num_text = "" + if show_values: + num_text = "%i / %i" % (cur_value, max_value) + bar_base_str = pre_text + num_text + post_text + # Cut down the length of the base string if needed + if len(bar_base_str) > length: + bar_base_str = bar_base_str[:length] + # Pad and align the bar base string + if align == "right": + bar_base_str = bar_base_str.rjust(length, " ") + elif align == "center": + bar_base_str = bar_base_str.center(length, " ") + else: + bar_base_str = bar_base_str.ljust(length, " ") + + if max_value < 1: # Prevent divide by zero + max_value = 1 + if cur_value < 0: # Prevent weirdly formatted 'negative bars' + cur_value = 0 + if cur_value > max_value: # Display overfull bars correctly + cur_value = max_value + + # Now it's time to determine where to put the color codes. + percent_full = float(cur_value) / float(max_value) + split_index = round(float(length) * percent_full) + # Determine point at which to split the bar + split_index = int(split_index) + + # Separate the bar string into full and empty portions + full_portion = bar_base_str[:split_index] + empty_portion = bar_base_str[split_index:] + + # Pick which fill color to use based on how full the bar is + fillcolor_index = float(len(fill_color)) * percent_full + fillcolor_index = max(0, int(round(fillcolor_index)) - 1) + fillcolor_code = "|[" + fill_color[fillcolor_index] + + # Make color codes for empty bar portion and text_color + emptycolor_code = "|[" + empty_color + textcolor_code = "|" + text_color + + # Assemble the final bar + final_bar = ( + fillcolor_code + + textcolor_code + + full_portion + + "|n" + + emptycolor_code + + textcolor_code + + empty_portion + + "|n" + ) + + return final_bar
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/health_bar/tests.html b/docs/latest/_modules/evennia/contrib/rpg/health_bar/tests.html new file mode 100644 index 0000000000..d171e8db64 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/health_bar/tests.html @@ -0,0 +1,152 @@ + + + + + + + + evennia.contrib.rpg.health_bar.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.health_bar.tests

+"""
+Test health bar contrib
+
+"""
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import health_bar
+
+
+
[docs]class TestHealthBar(BaseEvenniaTest): +
[docs] def test_healthbar(self): + expected_bar_str = "|[R|w|n|[B|w test0 / 200test |n" + self.assertEqual( + health_bar.display_meter( + 0, 200, length=40, pre_text="test", post_text="test", align="center" + ), + expected_bar_str, + ) + expected_bar_str = "|[R|w |n|[B|w test24 / 200test |n" + self.assertEqual( + health_bar.display_meter( + 24, 200, length=40, pre_text="test", post_text="test", align="center" + ), + expected_bar_str, + ) + expected_bar_str = "|[Y|w test100 /|n|[B|w 200test |n" + self.assertEqual( + health_bar.display_meter( + 100, 200, length=40, pre_text="test", post_text="test", align="center" + ), + expected_bar_str, + ) + expected_bar_str = "|[G|w test180 / 200test |n|[B|w |n" + self.assertEqual( + health_bar.display_meter( + 180, 200, length=40, pre_text="test", post_text="test", align="center" + ), + expected_bar_str, + ) + expected_bar_str = "|[G|w test200 / 200test |n|[B|w|n" + self.assertEqual( + health_bar.display_meter( + 200, 200, length=40, pre_text="test", post_text="test", align="center" + ), + expected_bar_str, + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/llm/llm_client.html b/docs/latest/_modules/evennia/contrib/rpg/llm/llm_client.html new file mode 100644 index 0000000000..d9cf0aabdc --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/llm/llm_client.html @@ -0,0 +1,281 @@ + + + + + + + + evennia.contrib.rpg.llm.llm_client — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.llm.llm_client

+"""
+LLM (Large Language Model) client, for communicating with an LLM backend. This can be used
+for generating texts for AI npcs, or for fine-tuning the LLM on a given prompt.
+
+Note that running a LLM locally requires a lot of power, and ideally a powerful GPU. Testing
+this with CPU mode on a beefy laptop, still takes some 4s just on a very small model.
+
+The server defaults to output suitable for a local server
+https://github.com/oobabooga/text-generation-webui, but could be used for other LLM servers too.
+
+See the LLM instructions on that page for how to set up the server. You'll also need
+a model file - there are thousands to try out on https://huggingface.co/models (you want Text
+Generation models specifically).
+
+# Optional Evennia settings (if not given, these defaults are used)
+
+DEFAULT_LLM_HOST = "http://localhost:5000"
+DEFAULT_LLM_PATH = "/api/v1/generate"
+DEFAULT_LLM_HEADERS = {"Content-Type": "application/json"}
+DEFAULT_LLM_PROMPT_KEYNAME = "prompt"
+DEFAULT_LLM_REQUEST_BODY = {...}   # see below, this controls how to prompt the LLM server.
+
+"""
+
+import json
+
+from django.conf import settings
+from twisted.internet import defer, protocol, reactor
+from twisted.internet.defer import inlineCallbacks
+from twisted.web.client import Agent, HTTPConnectionPool, _HTTP11ClientFactory
+from twisted.web.http_headers import Headers
+from twisted.web.iweb import IBodyProducer
+from zope.interface import implementer
+
+from evennia import logger
+from evennia.utils.utils import make_iter
+
+DEFAULT_LLM_HOST = "http://127.0.0.1:5000"
+DEFAULT_LLM_PATH = "/api/v1/generate"
+DEFAULT_LLM_HEADERS = {"Content-Type": "application/json"}
+DEFAULT_LLM_PROMPT_KEYNAME = "prompt"
+DEFAULT_LLM_API_TYPE = ""  # or openai
+DEFAULT_LLM_REQUEST_BODY = {
+    "max_new_tokens": 250,  # max number of tokens to generate
+    "temperature": 0.7,  # higher = more random, lower = more predictable
+}
+
+
+
[docs]@implementer(IBodyProducer) +class StringProducer: + """ + Used for feeding a request body to the HTTP client. + """ + +
[docs] def __init__(self, body): + self.body = bytes(body, "utf-8") + self.length = len(body)
+ +
[docs] def startProducing(self, consumer): + consumer.write(self.body) + return defer.succeed(None)
+ +
[docs] def pauseProducing(self): + pass
+ +
[docs] def stopProducing(self): + pass
+ + +
[docs]class SimpleResponseReceiver(protocol.Protocol): + """ + Used for pulling the response body out of an HTTP response. + """ + +
[docs] def __init__(self, status_code, d): + self.status_code = status_code + self.buf = b"" + self.d = d
+ +
[docs] def dataReceived(self, data): + self.buf += data
+ +
[docs] def connectionLost(self, reason=protocol.connectionDone): + self.d.callback((self.status_code, self.buf))
+ + +
[docs]class QuietHTTP11ClientFactory(_HTTP11ClientFactory): + """ + Silences the obnoxious factory start/stop messages in the default client. + """ + + noisy = False
+ + +
[docs]class LLMClient: + """ + A client for communicating with an LLM server. + + """ + +
[docs] def __init__(self, on_bad_request=None): + self._conn_pool = HTTPConnectionPool(reactor) + self._conn_pool._factory = QuietHTTP11ClientFactory + + self.prompt_keyname = getattr(settings, "LLM_PROMPT_KEYNAME", DEFAULT_LLM_PROMPT_KEYNAME) + self.hostname = getattr(settings, "LLM_HOST", DEFAULT_LLM_HOST) + self.pathname = getattr(settings, "LLM_PATH", DEFAULT_LLM_PATH) + self.headers = getattr(settings, "LLM_HEADERS", DEFAULT_LLM_HEADERS) + self.request_body = getattr(settings, "LLM_REQUEST_BODY", DEFAULT_LLM_REQUEST_BODY) + + self.api_type = getattr(settings, "LLM_API_TYPE", DEFAULT_LLM_API_TYPE) + + self.agent = Agent(reactor, pool=self._conn_pool)
+ + def _format_request_body(self, prompt): + """Structure the request body for the LLM server""" + request_body = self.request_body.copy() + + prompt = "\n".join(make_iter(prompt)) + + request_body[self.prompt_keyname] = prompt + + return request_body + + def _handle_llm_response_body(self, response): + """Get the response body from the response""" + d = defer.Deferred() + response.deliverBody(SimpleResponseReceiver(response.code, d)) + return d + + def _handle_llm_error(self, failure): + """Correctly handle server connection errors""" + failure.trap(Exception) + return (500, failure.getErrorMessage()) + + def _get_response_from_llm_server(self, prompt): + """Call the LLM server and handle the response/failure""" + request_body = self._format_request_body(prompt) + + if settings.DEBUG: + logger.log_info(f"LLM request body: {request_body}") + + d = self.agent.request( + b"POST", + bytes(self.hostname + self.pathname, "utf-8"), + headers=Headers(self.headers), + bodyProducer=StringProducer(json.dumps(request_body)), + ) + + d.addCallbacks(self._handle_llm_response_body, self._handle_llm_error) + return d + +
[docs] @inlineCallbacks + def get_response(self, prompt): + """ + Get a response from the LLM server for the given npc. + + Args: + prompt (str or list): The prompt to send to the LLM server. If a list, + this is assumed to be the chat history so far, and will be added to the + prompt in a way suitable for the api. + + Returns: + str: The generated text response. Will return an empty string + if there is an issue with the server, in which case the + the caller is expected to handle this gracefully. + + """ + status_code, response = yield self._get_response_from_llm_server(prompt) + if status_code == 200: + if settings.DEBUG: + logger.log_info(f"LLM response: {response}") + return json.loads(response)["results"][0]["text"] + else: + logger.log_err(f"LLM API error (status {status_code}): {response}") + return ""
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/llm/llm_npc.html b/docs/latest/_modules/evennia/contrib/rpg/llm/llm_npc.html new file mode 100644 index 0000000000..2bd2b3ae17 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/llm/llm_npc.html @@ -0,0 +1,318 @@ + + + + + + + + evennia.contrib.rpg.llm.llm_npc — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.llm.llm_npc

+"""
+Basic class for NPC that makes use of an LLM (Large Language Model) to generate replies.
+
+It comes with a `talk` command; use `talk npc <something>` to talk to the NPC. The NPC will
+respond using the LLM response.
+
+Makes use of the LLMClient for communicating with the server. The NPC will also
+echo a 'thinking...' message if the LLM server takes too long to respond.
+
+
+"""
+
+from collections import defaultdict
+from random import choice
+
+from django.conf import settings
+from twisted.internet import reactor, task
+from twisted.internet.defer import CancelledError, inlineCallbacks
+
+from evennia import AttributeProperty, Command, DefaultCharacter
+from evennia.utils.utils import make_iter
+
+from .llm_client import LLMClient
+
+# fallback if not specified anywhere else. Check order is
+# npc.db.prompt_prefix, npcClass.prompt_prefix, then settings.LLM_PROMPT_PREFIX, then this
+DEFAULT_PROMPT_PREFIX = (
+    "You are roleplaying as {name}, a {desc} existing in {location}. "
+    "Answer with short sentences. Only respond as {name} would. "
+    "From here on, the conversation between {name} and {character} begins."
+)
+
+
+
[docs]class LLMNPC(DefaultCharacter): + """An NPC that uses the LLM server to generate its responses. If the server is slow, it will + echo a thinking message to the character while it waits for a response.""" + + # use this to override the prefix per class. Assign an Attribute to override per-instance. + prompt_prefix = None + + response_template = AttributeProperty( + "$You() $conj(say) (to $You(character)): {response}", autocreate=False + ) + thinking_timeout = AttributeProperty(2, autocreate=False) # seconds + thinking_messages = AttributeProperty( + [ + "{name} thinks about what you said ...", + "{name} ponders your words ...", + "{name} ponders ...", + ], + autocreate=False, + ) + + max_chat_memory_size = AttributeProperty(25, autocreate=False) + # this is a store of {character: [chat, chat, ...]} + chat_memory = AttributeProperty(defaultdict(list)) + + @property + def llm_client(self): + if not self.ndb.llm_client: + self.ndb.llm_client = LLMClient() + return self.ndb.llm_client + + @property + def llm_prompt_prefix(self): + """get prefix, first from Attribute, then from class variable, + then from settings, then from default""" + return self.attributes.get( + "prompt_prefix", + default=getattr( + settings, "LLM_PROMPT_PREFIX", self.prompt_prefix or DEFAULT_PROMPT_PREFIX + ), + ) + + def _add_to_memory(self, character, who_talked, speech): + """Add a person's speech to the memory. This is stored as name: chat for the LLM.""" + memory = self.chat_memory[character] + memory.append(f"{who_talked.get_display_name(self)}: {speech}") + + # trim the memory if it's getting too long in order to save space + memory = memory[-self.max_chat_memory_size :] + self.chat_memory[character] = memory + +
[docs] def build_prompt(self, character, speech): + """ + Build the prompt to send to the LLM server. + + Args: + character (Object): The one talking to the NPC. + speech (str): The latest speech from the character. + + Returns: + str: The prompt to return. + + """ + name = self.get_display_name(character) + charname = character.get_display_name(self) + memory = self.chat_memory[character] + + # get starting prompt + prompt = self.llm_prompt_prefix.format( + name=name, + desc=self.db.desc or "someone", + location=self.location.key if self.location else "the void", + character=charname, + ) + prompt += "\n" + "\n".join(mem for mem in memory) + return prompt
+ +
[docs] @inlineCallbacks + def at_talked_to(self, speech, character): + """Called when this NPC is talked to by a character.""" + + def _respond(response): + """Async handling of the server response""" + + if thinking_defer and not thinking_defer.called: + # abort the thinking message if we were fast enough + thinking_defer.cancel() + + if response: + # remember this response + self._add_to_memory(character, self, response) + else: + response = "... I'm sorry, I was distracted. Can you repeat?" + + response = self.response_template.format( + name=self.get_display_name(character), response=response + ) + + # tell the character about it + if character.location: + character.location.msg_contents( + response, + mapping={"character": character}, + from_obj=self, + ) + else: + # fallback if character is not in a location + character.msg(f"{self.get_display_name(character)} says, {response}") + + # if response takes too long, note that the NPC is thinking. + + def _echo_thinking_message(): + """Echo a random thinking message to the character""" + thinking_message = choice( + make_iter(self.db.thinking_messages or self.thinking_messages) + ) + if character.location: + thinking_message = thinking_message.format(name="$You()") + character.location.msg_contents(thinking_message, from_obj=self) + else: + thinking_message = thinking_message.format(name=self.get_display_name(character)) + character.msg(thinking_message) + + def _handle_cancel_error(failure): + """Suppress task-cancel errors only""" + failure.trap(CancelledError) + + thinking_defer = task.deferLater( + reactor, self.thinking_timeout, _echo_thinking_message + ).addErrback(_handle_cancel_error) + + # remember latest input in memory, so it's included in the prompt + self._add_to_memory(character, character, speech) + + # build the prompt + prompt = self.build_prompt(character, speech) + + # get the response from the LLM server + yield self.llm_client.get_response(prompt).addCallback(_respond)
+ + +
[docs]class CmdLLMTalk(Command): + """ + Talk to an NPC + + Usage: + talk npc <something> + talk npc with spaces in name = <something> + + """ + + key = "talk" + +
[docs] def parse(self): + args = self.args.strip() + if "s=" in args: + name, *speech = args.split("=", 1) + else: + name, *speech = args.split(" ", 1) + self.target_name = name + self.speech = speech[0] if speech else ""
+ +
[docs] def func(self): + if not self.target_name: + self.caller.msg("Talk to who?") + return + + location = self.caller.location + target = self.caller.search(self.target_name) + if not target: + return + if location: + location.msg_contents( + f"$You() $conj(say) (to $You(target)): {self.speech}", + mapping={"target": target}, + from_obj=self.caller, + ) + if hasattr(target, "at_talked_to"): + target.at_talked_to(self.speech, self.caller) + else: + self.caller.msg(f"{target.key} doesn't seem to want to talk to you.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/llm/tests.html b/docs/latest/_modules/evennia/contrib/rpg/llm/tests.html new file mode 100644 index 0000000000..21c46d582f --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/llm/tests.html @@ -0,0 +1,147 @@ + + + + + + + + evennia.contrib.rpg.llm.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.llm.tests

+"""
+Unit tests for the LLM Client and npc.
+
+"""
+
+from anything import Something
+from django.test import override_settings
+from evennia.utils.create import create_object
+from evennia.utils.test_resources import BaseEvenniaTestCase
+from mock import Mock, patch
+
+from .llm_npc import LLMNPC
+
+
+
[docs]class TestLLMClient(BaseEvenniaTestCase): + """ + Test the LLMNPC class. + + """ + +
[docs] def setUp(self): + self.npc = create_object(LLMNPC, key="Test NPC") + self.npc.db_home = None # fix a bug in test suite + self.npc.save()
+ +
[docs] def tearDown(self): + self.npc.delete() + super().tearDown()
+ +
[docs] @override_settings(LLM_PROMPT_PREFIX="You are a test bot.") + @patch("evennia.contrib.rpg.llm.llm_npc.task.deferLater") + def test_npc_at_talked_to(self, mock_deferLater): + """ + Test the npc's at_talked_to method. + """ + mock_LLMClient = Mock() + self.npc.ndb.llm_client = mock_LLMClient + + self.npc.at_talked_to("Hello", self.npc) + + mock_deferLater.assert_called_with(Something, self.npc.thinking_timeout, Something) + mock_LLMClient.get_response.assert_called_with("You are a test bot.\nTest NPC: Hello")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/rpsystem/rplanguage.html b/docs/latest/_modules/evennia/contrib/rpg/rpsystem/rplanguage.html new file mode 100644 index 0000000000..ac88a79427 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/rpsystem/rplanguage.html @@ -0,0 +1,714 @@ + + + + + + + + evennia.contrib.rpg.rpsystem.rplanguage — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.rpsystem.rplanguage

+"""
+Language and whisper obfuscation system
+
+Evennia contrib - Griatch 2015
+
+This module is intented to be used with an emoting system (such as
+contrib/rpsystem.py). It offers the ability to obfuscate spoken words
+in the game in various ways:
+
+- Language: The language functionality defines a pseudo-language map
+    to any number of languages.  The string will be obfuscated depending
+    on a scaling that (most likely) will be input as a weighted average of
+    the language skill of the speaker and listener.
+- Whisper: The whisper functionality will gradually "fade out" a
+    whisper along as scale 0-1, where the fading is based on gradually
+    removing sections of the whisper that is (supposedly) easier to
+    overhear (for example "s" sounds tend to be audible even when no other
+    meaning can be determined).
+
+## Usage
+
+```python
+from evennia.contrib import rplanguage
+
+# need to be done once, here we create the "default" lang
+rplanguage.add_language()
+
+say = "This is me talking."
+whisper = "This is me whispering.
+
+print rplanguage.obfuscate_language(say, level=0.0)
+<<< "This is me talking."
+print rplanguage.obfuscate_language(say, level=0.5)
+<<< "This is me byngyry."
+print rplanguage.obfuscate_language(say, level=1.0)
+<<< "Daly ly sy byngyry."
+
+result = rplanguage.obfuscate_whisper(whisper, level=0.0)
+<<< "This is me whispering"
+result = rplanguage.obfuscate_whisper(whisper, level=0.2)
+<<< "This is m- whisp-ring"
+result = rplanguage.obfuscate_whisper(whisper, level=0.5)
+<<< "---s -s -- ---s------"
+result = rplanguage.obfuscate_whisper(whisper, level=0.7)
+<<< "---- -- -- ----------"
+result = rplanguage.obfuscate_whisper(whisper, level=1.0)
+<<< "..."
+
+```
+
+## Custom languages
+
+To set up new languages, you need to run `add_language()`
+helper function in this module. The arguments of this function (see below)
+are used to store the new language in the database (in the LanguageHandler,
+which is a type of Script).
+
+If you want to remember the language definitions, you could put them all
+in a module along with the `add_language` call as a quick way to
+rebuild the language on a db reset:
+
+```python
+# a stand-alone module somewhere under mygame. Just import this
+# once to automatically add the language!
+
+from evennia.contrib.rpg.rpsystem import rplanguage
+grammar = (...)
+vowels = "eaouy"
+# etc
+
+rplanguage.add_language(grammar=grammar, vowels=vowels, ...)
+```
+
+The variables of `add_language` allows you to customize the "feel" of
+the semi-random language you are creating. Especially
+the `word_length_variance` helps vary the length of translated
+words compared to the original. You can also add your own
+dictionary and "fix" random words for a list of input words.
+
+## Example
+
+Below is an example module creating "elvish", using "rounder" vowels and sounds:
+
+```python
+# vowel/consonant grammar possibilities
+grammar = ("v vv vvc vcc vvcc cvvc vccv vvccv vcvccv vcvcvcc vvccvvcc "
+           "vcvvccvvc cvcvvcvvcc vcvcvvccvcvv")
+
+# all not in this group is considered a consonant
+vowels = "eaoiuy"
+
+# you need a representative of all of the minimal grammars here, so if a
+# grammar v exists, there must be atleast one phoneme available with only
+# one vowel in it
+phonemes = ("oi oh ee ae aa eh ah ao aw ay er ey ow ia ih iy "
+            "oy ua uh uw y p b t d f v t dh s z sh zh ch jh k "
+            "ng g m n l r w")
+
+# how much the translation varies in length compared to the original. 0 is
+# smallest, higher values give ever bigger randomness (including removing
+# short words entirely)
+word_length_variance = 1
+
+# if a proper noun (word starting with capitalized letter) should be
+# translated or not. If not (default) it means e.g. names will remain
+# unchanged across languages.
+noun_translate = False
+
+# all proper nouns (words starting with a capital letter not at the beginning
+# of a sentence) can have either a postfix or -prefix added at all times
+noun_postfix = "'la"
+
+# words in dict will always be translated this way. The 'auto_translations'
+# is instead a list or filename to file with words to use to help build a
+# bigger dictionary by creating random translations of each word in the
+# list *once* and saving the result for subsequent use.
+manual_translations = {"the":"y'e", "we":"uyi", "she":"semi", "he":"emi",
+                      "you": "do", 'me':'mi','i':'me', 'be':"hy'e", 'and':'y'}
+
+rplanguage.add_language(key="elvish", phonemes=phonemes, grammar=grammar,
+                         word_length_variance=word_length_variance,
+                         noun_translate=noun_translate,
+                         noun_postfix=noun_postfix, vowels=vowels,
+                         manual_translations=manual_translations,
+                         auto_translations="my_word_file.txt")
+
+```
+
+This will produce a decicively more "rounded" and "soft" language
+than the default one. The few manual_translations also make sure
+to make it at least look superficially "reasonable".
+
+The `auto_translations` keyword is useful, this accepts either a
+list or a path to a file of words (one per line) to automatically
+create fixed translations for according to the grammatical rules.
+This allows to quickly build a large corpus of translated words
+that never change (if this is desired).
+
+"""
+import re
+from collections import defaultdict
+from random import choice, randint
+
+from evennia import DefaultScript
+from evennia.utils import logger
+
+# ------------------------------------------------------------
+#
+# Obfuscate language
+#
+# ------------------------------------------------------------
+
+# default language grammar
+_PHONEMES = (
+    "ea oh ae aa eh ah ao aw ai er ey ow ia ih iy oy ua uh uw a e i u y p b t d f v t dh "
+    "s z sh zh ch jh k ng g m n l r w"
+)
+_VOWELS = "eaoiuy"
+# these must be able to be constructed from phonemes (so for example,
+# if you have v here, there must exist at least one single-character
+# vowel phoneme defined above)
+_GRAMMAR = "v cv vc cvv vcc vcv cvcc vccv cvccv cvcvcc cvccvcv vccvccvc cvcvccvv cvcvcvcvv"
+
+_RE_FLAGS = re.MULTILINE + re.IGNORECASE + re.DOTALL + re.UNICODE
+_RE_GRAMMAR = re.compile(r"vv|cc|v|c", _RE_FLAGS)
+_RE_WORD = re.compile(r"\w+", _RE_FLAGS)
+# superfluous chars, except ` ... `
+_RE_EXTRA_CHARS = re.compile(r"\s+(?!... )(?=\W)|[,.?;](?!.. )(?=[,?;]|\s+[,.?;])", _RE_FLAGS)
+
+
+
[docs]class LanguageError(RuntimeError): + pass
+ + +
[docs]class LanguageExistsError(LanguageError): + pass
+ + +
[docs]class LanguageHandler(DefaultScript): + """ + This is a storage class that should usually not be created on its + own. It's automatically created by a call to `obfuscate_language` + or `add_language` below. + + Languages are implemented as a "logical" pseudo- consistent language + algorith here. The idea is that a language is built up from + phonemes. These are joined together according to a "grammar" of + possible phoneme- combinations and allowed characters. It may + sound simplistic, but this allows to easily make + "similar-sounding" languages. One can also custom-define a + dictionary of some common words to give further consistency. + Optionally, the system also allows an input list of common words + to be loaded and given random translations. These will be stored + to disk and will thus not change. This gives a decent "stability" + of the language but if the goal is to obfuscate, this may allow + players to eventually learn to understand the gist of a sentence + even if their characters can not. Any number of languages can be + created this way. + + This nonsense language will partially replace the actual spoken + language when so desired (usually because the speaker/listener + don't know the language well enough). + + """ + +
[docs] def at_script_creation(self): + "Called when script is first started" + self.key = "language_handler" + self.persistent = True + self.db.language_storage = {}
+ +
[docs] def add( + self, + key="default", + phonemes=_PHONEMES, + grammar=_GRAMMAR, + word_length_variance=0, + noun_translate=False, + noun_prefix="", + noun_postfix="", + vowels=_VOWELS, + manual_translations=None, + auto_translations=None, + force=False, + ): + """ + Add a new language. Note that you generally only need to do + this once per language and that adding an existing language + will re-initialize all the random components to new permanent + values. + + Args: + key (str, optional): The name of the language. This + will be used as an identifier for the language so it + should be short and unique. + phonemes (str, optional): Space-separated string of all allowed + phonemes in this language. If either of the base phonemes + (c, v, cc, vv) are present in the grammar, the phoneme list must + at least include one example of each. + grammar (str): All allowed consonant (c) and vowel (v) combinations + allowed to build up words. Grammars are broken into the base phonemes + (c, v, cc, vv) prioritizing the longer bases. So cvv would be a + the c + vv (would allow for a word like 'die' whereas + cvcvccc would be c+v+c+v+cc+c (a word like 'galosch'). + word_length_variance (real): The variation of length of words. + 0 means a minimal variance, higher variance may mean words + have wildly varying length; this strongly affects how the + language "looks". + noun_translate (bool, optional): If a proper noun should be translated or + not. By default they will not, allowing for e.g. the names of characters + to be understandable. A 'noun' is identified as a capitalized word + *not at the start of a sentence*. This simple metric means that names + starting a sentence always will be translated (- but hey, maybe + the fantasy language just never uses a noun at the beginning of + sentences, who knows?) + noun_prefix (str, optional): A prefix to go before every noun + in this language (if any). + noun_postfix (str, optuonal): A postfix to go after every noun + in this language (if any, usually best to avoid combining + with `noun_prefix` or language becomes very wordy). + vowels (str, optional): Every vowel allowed in this language. + manual_translations (dict, optional): This allows for custom-setting + certain words in the language to mean the same thing. It is + on the form `{real_word: fictional_word}`, for example + `{"the", "y'e"}` . + auto_translations (str or list, optional): These are lists + words that should be auto-translated with a random, but + fixed, translation. If a path to a file, this file should + contain a list of words to produce translations for, one + word per line. If a list, the list's elements should be + the words to translate. The `manual_translations` will + always override overlapping translations created + automatically. + force (bool, optional): Unless true, will not allow the addition + of a language that is already created. + + Raises: + LanguageExistsError: Raised if trying to adding a language + with a key that already exists, without `force` being set. + Notes: + The `word_file` is for example a word-frequency list for + the N most common words in the host language. The + translations will be random, but will be stored + persistently to always be the same. This allows for + building a quick, decently-sounding fictive language that + tend to produce the same "translation" (mostly) with the + same input sentence. + + """ + if key in self.db.language_storage and not force: + raise LanguageExistsError( + "Language is already created. Re-adding it will re-build" + " its dictionary map. Use 'force=True' keyword if you are sure." + ) + + # create grammar_component->phoneme mapping + # {"vv": ["ea", "oh", ...], ...} + grammar2phonemes = defaultdict(list) + for phoneme in phonemes.split(): + if re.search(r"\W", phoneme, re.U): + raise LanguageError("The phoneme '%s' contains an invalid character." % phoneme) + gram = "".join(["v" if char in vowels else "c" for char in phoneme]) + grammar2phonemes[gram].append(phoneme) + + # allowed grammar are grouped by length + gramdict = defaultdict(list) + for gram in grammar.split(): + if re.search(r"\W|(!=[cv])", gram): + raise LanguageError( + "The grammar '%s' is invalid (only 'c' and 'v' are allowed)" % gram + ) + gramdict[len(gram)].append(gram) + grammar = dict(gramdict) + + # create automatic translation + translation = {} + + if auto_translations: + if isinstance(auto_translations, str): + # path to a file rather than a list + with open(auto_translations, "r") as f: + auto_translations = f.readlines() + for word in auto_translations: + word = word.strip() + lword = len(word) + new_word = "" + wlen = max(0, lword + sum(randint(-1, 1) for i in range(word_length_variance))) + if wlen not in grammar: + # always create a translation, use random length + structure = choice(grammar[choice(list(grammar))]) + else: + # use the corresponding length + structure = choice(grammar[wlen]) + for match in _RE_GRAMMAR.finditer(structure): + try: + new_word += choice(grammar2phonemes[match.group()]) + except IndexError: + raise IndexError( + "Could not find a matching phoneme for the grammar " + f"'{match.group()}'. Make there is at least one phoneme matching this " + "combination of consonants and vowels." + ) + translation[word.lower()] = new_word.lower() + + if manual_translations: + # update with manual translations + translation.update( + dict((key.lower(), value.lower()) for key, value in manual_translations.items()) + ) + + # store data + storage = { + "translation": translation, + "grammar": grammar, + "grammar2phonemes": dict(grammar2phonemes), + "word_length_variance": word_length_variance, + "noun_translate": noun_translate, + "noun_prefix": noun_prefix, + "noun_postfix": noun_postfix, + } + self.db.language_storage[key] = storage
+ + def _translate_sub(self, match): + """ + Replacer method called by re.sub when + traversing the language string. + + Args: + match (re.matchobj): Match object from regex. + + Returns: + converted word. + Notes: + Assumes self.lastword and self.level is available + on the object. + + """ + word = match.group() + lword = len(word) + + # find out what preceeded this word + wpos = match.start() + preceeding = match.string[:wpos].strip() + start_sentence = preceeding.endswith((".", "!", "?")) or not preceeding + + if len(word) <= self.level: + # below level. Don't translate + new_word = word + else: + # try to translate the word from dictionary + new_word = self.language["translation"].get(word.lower(), "") + if not new_word: + # no dictionary translation. Generate one + + # make up translation on the fly. Length can + # vary from un-translated word. + wlen = max( + 0, + lword + + sum(randint(-1, 1) for i in range(self.language["word_length_variance"])), + ) + grammar = self.language["grammar"] + if wlen not in grammar: + if randint(0, 1) == 0: + # this word has no direct translation! + wlen = 0 + new_word = "" + else: + # use random word length + wlen = choice(list(grammar.keys())) + + if wlen: + structure = choice(grammar[wlen]) + grammar2phonemes = self.language["grammar2phonemes"] + for match in _RE_GRAMMAR.finditer(structure): + # there are only four combinations: vv,cc,c,v + try: + new_word += choice(grammar2phonemes[match.group()]) + except KeyError: + logger.log_trace( + "You need to supply at least one example of each of " + "the four base phonemes (c, v, cc, vv)" + ) + # abort translation here + new_word = "" + break + + if word.istitle(): + if not start_sentence: + # this is a noun. We miss nouns at the start of + # sentences this way, but it's as good as we can get + # with this simple analysis. Maybe the fantasy language + # just don't consider nouns at the beginning of + # sentences, who knows? + if not self.language.get("noun_translate", False): + # don't translate what we identify as proper nouns (names) + new_word = word + + # add noun prefix and/or postfix + new_word = "{prefix}{word}{postfix}".format( + prefix=self.language["noun_prefix"], + word=new_word.capitalize(), + postfix=self.language["noun_postfix"], + ) + + if len(word) > 1 and word.isupper(): + # keep LOUD words loud also when translated + new_word = new_word.upper() + + if start_sentence: + new_word = new_word.capitalize() + + return new_word + +
[docs] def translate(self, text, level=0.0, language="default"): + """ + Translate the text according to the given level. + + Args: + text (str): The text to translate + level (real): Value between 0.0 and 1.0, where + 0.0 means no obfuscation (text returned unchanged) and + 1.0 means full conversion of every word. The closer to + 1, the shorter words will be translated. + language (str): The language key identifier. + + Returns: + text (str): A translated string. + + """ + if level == 0.0: + # no translation + return text + language = self.db.language_storage.get(language, None) + if not language: + return text + self.language = language + + # configuring the translation + self.level = int(10 * (1.0 - max(0, min(level, 1.0)))) + translation = _RE_WORD.sub(self._translate_sub, text) + # the substitution may create too long empty spaces, remove those + return _RE_EXTRA_CHARS.sub("", translation)
+ + +# Language access functions + +_LANGUAGE_HANDLER = None + + +
[docs]def obfuscate_language(text, level=0.0, language="default"): + """ + Main access method for the language parser. + + Args: + text (str): Text to obfuscate. + level (real, optional): A value from 0.0-1.0 determining + the level of obfuscation where 0 means no obfuscation + (string returned unchanged) and 1.0 means the entire + string is obfuscated. + language (str, optional): The identifier of a language + the system understands. + + Returns: + translated (str): The translated text. + + """ + # initialize the language handler and cache it + global _LANGUAGE_HANDLER + if not _LANGUAGE_HANDLER: + try: + _LANGUAGE_HANDLER = LanguageHandler.objects.get(db_key="language_handler") + except LanguageHandler.DoesNotExist: + if not _LANGUAGE_HANDLER: + from evennia import create_script + + _LANGUAGE_HANDLER = create_script(LanguageHandler) + return _LANGUAGE_HANDLER.translate(text, level=level, language=language)
+ + +
[docs]def add_language(**kwargs): + """ + Access function to creating a new language. See the docstring of + `LanguageHandler.add` for list of keyword arguments. + + """ + global _LANGUAGE_HANDLER + if not _LANGUAGE_HANDLER: + try: + _LANGUAGE_HANDLER = LanguageHandler.objects.get(db_key="language_handler") + except LanguageHandler.DoesNotExist: + if not _LANGUAGE_HANDLER: + from evennia import create_script + + _LANGUAGE_HANDLER = create_script(LanguageHandler) + _LANGUAGE_HANDLER.add(**kwargs)
+ + +
[docs]def available_languages(): + """ + Returns all available language keys. + + Returns: + languages (list): List of key strings of all available + languages. + """ + global _LANGUAGE_HANDLER + if not _LANGUAGE_HANDLER: + try: + _LANGUAGE_HANDLER = LanguageHandler.objects.get(db_key="language_handler") + except LanguageHandler.DoesNotExist: + if not _LANGUAGE_HANDLER: + from evennia import create_script + + _LANGUAGE_HANDLER = create_script(LanguageHandler) + return list(_LANGUAGE_HANDLER.attributes.get("language_storage", {}))
+ + +# ----------------------------------------------------------------------------- +# +# Whisper obscuration +# +# This obsucration table is designed by obscuring certain vowels first, +# following by consonants that tend to be more audible over long distances, +# like s. Finally it does non-auditory replacements, like exclamation marks and +# capitalized letters (assumed to be spoken louder) that may still give a user +# some idea of the sentence structure. Then the word lengths are also +# obfuscated and finally the whisper length itself. +# +# ------------------------------------------------------------------------------ + + +_RE_WHISPER_OBSCURE = [ + re.compile(r"^$", _RE_FLAGS), # This is a Test! #0 full whisper + re.compile(r"[ae]", _RE_FLAGS), # This -s - Test! #1 add uy + re.compile(r"[aeuy]", _RE_FLAGS), # This -s - Test! #2 add oue + re.compile(r"[aeiouy]", _RE_FLAGS), # Th-s -s - T-st! #3 add all consonants + re.compile(r"[aeiouybdhjlmnpqrv]", _RE_FLAGS), # T--s -s - T-st! #4 add hard consonants + re.compile(r"[a-eg-rt-z]", _RE_FLAGS), # T--s -s - T-s-! #5 add all capitals + re.compile(r"[A-EG-RT-Za-eg-rt-z]", _RE_FLAGS), # ---s -s - --s-! #6 add f + re.compile(r"[A-EG-RT-Za-rt-z]", _RE_FLAGS), # ---s -s - --s-! #7 add s + re.compile(r"[A-EG-RT-Za-z]", _RE_FLAGS), # ---- -- - ----! #8 add capital F + re.compile(r"[A-RT-Za-z]", _RE_FLAGS), # ---- -- - ----! #9 add capital S + re.compile(r"[\w]", _RE_FLAGS), # ---- -- - ----! #10 non-alphanumerals + re.compile(r"[\S]", _RE_FLAGS), # ---- -- - ---- #11 words + re.compile(r"[\w\W]", _RE_FLAGS), # -------------- #12 whisper length + re.compile(r".*", _RE_FLAGS), +] # ... #13 (always same length) + + +
[docs]def obfuscate_whisper(whisper, level=0.0): + """ + Obfuscate whisper depending on a pre-calculated level + (that may depend on distance, listening skill etc) + + Args: + whisper (str): The whisper string to obscure. The + entire string will be considered in the obscuration. + level (real, optional): This is a value 0-1, where 0 + means not obscured (whisper returned unchanged) and 1 + means fully obscured. + + """ + level = min(max(0.0, level), 1.0) + olevel = int(13.0 * level) + if olevel == 13: + return "..." + else: + return _RE_WHISPER_OBSCURE[olevel].sub("-", whisper)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/rpsystem/rpsystem.html b/docs/latest/_modules/evennia/contrib/rpg/rpsystem/rpsystem.html new file mode 100644 index 0000000000..78fdde3833 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/rpsystem/rpsystem.html @@ -0,0 +1,1929 @@ + + + + + + + + evennia.contrib.rpg.rpsystem.rpsystem — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.rpsystem.rpsystem

+"""
+Roleplaying base system for Evennia
+
+Contribution - Griatch, 2015
+
+This module contains the ContribRPObject, ContribRPRoom and
+ContribRPCharacter typeclasses.  If you inherit your
+objects/rooms/character from these (or make them the defaults) from
+these you will get the following features:
+
+- Objects/Rooms will get the ability to have poses and will report
+  the poses of items inside them (the latter most useful for Rooms).
+- Characters will get poses and also sdescs (short descriptions)
+that will be used instead of their keys. They will gain commands
+for managing recognition (custom sdesc-replacement), masking
+themselves as well as an advanced free-form emote command.
+
+In more detail, This RP base system introduces the following features
+to a game, common to many RP-centric games:
+
+- emote system using director stance emoting (names/sdescs).
+    This uses a customizable replacement noun (/me, @ etc) to
+    represent you in the emote. You can use /sdesc, /nick, /key or
+    /alias to reference objects in the room. You can use any
+    number of sdesc sub-parts to differentiate a local sdesc, or
+    use /1-sdesc etc to differentiate them. The emote also
+    identifies nested says and separates case.
+- sdesc obscuration of real character names for use in emotes
+    and in any referencing such as object.search().  This relies
+    on an SdescHandler `sdesc` being set on the Character and
+    makes use of a custom Character.get_display_name hook. If
+    sdesc is not set, the character's `key` is used instead. This
+    is particularly used in the emoting system.
+- recog system to assign your own nicknames to characters, can then
+    be used for referencing. The user may recog a user and assign
+    any personal nick to them. This will be shown in descriptions
+    and used to reference them. This is making use of the nick
+    functionality of Evennia.
+- masks to hide your identity (using a simple lock).
+- pose system to set room-persistent poses, visible in room
+    descriptions and when looking at the person/object.  This is a
+    simple Attribute that modifies how the characters is viewed when
+    in a room as sdesc + pose.
+- in-emote says, including seamless integration with language
+    obscuration routine (such as contrib/rpg/rplanguage.py)
+
+Installation:
+
+Add `RPSystemCmdSet` from this module to your CharacterCmdSet:
+
+```python
+# mygame/commands/default_cmdsets.py
+
+# ...
+
+from evennia.contrib.rpg.rpsystem.rpsystem import RPSystemCmdSet  <---
+
+class CharacterCmdSet(default_cmds.CharacterCmdset):
+    # ...
+    def at_cmdset_creation(self):
+        # ...
+        self.add(RPSystemCmdSet())  # <---
+
+```
+
+You also need to make your Characters/Objects/Rooms inherit from
+the typeclasses in this module:
+
+```python
+# in mygame/typeclasses/characters.py
+
+from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPCharacter
+
+class Character(ContribRPCharacter):
+    # ...
+
+```
+
+```python
+# in mygame/typeclasses/objects.py
+
+from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPObject
+
+class Object(ContribRPObject):
+    # ...
+
+```
+
+```python
+# in mygame/typeclasses/rooms.py
+
+from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPRoom
+
+class Room(ContribRPRoom):
+    # ...
+
+```
+
+Examples:
+
+> look
+Tavern
+The tavern is full of nice people
+
+*A tall man* is standing by the bar.
+
+Above is an example of a player with an sdesc "a tall man". It is also
+an example of a static *pose*: The "standing by the bar" has been set
+by the player of the tall man, so that people looking at him can tell
+at a glance what is going on.
+
+> emote /me looks at /Tall and says "Hello!"
+
+I see:
+    Griatch looks at Tall man and says "Hello".
+Tall man (assuming his name is Tom) sees:
+    The godlike figure looks at Tom and says "Hello".
+
+Note that by default, the case of the tag matters, so `/tall` will
+lead to 'tall man' while `/Tall` will become 'Tall man' and /TALL
+becomes /TALL MAN. If you don't want this behavior, you can pass
+case_sensitive=False to the `send_emote` function.
+
+Extra Installation Instructions:
+
+1. In typeclasses/character.py:
+   Import the `ContribRPCharacter` class:
+       `from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPCharacter`
+   Inherit ContribRPCharacter:
+       Change "class Character(DefaultCharacter):" to
+       `class Character(ContribRPCharacter):`
+   If you have any overriden calls in `at_object_creation(self)`:
+       Add `super().at_object_creation()` as the top line.
+2. In `typeclasses/rooms.py`:
+       Import the `ContribRPRoom` class:
+       `from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPRoom`
+   Inherit `ContribRPRoom`:
+       Change `class Room(DefaultRoom):` to
+       `class Room(ContribRPRoom):`
+3. In `typeclasses/objects.py`
+       Import the `ContribRPObject` class:
+       `from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPObject`
+   Inherit `ContribRPObject`:
+       Change `class Object(DefaultObject):` to
+       `class Object(ContribRPObject):`
+4. Reload the server (`reload` or from console: "evennia reload")
+5. Force typeclass updates as required. Example for your character:
+       `type/reset/force me = typeclasses.characters.Character`
+
+"""
+import re
+from collections import defaultdict
+from string import punctuation
+
+import inflect
+from django.conf import settings
+
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.command import Command
+from evennia.objects.models import ObjectDB
+from evennia.objects.objects import DefaultCharacter, DefaultObject
+from evennia.utils import ansi, logger
+from evennia.utils.utils import (
+    iter_to_str,
+    lazy_property,
+    make_iter,
+    variable_from_module,
+)
+
+_INFLECT = inflect.engine()
+
+_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1))
+# ------------------------------------------------------------
+# Emote parser
+# ------------------------------------------------------------
+
+# Settings
+
+# The prefix is the (single-character) symbol used to find the start
+# of a object reference, such as /tall (note that
+# the system will understand multi-word references like '/a tall man' too).
+_PREFIX = "/"
+
+# The num_sep is the (single-character) symbol used to separate the
+# sdesc from the number when  trying to separate identical sdescs from
+# one another. This is the same syntax used in the rest of Evennia, so
+# by default, multiple "tall" can be separated by entering 1-tall,
+# 2-tall etc.
+_NUM_SEP = "-"
+
+# Texts
+
+_EMOTE_NOMATCH_ERROR = """|RNo match for |r{ref}|R.|n"""
+
+_EMOTE_MULTIMATCH_ERROR = """|RMultiple possibilities for {ref}:
+    |r{reflist}|n"""
+
+_RE_FLAGS = re.MULTILINE + re.IGNORECASE + re.UNICODE
+
+_RE_PREFIX = re.compile(rf"^{_PREFIX}", re.UNICODE)
+
+# This regex will return groups (num, word), where num is an optional counter to
+# separate multimatches from one another and word is the first word in the
+# marker. So entering "/tall man" will return groups ("", "tall")
+# and "/2-tall man" will return groups ("2", "tall").
+# the negative lookbehind for [:/] is to avoid http:// urls being detected as a /sdesc
+_RE_OBJ_REF_START = re.compile(rf"(?<![:/]){_PREFIX}(?:([0-9]+){_NUM_SEP})*(\w+)", _RE_FLAGS)
+
+_RE_LEFT_BRACKETS = re.compile(r"\{+", _RE_FLAGS)
+_RE_RIGHT_BRACKETS = re.compile(r"\}+", _RE_FLAGS)
+# Reference markers are used internally when distributing the emote to
+# all that can see it. They are never seen by players and are on the form {#dbref<char>}
+# with the <char> indicating case of the original reference query (like ^ for uppercase)
+_RE_REF = re.compile(r"\{+\#([0-9]+[\^\~tv]{0,1})\}+")
+
+# This regex is used to quickly reference one self in an emote.
+_RE_SELF_REF = re.compile(r"(/me|@)(?=\W+)", _RE_FLAGS)
+
+# regex for non-alphanumberic end of a string
+_RE_CHAREND = re.compile(r"\W+$", _RE_FLAGS)
+
+# reference markers for language
+_RE_REF_LANG = re.compile(r"\{+\##([0-9]+)\}+")
+# language says in the emote are on the form "..." or langname"..." (no spaces).
+# this regex returns in groups (langname, say), where langname can be empty.
+_RE_LANGUAGE = re.compile(r"(?:\((\w+)\))*(\".+?\")")
+
+
+# the emote parser works in two steps:
+#  1) convert the incoming emote into an intermediary
+#     form with all object references mapped to ids.
+#  2) for every person seeing the emote, parse this
+#     intermediary form into the one valid for that char.
+
+
+
[docs]class EmoteError(Exception): + pass
+ + +
[docs]class SdescError(Exception): + pass
+ + +
[docs]class RecogError(Exception): + pass
+ + +
[docs]class LanguageError(Exception): + pass
+ + +def _get_case_ref(string): + """ + Helper function which parses capitalization and + returns the appropriate case-ref character for emotes. + """ + # default to retaining the original case + case = "~" + # internal flags for the case used for the original /query + # - t for titled input (like /Name) + # - ^ for all upercase input (like /NAME) + # - v for lower-case input (like /name) + # - ~ for mixed case input (like /nAmE) + if string.istitle(): + case = "t" + elif string.isupper(): + case = "^" + elif string.islower(): + case = "v" + + return case + + +# emoting mechanisms +
[docs]def parse_language(speaker, emote): + """ + Parse the emote for language. This is + used with a plugin for handling languages. + + Args: + speaker (Object): The object speaking. + emote (str): An emote possibly containing + language references. + + Returns: + (emote, mapping) (tuple): A tuple where the + `emote` is the emote string with all says + (including quotes) replaced with reference + markers on the form {##n} where n is a running + number. The `mapping` is a dictionary between + the markers and a tuple (langname, saytext), where + langname can be None. + Raises: + evennia.contrib.rpg.rpsystem.LanguageError: If an invalid language was + specified. + + Notes: + Note that no errors are raised if the wrong language identifier + is given. + This data, together with the identity of the speaker, is + intended to be used by the "listener" later, since with this + information the language skill of the speaker can be offset to + the language skill of the listener to determine how much + information is actually conveyed. + + """ + # escape mapping syntax on the form {##id} if it exists already in emote, + # if so it is replaced with just "id". + emote = _RE_REF_LANG.sub(r"\1", emote) + + errors = [] + mapping = {} + for imatch, say_match in enumerate(reversed(list(_RE_LANGUAGE.finditer(emote)))): + # process matches backwards to be able to replace + # in-place without messing up indexes for future matches + # note that saytext includes surrounding "...". + langname, saytext = say_match.groups() + istart, iend = say_match.start(), say_match.end() + # the key is simply the running match in the emote + key = f"##{imatch}" + # replace say with ref markers in emote + emote = "{start}{{{key}}}{end}".format(start=emote[:istart], key=key, end=emote[iend:]) + mapping[key] = (langname, saytext) + + if errors: + # catch errors and report + raise LanguageError("\n".join(errors)) + + # at this point all says have been replaced with {##nn} markers + # and mapping maps 1:1 to this. + return emote, mapping
+ + +
[docs]def parse_sdescs_and_recogs( + sender, candidates, string, search_mode=False, case_sensitive=True, fallback=None +): + """ + Read a raw emote and parse it into an intermediary + format for distributing to all observers. + + Args: + sender (Object): The object sending the emote. This object's + recog data will be considered in the parsing. + candidates (iterable): A list of objects valid for referencing + in the emote. + string (str): The string (like an emote) we want to analyze for keywords. + search_mode (bool, optional): If `True`, the "emote" is a query string + we want to analyze. If so, the return value is changed. + case_sensitive (bool, optional): If set, the case of /refs matter, so that + /tall will come out as 'tall man' while /Tall will become 'Tall man'. + This allows for more grammatically correct emotes at the cost of being + a little more to learn for players. If disabled, the original sdesc case + is always kept and are inserted as-is. + fallback (string, optional): If set, any references that don't match a target + will be replaced with the fallback string. If `None` (default), the + parsing will fail and give a warning about the missing reference. + + Returns: + (emote, mapping) (tuple): If `search_mode` is `False` + (default), a tuple where the emote is the emote string, with + all references replaced with internal-representation {#dbref} + markers and mapping is a dictionary `{"#dbref":obj, ...}`. + result (list): If `search_mode` is `True` we are + performing a search query on `string`, looking for a specific + object. A list with zero, one or more matches. + + Raises: + EmoteException: For various ref-matching errors. + + Notes: + The parser analyzes and should understand the following + _PREFIX-tagged structures in the emote: + - self-reference (/me) + - recogs (any part of it) stored on emoter, matching obj in `candidates`. + - sdesc (any part of it) from any obj in `candidates`. + - N-sdesc, N-recog separating multi-matches (1-tall, 2-tall) + - says, "..." are + + """ + # build a list of candidates with all possible referrable names + # include 'me' keyword for self-ref + candidate_map = [] + for obj in candidates: + # check if sender has any recogs for obj and add + if hasattr(sender, "recog"): + if recog := sender.recog.get(obj): + candidate_map.append((obj, recog)) + # check if obj has an sdesc and add + if hasattr(obj, "sdesc"): + candidate_map.append((obj, obj.sdesc.get())) + # if no sdesc, include key plus aliases instead + else: + candidate_map.append((obj, obj.key)) + candidate_map.extend([(obj, alias) for alias in obj.aliases.all()]) + + # escape mapping syntax on the form {#id} if it exists already in emote, + # if so it is replaced with just "id". + string = _RE_REF.sub(r"\1", string) + # escape loose { } brackets since this will clash with formatting + string = _RE_LEFT_BRACKETS.sub("{{", string) + string = _RE_RIGHT_BRACKETS.sub("}}", string) + + # we now loop over all references and analyze them + mapping = {} + errors = [] + obj = None + nmatches = 0 + # first, find and replace any self-refs + for self_match in list(_RE_SELF_REF.finditer(string)): + matched = self_match.group() + case = _get_case_ref(matched.lstrip(_PREFIX)) if case_sensitive else "" + key = f"#{sender.id}{case}" + # replaced with ref + string = _RE_SELF_REF.sub(f"{{{key}}}", string, count=1) + mapping[key] = sender + + for marker_match in reversed(list(_RE_OBJ_REF_START.finditer(string))): + # we scan backwards so we can replace in-situ without messing + # up later occurrences. Given a marker match, query from + # start index forward for all candidates. + + # first see if there is a number given (e.g. 1-tall) + num_identifier, _ = marker_match.groups("") # return "" if no match, rather than None + match_index = marker_match.start() + # split the emote string at the reference marker, to process everything after it + head = string[:match_index] + tail = string[match_index + 1 :] + + if search_mode: + # match the candidates against the whole search string after the marker + rquery = "".join( + [ + r"\b(" + re.escape(word.strip(punctuation)) + r").*" + for word in iter(tail.split()) + ] + ) + matches = ( + (re.search(rquery, text, _RE_FLAGS), obj, text) for obj, text in candidate_map + ) + # filter out any non-matching candidates + bestmatches = [(obj, match.group()) for match, obj, text in matches if match] + + else: + # to find the longest match, we start from the marker and lengthen the + # match query one word at a time. + word_list = [] + bestmatches = [] + # preserve punctuation when splitting + tail = re.split("(\W)", tail) + iend = 0 + for i, item in enumerate(tail): + # don't add non-word characters to the search query + if not item.isalpha(): + continue + word_list.append(item) + rquery = "".join([r"\b(" + re.escape(word) + r").*" for word in word_list]) + # match candidates against the current set of words + matches = ( + (re.search(rquery, text, _RE_FLAGS), obj, text) for obj, text in candidate_map + ) + matches = [(obj, match.group()) for match, obj, text in matches if match] + if len(matches) == 0: + # no matches at this length, keep previous iteration as best + break + # since this is the longest match so far, set latest match set as best matches + bestmatches = matches + # save current index as end point of matched text + iend = i + + # save search string + matched_text = "".join(tail[1:iend]) + # recombine remainder of emote back into a string + tail = "".join(tail[iend + 1 :]) + + nmatches = len(bestmatches) + + if not nmatches: + # no matches + obj = None + nmatches = 0 + elif nmatches == 1: + # an exact match. + obj, match_str = bestmatches[0] + elif all(bestmatches[0][0].id == obj.id for obj, text in bestmatches): + # multi-match but all matches actually reference the same + # obj (could happen with clashing recogs + sdescs) + obj, match_str = bestmatches[0] + nmatches = 1 + else: + # multi-match. + # was a numerical identifier given to help us separate the multi-match? + inum = min(max(0, int(num_identifier) - 1), nmatches - 1) if num_identifier else None + if inum is not None: + # A valid inum is given. Use this to separate data. + obj, match_str = bestmatches[inum] + nmatches = 1 + else: + # no identifier given - a real multimatch. + obj = bestmatches + + if search_mode: + # single-object search mode. Don't continue loop. + break + elif nmatches == 0: + if fallback: + # replace unmatched reference with the fallback string + string = f"{head}{fallback}{tail}" + else: + errors.append(_EMOTE_NOMATCH_ERROR.format(ref=marker_match.group())) + elif nmatches == 1: + # a unique match - parse into intermediary representation + case = _get_case_ref(marker_match.group()) if case_sensitive else "" + # recombine emote with matched text replaced by ref + key = f"#{obj.id}{case}" + string = f"{head}{{{key}}}{tail}" + mapping[key] = obj + + else: + # multimatch error + refname = marker_match.group() + reflist = [ + "{num}{sep}{name} ({text}{key})".format( + num=inum + 1, + sep=_NUM_SEP, + name=_RE_PREFIX.sub("", refname), + text=text, + key=f" ({sender.key})" if sender == ob else "", + ) + for inum, (ob, text) in enumerate(obj) + ] + errors.append( + _EMOTE_MULTIMATCH_ERROR.format( + ref=marker_match.group(), reflist="\n ".join(reflist) + ) + ) + if search_mode: + # return list of object(s) matching + if nmatches == 0: + return [] + elif nmatches == 1: + return [obj] + else: + return [tup[0] for tup in obj] + + if errors: + # make sure to not let errors through. + raise EmoteError("\n".join(errors)) + + # at this point all references have been replaced with {#xxx} markers and the mapping contains + # a 1:1 mapping between those inline markers and objects. + return string, mapping
+ + +
[docs]def send_emote(sender, receivers, emote, msg_type="pose", anonymous_add="first", **kwargs): + """ + Main access function for distribute an emote. + + Args: + sender (Object): The one sending the emote. + receivers (iterable): Receivers of the emote. These + will also form the basis for which sdescs are + 'valid' to use in the emote. + emote (str): The raw emote string as input by emoter. + msg_type (str): The type of emote this is. "say" or "pose" + for example. This is arbitrary and used for generating + extra data for .msg(text) tuple. + anonymous_add (str or None, optional): If `sender` is not + self-referencing in the emote, this will auto-add + `sender`'s data to the emote. Possible values are + - None: No auto-add at anonymous emote + - 'last': Add sender to the end of emote as [sender] + - 'first': Prepend sender to start of emote. + Kwargs: + case_sensitive (bool): Defaults to True, but can be unset + here. When enabled, /tall will lead to a lowercase + 'tall man' while /Tall will lead to 'Tall man' and + /TALL will lead to 'TALL MAN'. If disabled, the sdesc's + case will always be used, regardless of the /ref case used. + any: Other kwargs will be passed on into the receiver's process_sdesc and + process_recog methods, and can thus be used to customize those. + + """ + case_sensitive = kwargs.pop("case_sensitive", True) + fallback = kwargs.pop("fallback", None) + try: + emote, obj_mapping = parse_sdescs_and_recogs( + sender, receivers, emote, case_sensitive=case_sensitive, fallback=fallback + ) + emote, language_mapping = parse_language(sender, emote) + except (EmoteError, LanguageError) as err: + # handle all error messages, don't hide actual coding errors + sender.msg(str(err)) + return + + skey = f"#{sender.id}" + + # we escape the object mappings since we'll do the language ones first + # (the text could have nested object mappings). + emote = _RE_REF.sub(r"{{#\1}}", emote) + # if anonymous_add is passed as a kwarg, collect and remove it from kwargs + if "anonymous_add" in kwargs: + anonymous_add = kwargs.pop("anonymous_add") + # make sure to catch all possible self-refs + self_refs = [f"{skey}{ref}" for ref in ("t", "^", "v", "~", "")] + if anonymous_add and not any(1 for tag in obj_mapping if tag in self_refs): + # no self-reference in the emote - add it + if anonymous_add == "first": + # add case flag for initial caps + skey += "t" + # don't put a space after the self-ref if it's a possessive emote + femote = "{key}{emote}" if emote.startswith("'") else "{key} {emote}" + else: + # add it to the end + femote = "{emote} [{key}]" + emote = femote.format(key="{{" + skey + "}}", emote=emote) + obj_mapping[skey] = sender + + # broadcast emote to everyone + for receiver in receivers: + # first handle the language mapping, which always produce different keys ##nn + if hasattr(receiver, "process_language") and callable(receiver.process_language): + receiver_lang_mapping = { + key: receiver.process_language(saytext, sender, langname) + for key, (langname, saytext) in language_mapping.items() + } + else: + receiver_lang_mapping = { + key: saytext for key, (langname, saytext) in language_mapping.items() + } + # map the language {##num} markers. This will convert the escaped sdesc markers on + # the form {{#num}} to {#num} markers ready to sdesc-map in the next step. + sendemote = emote.format_map(receiver_lang_mapping) + + # map the ref keys to sdescs + receiver_sdesc_mapping = dict( + ( + ref, + obj.get_display_name(receiver, ref=ref, noid=True), + ) + for ref, obj in obj_mapping.items() + ) + + # do the template replacement of the sdesc/recog {#num} markers + receiver.msg( + text=(sendemote.format_map(receiver_sdesc_mapping), {"type": msg_type}), + from_obj=sender, + **kwargs, + )
+ + +# ------------------------------------------------------------ +# Handlers for sdesc and recog +# ------------------------------------------------------------ + + +
[docs]class SdescHandler: + """ + This Handler wraps all operations with sdescs. We + need to use this since we do a lot preparations on + sdescs when updating them, in order for them to be + efficient to search for and query. + + The handler stores data in the following Attributes + + _sdesc - a string + _regex - an empty dictionary + + """ + +
[docs] def __init__(self, obj): + """ + Initialize the handler + + Args: + obj (Object): The entity on which this handler is stored. + + """ + self.obj = obj + self.sdesc = "" + self._cache()
+ + def _cache(self): + """ + Cache data from storage + """ + self.sdesc = self.obj.attributes.get("_sdesc", default=self.obj.key) + +
[docs] def add(self, sdesc, max_length=60): + """ + Add a new sdesc to object, replacing the old one. + + Args: + sdesc (str): The sdesc to set. This may be stripped + of control sequences before setting. + max_length (int, optional): The max limit of the sdesc. + + Returns: + sdesc (str): The actually set sdesc. + + Raises: + SdescError: If the sdesc is empty, can not be set or is + longer than `max_length`. + + """ + # strip emote components from sdesc + sdesc = _RE_REF.sub( + r"\1", + _RE_REF_LANG.sub( + r"\1", + _RE_SELF_REF.sub(r"", _RE_LANGUAGE.sub(r"", _RE_OBJ_REF_START.sub(r"", sdesc))), + ), + ) + + # make an sdesc clean of ANSI codes + cleaned_sdesc = ansi.strip_ansi(sdesc) + + if not cleaned_sdesc: + raise SdescError("Short desc cannot be empty.") + + if len(cleaned_sdesc) > max_length: + raise SdescError( + "Short desc can max be {} chars long (was {} chars).".format( + max_length, len(cleaned_sdesc) + ) + ) + + # store to attributes + self.obj.attributes.add("_sdesc", sdesc) + # local caching + self.sdesc = sdesc + + return sdesc
+ +
[docs] def clear(self): + """ + Clear sdesc. + + """ + self.obj.attributes.remove("_sdesc")
+ +
[docs] def get(self): + """ + Simple getter. The sdesc should never be allowed to + be empty, but if it is we must fall back to the key. + + """ + return self.sdesc or self.obj.key
+ + +
[docs]class RecogHandler: + """ + This handler manages the recognition mapping + of an Object. + + The handler stores data in Attributes as dictionaries of + the following names: + + _recog_ref2recog + _recog_obj2recog + + """ + +
[docs] def __init__(self, obj): + """ + Initialize the handler + + Args: + obj (Object): The entity on which this handler is stored. + + """ + self.obj = obj + # mappings + self.ref2recog = {} + self.obj2recog = {} + self._cache()
+ + def _cache(self): + """ + Load data to handler cache + """ + self.ref2recog = self.obj.attributes.get("_recog_ref2recog", default={}) + obj2recog = self.obj.attributes.get("_recog_obj2recog", default={}) + self.obj2recog = dict((obj, recog) for obj, recog in obj2recog.items() if obj) + +
[docs] def add(self, obj, recog, max_length=60): + """ + Assign a custom recog (nick) to the given object. + + Args: + obj (Object): The object ot associate with the recog + string. This is usually determined from the sdesc in the + room by a call to parse_sdescs_and_recogs, but can also be + given. + recog (str): The replacement string to use with this object. + max_length (int, optional): The max length of the recog string. + + Returns: + recog (str): The (possibly cleaned up) recog string actually set. + + Raises: + SdescError: When recog could not be set or sdesc longer + than `max_length`. + + """ + if not obj.access(self.obj, "enable_recog", default=True): + raise SdescError("This person is unrecognizeable.") + + # strip emote components from recog + recog = _RE_REF.sub( + r"\1", + _RE_REF_LANG.sub( + r"\1", + _RE_SELF_REF.sub(r"", _RE_LANGUAGE.sub(r"", _RE_OBJ_REF_START.sub(r"", recog))), + ), + ) + + # make an recog clean of ANSI codes + cleaned_recog = ansi.strip_ansi(recog) + + if not cleaned_recog: + raise SdescError("Recog string cannot be empty.") + + if len(cleaned_recog) > max_length: + raise RecogError( + "Recog string cannot be longer than {} chars (was {} chars)".format( + max_length, len(cleaned_recog) + ) + ) + + # mapping #dbref:obj + key = f"#{obj.id}" + self.obj.attributes.get("_recog_ref2recog", default={})[key] = recog + self.obj.attributes.get("_recog_obj2recog", default={})[obj] = recog + # local caching + self.ref2recog[key] = recog + self.obj2recog[obj] = recog + return recog
+ +
[docs] def get(self, obj): + """ + Get recog replacement string, if one exists. + + Args: + obj (Object): The object, whose sdesc to replace + Returns: + recog (str or None): The replacement string to use, or + None if there is no recog for this object. + + Notes: + This method will respect a "enable_recog" lock set on + `obj` (True by default) in order to turn off recog + mechanism. This is useful for adding masks/hoods etc. + """ + if obj.access(self.obj, "enable_recog", default=True): + # check an eventual recog_masked lock on the object + # to avoid revealing masked characters. If lock + # does not exist, pass automatically. + return self.obj2recog.get(obj, None) + else: + # recog_mask lock not passed, disable recog + return None
+ +
[docs] def all(self): + """ + Get a mapping of the recogs stored in handler. + + Returns: + recogs (dict): A mapping of {recog: obj} stored in handler. + + """ + return {self.obj2recog[obj]: obj for obj in self.obj2recog.keys()}
+ +
[docs] def remove(self, obj): + """ + Clear recog for a given object. + + Args: + obj (Object): The object for which to remove recog. + """ + if obj in self.obj2recog: + del self.obj.db._recog_obj2recog[obj] + del self.obj.db._recog_ref2recog[f"#{obj.id}"] + self._cache()
+ + +# ------------------------------------------------------------ +# RP Commands +# ------------------------------------------------------------ + + +
[docs]class RPCommand(Command): + "simple parent" + +
[docs] def parse(self): + "strip extra whitespace" + self.args = self.args.strip()
+ + +
[docs]class CmdEmote(RPCommand): # replaces the main emote + """ + Emote an action, allowing dynamic replacement of + text in the emote. + + Usage: + emote text + + Example: + emote /me looks around. + emote With a flurry /me attacks /tall man with his sword. + emote "Hello", /me says. + + Describes an event in the world. This allows the use of /ref + markers to replace with the short descriptions or recognized + strings of objects in the same room. These will be translated to + emotes to match each person seeing it. Use "..." for saying + things and langcode"..." without spaces to say something in + a different language. + + """ + + key = "emote" + aliases = [":"] + locks = "cmd:all()" + arg_regex = "" + +
[docs] def func(self): + "Perform the emote." + if not self.args: + self.caller.msg("What do you want to do?") + else: + # we also include ourselves here. + emote = self.args + targets = self.caller.location.contents + if not emote.endswith((".", "?", "!", '"')): # If emote is not punctuated or speech, + emote += "." # add a full-stop for good measure. + send_emote(self.caller, targets, emote, anonymous_add="first")
+ + +
[docs]class CmdSay(RPCommand): # replaces standard say + """ + speak as your character + + Usage: + say <message> + + Talk to those in your current location. + """ + + key = "say" + aliases = ['"', "'"] + locks = "cmd:all()" + arg_regex = "" + +
[docs] def func(self): + "Run the say command" + + caller = self.caller + + if not self.args: + caller.msg("Say what?") + return + + # calling the speech modifying hook + speech = caller.at_pre_say(self.args) + targets = self.caller.location.contents + send_emote(self.caller, targets, speech, msg_type="say", anonymous_add=None)
+ + +
[docs]class CmdSdesc(RPCommand): # set/look at own sdesc + """ + Assign yourself a short description (sdesc). + + Usage: + sdesc <short description> + sdesc - view current sdesc + sdesc clear - remove sdesc + + Assigns a short description to yourself. + + """ + + key = "sdesc" + locks = "cmd:all()" + +
[docs] def func(self): + "Assign the sdesc" + caller = self.caller + if not self.args: + sdesc = caller.sdesc.get() + if not sdesc: + caller.msg("You have no short description set.") + else: + caller.msg(f'Your short description is "{sdesc}".') + elif self.args == "clear": + ret = yield "Do you want to clear your sdesc? [Y]/n?" + if ret.lower() in ("n", "no"): + caller.msg("Aborted.") + else: + caller.sdesc.clear() + caller.msg(f'Cleared sdesc, using name "{caller.key}".') + else: + # strip non-alfanum chars from end of sdesc + sdesc = _RE_CHAREND.sub("", self.args) + try: + sdesc = caller.sdesc.add(sdesc) + except SdescError as err: + caller.msg(err) + return + except AttributeError: + caller.msg(f"Cannot set sdesc on {caller.key}.") + return + caller.msg(f"{caller.key}'s sdesc was set to '{sdesc}'.")
+ + +
[docs]class CmdPose(RPCommand): # set current pose and default pose + """ + Set a static pose + + Usage: + pose <pose> + pose default <pose> + pose reset + pose obj = <pose> + pose default obj = <pose> + pose reset obj = + + Examples: + pose leans against the tree + pose is talking to the barkeep. + pose box = is sitting on the floor. + + Set a static pose. This is the end of a full sentence that starts + with your sdesc. If no full stop is given, it will be added + automatically. The default pose is the pose you get when using + pose reset. Note that you can use sdescs/recogs to reference + people in your pose, but these always appear as that person's + sdesc in the emote, regardless of who is seeing it. + + """ + + key = "pose" + +
[docs] def parse(self): + """ + Extract the "default" alternative to the pose. + """ + args = self.args.strip() + default = args.startswith("default") + reset = args.startswith("reset") + if default: + args = re.sub(r"^default", "", args) + if reset: + args = re.sub(r"^reset", "", args) + target = None + if "=" in args: + target, args = [part.strip() for part in args.split("=", 1)] + + self.target = target + self.reset = reset + self.default = default + self.args = args.strip()
+ +
[docs] def func(self): + "Create the pose" + caller = self.caller + pose = self.args + target = self.target + if not pose and not self.reset: + caller.msg("Usage: pose <pose-text> OR pose obj = <pose-text>") + return + + if not pose.endswith((".", "?", "!", '"')): + pose += "." + if target: + # affect something else + target = caller.search(target) + if not target: + return + if not target.access(caller, "edit"): + caller.msg("You can't pose that.") + return + else: + target = caller + + target_name = target.sdesc.get() if hasattr(target, "sdesc") else target.key + if not target.attributes.has("pose"): + caller.msg(f"{target_name} cannot be posed.") + return + + # set the pose + if self.reset: + pose = target.db.pose_default + target.db.pose = pose + elif self.default: + target.db.pose_default = pose + caller.msg(f"Default pose is now '{target_name} {pose}'.") + return + else: + # set the pose. We do one-time ref->sdesc mapping here. + parsed, mapping = parse_sdescs_and_recogs(caller, caller.location.contents, pose) + mapping = dict( + (ref, obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key) + for ref, obj in mapping.items() + ) + pose = parsed.format_map(mapping) + + if len(target_name) + len(pose) > 60: + caller.msg(f"'{pose}' is too long.") + return + + target.db.pose = pose + + caller.msg(f"Pose will read '{target_name} {pose}'.")
+ + +
[docs]class CmdRecog(RPCommand): # assign personal alias to object in room + """ + Recognize another person in the same room. + + Usage: + recog + recog sdesc as alias + forget alias + + Example: + recog tall man as Griatch + forget griatch + + This will assign a personal alias for a person, or forget said alias. + Using the command without arguments will list all current recogs. + + """ + + key = "recog" + aliases = ["recognize", "forget"] + +
[docs] def parse(self): + "Parse for the sdesc as alias structure" + self.sdesc, self.alias = "", "" + if " as " in self.args: + self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)] + elif self.args: + # try to split by space instead + try: + self.sdesc, self.alias = [part.strip() for part in self.args.split(None, 1)] + except ValueError: + self.sdesc, self.alias = self.args.strip(), ""
+ +
[docs] def func(self): + "Assign the recog" + caller = self.caller + alias = self.alias.rstrip(".?!") + sdesc = self.sdesc + + recog_mode = self.cmdstring != "forget" and alias and sdesc + forget_mode = self.cmdstring == "forget" and sdesc + list_mode = not self.args + + if not (recog_mode or forget_mode or list_mode): + caller.msg("Usage: recog, recog <sdesc> as <alias> or forget <alias>") + return + + if list_mode: + # list all previously set recogs + all_recogs = caller.recog.all() + if not all_recogs: + caller.msg( + "You recognize no-one. (Use 'recog <sdesc> as <alias>' to recognize people." + ) + else: + # note that we don't skip those failing enable_recog lock here, + # because that would actually reveal more than we want. + lst = "\n".join( + " {} ({})".format(key, obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key) + for key, obj in all_recogs.items() + ) + caller.msg( + "Currently recognized (use 'recog <sdesc> as <alias>' to add " + f"new and 'forget <alias>' to remove):\n{lst}" + ) + return + + prefixed_sdesc = sdesc if sdesc.startswith(_PREFIX) else _PREFIX + sdesc + candidates = caller.location.contents + matches = parse_sdescs_and_recogs(caller, candidates, prefixed_sdesc, search_mode=True) + nmatches = len(matches) + # handle 0 and >1 matches + if nmatches == 0: + caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc)) + elif nmatches > 1: + reflist = [ + "{num}{sep}{sdesc} ({recog}{key})".format( + num=inum + 1, + sep=_NUM_SEP, + sdesc=_RE_PREFIX.sub("", sdesc), + recog=caller.recog.get(obj) or "no recog", + key=f" ({caller.key})" if caller == obj else "", + ) + for inum, obj in enumerate(matches) + ] + caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc, reflist="\n ".join(reflist))) + + else: + # one single match + obj = matches[0] + if not obj.access(self.obj, "enable_recog", default=True): + # don't apply recog if object doesn't allow it (e.g. by being masked). + caller.msg("It's impossible to recognize them.") + return + if forget_mode: + # remove existing recog + caller.recog.remove(obj) + caller.msg( + "You will now know them only as '{}'.".format( + obj.get_display_name(caller, noid=True) + ) + ) + else: + # set recog + sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key + try: + alias = caller.recog.add(obj, alias) + except RecogError as err: + caller.msg(err) + return + caller.msg("You will now remember |w{}|n as |w{}|n.".format(sdesc, alias))
+ + +
[docs]class CmdMask(RPCommand): + """ + Wear a mask + + Usage: + mask <new sdesc> + unmask + + This will put on a mask to hide your identity. When wearing + a mask, your sdesc will be replaced by the sdesc you pick and + people's recognitions of you will be disabled. + + """ + + key = "mask" + aliases = ["unmask"] + +
[docs] def func(self): + caller = self.caller + if self.cmdstring == "mask": + # wear a mask + if not self.args: + caller.msg("Usage: (un)mask sdesc") + return + if caller.db.unmasked_sdesc: + caller.msg("You are already wearing a mask.") + return + sdesc = _RE_CHAREND.sub("", self.args) + sdesc = f"{sdesc} |H[masked]|n" + if len(sdesc) > 60: + caller.msg("Your masked sdesc is too long.") + return + caller.db.unmasked_sdesc = caller.sdesc.get() + caller.locks.add("enable_recog:false()") + caller.sdesc.add(sdesc) + caller.msg(f"You wear a mask as '{sdesc}'.") + else: + # unmask + old_sdesc = caller.db.unmasked_sdesc + if not old_sdesc: + caller.msg("You are not wearing a mask.") + return + del caller.db.unmasked_sdesc + caller.locks.remove("enable_recog") + caller.sdesc.add(old_sdesc) + caller.msg(f"You remove your mask and are again '{old_sdesc}'.")
+ + +
[docs]class RPSystemCmdSet(CmdSet): + """ + Mix-in for adding rp-commands to default cmdset. + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdEmote()) + self.add(CmdSay()) + self.add(CmdSdesc()) + self.add(CmdPose()) + self.add(CmdRecog()) + self.add(CmdMask())
+ + +# ------------------------------------------------------------ +# RP typeclasses +# ------------------------------------------------------------ + + +
[docs]class ContribRPObject(DefaultObject): + """ + This class is meant as a mix-in or parent for objects in an + rp-heavy game. It implements the base functionality for poses. + """ + +
[docs] @lazy_property + def sdesc(self): + return SdescHandler(self)
+ +
[docs] def at_object_creation(self): + """ + Called at initial creation. + """ + super().at_object_creation() + + # emoting/recog data + self.db.pose = "" + self.db.pose_default = "is here." + self.db._sdesc = ""
+ +
[docs] def search( + self, + searchdata, + global_search=False, + use_nicks=True, + typeclass=None, + location=None, + attribute_name=None, + quiet=False, + exact=False, + candidates=None, + nofound_string=None, + multimatch_string=None, + use_dbref=None, + ): + """ + Returns an Object matching a search string/condition, taking + sdescs into account. + + Perform a standard object search in the database, handling + multiple results and lack thereof gracefully. By default, only + objects in the current `location` of `self` or its inventory are searched for. + + Args: + searchdata (str or obj): Primary search criterion. Will be matched + against `object.key` (with `object.aliases` second) unless + the keyword attribute_name specifies otherwise. + **Special strings:** + - `#<num>`: search by unique dbref. This is always + a global search. + - `me,self`: self-reference to this object + - `<num>-<string>` - can be used to differentiate + between multiple same-named matches + global_search (bool): Search all objects globally. This is overruled + by `location` keyword. + use_nicks (bool): Use nickname-replace (nicktype "object") on `searchdata`. + typeclass (str or Typeclass, or list of either): Limit search only + to `Objects` with this typeclass. May be a list of typeclasses + for a broader search. + location (Object or list): Specify a location or multiple locations + to search. Note that this is used to query the *contents* of a + location and will not match for the location itself - + if you want that, don't set this or use `candidates` to specify + exactly which objects should be searched. + attribute_name (str): Define which property to search. If set, no + key+alias search will be performed. This can be used + to search database fields (db_ will be automatically + appended), and if that fails, it will try to return + objects having Attributes with this name and value + equal to searchdata. A special use is to search for + "key" here if you want to do a key-search without + including aliases. + quiet (bool): don't display default error messages - this tells the + search method that the user wants to handle all errors + themselves. It also changes the return value type, see + below. + exact (bool): if unset (default) - prefers to match to beginning of + string rather than not matching at all. If set, requires + exact matching of entire string. + candidates (list of objects): this is an optional custom list of objects + to search (filter) between. It is ignored if `global_search` + is given. If not set, this list will automatically be defined + to include the location, the contents of location and the + caller's contents (inventory). + nofound_string (str): optional custom string for not-found error message. + multimatch_string (str): optional custom string for multimatch error header. + use_dbref (bool or None): If None, only turn off use_dbref if we are of a lower + permission than Builder. Otherwise, honor the True/False value. + + Returns: + match (Object, None or list): will return an Object/None if `quiet=False`, + otherwise it will return a list of 0, 1 or more matches. + + Notes: + To find Accounts, use eg. `evennia.account_search`. If + `quiet=False`, error messages will be handled by + `settings.SEARCH_AT_RESULT` and echoed automatically (on + error, return will be `None`). If `quiet=True`, the error + messaging is assumed to be handled by the caller. + + """ + is_string = isinstance(searchdata, str) + + if is_string: + # searchdata is a string; wrap some common self-references + if searchdata.lower() in ("here",): + return [self.location] if quiet else self.location + if searchdata.lower() in ("me", "self"): + return [self] if quiet else self + + if use_nicks: + # do nick-replacement on search + searchdata = self.nicks.nickreplace( + searchdata, categories=("object", "account"), include_account=True + ) + + if global_search or ( + is_string + and searchdata.startswith("#") + and len(searchdata) > 1 + and searchdata[1:].isdigit() + ): + # only allow exact matching if searching the entire database + # or unique #dbrefs + exact = True + elif candidates is None: + # no custom candidates given - get them automatically + if location: + # location(s) were given + candidates = [] + for obj in make_iter(location): + candidates.extend(obj.contents) + else: + # local search. Candidates are taken from + # self.contents, self.location and + # self.location.contents + location = self.location + candidates = self.contents + if location: + candidates = candidates + [location] + location.contents + else: + # normally we don't need this since we are + # included in location.contents + candidates.append(self) + + # the sdesc-related substitution + is_builder = self.locks.check_lockstring(self, "perm(Builder)") + use_dbref = is_builder if use_dbref is None else use_dbref + + def search_obj(string): + "helper wrapper for searching" + return ObjectDB.objects.object_search( + string, + attribute_name=attribute_name, + typeclass=typeclass, + candidates=candidates, + exact=exact, + use_dbref=use_dbref, + ) + + if candidates: + candidates = parse_sdescs_and_recogs( + self, candidates, _PREFIX + searchdata, search_mode=True + ) + results = [] + for candidate in candidates: + # we search by candidate keys here; this allows full error + # management and use of all kwargs - we will use searchdata + # in eventual error reporting later (not their keys). Doing + # it like this e.g. allows for use of the typeclass kwarg + # limiter. + results.extend([obj for obj in search_obj(candidate.key) if obj not in results]) + + if not results and is_builder: + # builders get a chance to search only by key+alias + results = search_obj(searchdata) + else: + # global searches / #drefs end up here. Global searches are + # only done in code, so is controlled, #dbrefs are turned off + # for non-Builders. + results = search_obj(searchdata) + + if quiet: + return results + return _AT_SEARCH_RESULT( + results, + self, + query=searchdata, + nofound_string=nofound_string, + multimatch_string=multimatch_string, + )
+ +
[docs] def get_posed_sdesc(self, sdesc, **kwargs): + """ + Displays the object with its current pose string. + + Returns: + pose (str): A string containing the object's sdesc and + current or default pose. + """ + + # get the current pose, or default if no pose is set + pose = self.db.pose or self.db.pose_default + + # return formatted string, or sdesc as fallback + return f"{sdesc} {pose}" if pose else sdesc
+ +
[docs] def get_display_name(self, looker, **kwargs): + """ + Displays the name of the object in a viewer-aware manner. + + Args: + looker (TypedObject): The object or account that is looking + at/getting inforamtion for this object. + + Keyword Args: + pose (bool): Include the pose (if available) in the return. + ref (str): The reference marker found in string to replace. + This is on the form #{num}{case}, like '#12^', where + the number is a processing location in the string and the + case symbol indicates the case of the original tag input + - `t` - input was Titled, like /Tall + - `^` - input was all uppercase, like /TALL + - `v` - input was all lowercase, like /tall + - `~` - input case should be kept, or was mixed-case + noid (bool): Don't show DBREF even if viewer has control access. + + Returns: + name (str): A string of the sdesc containing the name of the object, + if this is defined. By default, included the DBREF if this user + is privileged to control said object. + + """ + ref = kwargs.get("ref", "~") + + if looker == self: + # always show your own key + sdesc = self.key + else: + try: + # get the sdesc looker should see + sdesc = looker.get_sdesc(self, ref=ref) + except AttributeError: + # use own sdesc as a fallback + sdesc = self.sdesc.get() + + # add dbref is looker has control access and `noid` is not set + if self.access(looker, access_type="control") and not kwargs.get("noid", False): + sdesc = f"{sdesc}(#{self.id})" + + return self.get_posed_sdesc(sdesc) if kwargs.get("pose", False) else sdesc
+ +
[docs] def get_display_characters(self, looker, pose=True, **kwargs): + """ + Get the ‘characters’ component of the object description. Called by return_appearance. + """ + + def _filter_visible(obj_list): + return (obj for obj in obj_list if obj != looker and obj.access(looker, "view")) + + characters = _filter_visible(self.contents_get(content_type="character")) + character_names = "\n".join( + char.get_display_name(looker, pose=pose, **kwargs) for char in characters + ) + + return f"\n{character_names}" if character_names else ""
+ +
[docs] def get_display_things(self, looker, pose=True, **kwargs): + """ + Get the 'things' component of the object description. Called by `return_appearance`. + + Args: + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The things display data. + + """ + if not pose: + # if poses aren't included, we can use the core version instead + return super().get_display_things(looker, **kwargs) + + def _filter_visible(obj_list): + return [obj for obj in obj_list if obj != looker and obj.access(looker, "view")] + + # sort and handle same-named things + things = _filter_visible(self.contents_get(content_type="object")) + + posed_things = defaultdict(list) + for thing in things: + pose = thing.db.pose or thing.db.pose_default + if not pose: + pose = "" + posed_things[pose].append(thing) + + display_strings = [] + + for pose, thinglist in posed_things.items(): + grouped_things = defaultdict(list) + for thing in thinglist: + grouped_things[thing.get_display_name(looker, pose=False, **kwargs)].append(thing) + + thing_names = [] + for thingname, samethings in sorted(grouped_things.items()): + nthings = len(samethings) + thing = samethings[0] + singular, plural = thing.get_numbered_name(nthings, looker, key=thingname) + thing_names.append(singular if nthings == 1 else plural) + thing_names = iter_to_str(thing_names) + + if pose: + pose = _INFLECT.plural(pose) if nthings != 1 else pose + grouped_names = f"{thing_names} {pose}" + grouped_names = grouped_names[0].upper() + grouped_names[1:] + display_strings.append(grouped_names) + + if not display_strings: + return "" + + return "\n" + "\n".join(display_strings)
+ + +
[docs]class ContribRPRoom(ContribRPObject): + """ + Dummy inheritance for rooms. + """ + + pass
+ + +
[docs]class ContribRPCharacter(DefaultCharacter, ContribRPObject): + """ + This is a character class that has poses, sdesc and recog. + """ + +
[docs] @lazy_property + def recog(self): + return RecogHandler(self)
+ +
[docs] def get_display_name(self, looker, **kwargs): + """ + Displays the name of the object in a viewer-aware manner. + + Args: + looker (TypedObject): The object or account that is looking + at/getting inforamtion for this object. + + Keyword Args: + pose (bool): Include the pose (if available) in the return. + ref (str): The reference marker found in string to replace. + This is on the form #{num}{case}, like '#12^', where + the number is a processing location in the string and the + case symbol indicates the case of the original tag input + - `t` - input was Titled, like /Tall + - `^` - input was all uppercase, like /TALL + - `v` - input was all lowercase, like /tall + - `~` - input case should be kept, or was mixed-case + noid (bool): Don't show DBREF even if viewer has control access. + + Returns: + name (str): A string of the sdesc containing the name of the object, + if this is defined. By default, included the DBREF if this user + is privileged to control said object. + + Notes: + The RPCharacter version adds additional processing to sdescs to make + characters stand out from other objects. + + """ + ref = kwargs.get("ref", "~") + + if looker == self: + # process your key as recog since you recognize yourself + sdesc = self.process_recog(self.key, self) + else: + try: + # get the sdesc looker should see, with formatting + sdesc = looker.get_sdesc(self, process=True, ref=ref) + except AttributeError: + # use own sdesc as a fallback + sdesc = self.sdesc.get() + + # add dbref is looker has control access and `noid` is not set + if self.access(looker, access_type="control") and not kwargs.get("noid", False): + sdesc = f"{sdesc}(#{self.id})" + + return self.get_posed_sdesc(sdesc) if kwargs.get("pose", False) else sdesc
+ +
[docs] def at_object_creation(self): + """ + Called at initial creation. + """ + super().at_object_creation() + + self.db._sdesc = "" + + self.db._recog_ref2recog = {} + self.db._recog_obj2recog = {} + + self.cmdset.add(RPSystemCmdSet, persistent=True) + # initializing sdesc + self.sdesc.add("A normal person")
+ +
[docs] def at_pre_say(self, message, **kwargs): + """ + Called before the object says or whispers anything, return modified message. + + Args: + message (str): The suggested say/whisper text spoken by self. + Keyword Args: + whisper (bool): If True, this is a whisper rather than a say. + + """ + if kwargs.get("whisper"): + return f'/Me whispers "{message}"' + return f'/Me says, "{message}"'
+ +
[docs] def get_sdesc(self, obj, process=False, **kwargs): + """ + Single method to handle getting recogs with sdesc fallback in an + aware manner, to allow separate processing of recogs from sdescs. + Gets the sdesc or recog for obj from the view of self. + + Args: + obj (Object): the object whose sdesc or recog is being gotten + Keyword Args: + process (bool): If True, the sdesc/recog is run through the + appropriate process method for self - .process_sdesc or + .process_recog + """ + # always see own key + if obj == self: + recog = self.key + sdesc = self.key + else: + # first check if we have a recog for this object + recog = self.recog.get(obj) + # set sdesc to recog, using sdesc as a fallback, or the object's key if no sdesc + sdesc = recog or (hasattr(obj, "sdesc") and obj.sdesc.get()) or obj.key + + if process: + # process the sdesc as a recog if a recog was found, else as an sdesc + sdesc = (self.process_recog if recog else self.process_sdesc)(sdesc, obj, **kwargs) + + return sdesc
+ +
[docs] def process_sdesc(self, sdesc, obj, **kwargs): + """ + Allows to customize how your sdesc is displayed (primarily by + changing colors). + + Args: + sdesc (str): The sdesc to display. + obj (Object): The object to which the adjoining sdesc + belongs. If this object is equal to yourself, then + you are viewing yourself (and sdesc is your key). + This is not used by default. + + Kwargs: + ref (str): The reference marker found in string to replace. + This is on the form #{num}{case}, like '#12^', where + the number is a processing location in the string and the + case symbol indicates the case of the original tag input + - `t` - input was Titled, like /Tall + - `^` - input was all uppercase, like /TALL + - `v` - input was all lowercase, like /tall + - `~` - input case should be kept, or was mixed-case + + Returns: + sdesc (str): The processed sdesc ready + for display. + + """ + if not sdesc: + return "" + + ref = kwargs.get("ref", "~") # ~ to keep sdesc unchanged + if "t" in ref: + # we only want to capitalize the first letter if there are many words + sdesc = sdesc.lower() + sdesc = sdesc[0].upper() + sdesc[1:] if len(sdesc) > 1 else sdesc.upper() + elif "^" in ref: + sdesc = sdesc.upper() + elif "v" in ref: + sdesc = sdesc.lower() + return f"|b{sdesc}|n"
+ +
[docs] def process_recog(self, recog, obj, **kwargs): + """ + Allows to customize how a recog string is displayed. + + Args: + recog (str): The recog string. It has already been + translated from the original sdesc at this point. + obj (Object): The object the recog:ed string belongs to. + This is not used by default. + + Returns: + recog (str): The modified recog string. + + """ + if not recog: + return "" + + return f"|m{recog}|n"
+ +
[docs] def process_language(self, text, speaker, language, **kwargs): + """ + Allows to process the spoken text, for example + by obfuscating language based on your and the + speaker's language skills. Also a good place to + put coloring. + + Args: + text (str): The text to process. + speaker (Object): The object delivering the text. + language (str): An identifier string for the language. + + Return: + text (str): The optionally processed text. + + Notes: + This is designed to work together with a string obfuscator + such as the `obfuscate_language` or `obfuscate_whisper` in + the evennia.contrib.rpg.rplanguage module. + + """ + return "{label}|w{text}|n".format(label=f"|W({language})" if language else "", text=text)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/rpsystem/tests.html b/docs/latest/_modules/evennia/contrib/rpg/rpsystem/tests.html new file mode 100644 index 0000000000..eedda21bd3 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/rpsystem/tests.html @@ -0,0 +1,480 @@ + + + + + + + + evennia.contrib.rpg.rpsystem.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.rpsystem.tests

+"""
+Tests for RP system
+
+"""
+import time
+
+from anything import Anything
+
+from evennia import create_object
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import rplanguage, rpsystem
+
+mtrans = {"testing": "1", "is": "2", "a": "3", "human": "4"}
+atrans = ["An", "automated", "advantageous", "repeatable", "faster"]
+
+text = (
+    "Automated testing is advantageous for a number of reasons: "
+    "tests may be executed Continuously without the need for human "
+    "intervention, They are easily repeatable, and often faster."
+)
+
+
+
[docs]class TestLanguage(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + rplanguage.add_language( + key="testlang", + word_length_variance=1, + noun_prefix="bara", + noun_postfix="'y", + manual_translations=mtrans, + auto_translations=atrans, + force=True, + ) + rplanguage.add_language( + key="binary", + phonemes="oo ii a ck w b d t", + grammar="cvvv cvv cvvcv cvvcvv cvvvc cvvvcvv cvvc", + noun_prefix="beep-", + word_length_variance=4, + )
+ +
[docs] def tearDown(self): + super().tearDown() + rplanguage._LANGUAGE_HANDLER.delete() + rplanguage._LANGUAGE_HANDLER = None
+ +
[docs] def test_obfuscate_language(self): + result0 = rplanguage.obfuscate_language(text, level=0.0, language="testlang") + self.assertEqual(result0, text) + result1 = rplanguage.obfuscate_language(text, level=1.0, language="testlang") + result2 = rplanguage.obfuscate_language(text, level=1.0, language="testlang") + result3 = rplanguage.obfuscate_language(text, level=1.0, language="binary") + + self.assertNotEqual(result1, text) + self.assertNotEqual(result3, text) + result1, result2 = result1.split(), result2.split() + self.assertEqual(result1[:4], result2[:4]) + self.assertEqual(result1[1], "1") + self.assertEqual(result1[2], "2") + self.assertEqual(result2[-1], result2[-1])
+ +
[docs] def test_faulty_language(self): + self.assertRaises( + rplanguage.LanguageError, + rplanguage.add_language, + key="binary2", + phonemes="w b d t oe ee, oo e o a wh dw bw", # erroneous comma + grammar="cvvv cvv cvvcv cvvcvvo cvvvc cvvvcvv cvvc c v cc vv ccvvc ccvvccvv ", + vowels="oea", + word_length_variance=4, + )
+ +
[docs] def test_available_languages(self): + self.assertEqual(list(sorted(rplanguage.available_languages())), ["binary", "testlang"])
+ +
[docs] def test_obfuscate_whisper(self): + self.assertEqual(rplanguage.obfuscate_whisper(text, level=0.0), text) + assert rplanguage.obfuscate_whisper(text, level=0.1).startswith( + "-utom-t-d t-sting is -dv-nt-g-ous for - numb-r of r--sons: t-sts m-y b- -x-cut-d" + " Continuously" + ) + assert rplanguage.obfuscate_whisper(text, level=0.5).startswith( + "--------- --s---- -s -----------s f-- - ------ -f ---s--s: --s-s " + ) + self.assertEqual(rplanguage.obfuscate_whisper(text, level=1.0), "...")
+ + +# Testing of emoting / sdesc / recog system + +sdesc0 = "A nice sender of emotes" +sdesc1 = "The first receiver of emotes." +sdesc2 = "Another nice colliding sdesc-guy for tests" +recog01 = "Mr Receiver" +recog02 = "Mr Receiver2" +recog10 = "Mr Sender" +emote = 'With a flair, /me looks at /first and /colliding sdesc-guy. She says "This is a test."' +fallback_emote = "/Me is distracted from /first by /nomatch." +case_emote = "/Me looks at /first. Then, /me looks at /FIRST, /First and /Colliding twice." +poss_emote = "/Me frowns at /first for trying to steal /me's test." + + +
[docs]class TestRPSystem(BaseEvenniaTest): + maxDiff = None + +
[docs] def setUp(self): + super().setUp() + self.room = create_object(rpsystem.ContribRPRoom, key="Location") + self.speaker = create_object(rpsystem.ContribRPCharacter, key="Sender", location=self.room) + self.receiver1 = create_object( + rpsystem.ContribRPCharacter, key="Receiver1", location=self.room + ) + self.receiver2 = create_object( + rpsystem.ContribRPCharacter, key="Receiver2", location=self.room + )
+ +
[docs] def test_posed_contents(self): + self.obj1 = create_object(rpsystem.ContribRPObject, key="thing", location=self.room) + self.obj2 = create_object(rpsystem.ContribRPObject, key="thing", location=self.room) + self.obj3 = create_object(rpsystem.ContribRPObject, key="object", location=self.room) + room_display = self.room.return_appearance(self.speaker) + self.assertIn("An object and two things are here.", room_display) + self.obj3.db.pose = "is on the ground." + room_display = self.room.return_appearance(self.speaker) + self.assertIn("Two things are here.", room_display) + self.assertIn("An object is on the ground.", room_display)
+ +
[docs] def test_sdesc_handler(self): + self.speaker.sdesc.add(sdesc0) + self.assertEqual(self.speaker.sdesc.get(), sdesc0) + self.speaker.sdesc.add("This is {#324} ignored") + self.assertEqual(self.speaker.sdesc.get(), "This is 324 ignored")
+ +
[docs] def test_recog_handler(self): + self.speaker.sdesc.add(sdesc0) + self.receiver1.sdesc.add(sdesc1) + self.speaker.recog.add(self.receiver1, recog01) + self.speaker.recog.add(self.receiver2, recog02) + self.assertEqual(self.speaker.recog.get(self.receiver1), recog01) + self.assertEqual(self.speaker.recog.get(self.receiver2), recog02) + self.speaker.recog.remove(self.receiver1) + self.assertEqual(self.speaker.recog.get(self.receiver1), None) + + self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2})
+ +
[docs] def test_parse_language(self): + self.assertEqual( + rpsystem.parse_language(self.speaker, emote), + ( + "With a flair, /me looks at /first and /colliding sdesc-guy. She says {##0}", + {"##0": (None, '"This is a test."')}, + ), + )
+ +
[docs] def test_parse_sdescs_and_recogs(self): + speaker = self.speaker + speaker.sdesc.add(sdesc0) + self.receiver1.sdesc.add(sdesc1) + self.receiver2.sdesc.add(sdesc2) + id0 = f"#{speaker.id}" + id1 = f"#{self.receiver1.id}" + id2 = f"#{self.receiver2.id}" + candidates = (self.receiver1, self.receiver2) + result = ( + "With a flair, {" + + id0 + + "} looks at {" + + id1 + + "} and {" + + id2 + + '}. She says "This is a test."', + { + id2: self.receiver2, + id1: self.receiver1, + id0: speaker, + }, + ) + self.assertEqual( + rpsystem.parse_sdescs_and_recogs(speaker, candidates, emote, case_sensitive=False), + result, + ) + self.speaker.recog.add(self.receiver1, recog01) + self.assertEqual( + rpsystem.parse_sdescs_and_recogs(speaker, candidates, emote, case_sensitive=False), + result, + )
+ +
[docs] def test_possessive_selfref(self): + speaker = self.speaker + speaker.sdesc.add(sdesc0) + self.receiver1.sdesc.add(sdesc1) + self.receiver2.sdesc.add(sdesc2) + id0 = f"#{speaker.id}" + id1 = f"#{self.receiver1.id}" + id2 = f"#{self.receiver2.id}" + candidates = (self.receiver1, self.receiver2) + result = ( + "{" + id0 + "} frowns at {" + id1 + "} for trying to steal {" + id0 + "}'s test.", + { + id1: self.receiver1, + id0: speaker, + }, + ) + self.assertEqual( + rpsystem.parse_sdescs_and_recogs(speaker, candidates, poss_emote, case_sensitive=False), + result, + )
+ +
[docs] def test_get_sdesc(self): + looker = self.speaker # Sender + target = self.receiver1 # Receiver1 + looker.sdesc.add(sdesc0) # A nice sender of emotes + target.sdesc.add(sdesc1) # The first receiver of emotes. + + # sdesc with no processing + self.assertEqual(looker.get_sdesc(target), "The first receiver of emotes.") + # sdesc with processing + self.assertEqual( + looker.get_sdesc(target, process=True), "|bThe first receiver of emotes.|n" + ) + + looker.recog.add(target, recog01) # Mr Receiver + + # recog with no processing + self.assertEqual(looker.get_sdesc(target), "Mr Receiver") + # recog with processing + self.assertEqual(looker.get_sdesc(target, process=True), "|mMr Receiver|n")
+ +
[docs] def test_send_emote(self): + speaker = self.speaker + receiver1 = self.receiver1 + receiver2 = self.receiver2 + receivers = [speaker, receiver1, receiver2] + speaker.sdesc.add(sdesc0) + receiver1.sdesc.add(sdesc1) + receiver2.sdesc.add(sdesc2) + speaker.msg = lambda text, **kwargs: setattr(self, "out0", text) + receiver1.msg = lambda text, **kwargs: setattr(self, "out1", text) + receiver2.msg = lambda text, **kwargs: setattr(self, "out2", text) + rpsystem.send_emote(speaker, receivers, emote, case_sensitive=False) + self.assertEqual( + self.out0[0], + "With a flair, |mSender|n looks at |bThe first receiver of emotes.|n " + 'and |bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n', + ) + self.assertEqual( + self.out1[0], + "With a flair, |bA nice sender of emotes|n looks at |mReceiver1|n and " + '|bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n', + ) + self.assertEqual( + self.out2[0], + "With a flair, |bA nice sender of emotes|n looks at |bThe first " + 'receiver of emotes.|n and |mReceiver2|n. She says |w"This is a test."|n', + )
+ +
[docs] def test_send_emote_fallback(self): + speaker = self.speaker + receiver1 = self.receiver1 + receiver2 = self.receiver2 + receivers = [speaker, receiver1, receiver2] + speaker.sdesc.add(sdesc0) + receiver1.sdesc.add(sdesc1) + receiver2.sdesc.add(sdesc2) + speaker.msg = lambda text, **kwargs: setattr(self, "out0", text) + receiver1.msg = lambda text, **kwargs: setattr(self, "out1", text) + receiver2.msg = lambda text, **kwargs: setattr(self, "out2", text) + rpsystem.send_emote(speaker, receivers, fallback_emote, fallback="something") + self.assertEqual( + self.out0[0], + "|mSender|n is distracted from |bthe first receiver of emotes.|n by something.", + ) + self.assertEqual( + self.out1[0], + "|bA nice sender of emotes|n is distracted from |mReceiver1|n by something.", + ) + self.assertEqual( + self.out2[0], + "|bA nice sender of emotes|n is distracted from |bthe first receiver of emotes.|n by" + " something.", + )
+ +
[docs] def test_send_case_sensitive_emote(self): + """Test new case-sensitive rp-parsing""" + speaker = self.speaker + receiver1 = self.receiver1 + receiver2 = self.receiver2 + receivers = [speaker, receiver1, receiver2] + speaker.sdesc.add(sdesc0) + receiver1.sdesc.add(sdesc1) + receiver2.sdesc.add(sdesc2) + speaker.msg = lambda text, **kwargs: setattr(self, "out0", text) + receiver1.msg = lambda text, **kwargs: setattr(self, "out1", text) + receiver2.msg = lambda text, **kwargs: setattr(self, "out2", text) + rpsystem.send_emote(speaker, receivers, case_emote) + self.assertEqual( + self.out0[0], + "|mSender|n looks at |bthe first receiver of emotes.|n. Then, |mSender|n " + "looks at |bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of emotes.|n " + "and |bAnother nice colliding sdesc-guy for tests|n twice.", + ) + self.assertEqual( + self.out1[0], + "|bA nice sender of emotes|n looks at |mReceiver1|n. Then, " + "|ba nice sender of emotes|n looks at |mReceiver1|n, |mReceiver1|n " + "and |bAnother nice colliding sdesc-guy for tests|n twice.", + ) + self.assertEqual( + self.out2[0], + "|bA nice sender of emotes|n looks at |bthe first receiver of emotes.|n. " + "Then, |ba nice sender of emotes|n looks at |bTHE FIRST RECEIVER OF EMOTES.|n, " + "|bThe first receiver of emotes.|n and |mReceiver2|n twice.", + )
+ +
[docs] def test_rpsearch(self): + self.speaker.sdesc.add(sdesc0) + self.receiver1.sdesc.add(sdesc1) + self.receiver2.sdesc.add(sdesc2) + self.speaker.msg = lambda text, **kwargs: setattr(self, "out0", text) + self.assertEqual(self.speaker.search("receiver of emotes"), self.receiver1) + self.assertEqual(self.speaker.search("colliding"), self.receiver2)
+ + +
[docs]class TestRPSystemCommands(BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + self.char1.swap_typeclass(rpsystem.ContribRPCharacter) + self.char2.swap_typeclass(rpsystem.ContribRPCharacter)
+ +
[docs] def test_commands(self): + self.call( + rpsystem.CmdSdesc(), "Foobar Character", "Char's sdesc was set to 'Foobar Character'." + ) + self.call( + rpsystem.CmdSdesc(), + "BarFoo Character", + "Char2's sdesc was set to 'BarFoo Character'.", + caller=self.char2, + ) + + self.call(rpsystem.CmdSdesc(), "", 'Your short description is "Foobar Character".') + + self.call(rpsystem.CmdSay(), "Hello!", 'Char says, "Hello!"') + self.call(rpsystem.CmdEmote(), "/me smiles to /BarFoo.", "Char smiles to BarFoo Character") + + # escape urls in say + self.call(rpsystem.CmdSay(), "https://evennia.com", 'Char says, "https://evennia.com"') + self.call(rpsystem.CmdSay(), "http://evennia.com", 'Char says, "http://evennia.com"') + + self.call( + rpsystem.CmdPose(), + "stands by the bar", + "Pose will read 'Foobar Character stands by the bar.'.", + ) + self.call( + rpsystem.CmdRecog(), + "barfoo as friend", + "You will now remember BarFoo Character as friend.", + ) + self.call( + rpsystem.CmdRecog(), + "", + "Currently recognized (use 'recog <sdesc> as <alias>' to add new " + "and 'forget <alias>' to remove):\n friend (BarFoo Character)", + ) + self.call( + rpsystem.CmdRecog(), + "friend", + "You will now know them only as 'BarFoo Character'", + cmdstring="forget", + ) + + self.call(rpsystem.CmdSdesc(), "clear", 'Cleared sdesc, using name "Char".', inputs=["Y"])
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/traits/tests.html b/docs/latest/_modules/evennia/contrib/rpg/traits/tests.html new file mode 100644 index 0000000000..98a830cb72 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/traits/tests.html @@ -0,0 +1,1170 @@ + + + + + + + + evennia.contrib.rpg.traits.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.traits.tests

+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Unit test module for Trait classes.
+
+"""
+
+from copy import copy
+
+from anything import Something
+from mock import MagicMock, patch
+
+from evennia.objects.objects import DefaultCharacter
+from evennia.utils.test_resources import BaseEvenniaTestCase, EvenniaTest
+
+from . import traits
+
+
+class _MockObj:
+    def __init__(self):
+        self.attributes = MagicMock()
+        self.attributes.get = self.get
+        self.attributes.add = self.add
+        self.dbstore = {}
+        self.category = "traits"
+
+    def get(self, key, category=None):
+        assert category == self.category
+        return self.dbstore.get(key)
+
+    def add(self, key, value, category=None):
+        assert category == self.category
+        self.dbstore[key] = value
+
+
+# we want to test the base traits too
+_TEST_TRAIT_CLASS_PATHS = [
+    "evennia.contrib.rpg.traits.Trait",
+    "evennia.contrib.rpg.traits.StaticTrait",
+    "evennia.contrib.rpg.traits.CounterTrait",
+    "evennia.contrib.rpg.traits.GaugeTrait",
+]
+
+
+class _TraitHandlerBase(BaseEvenniaTestCase):
+    "Base for trait tests"
+
+    @patch("evennia.contrib.rpg.traits.traits._TRAIT_CLASS_PATHS", new=_TEST_TRAIT_CLASS_PATHS)
+    def setUp(self):
+        self.obj = _MockObj()
+        self.traithandler = traits.TraitHandler(self.obj)
+        self.obj.traits = self.traithandler
+
+    def _get_dbstore(self, key):
+        return self.obj.dbstore["traits"][key]
+
+
+
[docs]class TraitHandlerTest(_TraitHandlerBase): + """Testing for TraitHandler""" + +
[docs] def setUp(self): + super().setUp() + self.traithandler.add("test1", name="Test1", trait_type="trait") + self.traithandler.add( + "test2", + name="Test2", + trait_type="trait", + value=["foo", {"1": [1, 2, 3]}, 4], + )
+ +
[docs] def test_add_trait(self): + self.assertEqual( + self._get_dbstore("test1"), + { + "name": "Test1", + "trait_type": "trait", + "value": None, + }, + ) + self.assertEqual( + self._get_dbstore("test2"), + { + "name": "Test2", + "trait_type": "trait", + "value": ["foo", {"1": [1, 2, 3]}, 4], + }, + ) + self.assertEqual(len(self.traithandler), 2)
+ +
[docs] def test_cache(self): + """ + Cache should not be set until first get + """ + self.assertEqual(len(self.traithandler._cache), 0) + self.traithandler.all() # does not affect cache + self.assertEqual(len(self.traithandler._cache), 0) + self.traithandler.test1 + self.assertEqual(len(self.traithandler._cache), 1) + self.traithandler.test2 + self.assertEqual(len(self.traithandler._cache), 2)
+ +
[docs] def test_setting(self): + "Don't allow setting stuff on traithandler" + with self.assertRaises(traits.TraitException): + self.traithandler.foo = "bar" + with self.assertRaises(traits.TraitException): + self.traithandler["foo"] = "bar" + with self.assertRaises(traits.TraitException): + self.traithandler.test1 = "foo"
+ +
[docs] def test_getting(self): + "Test we are getting data from the dbstore" + self.assertEqual( + self.traithandler.test1._data, {"name": "Test1", "trait_type": "trait", "value": None} + ) + self.assertEqual(self.traithandler._cache, Something) + self.assertEqual( + self.traithandler.test2._data, + {"name": "Test2", "trait_type": "trait", "value": ["foo", {"1": [1, 2, 3]}, 4]}, + ) + self.assertEqual(self.traithandler._cache, Something) + self.assertFalse(self.traithandler.get("foo")) + self.assertFalse(self.traithandler.bar)
+ +
[docs] def test_all(self): + "Test all method" + self.assertEqual(self.traithandler.all(), ["test1", "test2"])
+ +
[docs] def test_remove(self): + "Test remove method" + self.traithandler.remove("test2") + self.assertEqual(len(self.traithandler), 1) + self.assertTrue(bool(self.traithandler.get("test1"))) # this populates cache + self.assertEqual(len(self.traithandler._cache), 1) + with self.assertRaises(traits.TraitException): + self.traithandler.remove("foo")
+ +
[docs] def test_clear(self): + "Test clear method" + self.traithandler.clear() + self.assertEqual(len(self.traithandler), 0)
+ +
[docs] def test_trait_db_connection(self): + "Test that updating a trait property actually updates value in db" + trait = self.traithandler.test1 + self.assertEqual(trait.value, None) + trait.value = 10 + self.assertEqual(trait.value, 10) + self.assertEqual(self.obj.attributes.get("traits", category="traits")["test1"]["value"], 10) + trait.value = 20 + self.assertEqual(trait.value, 20) + self.assertEqual(self.obj.attributes.get("traits", category="traits")["test1"]["value"], 20) + del trait.value + self.assertEqual( + self.obj.attributes.get("traits", category="traits")["test1"]["value"], None + )
+ + +
[docs]class TestTrait(_TraitHandlerBase): + """ + Test the base Trait class + """ + +
[docs] def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type="trait", + value="value", + extra_val1="xvalue1", + extra_val2="xvalue2", + ) + self.trait = self.traithandler.get("test1")
+ +
[docs] def test_init(self): + self.assertEqual( + self.trait._data, + { + "name": "Test1", + "trait_type": "trait", + "value": "value", + "extra_val1": "xvalue1", + "extra_val2": "xvalue2", + }, + )
+ +
[docs] def test_validate_input__valid(self): + """Test valid validation input""" + # all data supplied, and extras + dat = {"name": "Test", "trait_type": "trait", "value": 10, "extra_val": 1000} + expected = copy(dat) # we must break link or return === dat always + self.assertEqual(expected, traits.Trait.validate_input(traits.Trait, dat)) + + # don't supply value, should get default + dat = { + "name": "Test", + "trait_type": "trait", + # missing value + "extra_val": 1000, + } + expected = copy(dat) + expected["value"] = traits.Trait.default_keys["value"] + self.assertEqual(expected, traits.Trait.validate_input(traits.Trait, dat)) + + # make sure extra values are cleaned if trait accepts no extras + dat = { + "name": "Test", + "trait_type": "trait", + "value": 10, + "extra_val1": 1000, + "extra_val2": "xvalue", + } + expected = copy(dat) + expected.pop("extra_val1") + expected.pop("extra_val2") + with patch.object(traits.Trait, "allow_extra_properties", False): + self.assertEqual(expected, traits.Trait.validate_input(traits.Trait, dat))
+ +
[docs] def test_validate_input__fail(self): + """Test failing validation""" + dat = { + # missing name + "trait_type": "trait", + "value": 10, + "extra_val": 1000, + } + with self.assertRaises(traits.TraitException): + traits.Trait.validate_input(traits.Trait, dat) + + # make value a required key + mock_default_keys = {"value": traits.MandatoryTraitKey} + with patch.object(traits.Trait, "default_keys", mock_default_keys): + dat = { + "name": "Trait", + "trait_type": "trait", + # missing value, now mandatory + "extra_val": 1000, + } + with self.assertRaises(traits.TraitException): + traits.Trait.validate_input(traits.Trait, dat)
+ +
[docs] def test_trait_getset(self): + """Get-set-del operations on trait""" + self.assertEqual(self.trait.name, "Test1") + self.assertEqual(self.trait["name"], "Test1") + self.assertEqual(self.trait.value, "value") + self.assertEqual(self.trait["value"], "value") + self.assertEqual(self.trait.extra_val1, "xvalue1") + self.assertEqual(self.trait["extra_val2"], "xvalue2") + + self.trait.value = 20 + self.assertEqual(self.trait["value"], 20) + self.trait["value"] = 20 + self.assertEqual(self.trait.value, 20) + self.trait.extra_val1 = 100 + self.assertEqual(self.trait.extra_val1, 100) + # additional properties + self.trait.foo = "bar" + self.assertEqual(self.trait.foo, "bar") + + del self.trait.foo + with self.assertRaises(KeyError): + self.trait["foo"] + with self.assertRaises(AttributeError): + self.trait.foo + del self.trait.extra_val1 + with self.assertRaises(AttributeError): + self.trait.extra_val1 + del self.trait.value + # fall back to default + self.assertTrue(self.trait.value == traits.Trait.default_keys["value"])
+ +
[docs] def test_repr(self): + self.assertEqual(repr(self.trait), Something) + self.assertEqual(str(self.trait), Something)
+ + +
[docs]class TestTraitStatic(_TraitHandlerBase): + """ + Test for static Traits + """ + +
[docs] def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type="static", + base=1, + mod=2, + mult=1.0, + extra_val1="xvalue1", + extra_val2="xvalue2", + ) + self.trait = self.traithandler.get("test1")
+ + def _get_values(self): + return self.trait.base, self.trait.mod, self.trait.mult, self.trait.value + +
[docs] def test_init(self): + self.assertEqual( + self._get_dbstore("test1"), + { + "name": "Test1", + "trait_type": "static", + "base": 1, + "mod": 2, + "mult": 1.0, + "extra_val1": "xvalue1", + "extra_val2": "xvalue2", + }, + )
+ +
[docs] def test_value(self): + """value is (base + mod) * mult""" + self.assertEqual(self._get_values(), (1, 2, 1.0, 3)) + self.trait.base += 4 + self.assertEqual(self._get_values(), (5, 2, 1.0, 7)) + self.trait.mod -= 1 + self.assertEqual(self._get_values(), (5, 1, 1.0, 6)) + self.trait.mult += 1.0 + self.assertEqual(self._get_values(), (5, 1, 2.0, 12)) + self.trait.mult = 0.75 + self.assertEqual(self._get_values(), (5, 1, 0.75, 4.5))
+ +
[docs] def test_delete(self): + """Deleting resets to default.""" + self.trait.mult = 2.0 + del self.trait.base + self.assertEqual(self._get_values(), (0, 2, 2.0, 4)) + del self.trait.mult + self.assertEqual(self._get_values(), (0, 2, 1.0, 2)) + del self.trait.mod + self.assertEqual(self._get_values(), (0, 0, 1.0, 0))
+ + +
[docs]class TestTraitCounter(_TraitHandlerBase): + """ + Test for counter- Traits + """ + +
[docs] def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type="counter", + base=1, + mod=2, + mult=1.0, + min=0, + max=10, + extra_val1="xvalue1", + extra_val2="xvalue2", + descs={ + 0: "range0", + 2: "range1", + 5: "range2", + 7: "range3", + }, + ) + self.trait = self.traithandler.get("test1")
+ + def _get_values(self): + """Get (base, mod, mult, value, min, max).""" + return ( + self.trait.base, + self.trait.mod, + self.trait.mult, + self.trait.value, + self.trait.min, + self.trait.max, + ) + +
[docs] def test_init(self): + self.assertEqual( + self._get_dbstore("test1"), + { + "name": "Test1", + "trait_type": "counter", + "base": 1, + "mod": 2, + "mult": 1.0, + "min": 0, + "max": 10, + "extra_val1": "xvalue1", + "extra_val2": "xvalue2", + "descs": { + 0: "range0", + 2: "range1", + 5: "range2", + 7: "range3", + }, + "rate": 0, + "ratetarget": None, + "last_update": None, + }, + )
+ +
[docs] def test_value(self): + """value is (current + mod) * mult, where current defaults to base""" + self.assertEqual(self._get_values(), (1, 2, 1.0, 3, 0, 10)) + self.trait.base += 4 + self.assertEqual(self._get_values(), (5, 2, 1.0, 7, 0, 10)) + self.trait.mod -= 1 + self.assertEqual(self._get_values(), (5, 1, 1.0, 6, 0, 10)) + self.trait.mult += 1.0 + self.assertEqual(self._get_values(), (5, 1, 2.0, 10, 0, 10))
+ +
[docs] def test_boundaries__minmax(self): + """Test range""" + # should not exceed min/max values + self.trait.base += 20 + self.assertEqual(self._get_values(), (8, 2, 1.0, 10, 0, 10)) + self.trait.base = 100 + self.assertEqual(self._get_values(), (8, 2, 1.0, 10, 0, 10)) + self.trait.base -= 40 + self.assertEqual(self._get_values(), (-2, 2, 1.0, 0, 0, 10)) + self.trait.base = -100 + self.assertEqual(self._get_values(), (-2, 2, 1.0, 0, 0, 10))
+ +
[docs] def test_boundaries__bigmod(self): + """add a big mod""" + self.trait.base = 5 + self.trait.mod = 100 + self.assertEqual(self._get_values(), (5, 5, 1.0, 10, 0, 10)) + self.trait.mod = -100 + self.assertEqual(self._get_values(), (5, -5, 1.0, 0, 0, 10))
+ +
[docs] def test_boundaries__change_boundaries(self): + """Change boundaries after base/mod change""" + self.trait.base = 5 + self.trait.mod = -100 + self.trait.min = -20 + self.assertEqual(self._get_values(), (5, -5, 1.0, 0, -20, 10)) + self.trait.mod -= 100 + self.assertEqual(self._get_values(), (5, -25, 1.0, -20, -20, 10)) + self.trait.mod = 100 + self.trait.max = 20 + self.assertEqual(self._get_values(), (5, 5, 1.0, 10, -20, 20)) + self.trait.mod = 100 + self.assertEqual(self._get_values(), (5, 15, 1.0, 20, -20, 20))
+ +
[docs] def test_boundaries__disable(self): + """Disable and re-enable boundaries""" + self.trait.base = 5 + self.trait.mod = 100 + self.assertEqual(self._get_values(), (5, 5, 1.0, 10, 0, 10)) + del self.trait.max + self.assertEqual(self.trait.max, None) + del self.trait.min + self.assertEqual(self.trait.min, None) + self.trait.base = 100 + self.assertEqual(self._get_values(), (100, 5, 1.0, 105, None, None)) + self.trait.base = -200 + self.assertEqual(self._get_values(), (-200, 5, 1.0, -195, None, None)) + + # re-activate boundaries + self.trait.max = 15 + self.trait.min = 10 # his is blocked since base+mod is lower + self.assertEqual(self._get_values(), (-200, 5, 1.0, -195, -195, 15))
+ +
[docs] def test_boundaries__inverse(self): + """Set inverse boundaries - limited by base""" + self.trait.mod = 0 + self.assertEqual(self._get_values(), (1, 0, 1.0, 1, 0, 10)) + self.trait.min = 20 # will be set to base + self.assertEqual(self._get_values(), (1, 0, 1.0, 1, 1, 10)) + self.trait.max = -20 + self.assertEqual(self._get_values(), (1, 0, 1.0, 1, 1, 1))
+ +
[docs] def test_current(self): + """Modifying current value""" + self.trait.current = 5 + self.assertEqual(self._get_values(), (1, 2, 1.0, 7, 0, 10)) + self.trait.current = 10 + self.assertEqual(self._get_values(), (1, 2, 1.0, 10, 0, 10)) + self.trait.current = 12 + self.assertEqual(self._get_values(), (1, 2, 1.0, 10, 0, 10)) + self.trait.current = -1 + self.assertEqual(self._get_values(), (1, 2, 1.0, 2, 0, 10)) + self.trait.current -= 10 + self.assertEqual(self._get_values(), (1, 2, 1.0, 2, 0, 10))
+ +
[docs] def test_delete(self): + """Deleting resets to default.""" + del self.trait.base + self.assertEqual(self._get_values(), (0, 2, 1.0, 2, 0, 10)) + del self.trait.mod + self.assertEqual(self._get_values(), (0, 0, 1.0, 0, 0, 10)) + del self.trait.min + del self.trait.max + self.assertEqual(self._get_values(), (0, 0, 1.0, 0, None, None))
+ +
[docs] def test_percentage(self): + """Test percentage calculation""" + self.trait.base = 8 + self.trait.mod = 2 + self.trait.mult = 1.0 + self.trait.min = 0 + self.trait.max = 10 + self.assertEqual(self.trait.percent(), "100.0%") + self.trait.current = 3 + self.assertEqual(self.trait.percent(), "50.0%") + self.trait.current = 1 + self.assertEqual(self.trait.percent(), "30.0%") + # have to lower this since max cannot be lowered below base+mod + self.trait.mod = 1 + self.trait.current = 2 + self.trait.max -= 1 + self.assertEqual(self.trait.percent(), "33.3%") + # open boundary + del self.trait.min + self.assertEqual(self.trait.percent(), "100.0%")
+ +
[docs] def test_descs(self): + """Test descriptions""" + self.trait.min = -5 + self.trait.mod = 0 + self.assertEqual(self._get_values(), (1, 0, 1.0, 1, -5, 10)) + self.trait.current = -2 + self.assertEqual(self.trait.desc(), "range0") + self.trait.current = 0 + self.assertEqual(self.trait.desc(), "range0") + self.trait.current = 1 + self.assertEqual(self.trait.desc(), "range1") + self.trait.current = 3 + self.assertEqual(self.trait.desc(), "range2") + self.trait.current = 5 + self.assertEqual(self.trait.desc(), "range2") + self.trait.current = 9 + self.assertEqual(self.trait.desc(), "range3") + self.trait.current = 100 + self.assertEqual(self.trait.desc(), "range3")
+ + +
[docs]class TestTraitCounterTimed(_TraitHandlerBase): + """ + Test for trait with timer component + """ + +
[docs] @patch("evennia.contrib.rpg.traits.traits.time", new=MagicMock(return_value=1000)) + def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type="counter", + base=1, + mod=2, + mult=1.0, + min=0, + max=100, + extra_val1="xvalue1", + extra_val2="xvalue2", + descs={ + 0: "range0", + 2: "range1", + 5: "range2", + 7: "range3", + }, + rate=1, + ratetarget=None, + ) + self.trait = self.traithandler.get("test1")
+ + def _get_timer_data(self): + return ( + self.trait.value, + self.trait.current, + self.trait.rate, + self.trait._data["last_update"], + self.trait.ratetarget, + ) + +
[docs] @patch("evennia.contrib.rpg.traits.traits.time") + def test_timer_rate(self, mock_time): + """Test time stepping""" + mock_time.return_value = 1000 + self.assertEqual(self._get_timer_data(), (3, 1, 1, 1000, None)) + mock_time.return_value = 1001 + self.assertEqual(self._get_timer_data(), (4, 2, 1, 1001, None)) + mock_time.return_value = 1096 + self.assertEqual(self._get_timer_data(), (99, 97, 1, 1096, None)) + # hit maximum boundary + mock_time.return_value = 1120 + self.assertEqual(self._get_timer_data(), (100, 98, 1, None, None)) + mock_time.return_value = 1200 + self.assertEqual(self._get_timer_data(), (100, 98, 1, None, None)) + # drop current + self.trait.current = 50 + self.assertEqual(self._get_timer_data(), (52, 50, 1, 1200, None)) + # set a new rate + self.trait.rate = 2 + mock_time.return_value = 1210 + self.assertEqual(self._get_timer_data(), (72, 70, 2, 1210, None)) + self.trait.rate = -10 + mock_time.return_value = 1214 + self.assertEqual(self._get_timer_data(), (32, 30, -10, 1214, None)) + mock_time.return_value = 1218 + self.assertEqual(self._get_timer_data(), (0, -2, -10, None, None))
+ +
[docs] @patch("evennia.contrib.rpg.traits.traits.time") + def test_timer_ratetarget(self, mock_time): + """test ratetarget""" + mock_time.return_value = 1000 + self.trait.ratetarget = 60 + self.assertEqual(self._get_timer_data(), (3, 1, 1, 1000, 60)) + mock_time.return_value = 1056 + self.assertEqual(self._get_timer_data(), (59, 57, 1, 1056, 60)) + mock_time.return_value = 1057 + self.assertEqual(self._get_timer_data(), (60, 58, 1, None, 60)) + mock_time.return_value = 1060 + self.assertEqual(self._get_timer_data(), (60, 58, 1, None, 60)) + self.trait.ratetarget = 70 + mock_time.return_value = 1066 + self.assertEqual(self._get_timer_data(), (66, 64, 1, 1066, 70)) + mock_time.return_value = 1070 + self.assertEqual(self._get_timer_data(), (70, 68, 1, None, 70))
+ + +
[docs]class TestTraitGauge(_TraitHandlerBase): +
[docs] def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type="gauge", + base=8, # max = (base + mod) * mult + mod=2, + mult=1.0, + extra_val1="xvalue1", + extra_val2="xvalue2", + descs={ + 0: "range0", + 2: "range1", + 5: "range2", + 7: "range3", + }, + ) + self.trait = self.traithandler.get("test1")
+ + def _get_values(self): + """Get (base, mod, mult, value, min, max).""" + return ( + self.trait.base, + self.trait.mod, + self.trait.mult, + self.trait.value, + self.trait.min, + self.trait.max, + ) + +
[docs] def test_init(self): + self.assertEqual( + self._get_dbstore("test1"), + { + "name": "Test1", + "trait_type": "gauge", + "base": 8, + "mod": 2, + "mult": 1.0, + "min": 0, + "extra_val1": "xvalue1", + "extra_val2": "xvalue2", + "descs": { + 0: "range0", + 2: "range1", + 5: "range2", + 7: "range3", + }, + "rate": 0, + "ratetarget": None, + "last_update": None, + }, + )
+ +
[docs] def test_value(self): + """value is current, where current defaults to base + mod""" + # current unset - follows base + mod + self.assertEqual(self._get_values(), (8, 2, 1.0, 10, 0, 10)) + self.trait.base += 4 + self.assertEqual(self._get_values(), (12, 2, 1.0, 14, 0, 14)) + self.trait.mod -= 1 + self.assertEqual(self._get_values(), (12, 1, 1.0, 13, 0, 13)) + self.trait.mult += 1.0 + self.assertEqual(self._get_values(), (12, 1, 2.0, 26, 0, 26)) + # set current, decouple from base + mod + self.trait.current = 5 + self.assertEqual(self._get_values(), (12, 1, 2.0, 5, 0, 26)) + self.trait.mod += 1 + self.trait.base -= 4 + self.trait.mult -= 1.0 + self.assertEqual(self._get_values(), (8, 2, 1.0, 5, 0, 10)) + self.trait.min = -100 + self.trait.base = -20 + self.assertEqual(self._get_values(), (-20, 2, 1.0, -18, -100, -18))
+ +
[docs] def test_boundaries__minmax(self): + """Test range""" + # current unset - tied to base + mod + self.trait.base += 20 + self.assertEqual(self._get_values(), (28, 2, 1.0, 30, 0, 30)) + # set current - decouple from base + mod + self.trait.current = 19 + self.assertEqual(self._get_values(), (28, 2, 1.0, 19, 0, 30)) + # test upper bound + self.trait.current = 100 + self.assertEqual(self._get_values(), (28, 2, 1.0, 30, 0, 30)) + # with multiplier + self.trait.mult = 2.0 + self.assertEqual(self._get_values(), (28, 2, 2.0, 30, 0, 60)) + self.trait.current = 100 + self.assertEqual(self._get_values(), (28, 2, 2.0, 60, 0, 60)) + # min defaults to 0 + self.trait.mult = 1.0 + self.trait.current = -10 + self.assertEqual(self._get_values(), (28, 2, 1.0, 0, 0, 30)) + self.trait.min = -20 + self.assertEqual(self._get_values(), (28, 2, 1.0, 0, -20, 30)) + self.trait.current = -10 + self.assertEqual(self._get_values(), (28, 2, 1.0, -10, -20, 30))
+ +
[docs] def test_boundaries__bigmod(self): + """add a big mod""" + self.trait.base = 5 + self.trait.mod = 100 + self.assertEqual(self._get_values(), (5, 100, 1.0, 105, 0, 105)) + # restricted by min + self.trait.mod = -100 + self.assertEqual(self._get_values(), (5, -5, 1.0, 0, 0, 0)) + self.trait.min = -200 + self.assertEqual(self._get_values(), (5, -5, 1.0, 0, -200, 0))
+ +
[docs] def test_boundaries__change_boundaries(self): + """Change boundaries after current change""" + self.trait.current = 20 + self.assertEqual(self._get_values(), (8, 2, 1.0, 10, 0, 10)) + self.trait.mod = 102 + self.assertEqual(self._get_values(), (8, 102, 1.0, 10, 0, 110)) + # raising min past current value will force it upwards + self.trait.min = 20 + self.assertEqual(self._get_values(), (8, 102, 1.0, 20, 20, 110))
+ +
[docs] def test_boundaries__disable(self): + """Disable and re-enable boundary""" + self.trait.base = 5 + self.trait.min = 1 + self.assertEqual(self._get_values(), (5, 2, 1.0, 7, 1, 7)) + del self.trait.min + self.assertEqual(self._get_values(), (5, 2, 1.0, 7, 0, 7)) + del self.trait.base + del self.trait.mod + self.assertEqual(self._get_values(), (0, 0, 1.0, 0, 0, 0)) + with self.assertRaises(traits.TraitException): + del self.trait.max
+ +
[docs] def test_boundaries__inverse(self): + """Try to set reversed boundaries""" + self.trait.mod = 0 + self.trait.base = -10 # limited by min + self.assertEqual(self._get_values(), (0, 0, 1.0, 0, 0, 0)) + self.trait.min = -10 + self.assertEqual(self._get_values(), (0, 0, 1.0, 0, -10, 0)) + self.trait.base = -10 + self.assertEqual(self._get_values(), (-10, 0, 1.0, -10, -10, -10)) + self.min = 0 # limited by base + mod + self.assertEqual(self._get_values(), (-10, 0, 1.0, -10, -10, -10))
+ +
[docs] def test_current(self): + """Modifying current value""" + self.trait.base = 10 + self.trait.current = 5 + self.assertEqual(self._get_values(), (10, 2, 1.0, 5, 0, 12)) + self.trait.current = 10 + self.assertEqual(self._get_values(), (10, 2, 1.0, 10, 0, 12)) + self.trait.current = 12 + self.assertEqual(self._get_values(), (10, 2, 1.0, 12, 0, 12)) + self.trait.current = 0 + self.assertEqual(self._get_values(), (10, 2, 1.0, 0, 0, 12)) + self.trait.current = -1 + self.assertEqual(self._get_values(), (10, 2, 1.0, 0, 0, 12))
+ +
[docs] def test_delete(self): + """Deleting resets to default.""" + del self.trait.mod + self.assertEqual(self._get_values(), (8, 0, 1.0, 8, 0, 8)) + self.trait.mod = 2 + del self.trait.base + self.assertEqual(self._get_values(), (0, 2, 1.0, 2, 0, 2)) + del self.trait.min + self.assertEqual(self._get_values(), (0, 2, 1.0, 2, 0, 2)) + self.trait.min = -10 + self.assertEqual(self._get_values(), (0, 2, 1.0, 2, -10, 2)) + del self.trait.min + self.assertEqual(self._get_values(), (0, 2, 1.0, 2, 0, 2))
+ +
[docs] def test_percentage(self): + """Test percentage calculation""" + self.assertEqual(self.trait.percent(), "100.0%") + self.trait.current = 5 + self.assertEqual(self.trait.percent(), "50.0%") + self.trait.current = 3 + self.assertEqual(self.trait.percent(), "30.0%") + self.trait.mod -= 1 + self.assertEqual(self.trait.percent(), "33.3%")
+ +
[docs] def test_descs(self): + """Test descriptions""" + self.trait.min = -5 + self.assertEqual(self._get_values(), (8, 2, 1.0, 10, -5, 10)) + self.trait.current = -2 + self.assertEqual(self.trait.desc(), "range0") + self.trait.current = 0 + self.assertEqual(self.trait.desc(), "range0") + self.trait.current = 1 + self.assertEqual(self.trait.desc(), "range1") + self.trait.current = 3 + self.assertEqual(self.trait.desc(), "range2") + self.trait.current = 5 + self.assertEqual(self.trait.desc(), "range2") + self.trait.current = 9 + self.assertEqual(self.trait.desc(), "range3") + self.trait.current = 100 + self.assertEqual(self.trait.desc(), "range3")
+ + +
[docs]class TestTraitGaugeTimed(_TraitHandlerBase): + """ + Test for trait with timer component + """ + +
[docs] @patch("evennia.contrib.rpg.traits.traits.time", new=MagicMock(return_value=1000)) + def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type="gauge", + base=98, + mod=2, + min=0, + extra_val1="xvalue1", + extra_val2="xvalue2", + descs={ + 0: "range0", + 2: "range1", + 5: "range2", + 7: "range3", + }, + rate=1, + ratetarget=None, + ) + self.trait = self.traithandler.get("test1")
+ + def _get_timer_data(self): + return ( + self.trait.value, + self.trait.current, + self.trait.rate, + self.trait._data["last_update"], + self.trait.ratetarget, + ) + +
[docs] @patch("evennia.contrib.rpg.traits.traits.time") + def test_timer_rate(self, mock_time): + """Test time stepping""" + mock_time.return_value = 1000 + self.trait.current = 1 + self.assertEqual(self._get_timer_data(), (1, 1, 1, 1000, None)) + mock_time.return_value = 1001 + self.assertEqual(self._get_timer_data(), (2, 2, 1, 1001, None)) + mock_time.return_value = 1096 + self.assertEqual(self._get_timer_data(), (97, 97, 1, 1096, None)) + # hit maximum boundary + mock_time.return_value = 1120 + self.assertEqual(self._get_timer_data(), (100, 100, 1, None, None)) + mock_time.return_value = 1200 + self.assertEqual(self._get_timer_data(), (100, 100, 1, None, None)) + # drop current + self.trait.current = 50 + self.assertEqual(self._get_timer_data(), (50, 50, 1, 1200, None)) + # set a new rate + self.trait.rate = 2 + mock_time.return_value = 1210 + self.assertEqual(self._get_timer_data(), (70, 70, 2, 1210, None)) + self.trait.rate = -10 + mock_time.return_value = 1214 + self.assertEqual(self._get_timer_data(), (30, 30, -10, 1214, None)) + mock_time.return_value = 1218 + self.assertEqual(self._get_timer_data(), (0, 0, -10, None, None))
+ +
[docs] @patch("evennia.contrib.rpg.traits.traits.time") + def test_timer_ratetarget(self, mock_time): + """test ratetarget""" + mock_time.return_value = 1000 + self.trait.current = 1 + self.trait.ratetarget = 60 + self.assertEqual(self._get_timer_data(), (1, 1, 1, 1000, 60)) + mock_time.return_value = 1056 + self.assertEqual(self._get_timer_data(), (57, 57, 1, 1056, 60)) + mock_time.return_value = 1059 + self.assertEqual(self._get_timer_data(), (60, 60, 1, None, 60)) + mock_time.return_value = 1060 + self.assertEqual(self._get_timer_data(), (60, 60, 1, None, 60)) + self.trait.ratetarget = 70 + mock_time.return_value = 1066 + self.assertEqual(self._get_timer_data(), (66, 66, 1, 1066, 70)) + mock_time.return_value = 1070 + self.assertEqual(self._get_timer_data(), (70, 70, 1, None, 70))
+ + +
[docs]class TestNumericTraitOperators(BaseEvenniaTestCase): + """Test case for numeric magic method implementations.""" + +
[docs] def setUp(self): + # direct instantiation for testing only; use TraitHandler in production + self.st = traits.Trait( + { + "name": "Strength", + "trait_type": "trait", + "value": 8, + } + ) + self.at = traits.Trait( + { + "name": "Attack", + "trait_type": "trait", + "value": 4, + } + )
+ +
[docs] def tearDown(self): + self.st, self.at = None, None
+ +
[docs] def test_pos_shortcut(self): + """overridden unary + operator returns `value` property""" + self.assertIn(type(+self.st), (float, int)) + self.assertEqual(+self.st, self.st.value) + self.assertEqual(+self.st, 8)
+ +
[docs] def test_add_traits(self): + """test addition of `Trait` objects""" + # two Trait objects + self.assertEqual(self.st + self.at, 12) + # Trait and numeric + self.assertEqual(self.st + 1, 9) + self.assertEqual(1 + self.st, 9)
+ +
[docs] def test_sub_traits(self): + """test subtraction of `Trait` objects""" + # two Trait objects + self.assertEqual(self.st - self.at, 4) + # Trait and numeric + self.assertEqual(self.st - 1, 7) + self.assertEqual(10 - self.st, 2)
+ +
[docs] def test_mul_traits(self): + """test multiplication of `Trait` objects""" + # between two Traits + self.assertEqual(self.st * self.at, 32) + # between Trait and numeric + self.assertEqual(self.at * 4, 16) + self.assertEqual(4 * self.at, 16)
+ +
[docs] def test_floordiv(self): + """test floor division of `Trait` objects""" + # between two Traits + self.assertEqual(self.st // self.at, 2) + # between Trait and numeric + self.assertEqual(self.st // 2, 4) + self.assertEqual(18 // self.st, 2)
+ +
[docs] def test_comparisons_traits(self): + """test equality comparison between `Trait` objects""" + self.assertNotEqual(self.st, self.at) + self.assertLess(self.at, self.st) + self.assertLessEqual(self.at, self.st) + self.assertGreater(self.st, self.at) + self.assertGreaterEqual(self.st, self.at)
+ +
[docs] def test_comparisons_numeric(self): + """equality comparisons between `Trait` and numeric""" + self.assertEqual(self.st, 8) + self.assertEqual(8, self.st) + self.assertNotEqual(self.st, 0) + self.assertNotEqual(0, self.st) + self.assertLess(self.st, 10) + self.assertLess(0, self.st) + self.assertLessEqual(self.st, 8) + self.assertLessEqual(8, self.st) + self.assertLessEqual(self.st, 10) + self.assertLessEqual(0, self.st) + self.assertGreater(self.st, 0) + self.assertGreater(10, self.st) + self.assertGreaterEqual(self.st, 8) + self.assertGreaterEqual(8, self.st) + self.assertGreaterEqual(self.st, 0) + self.assertGreaterEqual(10, self.st)
+ + +
[docs]class DummyCharacter(_MockObj): + strength = traits.TraitProperty("Strength", trait_type="static", base=10, mod=2) + hunting = traits.TraitProperty("Hunting skill", trait_type="counter", base=10, mod=1, max=100) + health = traits.TraitProperty("Health value", trait_type="gauge", base=100)
+ + +
[docs]class TestTraitFields(BaseEvenniaTestCase): + """ + Test the TraitField class. + + """ + +
[docs] @patch("evennia.contrib.rpg.traits.traits._TRAIT_CLASS_PATHS", new=_TEST_TRAIT_CLASS_PATHS) + def test_traitfields(self): + obj = DummyCharacter() + obj2 = DummyCharacter() + + self.assertEqual(12, obj.strength.value) + self.assertEqual(11, obj.hunting.value) + self.assertEqual(100, obj.health.value) + + obj.strength.base += 5 + self.assertEqual(17, obj.strength.value) + + obj.strength.berserk = True + self.assertEqual(obj.strength.berserk, True) + + self.assertEqual(100, obj.traits.health) + self.assertEqual(None, obj.traits.hp) + + # the traithandler still works + obj.traits.health.current -= 1 + self.assertEqual(99, obj.health.value) + + # making sure Descriptors are separate + self.assertEqual(12, obj2.strength.value) + self.assertEqual(17, obj.strength.value) + + obj2.strength.base += 1 + obj.strength.base += 3 + + self.assertEqual(13, obj2.strength.value) + self.assertEqual(20, obj.strength.value)
+ + +
[docs]class TraitContribTestingChar(DefaultCharacter): + HP = traits.TraitProperty("health", trait_type="trait", value=5)
+ + +
[docs]class TraitPropertyTestCase(EvenniaTest): + """ + Test atomic updating. + + """ + + character_typeclass = TraitContribTestingChar + +
[docs] def test_round1(self): + self.char1.HP.value = 1
+ +
[docs] def test_round2(self): + self.char1.HP.value = 2
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/rpg/traits/traits.html b/docs/latest/_modules/evennia/contrib/rpg/traits/traits.html new file mode 100644 index 0000000000..f19a481a1c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/rpg/traits/traits.html @@ -0,0 +1,1803 @@ + + + + + + + + evennia.contrib.rpg.traits.traits — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.rpg.traits.traits

+"""
+Traits
+
+Whitenoise 2014, Ainneve contributors,
+Griatch 2020
+
+
+A `Trait` represents a modifiable property on (usually) a Character. They can
+be used to represent everything from attributes (str, agi etc) to skills
+(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc.
+
+Traits use Evennia Attributes under the hood, making them persistent (they survive
+a server reload/reboot).
+
+## Installation
+
+Traits are always added to a typeclass, such as the Character class.
+
+There are two ways to set up Traits on a typeclass. The first sets up the `TraitHandler`
+as a property `.traits` on your class and you then access traits as e.g. `.traits.strength`.
+The other alternative uses a `TraitProperty`, which makes the trait available directly
+as e.g. `.strength`. This solution also uses the `TraitHandler`, but you don't need to
+define it explicitly. You can combine both styles if you like.
+
+### Traits with TraitHandler
+
+Here's an example for adding the TraitHandler to the Character class:
+
+```python
+# mygame/typeclasses/objects.py
+
+from evennia import DefaultCharacter
+from evennia.utils import lazy_property
+from evennia.contrib.rpg.traits import TraitHandler
+
+# ...
+
+class Character(DefaultCharacter):
+    ...
+    @lazy_property
+    def traits(self):
+        # this adds the handler as .traits
+        return TraitHandler(self)
+
+
+    def at_object_creation(self):
+        # (or wherever you want)
+        self.traits.add("str", "Strength", trait_type="static", base=10, mod=2, mult=2.0)
+        self.traits.add("hp", "Health", trait_type="gauge", min=0, max=100)
+        self.traits.add("hunting", "Hunting Skill", trait_type="counter",
+                        base=10, mod=1, min=0, max=100)
+
+
+```
+When adding the trait, you supply the name of the property (`hunting`) along
+with a more human-friendly name ("Hunting Skill"). The latter will show if you
+print the trait etc. The `trait_type` is important, this specifies which type
+of trait this is (see below).
+
+### TraitProperties
+
+Using `TraitProperties` makes the trait available directly on the class, much like Django model
+fields. The drawback is that you must make sure that the name of your Traits don't collide with any
+other properties/methods on your class.
+
+```python
+# mygame/typeclasses/objects.py
+
+from evennia import DefaultObject
+from evennia.utils import lazy_property
+from evennia.contrib.rpg.traits import TraitProperty
+
+# ...
+
+class Object(DefaultObject):
+    ...
+    strength = TraitProperty("Strength", trait_type="static", base=10, mod=2, mult=1.5)
+    health = TraitProperty("Health", trait_type="gauge", min=0, base=100, mod=2)
+    hunting = TraitProperty("Hunting Skill", trait_type="counter", base=10, mod=1, mult=2.0, min=0, max=100)
+
+```
+
+> Note that the property-name will become the name of the trait and you don't supply `trait_key`
+> separately.
+
+> The `.traits` TraitHandler will still be created (it's used under the
+> hood. But it will only be created when the TraitProperty has been accessed at least once,
+> so be careful if mixing the two styles. If you want to make sure `.traits` is always available,
+> add the `TraitHandler` manually like shown earlier - the `TraitProperty` will by default use
+> the same handler (`.traits`).
+
+## Using traits
+
+A trait is added to the traithandler (if you use `TraitProperty` the handler is just created under
+the hood) after which one can access it as a property on the handler (similarly to how you can do
+.db.attrname for Attributes in Evennia).
+
+All traits have a _read-only_ field `.value`. This is only used to read out results, you never
+manipulate it directly (if you try, it will just remain unchanged). The `.value` is calculated based
+on combining fields, like `.base` and `.mod` - which fields are available and how they relate to
+each other depends on the trait type.
+
+```python
+> obj.traits.strength.value
+18                                  # (base + mod) * mult
+
+> obj.traits.strength.base += 6
+obj.traits.strength.value
+27
+
+> obj.traits.hp.value
+102                                 # (base + mod) * mult
+
+> obj.traits.hp.base -= 200
+> obj.traits.hp.value
+0                                   # min of 0
+
+> obj.traits.hp.reset()
+> obj.traits.hp.value
+100
+
+# you can also access properties like a dict
+> obj.traits.hp["value"]
+100
+
+# you can store arbitrary data persistently for easy reference
+> obj.traits.hp.effect = "poisoned!"
+> obj.traits.hp.effect
+"poisoned!"
+
+# with TraitProperties:
+
+> obj.hunting.value
+22
+
+> obj.strength.value += 5
+> obj.strength.value
+32
+
+```
+
+## Trait types
+
+All default traits have a read-only `.value` property that shows the relevant or
+'current' value of the trait. Exactly what this means depends on the type of trait.
+
+Traits can also be combined to do arithmetic with their .value, if both have a
+compatible type.
+
+```python
+> trait1 + trait2
+54
+
+> trait1.value
+3
+
+> trait1 + 2
+> trait1.value
+5
+
+```
+
+Two numerical traits can also be compared (bigger-than etc), which is useful in
+all sorts of rule-resolution.
+
+```python
+
+if trait1 > trait2:
+    # do stuff
+
+```
+## Static trait
+
+`value = (base + mod) * mult`
+
+The static trait has a `base` value and an optional `mod`-ifier and 'mult'-iplier.
+The modifier defaults to 0, and the multiplier to 1.0, for no change in value.
+A typical use of a static trait would be a Strength stat or Skill value. That is,
+somethingthat varies slowly or not at all, and which may be modified in-place.
+
+```python
+> obj.traits.add("str", "Strength", trait_type="static", base=10, mod=2)
+> obj.traits.mytrait.value
+12   # base + mod
+
+> obj.traits.mytrait.base += 2
+> obj.traits.mytrait.mod += 1
+> obj.traits.mytrait.value
+15
+
+> obj.traits.mytrait.mod = 0
+> obj.traits.mytrait.mult = 2.0
+> obj.traits.mytrait.value
+20
+
+```
+
+### Counter
+::
+
+    min/unset     base    base+mod                       max/unset
+    |--------------|--------|---------X--------X------------|
+                                  current    value
+                                             = (current
+                                             + mod)
+                                             * mult
+
+A counter describes a value that can move from a base. The `.current` property
+is the thing usually modified. It starts at the `.base`. One can also add a
+modifier, which is added to both the base and to current. '.value' is then formed
+by multiplying by the multiplier, which defaults to 1.0 for no change. The min/max
+of the range are optional, a boundary set to None will remove it. A suggested use
+for a Counter Trait would be to track skill values.
+
+```python
+> obj.traits.add("hunting", "Hunting Skill", trait_type="counter",
+                   base=10, mod=1, mult=1.0, min=0, max=100)
+> obj.traits.hunting.value
+11  # current starts at base + mod
+
+> obj.traits.hunting.current += 10
+> obj.traits.hunting.value
+21
+
+# reset back to base+mod by deleting current
+> del obj.traits.hunting.current
+> obj.traits.hunting.value
+11
+
+> obj.traits.hunting.max = None  # removing upper bound
+> obj.traits.hunting.mult = 100.0
+1100
+
+# for TraitProperties, pass the args/kwargs of traits.add() to the
+# TraitProperty constructor instead.
+
+
+```
+
+Counters have some extra properties:
+
+#### .descs
+
+The `descs` property is a dict {upper_bound:text_description}. This allows for easily
+storing a more human-friendly description of the current value in the
+interval. Here is an example for skill values between 0 and 10:
+::
+
+    {0: "unskilled", 1: "neophyte", 5: "trained", 7: "expert", 9: "master"}
+
+The keys must be supplied from smallest to largest. Any values below the lowest and above the
+highest description will be considered to be included in the closest description slot.
+By calling `.desc()` on the Counter, you will get the text matching the current `value`.
+
+```python
+# (could also have passed descs= to traits.add())
+> obj.traits.hunting.descs = {
+    0: "unskilled", 10: "neophyte", 50: "trained", 70: "expert", 90: "master"}
+> obj.traits.hunting.value
+11
+
+> obj.traits.hunting.desc()
+"neophyte"
+> obj.traits.hunting.current += 60
+> obj.traits.hunting.value
+71
+
+> obj.traits.hunting.desc()
+"expert"
+
+```
+
+#### .rate
+
+The `rate` property defaults to 0. If set to a value different from 0, it
+allows the trait to change value dynamically. This could be used for example
+for an attribute that was temporarily lowered but will gradually (or abruptly)
+recover after a certain time. The rate is given as change of the current
+`.value` per-second, and this will still be restrained by min/max boundaries,
+if those are set.
+
+It is also possible to set a `.ratetarget`, for the auto-change to stop at
+(rather than at the min/max boundaries). This allows the value to return to
+a previous value.
+
+```python
+
+> obj.traits.hunting.value
+71
+
+> obj.traits.hunting.ratetarget = 71
+# debuff hunting for some reason
+> obj.traits.hunting.current -= 30
+> obj.traits.hunting.value
+41
+
+> obj.traits.hunting.rate = 1  # 1/s increase
+# Waiting 5s
+> obj.traits.hunting.value
+46
+
+# Waiting 8s
+> obj.traits.hunting.value
+54
+
+# Waiting 100s
+> obj.traits.hunting.value
+71    # we have stopped at the ratetarget
+
+> obj.traits.hunting.rate = 0  # disable auto-change
+
+
+```
+Note that when retrieving the `current`, the result will always be of the same
+type as the `.base` even `rate` is a non-integer value. So if `base` is an `int`
+(default)`, the `current` value will also be rounded the closest full integer.
+If you want to see the exact `current` value, set `base` to a float - you
+will then need to use `round()` yourself on the result if you want integers.
+
+#### .percent()
+
+If both min and max are defined, the `.percent()` method of the trait will
+return the value as a percentage.
+
+```python
+> obj.traits.hunting.percent()
+"71.0%"
+
+> obj.traits.hunting.percent(formatting=None)
+71.0
+
+```
+
+### Gauge
+
+This emulates a [fuel-] gauge that empties from a base+mod value.
+::
+
+    min/0                                            max=base+mod
+     |-----------------------X---------------------------|
+                           value
+                          = current
+
+The `.current` value will start from a full gauge. The .max property is
+read-only and is set by `.base` + `.mod`. So contrary to a `Counter`, the
+`.mod` modifier only applies to the max value of the gauge and not the current
+value. The minimum bound defaults to 0 if not set explicitly.
+
+This trait is useful for showing commonly depletable resources like health,
+stamina and the like.
+
+```python
+> obj.traits.add("hp", "Health", trait_type="gauge", base=100)
+> obj.traits.hp.value  # (or .current)
+100
+
+> obj.traits.hp.mod = 10
+> obj.traits.hp.value
+110
+
+> obj.traits.hp.current -= 30
+> obj.traits.hp.value
+80
+
+```
+
+The Gauge trait is subclass of the Counter, so you have access to the same
+methods and properties where they make sense. So gauges can also have a
+`.descs` dict to describe the intervals in text, and can use `.percent()` to
+get how filled it is as a percentage etc.
+
+The `.rate` is particularly relevant for gauges - useful for everything
+from poison slowly draining your health, to resting gradually increasing it.
+
+### Trait
+
+A single value of any type.
+
+This is the 'base' Trait, meant to inherit from if you want to invent
+trait-types from scratch (most of the time you'll probably inherit from some of
+the more advanced trait-type classes though).
+
+Unlike other Trait-types, the single `.value` property of the base `Trait` can
+be editied. The value can hold any data that can be stored in an Attribute. If
+it's an integer/float you can do arithmetic with it, but otherwise this acts just
+like a glorified Attribute.
+
+
+```python
+> obj.traits.add("mytrait", "My Trait", trait_type="trait", value=30)
+> obj.traits.mytrait.value
+30
+
+> obj.traits.mytrait.value = "stringvalue"
+> obj.traits.mytrait.value
+"stringvalue"
+
+```
+
+## Expanding with your own Traits
+
+A Trait is a class inhering from `evennia.contrib.rpg.traits.Trait` (or from one of
+the existing Trait classes).
+
+```python
+# in a file, say, 'mygame/world/traits.py'
+
+from evennia.contrib.rpg.traits import StaticTrait
+
+class RageTrait(StaticTrait):
+
+    trait_type = "rage"
+    default_keys = {
+        "rage": 0
+    }
+
+    def berserk(self):
+        self.mod = 100
+
+    def sedate(self):
+        self.mod = 0
+
+
+```
+
+Above is an example custom-trait-class "rage" that stores a property "rage" on
+itself, with a default value of 0. This has all the functionality of a Trait -
+for example, if you do del on the `rage` property, it will be set back to its
+default (0). Above we also added some helper methods.
+
+To add your custom RageTrait to Evennia, add the following to your settings file
+(assuming your class is in mygame/world/traits.py):
+::
+
+    TRAIT_CLASS_PATHS = ["world.traits.RageTrait"]
+
+Reload the server and you should now be able to use your trait:
+
+```python
+> obj.traits.add("mood", "A dark mood", rage=30, trait_type='rage')
+> obj.traits.mood.rage
+30
+
+# as TraitProperty
+
+class Character(DefaultCharacter):
+    rage = TraitProperty("A dark mood", rage=30, trait_type='rage')
+
+```
+
+----
+
+"""
+
+
+from functools import total_ordering
+from time import time
+
+from django.conf import settings
+
+from evennia.utils import logger
+from evennia.utils.dbserialize import _SaverDict
+from evennia.utils.utils import (
+    class_from_module,
+    inherits_from,
+    list_to_string,
+    percent,
+)
+
+# Available Trait classes.
+# This way the user can easily supply their own. Each
+# class should have a class-property `trait_type` to
+# identify the Trait class. The default ones are "static",
+# "counter" and "gauge".
+
+_TRAIT_CLASS_PATHS = [
+    "evennia.contrib.rpg.traits.Trait",
+    "evennia.contrib.rpg.traits.StaticTrait",
+    "evennia.contrib.rpg.traits.CounterTrait",
+    "evennia.contrib.rpg.traits.GaugeTrait",
+]
+
+if hasattr(settings, "TRAIT_CLASS_PATHS"):
+    _TRAIT_CLASS_PATHS += settings.TRAIT_CLASS_PATHS
+
+# delay trait-class import to avoid circular import
+_TRAIT_CLASSES = None
+
+
+def _delayed_import_trait_classes():
+    """
+    Import classes based on the given paths. Note that
+    imports from settings are last in the list, so if they
+    have the same trait_type set, they will replace the
+    default.
+    """
+    global _TRAIT_CLASSES
+    if _TRAIT_CLASSES is None:
+        _TRAIT_CLASSES = {}
+        for classpath in _TRAIT_CLASS_PATHS:
+            try:
+                cls = class_from_module(classpath)
+            except ImportError:
+                logger.log_trace(f"Could not import Trait from {classpath}.")
+            else:
+                if hasattr(cls, "trait_type"):
+                    trait_type = cls.trait_type
+                else:
+                    trait_type = str(cls.__name___).lower()
+                _TRAIT_CLASSES[trait_type] = cls
+
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+_DA = object.__delattr__
+
+# this is the default we offer in TraitHandler.add
+DEFAULT_TRAIT_TYPE = "static"
+
+
+
[docs]class TraitException(RuntimeError): + """ + Base exception class raised by `Trait` objects. + + Args: + msg (str): informative error message + + """ + +
[docs] def __init__(self, msg): + self.msg = msg
+ + +
[docs]class MandatoryTraitKey: + """ + This represents a required key that must be + supplied when a Trait is initialized. It's used + by Trait classes when defining their required keys. + + """
+ + +
[docs]class TraitHandler: + """ + Factory class that instantiates Trait objects. Must be assigned as a property + on the class, usually with `lazy_property`. + + Example: + :: + class Object(DefaultObject): + ... + @lazy_property + def traits(self): + # this adds the handler as .traits + return TraitHandler(self) + + """ + +
[docs] def __init__(self, obj, db_attribute_key="traits", db_attribute_category="traits"): + """ + Initialize the handler and set up its internal Attribute-based storage. + + Args: + obj (Object): Parent Object typeclass for this TraitHandler + db_attribute_key (str): Name of the DB attribute for trait data storage. + db_attribute_category (str): Name of DB attribute's category to trait data storage. + + """ + # load the available classes, if necessary + _delayed_import_trait_classes() + + # initialize any + # Note that .trait_data retains the connection to the database, meaning every + # update we do to .trait_data automatically syncs with database. + self.trait_data = obj.attributes.get(db_attribute_key, category=db_attribute_category) + if self.trait_data is None: + # no existing storage; initialize it, we then have to fetch it again + # to retain the db connection + obj.attributes.add(db_attribute_key, {}, category=db_attribute_category) + self.trait_data = obj.attributes.get(db_attribute_key, category=db_attribute_category) + self._cache = {}
+ + def __len__(self): + """Return number of Traits registered with the handler""" + return len(self.trait_data) + + def __setattr__(self, trait_key, value): + """ + Returns error message if trait objects are assigned directly. + + Args: + trait_key (str): The Trait-key, like "hp". + value (any): Data to store. + """ + if trait_key in ("trait_data", "_cache"): + _SA(self, trait_key, value) + else: + trait_cls = self._get_trait_class(trait_key=trait_key) + valid_keys = list_to_string(list(trait_cls.default_keys.keys()), endsep="or") + raise TraitException( + f"Trait object not settable directly. Assign to {trait_key}.{valid_keys}." + ) + + def __setitem__(self, trait_key, value): + """Returns error message if trait objects are assigned directly.""" + return self.__setattr__(trait_key, value) + + def __getattr__(self, trait_key): + """Returns Trait instances accessed as attributes.""" + return self.get(trait_key) + + def __getitem__(self, trait_key): + """Returns `Trait` instances accessed as dict keys.""" + return self.get(trait_key) + + def __repr__(self): + return "TraitHandler ({num} Trait(s) stored): {keys}".format( + num=len(self), keys=", ".join(self.all()) + ) + + def _get_trait_class(self, trait_type=None, trait_key=None): + """ + Helper to retrieve Trait class based on type (like "static") + or trait-key (like "hp"). + + """ + if not trait_type and trait_key: + try: + trait_type = self.trait_data[trait_key]["trait_type"] + except KeyError: + raise TraitException(f"Trait class for Trait {trait_key} could not be found.") + try: + return _TRAIT_CLASSES[trait_type] + except KeyError: + raise TraitException(f"Trait class for {trait_type} could not be found.") + +
[docs] def all(self): + """ + Get all trait keys in this handler. + + Returns: + list: All Trait keys. + + """ + return list(self.trait_data.keys())
+ +
[docs] def get(self, trait_key): + """ + Args: + trait_key (str): key from the traits dict containing config data. + + Returns: + (`Trait` or `None`): named Trait class or None if trait key + is not found in traits collection. + + """ + trait = self._cache.get(trait_key) + if trait is None and trait_key in self.trait_data: + trait_type = self.trait_data[trait_key]["trait_type"] + trait_cls = self._get_trait_class(trait_type) + trait = self._cache[trait_key] = trait_cls(_GA(self, "trait_data")[trait_key]) + return trait
+ +
[docs] def add( + self, trait_key, name=None, trait_type=DEFAULT_TRAIT_TYPE, force=True, **trait_properties + ): + """ + Create a new Trait and add it to the handler. + + Args: + trait_key (str): This is the name of the property that will be made + available on this handler (example 'hp'). + name (str, optional): Name of the Trait, like "Health". If + not given, will use `trait_key` starting with a capital letter. + trait_type (str, optional): One of 'static', 'counter' or 'gauge'. + force (bool): If set, create a new Trait even if a Trait with + the same `trait_key` already exists. + trait_properties (dict): These will all be use to initialize + the new trait. See the `properties` class variable on each + Trait class to see which are required. + + Raises: + TraitException: If specifying invalid values for the given Trait, + the `trait_type` is not recognized, or an existing trait + already exists (and `force` is unset). + + """ + # from evennia import set_trace;set_trace() + + if trait_key in self.trait_data: + if force: + self.remove(trait_key) + else: + raise TraitException(f"Trait '{trait_key}' already exists.") + + trait_class = _TRAIT_CLASSES.get(trait_type) + if not trait_class: + raise TraitException(f"Trait-type '{trait_type}' is invalid.") + + trait_properties["name"] = trait_key.title() if not name else name + trait_properties["trait_type"] = trait_type + + # this will raise exception if input is insufficient + trait_properties = trait_class.validate_input(trait_class, trait_properties) + + self.trait_data[trait_key] = trait_properties
+ +
[docs] def remove(self, trait_key): + """ + Remove a Trait from the handler's parent object. + + Args: + trait_key (str): The name of the trait to remove. + + """ + if trait_key not in self.trait_data: + raise TraitException(f"Trait '{trait_key}' not found.") + + if trait_key in self._cache: + del self._cache[trait_key] + del self.trait_data[trait_key]
+ +
[docs] def clear(self): + """ + Remove all Traits from the handler's parent object. + """ + for trait_key in self.all(): + self.remove(trait_key)
+ + +
[docs]class TraitProperty: + """ + Optional extra: Allows for applying traits as individual properties directly on the parent class + instead for properties on the `.traits` handler. So with this you could access data e.g. as + `character.hp.value` instead of `character.traits.hp.value`. This still uses the traitshandler + under the hood. + + Example: + :: + from evennia.utils import lazy_property + from evennia.contrib.rpg.traits import TraitProperty + + class Character(DefaultCharacter): + + strength = TraitProperty(name="STR", trait_type="static", base=10, mod=2) + hunting = TraitProperty("Hunting Skill", trait_type="counter", + base=10, mod=1, max=100) + health = TraitProperty(trait_type="gauge", min=0, base=100) + + """ + +
[docs] def __init__(self, name=None, trait_type=DEFAULT_TRAIT_TYPE, force=True, **trait_properties): + """ + Initialize a TraitField. Mimics TraitHandler.add input except no `trait_key`. + + Args: + name (str, optional): Name of the Trait, like "Health". If + not given, will use `trait_key` starting with a capital letter. + trait_type (str, optional): One of 'static', 'counter' or 'gauge'. + force (bool): If set, create a new Trait even if a Trait with + the same `trait_key` already exists. + Kwargs: + traithandler_name (str): If given, this is used as the name of the TraitHandler created + behind the scenes. If not set, this will be a property `traits` on the class. + any: All other trait_properties are the same as for adding a new trait of the given type + using the normal TraitHandler. + + """ + self._traithandler_name = trait_properties.pop("traithandler_name", "traits") + + trait_properties.update({"name": name, "trait_type": trait_type, "force": force}) + self._trait_properties = trait_properties + self._cache = {}
+ + def __set_name__(self, instance, name): + """ + This is called the very first time the Descriptor is assigned to the + class; we store it so we can create new instances with this later. + + """ + self._trait_key = name + + def __get__(self, instance, owner): + """ + Descriptor definition. This is called when the trait-name is aqcuired on the + instance and reroutes to fetching the actual Trait from the connected + TraitHandler (the connection is set up on-demand). + + Returns: + Trait: The trait this property represents. + + Notes: + We have one descriptor on the class, but we don't want each instance to share the + state (self) of that descriptor. So we must make sure to cache the trait per-instance + or we would end up with cross-use between instances. + + """ + if instance not in self._cache: + try: + traithandler = getattr(instance, self._traithandler_name) + except AttributeError: + # traithandler not found; create a new on-demand + traithandler = TraitHandler(instance) + setattr(instance, self._traithandler_name, traithandler) + + # this will either get the trait from attribute or make a new one + trait = traithandler.get(self._trait_key) + if trait is None: + # initialize the trait + traithandler.add(self._trait_key, **self._trait_properties) + trait = traithandler.get(self._trait_key) # caches it in the traithandler + self._cache[instance] = trait + return self._cache[instance] + + def __set__(self, instance, value): + """ + We don't set data directly, it's all rerouted to the trait. + + """ + pass
+ + +# Parent Trait class + + +
[docs]@total_ordering +class Trait: + """Represents an object or Character trait. This simple base is just + storing anything in it's 'value' property, so it's pretty much just a + different wrapper to an Attribute. It does no type-checking of what is + stored. + + Note: + See module docstring for configuration details. + + value + + """ + + # this is the name used to refer to this trait when adding + # a new trait in the TraitHandler + trait_type = "trait" + + # Property kwargs settable when creating a Trait of this type. This is a + # dict of key: default. To indicate a mandatory kwarg and raise an error if + # not given, set the default value to the `traits.MandatoryTraitKey` class. + # Apart from the keys given here, "name" and "trait_type" will also always + # have to be a apart of the data. + default_keys = {"value": None} + + # enable to set/retrieve other arbitrary properties on the Trait + # and have them treated like data to store. + allow_extra_properties = True + +
[docs] def __init__(self, trait_data): + """ + This both initializes and validates the Trait on creation. It must + raise exception if validation fails. The TraitHandler will call this + when the trait is furst added, to make sure it validates before + storing. + + Args: + trait_data (any): Any pickle-able values to store with this trait. + This must contain any cls.default_keys that do not have a default + value in cls.data_default_values. Any extra kwargs will be made + available as extra properties on the Trait, assuming the class + variable `allow_extra_properties` is set. + + Raises: + TraitException: If input-validation failed. + + """ + self._data = self.__class__.validate_input(self.__class__, trait_data) + + if not isinstance(trait_data, _SaverDict): + logger.log_warn( + f"Non-persistent Trait data (type(trait_data)) loaded for {type(self).__name__}." + )
+ +
[docs] @staticmethod + def validate_input(cls, trait_data): + """ + Validate input + + Args: + trait_data (dict or _SaverDict): Data to be used for + initialization of this trait. + Returns: + dict: Validated data, possibly complemented with default + values from default_keys. + Raises: + TraitException: If finding unset keys without a default. + + """ + + def _raise_err(unset_required): + """Helper method to format exception.""" + raise TraitException( + "Trait {} could not be created - misses required keys {}.".format( + cls.trait_type, list_to_string(list(unset_required), addquote=True) + ) + ) + + inp = set(trait_data.keys()) + + # separate check for name/trait_type, those are always required. + req = set(("name", "trait_type")) + unsets = req.difference(inp.intersection(req)) + if unsets: + _raise_err(unsets) + + # check other keys, these likely have defaults to fall back to + req = set(list(cls.default_keys.keys())) + unsets = req.difference(inp.intersection(req)) + unset_defaults = {key: cls.default_keys[key] for key in unsets} + + if MandatoryTraitKey in unset_defaults.values(): + # we have one or more unset keys that was mandatory + _raise_err([key for key, value in unset_defaults.items() if value == MandatoryTraitKey]) + # apply the default values + trait_data.update(unset_defaults) + + if not cls.allow_extra_properties: + # don't allow any extra properties - remove the extra data + for key in (key for key in inp.difference(req) if key not in ("name", "trait_type")): + del trait_data[key] + + return trait_data
+ + # Grant access to properties on this Trait. + + def __getitem__(self, key): + """Access extra parameters as dict keys.""" + try: + return self.__getattr__(key) + except AttributeError: + raise KeyError(key) + + def __setitem__(self, key, value): + """Set extra parameters as dict keys.""" + self.__setattr__(key, value) + + def __delitem__(self, key): + """Delete extra parameters as dict keys.""" + self.__delattr__(key) + + def __getattr__(self, key): + """Access extra parameters as attributes.""" + if key in ("default_keys", "data_default", "trait_type", "allow_extra_properties"): + return _GA(self, key) + try: + return self._data[key] + except KeyError: + raise AttributeError( + "{!r} {} ({}) has no property {!r}.".format( + self._data["name"], type(self).__name__, self.trait_type, key + ) + ) + + def __setattr__(self, key, value): + """Set extra parameters as attributes. + + Arbitrary attributes set on a Trait object will be + stored in the 'extra' key of the `_data` attribute. + + This behavior is enabled by setting the instance + variable `_locked` to True. + + """ + propobj = getattr(self.__class__, key, None) + if isinstance(propobj, property): + # we have a custom property named as this key, find and use its setter + if propobj.fset: + propobj.fset(self, value) + return + else: + # this is some other value + if key in ("_data",): + _SA(self, key, value) + return + if _GA(self, "allow_extra_properties"): + _GA(self, "_data")[key] = value + return + raise AttributeError(f"Can't set attribute {key} on {self.trait_type} Trait.") + + def __delattr__(self, key): + """ + Delete or reset parameters. + + Args: + key (str): property-key to delete. + Raises: + TraitException: If trying to delete a data-key + without a default value to reset to. + Notes: + This will outright delete extra keys (if allow_extra_properties is + set). Keys in self.default_keys with a default value will be + reset to default. A data_key with a default of MandatoryDefaultKey + will raise a TraitException. Unfound matches will be silently ignored. + + """ + if key in self.default_keys: + if self.default_keys[key] == MandatoryTraitKey: + raise TraitException( + "Trait-Key {key} cannot be deleted: It's a mandatory property " + "with no default value to fall back to." + ) + # set to default + self._data[key] = self.default_keys[key] + elif key in self._data: + try: + # check if we have a custom deleter + _DA(self, key) + except AttributeError: + # delete normally + del self._data[key] + else: + try: + # check if we have custom deleter, otherwise ignore + _DA(self, key) + except AttributeError: + pass + + def __repr__(self): + """Debug-friendly representation of this Trait.""" + return "{}({{{}}})".format( + type(self).__name__, + ", ".join( + [ + "'{}': {!r}".format(k, self._data[k]) + for k in self.default_keys + if k in self._data + ] + ), + ) + + def __str__(self): + return f"<Trait {self.name}: {self._data['value']}>" + + # access properties + + @property + def name(self): + """Display name for the trait.""" + return self._data["name"] + + key = name + + # Numeric operations + + def __eq__(self, other): + """Support equality comparison between Traits or Trait and numeric. + + Note: + This class uses the @functools.total_ordering() decorator to + complete the rich comparison implementation, therefore only + `__eq__` and `__lt__` are implemented. + """ + if inherits_from(other, Trait): + return self.value == other.value + elif type(other) in (float, int): + return self.value == other + else: + return NotImplemented + + def __lt__(self, other): + """Support less than comparison between `Trait`s or `Trait` and numeric.""" + if inherits_from(other, Trait): + return self.value < other.value + elif type(other) in (float, int): + return self.value < other + else: + return NotImplemented + + def __pos__(self): + """Access `value` property through unary `+` operator.""" + return self.value + + def __add__(self, other): + """Support addition between `Trait`s or `Trait` and numeric""" + if inherits_from(other, Trait): + return self.value + other.value + elif type(other) in (float, int): + return self.value + other + else: + return NotImplemented + + def __sub__(self, other): + """Support subtraction between `Trait`s or `Trait` and numeric""" + if inherits_from(other, Trait): + return self.value - other.value + elif type(other) in (float, int): + return self.value - other + else: + return NotImplemented + + def __mul__(self, other): + """Support multiplication between `Trait`s or `Trait` and numeric""" + if inherits_from(other, Trait): + return self.value * other.value + elif type(other) in (float, int): + return self.value * other + else: + return NotImplemented + + def __floordiv__(self, other): + """Support floor division between `Trait`s or `Trait` and numeric""" + if inherits_from(other, Trait): + return self.value // other.value + elif type(other) in (float, int): + return self.value // other + else: + return NotImplemented + + # commutative property + __radd__ = __add__ + __rmul__ = __mul__ + + def __rsub__(self, other): + """Support subtraction between `Trait`s or `Trait` and numeric""" + if inherits_from(other, Trait): + return other.value - self.value + elif type(other) in (float, int): + return other - self.value + else: + return NotImplemented + + def __rfloordiv__(self, other): + """Support floor division between `Trait`s or `Trait` and numeric""" + if inherits_from(other, Trait): + return other.value // self.value + elif type(other) in (float, int): + return other // self.value + else: + return NotImplemented + + # Public members + + @property + def value(self): + """Store a value""" + return self._data["value"] + + @value.setter + def value(self, value): + """Get value""" + self._data["value"] = value
+ + +# Implementation of the respective Trait types + + +
[docs]class StaticTrait(Trait): + """ + Static Trait. This is a single value with a modifier, + multiplier, and no concept of a 'current' value or min/max etc. + + value = (base + mod) * mult + + """ + + trait_type = "static" + + default_keys = {"base": 0, "mod": 0, "mult": 1.0} + + def __str__(self): + status = "{value:11}".format(value=self.value) + return "{name:12} {status} ({mod:+3}) (* {mult:.2f})".format( + name=self.name, status=status, mod=self.mod, mult=self.mult + ) + + # Helpers + @property + def base(self): + return self._data["base"] + + @base.setter + def base(self, value): + if value is None: + self._data["base"] = self.default_keys["base"] + if type(value) in (int, float): + self._data["base"] = value + + @property + def mod(self): + """The trait's modifier.""" + return self._data["mod"] + + @mod.setter + def mod(self, amount): + if type(amount) in (int, float): + self._data["mod"] = amount + + @property + def mult(self): + """The trait's multiplier.""" + return self._data["mult"] + + @mult.setter + def mult(self, amount): + if type(amount) in (int, float): + self._data["mult"] = amount + + @mult.deleter + def mult(self): + self._data["mult"] = 1.0 + + @property + def value(self): + "The value of the Trait." + return (self.base + self.mod) * self.mult
+ + +
[docs]class CounterTrait(Trait): + """ + Counter Trait. + + This includes modifications and min/max limits as well as the notion of a + current value. The value can also be reset to the base value. + + min/unset base (base+mod)*mult max/unset + |--------------|--------|---------X--------X------------| + current value + = (current + + mod) + * mult + + - value = (current + mod) * mult, starts at (base + mod) * mult + - if min or max is None, there is no upper/lower bound (default) + - if max is set to "base", max will be equal ot base+mod + - descs are used to optionally describe each value interval. + The desc of the current `value` value can then be retrieved + with .desc(). The property is set as {lower_bound_inclusive:desc} + and should be given smallest-to-biggest. For example, for + a skill rating between 0 and 10: + {0: "unskilled", + 1: "neophyte", + 5: "traited", + 7: "expert", + 9: "master"} + - rate/ratetarget are optional settings to include a rate-of-change + of the current value. This is calculated on-demand and allows for + describing a value that is gradually growing smaller/bigger. The + increase will stop when either reaching a boundary (if set) or + ratetarget. Setting the rate to 0 (default) stops any change. + + """ + + trait_type = "counter" + + # current starts equal to base. + default_keys = { + "base": 0, + "mod": 0, + "mult": 1.0, + "min": None, + "max": None, + "descs": None, + "rate": 0, + "ratetarget": None, + } + +
[docs] @staticmethod + def validate_input(cls, trait_data): + """Add extra validation for descs""" + trait_data = Trait.validate_input(cls, trait_data) + # validate descs + descs = trait_data["descs"] + if isinstance(descs, dict): + if any( + not (isinstance(key, (int, float)) and isinstance(value, str)) + for key, value in descs.items() + ): + raise TraitException( + "Trait descs must be defined on the " + f"form {{number:str}} (instead found {descs})." + ) + # set up rate + if trait_data["rate"] != 0: + trait_data["last_update"] = time() + else: + trait_data["last_update"] = None + return trait_data
+ + def __str__(self): + status = "{current:4} / {base:4}".format(current=self.current, base=self.base) + return "{name:12} {status} ({mod:+3}) (* {mult:.2f})".format( + name=self.name, status=status, mod=self.mod, mult=self.mult + ) + + # Helpers + + def _within_boundaries(self, value): + """Check if given value is within boundaries""" + return not ( + (self.min is not None and value <= self.min) + or (self.max is not None and value >= self.max) + ) + + def _enforce_boundaries(self, value): + """Ensures that incoming value falls within boundaries""" + if self.min is not None and value <= self.min: + return self.min + if self.max is not None and value >= self.max: + return self.max + return value + + # timer component + + def _passed_ratetarget(self, value): + """Check if we passed the ratetarget in either direction.""" + ratetarget = self._data["ratetarget"] + return ratetarget is not None and ( + (self.rate < 0 and value <= ratetarget) or (self.rate > 0 and value >= ratetarget) + ) + + def _stop_timer(self): + """Stop rate-timer component.""" + if self.rate != 0 and self._data["last_update"] is not None: + self._data["last_update"] = None + + def _check_and_start_timer(self, value): + """Start timer if we are not at a boundary.""" + if self.rate != 0 and self._data["last_update"] is None: + if self._within_boundaries(value) and not self._passed_ratetarget(value): + # we are not at a boundary [anymore]. + self._data["last_update"] = time() + return value + + def _update_current(self, current): + """Update current value by scaling with rate and time passed.""" + rate = self.rate + if rate != 0 and self._data["last_update"] is not None: + now = time() + tdiff = now - self._data["last_update"] + current += rate * tdiff + value = current + self.mod + + # we must make sure so we don't overstep our bounds + # even if .mod is included + + if self._passed_ratetarget(value): + current = self._data["ratetarget"] - self.mod + self._stop_timer() + elif not self._within_boundaries(value): + current = self._enforce_boundaries(value) - self.mod + self._stop_timer() + else: + self._data["last_update"] = now + + self._data["current"] = current + + if self.base is not None and isinstance(self.base, int): + return round(current) + return current + + # properties + + @property + def base(self): + return self._data["base"] + + @base.setter + def base(self, value): + if value is None: + self._data["base"] = self.default_keys["base"] + if type(value) in (int, float): + if self.min is not None and value + self.mod < self.min: + value = self.min - self.mod + if self.max is not None and value + self.mod > self.max: + value = self.max - self.mod + self._data["base"] = value + + @property + def mod(self): + return self._data["mod"] + + @mod.setter + def mod(self, value): + if value is None: + # unsetting the boundary to default + self._data["mod"] = self.default_keys["mod"] + elif type(value) in (int, float): + if self.min is not None and value + self.base < self.min: + value = self.min - self.base + if self.max is not None and value + self.base > self.max: + value = self.max - self.base + self._data["mod"] = value + + @property + def mult(self): + return self._data["mult"] + + @mult.setter + def mult(self, amount): + if type(amount) in (int, float): + self._data["mult"] = amount + + @mult.deleter + def mult(self): + self._data["mult"] = 1.0 + + @property + def min(self): + return self._data["min"] + + @min.setter + def min(self, value): + if value is None: + # unsetting the boundary + self._data["min"] = value + elif type(value) in (int, float): + if self.max is not None: + value = min(self.max, value) + self._data["min"] = min(value, self.base + self.mod) + + @property + def max(self): + return self._data["max"] + + @max.setter + def max(self, value): + if value is None: + # unsetting the boundary + self._data["max"] = value + elif type(value) in (int, float): + if self.min is not None: + value = max(self.min, value) + self._data["max"] = max(value, self.base + self.mod) + + @property + def current(self): + """The `current` value of the `Trait`. This does not have .mod added and is not .mult-iplied.""" + return self._update_current(self._data.get("current", self.base)) + + @current.setter + def current(self, value): + if type(value) in (int, float): + self._data["current"] = self._check_and_start_timer(self._enforce_boundaries(value)) + + @current.deleter + def current(self): + """reset back to base""" + self._data["current"] = self.base + + @property + def value(self): + "The value of the Trait. (current + mod) * mult" + return self._enforce_boundaries((self.current + self.mod) * self.mult) + + @property + def ratetarget(self): + return self._data["ratetarget"] + + @ratetarget.setter + def ratetarget(self, value): + self._data["ratetarget"] = self._enforce_boundaries(value) + self._check_and_start_timer(self.value) + +
[docs] def percent(self, formatting="{:3.1f}%"): + """ + Return the current value as a percentage. + + Args: + formatting (str, optional): Should contain a + format-tag which will receive the value. If + this is set to None, the raw float will be + returned. + Returns: + float or str: Depending of if a `formatting` string + is supplied or not. + """ + return percent(self.value, self.min, self.max, formatting=formatting)
+ +
[docs] def reset(self): + """Resets `current` property equal to `base` value.""" + del self.current
+ +
[docs] def desc(self): + """ + Retrieve descriptions of the current value, if available. + + This must be a mapping {upper_bound_inclusive: text}, + ordered from small to big. Any value above the highest + upper bound will be included as being in the highest bound. + rely on Python3.7+ dicts retaining ordering to let this + describe the interval. + + Returns: + str: The description describing the `value` value. + If not found, returns the empty string. + """ + descs = self._data["descs"] + if descs is None: + return "" + value = self.value + # we rely on Python3.7+ dicts retaining ordering + highest = "" + for bound, txt in descs.items(): + highest = txt + if value <= bound: + return txt + # if we get here we are above the highest bound so + # we return the latest bound specified. + return highest
+ + +
[docs]class GaugeTrait(CounterTrait): + """ + Gauge Trait. + + This emulates a gauge-meter that empties from a (base+mod) * mult value. + + min/0 max=(base+mod)*mult + |-----------------------X---------------------------| + value + = current + + - min defaults to 0 + - max value is always (base + mod) * mult + - .max is an alias of .base + - value = current and varies from min to max. + - descs is a mapping {upper_bound_inclusive: desc}. These + are checked with .desc() and can be retrieve a text + description for a given current value. + + For example, this could be used to describe health + values between 0 and 100: + {0: "Dead" + 10: "Badly hurt", + 30: "Bleeding", + 50: "Hurting", + 90: "Healthy"} + + """ + + trait_type = "gauge" + + # same as Counter, here for easy reference + # current starts out equal to base + default_keys = { + "base": 0, + "mod": 0, + "mult": 1.0, + "min": 0, + "descs": None, + "rate": 0, + "ratetarget": None, + } + + def _update_current(self, current): + """Update current value by scaling with rate and time passed.""" + rate = self.rate + if rate != 0 and self._data["last_update"] is not None: + now = time() + tdiff = now - self._data["last_update"] + current += rate * tdiff + value = current + + # we don't worry about .mod for gauges + + if self._passed_ratetarget(value): + current = self._data["ratetarget"] + self._stop_timer() + elif not self._within_boundaries(value): + current = self._enforce_boundaries(value) + self._stop_timer() + else: + self._data["last_update"] = now + + self._data["current"] = current + + if self.base is not None and isinstance(self.base, int): + return round(current) + + return current + + def _enforce_boundaries(self, value): + """Ensures that incoming value falls within trait's range.""" + if self.min is not None and value <= self.min: + return self.min + return min((self.mod + self.base) * self.mult, value) + + def __str__(self): + status = "{value:4} / {base:4}".format(value=self.value, base=self.base) + return "{name:12} {status} ({mod:+3}) (* {mult:.2f})".format( + name=self.name, status=status, mod=self.mod, mult=self.mult + ) + + @property + def base(self): + return self._data["base"] + + @base.setter + def base(self, value): + """Limit so base+mod can never go below min.""" + if type(value) in (int, float): + if value + self.mod < self.min: + value = self.min - self.mod + self._data["base"] = value + + @property + def mod(self): + return self._data["mod"] + + @mod.setter + def mod(self, value): + """Limit so base+mod can never go below min.""" + if type(value) in (int, float): + if value + self.base < self.min: + value = self.min - self.base + self._data["mod"] = value + + @property + def mult(self): + return self._data["mult"] + + @mult.setter + def mult(self, amount): + if type(amount) in (int, float): + self._data["mult"] = amount + + @mult.deleter + def mult(self): + self._data["mult"] = 1.0 + + @property + def min(self): + val = self._data["min"] + return self.default_keys["min"] if val is None else val + + @min.setter + def min(self, value): + """Limit so min can never be greater than (base+mod)*mult.""" + if value is None: + self._data["min"] = self.default_keys["min"] + elif type(value) in (int, float): + self._data["min"] = min(value, (self.base + self.mod) * self.mult) + + @property + def max(self): + "The max is always (base + mod) * mult." + return (self.base + self.mod) * self.mult + + @max.setter + def max(self, value): + raise TraitException( + "The .max property is not settable on GaugeTraits. Set .mod and .base instead." + ) + + @max.deleter + def max(self): + raise TraitException( + "The .max property cannot be reset on GaugeTraits. Reset .mod and .base instead." + ) + + @property + def current(self): + """The `current` value of the gauge.""" + return self._update_current( + self._enforce_boundaries(self._data.get("current", (self.base + self.mod) * self.mult)) + ) + + @current.setter + def current(self, value): + if type(value) in (int, float): + self._data["current"] = self._check_and_start_timer(self._enforce_boundaries(value)) + + @current.deleter + def current(self): + "Resets current back to 'full'" + self._data["current"] = (self.base + self.mod) * self.mult + + @property + def value(self): + "The value of the trait" + return self.current + +
[docs] def percent(self, formatting="{:3.1f}%"): + """ + Return the current value as a percentage. + + Args: + formatting (str, optional): Should contain a + format-tag which will receive the value. If + this is set to None, the raw float will be + returned. + Returns: + float or str: Depending of if a `formatting` string + is supplied or not. + """ + return percent(self.current, self.min, self.max, formatting=formatting)
+ +
[docs] def reset(self): + """ + Fills the gauge to its maximum allowed by base + mod + """ + del self.current
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/bodyfunctions/bodyfunctions.html b/docs/latest/_modules/evennia/contrib/tutorials/bodyfunctions/bodyfunctions.html new file mode 100644 index 0000000000..8513699d3e --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/bodyfunctions/bodyfunctions.html @@ -0,0 +1,177 @@ + + + + + + + + evennia.contrib.tutorials.bodyfunctions.bodyfunctions — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.bodyfunctions.bodyfunctions

+"""
+Script example
+
+Griatch - 2012
+
+Example script for testing. This adds a simple timer that has your
+character make observations and notices at irregular intervals.
+
+To test, use
+  script me = tutorial_examples.bodyfunctions.BodyFunctions
+
+The script will only send messages to the object it is stored on, so
+make sure to put it on yourself or you won't see any messages!
+
+"""
+import random
+
+from evennia import DefaultScript
+
+
+
[docs]class BodyFunctions(DefaultScript): + """ + This class defines the script itself + + """ + +
[docs] def at_script_creation(self): + self.key = "bodyfunction" + self.desc = "Adds various timed events to a character." + self.interval = 20 # seconds + # self.repeats = 5 # repeat only a certain number of times + self.start_delay = True # wait self.interval until first call
+ # self.persistent = True + +
[docs] def at_repeat(self): + """ + This gets called every self.interval seconds. We make + a random check here so as to only return 33% of the time. + """ + if random.random() < 0.66: + # no message this time + return + self.send_random_message()
+ +
[docs] def send_random_message(self): + rand = random.random() + # return a random message + if rand < 0.1: + string = "You tap your foot, looking around." + elif rand < 0.2: + string = "You have an itch. Hard to reach too." + elif rand < 0.3: + string = ( + "You think you hear someone behind you. ... but when you look there's noone there." + ) + elif rand < 0.4: + string = "You inspect your fingernails. Nothing to report." + elif rand < 0.5: + string = "You cough discreetly into your hand." + elif rand < 0.6: + string = "You scratch your head, looking around." + elif rand < 0.7: + string = "You blink, forgetting what it was you were going to do." + elif rand < 0.8: + string = "You feel lonely all of a sudden." + elif rand < 0.9: + string = "You get a great idea. Of course you won't tell anyone." + else: + string = "You suddenly realize how much you love Evennia!" + + # echo the message to the object + self.obj.msg(string)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/bodyfunctions/tests.html b/docs/latest/_modules/evennia/contrib/tutorials/bodyfunctions/tests.html new file mode 100644 index 0000000000..a333c04c26 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/bodyfunctions/tests.html @@ -0,0 +1,180 @@ + + + + + + + + evennia.contrib.tutorials.bodyfunctions.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.bodyfunctions.tests

+"""
+Tests for the bodyfunctions.
+
+"""
+from mock import Mock, patch
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .bodyfunctions import BodyFunctions
+
+
+
[docs]@patch("evennia.contrib.tutorials.bodyfunctions.bodyfunctions.random") +class TestBodyFunctions(BaseEvenniaTest): + script_typeclass = BodyFunctions + +
[docs] def setUp(self): + super().setUp() + self.script.obj = self.char1
+ +
[docs] def tearDown(self): + super().tearDown() + # if we forget to stop the script, DirtyReactorAggregateError will be raised + self.script.stop()
+ +
[docs] def test_at_repeat(self, mock_random): + """test that no message will be sent when below the 66% threshold""" + mock_random.random = Mock(return_value=0.5) + old_func = self.script.send_random_message + self.script.send_random_message = Mock() + self.script.at_repeat() + self.script.send_random_message.assert_not_called() + # test that random message will be sent + mock_random.random = Mock(return_value=0.7) + self.script.at_repeat() + self.script.send_random_message.assert_called() + self.script.send_random_message = old_func
+ +
[docs] def test_send_random_message(self, mock_random): + """Test that correct message is sent for each random value""" + old_func = self.char1.msg + self.char1.msg = Mock() + # test each of the values + mock_random.random = Mock(return_value=0.05) + self.script.send_random_message() + self.char1.msg.assert_called_with("You tap your foot, looking around.") + mock_random.random = Mock(return_value=0.15) + self.script.send_random_message() + self.char1.msg.assert_called_with("You have an itch. Hard to reach too.") + mock_random.random = Mock(return_value=0.25) + self.script.send_random_message() + self.char1.msg.assert_called_with( + "You think you hear someone behind you. ... " "but when you look there's noone there." + ) + mock_random.random = Mock(return_value=0.35) + self.script.send_random_message() + self.char1.msg.assert_called_with("You inspect your fingernails. Nothing to report.") + mock_random.random = Mock(return_value=0.45) + self.script.send_random_message() + self.char1.msg.assert_called_with("You cough discreetly into your hand.") + mock_random.random = Mock(return_value=0.55) + self.script.send_random_message() + self.char1.msg.assert_called_with("You scratch your head, looking around.") + mock_random.random = Mock(return_value=0.65) + self.script.send_random_message() + self.char1.msg.assert_called_with("You blink, forgetting what it was you were going to do.") + mock_random.random = Mock(return_value=0.75) + self.script.send_random_message() + self.char1.msg.assert_called_with("You feel lonely all of a sudden.") + mock_random.random = Mock(return_value=0.85) + self.script.send_random_message() + self.char1.msg.assert_called_with("You get a great idea. Of course you won't tell anyone.") + mock_random.random = Mock(return_value=0.95) + self.script.send_random_message() + self.char1.msg.assert_called_with("You suddenly realize how much you love Evennia!") + self.char1.msg = old_func
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/ai.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/ai.html new file mode 100644 index 0000000000..fd762b143e --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/ai.html @@ -0,0 +1,477 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.ai — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.ai

+"""
+NPC AI module for EvAdventure (WIP)
+
+This implements a state machine for the NPCs, where it uses inputs from the game to determine what
+to do next. The AI works on the concept of being 'ticks', at which point, the AI will decide to move
+between different 'states', performing different 'actions' within each state until changing to
+another state. The odds of changing between states and performing actions are weighted, allowing for
+an AI agent to be more or less likely to perform certain actions.
+
+The state machine is fed a dictionary of states and their transitions, and a dictionary of available
+actions to choose between.
+::
+
+    {
+        "states": {
+            "state1": {"action1": odds, "action2": odds, ...},
+            "state2": {"action1": odds, "action2": odds, ...}, ...
+        }
+        "transition": {
+            "state1": {"state2": "odds, "state3": odds, ...},
+            "state2": {"state1": "odds, "state3": odds, ...}, ...
+        }
+    }
+
+The NPC class needs to look like this:
+::
+
+    class NPC(DefaultCharacter):
+
+        # ...
+
+        @lazy_property
+        def ai(self):
+            return AIHandler(self)
+
+        def ai_roam(self, action):
+            # perform the action within the current state ai.state
+
+        def ai_hunt(self, action):
+            # etc
+
+"""
+
+import random
+
+from evennia.utils import logger
+from evennia.utils.dbserialize import deserialize
+
+# Some example AI structures
+
+EMOTIONAL_AI = {
+    # Non-combat AI that has different moods for conversations
+    "states": {
+        "neutral": {"talk_neutral": 0.9, "change_state": 0.1},
+        "happy": {"talk_happy": 0.9, "change_state": 0.1},
+        "sad": {"talk_sad": 0.9, "change_state": 0.1},
+        "angry": {"talk_angry": 0.9, "change_state": 0.1},
+    }
+}
+
+STATIC_AI = {
+    # AI that just hangs around until attacked
+    "states": {
+        "idle": {"do_nothing": 1.0},
+        "combat": {"attack": 0.9, "stunt": 0.1},
+    }
+}
+
+ROAM_AI = {
+    # AI that roams around randomly, now and then stopping.
+    "states": {
+        "idle": {"do_nothing": 0.9, "change_state": 0.1},
+        "roam": {
+            "move_north": 0.1,
+            "move_south": 0.1,
+            "move_east": 0.1,
+            "move_west": 0.1,
+            "wait": 0.4,
+            "change_state": 0.2,
+        },
+        "combat": {"attack": 0.9, "stunt": 0.05, "flee": 0.05},
+    },
+    "transitions": {
+        "idle": {"roam": 0.5, "idle": 0.5},
+        "roam": {"idle": 0.1, "roam": 0.9},
+    },
+}
+
+HUNTER_AI = {
+    "states": {
+        "hunt_roam": {
+            "move_north": 0.2,
+            "move_south": 0.2,
+            "move_east": 0.2,
+            "move_west": 0.2,
+        },
+        "hunt_track": {
+            "track_and_move": 0.9,
+            "change_state": 0.1,
+        },
+        "combat": {"attack": 0.8, "stunt": 0.1, "other": 0.1},
+    },
+    "transitions": {
+        # add a chance of the hunter losing its trail
+        "hunt_track": {"hunt_roam": 1.0},
+    },
+}
+
+
+
[docs]class AIHandler: + """ + AIHandler class. This should be placed on the NPC object, and will handle the state machine, + including transitions and actions. + + Add to typeclass with @lazyproperty: + + class NPC(DefaultCharacter): + + ai_states = {...} + + # ... + + @lazyproperty + def ai(self): + return AIHandler(self) + + """ + +
[docs] def __init__(self, obj): + self.obj = obj + + if hasattr(self, "ai_states"): + # since we're not setting `force=True` here, we won't overwrite any existing / + # customized dicts. + self.add_aidict(self.ai_states)
+ + def __str__(self): + return f"AIHandler for {self.obj}. Current state: {self.state}" + + @staticmethod + def _normalize_odds(odds): + """ + Normalize odds to 1.0. + + Args: + odds (list): List of odds to normalize. + Returns: + list: Normalized list of odds. + + """ + return [float(i) / sum(odds) for i in odds] + + @staticmethod + def _weighted_choice(choices, odds): + """ + Choose a random element from a list of choices, with odds. + + Args: + choices (list): List of choices to choose from. Unordered. + odds (list): List of odds to choose from, matching the choices list. This + can be a list of integers or floats, indicating priority. Have odds sum + up to 100 or 1.0 to properly represent predictable odds. + Returns: + object: Randomly chosen element from choices. + + """ + if choices: + return random.choices(choices, odds)[0] + + @staticmethod + def _weighted_choice_dict(choices): + """ + Choose a random element from a dictionary of choices, with odds. + + Args: + choices (dict): Dictionary of choices to choose from, with odds as values. + Returns: + object: Randomly chosen element from choices. + + """ + return AIHandler._weighted_choice(list(choices.keys()), list(choices.values())) + + @staticmethod + def _validate_ai_dict(aidict): + """ + Validate and normalize an AI dictionary. + + Args: + aidict (dict): AI dictionary to normalize. + Returns: + dict: Normalized AI dictionary. + + """ + if "states" not in aidict: + raise ValueError("AI dictionary must contain a 'states' key.") + + if "transitions" not in aidict: + aidict["transitions"] = {} + + # if we have no transitions, make sure we have a transition for each state set to 0 + for state in aidict["states"]: + if state not in aidict["transitions"]: + aidict["transitions"][state] = {} + for state2 in aidict["states"]: + if state2 not in aidict["transitions"][state]: + aidict["transitions"][state][state2] = 0.0 + + # normalize odds + for state, actions in aidict["states"].items(): + aidict["states"][state] = AIHandler._normalize_odds(list(actions.values())) + for state, transitions in aidict["transitions"].items(): + aidict["transitions"][state] = AIHandler._normalize_odds(list(transitions.values())) + + return aidict + + @property + def state(self): + """ + Return the current state of the AI. + + Returns: + str: Current state of the AI. + + """ + return self.obj.attributes.get("ai_state", category="ai", default="idle") + + @state.setter + def state(self, value): + """ + Set the current state of the AI. This allows to force a state change, e.g. when starting + combat. + + Args: + value (str): New state of the AI. + + """ + return self.obj.attributes.add("ai_state", category="ai") + + @property + def states(self): + """ + Return the states dictionary for the AI. + + Returns: + dict: States dictionary for the AI. + + """ + return self.obj.attributes.get("ai_states", category="ai", default={"idle": {}}) + + @states.setter + def states(self, value): + """ + Set the states dictionary for the AI. + + Args: + value (dict): New states dictionary for the AI. + + """ + return self.obj.attributes.add("ai_states", value, category="ai") + + @property + def transitions(self): + """ + Return the transitions dictionary for the AI. + + Returns: + dict: Transitions dictionary for the AI. + + """ + return self.obj.attributes.get("ai_transitions", category="ai", default={"idle": []}) + + @transitions.setter + def transitions(self, value): + """ + Set the transitions dictionary for the AI. + + Args: + value (dict): New transitions dictionary for the AI. This will be automatically + normalized. + + """ + for state in value.keys(): + value[state] = dict( + zip(value[state].keys(), self._normalize_odds(value[state].values())) + ) + return self.obj.attributes.add("ai_transitions", value, category="ai") + +
[docs] def add_aidict(self, aidict, force=False): + """ + Add an AI dictionary to the AI handler, if one doesn't already exist. + + Args: + aidict (dict): AI dictionary to add. + force (bool, optional): Force adding the AI dictionary, even if one already exists on + this handler. + + """ + if not force and self.states and self.transitions: + return + + aidict = self._validate_ai_dict(aidict) + self.states = aidict["states"] + self.transitions = aidict["transitions"]
+ +
[docs] def adjust_transition_probability(self, state_start, state_end, odds): + """ + Adjust the transition probability between two states. + + Args: + state_start (str): State to start from. + state_end (str): State to end at. + odds (int): New odds for the transition. + + Note: + This will normalize the odds across the other transitions from the starting state. + + """ + transitions = deserialize(self.transitions) + transitions[state_start][state_end] = odds + transitions[state_start] = dict( + zip( + transitions[state_start].keys(), + self._normalize_odds(transitions[state_start].values()), + ) + ) + self.transitions = transitions
+ +
[docs] def get_next_state(self): + """ + Get the next state for the AI. + + Returns: + str: Next state for the AI. + + """ + return self._weighted_choice_dict(self.transitions[self.state])
+ +
[docs] def get_next_action(self): + """ + Get the next action for the AI within the current state. + + Returns: + str: Next action for the AI. + + """ + return self._weighted_choice_dict(self.states[self.state])
+ +
[docs] def execute_ai(self): + """ + Execute the next ai action in the current state. + + This assumes that each available state exists as a method on the object, named + ai_<state_name>, taking an optional argument of the next action to perform. The method + will itself update the state or transition weights through this handler. + + Some states have in-built state transitions, via the special "change_state" action. + + """ + next_action = self.get_next_action() + statechange = 0 + while next_action == "change_state": + self.state = self.get_next_state() + next_action = self.get_next_action() + if statechange > 5: + logger.log_err(f"AIHandler: {self.obj} got stuck in a state-change loop.") + return + + # perform the action + try: + getattr(self.obj, f"ai_{self.state}")(next_action) + except AttributeError: + logger.log_err(f"AIHandler: {self.obj} has no ai_{self.state} method.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/characters.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/characters.html new file mode 100644 index 0000000000..fc2dc45c55 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/characters.html @@ -0,0 +1,511 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.characters — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.characters

+"""
+Character class.
+
+"""
+
+from evennia.objects.objects import DefaultCharacter
+from evennia.typeclasses.attributes import AttributeProperty
+from evennia.utils.evform import EvForm
+from evennia.utils.evtable import EvTable
+from evennia.utils.logger import log_trace
+from evennia.utils.utils import lazy_property
+
+from . import rules
+from .equipment import EquipmentError, EquipmentHandler
+from .quests import EvAdventureQuestHandler
+
+
+
[docs]class LivingMixin: + """ + Mixin class to use for all living things. + + """ + + is_pc = False + + @property + def hurt_level(self): + """ + String describing how hurt this character is. + """ + percent = max(0, min(100, 100 * (self.hp / self.hp_max))) + if 95 < percent <= 100: + return "|gPerfect|n" + elif 80 < percent <= 95: + return "|gScraped|n" + elif 60 < percent <= 80: + return "|GBruised|n" + elif 45 < percent <= 60: + return "|yHurt|n" + elif 30 < percent <= 45: + return "|yWounded|n" + elif 15 < percent <= 30: + return "|rBadly wounded|n" + elif 1 < percent <= 15: + return "|rBarely hanging on|n" + elif percent == 0: + return "|RCollapsed!|n" + +
[docs] def heal(self, hp, healer=None): + """ + Heal by a certain amount of HP. + + """ + damage = self.hp_max - self.hp + healed = min(damage, hp) + self.hp += healed + + if healer is self: + self.msg(f"|gYou heal yourself for {healed} health.|n") + elif healer: + self.msg(f"|g{healer.key} heals you for {healed} health.|n") + else: + self.msg(f"You are healed for {healed} health.")
+ +
[docs] def at_attacked(self, attacker, **kwargs): + """ + Called when being attacked / combat starts. + + """ + pass
+ +
[docs] def at_damage(self, damage, attacker=None): + """ + Called when attacked and taking damage. + + """ + self.hp -= damage
+ +
[docs] def at_defeat(self): + """ + Called when this living thing reaches HP 0. + + """ + # by default, defeat means death + self.at_death()
+ +
[docs] def at_death(self): + """ + Called when this living thing dies. + + """ + pass
+ +
[docs] def at_pay(self, amount): + """ + Get coins, but no more than we actually have. + + """ + amount = min(amount, self.coins) + self.coins -= amount + return amount
+ +
[docs] def at_looted(self, looter): + """ + Called when being looted (after defeat). + + Args: + looter (Object): The one doing the looting. + + """ + max_steal = rules.dice.roll("1d10") + stolen = self.at_pay(max_steal) + + looter.coins += stolen + + self.location.msg_contents( + f"$You(looter) loots $You() for {stolen} coins!", + from_obj=self, + mapping={"looter": looter}, + )
+ +
[docs] def pre_loot(self, defeated_enemy): + """ + Called just before looting an enemy. + + Args: + defeated_enemy (Object): The enemy soon to loot. + + Returns: + bool: If False, no looting is allowed. + + """ + pass
+ +
[docs] def at_do_loot(self, defeated_enemy): + """ + Called when looting another entity. + + Args: + defeated_enemy: The thing to loot. + + """ + defeated_enemy.at_looted(self)
+ +
[docs] def post_loot(self, defeated_enemy): + """ + Called just after having looted an enemy. + + Args: + defeated_enemy (Object): The enemy just looted. + + """ + pass
+ + +
[docs]class EvAdventureCharacter(LivingMixin, DefaultCharacter): + """ + A Character for use with EvAdventure. + + """ + + is_pc = True + + # these are the ability bonuses. Defense is always 10 higher + strength = AttributeProperty(default=1) + dexterity = AttributeProperty(default=1) + constitution = AttributeProperty(default=1) + intelligence = AttributeProperty(default=1) + wisdom = AttributeProperty(default=1) + charisma = AttributeProperty(default=1) + + hp = AttributeProperty(default=4) + hp_max = AttributeProperty(default=4) + level = AttributeProperty(default=1) + coins = AttributeProperty(default=0) # copper coins + + xp = AttributeProperty(default=0) + xp_per_level = 1000 + +
[docs] @lazy_property + def equipment(self): + """Allows to access equipment like char.equipment.worn""" + return EquipmentHandler(self)
+ +
[docs] @lazy_property + def quests(self): + """Access and track quests""" + return EvAdventureQuestHandler(self)
+ + @property + def weapon(self): + return self.equipment.weapon + + @property + def armor(self): + return self.equipment.armor + +
[docs] def at_pre_object_receive(self, moved_object, source_location, **kwargs): + """ + Hook called by Evennia before moving an object here. Return False to abort move. + + Args: + moved_object (Object): Object to move into this one (that is, into inventory). + source_location (Object): Source location moved from. + **kwargs: Passed from move operation; the `move_type` is useful; if someone is giving + us something (`move_type=='give'`) we want to ask first. + + Returns: + bool: If move should be allowed or not. + + """ + # this will raise EquipmentError if inventory is full + return self.equipment.validate_slot_usage(moved_object)
+ +
[docs] def at_object_receive(self, moved_object, source_location, **kwargs): + """ + Hook called by Evennia as an object is moved here. We make sure it's added + to the equipment handler. + + Args: + moved_object (Object): Object to move into this one (that is, into inventory). + source_location (Object): Source location moved from. + **kwargs: Passed from move operation; unused here. + + """ + try: + self.equipment.add(moved_object) + except EquipmentError as err: + log_trace(f"at_object_receive error: {err}")
+ +
[docs] def at_pre_object_leave(self, leaving_object, destination, **kwargs): + """ + Hook called when dropping an item. We don't allow to drop wielded/worn items + (need to unwield/remove them first). Return False to + + """ + return True
+ +
[docs] def at_object_leave(self, moved_object, destination, **kwargs): + """ + Called just before an object leaves from inside this object + + Args: + moved_obj (Object): The object leaving + destination (Object): Where `moved_obj` is going. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + self.equipment.remove(moved_object)
+ +
[docs] def at_defeat(self): + """ + This happens when character drops <= 0 HP. For Characters, this means rolling on + the death table. + + """ + if self.location.allow_death: + rules.dice.roll_death(self) + else: + self.location.msg_contents("|y$You() $conj(yield), beaten and out of the fight.|n") + self.hp = self.hp_max
+ +
[docs] def at_death(self): + """ + Called when character dies. + + """ + self.location.msg_contents( + "|r$You() $conj(collapse) in a heap.\nDeath embraces you ...|n", + from_obj=self, + )
+ +
[docs] def at_pre_loot(self): + """ + Called before allowing to loot. Return False to block enemy looting. + """ + # don't allow looting in pvp + return not self.location.allow_pvp
+ +
[docs] def at_looted(self, looter): + """ + Called when being looted. + + """ + pass
+ +
[docs] def add_xp(self, xp): + """ + Add new XP. + + Args: + xp (int): The amount of gained XP. + + Returns: + bool: If a new level was reached or not. + + Notes: + level 1 -> 2 = 1000 XP + level 2 -> 3 = 2000 XP etc + + """ + self.xp += xp + next_level_xp = self.level * self.xp_per_level + return self.xp >= next_level_xp
+ +
[docs] def level_up(self, *abilities): + """ + Perform the level-up action. + + Args: + *abilities (str): A set of abilities (like 'strength', 'dexterity' (normally 3) + to upgrade by 1. Max is usually +10. + Notes: + We block increases above a certain value, but we don't raise an error here, that + will need to be done earlier, when the user selects the ability to increase. + + """ + + self.level += 1 + for ability in set(abilities[:3]): + # limit to max amount allowed, each one unique + try: + # set at most to the max bonus + current_bonus = getattr(self, ability) + setattr( + self, + ability, + min(10, current_bonus + 1), + ) + except AttributeError: + pass + + # update hp + self.hp_max = max(self.max_hp + 1, rules.dice.roll(f"{self.level}d8"))
+ + +# character sheet visualization + + +_SHEET = """ + +----------------------------------------------------------------------------+ + | Name: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | + +----------------------------------------------------------------------------+ + | STR: x2xxxxx DEX: x3xxxxx CON: x4xxxxx WIS: x5xxxxx CHA: x6xxxxx | + +----------------------------------------------------------------------------+ + | HP: x7xxxxx XP: x8xxxxx Level: x9x | + +----------------------------------------------------------------------------+ + | Desc: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | + | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxBxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | + | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | + +----------------------------------------------------------------------------+ + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccc1ccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + | cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc | + +----------------------------------------------------------------------------+ + """ + + +
[docs]def get_character_sheet(character): + """ + Generate a character sheet. This is grouped in a class in order to make + it easier to override the look of the sheet. + + """ + + @staticmethod + def get(character): + """ + Generate a character sheet from the character's stats. + + """ + equipment = character.equipment.all() + # divide into chunks of max 10 length (to go into two columns) + equipment_table = EvTable( + table=[equipment[i : i + 10] for i in range(0, len(equipment), 10)] + ) + form = EvForm({"FORMCHAR": "x", "TABLECHAR": "c", "SHEET": _SHEET}) + form.map( + cells={ + 1: character.key, + 2: f"+{character.strength}({character.strength + 10})", + 3: f"+{character.dexterity}({character.dexterity + 10})", + 4: f"+{character.constitution}({character.constitution + 10})", + 5: f"+{character.wisdom}({character.wisdom + 10})", + 6: f"+{character.charisma}({character.charisma + 10})", + 7: f"{character.hp}/{character.hp_max}", + 8: character.xp, + 9: character.level, + "A": character.db.desc, + }, + tables={ + 1: equipment_table, + }, + ) + return str(form)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/chargen.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/chargen.html new file mode 100644 index 0000000000..b19ed3b5e1 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/chargen.html @@ -0,0 +1,454 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.chargen — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.chargen

+"""
+EvAdventure character generation.
+
+"""
+from django.conf import settings
+
+from evennia.objects.models import ObjectDB
+from evennia.prototypes.spawner import spawn
+from evennia.utils.create import create_object
+from evennia.utils.evmenu import EvMenu
+
+from .characters import EvAdventureCharacter
+from .random_tables import chargen_tables
+from .rules import dice
+
+_ABILITIES = {
+    "STR": "strength",
+    "DEX": "dexterity",
+    "CON": "constitution",
+    "INT": "intelligence",
+    "WIS": "wisdom",
+    "CHA": "charisma",
+}
+
+_TEMP_SHEET = """
+{name}
+
+STR +{strength}
+DEX +{dexterity}
+CON +{constitution}
+INT +{intelligence}
+WIS +{wisdom}
+CHA +{charisma}
+
+{description}
+
+Your belongings:
+{equipment}
+"""
+
+
+
[docs]class TemporaryCharacterSheet: + """ + This collects all the rules for generating a new character. An instance of this class is used + to pass around the current character state during character generation and also applied to + the character at the end. This class instance can also be saved on the menu to make sure a user + is not losing their half-created character. + + Note: + In standard Knave, the character's attribute bonus is rolled randomly and will give a + value 1-6; and there is no guarantee for 'equal' starting characters. + + Knave uses a d8 roll to get the initial hit points. We will follow the recommendation + from the rule that we will use a minimum of 5 HP. + + We *will* roll random start equipment though. Contrary to standard Knave, we'll also + randomly assign the starting weapon among a small selection of equal-dmg weapons (since + there is no GM to adjudicate a different choice). + + """ + + def _random_ability(self): + return min(dice.roll("1d6"), dice.roll("1d6"), dice.roll("1d6")) + +
[docs] def __init__(self): + # name will likely be modified later + self.name = dice.roll_random_table("1d282", chargen_tables["name"]) + + # base attribute values + self.strength = self._random_ability() + self.dexterity = self._random_ability() + self.constitution = self._random_ability() + self.intelligence = self._random_ability() + self.wisdom = self._random_ability() + self.charisma = self._random_ability() + + # physical attributes (only for rp purposes) + physique = dice.roll_random_table("1d20", chargen_tables["physique"]) + face = dice.roll_random_table("1d20", chargen_tables["face"]) + skin = dice.roll_random_table("1d20", chargen_tables["skin"]) + hair = dice.roll_random_table("1d20", chargen_tables["hair"]) + clothing = dice.roll_random_table("1d20", chargen_tables["clothing"]) + speech = dice.roll_random_table("1d20", chargen_tables["speech"]) + virtue = dice.roll_random_table("1d20", chargen_tables["virtue"]) + vice = dice.roll_random_table("1d20", chargen_tables["vice"]) + background = dice.roll_random_table("1d20", chargen_tables["background"]) + misfortune = dice.roll_random_table("1d20", chargen_tables["misfortune"]) + alignment = dice.roll_random_table("1d20", chargen_tables["alignment"]) + + self.ability_changes = 0 + self.desc = ( + f"You are {physique} with a {face} face, {skin} skin, {hair} hair, {speech} speech, and" + f" {clothing} clothing. You were a {background.title()}, but you were {misfortune} and" + f" ended up a knave. You are {virtue} but also {vice}. You tend towards {alignment}." + ) + + # same for all + self.hp_max = max(5, dice.roll("1d8")) + self.hp = self.hp_max + + # random equipment + self.armor = dice.roll_random_table("1d20", chargen_tables["armor"]) + + _helmet_and_shield = dice.roll_random_table("1d20", chargen_tables["helmets and shields"]) + self.helmet = "helmet" if "helmet" in _helmet_and_shield else "none" + self.shield = "shield" if "shield" in _helmet_and_shield else "none" + + self.weapon = dice.roll_random_table("1d20", chargen_tables["starting weapon"]) + + self.backpack = [ + "ration", + "ration", + dice.roll_random_table("1d20", chargen_tables["dungeoning gear"]), + dice.roll_random_table("1d20", chargen_tables["dungeoning gear"]), + dice.roll_random_table("1d20", chargen_tables["general gear 1"]), + dice.roll_random_table("1d20", chargen_tables["general gear 2"]), + ]
+ +
[docs] def show_sheet(self): + """ + Show a temp character sheet, a compressed version of the real thing. + + """ + equipment = ( + str(item) + for item in [self.armor, self.helmet, self.shield, self.weapon] + self.backpack + if item + ) + + return _TEMP_SHEET.format( + name=self.name, + strength=self.strength, + dexterity=self.dexterity, + constitution=self.constitution, + intelligence=self.intelligence, + wisdom=self.wisdom, + charisma=self.charisma, + description=self.desc, + equipment=", ".join(equipment), + )
+ +
[docs] def apply(self, account): + """ + Once the chargen is complete, call this create and set up the character. + + """ + + start_location = ObjectDB.objects.get_id(settings.START_LOCATION) + default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME) + permissions = settings.PERMISSION_ACCOUNT_DEFAULT + # creating character with given abilities + new_character = create_object( + EvAdventureCharacter, + key=self.name, + location=start_location, + home=default_home, + permissions=permissions, + attributes=( + ("strength", self.strength), + ("dexterity", self.dexterity), + ("constitution", self.constitution), + ("intelligence", self.intelligence), + ("wisdom", self.wisdom), + ("charisma", self.wisdom), + ("hp", self.hp), + ("hp_max", self.hp_max), + ("desc", self.desc), + ), + ) + + new_character.locks.add( + "puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer);delete:id(%i) or" + " perm(Admin)" % (new_character.id, account.id, account.id) + ) + # spawn equipment + if self.weapon: + weapon = spawn(self.weapon) + new_character.equipment.move(weapon[0]) + if self.armor: + armor = spawn(self.armor) + new_character.equipment.move(armor[0]) + if self.shield: + shield = spawn(self.shield) + new_character.equipment.move(shield[0]) + if self.helmet: + helmet = spawn(self.helmet) + new_character.equipment.move(helmet[0]) + + for item in self.backpack: + item = spawn(item) + new_character.equipment.move(item[0]) + + return new_character
+ + +# chargen menu + + +
[docs]def node_chargen(caller, raw_string, **kwargs): + """ + This node is the central point of chargen. We return here to see our current + sheet and break off to edit different parts of it. + + In Knave, not so much can be changed. + """ + tmp_character = kwargs["tmp_character"] + + text = tmp_character.show_sheet() + + options = [{"desc": "Change your name", "goto": ("node_change_name", kwargs)}] + if tmp_character.ability_changes <= 0: + options.append( + { + "desc": "Swap two of your ability scores (once)", + "goto": ("node_swap_abilities", kwargs), + } + ) + options.append( + {"desc": "Accept and create character", "goto": ("node_apply_character", kwargs)}, + ) + + return text, options
+ + +def _update_name(caller, raw_string, **kwargs): + """ + Used by node_change_name below to check what user entered and update the name if appropriate. + + """ + if raw_string: + tmp_character = kwargs["tmp_character"] + tmp_character.name = raw_string.lower().capitalize() + + return "node_chargen", kwargs + + +
[docs]def node_change_name(caller, raw_string, **kwargs): + """ + Change the random name of the character. + + """ + tmp_character = kwargs["tmp_character"] + + text = ( + f"Your current name is |w{tmp_character.name}|n. Enter a new name or leave empty to abort." + ) + + options = {"key": "_default", "goto": (_update_name, kwargs)} + + return text, options
+ + +def _swap_abilities(caller, raw_string, **kwargs): + """ + Used by node_swap_abilities to parse the user's input and swap ability + values. + + """ + if raw_string: + abi1, *abi2 = raw_string.split(" ", 1) + if not abi2: + caller.msg("That doesn't look right.") + return None, kwargs + abi2 = abi2[0] + abi1, abi2 = abi1.upper().strip(), abi2.upper().strip() + if abi1 not in _ABILITIES or abi2 not in _ABILITIES: + caller.msg("Not a familiar set of abilites.") + return None, kwargs + + # looks okay = swap values. We need to convert STR to strength etc + tmp_character = kwargs["tmp_character"] + abi1 = _ABILITIES[abi1] + abi2 = _ABILITIES[abi2] + abival1 = getattr(tmp_character, abi1) + abival2 = getattr(tmp_character, abi2) + + setattr(tmp_character, abi1, abival2) + setattr(tmp_character, abi2, abival1) + + tmp_character.ability_changes += 1 + + return "node_chargen", kwargs + + +
[docs]def node_swap_abilities(caller, raw_string, **kwargs): + """ + One is allowed to swap the values of two abilities around, once. + + """ + tmp_character = kwargs["tmp_character"] + + text = f""" +Your current abilities: + +STR +{tmp_character.strength} +DEX +{tmp_character.dexterity} +CON +{tmp_character.constitution} +INT +{tmp_character.intelligence} +WIS +{tmp_character.wisdom} +CHA +{tmp_character.charisma} + +You can swap the values of two abilities around. +You can only do this once, so choose carefully! + +To swap the values of e.g. STR and INT, write |wSTR INT|n. Empty to abort. +""" + + options = {"key": "_default", "goto": (_swap_abilities, kwargs)} + + return text, options
+ + +
[docs]def node_apply_character(caller, raw_string, **kwargs): + """ + End chargen and create the character. We will also puppet it. + + """ + tmp_character = kwargs["tmp_character"] + new_character = tmp_character.apply(caller) + caller.characters.add(new_character) + + text = "Character created!" + + return text, None
+ + +
[docs]def start_chargen(caller, session=None): + """ + This is a start point for spinning up the chargen from a command later. + + """ + + menutree = { + "node_chargen": node_chargen, + "node_change_name": node_change_name, + "node_swap_abilities": node_swap_abilities, + "node_apply_character": node_apply_character, + } + + # this generates all random components of the character + tmp_character = TemporaryCharacterSheet() + + EvMenu( + caller, + menutree, + startnode="node_chargen", + session=session, + startnode_input=("sgsg", {"tmp_character": tmp_character}), + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_base.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_base.html new file mode 100644 index 0000000000..72d598d5d9 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_base.html @@ -0,0 +1,613 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.combat_base — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.combat_base

+"""
+EvAdventure Base combat utilities.
+
+This establishes the basic building blocks for combat:
+
+- `CombatFailure` - exception for combat-specific errors.
+- `CombatAction` (and subclasses) - classes encompassing all the working around an action.
+  They are initialized from 'action-dicts` - dictionaries with all the relevant data for the
+  particular invocation
+- `CombatHandler` - base class for running a combat. Exactly how this is used depends on the
+  type of combat intended (twitch- or turn-based) so many details of this will be implemented
+  in child classes.
+
+----
+
+"""
+
+from evennia.scripts.scripts import DefaultScript
+from evennia.typeclasses.attributes import AttributeProperty
+from evennia.utils import evtable
+from evennia.utils.create import create_script
+
+from . import rules
+
+
+
[docs]class CombatFailure(RuntimeError): + """ + Some failure during combat actions. + + """
+ + +
[docs]class CombatAction: + """ + Parent class for all actions. + + This represents the executable code to run to perform an action. It is initialized from an + 'action-dict', a set of properties stored in the action queue by each combatant. + + """ + +
[docs] def __init__(self, combathandler, combatant, action_dict): + """ + Each key-value pair in the action-dict is stored as a property on this class + for later access. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing + the action. + action_dict (dict): A dict containing all properties to initialize on this + class. This should not be any keys with `_` prefix, since these are + used internally by the class. + + """ + self.combathandler = combathandler + self.combatant = combatant + + # store the action dicts' keys as properties accessible as e.g. action.target etc + for key, val in action_dict.items(): + if not key.startswith("_"): + setattr(self, key, val)
+ +
[docs] def msg(self, message, broadcast=True): + """ + Convenience route to the combathandler msg-sender mechanism. + + Args: + message (str): Message to send; use `$You()` and `$You(other.key)` to refer to + the combatant doing the action and other combatants, respectively. + + """ + self.combathandler.msg(message, combatant=self.combatant, broadcast=broadcast)
+ +
[docs] def can_use(self): + """ + Called to determine if the action is usable with the current settings. This does not + actually perform the action. + + Returns: + bool: If this action can be used at this time. + + """ + return True
+ +
[docs] def execute(self): + """ + Perform the action as the combatant. Should normally make use of the properties + stored on the class during initialization. + + """ + pass
+ +
[docs] def post_execute(self): + """ + Called after execution. + """ + pass
+ + +
[docs]class CombatActionHold(CombatAction): + """ + Action that does nothing. + :: + action_dict = { + "key": "hold" + } + """
+ + +
[docs]class CombatActionAttack(CombatAction): + """ + A regular attack, using a wielded weapon. + :: + action-dict = { + "key": "attack", + "target": Character/Object + } + """ + +
[docs] def execute(self): + attacker = self.combatant + weapon = attacker.weapon + target = self.target + + if weapon.at_pre_use(attacker, target): + weapon.use( + attacker, target, advantage=self.combathandler.has_advantage(attacker, target) + ) + weapon.at_post_use(attacker, target)
+ + +
[docs]class CombatActionStunt(CombatAction): + """ + Perform a stunt the grants a beneficiary (can be self) advantage on their next action against a + target. Whenever performing a stunt that would affect another negatively (giving them + disadvantage against an ally, or granting an advantage against them, we need to make a check + first. We don't do a check if giving an advantage to an ally or ourselves. + :: + action_dict = { + "key": "stunt", + "recipient": Character/NPC, + "target": Character/NPC, + "advantage": bool, # if False, it's a disadvantage + "stunt_type": Ability, # what ability (like STR, DEX etc) to use to perform this stunt. + "defense_type": Ability, # what ability to use to defend against (negative) effects of + this stunt. + } + + """ + +
[docs] def execute(self): + combathandler = self.combathandler + attacker = self.combatant + recipient = self.recipient # the one to receive the effect of the stunt + target = self.target # the affected by the stunt (can be the same as recipient/combatant) + txt = "" + + if recipient == target: + # grant another entity dis/advantage against themselves + defender = recipient + else: + # recipient not same as target; who will defend depends on disadvantage or advantage + # to give. + defender = target if self.advantage else recipient + + # trying to give advantage to recipient against target. Target defends against caller + is_success, _, txt = rules.dice.opposed_saving_throw( + attacker, + defender, + attack_type=self.stunt_type, + defense_type=self.defense_type, + advantage=combathandler.has_advantage(attacker, defender), + disadvantage=combathandler.has_disadvantage(attacker, defender), + ) + + self.msg(f"$You() $conj(attempt) stunt on $You({defender.key}). {txt}") + + # deal with results + if is_success: + if self.advantage: + combathandler.give_advantage(recipient, target) + else: + combathandler.give_disadvantage(recipient, target) + if recipient == self.combatant: + self.msg( + f"$You() $conj(gain) {'advantage' if self.advantage else 'disadvantage'} " + f"against $You({target.key})!" + ) + else: + self.msg( + f"$You() $conj(cause) $You({recipient.key}) " + f"to gain {'advantage' if self.advantage else 'disadvantage'} " + f"against $You({target.key})!" + ) + else: + self.msg(f"$You({defender.key}) $conj(resist)! $You() $conj(fail) the stunt.")
+ + +
[docs]class CombatActionUseItem(CombatAction): + """ + Use an item in combat. This is meant for one-off or limited-use items (so things like + scrolls and potions, not swords and shields). If this is some sort of weapon or spell rune, + we refer to the item to determine what to use for attack/defense rolls. + :: + action_dict = { + "key": "use", + "item": Object + "target": Character/NPC/Object/None + } + """ + +
[docs] def execute(self): + item = self.item + user = self.combatant + target = self.target + + if item.at_pre_use(user, target): + item.use( + user, + target, + advantage=self.combathandler.has_advantage(user, target), + disadvantage=self.combathandler.has_disadvantage(user, target), + ) + item.at_post_use(user, target)
+ + +
[docs]class CombatActionWield(CombatAction): + """ + Wield a new weapon (or spell) from your inventory. This will swap out the one you are currently + wielding, if any. + :: + action_dict = { + "key": "wield", + "item": Object + } + """ + +
[docs] def execute(self): + self.combatant.equipment.move(self.item) + self.msg(f"$You() $conj(wield) $You({self.item.key}).")
+ + +# main combathandler + + +
[docs]class EvAdventureCombatBaseHandler(DefaultScript): + """ + This script is created when a combat starts. It 'ticks' the combat and tracks + all sides of it. + + """ + + # available actions in combat + action_classes = { + "hold": CombatActionHold, + "attack": CombatActionAttack, + "stunt": CombatActionStunt, + "use": CombatActionUseItem, + "wield": CombatActionWield, + } + + # fallback action if not selecting anything + fallback_action_dict = AttributeProperty({"key": "hold"}, autocreate=False) + +
[docs] @classmethod + def get_or_create_combathandler(cls, obj, **kwargs): + """ + Get or create a combathandler on `obj`. + + Args: + obj (any): The Typeclassed entity to store the CombatHandler Script on. This could be + a location (for turn-based combat) or a Character (for twitch-based combat). + Keyword Args: + combathandler_key (str): They key name for the script. Will be 'combathandler' by + default. + **kwargs: Arguments to the Script, if it is created. + + """ + if not obj: + raise CombatFailure("Cannot start combat without a place to do it!") + + combathandler_key = kwargs.pop("key", "combathandler") + combathandler = obj.ndb.combathandler + if not combathandler or not combathandler.id: + combathandler = obj.scripts.get(combathandler_key).first() + if not combathandler: + # have to create from scratch + persistent = kwargs.pop("persistent", True) + combathandler = create_script( + cls, + key=combathandler_key, + obj=obj, + persistent=persistent, + autostart=False, + **kwargs, + ) + obj.ndb.combathandler = combathandler + return combathandler
+ +
[docs] def msg(self, message, combatant=None, broadcast=True, location=None): + """ + Central place for sending messages to combatants. This allows + for adding any combat-specific text-decoration in one place. + + Args: + message (str): The message to send. + combatant (Object): The 'You' in the message, if any. + broadcast (bool): If `False`, `combatant` must be included and + will be the only one to see the message. If `True`, send to + everyone in the location. + location (Object, optional): If given, use this as the location to + send broadcast messages to. If not, use `self.obj` as that + location. + + Notes: + If `combatant` is given, use `$You/you()` markup to create + a message that looks different depending on who sees it. Use + `$You(combatant_key)` to refer to other combatants. + + """ + if not location: + location = self.obj + + location_objs = location.contents + + exclude = [] + if not broadcast and combatant: + exclude = [obj for obj in location_objs if obj is not combatant] + + location.msg_contents( + message, + exclude=exclude, + from_obj=combatant, + mapping={locobj.key: locobj for locobj in location_objs}, + )
+ +
[docs] def get_combat_summary(self, combatant): + """ + Get a 'battle report' - an overview of the current state of combat from the perspective + of one of the sides. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): The combatant to get. + + Returns: + EvTable: A table representing the current state of combat. + + Example: + :: + + Goblin shaman (Perfect) + Gregor (Hurt) Goblin brawler(Hurt) + Bob (Perfect) vs Goblin grunt 1 (Hurt) + Goblin grunt 2 (Perfect) + Goblin grunt 3 (Wounded) + + """ + allies, enemies = self.get_sides(combatant) + nallies, nenemies = len(allies), len(enemies) + + # prepare colors and hurt-levels + allies = [f"{ally} ({ally.hurt_level})" for ally in allies] + enemies = [f"{enemy} ({enemy.hurt_level})" for enemy in enemies] + + # the center column with the 'vs' + vs_column = ["" for _ in range(max(nallies, nenemies))] + vs_column[len(vs_column) // 2] = "|wvs|n" + + # the two allies / enemies columns should be centered vertically + diff = abs(nallies - nenemies) + top_empty = diff // 2 + bot_empty = diff - top_empty + topfill = ["" for _ in range(top_empty)] + botfill = ["" for _ in range(bot_empty)] + + if nallies >= nenemies: + enemies = topfill + enemies + botfill + else: + allies = topfill + allies + botfill + + # make a table with three columns + return evtable.EvTable( + table=[ + evtable.EvColumn(*allies, align="l"), + evtable.EvColumn(*vs_column, align="c"), + evtable.EvColumn(*enemies, align="r"), + ], + border=None, + maxwidth=78, + )
+ +
[docs] def get_sides(self, combatant): + """ + Get a listing of the two 'sides' of this combat, from the perspective of the provided + combatant. The sides don't need to be balanced. + + Args: + combatant (Character or NPC): The one whose sides are to determined. + + Returns: + tuple: A tuple of lists `(allies, enemies)`, from the perspective of `combatant`. + + Note: + The sides are found by checking PCs vs NPCs. PCs can normally not attack other PCs, so + are naturally allies. If the current room has the `allow_pvp` Attribute set, then _all_ + other combatants (PCs and NPCs alike) are considered valid enemies (one could expand + this with group mechanics). + + """ + raise NotImplementedError
+ +
[docs] def give_advantage(self, recipient, target): + """ + Let a benefiter gain advantage against the target. + + Args: + recipient (Character or NPC): The one to gain the advantage. This may or may not + be the same entity that creates the advantage in the first place. + target (Character or NPC): The one against which the target gains advantage. This + could (in principle) be the same as the benefiter (e.g. gaining advantage on + some future boost) + + """ + raise NotImplementedError
+ +
[docs] def give_disadvantage(self, recipient, target): + """ + Let an affected party gain disadvantage against a target. + + Args: + recipient (Character or NPC): The one to get the disadvantage. + target (Character or NPC): The one against which the target gains disadvantage, usually + an enemy. + + """ + raise NotImplementedError
+ +
[docs] def has_advantage(self, combatant, target): + """ + Check if a given combatant has advantage against a target. + + Args: + combatant (Character or NPC): The one to check if they have advantage + target (Character or NPC): The target to check advantage against. + + """ + raise NotImplementedError
+ +
[docs] def has_disadvantage(self, combatant, target): + """ + Check if a given combatant has disadvantage against a target. + + Args: + combatant (Character or NPC): The one to check if they have disadvantage + target (Character or NPC): The target to check disadvantage against. + + """ + raise NotImplementedError
+ +
[docs] def queue_action(self, action_dict, combatant=None): + """ + Queue an action by adding the new actiondict. + + Args: + action_dict (dict): A dict describing the action class by name along with properties. + combatant (EvAdventureCharacter, EvAdventureNPC, optional): A combatant queueing the + action. + + """ + raise NotImplementedError
+ +
[docs] def execute_next_action(self, combatant): + """ + Perform a combatant's next action. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing and action. + + + """ + raise NotImplementedError
+ +
[docs] def start_combat(self): + """ + Start combat. + + """ + raise NotImplementedError
+ +
[docs] def check_stop_combat(self): + """ + Check if this combat should be aborted, whatever this means for the particular + the particular combat type. + + Keyword Args: + kwargs: Any extra keyword args used. + + Returns: + bool: If `True`, the `stop_combat` method should be called. + + """ + raise NotImplementedError
+ +
[docs] def stop_combat(self): + """ + Stop combat. This should also do all cleanup. + """ + raise NotImplementedError
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_turnbased.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_turnbased.html new file mode 100644 index 0000000000..4760b81203 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_turnbased.html @@ -0,0 +1,952 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.combat_turnbased — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.combat_turnbased

+"""
+EvAdventure Turn-based combat
+
+This implements a turn-based (Final Fantasy, etc) style of MUD combat.
+
+In this variation, all combatants are sharing the same combat handler, sitting on the current room.
+The user will receive a menu of combat options and each combatat has a certain time time (e.g. 30s)
+to select their next action or do nothing. To speed up play, as soon as everyone in combat selected
+their next action, the next turn runs immediately, regardless of the timeout.
+
+With this example, all chosen combat actions are considered to happen at the same time (so you are
+able to kill and be killed in the same turn).
+
+Unlike in twitch-like combat, there is no movement while in turn-based combat. Fleeing is a select
+action that takes several vulnerable turns to complete.
+
+----
+
+"""
+
+
+import random
+from collections import defaultdict
+
+from evennia import AttributeProperty, CmdSet, Command, EvMenu
+from evennia.utils import inherits_from, list_to_string
+
+from .characters import EvAdventureCharacter
+from .combat_base import (
+    CombatAction,
+    CombatActionAttack,
+    CombatActionHold,
+    CombatActionStunt,
+    CombatActionUseItem,
+    CombatActionWield,
+    EvAdventureCombatBaseHandler,
+)
+from .enums import Ability
+
+
+# turnbased-combat needs the flee action too
+
[docs]class CombatActionFlee(CombatAction): + """ + Start (or continue) fleeing/disengaging from combat. + + action_dict = { + "key": "flee", + } + + Note: + Refer to as 'flee'. + + """ + +
[docs] def execute(self): + combathandler = self.combathandler + + if self.combatant not in combathandler.fleeing_combatants: + # we record the turn on which we started fleeing + combathandler.fleeing_combatants[self.combatant] = self.combathandler.turn + + # show how many turns until successful flight + current_turn = combathandler.turn + started_fleeing = combathandler.fleeing_combatants[self.combatant] + flee_timeout = combathandler.flee_timeout + time_left = flee_timeout - (current_turn - started_fleeing) - 1 + + if time_left > 0: + self.msg( + "$You() $conj(retreat), being exposed to attack while doing so (will escape in " + f"{time_left} $pluralize(turn, {time_left}))." + )
+ + +
[docs]class EvAdventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler): + """ + A version of the combathandler, handling turn-based combat. + + """ + + # available actions in combat + action_classes = { + "hold": CombatActionHold, + "attack": CombatActionAttack, + "stunt": CombatActionStunt, + "use": CombatActionUseItem, + "wield": CombatActionWield, + "flee": CombatActionFlee, + } + + # how many turns you must be fleeing before escaping + flee_timeout = AttributeProperty(1, autocreate=False) + + # fallback action if not selecting anything + fallback_action_dict = AttributeProperty({"key": "hold"}, autocreate=False) + + # persistent storage + + turn = AttributeProperty(0) + # who is involved in combat, and their queued action + # as {combatant: actiondict, ...} + combatants = AttributeProperty(dict) + + # who has advantage against whom + advantage_matrix = AttributeProperty(defaultdict(dict)) + disadvantage_matrix = AttributeProperty(defaultdict(dict)) + + fleeing_combatants = AttributeProperty(dict) + defeated_combatants = AttributeProperty(list) + + # usable script properties + # .is_active - show if timer is running + +
[docs] def give_advantage(self, combatant, target): + """ + Let a benefiter gain advantage against the target. + + Args: + combatant (Character or NPC): The one to gain the advantage. This may or may not + be the same entity that creates the advantage in the first place. + target (Character or NPC): The one against which the target gains advantage. This + could (in principle) be the same as the benefiter (e.g. gaining advantage on + some future boost) + + """ + self.advantage_matrix[combatant][target] = True
+ +
[docs] def give_disadvantage(self, combatant, target, **kwargs): + """ + Let an affected party gain disadvantage against a target. + + Args: + recipient (Character or NPC): The one to get the disadvantage. + target (Character or NPC): The one against which the target gains disadvantage, usually + an enemy. + + """ + self.disadvantage_matrix[combatant][target] = True
+ +
[docs] def has_advantage(self, combatant, target, **kwargs): + """ + Check if a given combatant has advantage against a target. + + Args: + combatant (Character or NPC): The one to check if they have advantage + target (Character or NPC): The target to check advantage against. + + """ + return target in self.fleeing_combatants or bool( + self.advantage_matrix[combatant].pop(target, False) + )
+ +
[docs] def has_disadvantage(self, combatant, target): + """ + Check if a given combatant has disadvantage against a target. + + Args: + combatant (Character or NPC): The one to check if they have disadvantage + target (Character or NPC): The target to check disadvantage against. + + """ + return bool(self.disadvantage_matrix[combatant].pop(target, False))
+ +
[docs] def add_combatant(self, combatant): + """ + Add a new combatant to the battle. Can be called multiple times safely. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): Any number of combatants to add to + the combat. + Returns: + bool: If this combatant was newly added or not (it was already in combat). + + """ + if combatant not in self.combatants: + self.combatants[combatant] = self.fallback_action_dict + return True + return False
+ +
[docs] def remove_combatant(self, combatant): + """ + Remove a combatant from the battle. This removes their queue. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): A combatant to add to + the combat. + + """ + self.combatants.pop(combatant, None) + # clean up menu if it exists + if combatant.ndb._evmenu: + combatant.ndb._evmenu.close_menu()
+ +
[docs] def start_combat(self, **kwargs): + """ + This actually starts the combat. It's safe to run this multiple times + since it will only start combat if it isn't already running. + + """ + if not self.is_active: + self.start(**kwargs)
+ +
[docs] def stop_combat(self): + """ + Stop the combat immediately. + + """ + for combatant in self.combatants: + self.remove_combatant(combatant) + self.stop() + self.delete()
+ +
[docs] def get_combat_summary(self, combatant): + """Add your next queued action to summary""" + summary = super().get_combat_summary(combatant) + next_action = self.get_next_action_dict(combatant) or {"key": "hold"} + next_repeat = self.time_until_next_repeat() + + summary = ( + f"{summary}\n Your queued action: [|b{next_action['key']}|n] (|b{next_repeat}s|n until" + " next round,\n or until all combatants have chosen their next action)." + ) + return summary
+ +
[docs] def get_sides(self, combatant): + """ + Get a listing of the two 'sides' of this combat, from the perspective of the provided + combatant. The sides don't need to be balanced. + + Args: + combatant (Character or NPC): The one whose sides are to determined. + + Returns: + tuple: A tuple of lists `(allies, enemies)`, from the perspective of `combatant`. + + Note: + The sides are found by checking PCs vs NPCs. PCs can normally not attack other PCs, so + are naturally allies. If the current room has the `allow_pvp` Attribute set, then _all_ + other combatants (PCs and NPCs alike) are considered valid enemies (one could expand + this with group mechanics). + + """ + if self.obj.allow_pvp: + # in pvp, everyone else is an ememy + allies = [combatant] + enemies = [comb for comb in self.combatants if comb != combatant] + else: + # otherwise, enemies/allies depend on who combatant is + pcs = [comb for comb in self.combatants if inherits_from(comb, EvAdventureCharacter)] + npcs = [comb for comb in self.combatants if comb not in pcs] + if combatant in pcs: + # combatant is a PC, so NPCs are all enemies + allies = pcs + enemies = npcs + else: + # combatant is an NPC, so PCs are all enemies + allies = npcs + enemies = pcs + return allies, enemies
+ +
[docs] def queue_action(self, combatant, action_dict): + """ + Queue an action by adding the new actiondict. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): A combatant queueing the action. + action_dict (dict): A dict describing the action class by name along with properties. + + """ + self.combatants[combatant] = action_dict + + # track who inserted actions this turn (non-persistent) + did_action = set(self.ndb.did_action or set()) + did_action.add(combatant) + if len(did_action) >= len(self.combatants): + # everyone has inserted an action. Start next turn without waiting! + self.force_repeat()
+ +
[docs] def get_next_action_dict(self, combatant): + """ + Give the action_dict for the next action that will be executed. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): The combatant to get the action for. + + Returns: + dict: The next action-dict in the queue. + + """ + return self.combatants.get(combatant, self.fallback_action_dict)
+ +
[docs] def execute_next_action(self, combatant): + """ + Perform a combatant's next queued action. Note that there is _always_ an action queued, + even if this action is 'hold', which means the combatant will do nothing. + + Args: + combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing and action. + + + """ + # this gets the next dict and rotates the queue + action_dict = self.combatants.get(combatant, self.fallback_action_dict) + + # use the action-dict to select and create an action from an action class + action_class = self.action_classes[action_dict["key"]] + action = action_class(self, combatant, action_dict) + + action.execute() + action.post_execute() + + if action_dict.get("repeat", False): + # queue the action again *without updating the *.ndb.did_action list* (otherwise + # we'd always auto-end the turn if everyone used repeating actions and there'd be + # no time to change it before the next round) + self.combatants[combatant] = action_dict + else: + # if not a repeat, set the fallback action + self.combatants[combatant] = self.fallback_action_dict
+ +
[docs] def check_stop_combat(self): + """Check if it's time to stop combat""" + + # check if anyone is defeated + for combatant in list(self.combatants.keys()): + if combatant.hp <= 0: + # PCs roll on the death table here, NPCs die. Even if PCs survive, they + # are still out of the fight. + combatant.at_defeat() + self.combatants.pop(combatant) + self.defeated_combatants.append(combatant) + self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant) + + # check if anyone managed to flee + flee_timeout = self.flee_timeout + for combatant, started_fleeing in self.fleeing_combatants.items(): + if self.turn - started_fleeing >= flee_timeout - 1: + # if they are still alive/fleeing and have been fleeing long enough, escape + self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant) + self.remove_combatant(combatant) + + # check if one side won the battle + if not self.combatants: + # noone left in combat - maybe they killed each other or all fled + surviving_combatant = None + allies, enemies = (), () + else: + # grab a random survivor and check if they have any living enemies. + surviving_combatant = random.choice(list(self.combatants.keys())) + allies, enemies = self.get_sides(surviving_combatant) + + if not enemies: + # if one way or another, there are no more enemies to fight + still_standing = list_to_string(f"$You({comb.key})" for comb in allies) + knocked_out = list_to_string(comb for comb in self.defeated_combatants if comb.hp > 0) + killed = list_to_string(comb for comb in self.defeated_combatants if comb.hp <= 0) + + if still_standing: + txt = [f"The combat is over. {still_standing} are still standing."] + else: + txt = ["The combat is over. No-one stands as the victor."] + if knocked_out: + txt.append(f"{knocked_out} were taken down, but will live.") + if killed: + txt.append(f"{killed} were killed.") + self.msg(txt) + self.stop_combat()
+ +
[docs] def at_repeat(self): + """ + This method is called every time Script repeats (every `interval` seconds). Performs a full + turn of combat, performing everyone's actions in random order. + + """ + self.turn += 1 + # random turn order + combatants = list(self.combatants.keys()) + random.shuffle(combatants) # shuffles in place + + # do everyone's next queued combat action + for combatant in combatants: + self.execute_next_action(combatant) + + self.ndb.did_action = set() + + # check if one side won the battle + self.check_stop_combat()
+ + +# ----------------------------------------------------------------------------------- +# +# Turn-based combat (Final Fantasy style), using a menu +# +# Activate by adding the CmdTurnCombat command to Character cmdset, then +# use it to attack a target. +# +# ----------------------------------------------------------------------------------- + + +def _get_combathandler(caller, turn_timeout=30, flee_time=3, combathandler_key="combathandler"): + """ + Get the combat handler for the caller's location. If it doesn't exist, create it. + + Args: + caller (EvAdventureCharacter or EvAdventureNPC): The character/NPC to get the + combat handler for. + turn_timeout (int): After this time, the turn will roll around. + flee_time (int): How many turns it takes to flee. + + """ + return EvAdventureTurnbasedCombatHandler.get_or_create_combathandler( + caller.location, + interval=turn_timeout, + attributes=[("flee_time", flee_time)], + key=combathandler_key, + ) + + +def _queue_action(caller, raw_string, **kwargs): + """ + Goto-function that queue the action with the CombatHandler. This always returns + to the top-level combat menu "node_combat" + """ + action_dict = kwargs["action_dict"] + _get_combathandler(caller).queue_action(caller, action_dict) + return "node_combat" + + +def _rerun_current_node(caller, raw_string, **kwargs): + return None, kwargs + + +def _get_default_wizard_options(caller, **kwargs): + """ + Get the standard wizard options for moving back/forward/abort. This can be appended to + the end of other options. + + """ + + return [ + {"key": ("back", "b"), "goto": (_step_wizard, {**kwargs, **{"step": "back"}})}, + {"key": ("abort", "a"), "goto": "node_combat"}, + { + "key": "_default", + "goto": (_rerun_current_node, kwargs), + }, + ] + + +def _step_wizard(caller, raw_string, **kwargs): + """ + Many options requires stepping through several steps, wizard style. This + will redirect back/forth in the sequence. + + E.g. Stunt boost -> Choose ability to boost -> Choose recipient -> Choose target -> queue + + """ + steps = kwargs.get("steps", []) + nsteps = len(steps) + istep = kwargs.get("istep", -1) + # one of abort, back, forward + step_direction = kwargs.get("step", "forward") + + if step_direction == "back": + # step back in wizard + if istep <= 0: + return "node_combat" + istep = kwargs["istep"] = istep - 1 + return steps[istep], kwargs + else: + # step to the next step in wizard + if istep >= nsteps - 1: + # we are already at end of wizard - queue action! + return _queue_action(caller, raw_string, **kwargs) + else: + # step forward + istep = kwargs["istep"] = istep + 1 + return steps[istep], kwargs + + +
[docs]def node_choose_enemy_target(caller, raw_string, **kwargs): + """ + Choose an enemy as a target for an action + """ + text = "Choose an enemy to target." + action_dict = kwargs["action_dict"] + + combathandler = _get_combathandler(caller) + + _, enemies = combathandler.get_sides(caller) + + options = [ + { + "desc": target.get_display_name(caller), + "goto": ( + _step_wizard, + {**kwargs, **{"action_dict": {**action_dict, **{"target": target}}}}, + ), + } + for target in enemies + ] + options.extend(_get_default_wizard_options(caller, **kwargs)) + return text, options
+ + +
[docs]def node_choose_enemy_recipient(caller, raw_string, **kwargs): + """ + Choose an enemy as a 'recipient' for an action. + """ + text = "Choose an enemy as a recipient." + action_dict = kwargs["action_dict"] + + combathandler = _get_combathandler(caller) + _, enemies = combathandler.get_sides(caller) + + options = [ + { + "desc": target.get_display_name(caller), + "goto": ( + _step_wizard, + {**kwargs, **{"action_dict": {**action_dict, **{"recipient": target}}}}, + ), + } + for target in enemies + ] + options.extend(_get_default_wizard_options(caller, **kwargs)) + return text, options
+ + +
[docs]def node_choose_allied_target(caller, raw_string, **kwargs): + """ + Choose an enemy as a target for an action + """ + text = "Choose an ally to target." + action_dict = kwargs["action_dict"] + + combathandler = _get_combathandler(caller) + allies, _ = combathandler.get_sides(caller) + + options.extend( + [ + { + "desc": target.get_display_name(caller), + "goto": ( + _step_wizard, + { + **kwargs, + **{"action_dict": {**action_dict, **{"target": target}}}, + }, + ), + } + for target in allies + ] + ) + options.extend(_get_default_wizard_options(caller, **kwargs)) + return text, options
+ + +
[docs]def node_choose_allied_recipient(caller, raw_string, **kwargs): + """ + Choose an allied recipient for an action + """ + text = "Choose an ally as a recipient." + action_dict = kwargs["action_dict"] + + combathandler = _get_combathandler(caller) + allies, _ = combathandler.get_sides(caller) + + options.extend( + [ + { + "desc": target.get_display_name(caller), + "goto": ( + _step_wizard, + { + **kwargs, + **{ + "action_dict": { + **action_dict, + **{"recipient": target}, + } + }, + }, + ), + } + for target in allies + ] + ) + options.extend(_get_default_wizard_options(caller, **kwargs)) + return text, options
+ + +
[docs]def node_choose_ability(caller, raw_string, **kwargs): + """ + Select an ability to use/boost etc. + """ + text = "Choose the ability to apply" + action_dict = kwargs["action_dict"] + + options = [ + { + "desc": abi.value, + "goto": ( + _step_wizard, + { + **kwargs, + **{ + "action_dict": {**action_dict, **{"stunt_type": abi, "defense_type": abi}}, + }, + }, + ), + } + for abi in ( + Ability.STR, + Ability.DEX, + Ability.CON, + Ability.INT, + Ability.INT, + Ability.WIS, + Ability.CHA, + ) + ] + options.extend(_get_default_wizard_options(caller, **kwargs)) + return text, options
+ + +
[docs]def node_choose_use_item(caller, raw_string, **kwargs): + """ + Choose item to use. + + """ + text = "Select the item" + action_dict = kwargs["action_dict"] + + options = [ + { + "desc": item.get_display_name(caller), + "goto": ( + _step_wizard, + {**kwargs, **{"action_dict": {**action_dict, **{"item": item}}}}, + ), + } + for item in caller.equipment.get_usable_objects_from_backpack() + ] + if not options: + text = "There are no usable items in your inventory!" + + options.extend(_get_default_wizard_options(caller, **kwargs)) + return text, options
+ + +
[docs]def node_choose_wield_item(caller, raw_string, **kwargs): + """ + Choose item to use. + + """ + text = "Select the item" + action_dict = kwargs["action_dict"] + + options = [ + { + "desc": item.get_display_name(caller), + "goto": ( + _step_wizard, + {**kwargs, **{"action_dict": {**action_dict, **{"item": item}}}}, + ), + } + for item in caller.equipment.get_wieldable_objects_from_backpack() + ] + if not options: + text = "There are no items in your inventory that you can wield!" + + options.extend(_get_default_wizard_options(caller, **kwargs)) + return text, options
+ + +
[docs]def node_combat(caller, raw_string, **kwargs): + """Base combat menu""" + + combathandler = _get_combathandler(caller) + + text = combathandler.get_combat_summary(caller) + options = [ + { + "desc": "attack an enemy", + "goto": ( + _step_wizard, + { + "steps": ["node_choose_enemy_target"], + "action_dict": {"key": "attack", "target": None, "repeat": True}, + }, + ), + }, + { + "desc": "Stunt - gain a later advantage against a target", + "goto": ( + _step_wizard, + { + "steps": [ + "node_choose_ability", + "node_choose_enemy_target", + "node_choose_allied_recipient", + ], + "action_dict": {"key": "stunt", "advantage": True}, + }, + ), + }, + { + "desc": "Stunt - give an enemy disadvantage against yourself or an ally", + "goto": ( + _step_wizard, + { + "steps": [ + "node_choose_ability", + "node_choose_enemy_recipient", + "node_choose_allied_target", + ], + "action_dict": {"key": "stunt", "advantage": False}, + }, + ), + }, + { + "desc": "Use an item on yourself or an ally", + "goto": ( + _step_wizard, + { + "steps": ["node_choose_use_item", "node_choose_allied_target"], + "action_dict": {"key": "use", "item": None, "target": None}, + }, + ), + }, + { + "desc": "Use an item on an enemy", + "goto": ( + _step_wizard, + { + "steps": ["node_choose_use_item", "node_choose_enemy_target"], + "action_dict": {"key": "use", "item": None, "target": None}, + }, + ), + }, + { + "desc": "Wield/swap with an item from inventory", + "goto": ( + _step_wizard, + { + "steps": ["node_choose_wield_item"], + "action_dict": {"key": "wield", "item": None}, + }, + ), + }, + { + "desc": "flee!", + "goto": (_queue_action, {"action_dict": {"key": "flee", "repeat": True}}), + }, + { + "desc": "hold, doing nothing", + "goto": (_queue_action, {"action_dict": {"key": "hold"}}), + }, + { + "key": "_default", + "goto": "node_combat", + }, + ] + + return text, options
+ + +# Add this command to the Character cmdset to make turn-based combat available. + + +
[docs]class CmdTurnAttack(Command): + """ + Start or join combat. + + Usage: + attack [<target>] + + """ + + key = "attack" + aliases = ["hit", "turnbased combat"] + + turn_timeout = 30 # seconds + flee_time = 3 # rounds + +
[docs] def parse(self): + super().parse() + self.args = self.args.strip()
+ +
[docs] def func(self): + if not self.args: + self.msg("What are you attacking?") + return + + target = self.caller.search(self.args) + if not target: + return + + if not hasattr(target, "hp"): + self.msg("You can't attack that.") + return + elif target.hp <= 0: + self.msg(f"{target.get_display_name(self.caller)} is already down.") + return + + if target.is_pc and not target.location.allow_pvp: + self.msg("PvP combat is not allowed here!") + return + + combathandler = _get_combathandler(self.caller, self.turn_timeout, self.flee_time) + + # add combatants to combathandler. this can be done safely over and over + combathandler.add_combatant(self.caller) + combathandler.queue_action(self.caller, {"key": "attack", "target": target}) + combathandler.add_combatant(target) + target.msg("|rYou are attacked by {self.caller.get_display_name(self.caller)}!|n") + combathandler.start_combat() + + # build and start the menu + EvMenu( + self.caller, + { + "node_choose_enemy_target": node_choose_enemy_target, + "node_choose_allied_target": node_choose_allied_target, + "node_choose_enemy_recipient": node_choose_enemy_recipient, + "node_choose_allied_recipient": node_choose_allied_recipient, + "node_choose_ability": node_choose_ability, + "node_choose_use_item": node_choose_use_item, + "node_choose_wield_item": node_choose_wield_item, + "node_combat": node_combat, + }, + startnode="node_combat", + combathandler=combathandler, + auto_look=False, + # cmdset_mergetype="Union", + persistent=True, + )
+ + +
[docs]class TurnCombatCmdSet(CmdSet): + """ + CmdSet for the turn-based combat. + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdTurnAttack())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_twitch.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_twitch.html new file mode 100644 index 0000000000..6a7af50843 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/combat_twitch.html @@ -0,0 +1,682 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.combat_twitch — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.combat_twitch

+"""
+EvAdventure Twitch-based combat
+
+This implements a 'twitch' (aka DIKU or other traditional muds) style of MUD combat.
+
+----
+
+"""
+from evennia import AttributeProperty, CmdSet, default_cmds
+from evennia.commands.command import Command, InterruptCommand
+from evennia.utils.utils import (
+    display_len,
+    inherits_from,
+    list_to_string,
+    pad,
+    repeat,
+    unrepeat,
+)
+
+from .characters import EvAdventureCharacter
+from .combat_base import (
+    CombatActionAttack,
+    CombatActionHold,
+    CombatActionStunt,
+    CombatActionUseItem,
+    CombatActionWield,
+    EvAdventureCombatBaseHandler,
+)
+from .enums import ABILITY_REVERSE_MAP
+
+
+
[docs]class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler): + """ + This is created on the combatant when combat starts. It tracks only the combatants + side of the combat and handles when the next action will happen. + + + """ + + # fixed properties + action_classes = { + "hold": CombatActionHold, + "attack": CombatActionAttack, + "stunt": CombatActionStunt, + "use": CombatActionUseItem, + "wield": CombatActionWield, + } + + # dynamic properties + + advantage_against = AttributeProperty(dict) + disadvantage_against = AttributeProperty(dict) + + action_dict = AttributeProperty(dict) + fallback_action_dict = AttributeProperty({"key": "hold", "dt": 0}) + + # stores the current ticker reference, so we can manipulate it later + current_ticker_ref = AttributeProperty(None) + +
[docs] def msg(self, message, broadcast=True, **kwargs): + """ + Central place for sending messages to combatants. This allows + for adding any combat-specific text-decoration in one place. + + Args: + message (str): The message to send. + combatant (Object): The 'You' in the message, if any. + broadcast (bool): If `False`, `combatant` must be included and + will be the only one to see the message. If `True`, send to + everyone in the location. + location (Object, optional): If given, use this as the location to + send broadcast messages to. If not, use `self.obj` as that + location. + + Notes: + If `combatant` is given, use `$You/you()` markup to create + a message that looks different depending on who sees it. Use + `$You(combatant_key)` to refer to other combatants. + """ + super().msg(message, combatant=self.obj, broadcast=broadcast, location=self.obj.location)
+ +
[docs] def at_init(self): + self.obj.cmdset.add(TwitchLookCmdSet, persistent=False)
+ +
[docs] def get_sides(self, combatant): + """ + Get a listing of the two 'sides' of this combat, from the perspective of the provided + combatant. The sides don't need to be balanced. + + Args: + combatant (Character or NPC): The one whose sides are to determined. + + Returns: + tuple: A tuple of lists `(allies, enemies)`, from the perspective of `combatant`. + Note that combatant itself is not included in either of these. + + """ + # get all entities involved in combat by looking up their combathandlers + combatants = [ + comb + for comb in self.obj.location.contents + if hasattr(comb, "scripts") and comb.scripts.has(self.key) + ] + location = self.obj.location + + if hasattr(location, "allow_pvp") and location.allow_pvp: + # in pvp, everyone else is an enemy + allies = [combatant] + enemies = [comb for comb in combatants if comb != combatant] + else: + # otherwise, enemies/allies depend on who combatant is + pcs = [comb for comb in combatants if inherits_from(comb, EvAdventureCharacter)] + npcs = [comb for comb in combatants if comb not in pcs] + if combatant in pcs: + # combatant is a PC, so NPCs are all enemies + allies = pcs + enemies = npcs + else: + # combatant is an NPC, so PCs are all enemies + allies = npcs + enemies = pcs + return allies, enemies
+ +
[docs] def give_advantage(self, recipient, target): + """ + Let a benefiter gain advantage against the target. + + Args: + recipient (Character or NPC): The one to gain the advantage. This may or may not + be the same entity that creates the advantage in the first place. + target (Character or NPC): The one against which the target gains advantage. This + could (in principle) be the same as the benefiter (e.g. gaining advantage on + some future boost) + + """ + self.advantage_against[target] = True
+ +
[docs] def give_disadvantage(self, recipient, target): + """ + Let an affected party gain disadvantage against a target. + + Args: + recipient (Character or NPC): The one to get the disadvantage. + target (Character or NPC): The one against which the target gains disadvantage, usually + an enemy. + + """ + self.disadvantage_against[target] = True
+ +
[docs] def has_advantage(self, combatant, target): + """ + Check if a given combatant has advantage against a target. + + Args: + combatant (Character or NPC): The one to check if they have advantage + target (Character or NPC): The target to check advantage against. + + """ + return self.advantage_against.get(target, False)
+ +
[docs] def has_disadvantage(self, combatant, target): + """ + Check if a given combatant has disadvantage against a target. + + Args: + combatant (Character or NPC): The one to check if they have disadvantage + target (Character or NPC): The target to check disadvantage against. + + """ + return self.disadvantage_against.get(target, False)
+ +
[docs] def queue_action(self, action_dict, combatant=None): + """ + Schedule the next action to fire. + + Args: + action_dict (dict): The new action-dict to initialize. + combatant: Unused. + + """ + if action_dict["key"] not in self.action_classes: + self.obj.msg("This is an unkown action!") + return + + # store action dict and schedule it to run in dt time + self.action_dict = action_dict + dt = action_dict.get("dt", 0) + + if self.current_ticker_ref: + # we already have a current ticker going - abort it + unrepeat(self.current_ticker_ref) + if dt <= 0: + # no repeat + self.current_ticker_ref = None + else: + # always schedule the task to be repeating, cancel later otherwise. We store + # the tickerhandler's ref to make sure we can remove it later + self.current_ticker_ref = repeat(dt, self.execute_next_action, id_string="combat")
+ +
[docs] def execute_next_action(self): + """ + Triggered after a delay by the command + """ + combatant = self.obj + action_dict = self.action_dict + action_class = self.action_classes[action_dict["key"]] + action = action_class(self, combatant, action_dict) + + if action.can_use(): + action.execute() + action.post_execute() + + if not action_dict.get("repeat", True): + # not a repeating action, use the fallback (normally the original attack) + self.action_dict = self.fallback_action_dict + self.queue_action(self.fallback_action_dict) + + self.check_stop_combat()
+ +
[docs] def check_stop_combat(self): + """ + Check if the combat is over. + """ + + allies, enemies = self.get_sides(self.obj) + + location = self.obj.location + + # only keep combatants that are alive and still in the same room + allies = [comb for comb in allies if comb.hp > 0 and comb.location == location] + enemies = [comb for comb in enemies if comb.hp > 0 and comb.location == location] + + if not allies and not enemies: + self.msg("Noone stands after the dust settles.", broadcast=False) + self.stop_combat() + return + + if not allies or not enemies: + if allies + enemies == [self.obj]: + self.msg("The combat is over.") + else: + still_standing = list_to_string(f"$You({comb.key})" for comb in allies + enemies) + self.msg( + f"The combat is over. Still standing: {still_standing}.", + broadcast=False, + ) + self.stop_combat()
+ +
[docs] def stop_combat(self): + """ + Stop combat immediately. + """ + self.queue_action({"key": "hold", "dt": 0}) # make sure ticker is killed + del self.obj.ndb.combathandler + self.obj.cmdset.remove(TwitchLookCmdSet) + self.delete()
+ + +class _BaseTwitchCombatCommand(Command): + """ + Parent class for all twitch-combat commnads. + + """ + + def at_pre_command(self): + """ + Called before parsing. + + """ + if not self.caller.location or not self.caller.location.allow_combat: + self.msg("Can't fight here!") + raise InterruptCommand() + + def parse(self): + """ + Handle parsing of most supported combat syntaxes (except stunts). + + <action> [<target>|<item>] + or + <action> <item> [on] <target> + + Use 'on' to differentiate if names/items have spaces in the name. + + """ + self.args = args = self.args.strip() + self.lhs, self.rhs = "", "" + + if not args: + return + + if " on " in args: + lhs, rhs = args.split(" on ", 1) + else: + lhs, *rhs = args.split(None, 1) + rhs = " ".join(rhs) + self.lhs, self.rhs = lhs.strip(), rhs.strip() + + def get_or_create_combathandler(self, target=None, combathandler_key="combathandler"): + """ + Get or create the combathandler assigned to this combatant. + + """ + if target: + # add/check combathandler to the target + if target.hp_max is None: + self.msg("You can't attack that!") + raise InterruptCommand() + + EvAdventureCombatTwitchHandler.get_or_create_combathandler( + target, key=combathandler_key + ) + return EvAdventureCombatTwitchHandler.get_or_create_combathandler(self.caller) + + +
[docs]class CmdAttack(_BaseTwitchCombatCommand): + """ + Attack a target. Will keep attacking the target until + combat ends or another combat action is taken. + + Usage: + attack/hit <target> + + """ + + key = "attack" + aliases = ["hit"] + help_category = "combat" + +
[docs] def func(self): + target = self.caller.search(self.lhs) + if not target: + return + + combathandler = self.get_or_create_combathandler(target) + # we use a fixed dt of 3 here, to mimic Diku style; one could also picture + # attacking at a different rate, depending on skills/weapon etc. + combathandler.queue_action({"key": "attack", "target": target, "dt": 3, "repeat": True}) + combathandler.msg(f"$You() $conj(attack) $You({target.key})!", self.caller)
+ + +
[docs]class CmdLook(default_cmds.CmdLook, _BaseTwitchCombatCommand): +
[docs] def func(self): + # get regular look, followed by a combat summary + super().func() + if not self.args: + combathandler = self.get_or_create_combathandler() + txt = str(combathandler.get_combat_summary(self.caller)) + maxwidth = max(display_len(line) for line in txt.strip().split("\n")) + self.msg(f"|r{pad(' Combat Status ', width=maxwidth, fillchar='-')}|n\n{txt}")
+ + +
[docs]class CmdHold(_BaseTwitchCombatCommand): + """ + Hold back your blows, doing nothing. + + Usage: + hold + + """ + + key = "hold" + +
[docs] def func(self): + combathandler = self.get_or_create_combathandler() + combathandler.queue_action({"key": "hold"}) + combathandler.msg("$You() $conj(hold) back, doing nothing.", self.caller)
+ + +
[docs]class CmdStunt(_BaseTwitchCombatCommand): + """ + Perform a combat stunt, that boosts an ally against a target, or + foils an enemy, giving them disadvantage against an ally. + + Usage: + boost [ability] <recipient> <target> + foil [ability] <recipient> <target> + boost [ability] <target> (same as boost me <target>) + foil [ability] <target> (same as foil <target> me) + + Example: + boost STR me Goblin + boost DEX Goblin + foil STR Goblin me + foil INT Goblin + boost INT Wizard Goblin + + """ + + key = "stunt" + aliases = ( + "boost", + "foil", + ) + help_category = "combat" + +
[docs] def parse(self): + args = self.args + + if not args or " " not in args: + self.msg("Usage: <ability> <recipient> <target>") + raise InterruptCommand() + + advantage = self.cmdname != "foil" + + # extract data from the input + + stunt_type, recipient, target = None, None, None + + stunt_type, *args = args.split(None, 1) + if stunt_type: + stunt_type = stunt_type.strip().lower() + + args = args[0] if args else "" + + recipient, *args = args.split(None, 1) + target = args[0] if args else None + + # validate input and try to guess if not given + + # ability is requried + if not stunt_type or stunt_type not in ABILITY_REVERSE_MAP: + self.msg( + f"'{stunt_type}' is not a valid ability. Pick one of" + f" {', '.join(ABILITY_REVERSE_MAP.keys())}." + ) + raise InterruptCommand() + + if not recipient: + self.msg("Must give at least a recipient or target.") + raise InterruptCommand() + + if not target: + # something like `boost str target` + target = recipient if advantage else "me" + recipient = "me" if advantage else recipient + + # if we still have None:s at this point, we can't continue + if None in (stunt_type, recipient, target): + self.msg("Both ability, recipient and target of stunt must be given.") + raise InterruptCommand() + + # save what we found so it can be accessed from func() + self.advantage = advantage + self.stunt_type = ABILITY_REVERSE_MAP[stunt_type] + self.recipient = recipient.strip() + self.target = target.strip()
+ +
[docs] def func(self): + target = self.caller.search(self.target) + if not target: + return + recipient = self.caller.search(self.recipient) + if not recipient: + return + + combathandler = self.get_or_create_combathandler(target) + + combathandler.queue_action( + { + "key": "stunt", + "recipient": recipient, + "target": target, + "advantage": self.advantage, + "stunt_type": self.stunt_type, + "defense_type": self.stunt_type, + "dt": 3, + }, + ) + combathandler.msg("$You() prepare a stunt!", self.caller)
+ + +
[docs]class CmdUseItem(_BaseTwitchCombatCommand): + """ + Use an item in combat. The item must be in your inventory to use. + + Usage: + use <item> + use <item> [on] <target> + + Examples: + use potion + use throwing knife on goblin + use bomb goblin + + """ + + key = "use" + help_category = "combat" + +
[docs] def parse(self): + super().parse() + + if not self.args: + self.msg("What do you want to use?") + raise InterruptCommand() + + self.item = self.lhs + self.target = self.rhs or "me"
+ +
[docs] def func(self): + item = self.caller.search( + self.item, candidates=self.caller.equipment.get_usable_objects_from_backpack() + ) + if not item: + self.msg("(You must carry the item to use it.)") + return + if self.target: + target = self.caller.search(self.target) + if not target: + return + + combathandler = self.get_or_create_combathandler(target) + combathandler.queue_action({"key": "use", "item": item, "target": target, "dt": 3}) + combathandler.msg( + f"$You() prepare to use {item.get_display_name(self.caller)}!", self.caller + )
+ + +
[docs]class CmdWield(_BaseTwitchCombatCommand): + """ + Wield a weapon or spell-rune. You will the wield the item, swapping with any other item(s) you + were wielded before. + + Usage: + wield <weapon or spell> + + Examples: + wield sword + wield shield + wield fireball + + Note that wielding a shield will not replace the sword in your hand, while wielding a two-handed + weapon (or a spell-rune) will take two hands and swap out what you were carrying. + + """ + + key = "wield" + help_category = "combat" + +
[docs] def parse(self): + if not self.args: + self.msg("What do you want to wield?") + raise InterruptCommand() + super().parse()
+ +
[docs] def func(self): + item = self.caller.search( + self.args, candidates=self.caller.equipment.get_wieldable_objects_from_backpack() + ) + if not item: + self.msg("(You must carry the item to wield it.)") + return + combathandler = self.get_or_create_combathandler() + combathandler.queue_action({"key": "wield", "item": item, "dt": 3}) + combathandler.msg(f"$You() reach for {item.get_display_name(self.caller)}!", self.caller)
+ + +
[docs]class TwitchCombatCmdSet(CmdSet): + """ + Add to character, to be able to attack others in a twitch-style way. + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdAttack()) + self.add(CmdHold()) + self.add(CmdStunt()) + self.add(CmdUseItem()) + self.add(CmdWield())
+ + +
[docs]class TwitchLookCmdSet(CmdSet): + """ + This will be added/removed dynamically when in combat. + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdLook())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/commands.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/commands.html new file mode 100644 index 0000000000..a410eecaf6 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/commands.html @@ -0,0 +1,524 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.commands — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.commands

+"""
+EvAdventure commands and cmdsets. We don't need that many stand-alone new
+commands since a lot of functionality is managed in menus. These commands
+are in additional to normal Evennia commands and should be added
+to the CharacterCmdSet
+
+New commands:
+    inventory
+    wield/wear <item>
+    unwield/remove <item>
+    give <item or coin> to <character>
+    talk <npc>
+
+To install, add the `EvAdventureCmdSet` from this module to the default character cmdset:
+
+```python
+    # in mygame/commands/default_cmds.py
+
+    from evennia.contrib.tutorials.evadventure.commands import EvAdventureCmdSet  # <---
+
+    # ...
+
+    class CharacterCmdSet(CmdSet):
+        def at_cmdset_creation(self):
+            # ...
+            self.add(EvAdventureCmdSet)   # <-----
+
+```
+"""
+
+from evennia import CmdSet, Command, InterruptCommand
+from evennia.utils.evmenu import EvMenu
+from evennia.utils.utils import inherits_from
+
+from .enums import WieldLocation
+from .equipment import EquipmentError
+from .npcs import EvAdventureTalkativeNPC
+from .utils import get_obj_stats
+
+
+
[docs]class EvAdventureCommand(Command): + """ + Base EvAdventure command. This is on the form + + command <args> + + where whitespace around the argument(s) are stripped. + + """ + +
[docs] def parse(self): + self.args = self.args.strip()
+ + +
[docs]class CmdInventory(EvAdventureCommand): + """ + View your inventory + + Usage: + inventory + + """ + + key = "inventory" + aliases = ("i", "inv") + +
[docs] def func(self): + loadout = self.caller.equipment.display_loadout() + backpack = self.caller.equipment.display_backpack() + slot_usage = self.caller.equipment.display_slot_usage() + + self.caller.msg(f"{loadout}\n{backpack}\nYou use {slot_usage} equipment slots.")
+ + +
[docs]class CmdWieldOrWear(EvAdventureCommand): + """ + Wield a weapon/shield, or wear a piece of armor or a helmet. + + Usage: + wield <item> + wear <item> + + The item will automatically end up in the suitable spot, replacing whatever + was there previously. + + """ + + key = "wield" + aliases = ("wear",) + + out_txts = { + WieldLocation.BACKPACK: "You shuffle the position of {key} around in your backpack.", + WieldLocation.TWO_HANDS: "You hold {key} with both hands.", + WieldLocation.WEAPON_HAND: "You hold {key} in your strongest hand, ready for action.", + WieldLocation.SHIELD_HAND: "You hold {key} in your off hand, ready to protect you.", + WieldLocation.BODY: "You strap {key} on yourself.", + WieldLocation.HEAD: "You put {key} on your head.", + } + +
[docs] def func(self): + # find the item among those in equipment + item = self.caller.search(self.args, candidates=self.caller.equipment.all(only_objs=True)) + if not item: + # An 'item not found' error will already have been reported; we add another line + # here for clarity. + self.caller.msg("You must carry the item you want to wield or wear.") + return + + use_slot = getattr(item, "inventory_use_slot", WieldLocation.BACKPACK) + + # check what is currently in this slot + current = self.caller.equipment.slots[use_slot] + + if current == item: + self.caller.msg(f"You are already using {item.key}.") + return + + # move it to the right slot based on the type of object + self.caller.equipment.move(item) + + # inform the user of the change (and potential swap) + if current: + self.caller.msg(f"Returning {current.key} to the backpack.") + self.caller.msg(self.out_txts[use_slot].format(key=item.key))
+ + +
[docs]class CmdRemove(EvAdventureCommand): + """ + Remove a remove a weapon/shield, armor or helmet. + + Usage: + remove <item> + unwield <item> + unwear <item> + + To remove an item from the backpack, use |wdrop|n instead. + + """ + + key = "remove" + aliases = ("unwield", "unwear") + +
[docs] def func(self): + caller = self.caller + + # find the item among those in equipment + item = caller.search(self.args, candidates=caller.equipment.all(only_objs=True)) + if not item: + # An 'item not found' error will already have been reported + return + + current_slot = caller.equipment.get_current_slot(item) + + if current_slot is WieldLocation.BACKPACK: + # we don't allow dropping this way since it may be unexepected by users who forgot just + # where their item currently is. + caller.msg( + f"You already stashed away {item.key} in your backpack. Use 'drop' if " + "you want to get rid of it." + ) + return + + caller.equipment.remove(item) + caller.equipment.add(item) + caller.msg(f"You stash {item.key} in your backpack.")
+ + +# give / accept menu + + +def _rescind_gift(caller, raw_string, **kwargs): + """ + Called when giver rescinds their gift in `node_give` below. + It means they entered 'cancel' on the gift screen. + + """ + # kill the gift menu for the receiver immediately + receiver = kwargs["receiver"] + receiver.ndb._evmenu.close_menu() + receiver.msg("The offer was rescinded.") + return "node_end" + + +
[docs]def node_give(caller, raw_string, **kwargs): + """ + This will show to the giver until receiver accepts/declines. It allows them + to rescind their offer. + + The `caller` here is the one giving the item. We also make sure to feed + the 'item' and 'receiver' into the Evmenu. + + """ + item = kwargs["item"] + receiver = kwargs["receiver"] + text = f""" +You are offering {item.key} to {receiver.get_display_name(looker=caller)}. +|wWaiting for them to accept or reject the offer ...|n +""".strip() + + options = { + "key": ("cancel", "abort"), + "desc": "Rescind your offer.", + "goto": (_rescind_gift, kwargs), + } + return text, options
+ + +def _accept_or_reject_gift(caller, raw_string, **kwargs): + """ + Called when receiver enters yes/no in `node_receive` below. We first need to + figure out which. + + """ + item = kwargs["item"] + giver = kwargs["giver"] + if raw_string.lower() in ("yes", "y"): + # they accepted - move the item! + item = giver.equipment.remove(item) + if item: + try: + # this will also add them to the equipment backpack, if possible + item.move_to(caller, quiet=True, move_type="give") + except EquipmentError: + caller.location.msg_contents( + ( + f"$You({giver.key.key}) $conj(try) to give " + f"{item.key} to $You({caller.key}), but they can't accept it since their " + "inventory is full." + ), + mapping={giver.key: giver, caller.key: caller}, + ) + else: + caller.location.msg_contents( + ( + f"$You({giver.key}) $conj(give) {item.key} to $You({caller.key}), " + "and they accepted the offer." + ), + mapping={giver.key: giver, caller.key: caller}, + ) + giver.ndb._evmenu.close_menu() + return "node_end" + + +
[docs]def node_receive(caller, raw_string, **kwargs): + """ + Will show to the receiver and allow them to accept/decline the offer for + as long as the giver didn't rescind it. + + The `caller` here is the one receiving the item. We also make sure to feed + the 'item' and 'giver' into the EvMenu. + + """ + item = kwargs["item"] + giver = kwargs["giver"] + text = f""" +{giver.get_display_name()} is offering you {item.key}: + +{get_obj_stats(item)} + +[Your inventory usage: {caller.equipment.display_slot_usage()}] +|wDo you want to accept the given item? Y/[N] + """ + options = ({"key": "_default", "goto": (_accept_or_reject_gift, kwargs)},) + return text, options
+ + +
[docs]def node_end(caller, raw_string, **kwargs): + return "", None
+ + +
[docs]class CmdGive(EvAdventureCommand): + """ + Give item or money to another person. Items need to be accepted before + they change hands. Money changes hands immediately with no wait. + + Usage: + give <item> to <receiver> + give <number of coins> [coins] to receiver + + If item name includes ' to ', surround it in quotes. + + Examples: + give apple to ranger + give "road to happiness" to sad ranger + give 10 coins to ranger + give 12 to ranger + + """ + + key = "give" + +
[docs] def parse(self): + """ + Parsing is a little more complex for this command. + + """ + super().parse() + args = self.args + if " to " not in args: + self.caller.msg( + "Usage: give <item> to <recevier>. Specify e.g. '10 coins' to pay money. " + "Use quotes around the item name it if includes the substring ' to '. " + ) + raise InterruptCommand + + self.item_name = "" + self.coins = 0 + + # make sure we can use '...' to include items with ' to ' in the name + if args.startswith('"') and args.count('"') > 1: + end_ind = args[1:].index('"') + 1 + item_name = args[:end_ind] + _, receiver_name = args.split(" to ", 1) + elif args.startswith("'") and args.count("'") > 1: + end_ind = args[1:].index("'") + 1 + item_name = args[:end_ind] + _, receiver_name = args.split(" to ", 1) + else: + item_name, receiver_name = args.split(" to ", 1) + + # a coin count rather than a normal name + if " coins" in item_name: + item_name = item_name[:-6] + if item_name.isnumeric(): + self.coins = max(0, int(item_name)) + + self.item_name = item_name + self.receiver_name = receiver_name
+ +
[docs] def func(self): + caller = self.caller + + receiver = caller.search(self.receiver_name) + if not receiver: + return + + # giving of coins is always accepted + + if self.coins: + current_coins = caller.coins + if self.coins > current_coins: + caller.msg(f"You only have |y{current_coins}|n coins to give.") + return + # do transaction + caller.coins -= self.coins + receiver.coins += self.coins + caller.location.msg_contents( + f"$You() $conj(give) $You({receiver.key}) {self.coins} coins.", + from_obj=caller, + mapping={receiver.key: receiver}, + ) + return + + # giving of items require acceptance before it happens + + item = caller.search(self.item_name, candidates=caller.equipment.all(only_objs=True)) + if not item: + return + + # testing hook + if not item.at_pre_give(caller, receiver): + return + + # before we start menus, we must check so either part is not already in a menu, + # that would be annoying otherwise + if receiver.ndb._evmenu: + caller.msg( + f"{receiver.get_display_name(looker=caller)} seems busy talking to someone else." + ) + return + if caller.ndb._evmenu: + caller.msg("Close the current menu first.") + return + + # this starts evmenus for both parties + EvMenu( + receiver, {"node_receive": node_receive, "node_end": node_end}, item=item, giver=caller + ) + EvMenu(caller, {"node_give": node_give, "node_end": node_end}, item=item, receiver=receiver)
+ + +
[docs]class CmdTalk(EvAdventureCommand): + """ + Start a conversations with shop keepers and other NPCs in the world. + + Args: + talk <npc> + + """ + + key = "talk" + +
[docs] def func(self): + target = self.caller.search(self.args) + if not target: + return + + if not inherits_from(target, EvAdventureTalkativeNPC): + self.caller.msg( + f"{target.get_display_name(looker=self.caller)} does not seem very talkative." + ) + return + target.at_talk(self.caller)
+ + +
[docs]class EvAdventureCmdSet(CmdSet): + """ + Groups all commands in one cmdset which can be added in one go to the DefaultCharacter cmdset. + + """ + + key = "evadventure" + +
[docs] def at_cmdset_creation(self): + self.add(CmdInventory()) + self.add(CmdWieldOrWear()) + self.add(CmdRemove()) + self.add(CmdGive()) + self.add(CmdTalk())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/dungeon.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/dungeon.html new file mode 100644 index 0000000000..3075b99e30 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/dungeon.html @@ -0,0 +1,600 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.dungeon — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.dungeon

+"""
+Dungeon system
+
+This creates a procedurally generated dungeon.
+
+The dungone originates in an entrance room with exits that spawn a new dungeon connection every X
+minutes. As long as characters go through the same exit within that time, they will all end up in
+the same dungeon 'branch', otherwise they will go into separate, un-connected dungeon 'branches'.
+They can always go back to the start room, but this will become a one-way exit back.
+
+When moving through the dungeon, a new room is not generated until characters
+decided to go in that direction. Each room is tagged with the specific 'instance'
+id of that particular branch of dungon. When no characters remain in the branch,
+the branch is deleted.
+
+Each room in the dungeon starts with a Tag `not_clear`; while this is set, all exits out
+of the room (not the one they came from) is blocked. When whatever problem the room
+offers has been solved (such as a puzzle or a battle), the tag is removed and the player(s)
+can choose which exit to leave through.
+
+"""
+
+from datetime import datetime, timedelta
+from math import sqrt
+from random import randint, random, shuffle
+
+from evennia.objects.objects import DefaultExit
+from evennia.scripts.scripts import DefaultScript
+from evennia.typeclasses.attributes import AttributeProperty
+from evennia.utils import create, search
+from evennia.utils.utils import inherits_from
+
+from .rooms import EvAdventureRoom
+
+# aliases for cardinal directions
+_AVAILABLE_DIRECTIONS = [
+    "north",
+    "east",
+    "south",
+    "west",
+    # commented out to make the dungeon simpler to navigate
+    # "northeast", "southeast", "southwest", "northwest",
+]
+
+_EXIT_ALIASES = {
+    "north": ("n",),
+    "east": ("e",),
+    "south": ("s",),
+    "west": ("w",),
+    "northeast": ("ne",),
+    "southeast": ("se",),
+    "southwest": ("sw",),
+    "northwest": ("nw",),
+}
+# finding the reverse cardinal direction
+_EXIT_REVERSE_MAPPING = {
+    "north": "south",
+    "east": "west",
+    "south": "north",
+    "west": "east",
+    "northeast": "southwest",
+    "southeast": "northwest",
+    "southwest": "northeast",
+    "northwest": "southeast",
+}
+
+# how xy coordinate shifts by going in direction
+_EXIT_GRID_SHIFT = {
+    "north": (0, 1),
+    "east": (1, 0),
+    "south": (0, -1),
+    "west": (-1, 0),
+    "northeast": (1, 1),
+    "southeast": (1, -1),
+    "southwest": (-1, -1),
+    "northwest": (-1, 1),
+}
+
+
+# --------------------------------------------------
+# Dungeon orchestrator and room / exits
+# --------------------------------------------------
+
+
+
[docs]class EvAdventureDungeonRoom(EvAdventureRoom): + """ + Dangerous dungeon room. + + """ + + allow_combat = AttributeProperty(True, autocreate=False) + allow_death = AttributeProperty(True, autocreate=False) + + # dungeon generation attributes; set when room is created + back_exit = AttributeProperty(None, autocreate=False) + dungeon_orchestrator = AttributeProperty(None, autocreate=False) + xy_coords = AttributeProperty(None, autocreate=False) + + @property + def is_room_clear(self): + return not bool(self.tags.get("not_clear", category="dungeon_room")) + +
[docs] def clear_room(self): + self.tags.remove("not_clear", category="dungeon_room")
+ +
[docs] def at_object_creation(self): + """ + Set the `not_clear` tag on the room. This is removed when the room is + 'cleared', whatever that means for each room. + + We put this here rather than in the room-creation code so we can override + easier (for example we may want an empty room which auto-clears). + + """ + self.tags.add("not_clear", category="dungeon_room")
+ +
+ + +
[docs]class EvAdventureDungeonExit(DefaultExit): + """ + Dungeon exit. This will not create the target room until it's traversed. + + """ + +
[docs] def at_object_creation(self): + """ + We want to block progressing forward unless the room is clear. + + """ + self.locks.add("traverse:not objloctag(not_clear, dungeon_room)")
+ +
[docs] def at_traverse(self, traversing_object, target_location, **kwargs): + """ + Called when traversing. `target_location` will be None if the + target was not yet created. It checks the current location to get the + dungeon-orchestrator in use. + + """ + if target_location == self.location: + self.destination = target_location = self.location.db.dungeon_orchestrator.new_room( + self + ) + if self.id in self.location.dungeon_orchestrator.unvisited_exits: + self.location.dungeon_orchestrator.unvisited_exits.remove(self.id) + + super().at_traverse(traversing_object, target_location, **kwargs)
+ +
[docs] def at_failed_traverse(self, traversing_object, **kwargs): + """ + Called when failing to traverse. + + """ + traversing_object.msg("You can't get through this way yet!")
+ + +
[docs]def room_generator(dungeon_orchestrator, depth, coords): + """ + Plugin room generator + + This default one returns the same empty room. + + Args: + dungeon_orchestrator (EvAdventureDungeonOrchestrator): The current orchestrator. + depth (int): The 'depth' of the dungeon (radial distance from start room) this + new room will be placed at. + coords (tuple): The `(x,y)` coords that the new room will be created at. + + """ + room_typeclass = EvAdventureDungeonRoom + + # simple map of depth to name and desc of room + name_depth_map = { + 1: ("Water-logged passage", "This earth-walled passage is dripping of water."), + 2: ("Passage with roots", "Roots are pushing through the earth walls."), + 3: ("Hardened clay passage", "The walls of this passage is of hardened clay."), + 4: ("Clay with stones", "This passage has clay with pieces of stone embedded."), + 5: ("Stone passage", "Walls are crumbling stone, with roots passing through it."), + 6: ("Stone hallway", "Walls are cut from rough stone."), + 7: ("Stone rooms", "A stone room, built from crude and heavy blocks."), + 8: ("Granite hall", "The walls are of well-fitted granite blocks."), + 9: ("Marble passages", "The walls are blank and shiny marble."), + 10: ("Furnished rooms", "The marble walls have tapestries and furnishings."), + } + key, desc = name_depth_map.get(depth, ("Dark rooms", "There is very dark here.")) + + new_room = create.create_object( + room_typeclass, + key=key, + attributes=( + ("desc", desc), + ("xy_coords", coords), + ("dungeon_orchestrator", dungeon_orchestrator), + ), + ) + return new_room
+ + +
[docs]class EvAdventureDungeonOrchestrator(DefaultScript): + """ + One script is created per dungeon 'branch' created. The orchestrator is + responsible for determining what is created next when a character enters an + exit within the dungeon. + + """ + + # this determines how branching the dungeon will be + max_unexplored_exits = 2 + max_new_exits_per_room = 2 + + rooms = AttributeProperty(list()) + unvisited_exits = AttributeProperty(list()) + highest_depth = AttributeProperty(0) + + last_updated = AttributeProperty(datetime.utcnow()) + + # the room-generator function; copied from the same-name value on the start-room when the + # orchestrator is first created + room_generator = AttributeProperty(None, autocreate=False) + + # (x,y): room coordinates used up by orchestrator + xy_grid = AttributeProperty(dict()) + start_room = AttributeProperty(None, autocreate=False) + +
[docs] def register_exit_traversed(self, exit): + """ + Tell the system the given exit was traversed. This allows us to track how many unvisited + paths we have so as to not have it grow exponentially. + + """ + if exit.id in self.unvisited_exits: + self.unvisited_exits.remove(exit.id)
+ +
[docs] def create_out_exit(self, location, exit_direction="north"): + """ + Create outgoing exit from a room. The target room is not yet created. + + """ + out_exit = create.create_object( + EvAdventureDungeonExit, + key=exit_direction, + location=location, + aliases=_EXIT_ALIASES[exit_direction], + ) + self.unvisited_exits.append(out_exit.id)
+ +
[docs] def delete(self): + """ + Clean up the entire dungeon along with the orchestrator. + + """ + # first secure all characters in this branch back to the start room + characters = search.search_object_by_tag(self.key, category="dungeon_character") + start_room = self.start_room + for character in characters: + start_room.msg_contents( + "Suddenly someone stumbles out of a dark exit, covered in dust!" + ) + character.location = start_room + character.msg( + "|rAfter a long time of silence, the room suddenly rumbles and then collapses! " + "All turns dark ...|n\n\nThen you realize you are back where you started." + ) + character.tags.remove(self.key, category="dungeon_character") + # next delete all rooms in the dungeon (this will also delete exits) + rooms = search.search_object_by_tag(self.key, category="dungeon_room") + for room in rooms: + room.delete() + # finally delete the orchestrator itself + super().delete()
+ +
[docs] def new_room(self, from_exit): + """ + Create a new Dungeon room leading from the provided exit. + + Args: + from_exit (Exit): The exit leading to this new room. + + """ + self.last_updated = datetime.utcnow() + # figure out coordinate of old room and figure out what coord the + # new one would get + source_location = from_exit.location + x, y = source_location.attributes.get("xy_coords", default=(0, 0)) + dx, dy = _EXIT_GRID_SHIFT.get(from_exit.key, (0, 1)) + new_x, new_y = (x + dx, y + dy) + + # the dungeon's depth acts as a measure of the current difficulty level. This is the radial + # distance from the (0, 0) (the entrance). The Orchestrator also tracks the highest + # depth achieved. + depth = int(sqrt(new_x**2 + new_y**2)) + + new_room = self.room_generator(self, depth, (new_x, new_y)) + + self.xy_grid[(new_x, new_y)] = new_room + + # always make a return exit back to where we came from + back_exit_key = _EXIT_REVERSE_MAPPING.get(from_exit.key, "back") + create.create_object( + EvAdventureDungeonExit, + key=back_exit_key, + aliases=_EXIT_ALIASES.get(back_exit_key, ()), + location=new_room, + destination=from_exit.location, + attributes=( + ( + "desc", + "A dark passage.", + ), + ), + # we default to allowing back-tracking (also used for fleeing) + locks=("traverse: true()",), + ) + + # figure out what other exits should be here, if any + n_unexplored = len(self.unvisited_exits) + + if n_unexplored < self.max_unexplored_exits: + # we have a budget of unexplored exits to open + n_exits = min(self.max_new_exits_per_room, self.max_unexplored_exits) + if n_exits > 1: + n_exits = randint(1, n_exits) + available_directions = [ + direction for direction in _AVAILABLE_DIRECTIONS if direction != back_exit_key + ] + # randomize order of exits + shuffle(available_directions) + for _ in range(n_exits): + while available_directions: + # get a random direction and check so there isn't a room already + # created in that direction + direction = available_directions.pop(0) + dx, dy = _EXIT_GRID_SHIFT[direction] + target_coord = (new_x + dx, new_y + dy) + if target_coord not in self.xy_grid and target_coord != (0, 0): + # no room there (and not back to start room) - make an exit to it + self.create_out_exit(new_room, direction) + # we create this to avoid other rooms linking here, but don't create the + # room yet + self.xy_grid[target_coord] = None + break + + self.highest_depth = max(self.highest_depth, depth) + + return new_room
+ + +# -------------------------------------------------- +# Start room +# -------------------------------------------------- + + +
[docs]class EvAdventureDungeonStartRoomExit(DefaultExit): + """ + Traversing this exit will either lead to an existing dungeon branch or create + a new one. + + Since exits need to have a destination, we start out having them loop back to + the same location and change this whenever someone actually traverse them. The + act of passing through creates a room on the other side. + + """ + +
[docs] def reset_exit(self): + """ + Flush the exit, so next traversal creates a new dungeon branch. + + """ + self.destination = self.location
+ +
[docs] def at_traverse(self, traversing_object, target_location, **kwargs): + """ + When traversing create a new orchestrator if one is not already assigned. + + """ + if target_location == self.location: + # make a global orchestrator script for this dungeon branch + self.location.room_generator + dungeon_orchestrator = create.create_script( + EvAdventureDungeonOrchestrator, + key=f"dungeon_orchestrator_{self.key}_{datetime.utcnow()}", + attributes=( + ("start_room", self.location), + ("room_generator", self.location.room_generator), + ), + ) + self.destination = target_location = dungeon_orchestrator.new_room(self) + # make sure to tag character when entering so we can find them again later + traversing_object.tags.add(dungeon_orchestrator.key, category="dungeon_character") + + super().at_traverse(traversing_object, target_location, **kwargs)
+ + +
[docs]class EvAdventureStartRoomResetter(DefaultScript): + """ + Simple ticker-script. Introduces a chance of the room's exits cycling every interval. + + """ + +
[docs] def at_script_creation(self): + self.key = "evadventure_dungeon_startroom_resetter"
+ +
[docs] def at_repeat(self): + """ + Called every time the script repeats. + + """ + room = self.obj + for exi in room.exits: + if inherits_from(exi, EvAdventureDungeonStartRoomExit) and random() < 0.5: + exi.reset_exit()
+ + +
[docs]class EvAdventureDungeonBranchDeleter(DefaultScript): + """ + Cleanup script. After some time a dungeon branch will 'collapse', forcing all players in it + back to the start room. + + """ + + # set at creation time when the start room is created + branch_max_life = AttributeProperty(0, autocreate=False) + +
[docs] def at_script_creation(self): + self.key = "evadventure_dungeon_branch_deleter"
+ +
[docs] def at_repeat(self): + """ + Go through all dungeon-orchestrators and find which ones are too old. + + """ + max_dt = timedelta(seconds=self.branch_max_life) + max_allowed_date = datetime.utcnow() - max_dt + + for orchestrator in EvAdventureDungeonOrchestrator.objects.all(): + if orchestrator.last_updated < max_allowed_date: + # orchestrator is too old; tell it to clean up and delete itself + orchestrator.delete()
+ + +
[docs]class EvAdventureDungeonStartRoom(EvAdventureDungeonRoom): + """ + The start room is the only permanent part of the dungeon. Exits leading from this room (except + one leading back outside) each create/links to a separate dungeon branch/instance. + + - A script will reset each exit every 5 mins; after that time, entering the exit will spawn + a new branch-instance instead of leading to the one before. + - Another script will check age of branch instance every hour; once an instance has been + inactive for a week, it will 'collapse', forcing everyone inside back to the start room. + + The actual exits should be created in the build script. + + """ + + recycle_time = 60 * 5 # 5 mins + branch_check_time = 60 * 60 # one hour + branch_max_life = 60 * 60 * 24 * 7 # 1 week + + # allow for a custom room_generator function + room_generator = AttributeProperty(lambda: room_generator, autocreate=False) + + + +
[docs] def at_object_creation(self): + # want to set the script interval on creation time, so we use create_script with obj=self + # instead of self.scripts.add() here + create.create_script( + EvAdventureStartRoomResetter, obj=self, interval=self.recycle_time, autostart=True + ) + create.create_script( + EvAdventureDungeonBranchDeleter, + obj=self, + interval=self.branch_check_time, + autostart=True, + attributes=(("branch_max_life", self.branch_max_life),), + )
+ +
[docs] def at_object_receive(self, obj, source_location, **kwargs): + """ + Make sure to clean the dungeon branch-tag from characters when leaving a dungeon branch. + + """ + obj.tags.remove(category="dungeon_character")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/enums.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/enums.html new file mode 100644 index 0000000000..ab6bfb8018 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/enums.html @@ -0,0 +1,191 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.enums — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.enums

+"""
+Enums are constants representing different things in EvAdventure. The advantage
+of using an Enum over, say, a string is that if you make a typo using an unknown
+enum, Python will give you an error while a typo in a string may go through silently.
+
+It's used as a direct reference:
+::
+
+    from enums import Ability
+
+    if abi is Ability.STR:
+        # ...
+
+To get the `value` of an enum (must always be hashable, useful for Attribute lookups), use
+`Ability.STR.value` (which would return 'strength' in our case).
+
+----
+
+"""
+from enum import Enum
+
+
+
[docs]class Ability(Enum): + """ + The six base abilities (defense is always bonus + 10) + + """ + + STR = "strength" + DEX = "dexterity" + CON = "constitution" + INT = "intelligence" + WIS = "wisdom" + CHA = "charisma" + + ARMOR = "armor" + + CRITICAL_FAILURE = "critical_failure" + CRITICAL_SUCCESS = "critical_success" + + ALLEGIANCE_HOSTILE = "hostile" + ALLEGIANCE_NEUTRAL = "neutral" + ALLEGIANCE_FRIENDLY = "friendly"
+ + +ABILITY_REVERSE_MAP = { + "str": Ability.STR, + "dex": Ability.DEX, + "con": Ability.CON, + "int": Ability.INT, + "wis": Ability.WIS, + "cha": Ability.CHA, +} + + +
[docs]class WieldLocation(Enum): + """ + Wield (or wear) locations. + + """ + + # wield/wear location + BACKPACK = "backpack" + WEAPON_HAND = "weapon_hand" + SHIELD_HAND = "shield_hand" + TWO_HANDS = "two_handed_weapons" + BODY = "body" # armor + HEAD = "head" # helmets
+ + +
[docs]class ObjType(Enum): + """ + Object types + + """ + + WEAPON = "weapon" + ARMOR = "armor" + SHIELD = "shield" + HELMET = "helmet" + CONSUMABLE = "consumable" + GEAR = "gear" + THROWABLE = "throwable" + MAGIC = "magic" + QUEST = "quest" + TREASURE = "treasure"
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/equipment.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/equipment.html new file mode 100644 index 0000000000..bd191df97b --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/equipment.html @@ -0,0 +1,528 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.equipment — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.equipment

+"""
+Knave has a system of Slots for its inventory.
+
+"""
+
+from evennia.utils.utils import inherits_from
+
+from .enums import Ability, WieldLocation
+from .objects import EvAdventureObject, get_bare_hands
+
+
+
[docs]class EquipmentError(TypeError): + pass
+ + +
[docs]class EquipmentHandler: + """ + _Knave_ puts a lot of emphasis on the inventory. You have CON_DEFENSE inventory + slots. Some things, like torches can fit multiple in one slot, other (like + big weapons and armor) use more than one slot. The items carried and wielded has a big impact + on character customization - even magic requires carrying a runestone per spell. + + The inventory also doubles as a measure of negative effects. Getting soaked in mud + or slime could gunk up some of your inventory slots and make the items there unusuable + until you clean them. + + """ + + save_attribute = "inventory_slots" + +
[docs] def __init__(self, obj): + self.obj = obj + self._load()
+ + def _load(self): + """ + Load or create a new slot storage. + + """ + self.slots = self.obj.attributes.get( + self.save_attribute, + category="inventory", + default={ + WieldLocation.WEAPON_HAND: None, + WieldLocation.SHIELD_HAND: None, + WieldLocation.TWO_HANDS: None, + WieldLocation.BODY: None, + WieldLocation.HEAD: None, + WieldLocation.BACKPACK: [], + }, + ) + self.slots[WieldLocation.BACKPACK] = [ + obj for obj in self.slots[WieldLocation.BACKPACK] if obj and obj.id + ] + + def _save(self): + """ + Save slot to storage. + + """ + self.obj.attributes.add(self.save_attribute, self.slots, category="inventory") + +
[docs] def count_slots(self): + """ + Count slot usage. This is fetched from the .size Attribute of the + object. The size can also be partial slots. + + """ + slots = self.slots + wield_usage = sum( + getattr(slotobj, "size", 0) or 0 + for slot, slotobj in slots.items() + if slot is not WieldLocation.BACKPACK + ) + backpack_usage = sum( + getattr(slotobj, "size", 0) or 0 for slotobj in slots[WieldLocation.BACKPACK] + ) + return wield_usage + backpack_usage
+ + @property + def max_slots(self): + """ + The max amount of equipment slots ('carrying capacity') is based on + the constitution defense. + + """ + return getattr(self.obj, Ability.CON.value, 1) + 10 + +
[docs] def validate_slot_usage(self, obj): + """ + Check if obj can fit in equipment, based on its size. + + Args: + obj (EvAdventureObject): The object to add. + + """ + if not inherits_from(obj, EvAdventureObject): + raise EquipmentError(f"{obj.key} is not something that can be equipped.") + + size = obj.size + max_slots = self.max_slots + current_slot_usage = self.count_slots() + + if current_slot_usage + size > max_slots: + slots_left = max_slots - current_slot_usage + raise EquipmentError( + f"Equipment full ($int2str({slots_left}) slots " + f"remaining, {obj.key} needs $int2str({size}) " + f"$pluralize(slot, {size}))." + ) + return True
+ +
[docs] def get_current_slot(self, obj): + """ + Check which slot-type the given object is in. + + Args: + obj (EvAdventureObject): The object to check. + + Returns: + WieldLocation: A location the object is in. None if the object + is not in the inventory at all. + + """ + for equipment_item, slot in self.all(): + if obj == equipment_item: + return slot
+ + @property + def armor(self): + """ + Armor provided by actually worn equipment/shield. For body armor + this is a base value, like 12, for shield/helmet, it's a bonus, like +1. + We treat values and bonuses equal and just add them up. This value + can thus be 0, the 'unarmored' default should be handled by the calling + method. + + Returns: + int: Armor from equipment. Note that this is the +bonus of Armor, not the + 'defense' (to get that one adds 10). + + """ + slots = self.slots + return sum( + ( + # armor is listed using its defense, so we remove 10 from it + # (11 is base no-armor value in Knave) + getattr(slots[WieldLocation.BODY], "armor", 1), + # shields and helmets are listed by their bonus to armor + getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0), + getattr(slots[WieldLocation.HEAD], "armor", 0), + ) + ) + + @property + def weapon(self): + """ + Conveniently get the currently active weapon or rune stone. + + Returns: + obj or None: The weapon. None if unarmored. + + """ + # first checks two-handed wield, then one-handed; the two + # should never appear simultaneously anyhow (checked in `move` method). + slots = self.slots + weapon = slots[WieldLocation.TWO_HANDS] + if not weapon: + weapon = slots[WieldLocation.WEAPON_HAND] + if not weapon: + weapon = get_bare_hands() + return weapon + +
[docs] def display_loadout(self): + """ + Get a visual representation of your current loadout. + + Returns: + str: The current loadout. + + """ + slots = self.slots + weapon_str = "You are fighting with your bare fists" + shield_str = " and have no shield." + armor_str = "You wear no armor" + helmet_str = " and no helmet." + + two_hands = slots[WieldLocation.TWO_HANDS] + if two_hands: + weapon_str = f"You wield {two_hands} with both hands" + shield_str = " (you can't hold a shield at the same time)." + else: + one_hands = slots[WieldLocation.WEAPON_HAND] + if one_hands: + weapon_str = f"You are wielding {one_hands} in one hand." + shield = slots[WieldLocation.SHIELD_HAND] + if shield: + shield_str = f"You have {shield} in your off hand." + + armor = slots[WieldLocation.BODY] + if armor: + armor_str = f"You are wearing {armor}" + + helmet = slots[WieldLocation.BODY] + if helmet: + helmet_str = f" and {helmet} on your head." + + return f"{weapon_str}{shield_str}\n{armor_str}{helmet_str}"
+ +
[docs] def display_backpack(self): + """ + Get a visual representation of the backpack's contents. + + """ + backpack = self.slots[WieldLocation.BACKPACK] + if not backpack: + return "Backpack is empty." + out = [] + for item in backpack: + out.append(f"{item.key} [|b{item.size}|n] slot(s)") + return "\n".join(out)
+ +
[docs] def display_slot_usage(self): + """ + Get a slot usage/max string for display. + + Returns: + str: The usage string. + + """ + return f"|b{self.count_slots()}/{self.max_slots}|n"
+ +
[docs] def move(self, obj): + """ + Moves item to the place it things it should be in - this makes use of the object's wield + slot to decide where it goes. The item is assumed to already be in the backpack. + + Args: + obj (EvAdventureObject): Thing to use. + + Raises: + EquipmentError: If there's no room in inventory. It will contains the details + of the error, suitable to echo to user. + + Notes: + This will cleanly move any 'colliding' items to the backpack to + make the use possible (such as moving sword + shield to backpack when wielding + a two-handed weapon). If wanting to warn the user about this, it needs to happen + before this call. + + """ + # make sure to remove from backpack first, if it's there, since we'll be re-adding it + self.remove(obj) + + self.validate_slot_usage(obj) + slots = self.slots + use_slot = getattr(obj, "inventory_use_slot", WieldLocation.BACKPACK) + + to_backpack = [] + if use_slot is WieldLocation.TWO_HANDS: + # two-handed weapons can't co-exist with weapon/shield-hand used items + to_backpack = [slots[WieldLocation.WEAPON_HAND], slots[WieldLocation.SHIELD_HAND]] + slots[WieldLocation.WEAPON_HAND] = slots[WieldLocation.SHIELD_HAND] = None + slots[use_slot] = obj + elif use_slot in (WieldLocation.WEAPON_HAND, WieldLocation.SHIELD_HAND): + # can't keep a two-handed weapon if adding a one-handed weapon or shield + to_backpack = [slots[WieldLocation.TWO_HANDS]] + slots[WieldLocation.TWO_HANDS] = None + slots[use_slot] = obj + elif use_slot is WieldLocation.BACKPACK: + # it belongs in backpack, so goes back to it + to_backpack = [obj] + else: + # for others (body, head), just replace whatever's there and put the old + # thing in the backpack + to_backpack = [slots[use_slot]] + slots[use_slot] = obj + + for to_backpack_obj in to_backpack: + # put stuff in backpack + if to_backpack_obj: + slots[WieldLocation.BACKPACK].append(to_backpack_obj) + + # store new state + self._save()
+ +
[docs] def add(self, obj): + """ + Put something in the backpack specifically (even if it could be wield/worn). + + Args: + obj (EvAdventureObject): The object to add. + + Notes: + This will not change the object's `.location`, this must be done + by the calling code. + + """ + # check if we have room + self.validate_slot_usage(obj) + self.slots[WieldLocation.BACKPACK].append(obj) + self._save()
+ +
[docs] def remove(self, obj_or_slot): + """ + Remove specific object or objects from a slot. + + Args: + obj_or_slot (EvAdventureObject or WieldLocation): The specific object or + location to empty. If this is WieldLocation.BACKPACK, all items + in the backpack will be emptied and returned! + Returns: + list: A list of 0, 1 or more objects emptied from the inventory. + + Notes: + This will not change the object's `.location`, this must be done separately + by the calling code. + + """ + slots = self.slots + ret = [] + if isinstance(obj_or_slot, WieldLocation): + if obj_or_slot is WieldLocation.BACKPACK: + # empty entire backpack + ret.extend(slots[obj_or_slot]) + slots[obj_or_slot] = [] + else: + ret.append(slots[obj_or_slot]) + slots[obj_or_slot] = None + elif obj_or_slot in self.slots.values(): + # obj in use/wear slot + for slot, objslot in slots.items(): + if objslot is obj_or_slot: + slots[slot] = None + ret.append(objslot) + elif obj_or_slot in slots[WieldLocation.BACKPACK]: + # obj in backpack slot + try: + slots[WieldLocation.BACKPACK].remove(obj_or_slot) + ret.append(obj_or_slot) + except ValueError: + pass + if ret: + self._save() + return ret
+ +
[docs] def get_wieldable_objects_from_backpack(self): + """ + Get all wieldable weapons (or spell runes) from backpack. This is useful in order to + have a list to select from when swapping your wielded loadout. + + Returns: + list: A list of objects with a suitable `inventory_use_slot`. We don't check + quality, so this may include broken items (we may want to visually show them + in the list after all). + + """ + return [ + obj + for obj in self.slots[WieldLocation.BACKPACK] + if obj + and obj.id + and obj.inventory_use_slot + in (WieldLocation.WEAPON_HAND, WieldLocation.TWO_HANDS, WieldLocation.SHIELD_HAND) + ]
+ +
[docs] def get_wearable_objects_from_backpack(self): + """ + Get all wearable items (armor or helmets) from backpack. This is useful in order to + have a list to select from when swapping your worn loadout. + + Returns: + list: A list of objects with a suitable `inventory_use_slot`. We don't check + quality, so this may include broken items (we may want to visually show them + in the list after all). + + """ + return [ + obj + for obj in self.slots[WieldLocation.BACKPACK] + if obj and obj.id and obj.inventory_use_slot in (WieldLocation.BODY, WieldLocation.HEAD) + ]
+ +
[docs] def get_usable_objects_from_backpack(self): + """ + Get all 'usable' items (like potions) from backpack. This is useful for getting a + list to select from. + + Returns: + list: A list of objects that are usable. + + """ + character = self.obj + return [ + obj for obj in self.slots[WieldLocation.BACKPACK] if obj and obj.at_pre_use(character) + ]
+ +
[docs] def all(self, only_objs=False): + """ + Get all objects in inventory, regardless of location. + + Keyword Args: + only_objs (bool): Only return a flat list of objects, not tuples. + + Returns: + list: A list of item tuples `[(item, WieldLocation),...]` + starting with the wielded ones, backpack content last. If `only_objs` is set, + this will just be a flat list of objects. + + """ + slots = self.slots + lst = [ + (slots[WieldLocation.WEAPON_HAND], WieldLocation.WEAPON_HAND), + (slots[WieldLocation.SHIELD_HAND], WieldLocation.SHIELD_HAND), + (slots[WieldLocation.TWO_HANDS], WieldLocation.TWO_HANDS), + (slots[WieldLocation.BODY], WieldLocation.BODY), + (slots[WieldLocation.HEAD], WieldLocation.HEAD), + ] + [(item, WieldLocation.BACKPACK) for item in slots[WieldLocation.BACKPACK]] + if only_objs: + # remove any None-results from empty slots + return [tup[0] for tup in lst if tup[0]] + # keep empty slots + return [tup for tup in lst]
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/npcs.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/npcs.html new file mode 100644 index 0000000000..60e66e6052 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/npcs.html @@ -0,0 +1,451 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.npcs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.npcs

+"""
+EvAdventure NPCs. This includes both friends and enemies, only separated by their AI.
+
+"""
+from random import choice
+
+from evennia import DefaultCharacter
+from evennia.typeclasses.attributes import AttributeProperty
+from evennia.typeclasses.tags import TagProperty
+from evennia.utils.evmenu import EvMenu
+from evennia.utils.utils import make_iter
+
+from .characters import LivingMixin
+from .enums import Ability, WieldLocation
+from .objects import get_bare_hands
+from .rules import dice
+
+
+
[docs]class EvAdventureNPC(LivingMixin, DefaultCharacter): + """ + This is the base class for all non-player entities, including monsters. These + generally don't advance in level but uses a simplified, abstract measure of how + dangerous or competent they are - the 'hit dice' (HD). + + HD indicates how much health they have and how hard they hit. In _Knave_, HD also + defaults to being the bonus for all abilities. HP is 4 x Hit die (this can then be + customized per-entity of course). + + Morale is set explicitly per-NPC, usually between 7 and 9. + + Monsters don't use equipment in the way PCs do, instead they have a fixed armor + value, and their Abilities are dynamically generated from the HD (hit_dice). + + If wanting monsters or NPCs that can level and work the same as PCs, base them off the + EvAdventureCharacter class instead. + + The weapon of the npc is stored as an Attribute instead of implementing a full + inventory/equipment system. This means that the normal inventory can be used for + non-combat purposes (or for loot to get when killing an enemy). + + """ + + is_pc = False + + hit_dice = AttributeProperty(default=1, autocreate=False) + armor = AttributeProperty(default=1, autocreate=False) # +10 to get armor defense + morale = AttributeProperty(default=9, autocreate=False) + hp_multiplier = AttributeProperty(default=4, autocreate=False) # 4 default in Knave + hp = AttributeProperty(default=None, autocreate=False) # internal tracking, use .hp property + allegiance = AttributeProperty(default=Ability.ALLEGIANCE_HOSTILE, autocreate=False) + + is_idle = AttributeProperty(default=False, autocreate=False) + + weapon = AttributeProperty(default=get_bare_hands, autocreate=False) # instead of inventory + coins = AttributeProperty(default=1, autocreate=False) # coin loot + + # if this npc is attacked, everyone with the same tag in the current location will also be + # pulled into combat. + group = TagProperty("npcs") + + @property + def strength(self): + return self.hit_dice + + @property + def dexterity(self): + return self.hit_dice + + @property + def constitution(self): + return self.hit_dice + + @property + def intelligence(self): + return self.hit_dice + + @property + def wisdom(self): + return self.hit_dice + + @property + def charisma(self): + return self.hit_dice + + @property + def hp_max(self): + return self.hit_dice * self.hp_multiplier + +
[docs] def at_object_creation(self): + """ + Start with max health. + + """ + self.hp = self.hp_max + self.tags.add("npcs", category="group")
+ +
[docs] def at_attacked(self, attacker, **kwargs): + """ + Called when being attacked and combat starts. + + """ + pass
+ +
[docs] def ai_next_action(self, **kwargs): + """ + The combat engine should ask this method in order to + get the next action the npc should perform in combat. + + """ + pass
+ + +
[docs]class EvAdventureTalkativeNPC(EvAdventureNPC): + """ + Talkative NPCs can be addressed by `talk [to] <npc>`. This opens a chat menu with + communication options. The menu is created with the npc and we override the .create + to allow passing in the menu nodes. + + """ + + menudata = AttributeProperty(dict(), autocreate=False) + menu_kwargs = AttributeProperty(dict(), autocreate=False) + # text shown when greeting at the start of a conversation. If this is an + # iterable, a random reply will be chosen by the menu + hi_text = AttributeProperty("Hi!", autocreate=False) + +
[docs] def at_damage(self, damage, attacker=None): + """ + Talkative NPCs are generally immortal (we don't deduct HP here by default)." + + """ + attacker.msg(f'{self.key} dodges the damage and shouts "|wHey! What are you doing?|n"')
+ +
[docs] @classmethod + def create(cls, key, account=None, **kwargs): + """ + Overriding the creation of the NPC, allowing some extra `**kwargs`. + + Args: + key (str): Name of the new object. + account (Account, optional): Account to attribute this object to. + + Keyword Args: + description (str): Brief description for this object (same as default Evennia) + ip (str): IP address of creator (for object auditing) (same as default Evennia). + menudata (dict or str): The `menudata` argument to `EvMenu`. This is either a dict of + `{"nodename": <node_callable>,...}` or the python-path to a module containing + such nodes (see EvMenu docs). This will be used to generate the chat menu + chat menu for the character that talks to the NPC (which means the `at_talk` hook + is called (by our custom `talk` command). + menu_kwargs (dict): This will be passed as `**kwargs` into `EvMenu` when it + is created. Make sure this dict can be pickled to an Attribute. + + Returns: + tuple: `(new_character, errors)`. On error, the `new_character` is `None` and + `errors` is a `list` of error strings (an empty list otherwise). + + + """ + menudata = kwargs.pop("menudata", None) + menu_kwargs = kwargs.pop("menu_kwargs", {}) + + # since this is a @classmethod we can't use super() here + new_object, errors = EvAdventureNPC.create( + key, account=account, attributes=(("menudata", menudata), ("menu_kwargs", menu_kwargs)) + ) + + return new_object, errors
+ +
[docs] def at_talk(self, talker, startnode="node_start", session=None, **kwargs): + """ + Called by the `talk` command when another entity addresses us. + + Args: + talker (Object): The one talking to us. + startnode (str, optional): Allows to start in a different location in the menu tree. + The given node must exist in the tree. + session (Session, optional): The talker's current session, allows for routing + correctly in multi-session modes. + **kwargs: This will be passed into the `EvMenu` creation and appended and `menu_kwargs` + given to the NPC at creation. + + Notes: + We pass `npc=self` into the EvMenu for easy back-reference. This will appear in the + `**kwargs` of the start node. + + """ + menu_kwargs = {**self.menu_kwargs, **kwargs} + EvMenu(talker, self.menudata, startnode=startnode, session=session, npc=self, **menu_kwargs)
+ + +
[docs]def node_start(caller, raw_string, **kwargs): + """ + This is the intended start menu node for the Talkative NPC interface. It will + use on-npc Attributes to build its message and will also pick its options + based on nodes named `node_start_*` are available in the node tree. + + """ + # we presume a back-reference to the npc this is added when the menu is created + npc = kwargs["npc"] + + # grab a (possibly random) welcome text + text = choice(make_iter(npc.hi_text)) + + # determine options based on `node_start_*` nodes available + toplevel_node_keys = [ + node_key for node_key in caller.ndb._evmenu._menutree if node_key.startswith("node_start_") + ] + options = [] + for node_key in toplevel_node_keys: + option_name = node_key[11:].replace("_", " ").capitalized() + + # we let the menu number the choices, so we don't use key here + options.append({"desc": option_name, "goto": node_key}) + + return text, options
+ + +
[docs]class EvAdventureQuestGiver(EvAdventureTalkativeNPC): + """ + An NPC that acts as a dispenser of quests. + + """
+ + +
[docs]class EvAdventureShopKeeper(EvAdventureTalkativeNPC): + """ + ShopKeeper NPC. + + """ + + # how much extra the shopkeeper adds on top of the item cost + upsell_factor = AttributeProperty(1.0, autocreate=False) + # how much of the raw cost the shopkeep is willing to pay when buying from character + miser_factor = AttributeProperty(0.5, autocreate=False) + # prototypes of common wares + common_ware_prototypes = AttributeProperty([], autocreate=False) + +
[docs] def at_damage(self, damage, attacker=None): + """ + Immortal - we don't deduct any damage here. + + """ + attacker.msg( + f"{self.key} brushes off the hit and shouts " + '"|wHey! This is not the way to get a discount!|n"' + )
+ + +
[docs]class EvAdventureMob(EvAdventureNPC): + """ + Mob (mobile) NPC; this is usually an enemy. + + """ + + # chance (%) that this enemy will loot you when defeating you + loot_chance = AttributeProperty(75, autocreate=False) + +
[docs] def ai_next_action(self, **kwargs): + """ + Called to get the next action in combat. + + Args: + combathandler (EvAdventureCombatHandler): The currently active combathandler. + + Returns: + tuple: A tuple `(str, tuple, dict)`, being the `action_key`, and the `*args` and + `**kwargs` for that action. The action-key is that of a CombatAction available to the + combatant in the current combat handler. + + """ + from .combat import CombatActionAttack, CombatActionDoNothing + + if self.is_idle: + # mob just stands around + return CombatActionDoNothing.key, (), {} + + target = choice(combathandler.get_enemy_targets(self)) + + # simply randomly decide what action to take + action = choice( + ( + CombatActionAttack, + CombatActionDoNothing, + ) + ) + return action.key, (target,), {}
+ +
[docs] def at_defeat(self): + """ + Mobs die right away when defeated, no death-table rolls. + + """ + self.at_death()
+ +
[docs] def at_do_loot(self, looted): + """ + Called when mob gets to loot a PC. + + """ + if dice.roll("1d100") > self.loot_chance: + # don't loot + return + + if looted.coins: + # looter prefer coins + loot = dice.roll("1d20") + if looted.coins < loot: + self.location.msg_location( + "$You(looter) loots $You() for all coin!", + from_obj=looted, + mapping={"looter": self}, + ) + else: + self.location.msg_location( + "$You(looter) loots $You() for |y{loot}|n coins!", + from_obj=looted, + mapping={"looter": self}, + ) + elif hasattr(looted, "equipment"): + # go through backpack, first usable, then wieldable, wearable items + # and finally stuff wielded + stealable = looted.equipment.get_usable_objects_from_backpack() + if not stealable: + stealable = looted.equipment.get_wieldable_objects_from_backpack() + if not stealable: + stealable = looted.equipment.get_wearable_objects_from_backpack() + if not stealable: + stealable = [looted.equipment.slots[WieldLocation.SHIELD_HAND]] + if not stealable: + stealable = [looted.equipment.slots[WieldLocation.HEAD]] + if not stealable: + stealable = [looted.equipment.slots[WieldLocation.ARMOR]] + if not stealable: + stealable = [looted.equipment.slots[WieldLocation.WEAPON_HAND]] + if not stealable: + stealable = [looted.equipment.slots[WieldLocation.TWO_HANDS]] + + stolen = looted.equipment.remove(choice(stealable)) + stolen.location = self + + self.location.msg_location( + "$You(looter) steals {stolen.key} from $You()!", + from_obj=looted, + mapping={"looter": self}, + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/objects.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/objects.html new file mode 100644 index 0000000000..2c6936cbcf --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/objects.html @@ -0,0 +1,478 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.objects — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.objects

+"""
+All items in the game inherit from a base object. The properties (what you can do
+with an object, such as wear, wield, eat, drink, kill etc) are all controlled by
+Tags.
+
+Every object has one of a few `obj_type`-category tags:
+- weapon
+- armor
+- shield
+- helmet
+- consumable  (potions, torches etc)
+- magic (runestones, magic items)
+- quest (quest-items)
+- treasure  (valuable to sell)
+
+It's possible for an item to have more than one tag, such as a golden helmet (helmet+treasure) or
+rune sword (weapon+quest).
+
+"""
+
+from evennia import AttributeProperty, create_object, search_object
+from evennia.objects.objects import DefaultObject
+from evennia.utils.utils import make_iter
+
+from . import rules
+from .enums import Ability, ObjType, WieldLocation
+from .utils import get_obj_stats
+
+_BARE_HANDS = None
+
+
+
[docs]class EvAdventureObject(DefaultObject): + """ + Base in-game entity. + + """ + + # inventory management + inventory_use_slot = AttributeProperty(WieldLocation.BACKPACK) + # how many inventory slots it uses (can be a fraction) + size = AttributeProperty(1) + value = AttributeProperty(0) + + # can also be an iterable, for adding multiple obj-type tags + obj_type = ObjType.GEAR + +
[docs] def at_object_creation(self): + for obj_type in make_iter(self.obj_type): + self.tags.add(obj_type.value, category="obj_type")
+ +
[docs] def get_display_header(self, looker, **kwargs): + return "" # this is handled by get_obj_stats
+ +
[docs] def get_display_desc(self, looker, **kwargs): + return get_obj_stats(self, owner=looker)
+ +
[docs] def has_obj_type(self, objtype): + """ + Check if object is of a particular type. + + typeobj_enum (enum.ObjType): A type to check, like enums.TypeObj.TREASURE. + + """ + return objtype.value in make_iter(self.obj_type)
+ +
[docs] def get_help(self): + """ + Get help text for the item. + + Returns: + str: The help text, by default taken from the `.help_text` property. + + """ + return "No help for this item."
+ +
[docs] def at_pre_use(self, *args, **kwargs): + """ + Called before use. If returning False, usage should be aborted. + """ + return True
+ +
[docs] def use(self, *args, **kwargs): + """ + Use this object, whatever that may mean. + + """ + raise NotImplementedError
+ +
[docs] def at_post_use(self, *args, **kwargs): + """ + Called after use happened. + """ + pass
+ + +
[docs]class EvAdventureObjectFiller(EvAdventureObject): + """ + In _Knave_, the inventory slots act as an extra measure of how you are affected by + various averse effects. For example, mud or water could fill up some of your inventory + slots and make the equipment there unusable until you cleaned it. Inventory is also + used to track how long you can stay under water etc - the fewer empty slots you have, + the less time you can stay under water due to carrying so much stuff with you. + + This class represents such an effect filling up an empty slot. It has a quality of 0, + meaning it's unusable. + + """ + + obj_type = ObjType.QUEST.value # can't be sold + quality = AttributeProperty(0)
+ + +
[docs]class EvAdventureQuestObject(EvAdventureObject): + """ + A quest object. These cannot be sold and only be used for quest resolution. + + """ + + obj_type = ObjType.QUEST + value = AttributeProperty(0)
+ + +
[docs]class EvAdventureTreasure(EvAdventureObject): + """ + A 'treasure' is mainly useful to sell for coin. + + """ + + obj_type = ObjType.TREASURE + value = AttributeProperty(100, autocreate=False)
+ + +
[docs]class EvAdventureConsumable(EvAdventureObject): + """ + Item that can be 'used up', like a potion or food. Weapons, armor etc does not + have a limited usage in this way. + + """ + + obj_type = ObjType.CONSUMABLE + size = AttributeProperty(0.25, autocreate=False) + uses = AttributeProperty(1, autocreate=False) + +
[docs] def at_pre_use(self, user, target=None, *args, **kwargs): + if target and user.location != target.location: + user.msg("You are not close enough to the target!") + return False + + if self.uses <= 0: + user.msg(f"|w{self.key} is used up.|n") + return False + + return super().at_pre_use(user, target=target, *args, **kwargs)
+ +
[docs] def use(self, user, target=None, *args, **kwargs): + """ + Use the consumable. + + """ + + if user.location: + user.location.msg_contents( + f"$You() $conj(use) {self.get_display_name(user)}.", from_obj=user + )
+ +
[docs] def at_post_use(self, user, *args, **kwargs): + """ + Called after this item was used. + + Args: + user (Object): The one using the item. + *args, **kwargs: Optional arguments. + + """ + self.uses -= 1 + if self.uses <= 0: + user.msg(f"|w{self.key} was used up.|n") + self.delete()
+ + +
[docs]class EvAdventureWeapon(EvAdventureObject): + """ + Base weapon class for all EvAdventure weapons. + + """ + + obj_type = ObjType.WEAPON + inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND) + quality = AttributeProperty(3) + + # what ability used to attack with this weapon + attack_type = AttributeProperty(Ability.STR) + # what defense stat of the enemy it must defeat + defense_type = AttributeProperty(Ability.ARMOR) + damage_roll = AttributeProperty("1d6") + +
[docs] def get_display_name(self, looker=None, **kwargs): + quality = self.quality + + quality_txt = "" + if quality <= 0: + quality_txt = "|r(broken!)|n" + elif quality < 2: + quality_txt = "|y(damaged)|n" + elif quality < 3: + quality_txt = "|Y(chipped)|n" + + return super().get_display_name(looker=looker, **kwargs) + quality_txt
+ +
[docs] def at_pre_use(self, user, target=None, *args, **kwargs): + if target and user.location != target.location: + # we assume weapons can only be used in the same location + user.msg("You are not close enough to the target!") + return False + + if self.quality is not None and self.quality <= 0: + user.msg(f"{self.get_display_name(user)} is broken and can't be used!") + return False + return super().at_pre_use(user, target=target, *args, **kwargs)
+ +
[docs] def use(self, attacker, target, *args, advantage=False, disadvantage=False, **kwargs): + """When a weapon is used, it attacks an opponent""" + + location = attacker.location + + is_hit, quality, txt = rules.dice.opposed_saving_throw( + attacker, + target, + attack_type=self.attack_type, + defense_type=self.defense_type, + advantage=advantage, + disadvantage=disadvantage, + ) + location.msg_contents( + f"$You() $conj(attack) $You({target.key}) with {self.key}: {txt}", + from_obj=attacker, + mapping={target.key: target}, + ) + if is_hit: + # enemy hit, calculate damage + dmg = rules.dice.roll(self.damage_roll) + + if quality is Ability.CRITICAL_SUCCESS: + # doble damage roll for critical success + dmg += rules.dice.roll(self.damage_roll) + message = ( + f" $You() |ycritically|n $conj(hit) $You({target.key}) for |r{dmg}|n damage!" + ) + else: + message = f" $You() $conj(hit) $You({target.key}) for |r{dmg}|n damage!" + + location.msg_contents(message, from_obj=attacker, mapping={target.key: target}) + # call hook + target.at_damage(dmg, attacker=attacker) + + else: + # a miss + message = f" $You() $conj(miss) $You({target.key})." + if quality is Ability.CRITICAL_FAILURE: + message += ".. it's a |rcritical miss!|n, damaging the weapon." + if self.quality is not None: + self.quality -= 1 + location.msg_contents(message, from_obj=attacker, mapping={target.key: target})
+ +
[docs] def at_post_use(self, user, *args, **kwargs): + if self.quality is not None and self.quality <= 0: + user.msg(f"|r{self.get_display_name(user)} breaks and can no longer be used!")
+ + +
[docs]class EvAdventureThrowable(EvAdventureWeapon, EvAdventureConsumable): + """ + Something you can throw at an enemy to harm them once, like a knife or exploding potion/grenade. + + Note: In Knave, ranged attacks are done with WIS (representing the stillness of your mind?) + + """ + + obj_type = (ObjType.THROWABLE, ObjType.WEAPON, ObjType.CONSUMABLE) + + attack_type = AttributeProperty(Ability.WIS) + defense_type = AttributeProperty(Ability.DEX) + damage_roll = AttributeProperty("1d6")
+ + +
[docs]class EvAdventureRunestone(EvAdventureWeapon, EvAdventureConsumable): + """ + Base class for magic runestones. In _Knave_, every spell is represented by a rune stone + that takes up an inventory slot. It is wielded as a weapon in order to create the specific + magical effect provided by the stone. Normally each stone can only be used once per day but + they are quite powerful (and scales with caster level). + + """ + + obj_type = (ObjType.WEAPON, ObjType.MAGIC) + inventory_use_slot = WieldLocation.TWO_HANDS + quality = AttributeProperty(3) + + attack_type = AttributeProperty(Ability.INT) + defense_type = AttributeProperty(Ability.DEX) + damage_roll = AttributeProperty("1d8") + +
[docs] def at_post_use(self, user, *args, **kwargs): + """Called after the spell was cast""" + self.uses -= 1
+ # the rune stone is not deleted after use, but + # it needs to be refreshed after resting. + +
[docs] def refresh(self): + self.uses = 1
+ + +
[docs]class EvAdventureArmor(EvAdventureObject): + """ + Base class for all wearable Armors. + + """ + + obj_type = ObjType.ARMOR + inventory_use_slot = WieldLocation.BODY + + armor = AttributeProperty(1) + quality = AttributeProperty(3)
+ + +
[docs]class EvAdventureShield(EvAdventureArmor): + """ + Base class for all Shields. + + """ + + obj_type = ObjType.SHIELD + inventory_use_slot = WieldLocation.SHIELD_HAND
+ + +
[docs]class EvAdventureHelmet(EvAdventureArmor): + """ + Base class for all Helmets. + + """ + + obj_type = ObjType.HELMET + inventory_use_slot = WieldLocation.HEAD
+ + +
[docs]class WeaponBareHands(EvAdventureWeapon): + """ + This is a dummy-class loaded when you wield no weapons. We won't create any db-object for it. + + """ + + obj_type = ObjType.WEAPON + key = "Bare hands" + inventory_use_slot = WieldLocation.WEAPON_HAND + attack_type = Ability.STR + defense_type = Ability.ARMOR + damage_roll = "1d4" + quality = None # let's assume fists are always available ...
+ + +
[docs]def get_bare_hands(): + """ + Get the bare-hands singleton object. + + Returns: + WeaponBareHands + """ + global _BARE_HANDS + + if not _BARE_HANDS: + _BARE_HANDS = search_object("Bare hands", typeclass=WeaponBareHands).first() + if not _BARE_HANDS: + _BARE_HANDS = create_object(WeaponBareHands, key="Bare hands") + return _BARE_HANDS
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/quests.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/quests.html new file mode 100644 index 0000000000..69a3d9e946 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/quests.html @@ -0,0 +1,411 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.quests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.quests

+"""
+A simple quest system for EvAdventure.
+
+A quest is represented by a quest-handler sitting as
+.quest on a Character. Individual Quests are objects
+that track the state and can have multiple steps, each
+of which are checked off during the quest's progress.
+
+The player can use the quest handler to track the
+progress of their quests.
+
+A quest ending can mean a reward or the start of
+another quest.
+
+"""
+
+from copy import copy, deepcopy
+
+from evennia.utils import dbserialize
+
+
+
[docs]class EvAdventureQuest: + """ + This represents a single questing unit of quest. + + Properties: + name (str): Main identifier for the quest. + category (str, optional): This + name must be globally unique. + it ends - it then pauses after the last completed step. + + Each step of the quest is represented by a `.step_<stepname>` method. This should check + the status of the quest-step and update the `.current_step` or call `.complete()`. There + are also `.help_<stepname>` which is either a class-level help string or a method + returning a help text. All properties should be stored on the quester. + + Example: + ```py + class MyQuest(EvAdventureQuest): + '''A quest with two steps that ar''' + + start_step = "A" + + help_A = "You need a '_quest_A_flag' on yourself to finish this step!" + help_B = "Finally, you need more than 4 items in your inventory!" + + def step_A(self, *args, **kwargs): + if self.quester.db._quest_A_flag == True: + self.quester.msg("Completed the first step of the quest.") + self.current_step = "end" + self.progress() + + def step_end(self, *args, **kwargs): + if len(self.quester.contents) > 4: + self.quester.msg("Quest complete!") + self.complete() + ``` + """ + + key = "basequest" + desc = "This is the base quest class" + start_step = "start" + + completed_text = "This quest is completed!" + abandoned_text = "This quest is abandoned." + + # help entries for quests (could also be methods) + help_start = "You need to start first" + help_end = "You need to end the quest" + +
[docs] def __init__(self, quester, start_step=None): + if " " in self.key: + raise TypeError("The Quest name must not have spaces in it.") + + self.quester = quester + self._current_step = start_step or self.start_step + self.is_completed = False + self.is_abandoned = False
+ + def __serialize_dbobjs__(self): + self.quester = dbserialize.dbserialize(self.quester) + + def __deserialize_dbobjs__(self): + if isinstance(self.quester, bytes): + self.quester = dbserialize.dbunserialize(self.quester) + + @property + def questhandler(self): + return self.quester.quests + + @property + def current_step(self): + return self._current_step + + @current_step.setter + def current_step(self, step_name): + self._current_step = step_name + self.questhandler.do_save = True + +
[docs] def abandon(self): + """ + Call when quest is abandoned. + + """ + self.is_abandoned = True + self.cleanup()
+ +
[docs] def complete(self): + """ + Call this to end the quest. + + """ + self.is_completed = True + self.cleanup()
+ +
[docs] def progress(self, *args, **kwargs): + """ + This is called whenever the environment expects a quest may need stepping. This will + determine which quest-step we are on and run `step_<stepname>`, which in turn will figure + out if the step is complete or not. + + Args: + *args, **kwargs: Will be passed into the step method. + + """ + if not (self.is_completed or self.is_abandoned): + getattr(self, f"step_{self.current_step}")(*args, **kwargs)
+ +
[docs] def help(self): + """ + This is used to get help (or a reminder) of what needs to be done to complete the current + quest-step. + + Returns: + str: The help text for the current step. + + """ + if self.is_completed: + return self.completed_text + if self.is_abandoned: + return self.abandoned_text + + help_resource = ( + getattr(self, f"help_{self.current_step}", None) + or "You need to {self.current_step} ..." + ) + if callable(help_resource): + # the help_<current_step> can be a method to call + return help_resource() + else: + # normally it's just a string + return str(help_resource)
+ + # step methods and hooks + +
[docs] def step_start(self, *args, **kwargs): + """ + Example step that completes immediately. + + """ + self.complete()
+ +
[docs] def cleanup(self): + """ + This is called both when completing the quest, or when it is abandoned prematurely. + Make sure to cleanup any quest-related data stored when following the quest. + + """ + pass
+ + +
[docs]class EvAdventureQuestHandler: + """ + This sits on the Character, as `.quests`. + + It's initiated using a lazy property on the Character: + + ``` + @lazy_property + def quests(self): + return EvAdventureQuestHandler(self) + ``` + + """ + + quest_storage_attribute_key = "_quests" + quest_storage_attribute_category = "evadventure" + +
[docs] def __init__(self, obj): + self.obj = obj + self.do_save = False + self._load()
+ + def _load(self): + self.storage = self.obj.attributes.get( + self.quest_storage_attribute_key, + category=self.quest_storage_attribute_category, + default={}, + ) + + def _save(self): + self.obj.attributes.add( + self.quest_storage_attribute_key, + self.storage, + category=self.quest_storage_attribute_category, + ) + self._load() # important + self.do_save = False + +
[docs] def has(self, quest_key): + """ + Check if a given quest is registered with the Character. + + Args: + quest_key (str): The name of the quest to check for. + quest_category (str, optional): Quest category, if any. + + Returns: + bool: If the character is following this quest or not. + + """ + return bool(self.storage.get(quest_key))
+ +
[docs] def get(self, quest_key): + """ + Get the quest stored on character, if any. + + Args: + quest_key (str): The name of the quest to check for. + + Returns: + EvAdventureQuest or None: The quest stored, or None if + Character is not on this quest. + + """ + return self.storage.get(quest_key)
+ +
[docs] def add(self, quest): + """ + Add a new quest + + Args: + quest (EvAdventureQuest): The quest class to start. + + """ + self.storage[quest.key] = quest(self.obj) + self._save()
+ +
[docs] def remove(self, quest_key): + """ + Remove a quest. If not complete, it will be abandoned. + + Args: + quest_key (str): The quest to remove. + + """ + quest = self.storage.pop(quest_key, None) + if not quest.is_completed: + # make sure to cleanup + quest.abandon() + self._save()
+ +
[docs] def get_help(self, quest_key=None): + """ + Get help text for a quest or for all quests. The help text is + a combination of the description of the quest and the help-text + of the current step. + + Args: + quest_key (str, optional): The quest-key. If not given, get help for all + quests in handler. + + Returns: + list: Help texts, one for each quest, or only one if `quest_key` is given. + + """ + help_texts = [] + if quest_key in self.storage: + quests = [self.storage[quest_key]] + else: + quests = self.storage.values() + + for quest in quests: + help_texts.append(f"|c{quest.key}|n\n {quest.desc}\n\n - {quest.help()}") + return help_texts
+ +
[docs] def progress(self, quest_key=None, *args, **kwargs): + """ + Check progress of a given quest or all quests. + + Args: + quest_key (str, optional): If given, check the progress of this quest (if we have it), + otherwise check progress on all quests. + *args, **kwargs: Will be passed into each quest's `progress` call. + + """ + if quest_key in self.storage: + quests = [self.storage[quest_key]] + else: + quests = self.storage.values() + + for quest in quests: + quest.progress(*args, **kwargs) + + if self.do_save: + # do_save is set by the quest + self._save()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/rooms.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/rooms.html new file mode 100644 index 0000000000..04716a3551 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/rooms.html @@ -0,0 +1,205 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.rooms — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.rooms

+"""
+EvAdventure rooms.
+
+The base EvAdventure room has a modified display header that shows a little mini-map.
+
+All EvAdventure rooms inherit from this room, and it is integral to combat as well as
+the dungeon generation. But one can also mix with other non-EvAdventure rooms (you will
+just not be able to fight in them).
+
+"""
+
+from copy import deepcopy
+
+from evennia import AttributeProperty, DefaultCharacter, DefaultRoom
+from evennia.utils.utils import inherits_from
+
+CHAR_SYMBOL = "|w@|n"
+CHAR_ALT_SYMBOL = "|w>|n"
+ROOM_SYMBOL = "|bo|n"
+LINK_COLOR = "|B"
+
+_MAP_GRID = [
+    [" ", " ", " ", " ", " "],
+    [" ", " ", " ", " ", " "],
+    [" ", " ", "@", " ", " "],
+    [" ", " ", " ", " ", " "],
+    [" ", " ", " ", " ", " "],
+]
+_EXIT_GRID_SHIFT = {
+    "north": (0, 1, "||"),
+    "east": (1, 0, "-"),
+    "south": (0, -1, "||"),
+    "west": (-1, 0, "-"),
+    "northeast": (1, 1, "/"),
+    "southeast": (1, -1, "\\"),
+    "southwest": (-1, -1, "/"),
+    "northwest": (-1, 1, "\\"),
+}
+
+
+
[docs]class EvAdventureRoom(DefaultRoom): + """ + Simple room supporting some EvAdventure-specifics. + + """ + + allow_combat = AttributeProperty(False, autocreate=False) + allow_pvp = AttributeProperty(False, autocreate=False) + allow_death = AttributeProperty(False, autocreate=False) + +
[docs] def format_appearance(self, appearance, looker, **kwargs): + """Don't left-strip the appearance string""" + return appearance.rstrip()
+ +
[docs] def get_display_header(self, looker, **kwargs): + """ + Display the current location as a mini-map. + + """ + # make sure to not show make a map for users of screenreaders. + # for optimization we also don't show it to npcs/mobs + if not inherits_from(looker, DefaultCharacter) or ( + looker.account and looker.account.uses_screenreader() + ): + return "" + + # build a map + map_grid = deepcopy(_MAP_GRID) + dx0, dy0 = 2, 2 + map_grid[dy0][dx0] = CHAR_SYMBOL + for exi in self.exits: + dx, dy, symbol = _EXIT_GRID_SHIFT.get(exi.key, (None, None, None)) + if symbol is None: + # we have a non-cardinal direction to go to - indicate this + map_grid[dy0][dx0] = CHAR_ALT_SYMBOL + continue + map_grid[dy0 + dy][dx0 + dx] = f"{LINK_COLOR}{symbol}|n" + if exi.destination != self: + map_grid[dy0 + dy + dy][dx0 + dx + dx] = ROOM_SYMBOL + + # Note that on the grid, dy is really going *downwards* (origo is + # in the top left), so we need to reverse the order at the end to mirror it + # vertically and have it come out right. + return " " + "\n ".join("".join(line) for line in reversed(map_grid))
+ + +
[docs]class EvAdventurePvPRoom(EvAdventureRoom): + """ + Room where PvP can happen, but noone gets killed. + + """ + + allow_combat = AttributeProperty(True, autocreate=False) + allow_pvp = AttributeProperty(True, autocreate=False) + +
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/rules.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/rules.html new file mode 100644 index 0000000000..2d2adbc12a --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/rules.html @@ -0,0 +1,433 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.rules — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.rules

+"""
+MUD ruleset based on the _Knave_ OSR tabletop RPG by Ben Milton (modified for MUD use).
+
+The center of the rule system is the "RollEngine", which handles all rolling of dice
+and determining what the outcome is.
+
+----
+
+"""
+from random import randint
+
+from .enums import Ability
+from .random_tables import death_and_dismemberment as death_table
+
+# Basic rolls
+
+
+
[docs]class EvAdventureRollEngine: + """ + This groups all dice rolls of EvAdventure. These could all have been normal functions, but we + are group them in a class to make them easier to partially override and replace later. + + """ + +
[docs] def roll(self, roll_string, max_number=10): + """ + NOTE: In evennia/contribs/rpg/dice/ is a more powerful dice roller with + more features, such as modifiers, secret rolls etc. This is much simpler and only + gets a simple sum of normal rpg-dice. + + Args: + roll_string (str): A roll using standard rpg syntax, <number>d<diesize>, like + 1d6, 2d10 etc. Max die-size is 1000. + max_number (int): The max number of dice to roll. Defaults to 10, which is usually + more than enough. + + Returns: + int: The rolled result - sum of all dice rolled. + + Raises: + TypeError: If roll_string is not on the right format or otherwise doesn't validate. + + Notes: + Since we may see user input to this function, we make sure to validate the inputs (we + wouldn't bother much with that if it was just for developer use). + + """ + max_diesize = 1000 + roll_string = roll_string.lower() + if "d" not in roll_string: + raise TypeError( + f"Dice roll '{roll_string}' was not recognized. Must be `<number>d<dicesize>`." + ) + number, diesize = roll_string.split("d", 1) + try: + number = int(number) + diesize = int(diesize) + except Exception: + raise TypeError(f"The number and dice-size of '{roll_string}' must be numerical.") + if 0 < number > max_number: + raise TypeError(f"Invalid number of dice rolled (must be between 1 and {max_number})") + if 0 < diesize > max_diesize: + raise TypeError(f"Invalid die-size used (must be between 1 and {max_diesize} sides)") + + # At this point we know we have valid input - roll and add dice together + return sum(randint(1, diesize) for _ in range(number))
+ +
[docs] def roll_with_advantage_or_disadvantage(self, advantage=False, disadvantage=False): + """ + Base roll of d20, or 2d20, based on dis/advantage given. + + Args: + bonus (int): The ability bonus to apply, like strength or charisma. + advantage (bool): Roll 2d20 and use the bigger number. + disadvantage (bool): Roll 2d20 and use the smaller number. + + Notes: + Disadvantage and advantage cancel each other out. + + """ + if not (advantage or disadvantage) or (advantage and disadvantage): + # normal roll, or advantage cancels disadvantage + return self.roll("1d20") + elif advantage: + return max(self.roll("1d20"), self.roll("1d20")) + else: + return min(self.roll("1d20"), self.roll("1d20"))
+ +
[docs] def saving_throw( + self, + character, + bonus_type=Ability.STR, + target=15, + advantage=False, + disadvantage=False, + modifier=0, + ): + """ + A saving throw without a clear enemy to beat. In _Knave_ all unopposed saving + throws always tries to beat 15, so (d20 + bonus + modifier) > 15. + + Args: + character (Object): The one attempting to save themselves. + bonus_type (enum.Ability): The ability bonus to apply, like strength or + charisma. + target (int, optional): Used for opposed throws (in Knave any regular + saving through must always beat 15). + advantage (bool, optional): Roll 2d20 and use the bigger number. + disadvantage (bool, optional): Roll 2d20 and use the smaller number. + modifier (int, optional): An additional +/- modifier to the roll. + + Returns: + tuple: A tuple `(bool, str, str)`. The bool indicates if the save was passed or not. + The second element is the quality of the roll - None (normal), + "critical fail" and "critical success". Last element is a text detailing + the roll, for display purposes. + Notes: + Advantage and disadvantage cancel each other out. + + Example: + Trying to overcome the effects of poison, roll d20 + Constitution-bonus above 15. + + """ + bonus = getattr(character, bonus_type.value, 1) + dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage) + if dice_roll == 1: + quality = Ability.CRITICAL_FAILURE + elif dice_roll == 20: + quality = Ability.CRITICAL_SUCCESS + else: + quality = None + result = dice_roll + bonus + modifier > target + + # determine text output + rolltxt = "d20 " + if advantage and disadvantage: + rolltxt = "d20 (advantage canceled by disadvantage)" + elif advantage: + rolltxt = "|g2d20|n (advantage: picking highest) " + elif disadvantage: + rolltxt = "|r2d20|n (disadvantage: picking lowest) " + bontxt = f"(+{bonus})" + modtxt = "" + if modifier: + modtxt = f"+ {modifier}" if modifier > 0 else f" - {abs(modifier)}" + qualtxt = f" ({quality.value}!)" if quality else "" + + txt = ( + f" rolled {dice_roll} on {rolltxt} " + f"+ {bonus_type.value}{bontxt}{modtxt} vs " + f"{target} -> |w{'|GSuccess|w' if result else '|RFail|w'}{qualtxt}|n" + ) + + return (dice_roll + bonus + modifier) > target, quality, txt
+ +
[docs] def opposed_saving_throw( + self, + attacker, + defender, + attack_type=Ability.STR, + defense_type=Ability.ARMOR, + advantage=False, + disadvantage=False, + modifier=0, + ): + """ + An saving throw that tries to beat an active opposing side. + + Args: + attacker (Character): The attacking party. + defender (Character): The one defending. + attack_type (str): Which ability to use in the attack, like 'strength' or 'willpower'. + Minimum is always 1. + defense_type (str): Which ability to defend with, in addition to 'armor'. + Minimum is always 11 (bonus + 10 is always the defense in _Knave_). + advantage (bool): Roll 2d20 and use the bigger number. + disadvantage (bool): Roll 2d20 and use the smaller number. + modifier (int): An additional +/- modifier to the roll. + + Returns: + tuple: (bool, str, str): If the attack succeed or not. The second element is the + quality of the roll - None (normal), "critical fail" and "critical success". Last + element is a text that summarizes the details of the roll. + Notes: + Advantage and disadvantage cancel each other out. + + """ + # what is stored on the character/npc is the bonus; we add 10 to get the defense target + defender_defense = getattr(defender, defense_type.value, 1) + 10 + + result, quality, txt = self.saving_throw( + attacker, + bonus_type=attack_type, + target=defender_defense, + advantage=advantage, + disadvantage=disadvantage, + modifier=modifier, + ) + txt = f"Roll vs {defense_type.value}({defender_defense}):\n{txt}" + + return result, quality, txt
+ +
[docs] def roll_random_table(self, dieroll, table_choices): + """ + Make a roll on a random table. + + Args: + dieroll (str): The dice to roll, like 1d6, 1d20, 3d6 etc). + table_choices (iterable): If a list of single elements, the die roll + should fully encompass the table, like a 1d20 roll for a table + with 20 elements. If each element is a tuple, the first element + of the tuple is assumed to be a string 'X-Y' indicating the + range of values that should match the roll. + + Returns: + Any: The result of the random roll. + + Example: + `roll table_choices = [('1-5', "Blue"), ('6-9': "Red"), ('10', "Purple")]` + + Notes: + If the roll is outside of the listing, the closest edge value is used. + + """ + roll_result = self.roll(dieroll) + if not table_choices: + return None + + if isinstance(table_choices[0], (tuple, list)): + # tuple with range conditional, like ('1-5', "Blue") or ('10', "Purple") + max_range = -1 + min_range = 10**6 + for valrange, choice in table_choices: + minval, *maxval = valrange.split("-", 1) + minval = abs(int(minval)) + maxval = abs(int(maxval[0]) if maxval else minval) + + # we store the largest/smallest values so far in case we need to use them + max_range = max(max_range, maxval) + min_range = min(min_range, minval) + + if minval <= roll_result <= maxval: + return choice + + # if we have no result, we are outside of the range, we pick the edge values. It is also + # possible the range contains 'gaps', but that'd be an error in the random table itself. + if roll_result > max_range: + return table_choices[-1][1] + else: + return table_choices[0][1] + else: + # regular list - one line per value. + roll_result = max(1, min(len(table_choices), roll_result)) + return table_choices[roll_result - 1]
+ + # specific rolls / actions + +
[docs] def morale_check(self, defender): + """ + A morale check is done for NPCs/monsters. It's done with a 2d6 against + their morale. + + Args: + defender (NPC): The entity trying to defend its morale. + + Returns: + bool: False if morale roll failed, True otherwise. + + """ + return self.roll("2d6") <= defender.morale
+ +
[docs] def heal_from_rest(self, character): + """ + A meal and a full night's rest allow for regaining 1d8 + Const bonus HP. + + Args: + character (Character): The one resting. + + """ + character.heal(self.roll("1d8") + character.constitution)
+ + death_map = { + "weakened": "strength", + "unsteady": "dexterity", + "sickly": "constitution", + "addled": "intelligence", + "rattled": "wisdom", + "disfigured": "charisma", + } + +
[docs] def roll_death(self, character): + """ + Happens when hitting <= 0 hp. unless dead, + + """ + + result = self.roll_random_table("1d8", death_table) + if result == "dead": + character.at_death() + else: + # survives with degraded abilities (1d4 roll) + abi = self.death_map[result] + + current_abi = getattr(character, abi) + loss = self.roll("1d4") + + current_abi -= loss + + if current_abi < -10: + # can't lose more - die + character.at_death() + else: + # refresh health, but get permanent ability loss + new_hp = self.roll("1d4") + character.heal(new_hp) + setattr(character, abi, current_abi) + + character.msg( + "~" * 78 + "\n|yYou survive your brush with death, " + f"but are |r{result.upper()}|y and permanently |rlose {loss} {abi}|y.|n\n" + f"|GYou recover |g{new_hp}|G health|.\n" + "~" * 78 + )
+ + +# singletons + +# access rolls e.g. with rules.dice.opposed_saving_throw(...) +dice = EvAdventureRollEngine() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/shops.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/shops.html new file mode 100644 index 0000000000..ef5fe4122b --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/shops.html @@ -0,0 +1,602 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.shops — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.shops

+"""
+EvAdventure Shop system.
+
+
+A shop is run by an NPC. It can provide one or more of several possible services:
+
+- Buy from a pre-set list of (possibly randomized) items. Cost is based on the item's value,
+  adjusted by how stingy the shopkeeper is. When bought this way, the item is
+  generated on the fly and passed to the player character's inventory. Inventory files are
+  a list of prototypes, normally from a prototype-file. A random selection of items from each
+  inventory file is available.
+- Sell items to the shop for a certain percent of their value. One could imagine being able
+  to buy back items again, but we will instead _destroy_ sold items, so as to remove them
+  from circulation. In-game we can say it's because the merchants collect the best stuff
+  to sell to collectors in the big city later. Each merchant buys a certain subset of items
+  based on their tags.
+- Buy a service. For a cost, a certain action is performed for the character; this applies
+  immediately when bought. The most notable services are healing and converting coin to XP.
+- Buy rumors - this is echoed to the player for a price. Different merchants could have
+  different rumors (or randomized ones).
+- Quest - gain or hand in a quest for a merchant.
+
+All shops are menu-driven. One starts talking to the npc and will then end up in their shop
+interface.
+
+
+This is a series of menu nodes meant to be added as a mapping via
+`EvAdventureShopKeeper.create(menudata={},...)`.
+
+To make this pluggable, the shopkeeper start page will analyze the available nodes
+and auto-add options to all nodes in the three named `node_start_*`. The last part of the
+node name will be the name of the option capitalized, with underscores replaced by spaces, so
+`node_start_sell_items` will become a top-level option `Sell items`.
+
+
+
+"""
+
+from dataclasses import dataclass
+
+from evennia.prototypes.prototypes import search_prototype
+from evennia.prototypes.spawner import flatten_prototype, spawn
+from evennia.utils.evmenu import list_node
+from evennia.utils.logger import log_err, log_trace
+
+from .enums import ObjType, WieldLocation
+from .equipment import EquipmentError
+
+# ------------------------------------ Buying from an NPC
+
+
+
[docs]@dataclass +class BuyItem: + """ + Storage container for storing generic info about an item for sale. This means it can be used + both for real objects and for prototypes without constantly having to track which is which. + + """ + + # skipping typehints here since we are not using them anywhere else + + # available for all buyable items + key = "" + desc = "" + obj_type = ObjType.GEAR + size = 1 + value = 0 + use_slot = WieldLocation.BACKPACK + + uses = None + quality = None + attack_type = None + defense_type = None + damage_roll = None + + # references the original (always only one of the two) + obj = None + prototype = None + +
[docs] @staticmethod + def create_from_obj(obj, shopkeeper): + """ + Build a new BuyItem container from a real db obj. + + Args: + obj (EvAdventureObject): An object to analyze. + shopkeeper (EvAdventureShopKeeper): The shopkeeper. + + Returns: + BuyItem: A general representation of the original data. + + """ + try: + # mandatory + key = obj.key + desc = obj.db.desc + obj_type = obj.obj_type + size = obj.size + use_slot = obj.use_slot + value = obj.value * shopkeeper.upsell_factor + except AttributeError: + # not a buyable item + log_trace("Not a buyable item") + return None + + # getting optional properties + + return BuyItem( + key=key, + desc=desc, + obj_type=obj_type, + size=size, + use_slot=use_slot, + value=value, + # optional fields + uses=getattr(obj, "uses", None), + quality=getattr(obj, "quality", None), + attack_type=getattr(obj, "attack_type", None), + defense_type=getattr(obj, "defense_type", None), + damage_roll=getattr(obj, "damage_roll", None), + # back-reference (don't set prototype) + obj=obj, + )
+ +
[docs] @staticmethod + def create_from_prototype(self, prototype_or_key, shopkeeper): + """ + Build a new BuyItem container from a prototype. + + Args: + prototype (dict or key): An Evennia prototype dict or the key of one + registered with the system. This is assumed to be a full prototype, + including having parsed and included parentage. + + Returns: + BuyItem: A general representation of the original data. + + """ + + def _get_attr_value(key, prot, optional=True): + """ + We want the attribute's value, which is always in the `attrs` field of + the prototype. + + """ + attr = [tup for tup in prot.get("attrs", ()) if tup[0] == key] + try: + return attr[0][1] + except IndexError: + if optional: + return None + raise + + if isinstance(prototype_or_key, dict): + prototype = prototype_or_key + else: + # make sure to generate a 'full' prototype with all inheritance applied ('flattened'), + # otherwise we will not get inherited data when we analyze it. + prototype = flatten_prototype(search_prototype(key=prototype_or_key)) + + if not prototype: + log_err(f"No valid prototype '{prototype_or_key}' found") + return None + + try: + # at this point we should have a full, flattened prototype ready to spawn. It must + # contain all fields needed for buying + key = prototype["key"] + desc = _get_attr_value("desc", prototype, optional=False) + obj_type = _get_attr_value("obj_type", prototype, optional=False) + size = _get_attr_value("size", prototype, optional=False) + use_slot = _get_attr_value("use_slot", prototype, optional=False) + value = int( + _get_attr_value("value", prototype, optional=False) * shopkeeper.upsell_factor + ) + except (KeyError, IndexError): + # not a buyable item + log_trace("Not a buyable item") + return None + + return BuyItem( + key=key, + desc=desc, + obj_type=obj_type, + size=size, + use_slot=use_slot, + value=value, + # optional fields + uses=_get_attr_value("uses", prototype), + quality=_get_attr_value("quality", prototype), + attack_type=_get_attr_value("attack_type", prototype), + defense_type=_get_attr_value("defense_type", prototype), + damage_roll=_get_attr_value("damage_roll", prototype), + # back-reference (don't set obj) + prototype=prototype, + )
+ + def __str__(self): + """ + Get the short description to show in buy list. + + """ + return f"{self.key} [|y{self.value}|n coins]" + +
[docs] def get_detail(self): + """ + Get more info when looking at the item. + + """ + return f""" +|c{self.key}|n Cost: |y{self.value}|n coins + +{self.desc} + +Slots: |w{self.size}|n Used from: |w{self.use_slot.value}|n +Quality: |w{self.quality}|n Uses: |wself.uses|n +Attacks using: |w{self.attack_type.value}|n against |w{self.defense_type.value}|n +Damage roll: |w{self.damage_roll}"""
+ +
[docs] def to_obj(self): + """ + Convert this into an actual database object that we can trade. This either means + using the stored `.prototype` to spawn a new instance of the object, or to + use the `.obj` reference to get the already existing object. + + """ + if self.obj: + return self.obj + return spawn(self.prototype)
+ + +def _get_or_create_buymap(caller, shopkeep): + """ + Helper that fetches or creates the mapping of `{"short description": BuyItem, ...}` + we need for the buy menu. We cache it on the `_evmenu` object on the caller. + + """ + if not caller.ndb._evmenu.buymap: + # buymap not in cache - build it and store in memory on _evmenu object - this way + # it will be removed automatically when the menu closes. We will need to reset this + # when the shopkeep buys new things. + # items carried by the shopkeep are sellable (these are items already created, such as + # things sold to the shopkeep earlier). We + obj_wares = [BuyItem.create_from_obj(obj) for obj in list(shopkeep.contents)] + prototype_wares = [ + BuyItem.create_from_prototype(prototype) + for prototype in shopkeep.common_ware_prototypes + ] + wares = obj_wares + prototype_wares + caller.ndb._evmenu.buymap = {str(ware): ware for ware in wares if ware} + + return caller.ndb._evmenu.buymap + + +# Helper functions for building the shop listings and select a ware to buy +def _get_all_wares_to_buy(caller, raw_string, **kwargs): + """ + This helper is used by `EvMenu.list_node` to build the list of items to buy. + + We rely on `**kwargs` being forwarded from `node_start_buy`, which in turns contains + the `npc` kwarg pointing to the shopkeeper (`caller` is the one doing the buying). + + """ + shopkeep = kwargs["npc"] + buymap = _get_or_create_buymap(caller, shopkeep) + return [ware_desc for ware_desc in buymap] + + +def _select_ware_to_buy(caller, selected_ware_desc, **kwargs): + """ + This helper is used by `EvMenu.list_node` to operate on what the user selected. + We return `item` in the kwargs to the `node_select_buy` node. + + """ + shopkeep = kwargs["npc"] + buymap = _get_or_create_buymap(caller, shopkeep) + kwargs["item"] = buymap[selected_ware_desc] + + return "node_confirm_buy", kwargs + + +def _back_to_previous_node(caller, raw_string, **kwargs): + """ + Back to previous node is achieved by returning a node of None. + + """ + return None, kwargs + + +def _buy_ware(caller, raw_string, **kwargs): + """ + Complete the purchase of a ware. At this point the money is deducted + and the item is either spawned from a prototype or simply moved from + the sellers inventory to that of the buyer. + + We will have kwargs `item` and `npc` passed along to refer to the BuyItem we bought + and the shopkeep selling it. + + """ + item = kwargs["item"] # a BuyItem instance + shopkeep = kwargs["npc"] + + # exchange money + caller.coins -= item.value + shopkeep += item.value + + # get the item - if not enough room, dump it on the ground + obj = item.to_obj() + try: + caller.equipment.add(obj) + except EquipmentError as err: + obj.location = caller.location + caller.msg(err) + caller.msg(f"|w{obj.key} ends up on the ground.|n") + + caller.msg("|gYou bought |w{obj.key}|g for |y{item.value}|g coins.|n") + + +@list_node(_get_all_wares_to_buy, select=_select_ware_to_buy, pagesize=40) +def node_start_buy(caller, raw_string, **kwargs): + """ + Menu node for the caller to buy items from the shopkeep. This assumes `**kwargs` contains + a kwarg `npc` referencing the npc/shopkeep being talked to. + + Items available to sell are a combination of items in the shopkeep's inventory and + the list of `prototypes` stored in the Shopkeep's "common_ware_prototypes` Attribute. In the + latter case, the properties will be extracted from the prototype when inspecting it (object will + only spawn when bought). + + """ + coins = caller.coins + used_slots = caller.equipment.count_slots() + max_slots = caller.equipment.max_slots + + text = ( + f'"Seeing something you like?" [you have |y{coins}|n coins, ' + f"using |b{used_slots}/{max_slots}|n slots]" + ) + # this will be in addition to the options generated by the list-node + extra_options = [{"key": ("[c]ancel", "b", "c", "cancel"), "goto": "node_start"}] + + return text, extra_options + + +
[docs]def node_confirm_buy(caller, raw_string, **kwargs): + """ + Menu node reached when a user selects an item in the buy menu. The `item` passed + along in `**kwargs` is the selected item (see `_select_ware_to_buy`, where this is injected). + + """ + # this was injected in _select_ware_to_buy. This is an BuyItem instance. + item = kwargs["item"] + + coins = caller.coins + used_slots = caller.equipment.count_slots() + max_slots = caller.equipment.max_slots + + text = item.get_detail() + text += f"\n\n[You have |y{coins}|n coins] and are using |b{used_slots}/{max_slots}|n slots]" + + options = [] + + if caller.coins >= item.value and item.size <= (max_slots - used_slots): + options.append({"desc": f"Buy [{item.value} coins]", "goto": (_buy_ware, kwargs)}) + options.append({"desc": "Cancel", "goto": (_back_to_previous_node, kwargs)}) + + return text, options
+ + +# node tree to inject for buying things +node_tree_buy = {"node_start_buy": node_start_buy, "node_confirm_buy": node_confirm_buy} + + +# ------------------------------------------------- Selling to an NPC + + +def _get_or_create_sellmap(self, caller, shopkeep): + if not caller.ndb._evmenu.sellmap: + # no sellmap, build one anew + + sellmap = {} + for obj, wieldlocation in caller.equipment.all(): + key = obj.key + value = int(obj.value * shopkeep.miser_factor) + if value > 0 and obj.obj_type is not ObjType.QUEST: + sellmap[f"|w{key}|n [{wieldlocation.value}] - sell price |y{value}|n coins"] = ( + obj, + value, + ) + caller.ndb._evmenu.sellmap = sellmap + + sellmap = caller.ndb._evmenu.sellmap + + return sellmap + + +def _get_all_wares_to_sell(caller, raw_string, **kwargs): + """ + Get all wares available to sell from caller's inventory. We need to build a + mapping between the descriptors and the items. + + """ + shopkeep = kwargs["npc"] + sellmap = _get_or_create_sellmap(caller, shopkeep) + return [ware_desc for ware_desc in sellmap] + + +def _sell_ware(caller, raw_string, **kwargs): + """ + Complete the sale of a ware. This is were money is gained and the item is removed. + + We will have kwargs `item`, `value` and `npc` passed along to refer to the inventory item we + sold, its (adjusted) sales cost and the shopkeep buying it. + + """ + item = kwargs["item"] + value = kwargs["value"] + shopkeep = kwargs["npc"] + + # move item to shopkeep + obj = caller.equipment.remove(item) + obj.location = shopkeep + + # exchange money - shopkeep always have money to pay, so we don't deduct from them + caller.coins += value + + caller.msg("|gYou sold |w{obj.key}|g for |y{value}|g coins.|n") + + +def _select_ware_to_sell(caller, selected_ware_desc, **kwargs): + """ + Selected one ware to sell. Figure out which one it is using the sellmap. + Store the result as "item" kwarg. + + """ + shopkeep = kwargs["npc"] + sellmap = _get_or_create_sellmap(caller, shopkeep) + kwargs["item"], kwargs["value"] = sellmap[selected_ware_desc] + + return "node_examine_sell", kwargs + + +@list_node(_get_all_wares_to_sell, select=_select_ware_to_sell, pagesize=20) +def node_start_sell(caller, raw_string, **kwargs): + """ + The start-level node for selling items from the user's inventory. This assumes + `**kwargs` contains a kwarg `npc` referencing the npc/shopkeep being talked to. + + Items available to sell are all items in the player's equipment handler, including + things in their hands. + + """ + coins = caller.coins + used_slots = caller.equipment.count_slots() + max_slots = caller.equipment.max_slots + + text = ( + f'"Anything you want to sell?" [you have |y{coins}|n coins, ' + f"using |b{used_slots}/{max_slots}|n slots]" + ) + # this will be in addition to the options generated by the list-node + extra_options = [{"key": ("[c]ancel", "b", "c", "cancel"), "goto": "node_start"}] + + return text, extra_options + + +
[docs]def node_confirm_sell(caller, raw_string, **kwargs): + """ + In this node we confirm the sell by first investigating the item we are about to sell. + + We have `item` and `value` available in kwargs here, added by `_select_ware_to_sell` earler. + + """ + item = kwargs["item"] + value = kwargs["value"] + + coins = caller.coins + used_slots = caller.equipment.count_slots() + max_slots = caller.equipment.max_slots + + text = caller.equipment.get_obj_stats(item) + text += f"\n\n[You have |y{coins}|n coins] and are using |b{used_slots}/{max_slots}|n slots]" + + options = ( + {"desc": f"Sell [{value} coins]", "goto": (_sell_ware, kwargs)}, + {"desc": "Cancel", "goto": (_back_to_previous_node, kwargs)}, + ) + + return text, options
+ + +# node tree to inject for selling things +node_tree_sell = {"node_start_sell": node_start_sell, "node_confirm_sell": node_confirm_sell} + + +# Full shopkeep node tree - inject into ShopKeep NPC menu to add buy/sell submenus +node_tree_shopkeep = {**node_tree_buy, **node_tree_sell} +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/mixins.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/mixins.html new file mode 100644 index 0000000000..b04430323d --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/mixins.html @@ -0,0 +1,158 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.mixins — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.mixins

+"""
+Helpers for testing evadventure modules.
+
+"""
+
+from evennia.utils import create
+
+from .. import enums
+from ..characters import EvAdventureCharacter
+from ..objects import (
+    EvAdventureArmor,
+    EvAdventureHelmet,
+    EvAdventureObject,
+    EvAdventureShield,
+    EvAdventureWeapon,
+)
+from ..rooms import EvAdventureRoom
+
+
+
[docs]class EvAdventureMixin: + """ + Provides a set of pre-made characters. + + """ + +
[docs] def setUp(self): + super().setUp() + self.location = create.create_object(EvAdventureRoom, key="testroom") + self.character = create.create_object( + EvAdventureCharacter, key="testchar", location=self.location + ) + self.helmet = create.create_object( + EvAdventureHelmet, + key="helmet", + ) + self.shield = create.create_object( + EvAdventureShield, + key="shield", + ) + self.armor = create.create_object( + EvAdventureArmor, + key="armor", + ) + self.weapon = create.create_object( + EvAdventureWeapon, + key="weapon", + ) + self.big_weapon = create.create_object( + EvAdventureWeapon, + key="big_weapon", + attributes=[("inventory_use_slot", enums.WieldLocation.TWO_HANDS)], + ) + self.item = create.create_object(EvAdventureObject, key="backpack item")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_characters.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_characters.html new file mode 100644 index 0000000000..fe908bd469 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_characters.html @@ -0,0 +1,151 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_characters — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_characters

+"""
+Test characters.
+
+"""
+
+from evennia.utils import create
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from ..characters import EvAdventureCharacter
+
+
+
[docs]class TestCharacters(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.character = create.create_object(EvAdventureCharacter, key="testchar")
+ +
[docs] def test_abilities(self): + self.character.strength += 2 + self.assertEqual(self.character.strength, 3)
+ +
[docs] def test_heal(self): + """Make sure we don't heal too much""" + self.character.hp = 0 + self.character.hp_max = 8 + + self.character.heal(1) + self.assertEqual(self.character.hp, 1) + self.character.heal(100) + self.assertEqual(self.character.hp, 8)
+ +
[docs] def test_at_damage(self): + self.character.hp = 8 + self.character.at_damage(5) + self.assertEqual(self.character.hp, 3)
+ +
[docs] def test_at_pay(self): + self.character.coins = 100 + + result = self.character.at_pay(60) + self.assertEqual(result, 60) + self.assertEqual(self.character.coins, 40) + + # can't get more coins than we have + result = self.character.at_pay(100) + self.assertEqual(result, 40) + self.assertEqual(self.character.coins, 0)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_chargen.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_chargen.html new file mode 100644 index 0000000000..6bb80693c7 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_chargen.html @@ -0,0 +1,171 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_chargen — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_chargen

+"""
+Test chargen.
+
+"""
+
+from unittest.mock import MagicMock, patch
+
+from parameterized import parameterized
+
+from evennia import create_object
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .. import chargen, enums, objects
+
+
+
[docs]class EvAdventureCharacterGenerationTest(BaseEvenniaTest): + """ + Test the Character generator in the rule engine. + + """ + +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def setUp(self, mock_randint): + super().setUp() + mock_randint.return_value = 10 + self.chargen = chargen.TemporaryCharacterSheet()
+ +
[docs] def test_base_chargen(self): + self.assertEqual(self.chargen.strength, 10) # not realistic, due to mock + self.assertEqual(self.chargen.armor, "gambeson") + self.assertEqual(self.chargen.shield, "shield") + self.assertEqual( + self.chargen.backpack, ["ration", "ration", "waterskin", "waterskin", "drill", "twine"] + )
+ +
[docs] def test_build_desc(self): + self.assertEqual( + self.chargen.desc, + "You are scrawny with a broken face, pockmarked skin, greased hair, hoarse speech, and " + "stained clothing. You were a Herbalist, but you were exiled and ended up a knave. You " + "are honest but also irascible. You tend towards neutrality.", + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.chargen.spawn") + def test_apply(self, mock_spawn): + gambeson = create_object(objects.EvAdventureArmor, key="gambeson") + mock_spawn.return_value = [gambeson] + account = MagicMock() + account.id = 2222 + + character = self.chargen.apply(account) + + self.assertIn("Herbalist", character.db.desc) + self.assertEqual( + character.equipment.all(), + [ + (None, enums.WieldLocation.WEAPON_HAND), + (None, enums.WieldLocation.SHIELD_HAND), + (None, enums.WieldLocation.TWO_HANDS), + (gambeson, enums.WieldLocation.BODY), + (None, enums.WieldLocation.HEAD), + ], + ) + + gambeson.delete() + character.delete()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_combat.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_combat.html new file mode 100644 index 0000000000..36ef6c5979 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_combat.html @@ -0,0 +1,839 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_combat — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_combat

+"""
+Test EvAdventure combat.
+
+"""
+
+from unittest.mock import Mock, call, patch
+
+from evennia.utils import create
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.test_resources import (
+    BaseEvenniaTest,
+    EvenniaCommandTestMixin,
+    EvenniaTestCase,
+)
+
+from .. import combat_base, combat_turnbased, combat_twitch
+from ..characters import EvAdventureCharacter
+from ..enums import Ability, WieldLocation
+from ..npcs import EvAdventureMob
+from ..objects import EvAdventureConsumable, EvAdventureRunestone, EvAdventureWeapon
+from ..rooms import EvAdventureRoom
+
+
+class _CombatTestBase(EvenniaTestCase):
+    """
+    Set up common entities for testing combat:
+
+    - `location` (key=testroom)
+    - `combatant` (key=testchar)
+    - `target` (key=testmonster)`
+
+    We also mock the `.msg` method of both `combatant` and `target` so we can
+    see what was sent.
+
+    """
+
+    def setUp(self):
+        self.location = create.create_object(EvAdventureRoom, key="testroom")
+        self.combatant = create.create_object(
+            EvAdventureCharacter, key="testchar", location=self.location
+        )
+
+        self.location.allow_combat = True
+        self.location.allow_death = True
+
+        self.target = create.create_object(
+            EvAdventureMob,
+            key="testmonster",
+            location=self.location,
+            attributes=(("is_idle", True),),
+        )
+
+        # mock the msg so we can check what they were sent later
+        self.combatant.msg = Mock()
+        self.target.msg = Mock()
+
+
+
[docs]class TestEvAdventureCombatBaseHandler(_CombatTestBase): + """ + Test the base functionality of the base combat handler. + + """ + +
[docs] def setUp(self): + """This also tests the `get_or_create_combathandler` classfunc""" + super().setUp() + self.combathandler = combat_base.EvAdventureCombatBaseHandler.get_or_create_combathandler( + self.location, key="combathandler" + )
+ +
[docs] def test_combathandler_msg(self): + """Test sending messages to all in handler""" + + self.location.msg_contents = Mock() + + self.combathandler.msg("test_message") + + self.location.msg_contents.assert_called_with( + "test_message", + exclude=[], + from_obj=None, + mapping={"testchar": self.combatant, "testmonster": self.target}, + )
+ +
[docs] def test_get_combat_summary(self): + """Test combat summary""" + + self.combathandler.get_sides = Mock(return_value=([self.combatant], [self.target])) + + # as seen from one side + result = str(self.combathandler.get_combat_summary(self.combatant)) + + self.assertEqual( + strip_ansi(result), + " testchar (Perfect) vs testmonster (Perfect) ", + ) + + # as seen from other side + self.combathandler.get_sides = Mock(return_value=([self.target], [self.combatant])) + result = str(self.combathandler.get_combat_summary(self.target)) + + self.assertEqual( + strip_ansi(result), + " testmonster (Perfect) vs testchar (Perfect) ", + )
+ + +
[docs]class TestCombatActionsBase(_CombatTestBase): + """ + A class for testing all subclasses of CombatAction in combat_base.py + + """ + +
[docs] def setUp(self): + super().setUp() + self.combathandler = combat_base.EvAdventureCombatBaseHandler.get_or_create_combathandler( + self.location, key="combathandler" + ) + # we need to mock all NotImplemented methods + self.combathandler.get_sides = Mock(return_value=([], [self.target])) + self.combathandler.give_advantage = Mock() + self.combathandler.give_disadvantage = Mock() + self.combathandler.remove_advantage = Mock() + self.combathandler.remove_disadvantage = Mock() + self.combathandler.get_advantage = Mock() + self.combathandler.get_disadvantage = Mock() + self.combathandler.has_advantage = Mock() + self.combathandler.has_disadvantage = Mock() + self.combathandler.queue_action = Mock()
+ +
[docs] def test_base_action(self): + action = combat_base.CombatAction( + self.combathandler, self.combatant, {"key": "hold", "foo": "bar"} + ) + self.assertEqual(action.key, "hold") + self.assertEqual(action.foo, "bar") + self.assertEqual(action.combathandler, self.combathandler) + self.assertEqual(action.combatant, self.combatant)
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_attack__miss(self, mock_randint): + actiondict = {"key": "attack", "target": self.target} + + mock_randint.return_value = 8 # target has default armor 11, so 8+1 str will miss + action = combat_base.CombatActionAttack(self.combathandler, self.combatant, actiondict) + action.execute() + self.assertEqual(self.target.hp, 4)
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_attack__success(self, mock_randint): + actiondict = {"key": "attack", "target": self.target} + + mock_randint.return_value = 11 # 11 + 1 str will hit beat armor 11 + self.target.hp = 20 + action = combat_base.CombatActionAttack(self.combathandler, self.combatant, actiondict) + action.execute() + self.assertEqual(self.target.hp, 9)
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_stunt_fail(self, mock_randint): + action_dict = { + "key": "stunt", + "recipient": self.combatant, + "target": self.target, + "advantage": True, + "stunt_type": Ability.STR, + "defense_type": Ability.DEX, + } + mock_randint.return_value = 8 # fails 8+1 dex vs DEX 11 defence + action = combat_base.CombatActionStunt(self.combathandler, self.combatant, action_dict) + action.execute() + self.combathandler.give_advantage.assert_not_called()
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_stunt_advantage__success(self, mock_randint): + action_dict = { + "key": "stunt", + "recipient": self.combatant, + "target": self.target, + "advantage": True, + "stunt_type": Ability.STR, + "defense_type": Ability.DEX, + } + mock_randint.return_value = 11 # 11+1 dex vs DEX 11 defence is success + action = combat_base.CombatActionStunt(self.combathandler, self.combatant, action_dict) + action.execute() + self.combathandler.give_advantage.assert_called_with(self.combatant, self.target)
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_stunt_disadvantage__success(self, mock_randint): + action_dict = { + "key": "stunt", + "recipient": self.target, + "target": self.combatant, + "advantage": False, + "stunt_type": Ability.STR, + "defense_type": Ability.DEX, + } + mock_randint.return_value = 11 # 11+1 dex vs DEX 11 defence is success + action = combat_base.CombatActionStunt(self.combathandler, self.combatant, action_dict) + action.execute() + self.combathandler.give_disadvantage.assert_called_with(self.target, self.combatant)
+ +
[docs] def test_use_item(self): + """ + Use up a potion during combat. + + """ + item = create.create_object( + EvAdventureConsumable, key="Healing potion", attributes=[("uses", 2)] + ) + + item.use = Mock() + + action_dict = { + "key": "use", + "item": item, + "target": self.target, + } + + self.assertEqual(item.uses, 2) + action = combat_base.CombatActionUseItem(self.combathandler, self.combatant, action_dict) + action.execute() + self.assertEqual(item.uses, 1) + action.execute() + self.assertEqual(item.pk, None) # deleted, it was used up
+ +
[docs] def test_swap_wielded_weapon_or_spell(self): + """ + First draw a weapon (from empty fists), then swap that out to another weapon, then + swap to a spell rune. + + """ + sword = create.create_object(EvAdventureWeapon, key="sword") + zweihander = create.create_object( + EvAdventureWeapon, + key="zweihander", + attributes=(("inventory_use_slot", WieldLocation.TWO_HANDS),), + ) + runestone = create.create_object(EvAdventureRunestone, key="ice rune") + + # check hands are empty + self.assertEqual(self.combatant.weapon.key, "Bare hands") + self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], None) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], None) + + # swap to sword + + actiondict = {"key": "wield", "item": sword} + + action = combat_base.CombatActionWield(self.combathandler, self.combatant, actiondict) + action.execute() + + self.assertEqual(self.combatant.weapon, sword) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], sword) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], None) + + # swap to zweihander (two-handed sword) + actiondict["item"] = zweihander + + action = combat_base.CombatActionWield(self.combathandler, self.combatant, actiondict) + action.execute() + + self.assertEqual(self.combatant.weapon, zweihander) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], None) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], zweihander) + + # swap to runestone (also using two hands) + actiondict["item"] = runestone + + action = combat_base.CombatActionWield(self.combathandler, self.combatant, actiondict) + action.execute() + + self.assertEqual(self.combatant.weapon, runestone) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], None) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], runestone) + + # swap back to normal one-handed sword + actiondict["item"] = sword + + action = combat_base.CombatActionWield(self.combathandler, self.combatant, actiondict) + action.execute() + + self.assertEqual(self.combatant.weapon, sword) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.WEAPON_HAND], sword) + self.assertEqual(self.combatant.equipment.slots[WieldLocation.TWO_HANDS], None)
+ + +
[docs]class EvAdventureTurnbasedCombatHandlerTest(_CombatTestBase): + """ + Test methods on the turn-based combat handler and actions + + """ + + maxDiff = None + + # make sure to mock away all time-keeping elements +
[docs] @patch( + ( + "evennia.contrib.tutorials.evadventure." + "combat_turnbased.EvAdventureTurnbasedCombatHandler.interval" + ), + new=-1, + ) + def setUp(self): + super().setUp() + # add target to combat + self.combathandler = ( + combat_turnbased.EvAdventureTurnbasedCombatHandler.get_or_create_combathandler( + self.location, key="combathandler" + ) + ) + self.combathandler.add_combatant(self.combatant) + self.combathandler.add_combatant(self.target)
+ + def _get_action(self, action_dict={"key": "hold"}): + action_class = self.combathandler.action_classes[action_dict["key"]] + return action_class(self.combathandler, self.combatant, action_dict) + + def _run_actions( + self, action_dict, action_dict2={"key": "hold"}, combatant_msg=None, target_msg=None + ): + """ + Helper method to run an action and check so combatant saw the expected message. + """ + self.combathandler.queue_action(self.combatant, action_dict) + self.combathandler.queue_action(self.target, action_dict2) + self.combathandler.at_repeat() + if combatant_msg is not None: + # this works because we mock combatant.msg in SetUp + self.combatant.msg.assert_called_with(combatant_msg) + if target_msg is not None: + # this works because we mock target.msg in SetUp + self.combatant.msg.assert_called_with(target_msg) + +
[docs] def test_combatanthandler_setup(self): + """Testing all is set up correctly in the combathandler""" + + chandler = self.combathandler + self.assertEqual( + dict(chandler.combatants), + {self.combatant: {"key": "hold"}, self.target: {"key": "hold"}}, + ) + self.assertEqual( + dict(chandler.action_classes), + { + "hold": combat_turnbased.CombatActionHold, + "attack": combat_turnbased.CombatActionAttack, + "stunt": combat_turnbased.CombatActionStunt, + "use": combat_turnbased.CombatActionUseItem, + "wield": combat_turnbased.CombatActionWield, + "flee": combat_turnbased.CombatActionFlee, + }, + ) + self.assertEqual(chandler.flee_timeout, 1) + self.assertEqual(dict(chandler.advantage_matrix), {}) + self.assertEqual(dict(chandler.disadvantage_matrix), {}) + self.assertEqual(dict(chandler.fleeing_combatants), {}) + self.assertEqual(dict(chandler.defeated_combatants), {})
+ +
[docs] def test_remove_combatant(self): + """Remove a combatant.""" + + self.combathandler.remove_combatant(self.target) + self.assertEqual(dict(self.combathandler.combatants), {self.combatant: {"key": "hold"}})
+ +
[docs] def test_stop_combat(self): + """Stopping combat, making sure combathandler is deleted.""" + + self.combathandler.stop_combat() + self.assertIsNone(self.combathandler.pk)
+ +
[docs] def test_get_sides(self): + """Getting the sides of combat""" + + combatant2 = create.create_object( + EvAdventureCharacter, key="testchar2", location=self.location + ) + target2 = create.create_object( + EvAdventureMob, + key="testmonster2", + location=self.location, + attributes=(("is_idle", True),), + ) + self.combathandler.add_combatant(combatant2) + self.combathandler.add_combatant(target2) + + # allies to combatant + allies, enemies = self.combathandler.get_sides(self.combatant) + self.assertEqual((allies, enemies), ([self.combatant, combatant2], [self.target, target2])) + + # allies to monster + allies, enemies = self.combathandler.get_sides(self.target) + self.assertEqual((allies, enemies), ([self.target, target2], [self.combatant, combatant2]))
+ +
[docs] def test_queue_and_execute_action(self): + """Queue actions and execute""" + + hold = {"key": "hold"} + + self.combathandler.queue_action(self.combatant, hold) + self.assertEqual( + dict(self.combathandler.combatants), + {self.combatant: {"key": "hold"}, self.target: {"key": "hold"}}, + ) + + mock_action = Mock() + self.combathandler.action_classes["hold"] = Mock(return_value=mock_action) + + self.combathandler.execute_next_action(self.combatant) + + self.combathandler.action_classes["hold"].assert_called_with( + self.combathandler, self.combatant, hold + ) + mock_action.execute.assert_called_once()
+ +
[docs] def test_execute_full_turn(self): + """Run a full (passive) turn""" + + hold = {"key": "hold"} + + self.combathandler.queue_action(self.combatant, hold) + self.combathandler.queue_action(self.target, hold) + + self.combathandler.execute_next_action = Mock() + + self.combathandler.at_repeat() + + self.combathandler.execute_next_action.assert_has_calls( + [call(self.combatant), call(self.target)], any_order=True + )
+ +
[docs] def test_action__action_ticks_turn(self): + """Test that action execution ticks turns""" + + actiondict = {"key": "hold"} + self._run_actions(actiondict, actiondict) + self.assertEqual(self.combathandler.turn, 1) + + self.combatant.msg.assert_not_called()
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_attack__success__kill(self, mock_randint): + """Test that the combathandler is deleted once there are no more enemies""" + actiondict = {"key": "attack", "target": self.target} + + mock_randint.return_value = 11 # 11 + 1 str will hit beat armor 11 + self._run_actions(actiondict) + self.assertEqual(self.target.hp, -7) + # after this the combat is over + self.assertIsNone(self.combathandler.pk)
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_stunt_fail(self, mock_randint): + action_dict = { + "key": "stunt", + "recipient": self.combatant, + "target": self.target, + "advantage": True, + "stunt_type": Ability.STR, + "defense_type": Ability.DEX, + } + mock_randint.return_value = 8 # fails 8+1 dex vs DEX 11 defence + self._run_actions(action_dict) + self.assertEqual(self.combathandler.advantage_matrix[self.combatant], {}) + self.assertEqual(self.combathandler.disadvantage_matrix[self.combatant], {})
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_stunt_advantage__success(self, mock_randint): + """Test so the advantage matrix is updated correctly""" + action_dict = { + "key": "stunt", + "recipient": self.combatant, + "target": self.target, + "advantage": True, + "stunt_type": Ability.STR, + "defense_type": Ability.DEX, + } + mock_randint.return_value = 11 # 11+1 dex vs DEX 11 defence is success + self._run_actions(action_dict) + self.assertEqual( + bool(self.combathandler.advantage_matrix[self.combatant][self.target]), True + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_base.rules.randint") + def test_stunt_disadvantage__success(self, mock_randint): + """Test so the disadvantage matrix is updated correctly""" + action_dict = { + "key": "stunt", + "recipient": self.target, + "target": self.combatant, + "advantage": False, + "stunt_type": Ability.STR, + "defense_type": Ability.DEX, + } + mock_randint.return_value = 11 # 11+1 dex vs DEX 11 defence is success + self._run_actions(action_dict) + self.assertEqual( + bool(self.combathandler.disadvantage_matrix[self.target][self.combatant]), True + )
+ +
[docs] def test_flee__success(self): + """ + Test fleeing twice, leading to leaving combat. + + """ + + self.assertEqual(self.combathandler.turn, 0) + action_dict = {"key": "flee", "repeat": True} + + # first flee records the fleeing state + self.combathandler.flee_timeout = 2 # to make sure + self._run_actions(action_dict) + self.assertEqual(self.combathandler.turn, 1) + self.assertEqual(self.combathandler.fleeing_combatants[self.combatant], 1) + + # action_dict should still be in place due to repeat + self.assertEqual(self.combathandler.combatants[self.combatant], action_dict) + + self.combatant.msg.assert_called_with( + text=( + "You retreat, being exposed to attack while doing so (will escape in 1 turn).", + {}, + ), + from_obj=self.combatant, + ) + # Check that enemies have advantage against you now + action = combat_turnbased.CombatAction(self.combathandler, self.target, {"key": "hold"}) + self.assertTrue(action.combathandler.has_advantage(self.target, self.combatant)) + + # second flee should remove combatant + self._run_actions(action_dict) + # this ends combat, so combathandler should be gone + self.assertIsNone(self.combathandler.pk)
+ + +
[docs]class TestEvAdventureTwitchCombatHandler(EvenniaCommandTestMixin, _CombatTestBase): +
[docs] def setUp(self): + super().setUp() + + # in order to use the EvenniaCommandTestMixin we need these variables defined + self.char1 = self.combatant + self.account = None + + self.combatant_combathandler = ( + combat_twitch.EvAdventureCombatTwitchHandler.get_or_create_combathandler( + self.combatant, key="combathandler" + ) + ) + self.target_combathandler = ( + combat_twitch.EvAdventureCombatTwitchHandler.get_or_create_combathandler( + self.target, key="combathandler" + ) + )
+ +
[docs] def test_get_sides(self): + sides = self.combatant_combathandler.get_sides(self.combatant) + self.assertEqual(sides, ([self.combatant], [self.target]))
+ +
[docs] def test_give_advantage(self): + self.combatant_combathandler.give_advantage(self.combatant, self.target) + self.assertTrue(self.combatant_combathandler.advantage_against[self.target])
+ +
[docs] def test_give_disadvantage(self): + self.combatant_combathandler.give_disadvantage(self.combatant, self.target) + self.assertTrue(self.combatant_combathandler.disadvantage_against[self.target])
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock()) + @patch("evennia.contrib.tutorials.evadventure.combat_twitch.repeat", new=Mock(return_value=999)) + def test_queue_action(self): + """Test so the queue action cleans up tickerhandler correctly""" + + actiondict = {"key": "hold"} + self.combatant_combathandler.queue_action(actiondict) + + self.assertIsNone(self.combatant_combathandler.current_ticker_ref) + + actiondict = {"key": "hold", "dt": 5} + self.combatant_combathandler.queue_action(actiondict) + self.assertEqual(self.combatant_combathandler.current_ticker_ref, 999)
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock()) + @patch("evennia.contrib.tutorials.evadventure.combat_twitch.repeat", new=Mock()) + def test_execute_next_action(self): + self.combatant_combathandler.action_dict = { + "key": "hold", + "dummy": "foo", + "repeat": False, + } # to separate from fallback + + self.combatant_combathandler.execute_next_action() + # should now be back to fallback + self.assertEqual( + self.combatant_combathandler.action_dict, + self.combatant_combathandler.fallback_action_dict, + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock()) + def test_check_stop_combat(self): + """Test combat-stop functionality""" + + # noone remains (both combatant/target <0 hp + # get_sides does not include the caller + self.combatant_combathandler.get_sides = Mock(return_value=([], [])) + self.combatant_combathandler.stop_combat = Mock() + + self.combatant.hp = -1 + self.target.hp = -1 + + self.combatant_combathandler.check_stop_combat() + self.combatant.msg.assert_called_with( + text=("Noone stands after the dust settles.", {}), from_obj=self.combatant + ) + self.combatant_combathandler.stop_combat.assert_called() + + # only one side wiped out + self.combatant.hp = 10 + self.target.hp = -1 + self.combatant_combathandler.get_sides = Mock(return_value=([self.combatant], [])) + self.combatant_combathandler.check_stop_combat() + self.combatant.msg.assert_called_with( + text=("The combat is over.", {}), from_obj=self.combatant + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock()) + @patch("evennia.contrib.tutorials.evadventure.combat_twitch.repeat", new=Mock()) + def test_hold(self): + self.call(combat_twitch.CmdHold(), "", "You hold back, doing nothing") + self.assertEqual(self.combatant_combathandler.action_dict, {"key": "hold"})
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock()) + @patch("evennia.contrib.tutorials.evadventure.combat_twitch.repeat", new=Mock()) + def test_attack(self): + """Test attack action in the twitch combathandler""" + self.call(combat_twitch.CmdAttack(), self.target.key, "You attack testmonster!") + self.assertEqual( + self.combatant_combathandler.action_dict, + {"key": "attack", "target": self.target, "dt": 3, "repeat": True}, + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock()) + @patch("evennia.contrib.tutorials.evadventure.combat_twitch.repeat", new=Mock()) + def test_stunt(self): + boost_result = { + "key": "stunt", + "recipient": self.combatant, + "target": self.target, + "advantage": True, + "stunt_type": Ability.STR, + "defense_type": Ability.STR, + "dt": 3, + } + foil_result = { + "key": "stunt", + "recipient": self.target, + "target": self.combatant, + "advantage": False, + "stunt_type": Ability.STR, + "defense_type": Ability.STR, + "dt": 3, + } + + self.call( + combat_twitch.CmdStunt(), + f"STR {self.target.key}", + "You prepare a stunt!", + cmdstring="boost", + ) + self.assertEqual(self.combatant_combathandler.action_dict, boost_result) + + self.call( + combat_twitch.CmdStunt(), + f"STR me {self.target.key}", + "You prepare a stunt!", + cmdstring="boost", + ) + self.assertEqual(self.combatant_combathandler.action_dict, boost_result) + + self.call( + combat_twitch.CmdStunt(), + f"STR {self.target.key}", + "You prepare a stunt!", + cmdstring="foil", + ) + self.assertEqual(self.combatant_combathandler.action_dict, foil_result) + + self.call( + combat_twitch.CmdStunt(), + f"STR {self.target.key} me", + "You prepare a stunt!", + cmdstring="foil", + ) + self.assertEqual(self.combatant_combathandler.action_dict, foil_result)
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock()) + @patch("evennia.contrib.tutorials.evadventure.combat_twitch.repeat", new=Mock()) + def test_useitem(self): + item = create.create_object( + EvAdventureConsumable, key="potion", attributes=[("uses", 2)], location=self.combatant + ) + + self.call(combat_twitch.CmdUseItem(), "potion", "You prepare to use potion!") + self.assertEqual( + self.combatant_combathandler.action_dict, + {"key": "use", "item": item, "target": self.combatant, "dt": 3}, + ) + + self.call( + combat_twitch.CmdUseItem(), + f"potion on {self.target.key}", + "You prepare to use potion!", + ) + self.assertEqual( + self.combatant_combathandler.action_dict, + {"key": "use", "item": item, "target": self.target, "dt": 3}, + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock()) + @patch("evennia.contrib.tutorials.evadventure.combat_twitch.repeat", new=Mock()) + def test_wield(self): + sword = create.create_object(EvAdventureWeapon, key="sword", location=self.combatant) + runestone = create.create_object( + EvAdventureWeapon, key="runestone", location=self.combatant + ) + + self.call(combat_twitch.CmdWield(), "sword", "You reach for sword!") + self.assertEqual( + self.combatant_combathandler.action_dict, {"key": "wield", "item": sword, "dt": 3} + ) + + self.call(combat_twitch.CmdWield(), "runestone", "You reach for runestone!") + self.assertEqual( + self.combatant_combathandler.action_dict, {"key": "wield", "item": runestone, "dt": 3} + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_commands.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_commands.html new file mode 100644 index 0000000000..d53f7d9a26 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_commands.html @@ -0,0 +1,219 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_commands — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_commands

+"""
+Test the EvAdventure commands.
+
+"""
+
+from unittest.mock import call, patch
+
+from anything import Something
+
+from evennia.utils.create import create_object
+from evennia.utils.test_resources import BaseEvenniaCommandTest
+
+from .. import commands
+from ..characters import EvAdventureCharacter
+from ..npcs import EvAdventureMob, EvAdventureShopKeeper
+from .mixins import EvAdventureMixin
+
+
+
[docs]class TestEvAdventureCommands(EvAdventureMixin, BaseEvenniaCommandTest): +
[docs] def setUp(self): + super().setUp() + # needed for the .call mechanism + self.char1 = self.character
+ +
[docs] def test_inventory(self): + self.call( + commands.CmdInventory(), + "inventory", + """ +You are fighting with your bare fists and have no shield. +You wear no armor and no helmet. +Backpack is empty. +You use 0/11 equipment slots. +""".strip(), + )
+ +
[docs] def test_wield_or_wear(self): + self.char1.equipment.add(self.helmet) + self.char1.equipment.add(self.weapon) + self.shield.location = self.location + + self.call(commands.CmdWieldOrWear(), "shield", "Could not find 'shield'") + self.call(commands.CmdWieldOrWear(), "helmet", "You put helmet on your head.") + self.call( + commands.CmdWieldOrWear(), + "weapon", + "You hold weapon in your strongest hand, ready for action.", + ) + self.call(commands.CmdWieldOrWear(), "helmet", "You are already using helmet.")
+ +
[docs] def test_remove(self): + self.char1.equipment.add(self.helmet) + self.call(commands.CmdWieldOrWear(), "helmet", "You put helmet on your head.") + + self.call(commands.CmdRemove(), "helmet", "You stash helmet in your backpack.")
+ +
[docs] def test_give__coins(self): + recipient = create_object(EvAdventureCharacter, key="Friend", location=self.location) + recipient.coins = 0 + self.char1.coins = 100 + + self.call(commands.CmdGive(), "40 coins to friend", "You give Friend 40 coins.") + self.assertEqual(self.char1.coins, 60) + self.assertEqual(recipient.coins, 40) + + self.call(commands.CmdGive(), "10 to friend", "You give Friend 10 coins.") + self.assertEqual(self.char1.coins, 50) + self.assertEqual(recipient.coins, 50) + + self.call(commands.CmdGive(), "60 to friend", "You only have 50 coins to give.") + + recipient.delete()
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.commands.EvMenu") + def test_give__item(self, mock_EvMenu): + self.char1.equipment.add(self.helmet) + recipient = create_object(EvAdventureCharacter, key="Friend", location=self.location) + + self.call(commands.CmdGive(), "helmet to friend", "") + + mock_EvMenu.assert_has_calls( + ( + call( + recipient, + {"node_receive": Something, "node_end": Something}, + item=self.helmet, + giver=self.char1, + ), + call( + self.char1, + {"node_give": Something, "node_end": Something}, + item=self.helmet, + receiver=recipient, + ), + ) + ) + + recipient.delete()
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.npcs.EvMenu") + def test_talk(self, mock_EvMenu): + npc = create_object(EvAdventureShopKeeper, key="shopkeep", location=self.location) + + npc.menudata = {"foo": None, "bar": None} + + self.call(commands.CmdTalk(), "shopkeep", "") + + mock_EvMenu.assert_called_with( + self.char1, + {"foo": None, "bar": None}, + startnode="node_start", + session=None, + npc=npc, + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_dungeon.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_dungeon.html new file mode 100644 index 0000000000..570bba8893 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_dungeon.html @@ -0,0 +1,202 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_dungeon — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_dungeon

+"""
+Test Dungeon orchestrator / procedurally generated dungeon rooms.
+
+"""
+
+from unittest.mock import MagicMock
+
+from evennia.utils.create import create_object
+from evennia.utils.test_resources import BaseEvenniaTest
+from evennia.utils.utils import inherits_from
+
+from .. import dungeon
+from .mixins import EvAdventureMixin
+
+
+
[docs]class TestDungeon(EvAdventureMixin, BaseEvenniaTest): + """ + Test with a starting room and a character moving through the dungeon, + generating more and more rooms as they go. + + """ + +
[docs] def setUp(self): + """ + Create a start room with exits leading away from it + + """ + super().setUp() + droomclass = dungeon.EvAdventureDungeonStartRoom + droomclass.recycle_time = 0 # disable the tick + droomclass.branch_check_time = 0 + + self.start_room = create_object(droomclass, key="bottom of well") + + self.assertEqual( + self.start_room.scripts.get("evadventure_dungeon_startroom_resetter")[0].interval, -1 + ) + self.start_north = create_object( + dungeon.EvAdventureDungeonStartRoomExit, + key="north", + location=self.start_room, + destination=self.start_room, + ) + self.start_north + self.start_south = create_object( + dungeon.EvAdventureDungeonStartRoomExit, + key="south", + location=self.start_room, + destination=self.start_room, + ) + self.character.location = self.start_room
+ + def _move_character(self, direction): + old_location = self.character.location + for exi in old_location.exits: + if exi.key == direction: + # by setting target to old-location we trigger the + # special behavior of this Exit type + exi.at_traverse(self.character, exi.destination) + break + return self.character.location + +
[docs] def test_start_room(self): + """ + Test move through one of the start room exits. + + """ + # begin in start room + self.assertEqual(self.character.location, self.start_room) + + # first go north, this should generate a new room + new_room_north = self._move_character("north") + self.assertNotEqual(self.start_room, new_room_north) + self.assertTrue(inherits_from(new_room_north, dungeon.EvAdventureDungeonRoom)) + + # check if Orchestrator was created + orchestrator = new_room_north.db.dungeon_orchestrator + self.assertTrue(bool(orchestrator)) + self.assertTrue(orchestrator.key.startswith("dungeon_orchestrator_north_"))
+ +
[docs] def test_different_start_directions(self): + # first go north, this should generate a new room + new_room_north = self._move_character("north") + self.assertNotEqual(self.start_room, new_room_north) + + # back to start room + start_room = self._move_character("south") + self.assertEqual(self.start_room, start_room) + + # next go south, this should generate a new room + new_room_south = self._move_character("south") + self.assertNotEqual(self.start_room, new_room_south) + self.assertNotEqual(new_room_north, new_room_south) + + # back to start room again + start_room = self._move_character("north") + self.assertEqual(self.start_room, start_room)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_equipment.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_equipment.html new file mode 100644 index 0000000000..38f91d6dec --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_equipment.html @@ -0,0 +1,295 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_equipment — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_equipment

+"""
+Test the EvAdventure equipment handler.
+
+"""
+
+
+from unittest.mock import MagicMock, patch
+
+from parameterized import parameterized
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from ..enums import Ability, WieldLocation
+from ..equipment import EquipmentError
+from .mixins import EvAdventureMixin
+
+
+
[docs]class TestEquipment(EvAdventureMixin, BaseEvenniaTest): +
[docs] def test_count_slots(self): + self.assertEqual(self.character.equipment.count_slots(), 0)
+ +
[docs] def test_max_slots(self): + self.assertEqual(self.character.equipment.max_slots, 11) + setattr(self.character, Ability.CON.value, 3) + self.assertEqual(self.character.equipment.max_slots, 13)
+ +
[docs] def test_add__remove(self): + self.character.equipment.add(self.helmet) + self.assertEqual(self.character.equipment.slots[WieldLocation.BACKPACK], [self.helmet]) + self.character.equipment.remove(self.helmet) + self.assertEqual(self.character.equipment.slots[WieldLocation.BACKPACK], [])
+ +
[docs] def test_move__get_current_slot(self): + self.character.equipment.add(self.helmet) + self.assertEqual( + self.character.equipment.get_current_slot(self.helmet), WieldLocation.BACKPACK + ) + self.character.equipment.move(self.helmet) + self.assertEqual(self.character.equipment.get_current_slot(self.helmet), WieldLocation.HEAD)
+ +
[docs] def test_get_wearable_or_wieldable_objects_from_backpack(self): + self.character.equipment.add(self.helmet) + self.character.equipment.add(self.weapon) + + self.assertEqual( + self.character.equipment.get_wieldable_objects_from_backpack(), [self.weapon] + ) + self.assertEqual( + self.character.equipment.get_wearable_objects_from_backpack(), [self.helmet] + ) + + self.assertEqual( + self.character.equipment.all(), + [ + (None, WieldLocation.WEAPON_HAND), + (None, WieldLocation.SHIELD_HAND), + (None, WieldLocation.TWO_HANDS), + (None, WieldLocation.BODY), + (None, WieldLocation.HEAD), + (self.helmet, WieldLocation.BACKPACK), + (self.weapon, WieldLocation.BACKPACK), + ], + )
+ + def _get_empty_slots(self): + return { + WieldLocation.BACKPACK: [], + WieldLocation.WEAPON_HAND: None, + WieldLocation.SHIELD_HAND: None, + WieldLocation.TWO_HANDS: None, + WieldLocation.BODY: None, + WieldLocation.HEAD: None, + } + +
[docs] def test_equipmenthandler_max_slots(self): + self.assertEqual(self.character.equipment.max_slots, 11)
+ + @parameterized.expand( + [ + # size, pass_validation? + (1, True), + (2, True), + (11, True), + (12, False), + (20, False), + (25, False), + ] + ) + def test_validate_slot_usage(self, size, is_ok): + obj = MagicMock() + obj.size = size + + with patch("evennia.contrib.tutorials.evadventure.equipment.inherits_from") as mock_inherit: + mock_inherit.return_value = True + if is_ok: + self.assertTrue(self.character.equipment.validate_slot_usage(obj)) + else: + with self.assertRaises(EquipmentError): + self.character.equipment.validate_slot_usage(obj) + + @parameterized.expand( + [ + # item, where + ("helmet", WieldLocation.HEAD), + ("shield", WieldLocation.SHIELD_HAND), + ("armor", WieldLocation.BODY), + ("weapon", WieldLocation.WEAPON_HAND), + ("big_weapon", WieldLocation.TWO_HANDS), + ("item", WieldLocation.BACKPACK), + ] + ) + def test_move(self, itemname, where): + self.assertEqual(self.character.equipment.slots, self._get_empty_slots()) + + obj = getattr(self, itemname) + self.character.equipment.move(obj) + # check that item ended up in the right place + if where is WieldLocation.BACKPACK: + self.assertTrue(obj in self.character.equipment.slots[where]) + else: + self.assertEqual(self.character.equipment.slots[where], obj) + +
[docs] def test_add(self): + self.character.equipment.add(self.weapon) + self.assertEqual(self.character.equipment.slots[WieldLocation.WEAPON_HAND], None) + self.assertTrue(self.weapon in self.character.equipment.slots[WieldLocation.BACKPACK])
+ +
[docs] def test_two_handed_exclusive(self): + """Two-handed weapons can't be used together with weapon+shield""" + self.character.equipment.move(self.big_weapon) + self.assertEqual(self.character.equipment.slots[WieldLocation.TWO_HANDS], self.big_weapon) + # equipping sword or shield removes two-hander + self.character.equipment.move(self.shield) + self.assertEqual(self.character.equipment.slots[WieldLocation.SHIELD_HAND], self.shield) + self.assertEqual(self.character.equipment.slots[WieldLocation.TWO_HANDS], None) + self.character.equipment.move(self.weapon) + self.assertEqual(self.character.equipment.slots[WieldLocation.WEAPON_HAND], self.weapon) + + # the two-hander removes the two weapons + self.character.equipment.move(self.big_weapon) + self.assertEqual(self.character.equipment.slots[WieldLocation.TWO_HANDS], self.big_weapon) + self.assertEqual(self.character.equipment.slots[WieldLocation.SHIELD_HAND], None) + self.assertEqual(self.character.equipment.slots[WieldLocation.WEAPON_HAND], None)
+ +
[docs] def test_remove__with_obj(self): + self.character.equipment.move(self.shield) + self.character.equipment.move(self.item) + self.character.equipment.add(self.weapon) + + self.assertEqual(self.character.equipment.slots[WieldLocation.SHIELD_HAND], self.shield) + self.assertEqual( + self.character.equipment.slots[WieldLocation.BACKPACK], [self.item, self.weapon] + ) + + self.assertEqual(self.character.equipment.remove(self.shield), [self.shield]) + self.assertEqual(self.character.equipment.remove(self.item), [self.item]) + + self.assertEqual(self.character.equipment.slots[WieldLocation.SHIELD_HAND], None) + self.assertEqual(self.character.equipment.slots[WieldLocation.BACKPACK], [self.weapon])
+ +
[docs] def test_remove__with_slot(self): + self.character.equipment.move(self.shield) + self.character.equipment.move(self.item) + self.character.equipment.add(self.helmet) + + self.assertEqual(self.character.equipment.slots[WieldLocation.SHIELD_HAND], self.shield) + self.assertEqual( + self.character.equipment.slots[WieldLocation.BACKPACK], [self.item, self.helmet] + ) + + self.assertEqual(self.character.equipment.remove(WieldLocation.SHIELD_HAND), [self.shield]) + self.assertEqual( + self.character.equipment.remove(WieldLocation.BACKPACK), [self.item, self.helmet] + ) + + self.assertEqual(self.character.equipment.slots[WieldLocation.SHIELD_HAND], None) + self.assertEqual(self.character.equipment.slots[WieldLocation.BACKPACK], [])
+ +
[docs] def test_properties(self): + self.character.equipment.move(self.armor) + self.assertEqual(self.character.equipment.armor, 1) + self.character.equipment.move(self.shield) + self.assertEqual(self.character.equipment.armor, 2) + self.character.equipment.move(self.helmet) + self.assertEqual(self.character.equipment.armor, 3) + + self.character.equipment.move(self.weapon) + self.assertEqual(self.character.equipment.weapon, self.weapon) + self.character.equipment.move(self.big_weapon) + self.assertEqual(self.character.equipment.weapon, self.big_weapon)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_npcs.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_npcs.html new file mode 100644 index 0000000000..0b65e1fd0c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_npcs.html @@ -0,0 +1,128 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_npcs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_npcs

+"""
+Test NPC classes.
+
+"""
+
+from evennia import create_object
+from evennia.utils.test_resources import EvenniaTest
+
+from .. import npcs
+
+
+
[docs]class TestNPCBase(EvenniaTest): +
[docs] def test_npc_base(self): + npc = create_object( + npcs.EvAdventureNPC, + key="TestNPC", + attributes=[("hit_dice", 4), ("armor", 1), ("morale", 9)], + ) + + self.assertEqual(npc.hp_multiplier, 4) + self.assertEqual(npc.hp_max, 16) + self.assertEqual(npc.strength, 4) + self.assertEqual(npc.charisma, 4)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_quests.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_quests.html new file mode 100644 index 0000000000..3f07703028 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_quests.html @@ -0,0 +1,255 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_quests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_quests

+"""
+Testing Quest functionality.
+
+"""
+
+from unittest.mock import MagicMock
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .. import quests
+from ..objects import EvAdventureObject
+from .mixins import EvAdventureMixin
+
+
+class _TestQuest(quests.EvAdventureQuest):
+    """
+    Test quest.
+
+    """
+
+    key = "testquest"
+    desc = "A test quest!"
+
+    start_step = "A"
+    end_text = "This task is completed."
+
+    help_A = "You need to do A first."
+    help_B = "Next, do B."
+
+    def step_A(self, *args, **kwargs):
+        """
+        Quest-step A is completed when quester carries an item with tag "QuestA" and category
+        "quests".
+        """
+        # note - this could be done with a direct db query instead to avoid a loop, for a
+        # unit test it's fine though
+        if any(obj for obj in self.quester.contents if obj.tags.has("QuestA", category="quests")):
+            self.quester.msg("Completed step A of quest!")
+            self.current_step = "B"
+            self.progress()
+
+    def step_B(self, *args, **kwargs):
+        """
+        Quest-step B is completed when the progress-check is called with a special kwarg
+        "complete_quest_B"
+
+        """
+        if kwargs.get("complete_quest_B", False):
+            self.quester.msg("Completed step B of quest!")
+            self.quester.db.test_quest_counter = 0
+            self.current_step = "C"
+            self.progress()
+
+    def help_C(self):
+        """Testing the method-version of getting a help entry"""
+        return f"Only C left now, {self.quester.key}!"
+
+    def step_C(self, *args, **kwargs):
+        """
+        Step C (final) step of quest completes when a counter on quester is big enough.
+
+        """
+        if self.quester.db.test_quest_counter and self.quester.db.test_quest_counter > 5:
+            self.quester.msg("Quest complete! Get XP rewards!")
+            self.quester.db.xp += 10
+            self.complete()
+
+    def cleanup(self):
+        """
+        Cleanup data related to quest.
+
+        """
+        del self.quester.db.test_quest_counter
+
+
+
[docs]class EvAdventureQuestTest(EvAdventureMixin, BaseEvenniaTest): + """ + Test questing. + + """ + +
[docs] def setUp(self): + super().setUp() + self.character.quests.add(_TestQuest) + self.character.msg = MagicMock()
+ + def _get_quest(self): + return self.character.quests.get(_TestQuest.key) + + def _fulfillA(self): + """Fulfill quest step A""" + EvAdventureObject.create( + key="quest obj", location=self.character, tags=(("QuestA", "quests"),) + ) + + def _fulfillC(self): + """Fullfill quest step C""" + self.character.db.test_quest_counter = 6 + +
[docs] def test_help(self): + """Get help""" + # get help for all quests + help_txt = self.character.quests.get_help() + self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - You need to do A first."]) + + # get help for one specific quest + help_txt = self.character.quests.get_help(_TestQuest.key) + self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - You need to do A first."]) + + # help for finished quest + self._get_quest().is_completed = True + help_txt = self.character.quests.get_help() + self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - This quest is completed!"])
+ +
[docs] def test_progress__fail(self): + """ + Check progress without having any. + """ + # progress all quests + self.character.quests.progress() + # progress one quest + self.character.quests.progress(_TestQuest.key) + + # still on step A + self.assertEqual(self._get_quest().current_step, "A")
+ +
[docs] def test_progress(self): + """ + Fulfill the quest steps in sequess + + """ + # A requires a certain object in inventory + self._fulfillA() + self.character.quests.progress() + self.assertEqual(self._get_quest().current_step, "B") + + # B requires progress be called with specific kwarg + # should not step (no kwarg) + self.character.quests.progress() + self.assertEqual(self._get_quest().current_step, "B") + + # should step (kwarg sent) + self.character.quests.progress(complete_quest_B=True) + self.assertEqual(self._get_quest().current_step, "C") + + # C requires a counter Attribute on char be high enough + self._fulfillC() + self.character.quests.progress() + self.assertEqual(self._get_quest().current_step, "C") # still on last step + self.assertEqual(self._get_quest().is_completed, True)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_rooms.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_rooms.html new file mode 100644 index 0000000000..aed13a402c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_rooms.html @@ -0,0 +1,159 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_rooms — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_rooms

+"""
+Test of EvAdventure Rooms
+
+"""
+
+from evennia import DefaultExit, create_object
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.test_resources import EvenniaTestCase
+
+from ..characters import EvAdventureCharacter
+from ..rooms import EvAdventureRoom
+
+
+
[docs]class EvAdventureRoomTest(EvenniaTestCase): +
[docs] def setUp(self): + self.char = create_object(EvAdventureCharacter, key="TestChar")
+ +
[docs] def test_map(self): + center_room = create_object(EvAdventureRoom, key="room_center") + n_room = create_object(EvAdventureRoom, key="room_n") + create_object(DefaultExit, key="north", location=center_room, destination=n_room) + ne_room = create_object(EvAdventureRoom, key="room_ne") + create_object(DefaultExit, key="northeast", location=center_room, destination=ne_room) + e_room = create_object(EvAdventureRoom, key="room_e") + create_object(DefaultExit, key="east", location=center_room, destination=e_room) + se_room = create_object(EvAdventureRoom, key="room_se") + create_object(DefaultExit, key="southeast", location=center_room, destination=se_room) + s_room = create_object(EvAdventureRoom, key="room_") + create_object(DefaultExit, key="south", location=center_room, destination=s_room) + sw_room = create_object(EvAdventureRoom, key="room_sw") + create_object(DefaultExit, key="southwest", location=center_room, destination=sw_room) + w_room = create_object(EvAdventureRoom, key="room_w") + create_object(DefaultExit, key="west", location=center_room, destination=w_room) + nw_room = create_object(EvAdventureRoom, key="room_nw") + create_object(DefaultExit, key="northwest", location=center_room, destination=nw_room) + + desc = center_room.return_appearance(self.char) + + expected = r""" + o o o + \|/ + o-@-o + /|\ + o o o +room_center +You see nothing special. +Exits: north, northeast, east, southeast, south, southwest, west, and northwest""" + + result = "\n".join(part.rstrip() for part in strip_ansi(desc).split("\n")) + expected = "\n".join(part.rstrip() for part in expected.split("\n")) + # print(result) + # print(expected) + + self.assertEqual(result, expected)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_rules.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_rules.html new file mode 100644 index 0000000000..4128455108 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_rules.html @@ -0,0 +1,331 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_rules — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_rules

+"""
+Test the rules and chargen.
+
+"""
+
+from unittest.mock import MagicMock, call, patch
+
+from anything import Something
+from parameterized import parameterized
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .. import characters, enums, equipment, random_tables, rules
+from .mixins import EvAdventureMixin
+
+
+
[docs]class EvAdventureRollEngineTest(BaseEvenniaTest): + """ + Test the roll engine in the rules module. This is the core of any RPG. + + """ + +
[docs] def setUp(self): + super().setUp() + self.roll_engine = rules.EvAdventureRollEngine()
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_roll(self, mock_randint): + mock_randint.return_value = 8 + self.assertEqual(self.roll_engine.roll("1d6"), 8) + mock_randint.assert_called_with(1, 6) + + self.assertEqual(self.roll_engine.roll("2d8"), 2 * 8) + mock_randint.assert_called_with(1, 8) + + self.assertEqual(self.roll_engine.roll("4d12"), 4 * 8) + mock_randint.assert_called_with(1, 12) + + self.assertEqual(self.roll_engine.roll("8d100"), 8 * 8) + mock_randint.assert_called_with(1, 100)
+ +
[docs] def test_roll_limits(self): + with self.assertRaises(TypeError): + self.roll_engine.roll("100d6", max_number=10) # too many die + with self.assertRaises(TypeError): + self.roll_engine.roll("100") # no d + with self.assertRaises(TypeError): + self.roll_engine.roll("dummy") # non-numerical + with self.assertRaises(TypeError): + self.roll_engine.roll("Ad4") # non-numerical + with self.assertRaises(TypeError): + self.roll_engine.roll("1d10000") # limit is d1000
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_roll_with_advantage_disadvantage(self, mock_randint): + mock_randint.return_value = 9 + + # no advantage/disadvantage + self.assertEqual(self.roll_engine.roll_with_advantage_or_disadvantage(), 9) + mock_randint.assert_called_once() + mock_randint.reset_mock() + + # cancel each other out + self.assertEqual( + self.roll_engine.roll_with_advantage_or_disadvantage(disadvantage=True, advantage=True), + 9, + ) + mock_randint.assert_called_once() + mock_randint.reset_mock() + + # run with advantage/disadvantage + self.assertEqual(self.roll_engine.roll_with_advantage_or_disadvantage(advantage=True), 9) + mock_randint.assert_has_calls([call(1, 20), call(1, 20)]) + mock_randint.reset_mock() + + self.assertEqual(self.roll_engine.roll_with_advantage_or_disadvantage(disadvantage=True), 9) + mock_randint.assert_has_calls([call(1, 20), call(1, 20)]) + mock_randint.reset_mock()
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_saving_throw(self, mock_randint): + mock_randint.return_value = 8 + + character = MagicMock() + character.strength = 2 + character.dexterity = 1 + + self.assertEqual( + self.roll_engine.saving_throw(character, bonus_type=enums.Ability.STR), + (False, None, Something), + ) + self.assertEqual( + self.roll_engine.saving_throw(character, bonus_type=enums.Ability.DEX, modifier=1), + (False, None, Something), + ) + self.assertEqual( + self.roll_engine.saving_throw( + character, advantage=True, bonus_type=enums.Ability.DEX, modifier=6 + ), + (False, None, Something), + ) + self.assertEqual( + self.roll_engine.saving_throw( + character, disadvantage=True, bonus_type=enums.Ability.DEX, modifier=7 + ), + (True, None, Something), + ) + + mock_randint.return_value = 1 + self.assertEqual( + self.roll_engine.saving_throw( + character, disadvantage=True, bonus_type=enums.Ability.STR, modifier=2 + ), + (False, enums.Ability.CRITICAL_FAILURE, Something), + ) + + mock_randint.return_value = 20 + self.assertEqual( + self.roll_engine.saving_throw( + character, disadvantage=True, bonus_type=enums.Ability.STR, modifier=2 + ), + (True, enums.Ability.CRITICAL_SUCCESS, Something), + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_opposed_saving_throw(self, mock_randint): + mock_randint.return_value = 10 + + attacker, defender = MagicMock(), MagicMock() + attacker.strength = 1 + defender.armor = 2 + + self.assertEqual( + self.roll_engine.opposed_saving_throw( + attacker, defender, attack_type=enums.Ability.STR, defense_type=enums.Ability.ARMOR + ), + (False, None, Something), + ) + self.assertEqual( + self.roll_engine.opposed_saving_throw( + attacker, + defender, + attack_type=enums.Ability.STR, + defense_type=enums.Ability.ARMOR, + modifier=2, + ), + (True, None, Something), + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_roll_random_table(self, mock_randint): + mock_randint.return_value = 10 + + self.assertEqual( + self.roll_engine.roll_random_table("1d20", random_tables.chargen_tables["physique"]), + "scrawny", + ) + self.assertEqual( + self.roll_engine.roll_random_table("1d20", random_tables.chargen_tables["vice"]), + "irascible", + ) + self.assertEqual( + self.roll_engine.roll_random_table("1d20", random_tables.chargen_tables["alignment"]), + "neutrality", + ) + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.chargen_tables["helmets and shields"] + ), + "no helmet or shield", + ) + # testing faulty rolls outside of the table ranges + mock_randint.return_value = 25 + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.chargen_tables["helmets and shields"] + ), + "helmet and shield", + ) + mock_randint.return_value = -10 + self.assertEqual( + self.roll_engine.roll_random_table( + "1d20", random_tables.chargen_tables["helmets and shields"] + ), + "no helmet or shield", + )
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_morale_check(self, mock_randint): + defender = MagicMock() + defender.morale = 12 + + mock_randint.return_value = 7 # 2d6 is rolled, so this will become 14 + self.assertEqual(self.roll_engine.morale_check(defender), False) + + mock_randint.return_value = 3 # 2d6 is rolled, so this will become 6 + self.assertEqual(self.roll_engine.morale_check(defender), True)
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_heal_from_rest(self, mock_randint): + character = MagicMock() + character.heal = MagicMock() + character.hp_max = 8 + character.hp = 1 + character.constitution = 1 + + mock_randint.return_value = 5 + self.roll_engine.heal_from_rest(character) + mock_randint.assert_called_with(1, 8) # 1d8 + character.heal.assert_called_with(6) # roll + constitution bonus
+ +
[docs] @patch("evennia.contrib.tutorials.evadventure.rules.randint") + def test_roll_death(self, mock_randint): + character = MagicMock() + character.strength = 13 + character.hp = 0 + character.hp_max = 8 + + # death + mock_randint.return_value = 1 + self.roll_engine.roll_death(character) + character.at_death.assert_called() + # strength loss + mock_randint.return_value = 3 + self.roll_engine.roll_death(character) + self.assertEqual(character.strength, 10)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_utils.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_utils.html new file mode 100644 index 0000000000..cebf789e36 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/tests/test_utils.html @@ -0,0 +1,138 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.tests.test_utils — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.tests.test_utils

+"""
+Tests of the utils module.
+
+"""
+
+from evennia.utils import create
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .. import utils
+from ..objects import EvAdventureObject
+
+
+
[docs]class TestUtils(BaseEvenniaTest): +
[docs] def test_get_obj_stats(self): + obj = create.create_object( + EvAdventureObject, key="testobj", attributes=(("desc", "A test object"),) + ) + result = utils.get_obj_stats(obj) + + self.assertEqual( + result, + """ +|ctestobj|n +Value: ~|y0|n coins + +A test object + +Slots: |w1|n, Used from: |wbackpack|n +Quality: |wN/A|n, Uses: |wuses|n +Attacks using |wNo attack|n against |wNo defense|n +Damage roll: |wNone|n +""".strip(), + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/evadventure/utils.html b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/utils.html new file mode 100644 index 0000000000..f087a0b41a --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/evadventure/utils.html @@ -0,0 +1,157 @@ + + + + + + + + evennia.contrib.tutorials.evadventure.utils — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.evadventure.utils

+"""
+Various utilities.
+
+"""
+
+_OBJ_STATS = """
+|c{key}|n
+Value: ~|y{value}|n coins{carried}
+
+{desc}
+
+Slots: |w{size}|n, Used from: |w{use_slot_name}|n
+Quality: |w{quality}|n, Uses: |wuses|n
+Attacks using |w{attack_type_name}|n against |w{defense_type_name}|n
+Damage roll: |w{damage_roll}|n""".strip()
+
+
+
[docs]def get_obj_stats(obj, owner=None): + """ + Get a string of stats about the object. + + Args: + obj (EvAdventureObject): The object to get stats for. + owner (EvAdventureCharacter, optional): If given, it allows us to + also get information about if the item is currently worn/wielded. + + Returns: + str: A stat string to show about the object. + + """ + carried = "" + if owner: + objmap = dict(owner.equipment.all()) + carried = objmap.get(obj) + carried = f", Worn: [{carried.value}]" if carried else "" + + attack_type = getattr(obj, "attack_type", None) + defense_type = getattr(obj, "attack_type", None) + + return _OBJ_STATS.format( + key=obj.key, + value=obj.value, + carried=carried, + desc=obj.db.desc, + size=obj.size, + use_slot_name=obj.inventory_use_slot.value, + quality=getattr(obj, "quality", "N/A"), + uses=getattr(obj, "uses", "N/A"), + attack_type_name=attack_type.value if attack_type else "No attack", + defense_type_name=defense_type.value if defense_type else "No defense", + damage_roll=getattr(obj, "damage_roll", "None"), + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/mirror/mirror.html b/docs/latest/_modules/evennia/contrib/tutorials/mirror/mirror.html new file mode 100644 index 0000000000..a57373a8e6 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/mirror/mirror.html @@ -0,0 +1,166 @@ + + + + + + + + evennia.contrib.tutorials.mirror.mirror — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.mirror.mirror

+"""
+TutorialMirror
+
+A simple mirror object to experiment with.
+
+"""
+
+from evennia import DefaultObject, logger
+from evennia.utils import is_iter, make_iter
+
+
+
[docs]class TutorialMirror(DefaultObject): + """ + A simple mirror object that + - echoes back the description of the object looking at it + - echoes back whatever is being sent to its .msg - to the + sender, if given, otherwise to the location of the mirror. + + """ + +
[docs] def return_appearance(self, looker, **kwargs): + """ + This formats the description of this object. Called by the 'look' command. + + Args: + looker (Object): Object doing the looking. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + """ + + if isinstance(looker, self.__class__): + # avoid infinite recursion by having two mirrors look at each other + return "The image of yourself stretches into infinity." + return f"{self.key} shows your reflection:\n{looker.db.desc}"
+ +
[docs] def msg(self, text=None, from_obj=None, **kwargs): + """ + Simply override .msg to echo back to the messenger or to the current + location. + + Args: + text (str or tuple, optional): The message to send. This + is treated internally like any send-command, so its + value can be a tuple if sending multiple arguments to + the `text` oob command. + from_obj (obj or iterable) + given, at_msg_send will be called. This value will be + passed on to the protocol. If iterable, will execute hook + on all entities in it. + """ + if not text: + text = "<silence>" + text = text[0] if is_iter(text) else text + if from_obj: + for obj in make_iter(from_obj): + obj.msg(f'{self.key} echoes back to you:\n"{text}".') + elif self.location: + self.location.msg_contents(f'{self.key} echoes back:\n"{text}".', exclude=[self]) + else: + # no from_obj and no location, just log + logger.log_msg(f"{self.key}.msg was called without from_obj and .location is None.")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/red_button/red_button.html b/docs/latest/_modules/evennia/contrib/tutorials/red_button/red_button.html new file mode 100644 index 0000000000..3c78462f56 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/red_button/red_button.html @@ -0,0 +1,699 @@ + + + + + + + + evennia.contrib.tutorials.red_button.red_button — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.red_button.red_button

+"""
+Red Button
+
+Griatch - 2011
+
+This is a more advanced example object. It combines functions from
+script.examples as well as commands.examples to make an interactive
+button typeclass.
+
+Create this button with
+
+    create/drop tutorials.red_button.RedButton
+
+Note that you must drop the button before you can see its messages!
+
+## Technical
+
+The button's functionality is controlled by CmdSets that gets added and removed
+depending on the 'state' the button is in.
+
+- Lid-closed state: In this state the button is covered by a glass cover and trying
+  to 'push' it will fail. You can 'nudge', 'smash' or 'open' the lid.
+- Lid-open state: In this state the lid is open but will close again after a certain
+  time. Using 'push' now will press the button and trigger the Blind-state.
+- Blind-state: In this mode you are blinded by a bright flash. This will affect your
+  normal commands like 'look' and help until the blindness wears off after a certain
+  time.
+
+Timers are handled by persistent delays on the button. These are examples of
+`evennia.utils.utils.delay` calls that wait a certain time before calling a method -
+such as when closing the lid and un-blinding a character.
+
+"""
+import random
+
+from evennia import CmdSet, Command, DefaultObject
+from evennia.utils.utils import delay, interactive, repeat
+
+# Commands on the button (not all awailable at the same time)
+
+
+# Commands for the state when the lid covering the button is closed.
+
+
+
[docs]class CmdPushLidClosed(Command): + """ + Push the red button (lid closed) + + Usage: + push button + + """ + + key = "push button" + aliases = ["push", "press button", "press"] + locks = "cmd:all()" + +
[docs] def func(self): + """ + This is the version of push used when the lid is closed. + + An alternative would be to make a 'push' command in a default cmdset + that is always available on the button and then use if-statements to + check if the lid is open or closed. + + """ + self.caller.msg("You cannot push the button = there is a glass lid covering it.")
+ + +
[docs]class CmdNudge(Command): + """ + Try to nudge the button's lid. + + Usage: + nudge lid + + This command will have you try to push the lid of the button away. + + """ + + key = "nudge lid" # two-word command name! + aliases = ["nudge"] + locks = "cmd:all()" + +
[docs] def func(self): + """ + Nudge the lid. Random chance of success to open it. + + """ + rand = random.random() + if rand < 0.5: + self.caller.msg("You nudge at the lid. It seems stuck.") + elif rand < 0.7: + self.caller.msg("You move the lid back and forth. It won't budge.") + else: + self.caller.msg("You manage to get a nail under the lid.") + # self.obj is the button object + self.obj.to_open_state()
+ + +
[docs]class CmdSmashGlass(Command): + """ + Smash the protective glass. + + Usage: + smash glass + + Try to smash the glass of the button. + + """ + + key = "smash glass" + aliases = ["smash lid", "break lid", "smash"] + locks = "cmd:all()" + +
[docs] def func(self): + """ + The lid won't open, but there is a small chance of causing the lamp to + break. + + """ + rand = random.random() + self.caller.location.msg_contents( + f"{self.caller.name} tries to smash the glass of the button.", exclude=self.caller + ) + + if rand < 0.2: + string = ( + "You smash your hand against the glass" + " with all your might. The lid won't budge" + " but you cause quite the tremor through the button's mount." + "\nIt looks like the button's lamp stopped working for the time being, " + "but the lid is still as closed as ever." + ) + # self.obj is the button itself + self.obj.break_lamp() + elif rand < 0.6: + string = "You hit the lid hard. It doesn't move an inch." + else: + string = ( + "You place a well-aimed fist against the glass of the lid." + " Unfortunately all you get is a pain in your hand. Maybe" + " you should just try to just ... open the lid instead?" + ) + self.caller.msg(string)
+ + +
[docs]class CmdOpenLid(Command): + """ + open lid + + Usage: + open lid + + """ + + key = "open lid" + aliases = ["open button"] + locks = "cmd:all()" + +
[docs] def func(self): + "simply call the right function." + + if self.obj.db.lid_locked: + self.caller.msg("This lid seems locked in place for the moment.") + return + + string = "\nA ticking sound is heard, like a winding mechanism. Seems " + string += "the lid will soon close again." + self.caller.msg(string) + self.caller.location.msg_contents( + f"{self.caller.name} opens the lid of the button.", exclude=self.caller + ) + self.obj.to_open_state()
+ + +
[docs]class LidClosedCmdSet(CmdSet): + """ + A simple cmdset tied to the redbutton object. + + It contains the commands that launches the other + command sets, making the red button a self-contained + item (i.e. you don't have to manually add any + scripts etc to it when creating it). + + Note that this is given with a `key_mergetype` set. This + is set up so that the cmdset with merge with Union merge type + *except* if the other cmdset to merge with is LidOpenCmdSet, + in which case it will Replace that. So these two cmdsets will + be mutually exclusive. + + """ + + key = "LidClosedCmdSet" + +
[docs] def at_cmdset_creation(self): + "Populates the cmdset when it is instantiated." + self.add(CmdPushLidClosed()) + self.add(CmdNudge()) + self.add(CmdSmashGlass()) + self.add(CmdOpenLid())
+ + +# Commands for the state when the button's protective cover is open - now the +# push command will work. You can also close the lid again. + + +
[docs]class CmdPushLidOpen(Command): + """ + Push the red button + + Usage: + push button + + """ + + key = "push button" + aliases = ["push", "press button", "press"] + locks = "cmd:all()" + + @interactive + def func(self): + """ + This version of push will immediately trigger the next button state. + + The use of the @interactive decorator allows for using `yield` to add + simple pauses in how quickly a message is returned to the user. This + kind of pause will not survive a server reload. + + """ + # pause a little between each message. + self.caller.msg("You reach out to press the big red button ...") + yield (2) # pause 2s before next message + self.caller.msg("\n\n|wBOOOOM! A bright light blinds you!|n") + yield (1) # pause 1s before next message + self.caller.msg("\n\n|xThe world goes dark ...|n") + + name = self.caller.name + self.caller.location.msg_contents( + f"{name} presses the button. BOOM! {name} is blinded by a flash!", exclude=self.caller + ) + self.obj.blind_target(self.caller)
+ + +
[docs]class CmdCloseLid(Command): + """ + Close the lid + + Usage: + close lid + + Closes the lid of the red button. + """ + + key = "close lid" + aliases = ["close"] + locks = "cmd:all()" + +
[docs] def func(self): + "Close the lid" + + self.obj.to_closed_state() + + # this will clean out scripts dependent on lid being open. + self.caller.msg("You close the button's lid. It clicks back into place.") + self.caller.location.msg_contents( + f"{self.caller.name} closes the button's lid.", exclude=self.caller + )
+ + +
[docs]class LidOpenCmdSet(CmdSet): + """ + This is the opposite of the Closed cmdset. + + Note that this is given with a `key_mergetype` set. This + is set up so that the cmdset with merge with Union merge type + *except* if the other cmdset to merge with is LidClosedCmdSet, + in which case it will Replace that. So these two cmdsets will + be mutually exclusive. + + """ + + key = "LidOpenCmdSet" + +
[docs] def at_cmdset_creation(self): + """Setup the cmdset""" + self.add(CmdPushLidOpen()) + self.add(CmdCloseLid())
+ + +# Commands for when the button has been pushed and the player is blinded. This +# replaces commands on the player making them 'blind' for a while. + + +
[docs]class CmdBlindLook(Command): + """ + Looking around in darkness + + Usage: + look <obj> + + ... not that there's much to see in the dark. + + """ + + key = "look" + aliases = ["l", "get", "examine", "ex", "feel", "listen"] + locks = "cmd:all()" + +
[docs] def func(self): + "This replaces all the senses when blinded." + + # we decide what to reply based on which command was + # actually tried + + if self.cmdstring == "get": + string = "You fumble around blindly without finding anything." + elif self.cmdstring == "examine": + string = "You try to examine your surroundings, but can't see a thing." + elif self.cmdstring == "listen": + string = "You are deafened by the boom." + elif self.cmdstring == "feel": + string = "You fumble around, hands outstretched. You bump your knee." + else: + # trying to look + string = ( + "You are temporarily blinded by the flash. " + "Until it wears off, all you can do is feel around blindly." + ) + self.caller.msg(string) + self.caller.location.msg_contents( + f"{self.caller.name} stumbles around, blinded.", exclude=self.caller + )
+ + +
[docs]class CmdBlindHelp(Command): + """ + Help function while in the blinded state + + Usage: + help + + """ + + key = "help" + aliases = "h" + locks = "cmd:all()" + +
[docs] def func(self): + """ + Just give a message while blinded. We could have added this to the + CmdBlindLook command too if we wanted to keep things more compact. + + """ + self.caller.msg("You are beyond help ... until you can see again.")
+ + +
[docs]class BlindCmdSet(CmdSet): + """ + This is the cmdset added to the *account* when + the button is pushed. + + Since this has mergetype Replace it will completely remove the commands of + all other cmdsets while active. To allow some limited interaction + (pose/say) we import those default commands and add them too. + + We also disable all exit-commands generated by exits and + object-interactions while blinded by setting `no_exits` and `no_objs` flags + on the cmdset. This is to avoid the player walking off or interfering with + other objects while blinded. Account-level commands however (channel messaging + etc) will not be affected by the blinding. + + """ + + key = "BlindCmdSet" + # we want it to completely replace all normal commands + # until the timed script removes it again. + mergetype = "Replace" + # we want to stop the player from walking around + # in this blinded state, so we hide all exits too. + # (channel commands will still work). + no_exits = True # keep player in the same room + no_objs = True # don't allow object commands + +
[docs] def at_cmdset_creation(self): + "Setup the blind cmdset" + from evennia.commands.default.general import CmdPose, CmdSay + + self.add(CmdSay()) + self.add(CmdPose()) + self.add(CmdBlindLook()) + self.add(CmdBlindHelp())
+ + +# +# Definition of the object itself +# + + +
[docs]class RedButton(DefaultObject): + """ + This class describes an evil red button. It will blink invitingly and + temporarily blind whomever presses it. + + The button can take a few optional attributes controlling how things will + be displayed in its various states. This is a useful way to give builders + the option to customize a complex object from in-game. Actual return messages + to event-actions are (in this example) left with each command, but one could + also imagine having those handled via Attributes as well, if one wanted a + completely in-game customizable button without needing to tweak command + classes. + + Attributes: + - `desc_closed_lid`: This is the description to show of the button + when the lid is closed. + - `desc_open_lid`": Shown when the lid is open + - `auto_close_msg`: Message to show when lid auto-closes + - `desc_add_lamp_broken`: Extra desc-line added after normal desc when lamp + is broken. + - blink_msg: A list of strings to randomly choose from when the lamp + blinks. + + Notes: + The button starts with lid closed. To set the initial description, + you can either set desc after creating it or pass a `desc` attribute + when creating it, such as + `button = create_object(RedButton, ..., attributes=[('desc', 'my desc')])`. + + """ + + # these are the pre-set descriptions. Setting attributes will override + # these on the fly. + + desc_closed_lid = ( + "This is a large red button, inviting yet evil-looking. A closed glass lid protects it." + ) + desc_open_lid = ( + "This is a large red button, inviting yet evil-looking. " + "Its glass cover is open and the button exposed." + ) + auto_close_msg = "The button's glass lid silently slides back in place." + lamp_breaks_msg = "The lamp flickers, the button going dark." + desc_add_lamp_broken = "\nThe big red button has stopped blinking for the time being." + # note that this is a list. A random message will display each time + blink_msgs = [ + "The red button flashes briefly.", + "The red button blinks invitingly.", + "The red button flashes. You know you wanna push it!", + ] + +
[docs] def at_object_creation(self): + """ + This function is called (once) when object is created. + + """ + self.db.lamp_works = True + + # start closed + self.to_closed_state() + + # start blinking every 35s. + repeat(35, self._do_blink, persistent=True)
+ + def _do_blink(self): + """ + Have the button blink invitingly unless it's broken. + + """ + if self.location and self.db.lamp_works: + possible_messages = self.db.blink_msgs or self.blink_msgs + self.location.msg_contents(random.choice(possible_messages)) + + def _set_desc(self, attrname=None): + """ + Set a description, based on the attrname given, taking the lamp-status + into account. + + Args: + attrname (str, optional): This will first check for an Attribute with this name, + secondly for a property on the class. So if `attrname="auto_close_msg"`, + we will first look for an attribute `.db.auto_close_msg` and if that's + not found we'll use `.auto_close_msg` instead. If unset (`None`), the + currently set desc will not be changed (only lamp will be checked). + + Notes: + If `self.db.lamp_works` is `False`, we'll append + `desc_add_lamp_broken` text. + + """ + if attrname: + # change desc + desc = self.attributes.get(attrname) or getattr(self, attrname) + else: + # use existing desc + desc = self.db.desc + + if not self.db.lamp_works: + # lamp not working. Add extra to button's desc + desc += self.db.desc_add_lamp_broken or self.desc_add_lamp_broken + + self.db.desc = desc + + # state-changing methods and actions + +
[docs] def to_closed_state(self, msg=None): + """ + Switches the button to having its lid closed. + + Args: + msg (str, optional): If given, display a message to the room + when lid closes. + + This will first try to get the Attribute (self.db.desc_closed_lid) in + case it was set by a builder and if that was None, it will fall back to + self.desc_closed_lid, the default description (note that lack of .db). + """ + self._set_desc("desc_closed_lid") + # remove lidopen-state, if it exists + self.cmdset.remove(LidOpenCmdSet) + # add lid-closed cmdset + self.cmdset.add(LidClosedCmdSet, persistent=True) + + if msg and self.location: + self.location.msg_contents(msg)
+ +
[docs] def to_open_state(self): + """ + Switches the button to having its lid open. This also starts a timer + that will eventually close it again. + + """ + self._set_desc("desc_open_lid") + # remove lidopen-state, if it exists + self.cmdset.remove(LidClosedCmdSet) + # add lid-open cmdset + self.cmdset.add(LidOpenCmdSet, persistent=True) + + # wait 20s then call self.to_closed_state with a message as argument + delay( + 35, self.to_closed_state, self.db.auto_close_msg or self.auto_close_msg, persistent=True + )
+ + def _unblind_target(self, caller): + """ + This is called to un-blind after a certain time. + + """ + caller.cmdset.remove(BlindCmdSet) + caller.msg("You blink feverishly as your eyesight slowly returns.") + self.location.msg_contents( + f"{caller.name} seems to be recovering their eyesight, blinking feverishly.", + exclude=caller, + ) + +
[docs] def blind_target(self, caller): + """ + Someone was foolish enough to press the button! Blind them + temporarily. + + Args: + caller (Object): The one to be blinded. + + """ + + # we don't need to remove other cmdsets, this will replace all, + # then restore whatever was there when it goes away. + # we don't make this persistent, to make sure any problem is just a reload away + caller.cmdset.add(BlindCmdSet) + + # wait 20s then call self._unblind to remove blindness effect. The + # persistent=True means the delay should survive a server reload. + delay(20, self._unblind_target, caller, persistent=True)
+ + def _unbreak_lamp(self): + """ + This is called to un-break the lamp after a certain time. + + """ + # we do this quietly, the user will just notice it starting blinking again + self.db.lamp_works = True + self._set_desc() + +
[docs] def break_lamp(self): + """ + Breaks the lamp in the button, stopping it from blinking for a while + + """ + self.db.lamp_works = False + # this will update the desc with the info about the broken lamp + self._set_desc() + self.location.msg_contents(self.db.lamp_breaks_msg or self.lamp_breaks_msg) + + # wait 21s before unbreaking the lamp again + delay(21, self._unbreak_lamp)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/talking_npc/talking_npc.html b/docs/latest/_modules/evennia/contrib/tutorials/talking_npc/talking_npc.html new file mode 100644 index 0000000000..820042933c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/talking_npc/talking_npc.html @@ -0,0 +1,248 @@ + + + + + + + + evennia.contrib.tutorials.talking_npc.talking_npc — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.talking_npc.talking_npc

+"""
+Evennia Talkative NPC
+
+Contribution - Griatch 2011, grungies1138, 2016
+
+This is a static NPC object capable of holding a simple menu-driven
+conversation. It's just meant as an example.
+
+Installation:
+
+Create the NPC by creating an object of typeclass `contrib.tutorials.talking_npc.TalkingNPC`,
+For example:
+
+    create/drop John : contrib.tutorials.talking_npc.TalkingNPC
+
+Use `talk` in the same room as the NPC to start a conversation.
+
+If there are many talkative npcs in the same room you will get to
+choose which one's talk command to call (Evennia handles this
+automatically). This use of EvMenu is very simplistic; See EvMenu for
+a lot more complex possibilities.
+
+
+"""
+
+from evennia import CmdSet, DefaultObject, default_cmds
+from evennia.utils.evmenu import EvMenu
+
+# Menu implementing the dialogue tree
+
+
+
+
+
+
[docs]def info1(caller): + text = "'Oh, Evennia is where you are right now! Don't you feel the power?'" + + options = ( + {"desc": "Sure, *I* do, not sure how you do though. You are just an NPC.", "goto": "info3"}, + {"desc": "Sure I do. What's yer name, NPC?", "goto": "info2"}, + {"desc": "Ok, bye for now then.", "goto": "END"}, + ) + + return text, options
+ + +
[docs]def info2(caller): + text = "'My name is not really important ... I'm just an NPC after all.'" + + options = ( + {"desc": "I didn't really want to know it anyhow.", "goto": "info3"}, + {"desc": "Okay then, so what's this 'Evennia' thing about?", "goto": "info1"}, + ) + + return text, options
+ + +
[docs]def info3(caller): + text = ( + "'Well ... I'm sort of busy so, have to go. NPC business. " + "Important stuff. You wouldn't understand.'" + ) + + options = ( + {"desc": "Oookay ... I won't keep you. Bye.", "goto": "END"}, + {"desc": "Wait, why don't you tell me your name first?", "goto": "info2"}, + ) + + return text, options
+ + +
[docs]def END(caller): + text = "'Goodbye, then.'" + + options = () + + return text, options
+ + +# +# The talk command (sits on the NPC) +# + + +
[docs]class CmdTalk(default_cmds.MuxCommand): + """ + Talks to an npc + + Usage: + talk + + This command is only available if a talkative non-player-character + (NPC) is actually present. It will strike up a conversation with + that NPC and give you options on what to talk about. + """ + + key = "talk" + locks = "cmd:all()" + help_category = "General" + +
[docs] def func(self): + "Implements the command." + + # self.obj is the NPC this is defined on + self.caller.msg("(You walk up and talk to %s.)" % self.obj.key) + + # Initiate the menu. Change this if you are putting this on + # some other custom NPC class. + EvMenu( + self.caller, + "evennia.contrib.tutorials.talking_npc.talking_npc", + startnode="menu_start_node", + )
+ + +
[docs]class TalkingCmdSet(CmdSet): + "Stores the talk command." + key = "talkingcmdset" + +
[docs] def at_cmdset_creation(self): + "populates the cmdset" + self.add(CmdTalk())
+ + +
[docs]class TalkingNPC(DefaultObject): + """ + This implements a simple Object using the talk command and using + the conversation defined above. + """ + +
[docs] def at_object_creation(self): + "This is called when object is first created." + self.db.desc = "This is a talkative NPC." + # assign the talk command to npc + self.cmdset.add_default(TalkingCmdSet, persistent=True)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/talking_npc/tests.html b/docs/latest/_modules/evennia/contrib/tutorials/talking_npc/tests.html new file mode 100644 index 0000000000..4cf0bb69dd --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/talking_npc/tests.html @@ -0,0 +1,120 @@ + + + + + + + + evennia.contrib.tutorials.talking_npc.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.talking_npc.tests

+"""
+Tutorial - talking NPC tests.
+
+"""
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.utils.create import create_object
+
+from . import talking_npc
+
+
+
[docs]class TestTalkingNPC(BaseEvenniaCommandTest): +
[docs] def test_talkingnpc(self): + npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1) + self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)") + npc.delete()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/intro_menu.html b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/intro_menu.html new file mode 100644 index 0000000000..9536e3bcd1 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/intro_menu.html @@ -0,0 +1,883 @@ + + + + + + + + evennia.contrib.tutorials.tutorial_world.intro_menu — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.tutorial_world.intro_menu

+"""
+Intro menu / game tutor
+
+Evennia contrib - Griatch 2020
+
+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.
+
+"""
+
+from evennia import CmdSet, create_object
+from evennia.utils.evmenu import EvMenu, parse_menu_template
+
+# Goto callbacks and helper resources for the menu
+
+
+
[docs]def do_nothing(caller, raw_string, **kwargs): + """ + Re-runs the current node + """ + return None
+ + +
[docs]def send_testing_tagged(caller, raw_string, **kwargs): + """ + Test to send a message to a pane tagged with 'testing' in the webclient. + + """ + 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 first help-command demo + + +
[docs]class DemoCommandSetHelp(CmdSet): + """ + Demo the help command + """ + + key = "Help Demo Set" + priority = 2 + +
[docs] def at_cmdset_creation(self): + from evennia import default_cmds + + self.add(default_cmds.CmdHelp()) + self.add(default_cmds.CmdChannel())
+ + +
[docs]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 + + +
[docs]class DemoCommandSetComms(CmdSet): + """ + Demo communications + """ + + key = "Color Demo Set" + priority = 2 + no_exits = True + no_objs = True + +
[docs] 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())
+ + +
[docs]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 +through the windows. + +Use |ylook sign|n or |yl sign|n to examine the wooden sign nailed to the wall. + +""" + +_SIGN_DESC = """ +The small sign reads: + + Good! Now try '|ylook small|n'. + + ... 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 + the one you want, like this: + + |ylook small-2|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 sm-1|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. + + Next try |ylook door|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): + """ + 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: + # 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, sign, room2, stone, door_out, door_in = roomdata + caller.location = prev_loc + sign.delete() + stone.delete() + door_out.delete() + door_in.delete() + room1.delete() + room2.delete() + 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.db.desc = _ROOM_DESC.lstrip() + sign = create_object( + "evennia.objects.objects.DefaultObject", key="small wooden sign", location=room1 + ) + 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.lstrip() + 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, + locks=["get:false()"], + ) + door_out.db.desc = _DOOR_DESC_OUT.strip() + door_in = create_object( + "evennia.objects.objects.DefaultExit", + key="entrance to the cabin", + aliases=["door", "in", "entrance"], + location=room2, + destination=room1, + locks=["get:false()"], + ) + door_in.db.desc = _DOOR_DESC_IN.strip() + + # store references for easy removal later + caller.db.tutorial_world_demo_room_data = ( + caller.location, + room1, + sign, + room2, + stone, + door_out, + door_in, + ) + # move caller into room + caller.location = room1 + + +
[docs]class DemoCommandSetRoom(CmdSet): + """ + Demo some general in-game commands command. + """ + + key = "Room Demo Set" + priority = 2 + no_exits = False + no_objs = False + +
[docs] 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())
+ + +
[docs]def goto_command_demo_room(caller, raw_string, **kwargs): + """ + 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(DemoCommandSetHelp) + caller.cmdset.remove(DemoCommandSetComms) + caller.cmdset.add(DemoCommandSetRoom) + return "command_demo_room"
+ + +
[docs]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 = { + "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, + "goto_cleanup_cmdsets": goto_cleanup_cmdsets, +} + + +# Main menu definition + +MENU_TEMPLATE = """ + +## NODE start + +|g** Evennia introduction wizard **|n + +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'. + +Press |y<return>|n or write |ynext|n to step forward. Or select a number to jump to. + +## OPTIONS + + 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() + 8: Conclusions & next steps-> conclusions + >: about_muds + +# --------------------------------------------------------------------------------- + +## NODE about_muds + +|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 have 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 + + next;n: About Evennia -> about_evennia + back to start;back;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 |wworking, 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 + +|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 (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 +and the main window. + +- Use |y<Return>|n (or click the arrow on the right) to send your input. +- 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) + +There is also some |wextra|n info to learn about customizing the webclient. + +## OPTIONS + + 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() + +# --------------------------------------------------------------------------------- + +# this is a dead-end 'leaf' of the menu + +## 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. +- 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;b: using webclient + > test *: send tagged message to new pane -> send_testing_tagged() + >: using webclient + +# --------------------------------------------------------------------------------- + +# we get here via goto_command_demo_help() + +## NODE command_demo_help + +|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. + +First to try is |yhelp|n. This lists all commands |wcurrently|n available to you. + +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 probably only have |whelp|n and a |wchannel|n command +(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 handful. Once you exit you'll find a lot more! Now let's try +those channels ... + +## OPTIONS + + next;n: Talk on Channels -> talk on channels + back;b: Using the webclient -> goto_cleanup_cmdsets(gotonode='using webclient') + back to start;start: start + >: talk on 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: + + |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. + +## OPTIONS + + 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() + +# --------------------------------------------------------------------------------- + +# we get here via goto_command_demo_comms() + +## 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 + +|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 -> goto_command_demo_help(gotonode='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 send private messages with |wpage|n: + + |ypage <name> Hello there!|n + +Put your own name as |y<name>|n to page yourself as a test. Write just |ypage|n +to see your latest pages. This will also show you if anyone paged you while you +were offline. + +## OPTIONS + + 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 +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 -> goto_command_demo_comms(gotonode='paging_people') + back to start;start: start + >: goto_command_demo_room() + +# --------------------------------------------------------------------------------- + +# we get here via goto_command_demo_room() + +## 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 +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 and use |ynext|n when you are done. + +## OPTIONS + + next;n: Conclusions -> conclusions + back;b: Channel commands -> goto_command_demo_comms(gotonode='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. If you want there is also some |wextra|n info +for where to go beyond that. + +## OPTIONS + + 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 + +# --------------------------------------------------------------------------------- + +## 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://www.evennia.com/docs/latest|n + +- You can start by trying to build some stuff by following the |wBuilder quick-start|n: + + |yhttps://www.evennia.com/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-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://www.evennia.com/docs/latest/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Tutorial-World|n + +- You can then continue looking through the |wTutorials|n and pick one that + fits your level of understanding. + + |yhttps://www.evennia.com/docs/latest/Howtos/Howtos-Overview|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 + + |yhttps://www.evennia.com|n + +# --------------------------------------------------------------------------------- + +## OPTIONS + +back: conclusions +>: conclusions + + +## NODE end + +|gGood luck!|n + +""" + + +# ------------------------------------------------------------------------------------------- +# +# EvMenu implementation and access function +# +# ------------------------------------------------------------------------------------------- + + +
[docs]class TutorialEvMenu(EvMenu): + """ + Custom EvMenu for displaying the intro-menu + """ + +
[docs] 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() + if self.caller.account: + self.caller.msg("Restoring permissions ...") + self.caller.account.execute_cmd("unquell")
+ +
[docs] 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}"
+ + +
[docs]def init_menu(caller): + """ + Call to initialize the menu. + + """ + menutree = parse_menu_template(caller, MENU_TEMPLATE, GOTO_CALLABLES) + TutorialEvMenu(caller, menutree)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/mob.html b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/mob.html new file mode 100644 index 0000000000..e454e37945 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/mob.html @@ -0,0 +1,539 @@ + + + + + + + + evennia.contrib.tutorials.tutorial_world.mob — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.tutorial_world.mob

+"""
+This module implements a simple mobile object with
+a very rudimentary AI as well as an aggressive enemy
+object based on that mobile class.
+
+"""
+
+import random
+
+from evennia import TICKER_HANDLER, CmdSet, Command, logger, search_object
+
+from . import objects as tut_objects
+
+
+
[docs]class CmdMobOnOff(Command): + """ + Activates/deactivates Mob + + Usage: + mobon <mob> + moboff <mob> + + This turns the mob from active (alive) mode + to inactive (dead) mode. It is used during + building to activate the mob once it's + prepared. + """ + + key = "mobon" + aliases = "moboff" + locks = "cmd:superuser()" + +
[docs] def func(self): + """ + Uses the mob's set_alive/set_dead methods + to turn on/off the mob." + """ + if not self.args: + self.caller.msg("Usage: mobon||moboff <mob>") + return + mob = self.caller.search(self.args) + if not mob: + return + if self.cmdstring == "mobon": + mob.set_alive() + else: + mob.set_dead()
+ + +
[docs]class MobCmdSet(CmdSet): + """ + Holds the admin command controlling the mob + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdMobOnOff())
+ + +
[docs]class Mob(tut_objects.TutorialObject): + """ + This is a state-machine AI mobile. It has several states which are + controlled from setting various Attributes. All default to True: + + patrolling: if set, the mob will move randomly + from room to room, but preferring to not return + the way it came. If unset, the mob will remain + stationary (idling) until attacked. + aggressive: if set, will attack Characters in + the same room using whatever Weapon it + carries (see tutorial_world.objects.TutorialWeapon). + if unset, the mob will never engage in combat + no matter what. + hunting: if set, the mob will pursue enemies trying + to flee from it, so it can enter combat. If unset, + it will return to patrolling/idling if fled from. + immortal: If set, the mob cannot take any damage. + irregular_echoes: list of strings the mob generates at irregular intervals. + desc_alive: the physical description while alive + desc_dead: the physical descripion while dead + send_defeated_to: unique key/alias for location to send defeated enemies to + defeat_msg: message to echo to defeated opponent + defeat_msg_room: message to echo to room. Accepts %s as the name of the defeated. + hit_msg: message to echo when this mob is hit. Accepts %s for the mob's key. + weapon_ineffective_msg: message to echo for useless attacks + death_msg: message to echo to room when this mob dies. + patrolling_pace: how many seconds per tick, when patrolling + aggressive_pace: -"- attacking + hunting_pace: -"- hunting + death_pace: -"- returning to life when dead + + field 'home' - the home location should set to someplace inside + the patrolling area. The mob will use this if it should + happen to roam into a room with no exits. + + """ + +
[docs] def at_init(self): + """ + When initialized from cache (after a server reboot), set up + the AI state. + """ + # The AI state machine (not persistent). + self.ndb.is_patrolling = self.db.patrolling and not self.db.is_dead + self.ndb.is_attacking = False + self.ndb.is_hunting = False + self.ndb.is_immortal = self.db.immortal or self.db.is_dead
+ +
[docs] def at_object_creation(self): + """ + Called the first time the object is created. + We set up the base properties and flags here. + """ + self.cmdset.add(MobCmdSet, persistent=True) + # Main AI flags. We start in dead mode so we don't have to + # chase the mob around when building. + self.db.patrolling = True + self.db.aggressive = True + self.db.immortal = False + # db-store if it is dead or not + self.db.is_dead = True + # specifies how much damage we divide away from non-magic weapons + self.db.damage_resistance = 100.0 + # pace (number of seconds between ticks) for + # the respective modes. + self.db.patrolling_pace = 6 + self.db.aggressive_pace = 2 + self.db.hunting_pace = 1 + self.db.death_pace = 100 # stay dead for 100 seconds + + # we store the call to the tickerhandler + # so we can easily deactivate the last + # ticker subscription when we switch. + # since we will use the same idstring + # throughout we only need to save the + # previous interval we used. + self.db.last_ticker_interval = None + + # store two separate descriptions, one for alive and + # one for dead (corpse) + self.db.desc_alive = "This is a moving object." + self.db.desc_dead = "A dead body." + + # health stats + self.db.full_health = 20 + self.db.health = 20 + + # when this mob defeats someone, we move the character off to + # some other place (Dark Cell in the tutorial). + self.db.send_defeated_to = "dark cell" + # text to echo to the defeated foe. + self.db.defeat_msg = "You fall to the ground." + self.db.defeat_msg_room = "%s falls to the ground." + self.db.weapon_ineffective_msg = ( + "Your weapon just passes through your enemy, causing almost no effect!" + ) + + self.db.death_msg = "After the last hit %s evaporates." % self.key + self.db.hit_msg = "%s wails, shudders and writhes." % self.key + self.db.irregular_msgs = ["the enemy looks about.", "the enemy changes stance."] + + self.db.tutorial_info = "This is an object with simple state AI, using a ticker to move."
+ + def _set_ticker(self, interval, hook_key, stop=False): + """ + Set how often the given hook key should + be "ticked". + + Args: + interval (int or None): The number of seconds + between ticks + hook_key (str or None): The name of the method + (on this mob) to call every interval + seconds. + stop (bool, optional): Just stop the + last ticker without starting a new one. + With this set, the interval and hook_key + arguments are unused. + + In order to only have one ticker + running at a time, we make sure to store the + previous ticker subscription so that we can + easily find and stop it before setting a + new one. The tickerhandler is persistent so + we need to remember this across reloads. + + """ + idstring = "tutorial_mob" # this doesn't change + last_interval = self.db.last_ticker_interval + last_hook_key = self.db.last_hook_key + if last_interval and last_hook_key: + # we have a previous subscription, kill this first. + TICKER_HANDLER.remove( + interval=last_interval, callback=getattr(self, last_hook_key), idstring=idstring + ) + self.db.last_ticker_interval = interval + self.db.last_hook_key = hook_key + if not stop: + # set the new ticker + TICKER_HANDLER.add( + interval=interval, callback=getattr(self, hook_key), idstring=idstring + ) + + def _find_target(self, location): + """ + Scan the given location for suitable targets (this is defined + as Characters) to attack. Will ignore superusers. + + Args: + location (Object): the room to scan. + + Returns: + The first suitable target found. + + """ + targets = [ + obj + for obj in location.contents_get(exclude=self) + if obj.has_account and not obj.is_superuser + ] + return targets[0] if targets else None + +
[docs] def set_alive(self, *args, **kwargs): + """ + Set the mob to "alive" mode. This effectively + resurrects it from the dead state. + """ + self.db.health = self.db.full_health + self.db.is_dead = False + self.db.desc = self.db.desc_alive + self.ndb.is_immortal = self.db.immortal + self.ndb.is_patrolling = self.db.patrolling + if not self.location: + self.move_to(self.home) + if self.db.patrolling: + self.start_patrolling()
+ +
[docs] def set_dead(self): + """ + Set the mob to "dead" mode. This turns it off + and makes sure it can take no more damage. + It also starts a ticker for when it will return. + """ + self.db.is_dead = True + self.location = None + self.ndb.is_patrolling = False + self.ndb.is_attacking = False + self.ndb.is_hunting = False + self.ndb.is_immortal = True + # we shall return after some time + self._set_ticker(self.db.death_pace, "set_alive")
+ +
[docs] def start_idle(self): + """ + Starts just standing around. This will kill + the ticker and do nothing more. + """ + self._set_ticker(None, None, stop=True)
+ +
[docs] def start_patrolling(self): + """ + Start the patrolling state by + registering us with the ticker-handler + at a leasurely pace. + """ + if not self.db.patrolling: + self.start_idle() + return + self._set_ticker(self.db.patrolling_pace, "do_patrol") + self.ndb.is_patrolling = True + self.ndb.is_hunting = False + self.ndb.is_attacking = False + # for the tutorial, we also heal the mob in this mode + self.db.health = self.db.full_health
+ +
[docs] def start_hunting(self): + """ + Start the hunting state + """ + if not self.db.hunting: + self.start_patrolling() + return + self._set_ticker(self.db.hunting_pace, "do_hunt") + self.ndb.is_patrolling = False + self.ndb.is_hunting = True + self.ndb.is_attacking = False
+ +
[docs] def start_attacking(self): + """ + Start the attacking state + """ + if not self.db.aggressive: + self.start_hunting() + return + self._set_ticker(self.db.aggressive_pace, "do_attack") + self.ndb.is_patrolling = False + self.ndb.is_hunting = False + self.ndb.is_attacking = True
+ +
[docs] def do_patrol(self, *args, **kwargs): + """ + Called repeatedly during patrolling mode. In this mode, the + mob scans its surroundings and randomly chooses a viable exit. + One should lock exits with the traverse:has_account() lock in + order to block the mob from moving outside its area while + allowing account-controlled characters to move normally. + """ + if random.random() < 0.01 and self.db.irregular_msgs: + self.location.msg_contents(random.choice(self.db.irregular_msgs)) + if self.db.aggressive: + # first check if there are any targets in the room. + target = self._find_target(self.location) + if target: + self.start_attacking() + return + # no target found, look for an exit. + exits = [exi for exi in self.location.exits if exi.access(self, "traverse")] + if exits: + # randomly pick an exit + exit = random.choice(exits) + # move there. + self.move_to(exit.destination) + else: + # no exits! teleport to home to get away. + self.move_to(self.home)
+ +
[docs] def do_hunting(self, *args, **kwargs): + """ + Called regularly when in hunting mode. In hunting mode the mob + scans adjacent rooms for enemies and moves towards them to + attack if possible. + """ + if random.random() < 0.01 and self.db.irregular_msgs: + self.location.msg_contents(random.choice(self.db.irregular_msgs)) + if self.db.aggressive: + # first check if there are any targets in the room. + target = self._find_target(self.location) + if target: + self.start_attacking() + return + # no targets found, scan surrounding rooms + exits = [exi for exi in self.location.exits if exi.access(self, "traverse")] + if exits: + # scan the exits destination for targets + for exit in exits: + target = self._find_target(exit.destination) + if target: + # a target found. Move there. + self.move_to(exit.destination) + return + # if we get to this point we lost our + # prey. Resume patrolling. + self.start_patrolling() + else: + # no exits! teleport to home to get away. + self.move_to(self.home)
+ +
[docs] def do_attack(self, *args, **kwargs): + """ + Called regularly when in attacking mode. In attacking mode + the mob will bring its weapons to bear on any targets + in the room. + """ + if random.random() < 0.01 and self.db.irregular_msgs: + self.location.msg_contents(random.choice(self.db.irregular_msgs)) + # first make sure we have a target + target = self._find_target(self.location) + if not target: + # no target, start looking for one + self.start_hunting() + return + + # we use the same attack commands as defined in + # tutorial_world.objects.TutorialWeapon, assuming that + # the mob is given a Weapon to attack with. + attack_cmd = random.choice(("thrust", "pierce", "stab", "slash", "chop")) + self.execute_cmd("%s %s" % (attack_cmd, target)) + + # analyze the current state + if target.db.health <= 0: + # we reduced the target to <= 0 health. Move them to the + # defeated room + target.msg(self.db.defeat_msg) + self.location.msg_contents(self.db.defeat_msg_room % target.key, exclude=target) + send_defeated_to = search_object(self.db.send_defeated_to) + if send_defeated_to: + target.move_to(send_defeated_to[0], quiet=True) + else: + logger.log_err( + "Mob: mob.db.send_defeated_to not found: %s" % self.db.send_defeated_to + )
+ + # response methods - called by other objects + +
[docs] def at_hit(self, weapon, attacker, damage): + """ + Someone landed a hit on us. Check our status + and start attacking if not already doing so. + """ + if self.db.health is None: + # health not set - this can't be damaged. + attacker.msg(self.db.weapon_ineffective_msg) + return + + if not self.ndb.is_immortal: + if not weapon.db.magic: + # not a magic weapon - divide away magic resistance + damage /= self.db.damage_resistance + attacker.msg(self.db.weapon_ineffective_msg) + else: + self.location.msg_contents(self.db.hit_msg) + self.db.health -= damage + + # analyze the result + if self.db.health <= 0: + # we are dead! + attacker.msg(self.db.death_msg) + self.set_dead() + else: + # still alive, start attack if not already attacking + if self.db.aggressive and not self.ndb.is_attacking: + self.start_attacking()
+ +
[docs] def at_new_arrival(self, new_character): + """ + This is triggered whenever a new character enters the room. + This is called by the TutorialRoom the mob stands in and + allows it to be aware of changes immediately without needing + to poll for them all the time. For example, the mob can react + right away, also when patrolling on a very slow ticker. + """ + # the room actually already checked all we need, so + # we know it is a valid target. + if self.db.aggressive and not self.ndb.is_attacking: + self.start_attacking()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/objects.html b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/objects.html new file mode 100644 index 0000000000..c5de5e2a2e --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/objects.html @@ -0,0 +1,1290 @@ + + + + + + + + evennia.contrib.tutorials.tutorial_world.objects — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.tutorial_world.objects

+"""
+TutorialWorld - basic objects - Griatch 2011
+
+This module holds all "dead" object definitions for
+the tutorial world. Object-commands and -cmdsets
+are also defined here, together with the object.
+
+Objects:
+
+TutorialObject
+
+TutorialReadable
+TutorialClimbable
+Obelisk
+LightSource
+CrumblingWall
+TutorialWeapon
+TutorialWeaponRack
+
+"""
+
+import random
+
+from evennia import CmdSet, Command, DefaultExit, DefaultObject
+from evennia.prototypes.spawner import spawn
+from evennia.utils import dedent, delay, search
+
+# -------------------------------------------------------------
+#
+# TutorialObject
+#
+# The TutorialObject is the base class for all items
+# in the tutorial. They have an attribute "tutorial_info"
+# on them that the global tutorial command can use to extract
+# interesting behind-the scenes information about the object.
+#
+# TutorialObjects may also be "reset". What the reset means
+# is up to the object. It can be the resetting of the world
+# itself, or the removal of an inventory item from a
+# character's inventory when leaving the tutorial, for example.
+#
+# -------------------------------------------------------------
+
+
+
[docs]class TutorialObject(DefaultObject): + """ + This is the baseclass for all objects in the tutorial. + """ + +
[docs] def at_object_creation(self): + """Called when the object is first created.""" + super().at_object_creation() + self.db.tutorial_info = "No tutorial info is available for this object."
+ +
[docs] def reset(self): + """Resets the object, whatever that may mean.""" + self.location = self.home
+ + +# ------------------------------------------------------------- +# +# Readable - an object that can be "read" +# +# ------------------------------------------------------------- + +# +# Read command +# + + +
[docs]class CmdRead(Command): + """ + Usage: + read [obj] + + Read some text of a readable object. + """ + + key = "read" + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """ + Implements the read command. This simply looks for an + Attribute "readable_text" on the object and displays that. + """ + + if self.args: + obj = self.caller.search(self.args.strip()) + else: + obj = self.obj + if not obj: + return + # we want an attribute read_text to be defined. + readtext = obj.db.readable_text + if readtext: + string = "You read |C%s|n:\n %s" % (obj.key, readtext) + else: + string = "There is nothing to read on %s." % obj.key + self.caller.msg(string)
+ + +
[docs]class CmdSetReadable(CmdSet): + """ + A CmdSet for readables. + """ + +
[docs] def at_cmdset_creation(self): + """ + Called when the cmdset is created. + """ + self.add(CmdRead())
+ + +
[docs]class TutorialReadable(TutorialObject): + """ + This simple object defines some attributes and + """ + +
[docs] def at_object_creation(self): + """ + Called when object is created. We make sure to set the needed + Attribute and add the readable cmdset. + """ + super().at_object_creation() + self.db.tutorial_info = ( + "This is an object with a 'read' command defined in a command set on itself." + ) + self.db.readable_text = "There is no text written on %s." % self.key + # define a command on the object. + self.cmdset.add_default(CmdSetReadable, persistent=True)
+ + +# ------------------------------------------------------------- +# +# Climbable object +# +# The climbable object works so that once climbed, it sets +# a flag on the climber to show that it was climbed. A simple +# command 'climb' handles the actual climbing. The memory +# of what was last climbed is used in a simple puzzle in the +# tutorial. +# +# ------------------------------------------------------------- + + +
[docs]class CmdClimb(Command): + """ + Climb an object + + Usage: + climb <object> + + This allows you to climb. + """ + + key = "climb" + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """Implements function""" + + if not self.args: + self.caller.msg("What do you want to climb?") + return + obj = self.caller.search(self.args.strip()) + if not obj: + return + if obj != self.obj: + self.caller.msg("Try as you might, you cannot climb that.") + return + ostring = self.obj.db.climb_text + if not ostring: + ostring = "You climb %s. Having looked around, you climb down again." % self.obj.name + self.caller.msg(ostring) + # set a tag on the caller to remember that we climbed. + self.caller.tags.add("tutorial_climbed_tree", category="tutorial_world")
+ + +
[docs]class CmdSetClimbable(CmdSet): + """Climbing cmdset""" + +
[docs] def at_cmdset_creation(self): + """populate set""" + self.add(CmdClimb())
+ + +
[docs]class TutorialClimbable(TutorialObject): + """ + A climbable object. All that is special about it is that it has + the "climb" command available on it. + """ + +
[docs] def at_object_creation(self): + """Called at initial creation only""" + self.cmdset.add_default(CmdSetClimbable, persistent=True)
+ + +# ------------------------------------------------------------- +# +# Obelisk - a unique item +# +# The Obelisk is an object with a modified return_appearance method +# that causes it to look slightly different every time one looks at it. +# Since what you actually see is a part of a game puzzle, the act of +# looking also stores a key attribute on the looking object (different +# depending on which text you saw) for later reference. +# +# ------------------------------------------------------------- + + +
[docs]class Obelisk(TutorialObject): + """ + This object changes its description randomly, and which is shown + determines which order "clue id" is stored on the Character for + future puzzles. + + Important Attribute: + puzzle_descs (list): list of descriptions. One of these is + picked randomly when this object is looked at and its index + in the list is used as a key for to solve the puzzle. + + """ + +
[docs] def at_object_creation(self): + """Called when object is created.""" + super().at_object_creation() + self.db.tutorial_info = ( + "This object changes its desc randomly, and makes sure to remember which one you saw." + ) + self.db.puzzle_descs = ["You see a normal stone slab"] + # make sure this can never be picked up + self.locks.add("get:false()")
+ +
[docs] def return_appearance(self, caller): + """ + This hook is called by the look command to get the description + of the object. We overload it with our own version. + """ + # randomly get the index for one of the descriptions + descs = self.db.puzzle_descs + clueindex = random.randint(0, len(descs) - 1) + # set this description, with the random extra + string = ( + "The surface of the obelisk seem to waver, shift and writhe under your gaze, with " + "different scenes and structures appearing whenever you look at it. " + ) + self.db.desc = string + descs[clueindex] + # remember that this was the clue we got. The Puzzle room will + # look for this later to determine if you should be teleported + # or not. + caller.db.puzzle_clue = clueindex + # call the parent function as normal (this will use + # the new desc Attribute we just set) + return super().return_appearance(caller)
+ + +# ------------------------------------------------------------- +# +# LightSource +# +# This object emits light. Once it has been turned on it +# cannot be turned off. When it burns out it will delete +# itself. +# +# This could be implemented using a single-repeat Script or by +# registering with the TickerHandler. We do it simpler by +# using the delay() utility function. This is very simple +# to use but does not survive a server @reload. Because of +# where the light matters (in the Dark Room where you can +# find new light sources easily), this is okay here. +# +# ------------------------------------------------------------- + + +
[docs]class CmdLight(Command): + """ + Creates light where there was none. Something to burn. + """ + + key = "on" + aliases = ["light", "burn"] + # only allow this command if command.obj is carried by caller. + locks = "cmd:holds()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """ + Implements the light command. Since this command is designed + to sit on a "lightable" object, we operate only on self.obj. + """ + + if self.obj.light(): + self.caller.msg("You light %s." % self.obj.key) + self.caller.location.msg_contents( + "%s lights %s!" % (self.caller, self.obj.key), exclude=[self.caller] + ) + else: + self.caller.msg("%s is already burning." % self.obj.key)
+ + +
[docs]class CmdSetLight(CmdSet): + """CmdSet for the lightsource commands""" + + key = "lightsource_cmdset" + # this is higher than the dark cmdset - important! + priority = 3 + +
[docs] def at_cmdset_creation(self): + """called at cmdset creation""" + self.add(CmdLight())
+ + +
[docs]class LightSource(TutorialObject): + """ + This implements a light source object. + + When burned out, the object will be deleted. + """ + +
[docs] def at_init(self): + """ + If this is called with the Attribute is_giving_light already + set, we know that the timer got killed by a server + reload/reboot before it had time to finish. So we kill it here + instead. This is the price we pay for the simplicity of the + non-persistent delay() method. + """ + if self.db.is_giving_light: + self.delete()
+ +
[docs] def at_object_creation(self): + """Called when object is first created.""" + super().at_object_creation() + self.db.tutorial_info = ( + "This object can be lit to create light. It has a timeout for how long it burns." + ) + self.db.is_giving_light = False + self.db.burntime = 60 * 3 # 3 minutes + # this is the default desc, it can of course be customized + # when created. + self.db.desc = "A splinter of wood with remnants of resin on it, enough for burning." + # add the Light command + self.cmdset.add_default(CmdSetLight, persistent=True)
+ + def _burnout(self): + """ + This is called when this light source burns out. We make no + use of the return value. + """ + # delete ourselves from the database + self.db.is_giving_light = False + try: + self.location.location.msg_contents( + "%s's %s flickers and dies." % (self.location, self.key), exclude=self.location + ) + self.location.msg("Your %s flickers and dies." % self.key) + self.location.location.check_light_state() + except AttributeError: + try: + self.location.msg_contents("A %s on the floor flickers and dies." % self.key) + self.location.location.check_light_state() + except AttributeError: + # Mainly happens if we happen to be in a None location + pass + self.delete() + +
[docs] def light(self): + """ + Light this object - this is called by Light command. + """ + if self.db.is_giving_light: + return False + # burn for 3 minutes before calling _burnout + self.db.is_giving_light = True + # if we are in a dark room, trigger its light check + try: + self.location.location.check_light_state() + except AttributeError: + try: + # maybe we are directly in the room + self.location.check_light_state() + except AttributeError: + # we are in a None location + pass + finally: + # start the burn timer. When it runs out, self._burnout + # will be called. We store the deferred so it can be + # killed in unittesting. + self.deferred = delay(60 * 3, self._burnout) + return True
+ + +# ------------------------------------------------------------- +# +# Crumbling wall - unique exit +# +# This implements a simple puzzle exit that needs to be +# accessed with commands before one can get to traverse it. +# +# The puzzle-part is simply to move roots (that have +# presumably covered the wall) aside until a button for a +# secret door is revealed. The original position of the +# roots blocks the button, so they have to be moved to a certain +# position - when they have, the "press button" command +# is made available and the Exit is made traversable. +# +# ------------------------------------------------------------- + +# There are four roots - two horizontal and two vertically +# running roots. Each can have three positions: top/middle/bottom +# and left/middle/right respectively. There can be any number of +# roots hanging through the middle position, but only one each +# along the sides. The goal is to make the center position clear. +# (yes, it's really as simple as it sounds, just move the roots +# to each side to "win". This is just a tutorial, remember?) +# +# The ShiftRoot command depends on the root object having an +# Attribute root_pos (a dictionary) to describe the current +# position of the roots. + + +
[docs]class CmdShiftRoot(Command): + """ + Shifts roots around. + + Usage: + shift blue root left/right + shift red root left/right + shift yellow root up/down + shift green root up/down + + """ + + key = "shift" + aliases = ["shiftroot", "push", "pull", "move"] + # we only allow to use this command while the + # room is properly lit, so we lock it to the + # setting of Attribute "is_lit" on our location. + locks = "cmd:locattr(is_lit)" + help_category = "TutorialWorld" + +
[docs] def parse(self): + """ + Custom parser; split input by spaces for simplicity. + """ + self.arglist = self.args.strip().split()
+ +
[docs] def func(self): + """ + Implement the command. + blue/red - vertical roots + yellow/green - horizontal roots + """ + + if not self.arglist: + self.caller.msg("What do you want to move, and in what direction?") + return + + if "root" in self.arglist: + # we clean out the use of the word "root" + self.arglist.remove("root") + + # we accept arguments on the form <color> <direction> + + if not len(self.arglist) > 1: + self.caller.msg( + "You must define which colour of root you want to move, and in which direction." + ) + return + + color = self.arglist[0].lower() + direction = self.arglist[1].lower() + + # get current root positions dict + root_pos = self.obj.db.root_pos + + if color not in root_pos: + self.caller.msg("No such root to move.") + return + + # first, vertical roots (red/blue) - can be moved left/right + if color == "red": + if direction == "left": + root_pos[color] = max(-1, root_pos[color] - 1) + self.caller.msg("You shift the reddish root to the left.") + if root_pos[color] != 0 and root_pos[color] == root_pos["blue"]: + root_pos["blue"] += 1 + self.caller.msg( + "The root with blue flowers gets in the way and is pushed to the right." + ) + elif direction == "right": + root_pos[color] = min(1, root_pos[color] + 1) + self.caller.msg("You shove the reddish root to the right.") + if root_pos[color] != 0 and root_pos[color] == root_pos["blue"]: + root_pos["blue"] -= 1 + self.caller.msg( + "The root with blue flowers gets in the way and is pushed to the left." + ) + else: + self.caller.msg( + "The root hangs straight down - you can only move it left or right." + ) + elif color == "blue": + if direction == "left": + root_pos[color] = max(-1, root_pos[color] - 1) + self.caller.msg("You shift the root with small blue flowers to the left.") + if root_pos[color] != 0 and root_pos[color] == root_pos["red"]: + root_pos["red"] += 1 + self.caller.msg( + "The reddish root is too big to fit as well, so that one falls away to the left." + ) + elif direction == "right": + root_pos[color] = min(1, root_pos[color] + 1) + self.caller.msg("You shove the root adorned with small blue flowers to the right.") + if root_pos[color] != 0 and root_pos[color] == root_pos["red"]: + root_pos["red"] -= 1 + self.caller.msg( + "The thick reddish root gets in the way and is pushed back to the left." + ) + else: + self.caller.msg( + "The root hangs straight down - you can only move it left or right." + ) + + # now the horizontal roots (yellow/green). They can be moved up/down + elif color == "yellow": + if direction == "up": + root_pos[color] = max(-1, root_pos[color] - 1) + self.caller.msg("You shift the root with small yellow flowers upwards.") + if root_pos[color] != 0 and root_pos[color] == root_pos["green"]: + root_pos["green"] += 1 + self.caller.msg("The green weedy root falls down.") + elif direction == "down": + root_pos[color] = min(1, root_pos[color] + 1) + self.caller.msg("You shove the root adorned with small yellow flowers downwards.") + if root_pos[color] != 0 and root_pos[color] == root_pos["green"]: + root_pos["green"] -= 1 + self.caller.msg("The weedy green root is shifted upwards to make room.") + else: + self.caller.msg("The root hangs across the wall - you can only move it up or down.") + elif color == "green": + if direction == "up": + root_pos[color] = max(-1, root_pos[color] - 1) + self.caller.msg("You shift the weedy green root upwards.") + if root_pos[color] != 0 and root_pos[color] == root_pos["yellow"]: + root_pos["yellow"] += 1 + self.caller.msg("The root with yellow flowers falls down.") + elif direction == "down": + root_pos[color] = min(1, root_pos[color] + 1) + self.caller.msg("You shove the weedy green root downwards.") + if root_pos[color] != 0 and root_pos[color] == root_pos["yellow"]: + root_pos["yellow"] -= 1 + self.caller.msg( + "The root with yellow flowers gets in the way and is pushed upwards." + ) + else: + self.caller.msg("The root hangs across the wall - you can only move it up or down.") + + # we have moved the root. Store new position + self.obj.db.root_pos = root_pos + + # Check victory condition + if list(root_pos.values()).count(0) == 0: # no roots in middle position + # This will affect the cmd: lock of CmdPressButton + self.obj.db.button_exposed = True + self.caller.msg("Holding aside the root you think you notice something behind it ...")
+ + +
[docs]class CmdPressButton(Command): + """ + Presses a button. + """ + + key = "press" + aliases = ["press button", "button", "push button"] + # only accessible if the button was found and there is light. This checks + # the Attribute button_exposed on the Wall object so that + # you can only push the button when the puzzle is solved. It also + # checks the is_lit Attribute on the location. + locks = "cmd:objattr(button_exposed) and objlocattr(is_lit)" + help_category = "TutorialWorld" + +
[docs] def func(self): + """Implements the command""" + + if self.caller.db.crumbling_wall_found_exit: + # we already pushed the button + self.caller.msg( + "The button folded away when the secret passage opened. You cannot push it again." + ) + return + + # pushing the button + string = ( + "You move your fingers over the suspicious depression, then gives it a " + "decisive push. First nothing happens, then there is a rumble and a hidden " + "|wpassage|n opens, dust and pebbles rumbling as part of the wall moves aside." + ) + self.caller.msg(string) + string = ( + "%s moves their fingers over the suspicious depression, then gives it a " + "decisive push. First nothing happens, then there is a rumble and a hidden " + "|wpassage|n opens, dust and pebbles rumbling as part of the wall moves aside." + ) + self.caller.location.msg_contents(string % self.caller.key, exclude=self.caller) + if not self.obj.open_wall(): + self.caller.msg("The exit leads nowhere, there's just more stone behind it ...")
+ + +
[docs]class CmdSetCrumblingWall(CmdSet): + """Group the commands for crumblingWall""" + + key = "crumblingwall_cmdset" + priority = 2 + +
[docs] def at_cmdset_creation(self): + """called when object is first created.""" + self.add(CmdShiftRoot()) + self.add(CmdPressButton())
+ + +
[docs]class CrumblingWall(TutorialObject, DefaultExit): + """ + This is a custom Exit. + + The CrumblingWall can be examined in various ways, but only if a + lit light source is in the room. The traversal itself is blocked + by a traverse: lock on the exit that only allows passage if a + certain attribute is set on the trying account. + + Important attribute + destination - this property must be set to make this a valid exit + whenever the button is pushed (this hides it as an exit + until it actually is) + """ + +
[docs] def at_init(self): + """ + Called when object is recalled from cache. + """ + self.reset()
+ +
[docs] def at_object_creation(self): + """called when the object is first created.""" + super().at_object_creation() + + 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: + # (-1, 0, 1) where 0 means the middle position. yellow/green are + # horizontal roots and red/blue vertical, all may have value 0, but n + # ever any other identical value. + self.db.root_pos = {"yellow": 0, "green": 0, "red": 0, "blue": 0} + + # flags controlling the puzzle victory conditions + self.db.button_exposed = False + self.db.exit_open = False + + # this is not even an Exit until it has a proper destination, and we won't assign + # that until it is actually open. Until then we store the destination here. This + # should be given a reasonable value at creation! + self.db.destination = "#2" + + # we lock this Exit so that one can only execute commands on it + # if its location is lit and only traverse it once the Attribute + # exit_open is set to True. + self.locks.add("cmd:locattr(is_lit);traverse:objattr(exit_open)") + # set cmdset + self.cmdset.add(CmdSetCrumblingWall, persistent=True)
+ +
[docs] def open_wall(self): + """ + This method is called by the push button command once the puzzle + is solved. It opens the wall and sets a timer for it to reset + itself. + """ + # this will make it into a proper exit (this returns a list) + eloc = search.search_object(self.db.destination) + if not eloc: + return False + else: + self.destination = eloc[0] + self.db.exit_open = True + # 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""" + rootnames = { + "red": "The |rreddish|n vertical-hanging root ", + "blue": "The thick vertical root with |bblue|n flowers ", + "yellow": "The thin horizontal-hanging root with |yyellow|n flowers ", + "green": "The weedy |ggreen|n horizontal root ", + } + vpos = { + -1: "hangs far to the |wleft|n on the wall.", + 0: "hangs straight down the |wmiddle|n of the wall.", + 1: "hangs far to the |wright|n of the wall.", + } + hpos = { + -1: "covers the |wupper|n part of the wall.", + 0: "passes right over the |wmiddle|n of the wall.", + 1: "nearly touches the floor, near the |wbottom|n of the wall.", + } + + if root in ("yellow", "green"): + string = rootnames[root] + hpos[ipos] + else: + string = rootnames[root] + vpos[ipos] + return string + +
[docs] def return_appearance(self, caller): + """ + This is called when someone looks at the wall. We need to echo the + current root positions. + """ + if self.db.button_exposed: + # we found the button by moving the roots + result = [ + "Having moved all the roots aside, you find that the center of the wall, " + "previously hidden by the vegetation, hid a curious square depression. It was maybe once " + "concealed and made to look a part of the wall, but with the crumbling of stone around it, " + "it's now easily identifiable as some sort of button." + ] + elif self.db.exit_open: + # we pressed the button; the exit is open + result = [ + "With the button pressed, a crack has opened in the root-covered wall, just wide enough " + "to squeeze through. A cold draft is coming from the hole and you get the feeling the " + "opening may close again soon." + ] + else: + # puzzle not solved yet. + result = [ + "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 (like '|wshift red up|n').\n" + ] + # display the root positions to help with the puzzle + for key, pos in self.db.root_pos.items(): + result.append("\n" + self._translate_position(key, pos)) + self.db.desc = "".join(result) + + # call the parent to continue execution (will use the desc we just set) + return super().return_appearance(caller)
+ +
[docs] def at_post_traverse(self, traverser, source_location): + """ + This is called after we traversed this exit. Cleans up and resets + the puzzle. + """ + del traverser.db.crumbling_wall_found_buttothe + del traverser.db.crumbling_wall_found_exit + self.reset()
+ +
[docs] def at_failed_traverse(self, traverser): + """This is called if the account fails to pass the Exit.""" + traverser.msg("No matter how you try, you cannot force yourself through %s." % self.key)
+ +
[docs] def reset(self): + """ + Called by tutorial world runner, or whenever someone successfully + traversed the Exit. + """ + self.location.msg_contents( + "The secret door closes abruptly, roots falling back into place." + ) + + # reset the flags and remove the exit destination + self.db.button_exposed = False + self.db.exit_open = False + self.destination = None + + # Reset the roots with some random starting positions for the roots: + start_pos = [ + {"yellow": 1, "green": 0, "red": 0, "blue": 0}, + {"yellow": 0, "green": 0, "red": 0, "blue": 0}, + {"yellow": 0, "green": 1, "red": -1, "blue": 0}, + {"yellow": 1, "green": 0, "red": 0, "blue": 0}, + {"yellow": 0, "green": 0, "red": 0, "blue": 1}, + ] + self.db.root_pos = random.choice(start_pos)
+ + +# ------------------------------------------------------------- +# +# TutorialWeapon - object type +# +# A weapon is necessary in order to fight in the tutorial +# world. A weapon (which here is assumed to be a bladed +# melee weapon for close combat) has three commands, +# stab, slash and defend. Weapons also have a property "magic" +# to determine if they are usable against certain enemies. +# +# Since Characters don't have special skills in the tutorial, +# we let the weapon itself determine how easy/hard it is +# to hit with it, and how much damage it can do. +# +# ------------------------------------------------------------- + + +
[docs]class CmdAttack(Command): + """ + Attack the enemy. Commands: + + stab <enemy> + slash <enemy> + parry + + stab - (thrust) makes a lot of damage but is harder to hit with. + slash - is easier to land, but does not make as much damage. + parry - forgoes your attack but will make you harder to hit on next + enemy attack. + + """ + + # this is an example of implementing many commands as a single + # command class, using the given command alias to separate between them. + + key = "attack" + aliases = [ + "hit", + "kill", + "fight", + "thrust", + "pierce", + "stab", + "slash", + "chop", + "bash", + "parry", + "defend", + ] + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """Implements the stab""" + + cmdstring = self.cmdstring + + if cmdstring in ("attack", "fight"): + string = "How do you want to fight? Choose one of 'stab', 'slash' or 'defend'." + self.caller.msg(string) + return + + # parry mode + if cmdstring in ("parry", "defend"): + string = ( + "You raise your weapon in a defensive pose, ready to block the next enemy attack." + ) + self.caller.msg(string) + self.caller.db.combat_parry_mode = True + self.caller.location.msg_contents( + "%s takes a defensive stance" % self.caller, exclude=[self.caller] + ) + return + + if not self.args: + self.caller.msg("Who do you attack?") + return + target = self.caller.search(self.args.strip()) + if not target: + return + + if cmdstring in ("thrust", "pierce", "stab"): + hit = float(self.obj.db.hit) * 0.7 # modified due to stab + damage = self.obj.db.damage * 2 # modified due to stab + string = "You stab with %s. " % self.obj.key + 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", "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 + tstring = "%s slash at you with %s. " % (self.caller.key, self.obj.key) + ostring = "%s slash at %s with %s. " % (self.caller.key, target.key, self.obj.key) + self.caller.db.combat_parry_mode = False + else: + self.caller.msg( + "You fumble with your weapon, unsure of whether to stab, slash or parry ..." + ) + self.caller.location.msg_contents( + "%s fumbles with their weapon." % self.caller, exclude=self.caller + ) + self.caller.db.combat_parry_mode = False + return + + if target.db.combat_parry_mode: + # target is defensive; even harder to hit! + target.msg("|GYou defend, trying to avoid the attack.|n") + hit *= 0.5 + + if random.random() <= hit: + self.caller.msg(string + "|gIt's a hit!|n") + target.msg(tstring + "|rIt's a hit!|n") + self.caller.location.msg_contents( + ostring + "It's a hit!", exclude=[target, self.caller] + ) + + # call enemy hook + if hasattr(target, "at_hit"): + # should return True if target is defeated, False otherwise. + target.at_hit(self.obj, self.caller, damage) + return + elif target.db.health: + target.db.health -= damage + else: + # sorry, impossible to fight this enemy ... + self.caller.msg("The enemy seems unaffected.") + return + else: + self.caller.msg(string + "|rYou miss.|n") + target.msg(tstring + "|gThey miss you.|n") + self.caller.location.msg_contents(ostring + "They miss.", exclude=[target, self.caller])
+ + +
[docs]class CmdSetWeapon(CmdSet): + """Holds the attack command.""" + +
[docs] def at_cmdset_creation(self): + """called at first object creation.""" + self.add(CmdAttack())
+ + +
[docs]class TutorialWeapon(TutorialObject): + """ + This defines a bladed weapon. + + Important attributes (set at creation): + hit - chance to hit (0-1) + parry - chance to parry (0-1) + damage - base damage given (modified by hit success and + type of attack) (0-10) + + """ + +
[docs] def at_object_creation(self): + """Called at first creation of the object""" + super().at_object_creation() + self.db.hit = 0.4 # hit chance + self.db.parry = 0.8 # parry chance + self.db.damage = 1.0 + self.db.magic = False + self.cmdset.add_default(CmdSetWeapon, persistent=True)
+ +
[docs] def reset(self): + """ + When reset, the weapon is simply deleted, unless it has a place + to return to. + """ + if self.location.has_account and self.home == self.location: + self.location.msg_contents( + "%s suddenly and magically fades into nothingness, as if it was never there ..." + % self.key + ) + self.delete() + else: + self.location = self.home
+ + +# ------------------------------------------------------------- +# +# Weapon rack - spawns weapons +# +# This is a spawner mechanism that creates custom weapons from a +# spawner prototype dictionary. Note that we only create a single typeclass +# (Weapon) yet customize all these different weapons using the spawner. +# The spawner dictionaries could easily sit in separate modules and be +# used to create unique and interesting variations of typeclassed +# objects. +# +# ------------------------------------------------------------- + +WEAPON_PROTOTYPES = { + "weapon": { + "typeclass": "evennia.contrib.tutorials.tutorial_world.objects.TutorialWeapon", + "key": "Weapon", + "hit": 0.2, + "parry": 0.2, + "damage": 1.0, + "magic": False, + "desc": "A generic blade.", + }, + "knife": { + "prototype_parent": "weapon", + "aliases": "sword", + "key": "Kitchen knife", + "desc": "A rusty kitchen knife. Better than nothing.", + "damage": 3, + }, + "dagger": { + "prototype_parent": "knife", + "key": "Rusty dagger", + "aliases": ["knife", "dagger"], + "desc": "A double-edged dagger with a nicked edge and a wooden handle.", + "hit": 0.25, + }, + "sword": { + "prototype_parent": "weapon", + "key": "Rusty sword", + "aliases": ["sword"], + "desc": "A rusty shortsword. It has a leather-wrapped handle covered i food grease.", + "hit": 0.3, + "damage": 5, + "parry": 0.5, + }, + "club": { + "prototype_parent": "weapon", + "key": "Club", + "desc": "A heavy wooden club, little more than a heavy branch.", + "hit": 0.4, + "damage": 6, + "parry": 0.2, + }, + "axe": { + "prototype_parent": "weapon", + "key": "Axe", + "desc": "A woodcutter's axe with a keen edge.", + "hit": 0.4, + "damage": 6, + "parry": 0.2, + }, + "ornate longsword": { + "prototype_parent": "sword", + "key": "Ornate longsword", + "desc": "A fine longsword with some swirling patterns on the handle.", + "hit": 0.5, + "magic": True, + "damage": 5, + }, + "warhammer": { + "prototype_parent": "club", + "key": "Silver Warhammer", + "aliases": ["hammer", "warhammer", "war"], + "desc": "A heavy war hammer with silver ornaments. This huge weapon causes massive damage - if you can hit.", + "hit": 0.4, + "magic": True, + "damage": 8, + }, + "rune axe": { + "prototype_parent": "axe", + "key": "Runeaxe", + "aliases": ["axe"], + "hit": 0.4, + "magic": True, + "damage": 6, + }, + "thruning": { + "prototype_parent": "ornate longsword", + "key": "Broadsword named Thruning", + "desc": "This heavy bladed weapon is marked with the name 'Thruning'. It is very powerful in skilled hands.", + "hit": 0.6, + "parry": 0.6, + "damage": 7, + }, + "slayer waraxe": { + "prototype_parent": "rune axe", + "key": "Slayer waraxe", + "aliases": ["waraxe", "war", "slayer"], + "desc": "A huge double-bladed axe marked with the runes for 'Slayer'." + " It has more runic inscriptions on its head, which you cannot decipher.", + "hit": 0.7, + "damage": 8, + }, + "ghostblade": { + "prototype_parent": "ornate longsword", + "key": "The Ghostblade", + "aliases": ["blade", "ghost"], + "desc": "This massive sword is large as you are tall, yet seems to weigh almost nothing." + " It's almost like it's not really there.", + "hit": 0.9, + "parry": 0.8, + "damage": 10, + }, + "hawkblade": { + "prototype_parent": "ghostblade", + "key": "The Hawkblade", + "aliases": ["hawk", "blade"], + "desc": "The weapon of a long-dead heroine and a more civilized age," + " the hawk-shaped hilt of this blade almost has a life of its own.", + "hit": 0.85, + "parry": 0.7, + "damage": 11, + }, +} + + +
[docs]class CmdGetWeapon(Command): + """ + Usage: + get weapon + + This will try to obtain a weapon from the container. + """ + + key = "get weapon" + aliases = "get weapon" + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """ + Get a weapon from the container. It will + itself handle all messages. + """ + self.obj.produce_weapon(self.caller)
+ + +
[docs]class CmdSetWeaponRack(CmdSet): + """ + The cmdset for the rack. + """ + + key = "weaponrack_cmdset" + +
[docs] def at_cmdset_creation(self): + """Called at first creation of cmdset""" + self.add(CmdGetWeapon())
+ + +
[docs]class TutorialWeaponRack(TutorialObject): + """ + This object represents a weapon store. When people use the + "get weapon" command on this rack, it will produce one + random weapon from among those registered to exist + on it. This will also set a property on the character + to make sure they can't get more than one at a time. + + Attributes to set on this object: + available_weapons: list of prototype-keys from + WEAPON_PROTOTYPES, the weapons available in this rack. + no_more_weapons_msg - error message to return to accounts + who already got one weapon from the rack and tries to + grab another one. + + """ + +
[docs] def at_object_creation(self): + """ + called at creation + """ + self.cmdset.add_default(CmdSetWeaponRack, persistent=True) + self.db.rack_id = "weaponrack_1" + # these are prototype names from the prototype + # dictionary above. + 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"]
+ +
[docs] def produce_weapon(self, caller): + """ + This will produce a new weapon from the rack, + assuming the caller hasn't already gotten one. When + doing so, the caller will get Tagged with the id + of this rack, to make sure they cannot keep + pulling weapons from it indefinitely. + """ + rack_id = self.db.rack_id + if caller.tags.get(rack_id, category="tutorial_world"): + caller.msg(self.db.no_more_weapons_msg) + else: + prototype = random.choice(self.db.available_weapons) + # use the spawner to create a new Weapon from the + # spawner dictionary, tag the caller + wpn = spawn(WEAPON_PROTOTYPES[prototype], prototype_parents=WEAPON_PROTOTYPES)[0] + caller.tags.add(rack_id, category="tutorial_world") + wpn.location = caller + caller.msg(self.db.get_weapon_msg % wpn.key)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/rooms.html b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/rooms.html new file mode 100644 index 0000000000..07c5b0cafb --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/rooms.html @@ -0,0 +1,1303 @@ + + + + + + + + evennia.contrib.tutorials.tutorial_world.rooms — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.tutorial_world.rooms

+"""
+
+Room Typeclasses for the TutorialWorld.
+
+This defines special types of Rooms available in the tutorial. To keep
+everything in one place we define them together with the custom
+commands needed to control them. Those commands could also have been
+in a separate module (e.g. if they could have been re-used elsewhere.)
+
+"""
+
+
+import random
+
+# the system error-handling module is defined in the settings. We load the
+# given setting here using utils.object_from_module. This way we can use
+# it regardless of if we change settings later.
+from django.conf import settings
+
+from evennia import (
+    TICKER_HANDLER,
+    CmdSet,
+    Command,
+    DefaultExit,
+    DefaultRoom,
+    create_object,
+    default_cmds,
+    search_object,
+    syscmdkeys,
+    utils,
+)
+
+from .objects import LightSource
+
+_SEARCH_AT_RESULT = utils.object_from_module(settings.SEARCH_AT_RESULT)
+
+# -------------------------------------------------------------
+#
+# Tutorial room - parent room class
+#
+# This room is the parent of all rooms in the tutorial.
+# It defines a tutorial command on itself (available to
+# all those who are in a tutorial room).
+#
+# -------------------------------------------------------------
+
+#
+# Special command available in all tutorial rooms
+
+
+
[docs]class CmdTutorial(Command): + """ + Get help during the tutorial + + Usage: + tutorial [obj] + + This command allows you to get behind-the-scenes info + about an object or the current location. + + """ + + key = "tutorial" + aliases = ["tut"] + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """ + All we do is to scan the current location for an Attribute + called `tutorial_info` and display that. + """ + + caller = self.caller + + if not self.args: + target = self.obj # this is the room the command is defined on + else: + target = caller.search(self.args.strip()) + if not target: + return + 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 +# argument. +
[docs]class CmdTutorialSetDetail(default_cmds.MuxCommand): + """ + sets a detail on a room + + Usage: + @detail <key> = <description> + @detail <key>;<alias>;... = description + + Example: + @detail walls = The walls are covered in ... + @detail castle;ruin;tower = The distant ruin ... + + This sets a "detail" on the object this command is defined on + (TutorialRoom for this tutorial). This detail can be accessed with + the TutorialRoomLook command sitting on TutorialRoom objects (details + are set as a simple dictionary on the room). This is a Builder command. + + We custom parse the key for the ;-separator in order to create + multiple aliases to the detail all at once. + """ + + key = "@detail" + locks = "cmd:perm(Builder)" + help_category = "TutorialWorld" + +
[docs] def func(self): + """ + All this does is to check if the object has + the set_detail method and uses it. + """ + if not self.args or not self.rhs: + self.caller.msg("Usage: @detail key = description") + return + if not hasattr(self.obj, "set_detail"): + self.caller.msg("Details cannot be set on %s." % self.obj) + return + for key in self.lhs.split(";"): + # loop over all aliases, if any (if not, this will just be + # the one key to loop over) + self.obj.set_detail(key, self.rhs) + self.caller.msg("Detail set: '%s': '%s'" % (self.lhs, self.rhs))
+ + +
[docs]class CmdTutorialLook(default_cmds.CmdLook): + """ + looks at the room and on details + + Usage: + look <obj> + look <room detail> + look *<account> + + Observes your location, details at your location or objects + in your vicinity. + + Tutorial: This is a child of the default Look command, that also + allows us to look at "details" in the room. These details are + things to examine and offers some extra description without + actually having to be actual database objects. It uses the + return_detail() hook on TutorialRooms for this. + """ + + # we don't need to specify key/locks etc, this is already + # set by the parent. + help_category = "TutorialWorld" + +
[docs] def func(self): + """ + Handle the looking. This is a copy of the default look + code except for adding in the details. + """ + caller = self.caller + args = self.args + if args: + # we use quiet=True to turn off automatic error reporting. + # This tells search that we want to handle error messages + # ourself. This also means the search function will always + # return a list (with 0, 1 or more elements) rather than + # result/None. + looking_at_obj = caller.search( + args, + # note: excludes room/room aliases + candidates=caller.location.contents + caller.contents, + use_nicks=True, + quiet=True, + ) + if len(looking_at_obj) != 1: + # no target found or more than one target found (multimatch) + # look for a detail that may match + detail = self.obj.return_detail(args) + if detail: + self.caller.msg(detail) + return + else: + # no detail found, delegate our result to the normal + # error message handler. + _SEARCH_AT_RESULT(looking_at_obj, caller, args) + return + else: + # we found a match, extract it from the list and carry on + # normally with the look handling. + looking_at_obj = looking_at_obj[0] + + else: + looking_at_obj = caller.location + if not looking_at_obj: + caller.msg("You have no location to look at!") + return + + if not hasattr(looking_at_obj, "return_appearance"): + # this is likely due to us having an account instead + looking_at_obj = looking_at_obj.character + if not looking_at_obj.access(caller, "view"): + caller.msg("Could not find '%s'." % args) + return + # get object's appearance + caller.msg(looking_at_obj.return_appearance(caller)) + # the object's at_desc() method. + looking_at_obj.at_desc(looker=caller) + return
+ + +
[docs]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"] + +
[docs] 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, move_type="teleport")
+ + +
[docs]class TutorialRoomCmdSet(CmdSet): + """ + Implements the simple tutorial cmdset. This will overload the look + command in the default CharacterCmdSet since it has a higher + priority (ChracterCmdSet has prio 0) + """ + + key = "tutorial_cmdset" + priority = 1 + +
[docs] def at_cmdset_creation(self): + """add the tutorial-room commands""" + self.add(CmdTutorial()) + self.add(CmdTutorialSetDetail()) + self.add(CmdTutorialLook()) + self.add(CmdTutorialGiveUp())
+ + +
[docs]class TutorialRoom(DefaultRoom): + """ + This is the base room type for all rooms in the tutorial world. + It defines a cmdset on itself for reading tutorial info about the location. + """ + +
[docs] def at_object_creation(self): + """Called when room is first created""" + self.db.tutorial_info = ( + "This is a tutorial room. It allows you to use the 'tutorial' command." + ) + self.cmdset.add_default(TutorialRoomCmdSet)
+ +
[docs] def at_object_receive(self, new_arrival, source_location, move_type="move", **kwargs): + """ + When an object enter a tutorial room we tell other objects in + the room about it by trying to call a hook on them. The Mob object + uses this to cheaply get notified of enemies without having + to constantly scan for them. + + Args: + new_arrival (Object): the object that just entered this room. + source_location (Object): the previous location of new_arrival. + + """ + if new_arrival.has_account and not new_arrival.is_superuser: + # this is a character + for obj in self.contents_get(exclude=new_arrival): + if hasattr(obj, "at_new_arrival"): + obj.at_new_arrival(new_arrival)
+ +
[docs] def return_detail(self, detailkey): + """ + This looks for an Attribute "obj_details" and possibly + returns the value of it. + + Args: + detailkey (str): The detail being looked at. This is + case-insensitive. + + """ + details = self.db.details + if details: + return details.get(detailkey.lower(), None)
+ +
[docs] def set_detail(self, detailkey, description): + """ + This sets a new detail, using an Attribute "details". + + Args: + detailkey (str): The detail identifier to add (for + aliases you need to add multiple keys to the + same description). Case-insensitive. + description (str): The text to return when looking + at the given detailkey. + + """ + if self.db.details: + self.db.details[detailkey.lower()] = description + else: + self.db.details = {detailkey.lower(): description}
+ + +
[docs]class TutorialStartExit(DefaultExit): + """ + This is like a normal exit except it makes the `intro` command available + on itself. We put it on the exit in order to provide this command to the + Limbo room without modifying Limbo itself - deleting the tutorial exit + will also clean up the intro command. + + """ + +
[docs] def at_object_creation(self): + self.cmdset.add(CmdSetEvenniaIntro, persistent=True)
+ + +# ------------------------------------------------------------- +# +# Weather room - room with a ticker +# +# ------------------------------------------------------------- + +# These are rainy weather strings +WEATHER_STRINGS = ( + "The rain coming down from the iron-grey sky intensifies.", + "A gust of wind throws the rain right in your face. Despite your cloak you shiver.", + "The rainfall eases a bit and the sky momentarily brightens.", + "For a moment it looks like the rain is slowing, then it begins anew with renewed force.", + "The rain pummels you with large, heavy drops. You hear the rumble of thunder in the distance.", + "The wind is picking up, howling around you, throwing water droplets in your face. It's cold.", + "Bright fingers of lightning flash over the sky, moments later followed by a deafening rumble.", + "It rains so hard you can hardly see your hand in front of you. You'll soon be drenched to the bone.", + "Lightning strikes in several thundering bolts, striking the trees in the forest to your west.", + "You hear the distant howl of what sounds like some sort of dog or wolf.", + "Large clouds rush across the sky, throwing their load of rain over the world.", +) + + +
[docs]class WeatherRoom(TutorialRoom): + """ + This should probably better be called a rainy room... + + This sets up an outdoor room typeclass. At irregular intervals, + the effects of weather will show in the room. Outdoor rooms should + inherit from this. + + """ + +
[docs] def at_object_creation(self): + """ + Called when object is first created. + We set up a ticker to update this room regularly. + + Note that we could in principle also use a Script to manage + the ticking of the room; the TickerHandler works fine for + simple things like this though. + """ + super().at_object_creation() + # subscribe ourselves to a ticker to repeatedly call the hook + # "update_weather" on this object. The interval is randomized + # so as to not have all weather rooms update at the same time. + self.db.interval = random.randint(50, 70) + TICKER_HANDLER.add( + interval=self.db.interval, callback=self.update_weather, idstring="tutorial" + ) + # this is parsed by the 'tutorial' command on TutorialRooms. + self.db.tutorial_info = "This room has a Script running that has it echo a weather-related message at irregular intervals."
+ +
[docs] def update_weather(self, *args, **kwargs): + """ + Called by the tickerhandler at regular intervals. Even so, we + only update 20% of the time, picking a random weather message + when we do. The tickerhandler requires that this hook accepts + any arguments and keyword arguments (hence the *args, **kwargs + even though we don't actually use them in this example) + """ + if random.random() < 0.2: + # only update 20 % of the time + self.msg_contents("|w%s|n" % random.choice(WEATHER_STRINGS))
+ + +SUPERUSER_WARNING = ( + "\nWARNING: You are playing as a superuser ({name}). Use the {quell} command to\n" + "play without superuser privileges (many functions and puzzles ignore the \n" + "presence of a superuser, making this mode useful for exploring things behind \n" + "the scenes later).\n" +) + +# ------------------------------------------------------------ +# +# Intro Room - unique room +# +# This room marks the start of the tutorial. It sets up properties on +# the player char that is needed for the tutorial. +# +# ------------------------------------------------------------- + + +
[docs]class CmdEvenniaIntro(Command): + """ + Start the Evennia intro wizard. + + Usage: + intro + + """ + + key = "intro" + +
[docs] def func(self): + from .intro_menu import init_menu + + # quell also superusers + if self.caller.account: + self.caller.msg("Auto-quelling permissions while in intro ...") + self.caller.account.execute_cmd("quell") + init_menu(self.caller)
+ + +
[docs]class CmdSetEvenniaIntro(CmdSet): + key = "Evennia Intro StartSet" + +
[docs] def at_cmdset_creation(self): + self.add(CmdEvenniaIntro())
+ + +
[docs]class IntroRoom(TutorialRoom): + """ + Intro room + + properties to customize: + char_health - integer > 0 (default 20) + """ + +
[docs] def at_object_creation(self): + """ + Called when the room is first created. + """ + super().at_object_creation() + self.db.tutorial_info = ( + "The first room of the tutorial. " + "This assigns the health Attribute to " + "the account." + )
+ +
[docs] def at_object_receive(self, character, source_location, move_type="move", **kwargs): + """ + Assign properties on characters + """ + + # setup character for the tutorial + health = self.db.char_health or 20 + + if character.has_account: + character.db.health = health + character.db.health_max = health + + if character.is_superuser: + string = "-" * 78 + SUPERUSER_WARNING + "-" * 78 + 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)")
+ + +# ------------------------------------------------------------- +# +# Bridge - unique room +# +# Defines a special west-eastward "bridge"-room, a large room that takes +# several steps to cross. It is complete with custom commands and a +# chance of falling off the bridge. This room has no regular exits, +# instead the exitings are handled by custom commands set on the account +# upon first entering the room. +# +# Since one can enter the bridge room from both ends, it is +# divided into five steps: +# westroom <- 0 1 2 3 4 -> eastroom +# +# ------------------------------------------------------------- + + +
[docs]class CmdEast(Command): + """ + Go eastwards across the bridge. + + Tutorial info: + This command relies on the caller having two Attributes + (assigned by the room when entering): + - east_exit: a unique name or dbref to the room to go to + when exiting east. + - west_exit: a unique name or dbref to the room to go to + when exiting west. + The room must also have the following Attributes + - tutorial_bridge_posistion: the current position on + on the bridge, 0 - 4. + + """ + + key = "east" + aliases = ["e"] + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """move one step eastwards""" + caller = self.caller + + bridge_step = min(5, caller.db.tutorial_bridge_position + 1) + + if bridge_step > 4: + # we have reached the far east end of the bridge. + # Move to the east room. + eexit = search_object(self.obj.db.east_exit) + if eexit: + caller.move_to(eexit[0], move_type="traverse") + else: + caller.msg("No east exit was found for this room. Contact an admin.") + return + caller.db.tutorial_bridge_position = bridge_step + # since we are really in one room, we have to notify others + # in the room when we move. + caller.location.msg_contents( + "%s steps eastwards across the bridge." % caller.name, exclude=caller + ) + caller.execute_cmd("look")
+ + +# go back across the bridge +
[docs]class CmdWest(Command): + """ + Go westwards across the bridge. + + Tutorial info: + This command relies on the caller having two Attributes + (assigned by the room when entering): + - east_exit: a unique name or dbref to the room to go to + when exiting east. + - west_exit: a unique name or dbref to the room to go to + when exiting west. + The room must also have the following property: + - tutorial_bridge_posistion: the current position on + on the bridge, 0 - 4. + + """ + + key = "west" + aliases = ["w"] + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """move one step westwards""" + caller = self.caller + + bridge_step = max(-1, caller.db.tutorial_bridge_position - 1) + + if bridge_step < 0: + # we have reached the far west end of the bridge. + # Move to the west room. + wexit = search_object(self.obj.db.west_exit) + if wexit: + caller.move_to(wexit[0], move_type="traverse") + else: + caller.msg("No west exit was found for this room. Contact an admin.") + return + caller.db.tutorial_bridge_position = bridge_step + # since we are really in one room, we have to notify others + # in the room when we move. + caller.location.msg_contents( + "%s steps westwards across the bridge." % caller.name, exclude=caller + ) + caller.execute_cmd("look")
+ + +BRIDGE_POS_MESSAGES = ( + "You are standing |wvery close to the the bridge's western foundation|n." + " If you go west you will be back on solid ground ...", + "The bridge slopes precariously where it extends eastwards" + " towards the lowest point - the center point of the hang bridge.", + "You are |whalfways|n out on the unstable bridge.", + "The bridge slopes precariously where it extends westwards" + " towards the lowest point - the center point of the hang bridge.", + "You are standing |wvery close to the bridge's eastern foundation|n." + " If you go east you will be back on solid ground ...", +) +BRIDGE_MOODS = ( + "The bridge sways in the wind.", + "The hanging bridge creaks dangerously.", + "You clasp the ropes firmly as the bridge sways and creaks under you.", + "From the castle you hear a distant howling sound, like that of a large dog or other beast.", + "The bridge creaks under your feet. Those planks does not seem very sturdy.", + "Far below you the ocean roars and throws its waves against the cliff," + " as if trying its best to reach you.", + "Parts of the bridge come loose behind you, falling into the chasm far below!", + "A gust of wind causes the bridge to sway precariously.", + "Under your feet a plank comes loose, tumbling down. For a moment you dangle over the abyss ...", + "The section of rope you hold onto crumble in your hands," + " parts of it breaking apart. You sway trying to regain balance.", +) + +FALL_MESSAGE = ( + "Suddenly the plank you stand on gives way under your feet! You fall!" + "\nYou try to grab hold of an adjoining plank, but all you manage to do is to " + "divert your fall westwards, towards the cliff face. This is going to hurt ... " + "\n ... The world goes dark ...\n\n" +) + + +
[docs]class CmdLookBridge(Command): + """ + looks around at the bridge. + + Tutorial info: + This command assumes that the room has an Attribute + "fall_exit", a unique name or dbref to the place they end upp + if they fall off the bridge. + """ + + key = "look" + aliases = ["l"] + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """Looking around, including a chance to fall.""" + caller = self.caller + bridge_position = self.caller.db.tutorial_bridge_position + # this command is defined on the room, so we get it through self.obj + location = self.obj + # randomize the look-echo + message = "|c%s|n\n%s\n%s" % ( + location.key, + BRIDGE_POS_MESSAGES[bridge_position], + random.choice(BRIDGE_MOODS), + ) + + chars = [obj for obj in self.obj.contents_get(exclude=caller) if obj.has_account] + if chars: + # we create the You see: message manually here + message += "\n You see: %s" % ", ".join("|c%s|n" % char.key for char in chars) + self.caller.msg(message) + + # there is a chance that we fall if we are on the western or central + # part of the bridge. + if bridge_position < 3 and random.random() < 0.05 and not self.caller.is_superuser: + # we fall 5% of time. + fall_exit = search_object(self.obj.db.fall_exit) + if fall_exit: + self.caller.msg("|r%s|n" % FALL_MESSAGE) + self.caller.move_to(fall_exit[0], quiet=True, move_type="fall") + # inform others on the bridge + self.obj.msg_contents( + "A plank gives way under %s's feet and " + "they fall from the bridge!" % self.caller.key + )
+ + +# custom help command +
[docs]class CmdBridgeHelp(Command): + """ + Overwritten help command while on the bridge. + """ + + key = "help" + aliases = ["h", "?"] + locks = "cmd:all()" + help_category = "Tutorial world" + +
[docs] def func(self): + """Implements the command.""" + string = ( + "You are trying hard not to fall off the bridge ..." + "\n\nWhat you can do is trying to cross the bridge |weast|n" + " or try to get back to the mainland |wwest|n)." + ) + self.caller.msg(string)
+ + +
[docs]class BridgeCmdSet(CmdSet): + """This groups the bridge commands. We will store it on the room.""" + + key = "Bridge commands" + priority = 2 # this gives it precedence over the normal look/help commands. + +
[docs] def at_cmdset_creation(self): + """Called at first cmdset creation""" + self.add(CmdTutorial()) + self.add(CmdEast()) + self.add(CmdWest()) + self.add(CmdLookBridge()) + self.add(CmdBridgeHelp())
+ + +BRIDGE_WEATHER = ( + "The rain intensifies, making the planks of the bridge even more slippery.", + "A gust of wind throws the rain right in your face.", + "The rainfall eases a bit and the sky momentarily brightens.", + "The bridge shakes under the thunder of a closeby thunder strike.", + "The rain pummels you with large, heavy drops. You hear the distinct howl of a large hound in the distance.", + "The wind is picking up, howling around you and causing the bridge to sway from side to side.", + "Some sort of large bird sweeps by overhead, giving off an eery screech. Soon it has disappeared in the gloom.", + "The bridge sways from side to side in the wind.", + "Below you a particularly large wave crashes into the rocks.", + "From the ruin you hear a distant, otherwordly howl. Or maybe it was just the wind.", +) + + +
[docs]class BridgeRoom(WeatherRoom): + """ + The bridge room implements an unsafe bridge. It also enters the player into + a state where they get new commands so as to try to cross the bridge. + + We want this to result in the account getting a special set of + commands related to crossing the bridge. The result is that it + will take several steps to cross it, despite it being represented + by only a single room. + + We divide the bridge into steps: + + self.db.west_exit - - | - - self.db.east_exit + 0 1 2 3 4 + + The position is handled by a variable stored on the character + when entering and giving special move commands will + increase/decrease the counter until the bridge is crossed. + + We also has self.db.fall_exit, which points to a gathering + location to end up if we happen to fall off the bridge (used by + the CmdLookBridge command). + + """ + +
[docs] def at_object_creation(self): + """Setups the room""" + # this will start the weather room's ticker and tell + # it to call update_weather regularly. + super().at_object_creation() + # this identifies the exits from the room (should be the command + # needed to leave through that exit). These are defaults, but you + # could of course also change them after the room has been created. + self.db.west_exit = "cliff" + self.db.east_exit = "gate" + self.db.fall_exit = "cliffledge" + # add the cmdset on the room. + self.cmdset.add(BridgeCmdSet, persistent=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 + # as a normal view target - once inside, our own look will + # handle all return messages. + self.locks.add("view:false()")
+ +
[docs] def update_weather(self, *args, **kwargs): + """ + This is called at irregular intervals and makes the passage + over the bridge a little more interesting. + """ + if random.random() < 80: + # send a message most of the time + self.msg_contents("|w%s|n" % random.choice(BRIDGE_WEATHER))
+ +
[docs] def at_object_receive(self, character, source_location, move_type="move", **kwargs): + """ + This hook is called by the engine whenever the player is moved + into this room. + """ + if character.has_account: + # we only run this if the entered object is indeed a player object. + # check so our east/west exits are correctly defined. + wexit = search_object(self.db.west_exit) + eexit = search_object(self.db.east_exit) + fexit = search_object(self.db.fall_exit) + if not (wexit and eexit and fexit): + character.msg( + "The bridge's exits are not properly configured. " + "Contact an admin. Forcing west-end placement." + ) + character.db.tutorial_bridge_position = 0 + return + if source_location == eexit[0]: + # we assume we enter from the same room we will exit to + character.db.tutorial_bridge_position = 4 + else: + # if not from the east, then from the west! + character.db.tutorial_bridge_position = 0 + character.execute_cmd("look")
+ +
[docs] def at_object_leave(self, character, target_location, move_type="move", **kwargs): + """ + This is triggered when the player leaves the bridge room. + """ + if character.has_account: + # clean up the position attribute + del character.db.tutorial_bridge_position
+ + +# ------------------------------------------------------------------------------- +# +# Dark Room - a room with states +# +# This room limits the movemenets of its denizens unless they carry an active +# LightSource object (LightSource is defined in +# tutorialworld.objects.LightSource) +# +# ------------------------------------------------------------------------------- + + +DARK_MESSAGES = ( + "It is pitch black. You are likely to be eaten by a grue.", + "It's pitch black. You fumble around but cannot find anything.", + "You don't see a thing. You feel around, managing to bump your fingers hard against something. Ouch!", + "You don't see a thing! Blindly grasping the air around you, you find nothing.", + "It's totally dark here. You almost stumble over some un-evenness in the ground.", + "You are completely blind. For a moment you think you hear someone breathing nearby ... " + "\n ... surely you must be mistaken.", + "Blind, you think you find some sort of object on the ground, but it turns out to be just a stone.", + "Blind, you bump into a wall. The wall seems to be covered with some sort of vegetation," + " but its too damp to burn.", + "You can't see anything, but the air is damp. It feels like you are far underground.", +) + +ALREADY_LIGHTSOURCE = ( + "You don't want to stumble around in blindness anymore. You already " + "found what you need. Let's get light already!" +) + +FOUND_LIGHTSOURCE = ( + "Your fingers bump against a splinter of wood in a corner." + " It smells of resin and seems dry enough to burn! " + "You pick it up, holding it firmly. Now you just need to" + " |wlight|n it using the flint and steel you carry with you." +) + + +
[docs]class CmdLookDark(Command): + """ + Look around in darkness + + Usage: + look + + Look around in the darkness, trying + to find something. + """ + + key = "look" + aliases = ["l", "feel", "search", "feel around", "fiddle"] + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """ + Implement the command. + + This works both as a look and a search command; there is a + random chance of eventually finding a light source. + """ + caller = self.caller + + # count how many searches we've done + nr_searches = caller.ndb.dark_searches + if nr_searches is None: + nr_searches = 0 + caller.ndb.dark_searches = nr_searches + + if nr_searches < 4 and random.random() < 0.90: + # we don't find anything + caller.msg(random.choice(DARK_MESSAGES)) + caller.ndb.dark_searches += 1 + else: + # we could have found something! + if any(obj for obj in caller.contents if utils.inherits_from(obj, LightSource)): + # we already carry a LightSource object. + caller.msg(ALREADY_LIGHTSOURCE) + else: + # don't have a light source, create a new one. + create_object(LightSource, key="splinter", location=caller) + caller.msg(FOUND_LIGHTSOURCE)
+ + +
[docs]class CmdDarkHelp(Command): + """ + Help command for the dark state. + """ + + key = "help" + locks = "cmd:all()" + help_category = "TutorialWorld" + +
[docs] def func(self): + """ + Replace the the help command with a not-so-useful help + """ + string = ( + "Can't help you until you find some light! Try looking/feeling around for something to burn. " + "You shouldn't give up even if you don't find anything right away." + ) + self.caller.msg(string)
+ + +
[docs]class CmdDarkNoMatch(Command): + """ + This is a system command. Commands with special keys are used to + override special sitations in the game. The CMD_NOMATCH is used + when the given command is not found in the current command set (it + replaces Evennia's default behavior or offering command + suggestions) + """ + + key = syscmdkeys.CMD_NOMATCH + locks = "cmd:all()" + +
[docs] def func(self): + """Implements the command.""" + self.caller.msg( + "Until you find some light, there's not much you can do. " + "Try feeling around, maybe you'll find something helpful!" + )
+ + +
[docs]class DarkCmdSet(CmdSet): + """ + Groups the commands of the dark room together. We also import the + default say command here so that players can still talk in the + darkness. + + We give the cmdset the mergetype "Replace" to make sure it + completely replaces whichever command set it is merged onto + (usually the default cmdset) + """ + + key = "darkroom_cmdset" + mergetype = "Replace" + priority = 2 + +
[docs] def at_cmdset_creation(self): + """populate the cmdset.""" + self.add(CmdTutorial()) + self.add(CmdLookDark()) + self.add(CmdDarkHelp()) + self.add(CmdDarkNoMatch()) + self.add(default_cmds.CmdSay()) + self.add(default_cmds.CmdQuit()) + self.add(default_cmds.CmdHome())
+ + +
[docs]class DarkRoom(TutorialRoom): + """ + A dark room. This tries to start the DarkState script on all + objects entering. The script is responsible for making sure it is + valid (that is, that there is no light source shining in the room). + + The is_lit Attribute is used to define if the room is currently lit + or not, so as to properly echo state changes. + + Since this room (in the tutorial) is meant as a sort of catch-all, + we also make sure to heal characters ending up here, since they + may have been beaten up by the ghostly apparition at this point. + + """ + +
[docs] def at_object_creation(self): + """ + Called when object is first created. + """ + super().at_object_creation() + self.db.tutorial_info = "This is a room with custom command sets on itself." + # the room starts dark. + self.db.is_lit = False + self.cmdset.add(DarkCmdSet, persistent=True)
+ +
[docs] def at_init(self): + """ + Called when room is first recached (such as after a reload) + """ + self.check_light_state()
+ + def _carries_light(self, obj): + """ + Checks if the given object carries anything that gives light. + + Note that we do NOT look for a specific LightSource typeclass, + but for the Attribute is_giving_light - this makes it easy to + later add other types of light-giving items. We also accept + if there is a light-giving object in the room overall (like if + a splinter was dropped in the room) + """ + return ( + obj.is_superuser + or obj.db.is_giving_light + or any(o for o in obj.contents if o.db.is_giving_light) + ) + + def _heal(self, character): + """ + Heal a character. + """ + health = character.db.health_max or 20 + character.db.health = health + +
[docs] def check_light_state(self, exclude=None): + """ + This method checks if there are any light sources in the room. + If there isn't it makes sure to add the dark cmdset to all + characters in the room. It is called whenever characters enter + the room and also by the Light sources when they turn on. + + Args: + exclude (Object): An object to not include in the light check. + """ + if any(self._carries_light(obj) for obj in self.contents if obj != exclude): + self.locks.add("view:all()") + self.cmdset.remove(DarkCmdSet) + self.db.is_lit = True + for char in (obj for obj in self.contents if obj.has_account): + # this won't do anything if it is already removed + char.msg("The room is lit up.") + else: + # noone is carrying light - darken the room + self.db.is_lit = False + self.locks.add("view:false()") + self.cmdset.add(DarkCmdSet, persistent=True) + for char in (obj for obj in self.contents if obj.has_account): + if char.is_superuser: + char.msg("You are Superuser, so you are not affected by the dark state.") + else: + # put players in darkness + char.msg("The room is completely dark.")
+ +
[docs] def at_object_receive(self, obj, source_location, move_type="move", **kwargs): + """ + Called when an object enters the room. + """ + if obj.has_account: + # a puppeted object, that is, a Character + self._heal(obj) + # in case the new guy carries light with them + self.check_light_state()
+ +
[docs] def at_object_leave(self, obj, target_location, move_type="move", **kwargs): + """ + In case people leave with the light, we make sure to clear the + DarkCmdSet if necessary. This also works if they are + teleported away. + """ + # since this hook is called while the object is still in the room, + # we exclude it from the light check, to ignore any light sources + # it may be carrying. + self.check_light_state(exclude=obj)
+ + +# ------------------------------------------------------------- +# +# Teleport room - puzzles solution +# +# This is a sort of puzzle room that requires a certain +# attribute on the entering character to be the same as +# an attribute of the room. If not, the character will +# be teleported away to a target location. This is used +# by the Obelisk - grave chamber puzzle, where one must +# have looked at the obelisk to get an attribute set on +# oneself, and then pick the grave chamber with the +# matching imagery for this attribute. +# +# ------------------------------------------------------------- + + +
[docs]class TeleportRoom(TutorialRoom): + """ + Teleporter - puzzle room. + + Important attributes (set at creation): + puzzle_key - which attr to look for on character + puzzle_value - what char.db.puzzle_key must be set to + success_teleport_to - where to teleport in case if success + success_teleport_msg - message to echo while teleporting to success + failure_teleport_to - where to teleport to in case of failure + failure_teleport_msg - message to echo while teleporting to failure + + """ + +
[docs] def at_object_creation(self): + """Called at first creation""" + super().at_object_creation() + # what character.db.puzzle_clue must be set to, to avoid teleportation. + self.db.puzzle_value = 1 + # target of successful teleportation. Can be a dbref or a + # unique room name. + self.db.success_teleport_msg = "You are successful!" + self.db.success_teleport_to = "treasure room" + # the target of the failure teleportation. + self.db.failure_teleport_msg = "You fail!" + self.db.failure_teleport_to = "dark cell"
+ +
[docs] def at_object_receive(self, character, source_location, move_type="move", **kwargs): + """ + This hook is called by the engine whenever the player is moved into + this room. + """ + if not character.has_account: + # only act on player characters. + return + # determine if the puzzle is a success or not + is_success = str(character.db.puzzle_clue) == str(self.db.puzzle_value) + teleport_to = self.db.success_teleport_to if is_success else self.db.failure_teleport_to + # note that this returns a list + results = search_object(teleport_to) + if not results or len(results) > 1: + # we cannot move anywhere since no valid target was found. + character.msg("no valid teleport target for %s was found." % teleport_to) + return + if character.is_superuser: + # superusers don't get teleported + character.msg("Superuser block: You would have been teleported to %s." % results[0]) + return + # perform the teleport + if is_success: + character.msg(self.db.success_teleport_msg) + else: + character.msg(self.db.failure_teleport_msg) + # teleport quietly to the new place + character.move_to(results[0], quiet=True, move_hooks=False, move_type="teleport") + # we have to call this manually since we turn off move_hooks + # - this is necessary to make the target dark room aware of an + # already carried light. + results[0].at_object_receive(character, self)
+ + +# ------------------------------------------------------------- +# +# Outro room - unique exit room +# +# Cleans up the character from all tutorial-related properties. +# +# ------------------------------------------------------------- + + +
[docs]class OutroRoom(TutorialRoom): + """ + Outro room. + + Called when exiting the tutorial, cleans the + character of tutorial-related attributes. + + """ + +
[docs] def at_object_creation(self): + """ + Called when the room is first created. + """ + super().at_object_creation() + self.db.tutorial_info = ( + "The last room of the tutorial. " + "This cleans up all temporary Attributes " + "the tutorial may have assigned to the " + "character." + )
+ +
[docs] def at_object_receive(self, character, source_location, move_type="move", **kwargs): + """ + Do cleanup. + """ + if character.has_account: + del character.db.health_max + del character.db.health + del character.db.last_climbed + del character.db.puzzle_clue + del character.db.combat_parry_mode + del character.db.tutorial_bridge_position + for obj in character.contents: + if obj.typeclass_path.startswith("evennia.contrib.tutorials.tutorial_world"): + obj.delete() + character.tags.clear(category="tutorial_world")
+ +
[docs] def at_object_leave(self, character, destination, move_type="move", **kwargs): + if character.account: + character.account.execute_cmd("unquell")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/tests.html b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/tests.html new file mode 100644 index 0000000000..9b52f48907 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/tutorials/tutorial_world/tests.html @@ -0,0 +1,307 @@ + + + + + + + + evennia.contrib.tutorials.tutorial_world.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.tutorials.tutorial_world.tests

+"""
+Test tutorial_world/mob
+
+"""
+
+from evennia.commands.default.tests import BaseEvenniaCommandTest
+from evennia.utils.create import create_object
+from evennia.utils.test_resources import BaseEvenniaTest, mockdeferLater, mockdelay
+from mock import patch
+from twisted.internet.base import DelayedCall
+from twisted.trial.unittest import TestCase as TwistedTestCase
+
+from . import mob
+from . import objects as tutobjects
+from . import rooms as tutrooms
+
+
+
[docs]class TestTutorialWorldMob(BaseEvenniaTest): +
[docs] def test_mob(self): + mobobj = create_object(mob.Mob, key="mob") + self.assertEqual(mobobj.db.is_dead, True) + mobobj.set_alive() + self.assertEqual(mobobj.db.is_dead, False) + mobobj.set_dead() + self.assertEqual(mobobj.db.is_dead, True) + mobobj._set_ticker(0, "foo", stop=True)
+ # TODO should be expanded with further tests of the modes and damage etc. + + +# test tutorial_world/objects + + +DelayedCall.debug = True + + +
[docs]class TestTutorialWorldObjects(TwistedTestCase, BaseEvenniaCommandTest): +
[docs] def tearDown(self): + self.char1.delete() + super(BaseEvenniaCommandTest, self).tearDown()
+ +
[docs] def test_tutorialobj(self): + obj1 = create_object(tutobjects.TutorialObject, key="tutobj") + obj1.reset() + self.assertEqual(obj1.location, obj1.home)
+ +
[docs] def test_readable(self): + readable = create_object(tutobjects.TutorialReadable, key="book", location=self.room1) + readable.db.readable_text = "Text to read" + self.call(tutobjects.CmdRead(), "book", "You read book:\n Text to read", obj=readable)
+ +
[docs] def test_climbable(self): + climbable = create_object(tutobjects.TutorialClimbable, key="tree", location=self.room1) + self.call( + tutobjects.CmdClimb(), + "tree", + "You climb tree. Having looked around, you climb down again.", + obj=climbable, + ) + self.assertEqual( + self.char1.tags.get("tutorial_climbed_tree", category="tutorial_world"), + "tutorial_climbed_tree", + )
+ +
[docs] def test_obelisk(self): + obelisk = create_object(tutobjects.Obelisk, key="obelisk", location=self.room1) + self.assertEqual(obelisk.return_appearance(self.char1).startswith("|cobelisk("), True)
+ +
[docs] @patch("evennia.contrib.tutorials.tutorial_world.objects.delay", mockdelay) + @patch("evennia.scripts.taskhandler.deferLater", mockdeferLater) + def test_lightsource(self): + light = create_object(tutobjects.LightSource, key="torch", location=self.room1) + self.call( + tutobjects.CmdLight(), + "", + "A torch on the floor flickers and dies.|You light torch.", + obj=light, + ) + self.assertFalse(light.pk)
+ +
[docs] @patch("evennia.contrib.tutorials.tutorial_world.objects.delay", mockdelay) + @patch("evennia.scripts.taskhandler.deferLater", mockdeferLater) + def test_crumblingwall(self): + wall = create_object(tutobjects.CrumblingWall, key="wall", location=self.room1) + wall.db.destination = self.room2.dbref + self.assertFalse(wall.db.button_exposed) + self.assertFalse(wall.db.exit_open) + wall.db.root_pos = {"yellow": 0, "green": 0, "red": 0, "blue": 0} + self.call( + tutobjects.CmdShiftRoot(), + "blue root right", + "You shove the root adorned with small blue flowers to the right.", + obj=wall, + ) + self.call( + tutobjects.CmdShiftRoot(), + "red root left", + "You shift the reddish root to the left.", + obj=wall, + ) + self.call( + tutobjects.CmdShiftRoot(), + "yellow root down", + "You shove the root adorned with small yellow flowers downwards.", + obj=wall, + ) + self.call( + tutobjects.CmdShiftRoot(), + "green root up", + ( + "You shift the weedy green root upwards.|Holding aside the root you " + "think you notice something behind it ..." + ), + obj=wall, + ) + self.call( + tutobjects.CmdPressButton(), + "", + ( + "You move your fingers over the suspicious depression, then gives it a " + "decisive push. First" + ), + obj=wall, + ) + # we patch out the delay, so these are closed immediately + self.assertFalse(wall.db.button_exposed) + self.assertFalse(wall.db.exit_open)
+ +
[docs] def test_weapon(self): + weapon = create_object(tutobjects.TutorialWeapon, key="sword", location=self.char1) + self.call( + tutobjects.CmdAttack(), "Char", "You stab with sword.", obj=weapon, cmdstring="stab" + ) + self.call( + tutobjects.CmdAttack(), "Char", "You slash with sword.", obj=weapon, cmdstring="slash" + )
+ +
[docs] def test_weaponrack(self): + rack = create_object(tutobjects.TutorialWeaponRack, key="rack", location=self.room1) + rack.db.available_weapons = ["sword"] + self.call(tutobjects.CmdGetWeapon(), "", "You find Rusty sword.", obj=rack)
+ + +
[docs]class TestTutorialWorldRooms(BaseEvenniaCommandTest): +
[docs] def test_cmdtutorial(self): + room = create_object(tutrooms.TutorialRoom, key="tutroom") + self.char1.location = room + self.call(tutrooms.CmdTutorial(), "", "Sorry, there is no tutorial help available here.") + self.call( + tutrooms.CmdTutorialSetDetail(), + "detail;foo;foo2 = A detail", + "Detail set: 'detail;foo;foo2': 'A detail'", + obj=room, + ) + self.call(tutrooms.CmdTutorialLook(), "", "tutroom(", obj=room) + self.call(tutrooms.CmdTutorialLook(), "detail", "A detail", obj=room) + self.call(tutrooms.CmdTutorialLook(), "foo", "A detail", obj=room) + room.delete()
+ +
[docs] def test_weatherroom(self): + room = create_object(tutrooms.WeatherRoom, key="weatherroom") + room.update_weather() + tutrooms.TICKER_HANDLER.remove( + interval=room.db.interval, callback=room.update_weather, idstring="tutorial" + ) + room.delete()
+ +
[docs] def test_introroom(self): + room = create_object(tutrooms.IntroRoom, key="introroom") + room.at_object_receive(self.char1, self.room1)
+ +
[docs] def test_bridgeroom(self): + room = create_object(tutrooms.BridgeRoom, key="bridgeroom") + room.update_weather() + self.char1.move_to(room, move_type="teleport") + self.call( + tutrooms.CmdBridgeHelp(), + "", + "You are trying hard not to fall off the bridge ...", + obj=room, + ) + self.call( + tutrooms.CmdLookBridge(), + "", + "bridgeroom\nYou are standing very close to the the bridge's western foundation.", + obj=room, + ) + room.at_object_leave(self.char1, self.room1) + tutrooms.TICKER_HANDLER.remove( + interval=room.db.interval, callback=room.update_weather, idstring="tutorial" + ) + room.delete()
+ +
[docs] def test_darkroom(self): + room = create_object(tutrooms.DarkRoom, key="darkroom") + self.char1.move_to(room, move_type="teleport") + self.call(tutrooms.CmdDarkHelp(), "", "Can't help you until")
+ +
[docs] def test_teleportroom(self): + create_object(tutrooms.TeleportRoom, key="teleportroom")
+ +
[docs] def test_outroroom(self): + create_object(tutrooms.OutroRoom, key="outroroom")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/auditing/outputs.html b/docs/latest/_modules/evennia/contrib/utils/auditing/outputs.html new file mode 100644 index 0000000000..673c703242 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/auditing/outputs.html @@ -0,0 +1,166 @@ + + + + + + + + evennia.contrib.utils.auditing.outputs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.auditing.outputs

+"""
+Auditable Server Sessions - Example Outputs
+Example methods demonstrating output destinations for logs generated by
+audited server sessions.
+
+This is designed to be a single source of events for developers to customize
+and add any additional enhancements before events are written out-- i.e. if you
+want to keep a running list of what IPs a user logs in from on account/character
+objects, or if you want to perform geoip or ASN lookups on IPs before committing,
+or tag certain events with the results of a reputational lookup, this should be
+the easiest place to do it. Write a method and invoke it via
+`settings.AUDIT_CALLBACK` to have log data objects passed to it.
+
+Evennia contribution - Johnny 2017
+"""
+import json
+import syslog
+
+from evennia.utils.logger import log_file
+
+
+
[docs]def to_file(data): + """ + Writes dictionaries of data generated by an AuditedServerSession to files + in JSON format, bucketed by date. + + Uses Evennia's native logger and writes to the default + log directory (~/yourgame/server/logs/ or settings.LOG_DIR) + + Args: + data (dict): Parsed session transmission data. + + """ + # Bucket logs by day and remove objects before serialization + bucket = data.pop("objects")["time"].strftime("%Y-%m-%d") + + # Write it + log_file(json.dumps(data), filename="audit_%s.log" % bucket)
+ + +
[docs]def to_syslog(data): + """ + Writes dictionaries of data generated by an AuditedServerSession to syslog. + + Takes advantage of your system's native logger and writes to wherever + you have it configured, which is independent of Evennia. + Linux systems tend to write to /var/log/syslog. + + If you're running rsyslog, you can configure it to dump and/or forward logs + to disk and/or an external data warehouse (recommended-- if your server is + compromised or taken down, losing your logs along with it is no help!). + + Args: + data (dict): Parsed session transmission data. + + """ + # Remove objects before serialization + data.pop("objects") + + # Write it out + syslog.syslog(json.dumps(data))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/auditing/server.html b/docs/latest/_modules/evennia/contrib/utils/auditing/server.html new file mode 100644 index 0000000000..4f67f6b4b2 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/auditing/server.html @@ -0,0 +1,355 @@ + + + + + + + + evennia.contrib.utils.auditing.server — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.auditing.server

+"""
+Auditable Server Sessions:
+Extension of the stock ServerSession that yields objects representing
+user inputs and system outputs.
+
+Evennia contribution - Johnny 2017
+"""
+import os
+import re
+import socket
+
+from django.conf import settings as ev_settings
+from django.utils import timezone
+
+from evennia.server.serversession import ServerSession
+from evennia.utils import get_evennia_version, logger, mod_import, utils
+
+# Attributes governing auditing of commands and where to send log objects
+AUDIT_CALLBACK = getattr(
+    ev_settings, "AUDIT_CALLBACK", "evennia.contrib.utils.auditing.outputs.to_file"
+)
+AUDIT_IN = getattr(ev_settings, "AUDIT_IN", False)
+AUDIT_OUT = getattr(ev_settings, "AUDIT_OUT", False)
+AUDIT_ALLOW_SPARSE = getattr(ev_settings, "AUDIT_ALLOW_SPARSE", False)
+AUDIT_MASKS = [
+    {"connect": r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P<secret>.+)"},
+    {"connect": r"^[@\s]*[connect]{5,8}\s+(?P<secret>[\w]+)"},
+    {"create": r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P<secret>[\w]+)"},
+    {"create": r"^[^@]?[create]{5,6}\s+(?P<secret>[\w]+)"},
+    {"userpassword": r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P<secret>[\w]+)"},
+    {"userpassword": r"^.*new password set to '(?P<secret>[^']+)'\."},
+    {"userpassword": r"^.* has changed your password to '(?P<secret>[^']+)'\."},
+    {"password": r"^[@\s]*[password]{6,9}\s+(?P<secret>.*)"},
+] + getattr(ev_settings, "AUDIT_MASKS", [])
+
+
+if AUDIT_CALLBACK:
+    try:
+        AUDIT_CALLBACK = getattr(
+            mod_import(".".join(AUDIT_CALLBACK.split(".")[:-1])), AUDIT_CALLBACK.split(".")[-1]
+        )
+        logger.log_sec("Auditing module online.")
+        logger.log_sec(
+            "Audit record User input: {}, output: {}.\n"
+            "Audit sparse recording: {}, Log callback: {}".format(
+                AUDIT_IN, AUDIT_OUT, AUDIT_ALLOW_SPARSE, AUDIT_CALLBACK
+            )
+        )
+    except Exception as e:
+        logger.log_err("Failed to activate Auditing module. %s" % e)
+
+
+
[docs]class AuditedServerSession(ServerSession): + """ + This particular implementation parses all server inputs and/or outputs and + passes a dict containing the parsed metadata to a callback method of your + creation. This is useful for recording player activity where necessary for + security auditing, usage analysis or post-incident forensic discovery. + + *** WARNING *** + All strings are recorded and stored in plaintext. This includes those strings + which might contain sensitive data (create, connect, @password). These commands + have their arguments masked by default, but you must mask or mask any + custom commands of your own that handle sensitive information. + + See README.md for installation/configuration instructions. + """ + +
[docs] def audit(self, **kwargs): + """ + Extracts messages and system data from a Session object upon message + send or receive. + + Keyword Args: + src (str): Source of data; 'client' or 'server'. Indicates direction. + text (str or list): Client sends messages to server in the form of + lists. Server sends messages to client as string. + + Returns: + log (dict): Dictionary object containing parsed system and user data + related to this message. + + """ + # Get time at start of processing + time_obj = timezone.now() + time_str = str(time_obj) + + session = self + src = kwargs.pop("src", "?") + bytecount = 0 + + # Do not log empty lines + if not kwargs: + return {} + + # Get current session's IP address + client_ip = session.address + + # Capture Account name and dbref together + account = session.get_account() + account_token = "" + if account: + account_token = "%s%s" % (account.key, account.dbref) + + # Capture Character name and dbref together + char = session.get_puppet() + char_token = "" + if char: + char_token = "%s%s" % (char.key, char.dbref) + + # Capture Room name and dbref together + room = None + room_token = "" + if char: + room = char.location + room_token = "%s%s" % (room.key, room.dbref) + + # Try to compile an input/output string + def drill(obj, bucket): + if isinstance(obj, dict): + return bucket + elif utils.is_iter(obj): + for sub_obj in obj: + bucket.extend(drill(sub_obj, [])) + else: + bucket.append(obj) + return bucket + + text = kwargs.pop("text", "") + if utils.is_iter(text): + text = "|".join(drill(text, [])) + + # Mask any PII in message, where possible + bytecount = len(text.encode("utf-8")) + text = self.mask(text) + + # Compile the IP, Account, Character, Room, and the message. + log = { + "time": time_str, + "hostname": socket.getfqdn(), + "application": "%s" % ev_settings.SERVERNAME, + "version": get_evennia_version(), + "pid": os.getpid(), + "direction": "SND" if src == "server" else "RCV", + "protocol": self.protocol_key, + "ip": client_ip, + "session": "session#%s" % self.sessid, + "account": account_token, + "character": char_token, + "room": room_token, + "text": text.strip(), + "bytes": bytecount, + "data": kwargs, + "objects": { + "time": time_obj, + "session": self, + "account": account, + "character": char, + "room": room, + }, + } + + # Remove any keys with blank values + if AUDIT_ALLOW_SPARSE is False: + log["data"] = {k: v for k, v in log["data"].items() if v} + log["objects"] = {k: v for k, v in log["objects"].items() if v} + log = {k: v for k, v in log.items() if v} + + return log
+ +
[docs] def mask(self, msg): + """ + Masks potentially sensitive user information within messages before + writing to log. Recording cleartext password attempts is bad policy. + + Args: + msg (str): Raw text string sent from client <-> server + + Returns: + msg (str): Text string with sensitive information masked out. + + """ + # Check to see if the command is embedded within server output + _msg = msg + is_embedded = False + match = re.match(".*Command.*'(.+)'.*is not available.*", msg, flags=re.IGNORECASE) + if match: + msg = match.group(1).replace("\\", "") + submsg = msg + is_embedded = True + + for mask in AUDIT_MASKS: + for command, regex in mask.items(): + try: + match = re.match(regex, msg, flags=re.IGNORECASE) + except Exception as e: + logger.log_err(regex) + logger.log_err(e) + continue + + if match: + term = match.group("secret") + masked = re.sub(term, "*" * len(term.zfill(8)), msg) + + if is_embedded: + msg = re.sub( + submsg, "%s <Masked: %s>" % (masked, command), _msg, flags=re.IGNORECASE + ) + else: + msg = masked + + return msg + + return _msg
+ +
[docs] def data_out(self, **kwargs): + """ + Generic hook for sending data out through the protocol. + + Keyword Args: + kwargs (any): Other data to the protocol. + + """ + if AUDIT_CALLBACK and AUDIT_OUT: + try: + log = self.audit(src="server", **kwargs) + if log: + AUDIT_CALLBACK(log) + except Exception as e: + logger.log_err(e) + + super().data_out(**kwargs)
+ +
[docs] def data_in(self, **kwargs): + """ + Hook for protocols to send incoming data to the engine. + + Keyword Args: + kwargs (any): Other data from the protocol. + + """ + if AUDIT_CALLBACK and AUDIT_IN: + try: + log = self.audit(src="client", **kwargs) + if log: + AUDIT_CALLBACK(log) + except Exception as e: + logger.log_err(e) + + super().data_in(**kwargs)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/auditing/tests.html b/docs/latest/_modules/evennia/contrib/utils/auditing/tests.html new file mode 100644 index 0000000000..efe80d2d31 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/auditing/tests.html @@ -0,0 +1,242 @@ + + + + + + + + evennia.contrib.utils.auditing.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.auditing.tests

+"""
+Module containing the test cases for the Audit system.
+
+"""
+
+import re
+
+from anything import Anything
+from django.test import override_settings
+from mock import patch
+
+import evennia
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .server import AuditedServerSession
+
+
+
[docs]@override_settings(AUDIT_MASKS=[]) +class AuditingTest(BaseEvenniaTest): +
[docs] @patch("evennia.server.sessionhandler._ServerSession", AuditedServerSession) + def setup_session(self): + """Overrides default one in EvenniaTest""" + dummysession = AuditedServerSession() + dummysession.init_session("telnet", ("localhost", "testmode"), evennia.SESSION_HANDLER) + dummysession.sessid = 1 + evennia.SESSION_HANDLER.portal_connect( + dummysession.get_sync_data() + ) # note that this creates a new Session! + session = evennia.SESSION_HANDLER.session_from_sessid(1) # the real session + evennia.SESSION_HANDLER.login(session, self.account, testmode=True) + self.session = session
+ +
[docs] @patch( + "evennia.contrib.utils.auditing.server.AUDIT_CALLBACK", + "evennia.contrib.utils.auditing.outputs.to_syslog", + ) + @patch("evennia.contrib.utils.auditing.server.AUDIT_IN", True) + @patch("evennia.contrib.utils.auditing.server.AUDIT_OUT", True) + def test_mask(self): + """ + Make sure the 'mask' function is properly masking potentially sensitive + information from strings. + """ + safe_cmds = ( + "/say hello to my little friend", + "@ccreate channel = for channeling", + "@create/drop some stuff", + "@create rock", + "@create a pretty shirt : evennia.contrib.game_systems.clothing.Clothing", + "@charcreate johnnyefhiwuhefwhef", + 'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?', + '/me says, "what is the password?"', + "say the password is plugh", + # Unfortunately given the syntax, there is no way to discern the + # latter of these as sensitive + "@create pretty sunset" "@create johnny password123", + '{"text": "Command \'do stuff\' is not available. Type "help" for help."}', + ) + + for cmd in safe_cmds: + self.assertEqual(self.session.mask(cmd), cmd) + + unsafe_cmds = ( + ( + "something - new password set to 'asdfghjk'.", + "something - new password set to '********'.", + ), + ( + "someone has changed your password to 'something'.", + "someone has changed your password to '*********'.", + ), + ("connect johnny password123", "connect johnny ***********"), + ("concnct johnny password123", "concnct johnny ***********"), + ("concnct johnnypassword123", "concnct *****************"), + ('connect "johnny five" "password 123"', 'connect "johnny five" **************'), + ('connect johnny "password 123"', "connect johnny **************"), + ("create johnny password123", "create johnny ***********"), + ("@password password1234 = password2345", "@password ***************************"), + ("@password password1234 password2345", "@password *************************"), + ("@passwd password1234 = password2345", "@passwd ***************************"), + ("@userpassword johnny = password234", "@userpassword johnny = ***********"), + ("craete johnnypassword123", "craete *****************"), + ( + "Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", + "Command 'conncect ******** ********' is not available. Maybe you meant \"@encode\"?", + ), + ( + "{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", + "{'text': u'Command \\'conncect jsis ********\\' is not available. Type \"help\" for help.'}", + ), + ) + + for index, (unsafe, safe) in enumerate(unsafe_cmds): + self.assertEqual(re.sub(" <Masked: .+>", "", self.session.mask(unsafe)).strip(), safe) + + # Make sure scrubbing is not being abused to evade monitoring + secrets = [ + "say password password password; ive got a secret that i cant explain", + "whisper johnny = password\n let's lynch the landlord", + "say connect johnny password1234|the secret life of arabia", + "@password eval(\"__import__('os').system('clear')\", {'__builtins__':{}})", + ] + for secret in secrets: + self.assertEqual(self.session.mask(secret), secret)
+ +
[docs] @patch( + "evennia.contrib.utils.auditing.server.AUDIT_CALLBACK", + "evennia.contrib.utils.auditing.outputs.to_syslog", + ) + @patch("evennia.contrib.utils.auditing.server.AUDIT_IN", True) + @patch("evennia.contrib.utils.auditing.server.AUDIT_OUT", True) + def test_audit(self): + """ + Make sure the 'audit' function is returning a dictionary based on values + parsed from the Session object. + """ + log = self.session.audit(src="client", text=[["hello"]]) + obj = { + k: v for k, v in log.items() if k in ("direction", "protocol", "application", "text") + } + self.assertEqual( + obj, + { + "direction": "RCV", + "protocol": "telnet", + "application": Anything, # this will change if running tests from the game dir + "text": "hello", + }, + ) + + # Make sure OOB data is being recorded + log = self.session.audit( + src="client", text="connect johnny password123", prompt="hp=20|st=10|ma=15", pane=2 + ) + self.assertEqual(log["text"], "connect johnny ***********") + self.assertEqual(log["data"]["prompt"], "hp=20|st=10|ma=15") + self.assertEqual(log["data"]["pane"], 2)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/fieldfill/fieldfill.html b/docs/latest/_modules/evennia/contrib/utils/fieldfill/fieldfill.html new file mode 100644 index 0000000000..557cf4c75e --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/fieldfill/fieldfill.html @@ -0,0 +1,820 @@ + + + + + + + + evennia.contrib.utils.fieldfill.fieldfill — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.fieldfill.fieldfill

+"""
+Easy fillable form
+
+Contrib - Tim Ashley Jenkins 2018
+
+This module contains a function that calls an easily customizable EvMenu - this
+menu presents the player with a fillable form, with fields that can be filled
+out in any order. Each field's value can be verified, with the function
+allowing easy checks for text and integer input, minimum and maximum values /
+character lengths, or can even be verified by a custom function. Once the form
+is submitted, the form's data is submitted as a dictionary to any callable of
+your choice.
+
+The function that initializes the fillable form menu is fairly simple, and
+includes the caller, the template for the form, and the callback(caller, result)
+to which the form data will be sent to upon submission.
+
+    init_fill_field(formtemplate, caller, formcallback)
+
+Form templates are defined as a list of dictionaries - each dictionary
+represents a field in the form, and contains the data for the field's name and
+behavior. For example, this basic form template will allow a player to fill out
+a brief character profile:
+
+    PROFILE_TEMPLATE = [
+    {"fieldname":"Name", "fieldtype":"text"},
+    {"fieldname":"Age", "fieldtype":"number"},
+    {"fieldname":"History", "fieldtype":"text"},
+    ]
+
+This will present the player with an EvMenu showing this basic form:
+
+      Name:
+       Age:
+   History:
+
+While in this menu, the player can assign a new value to any field with the
+syntax <field> = <new value>, like so:
+
+    > name = Ashley
+    Field 'Name' set to: Ashley
+
+Typing 'look' by itself will show the form and its current values.
+
+    > look
+
+      Name: Ashley
+       Age:
+   History:
+
+Number fields require an integer input, and will reject any text that can't
+be converted into an integer.
+
+    > age = youthful
+    Field 'Age' requires a number.
+    > age = 31
+    Field 'Age' set to: 31
+
+Form data is presented as an EvTable, so text of any length will wrap cleanly.
+
+    > history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...]
+    Field 'History' set to: EVERY MORNING I WAKE UP AND[...]
+    > look
+
+      Name: Ashley
+       Age: 31
+   History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT.
+            IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING
+            THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY
+            MOVE AND I DO EVERY MOVE HARD.
+
+When the player types 'submit' (or your specified submit command), the menu
+quits and the form's data is passed to your specified function as a dictionary,
+like so:
+
+    formdata = {"Name":"Ashley", "Age":31, "History":"EVERY MORNING I[...]"}
+
+You can do whatever you like with this data in your function - forms can be used
+to set data on a character, to help builders create objects, or for players to
+craft items or perform other complicated actions with many variables involved.
+
+The data that your form will accept can also be specified in your form template -
+let's say, for example, that you won't accept ages under 18 or over 100. You can
+do this by specifying "min" and "max" values in your field's dictionary:
+
+    PROFILE_TEMPLATE = [
+    {"fieldname":"Name", "fieldtype":"text"},
+    {"fieldname":"Age", "fieldtype":"number", "min":18, "max":100},
+    {"fieldname":"History", "fieldtype":"text"}
+    ]
+
+Now if the player tries to enter a value out of range, the form will not acept the
+given value.
+
+    > age = 10
+    Field 'Age' reqiures a minimum value of 18.
+    > age = 900
+    Field 'Age' has a maximum value of 100.
+
+Setting 'min' and 'max' for a text field will instead act as a minimum or
+maximum character length for the player's input.
+
+There are lots of ways to present the form to the player - fields can have default
+values or show a custom message in place of a blank value, and player input can be
+verified by a custom function, allowing for a great deal of flexibility. There
+is also an option for 'bool' fields, which accept only a True / False input and
+can be customized to represent the choice to the player however you like (E.G.
+Yes/No, On/Off, Enabled/Disabled, etc.)
+
+This module contains a simple example form that demonstrates all of the included
+functionality - a command that allows a player to compose a message to another
+online character and have it send after a custom delay. You can test it by
+importing this module in your game's default_cmdsets.py module and adding
+CmdTestMenu to your default character's command set.
+
+FIELD TEMPLATE KEYS:
+Required:
+    fieldname (str): Name of the field, as presented to the player.
+    fieldtype (str): Type of value required: 'text', 'number', or 'bool'.
+
+Optional:
+    max (int): Maximum character length (if text) or value (if number).
+    min (int): Minimum charater length (if text) or value (if number).
+    truestr (str): String for a 'True' value in a bool field.
+        (E.G. 'On', 'Enabled', 'Yes')
+    falsestr (str): String for a 'False' value in a bool field.
+        (E.G. 'Off', 'Disabled', 'No')
+    default (str): Initial value (blank if not given).
+    blankmsg (str): Message to show in place of value when field is blank.
+    cantclear (bool): Field can't be cleared if True.
+    required (bool): If True, form cannot be submitted while field is blank.
+    verifyfunc (callable): Name of a callable used to verify input - takes
+        (caller, value) as arguments. If the function returns True,
+        the player's input is considered valid - if it returns False,
+        the input is rejected. Any other value returned will act as
+        the field's new value, replacing the player's input. This
+        allows for values that aren't strings or integers (such as
+        object dbrefs). For boolean fields, return '0' or '1' to set
+        the field to False or True.
+"""
+import evennia
+from evennia import Command
+from evennia.utils import delay, evmenu, evtable, list_to_string, logger
+
+
+
[docs]class FieldEvMenu(evmenu.EvMenu): + """ + Custom EvMenu type with its own node formatter - removes extraneous lines + """ + +
[docs] def node_formatter(self, nodetext, optionstext): + """ + Formats the entirety of the node. + + Args: + nodetext (str): The node text as returned by `self.nodetext_formatter`. + optionstext (str): The options display as returned by `self.options_formatter`. + caller (Object, Account or None, optional): The caller of the node. + + Returns: + node (str): The formatted node to display. + + """ + # Only return node text, no options or separators + return nodetext
+ + +
[docs]def init_fill_field( + formtemplate, + caller, + formcallback, + pretext="", + posttext="", + submitcmd="submit", + borderstyle="cells", + formhelptext=None, + persistent=False, + initial_formdata=None, +): + """ + Initializes a menu presenting a player with a fillable form - once the form + is submitted, the data will be passed as a dictionary to your chosen + function. + + Args: + formtemplate (list of dicts): The template for the form's fields. + caller (obj): Player who will be filling out the form. + formcallback (callable): Function to pass the completed form's data to. + + Options: + pretext (str): Text to put before the form in the menu. + posttext (str): Text to put after the form in the menu. + submitcmd (str): Command used to submit the form. + borderstyle (str): Form's EvTable border style. + formhelptext (str): Help text for the form menu (or default is provided). + persistent (bool): Whether to make the EvMenu persistent across reboots. + initial_formdata (dict): Initial data for the form - a blank form with + defaults specified in the template will be generated otherwise. + In the case of a form used to edit properties on an object or a + similar application, you may want to generate the initial form + data dynamically before calling init_fill_field. + """ + + # Initialize form data from the template if none provided + formdata = form_template_to_dict(formtemplate) + if initial_formdata: + formdata = initial_formdata + + # Provide default help text if none given + if formhelptext is None: + formhelptext = ( + "Available commands:|/" + "|w<field> = <new value>:|n Set given field to new value, replacing the old value|/" + "|wclear <field>:|n Clear the value in the given field, making it blank|/" + "|wlook|n: Show the form's current values|/" + "|whelp|n: Display this help screen|/" + "|wquit|n: Quit the form menu without submitting|/" + "|w%s|n: Submit this form and quit the menu" % submitcmd + ) + + # Pass kwargs to store data needed in the menu + kwargs = { + "formdata": formdata, + "formtemplate": formtemplate, + "formcallback": formcallback, + "pretext": pretext, + "posttext": posttext, + "submitcmd": submitcmd, + "borderstyle": borderstyle, + "formhelptext": formhelptext, + } + + # Initialize menu of selections + FieldEvMenu( + caller, + "evennia.contrib.utils.fieldfill.fieldfill", + startnode="menunode_fieldfill", + auto_look=False, + persistent=persistent, + **kwargs, + )
+ + + + + +
[docs]def form_template_to_dict(formtemplate): + """ + Initializes a dictionary of form data from the given list-of-dictionaries + form template, as formatted above. + + Args: + formtemplate (list of dicts): Tempate for the form to be initialized. + + Returns: + formdata (dict): Dictionary of initalized form data. + """ + formdata = {} + + for field in formtemplate: + # Value is blank by default + fieldvalue = None + if "default" in field: + # Add in default value if present + fieldvalue = field["default"] + formdata.update({field["fieldname"]: fieldvalue}) + + return formdata
+ + +
[docs]def display_formdata(formtemplate, formdata, pretext="", posttext="", borderstyle="cells"): + """ + Displays a form's current data as a table. Used in the form menu. + + Args: + formtemplate (list of dicts): Template for the form + formdata (dict): Form's current data + + Options: + pretext (str): Text to put before the form table. + posttext (str): Text to put after the form table. + borderstyle (str): EvTable's border style. + """ + + formtable = evtable.EvTable(border=borderstyle, valign="t", maxwidth=80) + field_name_width = 5 + + for field in formtemplate: + new_fieldname = None + new_fieldvalue = None + # Get field name + new_fieldname = "|w" + field["fieldname"] + ":|n" + if len(field["fieldname"]) + 5 > field_name_width: + field_name_width = len(field["fieldname"]) + 5 + # Get field value + if formdata[field["fieldname"]] is not None: + new_fieldvalue = str(formdata[field["fieldname"]]) + # Use blank message if field is blank and once is present + if new_fieldvalue is None and "blankmsg" in field: + new_fieldvalue = "|x" + str(field["blankmsg"]) + "|n" + elif new_fieldvalue is None: + new_fieldvalue = " " + # Replace True and False values with truestr and falsestr from template + if formdata[field["fieldname"]] is True and "truestr" in field: + new_fieldvalue = field["truestr"] + elif formdata[field["fieldname"]] is False and "falsestr" in field: + new_fieldvalue = field["falsestr"] + # Add name and value to table + formtable.add_row(new_fieldname, new_fieldvalue) + + formtable.reformat_column(0, align="r", width=field_name_width) + + return pretext + "|/" + str(formtable) + "|/" + posttext
+ + +# EXAMPLE FUNCTIONS / COMMAND STARTS HERE + + +
[docs]def verify_online_player(caller, value): + """ + Example 'verify function' that matches player input to an online character + or else rejects their input as invalid. + + Args: + caller (obj): Player entering the form data. + value (str): String player entered into the form, to be verified. + + Returns: + matched_character (obj or False): dbref to a currently logged in + character object - reference to the object will be stored in + the form instead of a string. Returns False if no match is + made. + """ + # Get a list of sessions + session_list = evennia.SESSION_HANDLER.get_sessions() + char_list = [] + matched_character = None + + # Get a list of online characters + for session in session_list: + if not session.logged_in: + # Skip over logged out characters + continue + # Append to our list of online characters otherwise + char_list.append(session.get_puppet()) + + # Match player input to a character name + for character in char_list: + if value.lower() == character.key.lower(): + matched_character = character + + # If input didn't match to a character + if not matched_character: + # Send the player an error message unique to this function + caller.msg("No character matching '%s' is online." % value) + # Returning False indicates the new value is not valid + return False + + # Returning anything besides True or False will replace the player's input with the returned + # value. In this case, the value becomes a reference to the character object. You can store data + # besides strings and integers in the 'formdata' dictionary this way! + return matched_character
+ + +# Form template for the example 'delayed message' form +SAMPLE_FORM = [ + { + "fieldname": "Character", + "fieldtype": "text", + "max": 30, + "blankmsg": "(Name of an online player)", + "required": True, + "verifyfunc": verify_online_player, + }, + { + "fieldname": "Delay", + "fieldtype": "number", + "min": 3, + "max": 30, + "default": 10, + "cantclear": True, + }, + { + "fieldname": "Message", + "fieldtype": "text", + "min": 3, + "max": 200, + "blankmsg": "(Message up to 200 characters)", + }, + { + "fieldname": "Anonymous", + "fieldtype": "bool", + "truestr": "Yes", + "falsestr": "No", + "default": False, + }, +] + + +
[docs]class CmdTestMenu(Command): + """ + This test command will initialize a menu that presents you with a form. + You can fill out the fields of this form in any order, and then type in + 'send' to send a message to another online player, which will reach them + after a delay you specify. + + Usage: + <field> = <new value> + clear <field> + help + look + quit + send + """ + + key = "testmenu" + +
[docs] def func(self): + """ + This performs the actual command. + """ + pretext = ( + "|cSend a delayed message to another player ---------------------------------------|n" + ) + posttext = ( + "|c--------------------------------------------------------------------------------|n|/" + "Syntax: type |c<field> = <new value>|n to change the values of the form. Given|/" + "player must be currently logged in, delay is given in seconds. When you are|/" + "finished, type '|csend|n' to send the message.|/" + ) + + init_fill_field( + SAMPLE_FORM, + self.caller, + init_delayed_message, + pretext=pretext, + posttext=posttext, + submitcmd="send", + borderstyle="none", + )
+ + +
[docs]def sendmessage(obj, text): + """ + Callback to send a message to a player. + + Args: + obj (obj): Player to message. + text (str): Message. + """ + obj.msg(text)
+ + +
[docs]def init_delayed_message(caller, formdata): + """ + Initializes a delayed message, using data from the example form. + + Args: + caller (obj): Character submitting the message. + formdata (dict): Data from submitted form. + """ + # Retrieve data from the filled out form. + # We stored the character to message as an object ref using a verifyfunc + # So we don't have to do any more searching or matching here! + player_to_message = formdata["Character"] + message_delay = formdata["Delay"] + sender = str(caller) + if formdata["Anonymous"] is True: + sender = "anonymous" + message = ("Message from %s: " % sender) + str(formdata["Message"]) + + caller.msg("Message sent to %s!" % player_to_message) + # Make a deferred call to 'sendmessage' above. + delay(message_delay, sendmessage, player_to_message, message) + return
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/git_integration/git_integration.html b/docs/latest/_modules/evennia/contrib/utils/git_integration/git_integration.html new file mode 100644 index 0000000000..76bc036452 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/git_integration/git_integration.html @@ -0,0 +1,318 @@ + + + + + + + + evennia.contrib.utils.git_integration.git_integration — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.git_integration.git_integration

+import datetime
+
+import git
+from django.conf import settings
+
+import evennia
+from evennia import CmdSet, InterruptCommand
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.utils.utils import list_to_string
+
+
+
[docs]class GitCommand(MuxCommand): + """ + The shared functionality between git/git evennia + """ + +
[docs] def parse(self): + """ + Parse the arguments, set default arg to 'status' and check for existence of currently targeted repo + """ + super().parse() + + if self.args: + split_args = self.args.strip().split(" ", 1) + self.action = split_args[0] + if len(split_args) > 1: + self.args = "".join(split_args[1:]) + else: + self.args = "" + else: + self.action = "status" + self.args = "" + + self.err_msgs = [ + "|rInvalid Git Repository|n:", + "The {repo_type} repository is not recognized as a git directory.", + "In order to initialize it as a git directory, you will need to access your terminal and run the following commands from within your directory:", + " git init", + " git remote add origin {remote_link}", + ] + + try: + self.repo = git.Repo(self.directory, search_parent_directories=True) + except git.exc.InvalidGitRepositoryError: + err_msg = "\n".join(self.err_msgs).format( + repo_type=self.repo_type, remote_link=self.remote_link + ) + self.caller.msg(err_msg) + raise InterruptCommand + + self.commit = self.repo.head.commit + + try: + self.branch = self.repo.active_branch.name + except TypeError as type_err: + self.caller.msg(type_err) + raise InterruptCommand
+ +
[docs] def short_sha(self, repo, hexsha): + """ + Utility: Get the short SHA of a commit. + """ + short_sha = repo.git.rev_parse(hexsha, short=True) + return short_sha
+ +
[docs] def get_status(self): + """ + Retrieves the status of the active git repository, displaying unstaged changes/untracked files. + """ + time_of_commit = datetime.datetime.fromtimestamp(self.commit.committed_date) + status_msg = "\n".join( + [ + f"Branch: |w{self.branch}|n ({self.repo.git.rev_parse(self.commit.hexsha, short=True)}) ({time_of_commit})", + f"By {self.commit.author.email}: {self.commit.message}", + ] + ) + + changedFiles = {item.a_path for item in self.repo.index.diff(None)} + if changedFiles: + status_msg += f"Unstaged/uncommitted changes:|/ |g{'|/ '.join(changedFiles)}|n|/" + if len(self.repo.untracked_files) > 0: + status_msg += f"Untracked files:|/ |x{'|/ '.join(self.repo.untracked_files)}|n" + return status_msg
+ +
[docs] def get_branches(self): + """ + Display current and available branches. + """ + remote_refs = self.repo.remote().refs + branch_msg = ( + f"Current branch: |w{self.branch}|n. Branches available: {list_to_string(remote_refs)}" + ) + return branch_msg
+ +
[docs] def checkout(self): + """ + Check out a specific branch. + """ + remote_refs = self.repo.remote().refs + to_branch = self.args.strip().removeprefix( + "origin/" + ) # Slightly hacky, but git tacks on the origin/ + + if to_branch not in remote_refs: + self.caller.msg(f"Branch '{to_branch}' not available.") + return False + elif to_branch == self.branch: + self.caller.msg(f"Already on |w{to_branch}|n. Maybe you want <git pull>?") + return False + else: + try: + self.repo.git.checkout(to_branch) + except git.exc.GitCommandError as err: + self.caller.msg("Couldn't checkout {} ({})".format(to_branch, err.stderr.strip())) + return False + self.msg(f"Checked out |w{to_branch}|n successfully. Server restart initiated.") + return True
+ +
[docs] def pull(self): + """ + Attempt to pull new code. + """ + old_commit = self.commit + try: + self.repo.remotes.origin.pull() + except git.exc.GitCommandError as err: + self.caller.msg("Couldn't pull {} ({})".format(self.branch, err.stderr.strip())) + return False + if old_commit == self.repo.head.commit: + self.caller.msg("No new code to pull, no need to reset.\n") + return False + else: + self.caller.msg( + f"You have pulled new code. Server restart initiated.|/Head now at {self.repo.git.rev_parse(self.repo.head.commit.hexsha, short=True)}.|/Author: {self.repo.head.commit.author.name} ({self.repo.head.commit.author.email})|/{self.repo.head.commit.message.strip()}" + ) + return True
+ +
[docs] def func(self): + """ + Provide basic Git functionality within the game. + """ + caller = self.caller + + if self.action == "status": + caller.msg(self.get_status()) + elif self.action == "branch" or (self.action == "checkout" and not self.args): + caller.msg(self.get_branches()) + elif self.action == "checkout": + if self.checkout(): + evennia.SESSION_HANDLER.portal_restart_server() + elif self.action == "pull": + if self.pull(): + evennia.SESSION_HANDLER.portal_restart_server() + else: + caller.msg("You can only git status, git branch, git checkout, or git pull.") + return
+ + +
[docs]class CmdGitEvennia(GitCommand): + """ + Pull the latest code from the evennia core or checkout a different branch. + + Usage: + git evennia status - View an overview of the evennia repository status. + git evennia branch - View available branches in evennia. + git evennia checkout <branch> - Checkout a different branch in evennia. + git evennia pull - Pull the latest evennia code. + + For updating your local mygame repository, the same commands are available with 'git'. + + If there are any conflicts encountered, the command will abort. The command will reload your game after pulling new code automatically, but for some changes involving persistent scripts etc, you may need to manually restart. + """ + + key = "git evennia" + locks = "cmd:pperm(Developer)" + help_category = "System" + directory = settings.EVENNIA_DIR + repo_type = "Evennia" + remote_link = "https://github.com/evennia/evennia.git"
+ + +
[docs]class CmdGit(GitCommand): + """ + Pull the latest code from your repository or checkout a different branch. + + Usage: + git status - View an overview of your git repository. + git branch - View available branches. + git checkout main - Checkout the main branch of your code. + git pull - Pull the latest code from your current branch. + + For updating evennia code, the same commands are available with 'git evennia'. + + If there are any conflicts encountered, the command will abort. The command will reload your game after pulling new code automatically, but for changes involving persistent scripts etc, you may need to manually restart. + """ + + key = "git" + locks = "cmd:pperm(Developer)" + help_category = "System" + directory = settings.GAME_DIR + repo_type = "game" + remote_link = "[your remote link]"
+ + +# CmdSet for easily install all commands +
[docs]class GitCmdSet(CmdSet): + """ + The git command. + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdGit) + self.add(CmdGitEvennia)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/git_integration/tests.html b/docs/latest/_modules/evennia/contrib/utils/git_integration/tests.html new file mode 100644 index 0000000000..f46092ee8c --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/git_integration/tests.html @@ -0,0 +1,181 @@ + + + + + + + + evennia.contrib.utils.git_integration.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.git_integration.tests

+"""
+Tests of git.
+
+"""
+
+import datetime
+
+import git
+import mock
+
+from evennia.contrib.utils.git_integration.git_integration import GitCommand
+from evennia.utils.test_resources import EvenniaTest
+from evennia.utils.utils import list_to_string
+
+
+
[docs]class TestGitIntegration(EvenniaTest): +
[docs] @mock.patch("git.Repo") + @mock.patch("git.Git") + @mock.patch("git.Actor") + def setUp(self, mock_git, mock_repo, mock_author): + super().setUp() + + self.char1.msg = mock.Mock() + + p = mock_git.return_value = False + type(mock_repo.clone_from.return_value).bare = p + mock_repo.index.add(["mock.txt"]) + mock_git.Repo.side_effect = git.exc.InvalidGitRepositoryError + + mock_author.name = "Faux Author" + mock_author.email = "a@email.com" + + commit_date = datetime.datetime(2021, 2, 1) + + mock_repo.index.commit( + "Initial skeleton", + author=mock_author, + committer=mock_author, + author_date=commit_date, + commit_date=commit_date, + ) + + test_cmd_git = GitCommand() + self.repo = test_cmd_git.repo = mock_repo + self.commit = test_cmd_git.commit = mock_git.head.commit + self.branch = test_cmd_git.branch = mock_git.active_branch.name + test_cmd_git.caller = self.char1 + test_cmd_git.args = "nonexistent_branch" + self.test_cmd_git = test_cmd_git
+ +
[docs] def test_git_status(self): + time_of_commit = datetime.datetime.fromtimestamp(self.test_cmd_git.commit.committed_date) + status_msg = "\n".join( + [ + f"Branch: |w{self.test_cmd_git.branch}|n ({self.test_cmd_git.repo.git.rev_parse(self.test_cmd_git.commit.hexsha, short=True)}) ({time_of_commit})", + f"By {self.test_cmd_git.commit.author.email}: {self.test_cmd_git.commit.message}", + ] + ) + self.assertEqual(status_msg, self.test_cmd_git.get_status())
+ +
[docs] def test_git_branch(self): + # View current branch + remote_refs = self.test_cmd_git.repo.remote().refs + branch_msg = f"Current branch: |w{self.test_cmd_git.branch}|n. Branches available: {list_to_string(remote_refs)}" + self.assertEqual(branch_msg, self.test_cmd_git.get_branches())
+ +
[docs] def test_git_checkout(self): + # Checkout no branch + self.test_cmd_git.checkout() + self.char1.msg.assert_called_with("Branch 'nonexistent_branch' not available.")
+ +
[docs] def test_git_pull(self): + self.test_cmd_git.pull() + self.char1.msg.assert_called_with( + f"You have pulled new code. Server restart initiated.|/Head now at {self.repo.git.rev_parse(self.repo.head.commit.hexsha, short=True)}.|/Author: {self.repo.head.commit.author.name} ({self.repo.head.commit.author.email})|/{self.repo.head.commit.message.strip()}" + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/name_generator/namegen.html b/docs/latest/_modules/evennia/contrib/utils/name_generator/namegen.html new file mode 100644 index 0000000000..2333910cea --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/name_generator/namegen.html @@ -0,0 +1,549 @@ + + + + + + + + evennia.contrib.utils.name_generator.namegen — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.name_generator.namegen

+"""
+Random Name Generator
+
+Contribution by InspectorCaracal (2022)
+
+A module for generating random names, both real-world and fantasy. Real-world
+names can be generated either as first (personal) names, family (last) names, or
+full names (first, optional middles, and last). The name data is from [Behind the Name](https://www.behindthename.com/)
+and used under the [CC BY-SA 4.0 license](https://creativecommons.org/licenses/by-sa/4.0/).
+
+Fantasy names are generated from basic phonetic rules, using CVC syllable syntax.
+
+Both real-world and fantasy name generation can be extended to include additional
+information via your game's `settings.py`
+
+
+Available Methods:
+
+  first_name   - Selects a random a first (personal) name from the name lists.
+  last_name    - Selects a random last (family) name from the name lists.
+  full_name    - Generates a randomized full name, optionally including middle names, by selecting first/last names from the name lists.
+  fantasy_name - Generates a completely new made-up name based on phonetic rules.
+
+Method examples:
+
+>>> namegen.first_name(num=5)
+['Genesis', 'Tali', 'Budur', 'Dominykas', 'Kamau']
+
+>>> namegen.full_name(parts=3, surname_first=True)
+'Ó Muircheartach Torunn Dyson'
+>>> namegen.full_name(gender='f')
+'Wikolia Ó Deasmhumhnaigh'
+
+>>> namegen.fantasy_name(num=3, style="fluid")
+['Aewalisash', 'Ayi', 'Iaa']
+
+
+Available Settings (define these in your `settings.py`)
+
+  NAMEGEN_FIRST_NAMES   - Option to add a new list of first (personal) names.
+  NAMEGEN_LAST_NAMES    - Option to add a new list of last (family) names.
+  NAMEGEN_REPLACE_LISTS - Set to True if you want to use ONLY your name lists and not the ones that come with the contrib.
+  NAMEGEN_FANTASY_RULES - Option to add new fantasy-name style rules.
+      Must be a dictionary that includes "syllable", "consonants", "vowels", and "length" - see the example.
+      "start" and "end" keys are optional.
+
+Settings examples:
+
+NAMEGEN_FIRST_NAMES = [
+        ("Evennia", 'mf'),
+        ("Green Tea", 'f'),
+    ]
+
+NAMEGEN_LAST_NAMES = [ "Beeblebrox", "Son of Odin" ]
+
+NAMEGEN_FANTASY_RULES = {
+    "example_style": {
+            "syllable": "(C)VC",
+            "consonants": [ 'z','z','ph','sh','r','n' ],
+            "start": ['m'],
+            "end": ['x','n'],
+            "vowels": [ "e","e","e","a","i","i","u","o", ],
+            "length": (2,4),
+        }
+    }
+
+"""
+
+import random
+import re
+from os import path
+
+from django.conf import settings
+
+from evennia.utils.utils import is_iter
+
+# Load name data from Behind the Name lists
+dirpath = path.dirname(path.abspath(__file__))
+_FIRSTNAME_LIST = []
+with open(path.join(dirpath, "btn_givennames.txt"), "r", encoding="utf-8") as file:
+    _FIRSTNAME_LIST = [
+        line.strip().rsplit(" ") for line in file if line and not line.startswith("#")
+    ]
+
+_SURNAME_LIST = []
+with open(path.join(dirpath, "btn_surnames.txt"), "r", encoding="utf-8") as file:
+    _SURNAME_LIST = [line.strip() for line in file if line and not line.startswith("#")]
+
+_REQUIRED_KEYS = {"syllable", "consonants", "vowels", "length"}
+# Define phoneme structure for built-in fantasy name generators.
+_FANTASY_NAME_STRUCTURES = {
+    "harsh": {
+        "syllable": "CV(C)",
+        "consonants": [
+            "k",
+            "k",
+            "k",
+            "z",
+            "zh",
+            "g",
+            "v",
+            "t",
+            "th",
+            "w",
+            "n",
+            "d",
+            "d",
+        ],
+        "start": [
+            "dh",
+            "kh",
+            "kh",
+            "kh",
+            "vh",
+        ],
+        "end": [
+            "n",
+            "x",
+        ],
+        "vowels": [
+            "o",
+            "o",
+            "o",
+            "a",
+            "y",
+            "u",
+            "u",
+            "u",
+            "ä",
+            "ö",
+            "e",
+            "i",
+            "i",
+        ],
+        "length": (1, 3),
+    },
+    "fluid": {
+        "syllable": "V(C)",
+        "consonants": [
+            "r",
+            "r",
+            "l",
+            "l",
+            "l",
+            "l",
+            "s",
+            "s",
+            "s",
+            "sh",
+            "m",
+            "n",
+            "n",
+            "f",
+            "v",
+            "w",
+            "th",
+        ],
+        "start": [],
+        "end": [],
+        "vowels": [
+            "a",
+            "a",
+            "a",
+            "a",
+            "a",
+            "e",
+            "i",
+            "i",
+            "i",
+            "y",
+            "u",
+            "o",
+        ],
+        "length": (3, 5),
+    },
+    "alien": {
+        "syllable": "C(C(V))(')(C)",
+        "consonants": ["q", "q", "x", "z", "v", "w", "k", "h", "b"],
+        "start": [
+            "x",
+        ],
+        "end": [],
+        "vowels": ["y", "w", "o", "y"],
+        "length": (1, 5),
+    },
+}
+
+_RE_DOUBLES = re.compile(r"(\w)\1{2,}")
+
+# Load in optional settings
+
+custom_first_names = (
+    settings.NAMEGEN_FIRST_NAMES if hasattr(settings, "NAMEGEN_FIRST_NAMES") else []
+)
+custom_last_names = settings.NAMEGEN_LAST_NAMES if hasattr(settings, "NAMEGEN_LAST_NAMES") else []
+
+if hasattr(settings, "NAMEGEN_FANTASY_RULES"):
+    _FANTASY_NAME_STRUCTURES |= settings.NAMEGEN_FANTASY_RULES
+
+if hasattr(settings, "NAMEGEN_REPLACE_LISTS") and settings.NAMEGEN_REPLACE_LISTS:
+    _FIRSTNAME_LIST = custom_first_names or _FIRSTNAME_LIST
+    _SURNAME_LIST = custom_last_names or _SURNAME_LIST
+
+else:
+    _FIRSTNAME_LIST += custom_first_names
+    _SURNAME_LIST += custom_last_names
+
+
+
[docs]def fantasy_name(num=1, style="harsh", return_list=False): + """ + Generate made-up names in one of a number of "styles". + + Keyword args: + num (int) - How many names to return. + style (string) - The "style" of name. This references an existing algorithm. + return_list (bool) - Whether to always return a list. `False` by default, + which returns a string if there is only one value and a list if more. + """ + + def _validate(style_name): + if style_name not in _FANTASY_NAME_STRUCTURES: + raise ValueError( + f"Invalid style name: '{style_name}'. Available style names: {' '.join(_FANTASY_NAME_STRUCTURES.keys())}" + ) + style_dict = _FANTASY_NAME_STRUCTURES[style_name] + + if type(style_dict) is not dict: + raise ValueError(f"Style {style_name} must be a dictionary.") + + keys = set(style_dict.keys()) + missing_keys = _REQUIRED_KEYS - keys + if len(missing_keys): + raise KeyError( + f"Style dictionary {style_name} is missing required keys: {' '.join(missing_keys)}" + ) + + if not (type(style_dict["consonants"]) is list and type(style_dict["vowels"]) is list): + raise TypeError(f"'consonants' and 'vowels' for style {style_name} must be lists.") + + if not (is_iter(style_dict["length"]) and len(style_dict["length"]) == 2): + raise ValueError( + f"'length' key for {style_name} must have a minimum and maximum number of syllables." + ) + + return style_dict + + # validate num first + num = int(num) + if num < 1: + raise ValueError("Number of names to generate must be positive.") + + style_dict = _validate(style) + + syllable = [] + weight = 8 + # parse out the syllable structure with weights + for key in style_dict["syllable"]: + # parentheses mean optional - allow nested parens + if key == "(": + weight = weight / 2 + elif key == ")": + weight = weight * 2 + else: + if key == "C": + sound_type = "consonants" + elif key == "V": + sound_type = "vowels" + else: + sound_type = key + # append the sound type and weight + syllable.append((sound_type, int(weight))) + + name_list = [] + + # time to generate a name! + for n in range(num): + # build a list of syllables + length = random.randint(*style_dict["length"]) + name = "" + for i in range(length): + # build the syllable itself + syll = "" + for sound, weight in syllable: + # random chance to skip this key; lower weights mean less likely + if random.randint(0, 8) > weight: + continue + + if sound not in style_dict: + # extra character, like apostrophes + syll += sound + continue + + # get a random sound from the sound list + choices = list(style_dict[sound]) + + if sound == "consonants": + # if it's a starting consonant, add starting-sounds to the options + if not len(syll): + choices += style_dict.get("start", []) + # if it's an ending consonant, add ending-sounds to the options + elif i + 1 == length: + choices += style_dict.get("end", []) + + syll += random.choice(choices) + + name += syll + + # condense repeating letters down to a maximum of 2 + name = _RE_DOUBLES.sub(lambda m: m.group(1) * 2, name) + # capitalize the first letter + name = name[0].upper() + name[1:] if len(name) > 1 else name.upper() + name_list.append(name) + + if len(name_list) == 1 and not return_list: + return name_list[0] + return name_list
+ + +
[docs]def first_name( + num=1, + gender=None, + return_list=False, +): + """ + Generate first names, also known as personal names. + + Keyword args: + num (int) - How many names to return. + gender (str) - Restrict names by gender association. `None` by default, which selects from + all possible names. Set to "m" for masculine, "f" for feminine, "mf" for androgynous + return_list (bool) - Whether to always return a list. `False` by default, + which returns a string if there is only one value and a list if more. + """ + # validate num first + num = int(num) + if num < 1: + raise ValueError("Number of names to generate must be positive.") + + if gender: + # filter the options by gender + name_options = [ + name_data[0] + for name_data in _FIRSTNAME_LIST + if all([gender_key in gender for gender_key in name_data[1]]) + ] + if not len(name_options): + raise ValueError(f"Invalid gender '{gender}'.") + else: + name_options = [name_data[0] for name_data in _FIRSTNAME_LIST] + + # take a random selection of `num` names, without repeats + results = random.sample(name_options, num) + + if len(results) == 1 and not return_list: + # return single value as a string + return results[0] + + return results
+ + +
[docs]def last_name(num=1, return_list=False): + """ + Generate family names, also known as surnames or last names. + + Keyword args: + num (int) - How many names to return. + return_list (bool) - Whether to always return a list. `False` by default, + which returns a string if there is only one value and a list if more. + """ + # validate num first + num = int(num) + if num < 1: + raise ValueError("Number of names to generate must be positive.") + + # take a random selection of `num` names, without repeats + results = random.sample(_SURNAME_LIST, num) + + if len(results) == 1 and not return_list: + # return single value as a string + return results[0] + + return results
+ + +
[docs]def full_name(num=1, parts=2, gender=None, return_list=False, surname_first=False): + """ + Generate complete names with a personal name, family name, and optionally middle names. + + Keyword args: + num (int) - How many names to return. + parts (int) - How many parts the name should have. By default two: first and last. + gender (str) - Restrict names by gender association. `None` by default, which selects from + all possible names. Set to "m" for masculine, "f" for feminine, "mf" for androgynous + return_list (bool) - Whether to always return a list. `False` by default, + which returns a string if there is only one value and a list if more. + surname_first (bool) - Default `False`. Set to `True` if you want the family name to be + placed at the beginning of the name instead of the end. + """ + # validate num first + num = int(num) + if num < 1: + raise ValueError("Number of names to generate must be positive.") + # validate parts next + parts = int(parts) + if parts < 2: + raise ValueError("Number of name parts to generate must be at least 2.") + + name_lists = [] + + middle = parts - 2 + if middle: + # calculate "middle" names. + # we want them to be an intelligent mix of personal names and family names + # first, split the total number of middle-name parts into "personal" and "family" at a random point + total_mids = middle * num + personals = random.randint(1, total_mids) + familys = total_mids - personals + # then get the names for each + personal_mids = first_name(num=personals, gender=gender, return_list=True) + family_mids = last_name(num=familys, return_list=True) if familys else [] + # splice them together according to surname_first.... + middle_names = family_mids + personal_mids if surname_first else personal_mids + family_mids + # ...and then split into `num`-length lists to be used for the final names + name_lists = [middle_names[num * i : num * (i + 1)] for i in range(0, middle)] + + # get personal and family names + personal_names = first_name(num=num, gender=gender, return_list=True) + last_names = last_name(num=num, return_list=True) + + # attach personal/family names to the list of name lists, according to surname_first + if surname_first: + name_lists = [last_names] + name_lists + [personal_names] + else: + name_lists = [personal_names] + name_lists + [last_names] + + # lastly, zip them all up and join them together + names = list(zip(*name_lists)) + names = [" ".join(name) for name in names] + + if len(names) == 1 and not return_list: + # return single value as a string + return names[0] + + return names
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/name_generator/tests.html b/docs/latest/_modules/evennia/contrib/utils/name_generator/tests.html new file mode 100644 index 0000000000..23cca1ad97 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/name_generator/tests.html @@ -0,0 +1,263 @@ + + + + + + + + evennia.contrib.utils.name_generator.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.name_generator.tests

+"""
+Tests for the Random Name Generator
+"""
+
+from evennia.contrib.utils.name_generator import namegen
+from evennia.utils.test_resources import BaseEvenniaTest
+
+_INVALID_STYLES = {
+    "missing_keys": {
+        "consonants": ["c", "d"],
+        "length": (1, 2),
+    },
+    "invalid_vowels": {
+        "syllable": "CVC",
+        "consonants": ["c", "d"],
+        "vowels": "aeiou",
+        "length": (1, 2),
+    },
+    "invalid_length": {
+        "syllable": "CVC",
+        "consonants": ["c", "d"],
+        "vowels": ["a", "e"],
+        "length": 2,
+    },
+}
+
+namegen._FANTASY_NAME_STRUCTURES |= _INVALID_STYLES
+
+
+
[docs]class TestNameGenerator(BaseEvenniaTest): +
[docs] def test_fantasy_name(self): + """ + Verify output types and lengths. + + fantasy_name() - str + fantasy_name(style="fluid") - str + fantasy_name(num=3) - list of length 3 + fantasy_name(return_list=True) - list of length 1 + + raises KeyError on missing style or ValueError on num + """ + single_name = namegen.fantasy_name() + self.assertEqual(type(single_name), str) + + fluid_name = namegen.fantasy_name(style="fluid") + self.assertEqual(type(fluid_name), str) + + three_names = namegen.fantasy_name(num=3) + self.assertEqual(type(three_names), list) + self.assertEqual(len(three_names), 3) + + single_list = namegen.fantasy_name(return_list=True) + self.assertEqual(type(single_list), list) + self.assertEqual(len(single_list), 1) + + with self.assertRaises(ValueError): + namegen.fantasy_name(num=-1) + + with self.assertRaises(ValueError): + namegen.fantasy_name(style="dummy")
+ +
[docs] def test_structure_validation(self): + """ + Verify that validation raises the correct errors for invalid inputs. + """ + with self.assertRaises(KeyError): + namegen.fantasy_name(style="missing_keys") + + with self.assertRaises(TypeError): + namegen.fantasy_name(style="invalid_vowels") + + with self.assertRaises(ValueError): + namegen.fantasy_name(style="invalid_length")
+ +
[docs] def test_first_name(self): + """ + Verify output types and lengths. + + first_name() - str + first_name(num=3) - list of length 3 + first_name(gender='f') - str + first_name(return_list=True) - list of length 1 + """ + single_name = namegen.first_name() + self.assertEqual(type(single_name), str) + + three_names = namegen.first_name(num=3) + self.assertEqual(type(three_names), list) + self.assertEqual(len(three_names), 3) + + gendered_name = namegen.first_name(gender="f") + self.assertEqual(type(gendered_name), str) + + single_list = namegen.first_name(return_list=True) + self.assertEqual(type(single_list), list) + self.assertEqual(len(single_list), 1) + + with self.assertRaises(ValueError): + namegen.first_name(gender="x") + + with self.assertRaises(ValueError): + namegen.first_name(num=-1)
+ +
[docs] def test_last_name(self): + """ + Verify output types and lengths. + + last_name() - str + last_name(num=3) - list of length 3 + last_name(return_list=True) - list of length 1 + """ + single_name = namegen.last_name() + self.assertEqual(type(single_name), str) + + three_names = namegen.last_name(num=3) + self.assertEqual(type(three_names), list) + self.assertEqual(len(three_names), 3) + + single_list = namegen.last_name(return_list=True) + self.assertEqual(type(single_list), list) + self.assertEqual(len(single_list), 1) + + with self.assertRaises(ValueError): + namegen.last_name(num=-1)
+ +
[docs] def test_full_name(self): + """ + Verify output types and lengths. + + full_name() - str + full_name(num=3) - list of length 3 + full_name(gender='f') - str + full_name(return_list=True) - list of length 1 + """ + single_name = namegen.full_name() + self.assertEqual(type(single_name), str) + + three_names = namegen.full_name(num=3) + self.assertEqual(type(three_names), list) + self.assertEqual(len(three_names), 3) + + gendered_name = namegen.full_name(gender="f") + self.assertEqual(type(gendered_name), str) + + single_list = namegen.full_name(return_list=True) + self.assertEqual(type(single_list), list) + self.assertEqual(len(single_list), 1) + + parts_name = namegen.full_name(parts=4) + # a name made of 4 parts must have at least 3 spaces, but may have more + parts = parts_name.split(" ") + self.assertGreaterEqual(len(parts), 3) + + with self.assertRaises(ValueError): + namegen.full_name(parts=1) + + with self.assertRaises(ValueError): + namegen.full_name(num=-1)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/random_string_generator/random_string_generator.html b/docs/latest/_modules/evennia/contrib/utils/random_string_generator/random_string_generator.html new file mode 100644 index 0000000000..668f4555c9 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/random_string_generator/random_string_generator.html @@ -0,0 +1,470 @@ + + + + + + + + evennia.contrib.utils.random_string_generator.random_string_generator — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.random_string_generator.random_string_generator

+"""
+Pseudo-random generator and registry
+
+Evennia contribution - Vincent Le Goff 2017
+
+This contrib can be used to generate pseudo-random strings of information
+with specific criteria.  You could, for instance, use it to generate
+phone numbers, license plate numbers, validation codes, non-sensivite
+passwords and so on.  The strings generated by the generator will be
+stored and won't be available again in order to avoid repetition.
+Here's a very simple example:
+
+```python
+from evennia.contrib.utils.random_string_generator import RandomStringGenerator
+# Create a generator for phone numbers
+phone_generator = RandomStringGenerator("phone number", r"555-[0-9]{3}-[0-9]{4}")
+# Generate a phone number (555-XXX-XXXX with X as numbers)
+number = phone_generator.get()
+# `number` will contain something like: "555-981-2207"
+# If you call `phone_generator.get`, it won't give the same anymore.phone_generator.all()
+# Will return a list of all currently-used phone numbers
+phone_generator.remove("555-981-2207")
+# The number can be generated again
+```
+
+To use it, you will need to:
+
+1. Import the `RandomStringGenerator` class from the contrib.
+2. Create an instance of this class taking two arguments:
+   - The name of the gemerator (like "phone number", "license plate"...).
+   - The regular expression representing the expected results.
+3. Use the generator's `all`, `get` and `remove` methods as shown above.
+
+To understand how to read and create regular expressions, you can refer to
+[the documentation on the re module](https://docs.python.org/2/library/re.html).
+Some examples of regular expressions you could use:
+
+- `r"555-\d{3}-\d{4}"`: 555, a dash, 3 digits, another dash, 4 digits.
+- `r"[0-9]{3}[A-Z][0-9]{3}"`: 3 digits, a capital letter, 3 digits.
+- `r"[A-Za-z0-9]{8,15}"`: between 8 and 15 letters and digits.
+- ...
+
+Behind the scenes, a script is created to store the generated information
+for a single generator.  The `RandomStringGenerator` object will also
+read the regular expression you give to it to see what information is
+required (letters, digits, a more restricted class, simple characters...)...
+More complex regular expressions (with branches for instance) might not be
+available.
+
+"""
+
+import re
+import string
+import time
+from random import choice, randint, seed
+
+from evennia import DefaultScript, ScriptDB
+from evennia.utils.create import create_script
+
+
+
[docs]class RejectedRegex(RuntimeError): + + """The provided regular expression has been rejected. + + More details regarding why this error occurred will be provided in + the message. The usual reason is the provided regular expression is + not specific enough and could lead to inconsistent generating. + + """ + + pass
+ + +
[docs]class ExhaustedGenerator(RuntimeError): + + """The generator hasn't any available strings to generate anymore.""" + + pass
+ + +
[docs]class RandomStringGeneratorScript(DefaultScript): + + """ + The global script to hold all generators. + + It will be automatically created the first time `generate` is called + on a RandomStringGenerator object. + + """ + +
[docs] def at_script_creation(self): + """Hook called when the script is created.""" + self.key = "generator_script" + self.desc = "Global generator script" + self.persistent = True + + # Permanent data to be stored + self.db.generated = {}
+ + +
[docs]class RandomStringGenerator: + + """ + A generator class to generate pseudo-random strings with a rule. + + The "rule" defining what the generator should provide in terms of + string is given as a regular expression when creating instances of + this class. You can use the `all` method to get all generated strings, + the `get` method to generate a new string, the `remove` method + to remove a generated string, or the `clear` method to remove all + generated strings. + + Bear in mind, however, that while the generated strings will be + stored to avoid repetition, the generator will not concern itself + with how the string is stored on the object you use. You probably + want to create a tag to mark this object. This is outside of the scope + of this class. + + """ + + # We keep the script as a class variable to optimize querying + # with multiple instandces + script = None + +
[docs] def __init__(self, name, regex): + """ + Create a new generator. + + Args: + name (str): name of the generator to create. + regex (str): regular expression describing the generator. + + Notes: + `name` should be an explicit name. If you use more than one + generator in your game, be sure to give them different names. + This name will be used to store the generated information + in the global script, and in case of errors. + + The regular expression should describe the generator, what + it should generate: a phone number, a license plate, a password + or something else. Regular expressions allow you to use + pretty advanced criteria, but be aware that some regular + expressions will be rejected if not specific enough. + + Raises: + RejectedRegex: the provided regular expression couldn't be + accepted as a valid generator description. + + """ + self.name = name + self.elements = [] + self.total = 1 + + # Analyze the regex if any + if regex: + self._find_elements(regex)
+ + def __repr__(self): + return ( + "<evennia.contrib.utils.random_string_generator.RandomStringGenerator for {}>".format( + self.name + ) + ) + + def _get_script(self): + """Get or create the script.""" + if type(self).script: + return type(self).script + + try: + script = ScriptDB.objects.get(db_key="generator_script") + except ScriptDB.DoesNotExist: + script = create_script( + "evennia.contrib.utils.random_string_generator.RandomStringGeneratorScript" + ) + + type(self).script = script + return script + + def _find_elements(self, regex): + """ + Find the elements described in the regular expression. This will + analyze the provided regular expression and try to find elements. + + Args: + regex (str): the regular expression. + + """ + try: + # python 3.11 + regex_parser = re._parser + except AttributeError: + # python <3.11 + regex_parser = re.sre_parse + + self.total = 1 + self.elements = [] + tree = regex_parser.parse(regex).data # note - sre_parse removed in py3.11 + # `tree` contains a list of elements in the regular expression + for element in tree: + # `element` is also a list, the first element is a string + name = str(element[0]).lower() + desc = {"min": 1, "max": 1} + + # If `.`, break here + if name == "any": + raise RejectedRegex( + "the . definition is too broad, specify what you need more precisely" + ) + elif name == "at": + # Either the beginning or end, we ignore it + continue + elif name == "min_repeat": + raise RejectedRegex("you have to provide a maximum number of this character class") + elif name == "max_repeat": + desc["min"] = element[1][0] + desc["max"] = element[1][1] + desc["chars"] = self._find_literal(element[1][2][0]) + elif name == "in": + desc["chars"] = self._find_literal(element) + elif name == "literal": + desc["chars"] = self._find_literal(element) + else: + raise RejectedRegex("unhandled regex syntax:: {}".format(repr(name))) + + self.elements.append(desc) + self.total *= len(desc["chars"]) ** desc["max"] + + def _find_literal(self, element): + """Find the literal corresponding to a piece of regular expression.""" + name = str(element[0]).lower() + chars = [] + if name == "literal": + chars.append(chr(element[1])) + elif name == "in": + negate = False + if element[1][0][0] == "negate": + negate = True + chars = list(string.ascii_letters + string.digits) + + for part in element[1]: + if part[0] == "negate": + continue + + sublist = self._find_literal(part) + for char in sublist: + if negate: + if char in chars: + chars.remove(char) + else: + chars.append(char) + elif name == "range": + chars = [chr(i) for i in range(element[1][0], element[1][1] + 1)] + elif name == "category": + category = str(element[1]).lower() + if category == "category_digit": + chars = list(string.digits) + elif category == "category_word": + chars = list(string.letters) + else: + raise RejectedRegex("unknown category: {}".format(category)) + else: + raise RejectedRegex("cannot find the literal: {}".format(element[0])) + + return chars + +
[docs] def all(self): + """ + Return all generated strings for this generator. + + Returns: + strings (list of strr): the list of strings that are already + used. The strings that were generated first come first in the list. + + """ + script = self._get_script() + generated = list(script.db.generated.get(self.name, [])) + return generated
+ +
[docs] def get(self, store=True, unique=True): + """ + Generate a pseudo-random string according to the regular expression. + + Args: + store (bool, optional): store the generated string in the script. + unique (bool, optional): keep on trying if the string is already used. + + Returns: + The newly-generated string. + + Raises: + ExhaustedGenerator: if there's no available string in this generator. + + Note: + Unless asked explicitly, the returned string can't repeat itself. + + """ + script = self._get_script() + generated = script.db.generated.get(self.name) + if generated is None: + script.db.generated[self.name] = [] + generated = script.db.generated[self.name] + + if len(generated) >= self.total: + raise ExhaustedGenerator + + # Generate a pseudo-random string that might be used already + result = "" + for element in self.elements: + number = randint(element["min"], element["max"]) + chars = element["chars"] + for index in range(number): + char = choice(chars) + result += char + + # If the string has already been generated, try again + if result in generated and unique: + # Change the random seed, incrementing it slowly + epoch = time.time() + while result in generated: + epoch += 1 + seed(epoch) + result = self.get(store=False, unique=False) + + if store: + generated.append(result) + + return result
+ +
[docs] def remove(self, element): + """ + Remove a generated string from the list of stored strings. + + Args: + element (str): the string to remove from the list of generated strings. + + Raises: + ValueError: the specified value hasn't been generated and is not present. + + Note: + The specified string has to be present in the script (so + has to have been generated). It will remove this entry + from the script, so this string could be generated again by + calling the `get` method. + + """ + script = self._get_script() + generated = script.db.generated.get(self.name, []) + if element not in generated: + raise ValueError( + "the string {} isn't stored as generated by the generator {}".format( + element, self.name + ) + ) + + generated.remove(element)
+ +
[docs] def clear(self): + """ + Clear the generator of all generated strings. + + """ + script = self._get_script() + generated = script.db.generated.get(self.name, []) + generated[:] = []
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/random_string_generator/tests.html b/docs/latest/_modules/evennia/contrib/utils/random_string_generator/tests.html new file mode 100644 index 0000000000..c4356d6a47 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/random_string_generator/tests.html @@ -0,0 +1,131 @@ + + + + + + + + evennia.contrib.utils.random_string_generator.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.random_string_generator.tests

+"""
+Random string tests.
+
+"""
+
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import random_string_generator
+
+SIMPLE_GENERATOR = random_string_generator.RandomStringGenerator("simple", "[01]{2}")
+
+
+
[docs]class TestRandomStringGenerator(BaseEvenniaTest): +
[docs] def test_generate(self): + """Generate and fail when exhausted.""" + generated = [] + for i in range(4): + generated.append(SIMPLE_GENERATOR.get()) + + generated.sort() + self.assertEqual(generated, ["00", "01", "10", "11"]) + + # At this point, we have generated 4 strings. + # We can't generate one more + with self.assertRaises(random_string_generator.ExhaustedGenerator): + SIMPLE_GENERATOR.get()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/tree_select/tests.html b/docs/latest/_modules/evennia/contrib/utils/tree_select/tests.html new file mode 100644 index 0000000000..b3b4b5b5a8 --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/tree_select/tests.html @@ -0,0 +1,168 @@ + + + + + + + + evennia.contrib.utils.tree_select.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.tree_select.tests

+"""
+Test tree select
+
+"""
+
+from evennia.contrib.utils.fieldfill import fieldfill
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from . import tree_select
+
+TREE_MENU_TESTSTR = """Foo
+Bar
+-Baz
+--Baz 1
+--Baz 2
+-Qux"""
+
+
+
[docs]class TestTreeSelectFunc(BaseEvenniaTest): +
[docs] def test_tree_functions(self): + # Dash counter + self.assertTrue(tree_select.dashcount("--test") == 2) + # Is category + self.assertTrue(tree_select.is_category(TREE_MENU_TESTSTR, 1) is True) + # Parse options + self.assertTrue( + tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) + == [(3, "Baz 1"), (4, "Baz 2")] + ) + # Index to selection + self.assertTrue(tree_select.index_to_selection(TREE_MENU_TESTSTR, 2) == "Baz") + # Go up one category + self.assertTrue(tree_select.go_up_one_category(TREE_MENU_TESTSTR, 4) == 2) + # Option list to menu options + test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) + optlist_to_menu_expected_result = [ + {"goto": ["menunode_treeselect", {"newindex": 3}], "key": "Baz 1"}, + {"goto": ["menunode_treeselect", {"newindex": 4}], "key": "Baz 2"}, + { + "goto": ["menunode_treeselect", {"newindex": 1}], + "key": ["<< Go Back", "go back", "back"], + "desc": "Return to the previous menu.", + }, + ] + self.assertTrue( + tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) + == optlist_to_menu_expected_result + )
+ + +FIELD_TEST_TEMPLATE = [ + {"fieldname": "TextTest", "fieldtype": "text"}, + {"fieldname": "NumberTest", "fieldtype": "number", "blankmsg": "Number here!"}, + {"fieldname": "DefaultText", "fieldtype": "text", "default": "Test"}, + {"fieldname": "DefaultNum", "fieldtype": "number", "default": 3}, +] + +FIELD_TEST_DATA = {"TextTest": None, "NumberTest": None, "DefaultText": "Test", "DefaultNum": 3} + + +
[docs]class TestFieldFillFunc(BaseEvenniaTest): +
[docs] def test_field_functions(self): + self.assertTrue(fieldfill.form_template_to_dict(FIELD_TEST_TEMPLATE) == FIELD_TEST_DATA)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/contrib/utils/tree_select/tree_select.html b/docs/latest/_modules/evennia/contrib/utils/tree_select/tree_select.html new file mode 100644 index 0000000000..a7077876ed --- /dev/null +++ b/docs/latest/_modules/evennia/contrib/utils/tree_select/tree_select.html @@ -0,0 +1,683 @@ + + + + + + + + evennia.contrib.utils.tree_select.tree_select — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.contrib.utils.tree_select.tree_select

+"""
+Easy menu selection tree
+
+Contrib - Tim Ashley Jenkins 2017
+
+This module allows you to create and initialize an entire branching EvMenu
+instance with nothing but a multi-line string passed to one function.
+
+EvMenu is incredibly powerful and flexible, but using it for simple menus
+can often be fairly cumbersome - a simple menu that can branch into five
+categories would require six nodes, each with options represented as a list
+of dictionaries.
+
+This module provides a function, init_tree_selection, which acts as a frontend
+for EvMenu, dynamically sourcing the options from a multi-line string you provide.
+For example, if you define a string as such:
+
+    TEST_MENU = '''Foo
+    Bar
+    Baz
+    Qux'''
+
+And then use TEST_MENU as the 'treestr' source when you call init_tree_selection
+on a player:
+
+    init_tree_selection(TEST_MENU, caller, callback)
+
+The player will be presented with an EvMenu, like so:
+
+    ___________________________
+
+    Make your selection:
+    ___________________________
+
+    Foo
+    Bar
+    Baz
+    Qux
+
+Making a selection will pass the selection's key to the specified callback as a
+string along with the caller, as well as the index of the selection (the line number
+on the source string) along with the source string for the tree itself.
+
+In addition to specifying selections on the menu, you can also specify categories.
+Categories are indicated by putting options below it preceded with a '-' character.
+If a selection is a category, then choosing it will bring up a new menu node, prompting
+the player to select between those options, or to go back to the previous menu. In
+addition, categories are marked by default with a '[+]' at the end of their key. Both
+this marker and the option to go back can be disabled.
+
+Categories can be nested in other categories as well - just go another '-' deeper. You
+can do this as many times as you like. There's no hard limit to the number of
+categories you can go down.
+
+For example, let's add some more options to our menu, turning 'Bar' into a category.
+
+    TEST_MENU = '''Foo
+    Bar
+    -You've got to know
+    --When to hold em
+    --When to fold em
+    --When to walk away
+    Baz
+    Qux'''
+
+Now when we call the menu, we can see that 'Bar' has become a category instead of a
+selectable option.
+
+    _______________________________
+
+    Make your selection:
+    _______________________________
+
+    Foo
+    Bar [+]
+    Baz
+    Qux
+
+Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it.
+
+    ________________________________________________________________
+
+    Bar
+    ________________________________________________________________
+
+    You've got to know [+]
+    << Go Back: Return to the previous menu.
+
+Just the one option, which is a category itself, and the option to go back, which will
+take us back to the previous menu. Let's select 'You've got to know'.
+
+    ________________________________________________________________
+
+    You've got to know
+    ________________________________________________________________
+
+    When to hold em
+    When to fold em
+    When to walk away
+    << Go Back: Return to the previous menu.
+
+Now we see the three options listed under it, too. We can select one of them or use 'Go
+Back' to return to the 'Bar' menu we were just at before. It's very simple to make a
+branching tree of selections!
+
+One last thing - you can set the descriptions for the various options simply by adding a
+':' character followed by the description to the option's line. For example, let's add a
+description to 'Baz' in our menu:
+
+    TEST_MENU = '''Foo
+    Bar
+    -You've got to know
+    --When to hold em
+    --When to fold em
+    --When to walk away
+    Baz: Look at this one: the best option.
+    Qux'''
+
+Now we see that the Baz option has a description attached that's separate from its key:
+
+    _______________________________________________________________
+
+    Make your selection:
+    _______________________________________________________________
+
+    Foo
+    Bar [+]
+    Baz: Look at this one: the best option.
+    Qux
+
+Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call
+your specified callback with the selection, like so:
+
+    callback(caller, TEST_MENU, 0, "Foo")
+
+The index of the selection is given along with a string containing the selection's key.
+That way, if you have two selections in the menu with the same key, you can still
+differentiate between them.
+
+And that's all there is to it! For simple branching-tree selections, using this system is
+much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic
+options much easier - since the source of the menu tree is just a string, you could easily
+generate that string procedurally before passing it to the init_tree_selection function.
+For example, if a player casts a spell or does an attack without specifying a target, instead
+of giving them an error, you could present them with a list of valid targets to select by
+generating a multi-line string of targets and passing it to init_tree_selection, with the
+callable performing the maneuver once a selection is made.
+
+This selection system only works for simple branching trees - doing anything really complicated
+like jumping between categories or prompting for arbitrary input would still require a full
+EvMenu implementation. For simple selections, however, I'm sure you will find using this function
+to be much easier!
+
+Included in this module is a sample menu and function which will let a player change the color
+of their name - feel free to mess with it to get a feel for how this system works by importing
+this module in your game's default_cmdsets.py module and adding CmdNameColor to your default
+character's command set.
+
+"""
+
+from evennia import Command
+from evennia.utils import evmenu
+from evennia.utils.logger import log_trace
+
+
+
[docs]def init_tree_selection( + treestr, + caller, + callback, + index=None, + mark_category=True, + go_back=True, + cmd_on_exit="look", + start_text="Make your selection:", +): + """ + Prompts a player to select an option from a menu tree given as a multi-line string. + + Args: + treestr (str): Multi-lne string representing menu options + caller (obj): Player to initialize the menu for + callback (callable): Function to run when a selection is made. Must take 4 args: + caller (obj): Caller given above + treestr (str): Menu tree string given above + index (int): Index of final selection + selection (str): Key of final selection + + Options: + index (int or None): Index to start the menu at, or None for top level + mark_category (bool): If True, marks categories with a [+] symbol in the menu + go_back (bool): If True, present an option to go back to previous categories + start_text (str): Text to display at the top level of the menu + cmd_on_exit(str): Command to enter when the menu exits - 'look' by default + + + Notes: + This function will initialize an instance of EvMenu with options generated + dynamically from the source string, and passes the menu user's selection to + a function of your choosing. The EvMenu is made of a single, repeating node, + which will call itself over and over at different levels of the menu tree as + categories are selected. + + Once a non-category selection is made, the user's selection will be passed to + the given callable, both as a string and as an index number. The index is given + to ensure every selection has a unique identifier, so that selections with the + same key in different categories can be distinguished between. + + The menus called by this function are not persistent and cannot perform + complicated tasks like prompt for arbitrary input or jump multiple category + levels at once - you'll have to use EvMenu itself if you want to take full + advantage of its features. + """ + + # Pass kwargs to store data needed in the menu + kwargs = { + "index": index, + "mark_category": mark_category, + "go_back": go_back, + "treestr": treestr, + "callback": callback, + "start_text": start_text, + } + + # Initialize menu of selections + evmenu.EvMenu( + caller, + "evennia.contrib.utils.tree_select", + startnode="menunode_treeselect", + startnode_input=None, + cmd_on_exit=cmd_on_exit, + **kwargs, + )
+ + +
[docs]def dashcount(entry): + """ + Counts the number of dashes at the beginning of a string. This + is needed to determine the depth of options in categories. + + Args: + entry (str): String to count the dashes at the start of + + Returns: + dashes (int): Number of dashes at the start + """ + dashes = 0 + for char in entry: + if char == "-": + dashes += 1 + else: + return dashes + return dashes
+ + +
[docs]def is_category(treestr, index): + """ + Determines whether an option in a tree string is a category by + whether or not there are additional options below it. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Which line of the string to test + + Returns: + is_category (bool): Whether the option is a category + """ + opt_list = treestr.split("\n") + # Not a category if it's the last one in the list + if index == len(opt_list) - 1: + return False + # Not a category if next option is not one level deeper + return not bool(dashcount(opt_list[index + 1]) != dashcount(opt_list[index]) + 1)
+ + +
[docs]def parse_opts(treestr, category_index=None): + """ + Parses a tree string and given index into a list of options. If + category_index is none, returns all the options at the top level of + the menu. If category_index corresponds to a category, returns a list + of options under that category. If category_index corresponds to + an option that is not a category, it's a selection and returns True. + + Args: + treestr (str): Multi-line string representing menu options + category_index (int): Index of category or None for top level + + Returns: + kept_opts (list or True): Either a list of options in the selected + category or True if a selection was made + """ + dash_depth = 0 + opt_list = treestr.split("\n") + kept_opts = [] + + # If a category index is given + if category_index != None: + # If given index is not a category, it's a selection - return True. + if not is_category(treestr, category_index): + return True + # Otherwise, change the dash depth to match the new category. + dash_depth = dashcount(opt_list[category_index]) + 1 + # Delete everything before the category index + opt_list = opt_list[category_index + 1 :] + + # Keep every option (referenced by index) at the appropriate depth + cur_index = 0 + for option in opt_list: + if dashcount(option) == dash_depth: + if category_index == None: + kept_opts.append((cur_index, option[dash_depth:])) + else: + kept_opts.append((cur_index + category_index + 1, option[dash_depth:])) + # Exits the loop if leaving a category + if dashcount(option) < dash_depth: + return kept_opts + cur_index += 1 + return kept_opts
+ + +
[docs]def index_to_selection(treestr, index, desc=False): + """ + Given a menu tree string and an index, returns the corresponding selection's + name as a string. If 'desc' is set to True, will return the selection's + description as a string instead. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Index to convert to selection key or description + + Options: + desc (bool): If true, returns description instead of key + + Returns: + selection (str): Selection key or description if 'desc' is set + """ + opt_list = treestr.split("\n") + # Fetch the given line + selection = opt_list[index] + # Strip out the dashes at the start + selection = selection[dashcount(selection) :] + # Separate out description, if any + if ":" in selection: + # Split string into key and description + selection = selection.split(":", 1) + selection[1] = selection[1].strip(" ") + else: + # If no description given, set description to None + selection = [selection, None] + if not desc: + return selection[0] + else: + return selection[1]
+ + +
[docs]def go_up_one_category(treestr, index): + """ + Given a menu tree string and an index, returns the category that the given option + belongs to. Used for the 'go back' option. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Index to determine the parent category of + + Returns: + parent_category (int): Index of parent category + """ + opt_list = treestr.split("\n") + # Get the number of dashes deep the given index is + dash_level = dashcount(opt_list[index]) + # Delete everything after the current index + opt_list = opt_list[: index + 1] + + # If there's no dash, return 'None' to return to base menu + if dash_level == 0: + return None + current_index = index + # Go up through each option until we find one that's one category above + for selection in reversed(opt_list): + if dashcount(selection) == dash_level - 1: + return current_index + current_index -= 1
+ + +
[docs]def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back): + """ + Takes a list of options processed by parse_opts and turns it into + a list/dictionary of menu options for use in menunode_treeselect. + + Args: + treestr (str): Multi-line string representing menu options + optlist (list): List of options to convert to EvMenu's option format + index (int): Index of current category + mark_category (bool): Whether or not to mark categories with [+] + go_back (bool): Whether or not to add an option to go back in the menu + + Returns: + menuoptions (list of dicts): List of menu options formatted for use + in EvMenu, each passing a different "newindex" kwarg that changes + the menu level or makes a selection + """ + + menuoptions = [] + cur_index = 0 + for option in optlist: + index_to_add = optlist[cur_index][0] + menuitem = {} + keystr = index_to_selection(treestr, index_to_add) + if mark_category and is_category(treestr, index_to_add): + # Add the [+] to the key if marking categories, and the key by itself as an alias + menuitem["key"] = [keystr + " [+]", keystr] + else: + menuitem["key"] = keystr + # Get the option's description + desc = index_to_selection(treestr, index_to_add, desc=True) + if desc: + menuitem["desc"] = desc + # Passing 'newindex' as a kwarg to the node is how we move through the menu! + menuitem["goto"] = ["menunode_treeselect", {"newindex": index_to_add}] + menuoptions.append(menuitem) + cur_index += 1 + # Add option to go back, if needed + if index != None and go_back == True: + gobackitem = { + "key": ["<< Go Back", "go back", "back"], + "desc": "Return to the previous menu.", + "goto": ["menunode_treeselect", {"newindex": go_up_one_category(treestr, index)}], + } + menuoptions.append(gobackitem) + return menuoptions
+ + + + + +# The rest of this module is for the example menu and command! It'll change the color of your name. + +""" +Here's an example string that you can initialize a menu from. Note the dashes at +the beginning of each line - that's how menu option depth and hierarchy is determined. +""" + +NAMECOLOR_MENU = """Set name color: Choose a color for your name! +-Red shades: Various shades of |511red|n +--Red: |511Set your name to Red|n +--Pink: |533Set your name to Pink|n +--Maroon: |301Set your name to Maroon|n +-Orange shades: Various shades of |531orange|n +--Orange: |531Set your name to Orange|n +--Brown: |321Set your name to Brown|n +--Sienna: |420Set your name to Sienna|n +-Yellow shades: Various shades of |551yellow|n +--Yellow: |551Set your name to Yellow|n +--Gold: |540Set your name to Gold|n +--Dandelion: |553Set your name to Dandelion|n +-Green shades: Various shades of |141green|n +--Green: |141Set your name to Green|n +--Lime: |350Set your name to Lime|n +--Forest: |032Set your name to Forest|n +-Blue shades: Various shades of |115blue|n +--Blue: |115Set your name to Blue|n +--Cyan: |155Set your name to Cyan|n +--Navy: |113Set your name to Navy|n +-Purple shades: Various shades of |415purple|n +--Purple: |415Set your name to Purple|n +--Lavender: |535Set your name to Lavender|n +--Fuchsia: |503Set your name to Fuchsia|n +Remove name color: Remove your name color, if any""" + + +
[docs]class CmdNameColor(Command): + """ + Set or remove a special color on your name. Just an example for the + easy menu selection tree contrib. + """ + + key = "namecolor" + +
[docs] def func(self): + # This is all you have to do to initialize a menu! + init_tree_selection( + NAMECOLOR_MENU, self.caller, change_name_color, start_text="Name color options:" + )
+ + +
[docs]def change_name_color(caller, treestr, index, selection): + """ + Changes a player's name color. + + Args: + caller (obj): Character whose name to color. + treestr (str): String for the color change menu - unused + index (int): Index of menu selection - unused + selection (str): Selection made from the name color menu - used + to determine the color the player chose. + """ + + # Store the caller's uncolored name + if not caller.db.uncolored_name: + caller.db.uncolored_name = caller.key + + # Dictionary matching color selection names to color codes + colordict = { + "Red": "|511", + "Pink": "|533", + "Maroon": "|301", + "Orange": "|531", + "Brown": "|321", + "Sienna": "|420", + "Yellow": "|551", + "Gold": "|540", + "Dandelion": "|553", + "Green": "|141", + "Lime": "|350", + "Forest": "|032", + "Blue": "|115", + "Cyan": "|155", + "Navy": "|113", + "Purple": "|415", + "Lavender": "|535", + "Fuchsia": "|503", + } + + # I know this probably isn't the best way to do this. It's just an example! + if selection == "Remove name color": # Player chose to remove their name color + caller.key = caller.db.uncolored_name + caller.msg("Name color removed.") + elif selection in colordict: + newcolor = colordict[selection] # Retrieve color code based on menu selection + caller.key = newcolor + caller.db.uncolored_name + "|n" # Add color code to caller's name + caller.msg(newcolor + ("Name color changed to %s!" % selection) + "|n")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/help/filehelp.html b/docs/latest/_modules/evennia/help/filehelp.html new file mode 100644 index 0000000000..26b1d1e9ae --- /dev/null +++ b/docs/latest/_modules/evennia/help/filehelp.html @@ -0,0 +1,371 @@ + + + + + + + + evennia.help.filehelp — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.help.filehelp

+"""
+The filehelp-system allows for defining help files outside of the game. These
+will be treated as non-command help entries and displayed in the same way as
+help entries created using the `sethelp` default command. After changing an
+entry on-disk you need to reload the server to have the change show in-game.
+
+An filehelp file is a regular python module with dicts representing each help
+entry. If a list `HELP_ENTRY_DICTS` is found in the module, this should be a list of
+dicts.  Otherwise *all* top-level dicts in the module will be assumed to be a
+help-entry dict.
+
+Each help-entry dict is on the form
+::
+
+    {'key': <str>,
+     'text': <str>,
+     'category': <str>,   # optional, otherwise settings.DEFAULT_HELP_CATEGORY
+     'aliases': <list>,   # optional
+     'locks': <str>}      # optional, use access-type 'view'. Default is view:all()
+
+The `text`` should be formatted on the same form as other help entry-texts and
+can contain ``# subtopics`` as normal.
+
+New help-entry modules are added to the system by providing the python-path to
+the module to `settings.FILE_HELP_ENTRY_MODULES`. Note that if same-key entries are
+added, entries in latter modules will override that of earlier ones. Use
+`settings.DEFAULT_HELP_CATEGORY`` to customize what category is used if
+not set explicitly.
+
+An example of the contents of a module:
+::
+
+    help_entry1 = {
+        "key": "The Gods",   # case-insensitive, also partial-matching ('gods') works
+        "aliases": ['pantheon', 'religion'],
+        "category": "Lore",
+        "locks": "view:all()",   # this is optional unless restricting access
+        "text": '''
+            The gods formed the world ...
+
+            # Subtopics
+
+            ## Pantheon
+
+            ...
+
+            ### God of love
+
+            ...
+
+            ### God of war
+
+            ...
+
+        '''
+    }
+
+
+    HELP_ENTRY_DICTS = [
+        help_entry1,
+        ...
+    ]
+
+----
+
+"""
+
+from dataclasses import dataclass
+
+from django.conf import settings
+from django.urls import reverse
+from django.utils.text import slugify
+
+from evennia.locks.lockhandler import LockHandler
+from evennia.utils import logger
+from evennia.utils.utils import (
+    all_from_module,
+    lazy_property,
+    make_iter,
+    variable_from_module,
+)
+
+_DEFAULT_HELP_CATEGORY = settings.DEFAULT_HELP_CATEGORY
+
+
+
[docs]@dataclass +class FileHelpEntry: + """ + Represents a help entry read from file. This mimics the api of the + database-bound HelpEntry so that they can be used interchangeably in the + help command. + + """ + + key: str + aliases: list + help_category: str + entrytext: str + lock_storage: str + + @property + def search_index_entry(self): + """ + Property for easily retaining a search index entry for this object. + + """ + return { + "key": self.key, + "aliases": " ".join(self.aliases), + "category": self.help_category, + "no_prefix": "", + "tags": "", + "locks": "", + "text": self.entrytext, + } + + def __str__(self): + return self.key + + def __repr__(self): + return f"<FileHelpEntry {self.key}>" + +
[docs] @lazy_property + def locks(self): + return LockHandler(self)
+ +
[docs] def web_get_detail_url(self): + """ + Returns the URI path for a View that allows users to view details for + this object. + + ex. Oscar (Character) = '/characters/oscar/1/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-detail' would be referenced by this method. + + ex. + :: + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', + CharDetailView.as_view(), name='character-detail') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can view this object is the developer's + responsibility. + + Returns: + path (str): URI path to object detail page, if defined. + + """ + try: + return reverse( + "help-entry-detail", + kwargs={"category": slugify(self.help_category), "topic": slugify(self.key)}, + ) + except Exception: + return "#"
+ +
[docs] def web_get_admin_url(self): + """ + Returns the URI path for the Django Admin page for this object. + + ex. Account#1 = '/admin/accounts/accountdb/1/change/' + + Returns: + path (str): URI path to Django Admin page for object. + + """ + return False
+ +
[docs] def access(self, accessing_obj, access_type="view", default=True): + """ + Determines if another object has permission to access this help entry. + + Args: + accessing_obj (Object or Account): Entity trying to access this one. + access_type (str): type of access sought. + default (bool): What to return if no lock of `access_type` was found. + + """ + return self.locks.check(accessing_obj, access_type=access_type, default=default)
+ + +
[docs]class FileHelpStorageHandler: + """ + This reads and stores help entries for quick access. By default + it reads modules from `settings.FILE_HELP_ENTRY_MODULES`. + + Note that this is not meant to any searching/lookup - that is all handled + by the help command. + + """ + +
[docs] def __init__(self, help_file_modules=settings.FILE_HELP_ENTRY_MODULES): + """ + Initialize the storage. + """ + self.help_file_modules = [str(part).strip() for part in make_iter(help_file_modules)] + self.help_entries = [] + self.help_entries_dict = {} + self.load()
+ +
[docs] def load(self): + """ + Load/reload file-based help-entries from file. + + """ + loaded_help_dicts = [] + + for module_or_path in self.help_file_modules: + help_dict_list = variable_from_module(module_or_path, variable="HELP_ENTRY_DICTS") + if not help_dict_list: + help_dict_list = [ + dct for dct in all_from_module(module_or_path).values() if isinstance(dct, dict) + ] + if help_dict_list: + loaded_help_dicts.extend(help_dict_list) + else: + logger.log_err(f"Could not find file-help module {module_or_path} (skipping).") + + # validate and parse dicts into FileEntryHelp objects and make sure they are unique-by-key + # by letting latter added ones override earlier ones. + unique_help_entries = {} + + for dct in loaded_help_dicts: + key = dct.get("key").lower().strip() + category = dct.get("category", _DEFAULT_HELP_CATEGORY).strip() + aliases = list(dct.get("aliases", [])) + entrytext = dct.get("text", "") + locks = dct.get("locks", "") + + if not key and entrytext: + logger.error(f"Cannot load file-help-entry (missing key or text): {dct}") + continue + + unique_help_entries[key] = FileHelpEntry( + key=key, + help_category=category, + aliases=aliases, + lock_storage=locks, + entrytext=entrytext, + ) + + self.help_entries_dict = unique_help_entries + self.help_entries = list(unique_help_entries.values())
+ +
[docs] def all(self, return_dict=False): + """ + Get all help entries. + + Args: + return_dict (bool): Return a dict ``{key: FileHelpEntry,...}``. Otherwise, + return a list of ``FileHelpEntry`. + + Returns: + dict or list: Depending on the setting of ``return_dict``. + + """ + return self.help_entries_dict if return_dict else self.help_entries
+ + +# singleton to hold the loaded help entries +FILE_HELP_ENTRIES = FileHelpStorageHandler() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/help/manager.html b/docs/latest/_modules/evennia/help/manager.html new file mode 100644 index 0000000000..7a011d1838 --- /dev/null +++ b/docs/latest/_modules/evennia/help/manager.html @@ -0,0 +1,306 @@ + + + + + + + + evennia.help.manager — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.help.manager

+"""
+Custom manager for HelpEntry objects.
+"""
+from django.db import IntegrityError
+
+from evennia.server import signals
+from evennia.typeclasses.managers import TypedObjectManager
+from evennia.utils import logger, utils
+from evennia.utils.utils import make_iter
+
+__all__ = ("HelpEntryManager",)
+
+
+
[docs]class HelpEntryManager(TypedObjectManager): + """ + This HelpEntryManager implements methods for searching + and manipulating HelpEntries directly from the database. + + These methods will all return database objects + (or QuerySets) directly. + + Evennia-specific: + find_topicmatch + find_apropos + find_topicsuggestions + find_topics_with_category + all_to_category + search_help (equivalent to evennia.search_helpentry) + + """ + +
[docs] def find_topicmatch(self, topicstr, exact=False): + """ + Searches for matching topics or aliases based on player's + input. + + Args: + topcistr (str): Help topic to search for. + exact (bool, optional): Require exact match + (non-case-sensitive). If `False` (default), match + sub-parts of the string. + + Returns: + matches (HelpEntries): Query results. + + """ + dbref = utils.dbref(topicstr) + if dbref: + return self.filter(id=dbref) + topics = self.filter(db_key__iexact=topicstr) + if not topics: + topics = self.get_by_alias(topicstr) + if not topics and not exact: + topics = self.filter(db_key__istartswith=topicstr) + if not topics: + topics = self.filter(db_key__icontains=topicstr) + return topics
+ +
[docs] def find_apropos(self, topicstr): + """ + Do a very loose search, returning all help entries containing + the search criterion in their titles. + + Args: + topicstr (str): Search criterion. + + Returns: + matches (HelpEntries): Query results. + + """ + return self.filter(db_key__icontains=topicstr)
+ +
[docs] def find_topicsuggestions(self, topicstr): + """ + Do a fuzzy match, preferably within the category of the + current topic. + + Args: + topicstr (str): Search criterion. + + Returns: + matches (Helpentries): Query results. + + """ + return self.filter(db_key__icontains=topicstr).exclude(db_key__iexact=topicstr)
+ +
[docs] def find_topics_with_category(self, help_category): + """ + Search topics having a particular category. + + Args: + help_category (str): Category query criterion. + + Returns: + matches (HelpEntries): Query results. + + """ + return self.filter(db_help_category__iexact=help_category)
+ +
[docs] def get_all_topics(self): + """ + Get all topics. + + Returns: + all (HelpEntries): All topics. + + """ + return self.all()
+ +
[docs] def get_all_categories(self): + """ + Return all defined category names with at least one topic in + them. + + Returns: + matches (list): Unique list of category names across all + topics. + + """ + return list(set(topic.help_category for topic in self.all()))
+ +
[docs] def all_to_category(self, default_category): + """ + Shifts all help entries in database to default_category. This + action cannot be reverted. It is used primarily by the engine + when importing a default help database, making sure this ends + up in one easily separated category. + + Args: + default_category (str): Category to move entries to. + + """ + topics = self.all() + for topic in topics: + topic.help_category = default_category + topic.save() + string = "Help database moved to category {default_category}".format( + default_category=default_category + ) + logger.log_info(string)
+ +
[docs] def search_help(self, ostring, help_category=None): + """ + Retrieve a search entry object. + + Args: + ostring (str): The help topic to look for. + category (str): Limit the search to a particular help topic + + Returns: + Queryset: An iterable with 0, 1 or more matches. + + """ + ostring = ostring.strip().lower() + if help_category: + return self.filter(db_key__iexact=ostring, db_help_category__iexact=help_category) + else: + return self.filter(db_key__iexact=ostring)
+ +
[docs] def create_help(self, key, entrytext, category="General", locks=None, aliases=None, tags=None): + """ + Create a static help entry in the help database. Note that Command + help entries are dynamic and directly taken from the __doc__ + entries of the command. The database-stored help entries are + intended for more general help on the game, more extensive info, + in-game setting information and so on. + + Args: + key (str): The name of the help entry. + entrytext (str): The body of te help entry + category (str, optional): The help category of the entry. + locks (str, optional): A lockstring to restrict access. + aliases (list of str, optional): List of alternative (likely shorter) keynames. + tags (lst, optional): List of tags or tuples `(tag, category)`. + + Returns: + help (HelpEntry): A newly created help entry. + + """ + try: + new_help = self.model() + new_help.key = key + new_help.entrytext = entrytext + new_help.help_category = category + if locks: + new_help.locks.add(locks) + if aliases: + new_help.aliases.add(make_iter(aliases)) + if tags: + new_help.tags.batch_add(*tags) + new_help.save() + return new_help + except IntegrityError: + string = "Could not add help entry: key '%s' already exists." % key + logger.log_err(string) + return None + except Exception: + logger.log_trace() + return None + + signals.SIGNAL_HELPENTRY_POST_CREATE.send(sender=new_help)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/help/models.html b/docs/latest/_modules/evennia/help/models.html new file mode 100644 index 0000000000..b17d70b27a --- /dev/null +++ b/docs/latest/_modules/evennia/help/models.html @@ -0,0 +1,413 @@ + + + + + + + + evennia.help.models — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.help.models

+"""
+Models for the help system.
+
+The database-tied help system is only half of Evennia's help
+functionality, the other one being the auto-generated command help
+that is created on the fly from each command's `__doc__` string. The
+persistent database system defined here is intended for all other
+forms of help that do not concern commands, like information about the
+game world, policy info, rules and similar.
+
+"""
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from django.urls import reverse
+from django.utils.text import slugify
+
+from evennia.help.manager import HelpEntryManager
+from evennia.locks.lockhandler import LockHandler
+from evennia.typeclasses.models import AliasHandler, Tag, TagHandler
+from evennia.utils.idmapper.models import SharedMemoryModel
+from evennia.utils.utils import lazy_property
+
+__all__ = ("HelpEntry",)
+
+
+# ------------------------------------------------------------
+#
+# HelpEntry
+#
+# ------------------------------------------------------------
+
+
+
[docs]class HelpEntry(SharedMemoryModel): + """ + A generic help entry. + + An HelpEntry object has the following properties defined: + key - main name of entry + help_category - which category entry belongs to (defaults to General) + entrytext - the actual help text + permissions - perm strings + + Method: + access + + """ + + # + # HelpEntry Database Model setup + # + # + # These database fields are all set using their corresponding properties, + # named same as the field, but withtout the db_* prefix. + + # title of the help entry + db_key = models.CharField( + "help key", max_length=255, unique=True, help_text="key to search for" + ) + + # help category + db_help_category = models.CharField( + "help category", + max_length=255, + default="General", + help_text="organizes help entries in lists", + ) + + # the actual help entry text, in any formatting. + db_entrytext = models.TextField( + "help entry", blank=True, help_text="the main body of help text" + ) + # lock string storage + db_lock_storage = models.TextField("locks", blank=True, help_text="normally view:all().") + # tags are primarily used for permissions + db_tags = models.ManyToManyField( + Tag, + blank=True, + help_text="tags on this object. Tags are simple string markers to " + "identify, group and alias objects.", + ) + # Creation date. This is not changed once the object is created. + db_date_created = models.DateTimeField("creation date", editable=False, auto_now=True) + + # Database manager + objects = HelpEntryManager() + _is_deleted = False + + # lazy-loaded handlers + +
[docs] @lazy_property + def locks(self): + return LockHandler(self)
+ +
[docs] @lazy_property + def tags(self): + return TagHandler(self)
+ +
[docs] @lazy_property + def aliases(self): + return AliasHandler(self)
+ + class Meta: + "Define Django meta options" + verbose_name = "Help Entry" + verbose_name_plural = "Help Entries" + + # + # + # HelpEntry main class methods + # + # + def __str__(self): + return str(self.key) + + def __repr__(self): + return f"<HelpEntry {self.key}>" + +
[docs] def access(self, accessing_obj, access_type="read", default=True): + """ + Determines if another object has permission to access this help entry. + + Accesses used by default: + 'read' - read the help entry itself. + 'view' - see help entry in help index. + + Args: + accessing_obj (Object or Account): Entity trying to access this one. + access_type (str): type of access sought. + default (bool): What to return if no lock of `access_type` was found. + + """ + return self.locks.check(accessing_obj, access_type=access_type, default=default)
+ + @property + def search_index_entry(self): + """ + Property for easily retaining a search index entry for this object. + """ + return { + "key": self.db_key, + "aliases": " ".join(self.aliases.all()), + "no_prefix": "", + "category": self.db_help_category, + "text": self.db_entrytext, + "tags": " ".join(str(tag) for tag in self.tags.all()), + } + + # + # Web/Django methods + # + +
[docs] def web_get_admin_url(self): + """ + Returns the URI path for the Django Admin page for this object. + + ex. Account#1 = '/admin/accounts/accountdb/1/change/' + + Returns: + path (str): URI path to Django Admin page for object. + + """ + content_type = ContentType.objects.get_for_model(self.__class__) + return reverse( + "admin:%s_%s_change" % (content_type.app_label, content_type.model), args=(self.id,) + )
+ +
[docs] @classmethod + def web_get_create_url(cls): + """ + Returns the URI path for a View that allows users to create new + instances of this object. + + ex. Chargen = '/characters/create/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-create' would be referenced by this method. + + ex. + :: + + url(r'characters/create/', ChargenView.as_view(), name='character-create') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can create new objects is the + developer's responsibility. + + Returns: + path (str): URI path to object creation page, if defined. + + """ + try: + return reverse("%s-create" % slugify(cls._meta.verbose_name)) + except Exception: + return "#"
+ +
[docs] def web_get_detail_url(self): + """ + Returns the URI path for a View that allows users to view details for + this object. + + ex. Oscar (Character) = '/characters/oscar/1/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-detail' would be referenced by this method. + + ex. + :: + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', + CharDetailView.as_view(), name='character-detail') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can view this object is the developer's + responsibility. + + Returns: + path (str): URI path to object detail page, if defined. + + """ + + try: + return reverse( + "%s-detail" % slugify(self._meta.verbose_name), + kwargs={"category": slugify(self.db_help_category), "topic": slugify(self.db_key)}, + ) + except Exception: + return "#"
+ +
[docs] def web_get_update_url(self): + """ + Returns the URI path for a View that allows users to update this + object. + + ex. Oscar (Character) = '/characters/oscar/1/change/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-update' would be referenced by this method. + + ex. + :: + + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$', + CharUpdateView.as_view(), name='character-update') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can modify objects is the developer's + responsibility. + + Returns: + path (str): URI path to object update page, if defined. + + """ + try: + return reverse( + "%s-update" % slugify(self._meta.verbose_name), + kwargs={"category": slugify(self.db_help_category), "topic": slugify(self.db_key)}, + ) + except Exception: + return "#"
+ +
[docs] def web_get_delete_url(self): + """ + Returns the URI path for a View that allows users to delete this object. + + ex. Oscar (Character) = '/characters/oscar/1/delete/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-detail' would be referenced by this method. + + ex. + :: + + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$', + CharDeleteView.as_view(), name='character-delete') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can delete this object is the developer's + responsibility. + + Returns: + path (str): URI path to object deletion page, if defined. + + """ + try: + return reverse( + "%s-delete" % slugify(self._meta.verbose_name), + kwargs={"category": slugify(self.db_help_category), "topic": slugify(self.db_key)}, + ) + except Exception: + return "#"
+ + # Used by Django Sites/Admin + get_absolute_url = web_get_detail_url
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/help/utils.html b/docs/latest/_modules/evennia/help/utils.html new file mode 100644 index 0000000000..b610886398 --- /dev/null +++ b/docs/latest/_modules/evennia/help/utils.html @@ -0,0 +1,337 @@ + + + + + + + + evennia.help.utils — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.help.utils

+"""
+Resources for indexing help entries and for splitting help entries into
+sub-categories.
+
+This is used primarily by the default `help` command.
+
+"""
+import re
+
+from django.conf import settings
+
+# these are words that Lunr normally ignores but which we want to find
+# since we use them (e.g. as command names).
+# Lunr's default ignore-word list is found here:
+# https://github.com/yeraydiazdiaz/lunr.py/blob/master/lunr/stop_word_filter.py
+_LUNR_STOP_WORD_FILTER_EXCEPTIONS = [
+    "about",
+    "might",
+    "get",
+    "who",
+    "say",
+] + settings.LUNR_STOP_WORD_FILTER_EXCEPTIONS
+
+
+_LUNR = None
+_LUNR_EXCEPTION = None
+
+_LUNR_GET_BUILDER = None
+_LUNR_BUILDER_PIPELINE = None
+
+_RE_HELP_SUBTOPICS_START = re.compile(r"^\s*?#\s*?subtopics\s*?$", re.I + re.M)
+_RE_HELP_SUBTOPIC_SPLIT = re.compile(r"^\s*?(\#{2,6}\s*?\w+?[a-z0-9 \-\?!,\.]*?)$", re.M + re.I)
+_RE_HELP_SUBTOPIC_PARSE = re.compile(r"^(?P<nesting>\#{2,6})\s*?(?P<name>.*?)$", re.I + re.M)
+
+MAX_SUBTOPIC_NESTING = 5
+
+
+
[docs]def help_search_with_index(query, candidate_entries, suggestion_maxnum=5, fields=None): + """ + Lunr-powered fast index search and suggestion wrapper. See https://lunrjs.com/. + + Args: + query (str): The query to search for. + candidate_entries (list): This is the body of possible entities to search. Each + must have a property `.search_index_entry` that returns a dict with all + keys in the `fields` arg. + suggestion_maxnum (int): How many matches to allow at most in a multi-match. + fields (list, optional): A list of Lunr field mappings + ``{"field_name": str, "boost": int}``. See the Lunr documentation + for more details. The field name must exist in the dicts returned + by `.search_index_entry` of the candidates. If not given, a default setup + is used, prefering keys > aliases > category > tags. + Returns: + tuple: A tuple (matches, suggestions), each a list, where the `suggestion_maxnum` limits + how many suggestions are included. + + """ + global _LUNR, _LUNR_EXCEPTION, _LUNR_BUILDER_PIPELINE, _LUNR_GET_BUILDER + if not _LUNR: + # we have to delay-load lunr because it messes with logging if it's imported + # before twisted's logging has been set up + from lunr import get_default_builder as _LUNR_GET_BUILDER + from lunr import lunr as _LUNR + from lunr import stop_word_filter + from lunr.exceptions import QueryParseError as _LUNR_EXCEPTION + from lunr.stemmer import stemmer + + # from lunr.trimmer import trimmer + # pre-create a lunr index-builder pipeline where we've removed some of + # the stop-words from the default in lunr. + + stop_words = stop_word_filter.WORDS + + for ignore_word in _LUNR_STOP_WORD_FILTER_EXCEPTIONS: + try: + stop_words.remove(ignore_word) + except ValueError: + pass + + custom_stop_words_filter = stop_word_filter.generate_stop_word_filter(stop_words) + # _LUNR_BUILDER_PIPELINE = (trimmer, custom_stop_words_filter, stemmer) + _LUNR_BUILDER_PIPELINE = (custom_stop_words_filter, stemmer) + + indx = [cnd.search_index_entry for cnd in candidate_entries] + mapping = {indx[ix]["key"]: cand for ix, cand in enumerate(candidate_entries)} + + if not fields: + fields = [ + {"field_name": "key", "boost": 10}, + {"field_name": "aliases", "boost": 9}, + {"field_name": "category", "boost": 8}, + {"field_name": "tags", "boost": 5}, + ] + + # build the search index + builder = _LUNR_GET_BUILDER() + builder.pipeline.reset() + builder.pipeline.add(*_LUNR_BUILDER_PIPELINE) + + search_index = _LUNR(ref="key", fields=fields, documents=indx, builder=builder) + + try: + matches = search_index.search(query)[:suggestion_maxnum] + except _LUNR_EXCEPTION: + # this is a user-input problem + matches = [] + + # matches (objs), suggestions (strs) + return ( + [mapping[match["ref"]] for match in matches], + [str(match["ref"]) for match in matches], # + f" (score {match['score']})") # good debug + )
+ + +
[docs]def parse_entry_for_subcategories(entry): + """ + Parse a command docstring for special sub-category blocks: + + Args: + entry (str): A help entry to parse + + Returns: + dict: The dict is a mapping that splits the entry into subcategories. This + will always hold a key `None` for the main help entry and + zero or more keys holding the subcategories. Each is itself + a dict with a key `None` for the main text of that subcategory + followed by any sub-sub-categories down to a max-depth of 5. + + Example: + :: + + ''' + Main topic text + + # SUBTOPICS + + ## foo + + A subcategory of the main entry, accessible as `help topic foo` + (or using /, like `help topic/foo`) + + ## bar + + Another subcategory, accessed as `help topic bar` + (or `help topic/bar`) + + ### moo + + A subcategory of bar, accessed as `help bar moo` + (or `help bar/moo`) + + #### dum + + A subcategory of moo, accessed `help bar moo dum` + (or `help bar/moo/dum`) + + ''' + + This will result in this returned entry structure: + :: + + { + None: "Main topic text": + "foo": { + None: "main topic/foo text" + }, + "bar": { + None: "Main topic/bar text", + "moo": { + None: "topic/bar/moo text" + "dum": { + None: "topic/bar/moo/dum text" + } + } + } + } + + """ + topic, *subtopics = _RE_HELP_SUBTOPICS_START.split(entry, maxsplit=1) + structure = {None: topic.strip("\n")} + + if subtopics: + subtopics = subtopics[0] + else: + return structure + + keypath = [] + current_nesting = 0 + subtopic = None + + # from evennia import set_trace;set_trace() + for part in _RE_HELP_SUBTOPIC_SPLIT.split(subtopics.strip()): + subtopic_match = _RE_HELP_SUBTOPIC_PARSE.match(part.strip()) + if subtopic_match: + # a new sub(-sub..) category starts. + mdict = subtopic_match.groupdict() + subtopic = mdict["name"].lower().strip() + new_nesting = len(mdict["nesting"]) - 1 + + if new_nesting > MAX_SUBTOPIC_NESTING: + raise RuntimeError( + f"Can have max {MAX_SUBTOPIC_NESTING} levels of nested help subtopics." + ) + + nestdiff = new_nesting - current_nesting + if nestdiff < 0: + # jumping back up in nesting + for _ in range(abs(nestdiff) + 1): + try: + keypath.pop() + except IndexError: + pass + elif nestdiff == 0: + # don't add a deeper nesting but replace the current + try: + keypath.pop() + except IndexError: + pass + keypath.append(subtopic) + current_nesting = new_nesting + else: + # an entry belonging to a subtopic - find the nested location + dct = structure + if not keypath and subtopic is not None: + structure[subtopic] = part + else: + for key in keypath: + if key in dct: + dct = dct[key] + else: + dct[key] = {None: part} + return structure
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/locks/lockfuncs.html b/docs/latest/_modules/evennia/locks/lockfuncs.html new file mode 100644 index 0000000000..4c4a54cd1c --- /dev/null +++ b/docs/latest/_modules/evennia/locks/lockfuncs.html @@ -0,0 +1,798 @@ + + + + + + + + evennia.locks.lockfuncs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.locks.lockfuncs

+"""
+This module provides a set of permission lock functions for use
+with Evennia's permissions system.
+
+To call these locks, make sure this module is included in the
+settings tuple `PERMISSION_FUNC_MODULES` then define a lock on the form
+'<access_type>:func(args)' and add it to the object's lockhandler.
+Run the `access()` method of the handler to execute the lock check.
+
+Note that `accessing_obj` and `accessed_obj` can be any object type
+with a lock variable/field, so be careful to not expect
+a certain object type.
+
+"""
+
+
+from ast import literal_eval
+
+from django.conf import settings
+
+import evennia
+from evennia.utils import utils
+
+_PERMISSION_HIERARCHY = [pe.lower() for pe in settings.PERMISSION_HIERARCHY]
+# also accept different plural forms
+_PERMISSION_HIERARCHY_PLURAL = [
+    pe + "s" if not pe.endswith("s") else pe for pe in _PERMISSION_HIERARCHY
+]
+
+
+def _to_account(accessing_obj):
+    "Helper function. Makes sure an accessing object is an account object"
+    if utils.inherits_from(accessing_obj, "evennia.objects.objects.DefaultObject"):
+        # an object. Convert to account.
+        accessing_obj = accessing_obj.account
+    return accessing_obj
+
+
+# lock functions
+
+
+
[docs]def true(*args, **kwargs): + """ + Always returns True. + + """ + return True
+ + +
[docs]def all(*args, **kwargs): + return True
+ + +
[docs]def false(*args, **kwargs): + """ + Always returns False + + """ + return False
+ + +
[docs]def none(*args, **kwargs): + return False
+ + +
[docs]def superuser(*args, **kwargs): + return False
+ + +
[docs]def self(accessing_obj, accessed_obj, *args, **kwargs): + """ + Check if accessing_obj is the same as accessed_obj + + Usage: + self() + + This can be used to lock specifically only to + the same object that the lock is defined on. + """ + return accessing_obj == accessed_obj
+ + +
[docs]def perm(accessing_obj, accessed_obj, *args, **kwargs): + """ + The basic permission-checker. Ignores case. + + Usage: + perm(<permission>) + + where <permission> is the permission accessing_obj must + have in order to pass the lock. + + If the given permission is part of settings.PERMISSION_HIERARCHY, + permission is also granted to all ranks higher up in the hierarchy. + + If accessing_object is an Object controlled by an Account, the + permissions of the Account is used unless the Attribute _quell + is set to True on the Object. In this case however, the + LOWEST hieararcy-permission of the Account/Object-pair will be used + (this is order to avoid Accounts potentially escalating their own permissions + by use of a higher-level Object) + + For non-hierarchical permissions, a puppeted object's account is checked first, + followed by the puppet (unless quelled, when only puppet's access is checked). + + """ + # this allows the perm_above lockfunc to make use of this function too + try: + permission = args[0].lower() + perms_object = accessing_obj.permissions.all() + except (AttributeError, IndexError): + return False + + gtmode = kwargs.pop("_greater_than", False) + is_quell = False + + account = ( + utils.inherits_from(accessing_obj, "evennia.objects.objects.DefaultObject") + and accessing_obj.account + ) + # check object perms (note that accessing_obj could be an Account too) + perms_account = [] + if account: + perms_account = account.permissions.all() + is_quell = account.attributes.get("_quell") + + # Check hirarchy matches; handle both singular/plural forms in hierarchy + hpos_target = None + if permission in _PERMISSION_HIERARCHY: + hpos_target = _PERMISSION_HIERARCHY.index(permission) + elif permission.endswith("s") and permission[:-1] in _PERMISSION_HIERARCHY: + hpos_target = _PERMISSION_HIERARCHY.index(permission[:-1]) + if hpos_target is not None: + # hieratchy match + hpos_account = -1 + hpos_object = -1 + + if account: + # we have an account puppeting this object. We must check what perms it has + perms_account_single = [p[:-1] if p.endswith("s") else p for p in perms_account] + hpos_account = [ + hpos + for hpos, hperm in enumerate(_PERMISSION_HIERARCHY) + if hperm in perms_account_single + ] + hpos_account = hpos_account[-1] if hpos_account else -1 + + if not account or is_quell: + # only get the object-level perms if there is no account or quelling + perms_object_single = [p[:-1] if p.endswith("s") else p for p in perms_object] + hpos_object = [ + hpos + for hpos, hperm in enumerate(_PERMISSION_HIERARCHY) + if hperm in perms_object_single + ] + hpos_object = hpos_object[-1] if hpos_object else -1 + + if account and is_quell: + # quell mode: use smallest perm from account and object + if gtmode: + return hpos_target < min(hpos_account, hpos_object) + else: + return hpos_target <= min(hpos_account, hpos_object) + elif account: + # use account perm + if gtmode: + return hpos_target < hpos_account + else: + return hpos_target <= hpos_account + else: + # use object perm + if gtmode: + return hpos_target < hpos_object + else: + return hpos_target <= hpos_object + else: + # no hierarchy match - check direct matches + if account: + # account exists + if is_quell and permission in perms_object: + # if quelled, first check object + return True + elif permission in perms_account: + # unquelled - check account + return True + else: + # no account-pass, check object pass + return permission in perms_object + + elif permission in perms_object: + return True + + return False
+ + +
[docs]def perm_above(accessing_obj, accessed_obj, *args, **kwargs): + """ + Only allow objects with a permission *higher* in the permission + hierarchy than the one given. If there is no such higher rank, + it's assumed we refer to superuser. If no hierarchy is defined, + this function has no meaning and returns False. + """ + kwargs["_greater_than"] = True + return perm(accessing_obj, accessed_obj, *args, **kwargs)
+ + +
[docs]def pperm(accessing_obj, accessed_obj, *args, **kwargs): + """ + The basic permission-checker only for Account objects. Ignores case. + + Usage: + pperm(<permission>) + + where <permission> is the permission accessing_obj must + have in order to pass the lock. If the given permission + is part of _PERMISSION_HIERARCHY, permission is also granted + to all ranks higher up in the hierarchy. + """ + return perm(_to_account(accessing_obj), accessed_obj, *args, **kwargs)
+ + +
[docs]def pperm_above(accessing_obj, accessed_obj, *args, **kwargs): + """ + Only allow Account objects with a permission *higher* in the permission + hierarchy than the one given. If there is no such higher rank, + it's assumed we refer to superuser. If no hierarchy is defined, + this function has no meaning and returns False. + """ + return perm_above(_to_account(accessing_obj), accessed_obj, *args, **kwargs)
+ + +
[docs]def dbref(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + dbref(3) + + This lock type checks if the checking object + has a particular dbref. Note that this only + works for checking objects that are stored + in the database (e.g. not for commands) + """ + if not args: + return False + try: + dbr = int(args[0].strip().strip("#")) + except ValueError: + return False + if hasattr(accessing_obj, "dbid"): + return dbr == accessing_obj.dbid + return False
+ + +
[docs]def pdbref(accessing_obj, accessed_obj, *args, **kwargs): + """ + Same as dbref, but making sure accessing_obj is an account. + """ + return dbref(_to_account(accessing_obj), accessed_obj, *args, **kwargs)
+ + +
[docs]def id(accessing_obj, accessed_obj, *args, **kwargs): + "Alias to dbref" + return dbref(accessing_obj, accessed_obj, *args, **kwargs)
+ + +
[docs]def pid(accessing_obj, accessed_obj, *args, **kwargs): + "Alias to dbref, for Accounts" + return dbref(_to_account(accessing_obj), accessed_obj, *args, **kwargs)
+ + +# this is more efficient than multiple if ... elif statments +CF_MAPPING = { + "eq": lambda val1, val2: val1 == val2 or str(val1) == str(val2) or float(val1) == float(val2), + "gt": lambda val1, val2: float(val1) > float(val2), + "lt": lambda val1, val2: float(val1) < float(val2), + "ge": lambda val1, val2: float(val1) >= float(val2), + "le": lambda val1, val2: float(val1) <= float(val2), + "ne": lambda val1, val2: float(val1) != float(val2), + "default": lambda val1, val2: False, +} + + +
[docs]def attr(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + attr(attrname) + attr(attrname, value) + attr(attrname, value, compare=type) + + where compare's type is one of (eq,gt,lt,ge,le,ne) and signifies + how the value should be compared with one on accessing_obj (so + compare=gt means the accessing_obj must have a value greater than + the one given). + + Searches attributes *and* properties stored on the accessing_obj. + if accessing_obj has a property "obj", then this is used as + accessing_obj (this makes this usable for Commands too) + + The first form works like a flag - if the attribute/property + exists on the object, the value is checked for True/False. The + second form also requires that the value of the attribute/property + matches. Note that all retrieved values will be converted to + strings before doing the comparison. + """ + # deal with arguments + if not args: + return False + attrname = args[0].strip() + value = None + if len(args) > 1: + value = args[1].strip() + compare = "eq" + if kwargs: + compare = kwargs.get("compare", "eq") + + def valcompare(val1, val2, typ="eq"): + "compare based on type" + try: + return CF_MAPPING.get(typ, CF_MAPPING["default"])(val1, val2) + except Exception: + # this might happen if we try to compare two things that + # cannot be compared + return False + + if hasattr(accessing_obj, "obj"): + # NOTE: this is relevant for Commands. It may clash with scripts + # (they have Attributes and .obj) , but are scripts really + # used so that one ever wants to check the property on the + # Script rather than on its owner? + accessing_obj = accessing_obj.obj + + # first, look for normal properties on the object trying to gain access + if hasattr(accessing_obj, attrname): + if value: + return valcompare(str(getattr(accessing_obj, attrname)), value, compare) + # will return Fail on False value etc + return bool(getattr(accessing_obj, attrname)) + # check attributes, if they exist + if hasattr(accessing_obj, "attributes") and accessing_obj.attributes.has(attrname): + if value: + return hasattr(accessing_obj, "attributes") and valcompare( + accessing_obj.attributes.get(attrname), value, compare + ) + # fails on False/None values + return bool(accessing_obj.attributes.get(attrname)) + return False
+ + +
[docs]def objattr(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + objattr(attrname) + objattr(attrname, value) + objattr(attrname, value, compare=type) + + Works like attr, except it looks for an attribute on + accessed_obj instead. + + """ + return attr(accessed_obj, accessed_obj, *args, **kwargs)
+ + +
[docs]def locattr(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + locattr(attrname) + locattr(attrname, value) + locattr(attrname, value, compare=type) + + Works like attr, except it looks for an attribute on + accessing_obj.location, if such an entity exists. + + if accessing_obj has a property ".obj" (such as is the case for a + Command), then accessing_obj.obj.location is used instead. + + """ + if hasattr(accessing_obj, "obj"): + accessing_obj = accessing_obj.obj + if hasattr(accessing_obj, "location"): + return attr(accessing_obj.location, accessed_obj, *args, **kwargs) + return False
+ + +
[docs]def objlocattr(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + locattr(attrname) + locattr(attrname, value) + locattr(attrname, value, compare=type) + + Works like attr, except it looks for an attribute on + accessed_obj.location, if such an entity exists. + + if accessed_obj has a property ".obj" (such as is the case for a + Command), then accessing_obj.obj.location is used instead. + + """ + if hasattr(accessed_obj, "obj"): + accessed_obj = accessed_obj.obj + if hasattr(accessed_obj, "location"): + return attr(accessed_obj.location, accessed_obj, *args, **kwargs) + return False
+ + +
[docs]def attr_eq(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + attr_gt(attrname, 54) + """ + return attr(accessing_obj, accessed_obj, *args, **kwargs)
+ + +
[docs]def attr_gt(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + attr_gt(attrname, 54) + + Only true if access_obj's attribute > the value given. + """ + return attr(accessing_obj, accessed_obj, *args, **{"compare": "gt"})
+ + +
[docs]def attr_ge(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + attr_gt(attrname, 54) + + Only true if access_obj's attribute >= the value given. + """ + return attr(accessing_obj, accessed_obj, *args, **{"compare": "ge"})
+ + +
[docs]def attr_lt(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + attr_gt(attrname, 54) + + Only true if access_obj's attribute < the value given. + """ + return attr(accessing_obj, accessed_obj, *args, **{"compare": "lt"})
+ + +
[docs]def attr_le(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + attr_gt(attrname, 54) + + Only true if access_obj's attribute <= the value given. + """ + return attr(accessing_obj, accessed_obj, *args, **{"compare": "le"})
+ + +
[docs]def attr_ne(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + attr_gt(attrname, 54) + + Only true if access_obj's attribute != the value given. + """ + return attr(accessing_obj, accessed_obj, *args, **{"compare": "ne"})
+ + +
[docs]def tag(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + tag(tagkey) + tag(tagkey, category) + + Only true if accessing_obj has the specified tag and optional + category. + If accessing_obj has the ".obj" property (such as is the case for + a command), then accessing_obj.obj is used instead. + + """ + if hasattr(accessing_obj, "obj"): + accessing_obj = accessing_obj.obj + tagkey = args[0] if args else None + category = args[1] if len(args) > 1 else None + return bool(accessing_obj.tags.get(tagkey, category=category))
+ + +def objtag(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + objtag(tagkey) + objtag(tagkey, category): + + Only true if `accessed_obj` has the given tag and optional category. + + """ + return tag(accessed_obj, None, *args, **kwargs) + + +
[docs]def objloctag(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + objloctag(tagkey) + objloctag(tagkey, category): + + Only true if `accessed_obj.location` has the given tag and optional category. + If obj has no location, this lockfunc fails. + + """ + try: + return tag(accessed_obj.location, None, *args, **kwargs) + except AttributeError: + return False
+ + +
[docs]def is_ooc(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + is_ooc() + + This is normally used to lock a Command, so it can be used + only when out of character. When not logged in at all, this + function will still return True. + """ + obj = accessed_obj.obj if hasattr(accessed_obj, "obj") else accessed_obj + account = obj.account if utils.inherits_from(obj, evennia.DefaultObject) else obj + if not account: + return True + try: + session = accessed_obj.session + except AttributeError: + # note-this doesn't work well + # for high multisession mode. We may need + # to change to sessiondb to resolve this + sessions = session = account.sessions.get() + session = sessions[0] if sessions else None + if not session: + # this suggests we are not even logged in; treat as ooc. + return True + try: + return not account.get_puppet(session) + except TypeError: + return not session.get_puppet()
+ + +
[docs]def objtag(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + objtag(tagkey) + objtag(tagkey, category) + + Only true if accessed_obj has the specified tag and optional + category. + """ + tagkey = args[0] if args else None + category = args[1] if len(args) > 1 else None + return bool(accessed_obj.tags.get(tagkey, category=category))
+ + +
[docs]def inside(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + inside() + + True if accessing_obj is 'inside' accessing_obj. Note that this only checks + one level down. So if if the lock is on a room, you will pass but not your + inventory (since their location is you, not the locked object). If you + want also nested objects to pass the lock, use the `insiderecursive` + lockfunc. + """ + if hasattr(accessed_obj, "obj"): + accessed_obj = accessed_obj.obj + return accessing_obj.location == accessed_obj
+ + +
[docs]def inside_rec(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + inside_rec() + + True if accessing_obj is inside the accessed obj, at up to 10 levels + of recursion (so if this lock is on a room, then an object inside a box + in your inventory will also pass the lock). + """ + + def _recursive_inside(obj, accessed_obj, lvl=1): + if obj.location: + if obj.location == accessed_obj: + return True + elif lvl >= 10: + # avoid infinite recursions + return False + else: + return _recursive_inside(obj.location, accessed_obj, lvl + 1) + return False + + return _recursive_inside(accessing_obj, accessed_obj)
+ + +
[docs]def holds(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + holds() checks if accessed_obj or accessed_obj.obj + is held by accessing_obj + holds(key/dbref) checks if accessing_obj holds an object + with given key/dbref + holds(attrname, value) checks if accessing_obj holds an + object with the given attrname and value + + This is passed if accessed_obj is carried by accessing_obj (that is, + accessed_obj.location == accessing_obj), or if accessing_obj itself holds + an object matching the given key. + """ + try: + # commands and scripts don't have contents, so we are usually looking + # for the contents of their .obj property instead (i.e. the object the + # command/script is attached to). + contents = accessing_obj.contents + except AttributeError: + try: + contents = accessing_obj.obj.contents + except AttributeError: + return False + + def check_holds(objid): + # helper function. Compares both dbrefs and keys/aliases. + objid = str(objid) + dbref = utils.dbref(objid, reqhash=False) + if dbref and any((True for obj in contents if obj.dbid == dbref)): + return True + objid = objid.lower() + return any( + ( + True + for obj in contents + if obj.key.lower() == objid or objid in [al.lower() for al in obj.aliases.all()] + ) + ) + + if not args: + # holds() - check if accessed_obj or accessed_obj.obj is held by accessing_obj + try: + if check_holds(accessed_obj.dbid): + return True + except Exception: + # we need to catch any trouble here + pass + return hasattr(accessed_obj, "obj") and check_holds(accessed_obj.obj.dbid) + if len(args) == 1: + # command is holds(dbref/key) - check if given objname/dbref is held by accessing_ob + return check_holds(args[0]) + elif len(args) > 1: + # command is holds(attrname, value) check if any held object has the given attribute and value + for obj in contents: + if obj.attributes.get(args[0]) == args[1]: + return True + return False
+ + +
[docs]def has_account(accessing_obj, accessed_obj, *args, **kwargs): + """ + Only returns true if accessing_obj has_account is true, that is, + this is an account-controlled object. It fails on actual accounts! + + This is a useful lock for traverse-locking Exits to restrain NPC + mobiles from moving outside their areas. + """ + return utils.inherits_from(accessing_obj, evennia.DefaultObject) and accessing_obj.has_account
+ + +
[docs]def serversetting(accessing_obj, accessed_obj, *args, **kwargs): + """ + Only returns true if the Evennia settings exists, alternatively has + a certain value. + + Usage: + serversetting(IRC_ENABLED) + serversetting(BASE_SCRIPT_PATH, ['types']) + + A given True/False or integers will be converted properly. Note that + everything will enter this function as strings, so they have to be + unpacked to their real value. We only support basic properties. + """ + if not args or not args[0]: + return False + if len(args) < 2: + setting = args[0] + val = "True" + else: + setting, val = args[0], args[1] + # convert + try: + val = literal_eval(val) + except Exception: + # we swallow errors here, lockfuncs has noone to report to + return False + + if setting in settings._wrapped.__dict__: + return settings._wrapped.__dict__[setting] == val + return False
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/locks/lockhandler.html b/docs/latest/_modules/evennia/locks/lockhandler.html new file mode 100644 index 0000000000..2ebb02450b --- /dev/null +++ b/docs/latest/_modules/evennia/locks/lockhandler.html @@ -0,0 +1,905 @@ + + + + + + + + evennia.locks.lockhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.locks.lockhandler

+"""
+A *lock* defines access to a particular subsystem or property of
+Evennia. For example, the "owner" property can be impmemented as a
+lock. Or the disability to lift an object or to ban users.
+
+
+A lock consists of three parts:
+
+ - access_type - this defines what kind of access this lock regulates. This
+   just a string.
+ - function call - this is one or many calls to functions that will determine
+   if the lock is passed or not.
+ - lock function(s). These are regular python functions with a special
+   set of allowed arguments. They should always return a boolean depending
+   on if they allow access or not.
+
+A lock function is defined by existing in one of the modules
+listed by settings.LOCK_FUNC_MODULES. It should also always
+take four arguments looking like this:
+
+   funcname(accessing_obj, accessed_obj, *args, **kwargs):
+        [...]
+
+The accessing object is the object wanting to gain access.
+The accessed object is the object this lock resides on
+args and kwargs will hold optional arguments and/or keyword arguments
+to the function as a list and a dictionary respectively.
+
+Example:
+
+   perm(accessing_obj, accessed_obj, *args, **kwargs):
+       "Checking if the object has a particular, desired permission"
+       if args:
+           desired_perm = args[0]
+           return desired_perm in accessing_obj.permissions.all()
+       return False
+
+Lock functions should most often be pretty general and ideally possible to
+re-use and combine in various ways to build clever locks.
+
+
+
+Lock definition ("Lock string")
+
+A lock definition is a string with a special syntax. It is added to
+each object's lockhandler, making that lock available from then on.
+
+The lock definition looks like this:
+
+ 'access_type:[NOT] func1(args)[ AND|OR][NOT] func2() ...'
+
+That is, the access_type, a colon followed by calls to lock functions
+combined with AND or OR. NOT negates the result of the following call.
+
+Example:
+
+ We want to limit who may edit a particular object (let's call this access_type
+for 'edit', it depends on what the command is looking for). We want this to
+only work for those with the Permission 'Builder'. So we use our lock
+function above and define it like this:
+
+  'edit:perm(Builder)'
+
+Here, the lock-function perm() will be called with the string
+'Builder' (accessing_obj and accessed_obj are added automatically,
+you only need to add the args/kwargs, if any).
+
+If we wanted to make sure the accessing object was BOTH a Builder and a
+GoodGuy, we could use AND:
+
+  'edit:perm(Builder) AND perm(GoodGuy)'
+
+To allow EITHER Builder and GoodGuys, we replace AND with OR. perm() is just
+one example, the lock function can do anything and compare any properties of
+the calling object to decide if the lock is passed or not.
+
+  'lift:attrib(very_strong) AND NOT attrib(bad_back)'
+
+To make these work, add the string to the lockhandler of the object you want
+to apply the lock to:
+
+  obj.lockhandler.add('edit:perm(Builder)')
+
+From then on, a command that wants to check for 'edit' access on this
+object would do something like this:
+
+  if not target_obj.lockhandler.has_perm(caller, 'edit'):
+      caller.msg("Sorry, you cannot edit that.")
+
+All objects also has a shortcut called 'access' that is recommended to
+use instead:
+
+  if not target_obj.access(caller, 'edit'):
+      caller.msg("Sorry, you cannot edit that.")
+
+
+Permissions
+
+Permissions are just text strings stored in a comma-separated list on
+typeclassed objects. The default perm() lock function uses them,
+taking into account settings.PERMISSION_HIERARCHY. Also, the
+restricted @perm command sets them, but otherwise they are identical
+to any other identifier you can use.
+
+"""
+
+import re
+
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+import evennia
+from evennia.utils import logger, utils
+
+__all__ = ("LockHandler", "LockException")
+
+WARNING_LOG = settings.LOCKWARNING_LOG_FILE
+_LOCK_HANDLER = None
+
+
+#
+# Exception class. This will be raised
+# by errors in lock definitions.
+#
+
+
+
[docs]class LockException(Exception): + """ + Raised during an error in a lock. + + """ + + pass
+ + +# +# Cached lock functions +# + +_LOCKFUNCS = {} + + +def _cache_lockfuncs(): + """ + Updates the cache. + + """ + global _LOCKFUNCS + _LOCKFUNCS = {} + for modulepath in settings.LOCK_FUNC_MODULES: + _LOCKFUNCS.update(utils.callables_from_module(modulepath)) + + +# +# pre-compiled regular expressions +# + + +_RE_FUNCS = re.compile(r"\w+\([^)]*\)") +_RE_SEPS = re.compile(r"(?<=[ )])AND(?=\s)|(?<=[ )])OR(?=\s)|(?<=[ )])NOT(?=\s)") +_RE_OK = re.compile(r"%s|and|or|not") + + +# +# +# Lock handler +# +# + + +
[docs]class LockHandler: + """ + This handler should be attached to all objects implementing + permission checks, under the property 'lockhandler'. + + """ + +
[docs] def __init__(self, obj): + """ + Loads and pre-caches all relevant locks and their functions. + + Args: + obj (object): The object on which the lockhandler is + defined. + + """ + if not _LOCKFUNCS: + _cache_lockfuncs() + self.obj = obj + self.locks = {} + try: + self.reset() + except LockException as err: + logger.log_trace(err)
+ + def __str__(self): + return ";".join(self.locks[key][2] for key in sorted(self.locks)) + + def _log_error(self, message): + "Try to log errors back to object" + raise LockException(message) + + def _parse_lockstring(self, storage_lockstring): + """ + Helper function. This is normally only called when the + lockstring is cached and does preliminary checking. locks are + stored as a string + + atype:[NOT] lock()[[ AND|OR [NOT] lock()[...]];atype... + + Args: + storage_locksring (str): The lockstring to parse. + + """ + locks = {} + if not storage_lockstring: + return locks + duplicates = 0 + elist = [] # errors + wlist = [] # warnings + for raw_lockstring in storage_lockstring.split(";"): + if not raw_lockstring: + continue + lock_funcs = [] + try: + access_type, rhs = (part.strip() for part in raw_lockstring.split(":", 1)) + except ValueError: + logger.log_trace() + return locks + + # parse the lock functions and separators + funclist = _RE_FUNCS.findall(rhs) + evalstring = rhs + for pattern in ("AND", "OR", "NOT"): + evalstring = re.sub(r"\b%s\b" % pattern, pattern.lower(), evalstring) + nfuncs = len(funclist) + for funcstring in funclist: + funcname, rest = (part.strip().strip(")") for part in funcstring.split("(", 1)) + func = _LOCKFUNCS.get(funcname, None) + if not callable(func): + elist.append( + _("Lock: lock-function '{lockfunc}' is not available.").format( + lockfunc=funcstring + ) + ) + continue + args = list(arg.strip() for arg in rest.split(",") if arg and "=" not in arg) + kwargs = dict( + [ + (part.strip() for part in arg.split("=", 1)) + for arg in rest.split(",") + if arg and "=" in arg + ] + ) + lock_funcs.append((func, args, kwargs)) + evalstring = evalstring.replace(funcstring, "%s") + if len(lock_funcs) < nfuncs: + continue + try: + # purge the eval string of any superfluous items, then test it + evalstring = " ".join(_RE_OK.findall(evalstring)) + eval(evalstring % tuple(True for func in funclist), {}, {}) + except Exception: + elist.append( + _("Lock: definition '{lock_string}' has syntax errors.").format( + lock_string=raw_lockstring + ) + ) + continue + if access_type in locks: + duplicates += 1 + wlist.append( + _( + "LockHandler on {obj}: access type '{access_type}' " + "changed from '{source}' to '{goal}' ".format( + obj=self.obj, + access_type=access_type, + source=locks[access_type][2], + goal=raw_lockstring, + ) + ) + ) + locks[access_type] = (evalstring, tuple(lock_funcs), raw_lockstring) + if wlist and WARNING_LOG: + # a warning text was set, it's not an error, so only report + logger.log_file("\n".join(wlist), WARNING_LOG) + if elist: + # an error text was set, raise exception. + raise LockException("\n".join(elist)) + # return the gathered locks in an easily executable form + return locks + + def _cache_locks(self, storage_lockstring): + """ + Store data + + """ + self.locks = self._parse_lockstring(storage_lockstring) + + def _save_locks(self): + """ + Store locks to obj + + """ + self.obj.lock_storage = ";".join([tup[2] for tup in self.locks.values()]) + +
[docs] def cache_lock_bypass(self, obj): + """ + We cache superuser bypass checks here for efficiency. This + needs to be re-run when an account is assigned to a character. + We need to grant access to superusers. We need to check both + directly on the object (accounts), through obj.account and using + the get_account() method (this sits on serversessions, in some + rare cases where a check is done before the login process has + yet been fully finalized) + + Args: + obj (object): This is checked for the `is_superuser` property. + + """ + self.lock_bypass = hasattr(obj, "is_superuser") and obj.is_superuser
+ +
[docs] def add(self, lockstring, validate_only=False): + """ + Add a new lockstring to handler. + + Args: + lockstring (str or list): A string on the form + `"<access_type>:<functions>"`. Multiple access types + should be separated by semicolon (`;`). Alternatively, + a list with lockstrings. + validate_only (bool, optional): If True, validate the lockstring but + don't actually store it. + Returns: + success (bool): The outcome of the addition, `False` on + error. If `validate_only` is True, this will be a tuple + (bool, error), for pass/fail and a string error. + + """ + if isinstance(lockstring, str): + lockdefs = [ + stripped for lockdef in lockstring.split(";") if (stripped := lockdef.strip()) + ] + else: + lockdefs = [ + stripped + for locks in lockstring + for lockdef in locks.split(";") + if (stripped := lockdef.strip()) + ] + lockstring = ";".join(lockdefs) + + err = "" + # sanity checks + for lockdef in lockdefs: + if ":" not in lockdef: + err = _("Lock: '{lockdef}' contains no colon (:).").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False + access_type, rhs = [part.strip() for part in lockdef.split(":", 1)] + if not access_type: + err = _( + "Lock: '{lockdef}' has no access_type " "(left-side of colon is empty)." + ).format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False + if rhs.count("(") != rhs.count(")"): + err = _("Lock: '{lockdef}' has mismatched parentheses.").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False + if not _RE_FUNCS.findall(rhs): + err = _("Lock: '{lockdef}' has no valid lock functions.").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False + if validate_only: + return True, None + # get the lock string + storage_lockstring = self.obj.lock_storage + if storage_lockstring: + storage_lockstring = storage_lockstring + ";" + lockstring + else: + storage_lockstring = lockstring + # cache the locks will get rid of eventual doublets + self._cache_locks(storage_lockstring) + self._save_locks() + return True
+ +
[docs] def validate(self, lockstring): + """ + Validate lockstring syntactically, without saving it. + + Args: + lockstring (str): Lockstring to validate. + Returns: + valid (bool): If validation passed or not. + + """ + return self.add(lockstring, validate_only=True)
+ +
[docs] def replace(self, lockstring): + """ + Replaces the lockstring entirely. + + Args: + lockstring (str): The new lock definition. + + Return: + success (bool): False if an error occurred. + + Raises: + LockException: If a critical error occurred. + If so, the old string is recovered. + + """ + old_lockstring = str(self) + self.clear() + try: + return self.add(lockstring) + except LockException: + self.add(old_lockstring) + raise
+ +
[docs] def get(self, access_type=None): + """ + Get the full lockstring or the lockstring of a particular + access type. + + Args: + access_type (str, optional): + + Returns: + lockstring (str): The matched lockstring, or the full + lockstring if no access_type was given. + """ + + if access_type: + return self.locks.get(access_type, ["", "", ""])[2] + return str(self)
+ +
[docs] def all(self): + """ + Return all lockstrings + + Returns: + lockstrings (list): All separate lockstrings + + """ + return str(self).split(";")
+ +
[docs] def remove(self, access_type): + """ + Remove a particular lock from the handler + + Args: + access_type (str): The type of lock to remove. + + Returns: + success (bool): If the access_type was not found + in the lock, this returns `False`. + + """ + if access_type in self.locks: + del self.locks[access_type] + self._save_locks() + return True + return False
+ + delete = remove # alias for historical reasons + +
[docs] def clear(self): + """ + Remove all locks in the handler. + + """ + self.locks = {} + self.lock_storage = "" + self._save_locks()
+ +
[docs] def reset(self): + """ + Set the reset flag, so the the lock will be re-cached at next + checking. This is usually called by @reload. + + """ + self._cache_locks(self.obj.lock_storage) + self.cache_lock_bypass(self.obj)
+ +
[docs] def append(self, access_type, lockstring, op="or"): + """ + Append a lock definition to access_type if it doesn't already exist. + + Args: + access_type (str): Access type. + lockstring (str): A valid lockstring, without the operator to + link it to an eventual existing lockstring. + op (str): An operator 'and', 'or', 'and not', 'or not' used + for appending the lockstring to an existing access-type. + Note: + The most common use of this method is for use in commands where + the user can specify their own lockstrings. This method allows + the system to auto-add things like Admin-override access. + + """ + old_lockstring = self.get(access_type) + if not lockstring.strip().lower() in old_lockstring.lower(): + lockstring = "{old} {op} {new}".format( + old=old_lockstring, op=op, new=lockstring.strip() + ) + self.add(lockstring)
+ +
[docs] def check(self, accessing_obj, access_type, default=False, no_superuser_bypass=False): + """ + Checks a lock of the correct type by passing execution off to + the lock function(s). + + Args: + accessing_obj (object): The object seeking access. + access_type (str): The type of access wanted. + default (bool, optional): If no suitable lock type is + found, default to this result. + no_superuser_bypass (bool): Don't use this unless you + really, really need to, it makes supersusers susceptible + to the lock check. + + Notes: + A lock is executed in the follwoing way: + + Parsing the lockstring, we (during cache) extract the valid + lock functions and store their function objects in the right + order along with their args/kwargs. These are now executed in + sequence, creating a list of True/False values. This is put + into the evalstring, which is a string of AND/OR/NOT entries + separated by placeholders where each function result should + go. We just put those results in and evaluate the string to + get a final, combined True/False value for the lockstring. + + The important bit with this solution is that the full + lockstring is never blindly evaluated, and thus there (should + be) no way to sneak in malign code in it. Only "safe" lock + functions (as defined by your settings) are executed. + + """ + try: + # check if the lock should be bypassed (e.g. superuser status) + if accessing_obj.locks.lock_bypass and not no_superuser_bypass: + return True + except AttributeError: + # happens before session is initiated. + if not no_superuser_bypass and ( + (hasattr(accessing_obj, "is_superuser") and accessing_obj.is_superuser) + or ( + utils.inherits_from(accessing_obj, evennia.DefaultObject) + and hasattr(accessing_obj.account, "is_superuser") + and accessing_obj.account.is_superuser + ) + or ( + hasattr(accessing_obj, "get_account") + and ( + not accessing_obj.get_account() or accessing_obj.get_account().is_superuser + ) + ) + ): + return True + + # no superuser or bypass -> normal lock operation + if access_type in self.locks: + # we have a lock, test it. + evalstring, func_tup, raw_string = self.locks[access_type] + # execute all lock funcs in the correct order, producing a tuple of True/False results. + true_false = tuple( + bool(tup[0](accessing_obj, self.obj, *tup[1], access_type=access_type, **tup[2])) + for tup in func_tup + ) + # the True/False tuple goes into evalstring, which combines them + # with AND/OR/NOT in order to get the final result. + return eval(evalstring % true_false) + else: + return default
+ + def _eval_access_type(self, accessing_obj, locks, access_type): + """ + Helper method for evaluating the access type using eval(). + + Args: + accessing_obj (object): Object seeking access. + locks (dict): The pre-parsed representation of all access-types. + access_type (str): An access-type key to evaluate. + + """ + evalstring, func_tup, raw_string = locks[access_type] + true_false = tuple(tup[0](accessing_obj, self.obj, *tup[1], **tup[2]) for tup in func_tup) + return eval(evalstring % true_false) + +
[docs] def check_lockstring( + self, accessing_obj, lockstring, no_superuser_bypass=False, default=False, access_type=None + ): + """ + Do a direct check against a lockstring ('atype:func()..'), + without any intermediary storage on the accessed object. + + Args: + accessing_obj (object or None): The object seeking access. + Importantly, this can be left unset if the lock functions + don't access it, no updating or storage of locks are made + against this object in this method. + lockstring (str): Lock string to check, on the form + `"access_type:lock_definition"` where the `access_type` + part can potentially be set to a dummy value to just check + a lock condition. + no_superuser_bypass (bool, optional): Force superusers to heed lock. + default (bool, optional): Fallback result to use if `access_type` is set + but no such `access_type` is found in the given `lockstring`. + access_type (str, bool): If set, only this access_type will be looked up + among the locks defined by `lockstring`. + + Return: + access (bool): If check is passed or not. + + """ + try: + if accessing_obj.locks.lock_bypass and not no_superuser_bypass: + return True + except AttributeError: + if no_superuser_bypass and ( + (hasattr(accessing_obj, "is_superuser") and accessing_obj.is_superuser) + or ( + utils.inherits_from(accessing_obj, evennia.DefaultObject) + and hasattr(accessing_obj.account, "is_superuser") + and accessing_obj.account.is_superuser + ) + or ( + hasattr(accessing_obj, "get_account") + and ( + not accessing_obj.get_account() or accessing_obj.get_account().is_superuser + ) + ) + ): + return True + if ":" not in lockstring: + lockstring = "%s:%s" % ("_dummy", lockstring) + + locks = self._parse_lockstring(lockstring) + + if access_type: + if access_type not in locks: + return default + else: + return self._eval_access_type(accessing_obj, locks, access_type) + else: + # if no access types was given and multiple locks were + # embedded in the lockstring we assume all must be true + return all( + self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks + )
+ + +# convenience access function + +# dummy to be able to call check_lockstring from the outside + + +class _ObjDummy: + lock_storage = "" + + +def check_lockstring( + accessing_obj, lockstring, no_superuser_bypass=False, default=False, access_type=None +): + """ + Do a direct check against a lockstring ('atype:func()..'), + without any intermediary storage on the accessed object. + + Args: + accessing_obj (object or None): The object seeking access. + Importantly, this can be left unset if the lock functions + don't access it, no updating or storage of locks are made + against this object in this method. + lockstring (str): Lock string to check, on the form + `"access_type:lock_definition"` where the `access_type` + part can potentially be set to a dummy value to just check + a lock condition. + no_superuser_bypass (bool, optional): Force superusers to heed lock. + default (bool, optional): Fallback result to use if `access_type` is set + but no such `access_type` is found in the given `lockstring`. + access_type (str, bool): If set, only this access_type will be looked up + among the locks defined by `lockstring`. + + Return: + access (bool): If check is passed or not. + + """ + global _LOCK_HANDLER + if not _LOCK_HANDLER: + _LOCK_HANDLER = LockHandler(_ObjDummy()) + return _LOCK_HANDLER.check_lockstring( + accessing_obj, + lockstring, + no_superuser_bypass=no_superuser_bypass, + default=default, + access_type=access_type, + ) + + +def check_perm(obj, permission, no_superuser_bypass=False): + """ + Shortcut for checking if an object has the given `permission`. If the + permission is in `settings.PERMISSION_HIERARCHY`, the check passes + if the object has this permission or higher. + + This is equivalent to calling the perm() lockfunc, but without needing + an accessed object. + + Args: + obj (Object, Account): The object to check access. If this has a linked + Account, the account is checked instead (same rules as per perm()). + permission (str): The permission string to check. + no_superuser_bypass (bool, optional): If unset, the superuser + will always pass this check. + + """ + from evennia.locks.lockfuncs import perm + + if not no_superuser_bypass and obj.is_superuser: + return True + return perm(obj, None, permission) + + +def validate_lockstring(lockstring): + """ + Validate so lockstring is on a valid form. + + Args: + lockstring (str): Lockstring to validate. + + Returns: + is_valid (bool): If the lockstring is valid or not. + error (str or None): A string describing the error, or None + if no error was found. + + """ + global _LOCK_HANDLER + if not _LOCK_HANDLER: + _LOCK_HANDLER = LockHandler(_ObjDummy()) + return _LOCK_HANDLER.validate(lockstring) + + +def get_all_lockfuncs(): + """ + Get a dict of available lock funcs. + + Returns: + lockfuncs (dict): Mapping {lockfuncname:func}. + + """ + if not _LOCKFUNCS: + _cache_lockfuncs() + return _LOCKFUNCS + + +def _test(): + # testing + + class TestObj(object): + pass + + import pdb + + obj1 = TestObj() + obj2 = TestObj() + + # obj1.lock_storage = "owner:dbref(#4);edit:dbref(#5) or perm(Admin);examine:perm(Builder);delete:perm(Admin);get:all()" + # obj1.lock_storage = "cmd:all();admin:id(1);listen:all();send:all()" + obj1.lock_storage = "listen:perm(Developer)" + + pdb.set_trace() + obj1.locks = LockHandler(obj1) + obj2.permissions.add("Developer") + obj2.id = 4 + + # obj1.locks.add("edit:attr(test)") + + print("comparing obj2.permissions (%s) vs obj1.locks (%s)" % (obj2.permissions, obj1.locks)) + print(obj1.locks.check(obj2, "owner")) + print(obj1.locks.check(obj2, "edit")) + print(obj1.locks.check(obj2, "examine")) + print(obj1.locks.check(obj2, "delete")) + print(obj1.locks.check(obj2, "get")) + print(obj1.locks.check(obj2, "listen")) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/objects/manager.html b/docs/latest/_modules/evennia/objects/manager.html new file mode 100644 index 0000000000..2eb4034ef0 --- /dev/null +++ b/docs/latest/_modules/evennia/objects/manager.html @@ -0,0 +1,872 @@ + + + + + + + + evennia.objects.manager — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.objects.manager

+"""
+Custom manager for Objects.
+"""
+import re
+
+from django.conf import settings
+from django.db.models import Q
+from django.db.models.fields import exceptions
+from evennia.server import signals
+from evennia.typeclasses.managers import TypeclassManager, TypedObjectManager
+from evennia.utils.utils import (
+    class_from_module,
+    dbid_to_obj,
+    is_iter,
+    make_iter,
+    string_partial_matching,
+)
+
+__all__ = ("ObjectManager", "ObjectDBManager")
+_GA = object.__getattribute__
+
+# delayed import
+_ATTR = None
+
+_MULTIMATCH_REGEX = re.compile(settings.SEARCH_MULTIMATCH_REGEX, re.I + re.U)
+
+# Try to use a custom way to parse id-tagged multimatches.
+
+
+
[docs]class ObjectDBManager(TypedObjectManager): + """ + This ObjectManager implements methods for searching + and manipulating Objects directly from the database. + + Evennia-specific search methods (will return Typeclasses or + lists of Typeclasses, whereas Django-general methods will return + Querysets or database objects). + + dbref (converter) + dbref_search + get_dbref_range + object_totals + typeclass_search + get_object_with_account + get_objs_with_key_and_typeclass + get_objs_with_attr + get_objs_with_attr_match + get_objs_with_db_property + get_objs_with_db_property_match + get_objs_with_key_or_alias + get_contents + search_object (interface to many of the above methods, + equivalent to evennia.search_object) + copy_object + + """ + + # + # ObjectManager Get methods + # + + # account related + +
[docs] def get_object_with_account(self, ostring, exact=True, candidates=None): + """ + Search for an object based on its account's name or dbref. + + Args: + ostring (str or int): Search criterion or dbref. Searching + for an account is sometimes initiated by appending an `*` to + the beginning of the search criterion (e.g. in + local_and_global_search). This is stripped here. + exact (bool, optional): Require an exact account match. + candidates (list, optional): Only search among this list of possible + object candidates. + + Return: + match (query): Matching query. + + """ + ostring = str(ostring).lstrip("*") + # simplest case - search by dbref + dbref = self.dbref(ostring) + if dbref: + try: + return self.get(db_account__id=dbref) + except self.model.DoesNotExist: + pass + + # not a dbref. Search by name. + cand_restriction = ( + candidates is not None + and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) + or Q() + ) + if exact: + return self.filter(cand_restriction & Q(db_account__username__iexact=ostring)).order_by( + "id" + ) + else: # fuzzy matching + obj_cands = self.select_related().filter( + cand_restriction & Q(db_account__username__istartswith=ostring) + ) + acct_cands = [obj.account for obj in obj_cands] + + if obj_cands: + index_matches = string_partial_matching( + [acct.key for acct in acct_cands], ostring, ret_index=True + ) + acct_cands = [acct_cands[i].id for i in index_matches] + return obj_cands.filter(db_account__id__in=acct_cands).order_by("id")
+ +
[docs] def get_objs_with_key_and_typeclass(self, oname, otypeclass_path, candidates=None): + """ + Returns objects based on simultaneous key and typeclass match. + + Args: + oname (str): Object key to search for + otypeclass_path (str): Full Python path to tyepclass to search for + candidates (list, optional): Only match among the given list of candidates. + + Returns: + matches (query): The matching objects. + """ + cand_restriction = ( + candidates is not None + and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) + or Q() + ) + return self.filter( + cand_restriction & Q(db_key__iexact=oname, db_typeclass_path__exact=otypeclass_path) + ).order_by("id")
+ + # attr/property related + +
[docs] def get_objs_with_attr(self, attribute_name, candidates=None): + """ + Get objects based on having a certain Attribute defined. + + Args: + attribute_name (str): Attribute name to search for. + candidates (list, optional): Only match among the given list of object + candidates. + + Returns: + matches (query): All objects having the given attribute_name defined at all. + + """ + cand_restriction = ( + candidates is not None and Q(id__in=[obj.id for obj in candidates]) or Q() + ) + return self.filter(cand_restriction & Q(db_attributes__db_key=attribute_name)).order_by( + "id" + )
+ +
[docs] def get_objs_with_attr_value( + self, attribute_name, attribute_value, candidates=None, typeclasses=None + ): + """ + Get all objects having the given attrname set to the given value. + + Args: + attribute_name (str): Attribute key to search for. + attribute_value (any): Attribute value to search for. This can also be database + objects. + candidates (list, optional): Candidate objects to limit search to. + typeclasses (list, optional): Python pats to restrict matches with. + + Returns: + Queryset: Iterable with 0, 1 or more matches fullfilling both the `attribute_name` and + `attribute_value` criterions. + + Notes: + This uses the Attribute's PickledField to transparently search the database by matching + the internal representation. This is reasonably effective but since Attribute values + cannot be indexed, searching by Attribute key is to be preferred whenever possible. + + """ + cand_restriction = ( + candidates is not None + and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) + or Q() + ) + type_restriction = typeclasses and Q(db_typeclass_path__in=make_iter(typeclasses)) or Q() + + results = self.filter( + cand_restriction + & type_restriction + & Q(db_attributes__db_key=attribute_name) + & Q(db_attributes__db_value=attribute_value) + ).order_by("id") + return results
+ +
[docs] def get_objs_with_db_property(self, property_name, candidates=None): + """ + Get all objects having a given db field property. + + Args: + property_name (str): The name of the field to match for. + candidates (list, optional): Only search among th egiven candidates. + + Returns: + matches (list): The found matches. + + """ + property_name = "db_%s" % property_name.lstrip("db_") + cand_restriction = ( + candidates is not None + and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) + or Q() + ) + querykwargs = {property_name: None} + try: + return list(self.filter(cand_restriction).exclude(Q(**querykwargs)).order_by("id")) + except exceptions.FieldError: + return []
+ +
[docs] def get_objs_with_db_property_value( + self, property_name, property_value, candidates=None, typeclasses=None + ): + """ + Get objects with a specific field name and value. + + Args: + property_name (str): Field name to search for. + property_value (any): Value required for field with `property_name` to have. + candidates (list, optional): List of objects to limit search to. + typeclasses (list, optional): List of typeclass-path strings to restrict matches with + + Returns: + Queryset: Iterable with 0, 1 or more matches. + + """ + if isinstance(property_name, str): + if not property_name.startswith("db_"): + property_name = "db_%s" % property_name + querykwargs = {property_name: property_value} + cand_restriction = ( + candidates is not None + and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) + or Q() + ) + type_restriction = typeclasses and Q(db_typeclass_path__in=make_iter(typeclasses)) or Q() + try: + return self.filter(cand_restriction & type_restriction & Q(**querykwargs)).order_by( + "id" + ) + except exceptions.FieldError: + return self.none() + except ValueError: + from evennia.utils import logger + + logger.log_err( + "The property '%s' does not support search criteria of the type %s." + % (property_name, type(property_value)) + ) + return self.none()
+ +
[docs] def get_contents(self, location, excludeobj=None): + """ + Get all objects that has a location set to this one. + + Args: + location (Object): Where to get contents from. + excludeobj (Object or list, optional): One or more objects + to exclude from the match. + + Returns: + Queryset: Iterable with 0, 1 or more matches. + + """ + exclude_restriction = ( + Q(pk__in=[_GA(obj, "id") for obj in make_iter(excludeobj)]) if excludeobj else Q() + ) + return self.filter(db_location=location).exclude(exclude_restriction).order_by("id")
+ +
[docs] def get_objs_with_key_or_alias(self, ostring, exact=True, candidates=None, typeclasses=None): + """ + Args: + ostring (str): A search criterion. + exact (bool, optional): Require exact match of ostring + (still case-insensitive). If `False`, will do fuzzy matching + using `evennia.utils.utils.string_partial_matching` algorithm. + candidates (list): Only match among these candidates. + typeclasses (list): Only match objects with typeclasses having thess path strings. + + Returns: + Queryset: An iterable with 0, 1 or more matches. + + """ + if not isinstance(ostring, str): + if hasattr(ostring, "key"): + ostring = ostring.key + else: + return self.none() + if is_iter(candidates) and not len(candidates): + # if candidates is an empty iterable there can be no matches + # Exit early. + return self.none() + + # build query objects + candidates_id = [_GA(obj, "id") for obj in make_iter(candidates) if obj] + cand_restriction = candidates is not None and Q(pk__in=candidates_id) or Q() + type_restriction = typeclasses and Q(db_typeclass_path__in=make_iter(typeclasses)) or Q() + if exact: + # exact match - do direct search + return ( + ( + self.filter( + cand_restriction + & type_restriction + & ( + Q(db_key__iexact=ostring) + | Q(db_tags__db_key__iexact=ostring) + & Q(db_tags__db_tagtype__iexact="alias") + ) + ) + ) + .distinct() + .order_by("id") + ) + elif candidates: + # fuzzy with candidates + search_candidates = ( + self.filter(cand_restriction & type_restriction).distinct().order_by("id") + ) + else: + # fuzzy without supplied candidates - we select our own candidates + search_candidates = ( + self.filter( + type_restriction + & (Q(db_key__icontains=ostring) | Q(db_tags__db_key__icontains=ostring)) + ) + .distinct() + .order_by("id") + ) + # fuzzy matching + key_strings = search_candidates.values_list("db_key", flat=True).order_by("id") + + match_ids = [] + index_matches = string_partial_matching(key_strings, ostring, ret_index=True) + if index_matches: + # a match by key + match_ids = [ + obj.id for ind, obj in enumerate(search_candidates) if ind in index_matches + ] + else: + # match by alias rather than by key + search_candidates = search_candidates.filter( + db_tags__db_tagtype__iexact="alias", db_tags__db_key__icontains=ostring + ).distinct() + alias_strings = [] + alias_candidates = [] + # TODO create the alias_strings and alias_candidates lists more efficiently? + for candidate in search_candidates: + for alias in candidate.aliases.all(): + alias_strings.append(alias) + alias_candidates.append(candidate) + index_matches = string_partial_matching(alias_strings, ostring, ret_index=True) + if index_matches: + # it's possible to have multiple matches to the same Object, we must weed those out + match_ids = [alias_candidates[ind].id for ind in index_matches] + # TODO - not ideal to have to do a second lookup here, but we want to return a queryset + # rather than a list ... maybe the above queries can be improved. + return self.filter(id__in=match_ids)
+ + # main search methods and helper functions + +
[docs] def search_object( + self, + searchdata, + attribute_name=None, + typeclass=None, + candidates=None, + exact=True, + use_dbref=True, + tags=None, + ): + """ + Search as an object globally or in a list of candidates and + return results. The result is always an Object. Always returns + a list. + + Args: + searchdata (str or Object): The entity to match for. This is + usually a key string but may also be an object itself. + By default (if no `attribute_name` is set), this will + search `object.key` and `object.aliases` in order. + Can also be on the form #dbref, which will (if + `exact=True`) be matched against primary key. + attribute_name (str): Use this named Attribute to + match searchdata against, instead of the defaults. If + this is the name of a database field (with or without + the `db_` prefix), that will be matched too. + typeclass (str or TypeClass): restrict matches to objects + having this typeclass. This will help speed up global + searches. + candidates (list): If supplied, search will + only be performed among the candidates in this list. A + common list of candidates is the contents of the + current location searched. + exact (bool): Match names/aliases exactly or partially. + Partial matching matches the beginning of words in the + names/aliases, using a matching routine to separate + multiple matches in names with multiple components (so + "bi sw" will match "Big sword"). Since this is more + expensive than exact matching, it is recommended to be + used together with the `candidates` keyword to limit the + number of possibilities. This value has no meaning if + searching for attributes/properties. + use_dbref (bool): If False, bypass direct lookup of a string + on the form #dbref and treat it like any string. + tags (list): A list of tuples `(tagkey, tagcategory)` where the + matched object must have _all_ tags in order to be considered + a match. + + Returns: + matches (list): Matching objects + + """ + + def _searcher(searchdata, candidates, typeclass, exact=False): + """ + Helper method for searching objects. `typeclass` is only used + for global searching (no candidates) + """ + if attribute_name: + # attribute/property search (always exact). + matches = self.get_objs_with_db_property_value( + attribute_name, searchdata, candidates=candidates, typeclasses=typeclass + ) + if matches: + return matches + return self.get_objs_with_attr_value( + attribute_name, searchdata, candidates=candidates, typeclasses=typeclass + ) + else: + # normal key/alias search + return self.get_objs_with_key_or_alias( + searchdata, exact=exact, candidates=candidates, typeclasses=typeclass + ) + + def _search_by_tag(query, taglist): + if not query: + query = self.all() + + for tagkey, tagcategory in taglist: + query = query.filter(db_tags__db_key=tagkey, db_tags__db_category=tagcategory) + + return query + + if not searchdata and searchdata != 0: + if tags: + return _search_by_tag(make_iter(tags)) + + return self.none() + + if typeclass: + # typeclass may also be a list + typeclasses = make_iter(typeclass) + for i, typeclass in enumerate(make_iter(typeclasses)): + if callable(typeclass): + typeclasses[i] = "%s.%s" % (typeclass.__module__, typeclass.__name__) + else: + typeclasses[i] = "%s" % typeclass + typeclass = typeclasses + + if candidates is not None: + if not candidates: + # candidates is the empty list. This should mean no matches can ever be acquired. + return [] + # Convenience check to make sure candidates are really dbobjs + candidates = [cand for cand in make_iter(candidates) if cand] + if typeclass: + candidates = [ + cand for cand in candidates if _GA(cand, "db_typeclass_path") in typeclass + ] + + dbref = not attribute_name and exact and use_dbref and self.dbref(searchdata) + if dbref: + # Easiest case - dbref matching (always exact) + dbref_match = self.dbref_search(dbref) + if dbref_match: + dmatch = dbref_match[0] + if not candidates or dmatch in candidates: + return dbref_match + else: + return self.none() + + # Search through all possibilities. + match_number = None + # always run first check exact - we don't want partial matches + # if on the form of 1-keyword etc. + matches = _searcher(searchdata, candidates, typeclass, exact=True) + + if not matches: + # no matches found - check if we are dealing with N-keyword + # query - if so, strip it. + match = _MULTIMATCH_REGEX.match(str(searchdata)) + match_number = None + stripped_searchdata = searchdata + if match: + # strips the number + match_number, stripped_searchdata = match.group("number"), match.group("name") + match_number = int(match_number) - 1 + if match_number is not None: + # run search against the stripped data + matches = _searcher(stripped_searchdata, candidates, typeclass, exact=True) + if not matches: + # final chance to get a looser match against the number-strippped query + matches = _searcher(stripped_searchdata, candidates, typeclass, exact=False) + elif not exact: + matches = _searcher(searchdata, candidates, typeclass, exact=False) + + if tags: + matches = _search_by_tag(matches, make_iter(tags)) + + # deal with result + if len(matches) == 1 and match_number is not None and match_number != 0: + # this indicates trying to get a single match with a match-number + # targeting some higher-number match (like 2-box when there is only + # one box in the room). This leads to a no-match. + matches = self.none() + elif len(matches) > 1 and match_number is not None: + # multiple matches, but a number was given to separate them + if 0 <= match_number < len(matches): + # limit to one match (we still want a queryset back) + # TODO: Can we do this some other way and avoid a second lookup? + matches = self.filter(id=matches[match_number].id) + else: + # a number was given outside of range. This means a no-match. + matches = self.none() + + # return a list (possibly empty) + return matches
+ + # alias for backwards compatibility + object_search = search_object + search = search_object + + # + # ObjectManager Copy method + +
[docs] def copy_object( + self, + original_object, + new_key=None, + new_location=None, + new_home=None, + new_permissions=None, + new_locks=None, + new_aliases=None, + new_destination=None, + ): + """ + Create and return a new object as a copy of the original object. All + will be identical to the original except for the arguments given + specifically to this method. Object contents will not be copied. + + Args: + original_object (Object): The object to make a copy from. + new_key (str, optional): Name of the copy, if different + from the original. + new_location (Object, optional): Alternate location. + new_home (Object, optional): Change the home location + new_aliases (list, optional): Give alternate object + aliases as a list of strings. + new_destination (Object, optional): Used only by exits. + + Returns: + copy (Object or None): The copy of `original_object`, + optionally modified as per the ingoing keyword + arguments. `None` if an error was encountered. + + """ + + # get all the object's stats + typeclass_path = original_object.typeclass_path + if not new_key: + new_key = original_object.key + if not new_location: + new_location = original_object.location + if not new_home: + new_home = original_object.home + if not new_aliases: + new_aliases = original_object.aliases.all() + if not new_locks: + new_locks = original_object.db_lock_storage + if not new_permissions: + new_permissions = original_object.permissions.all() + if not new_destination: + new_destination = original_object.destination + + # create new object + from evennia.scripts.models import ScriptDB + from evennia.utils import create + + new_object = create.create_object( + typeclass_path, + key=new_key, + location=new_location, + home=new_home, + permissions=new_permissions, + locks=new_locks, + aliases=new_aliases, + destination=new_destination, + ) + if not new_object: + return None + + # copy over all attributes from old to new. + attrs = ( + (a.key, a.value, a.category, a.lock_storage) for a in original_object.attributes.all() + ) + new_object.attributes.batch_add(*attrs) + + # copy over all cmdsets, if any + for icmdset, cmdset in enumerate(original_object.cmdset.all()): + if icmdset == 0: + new_object.cmdset.add_default(cmdset) + else: + new_object.cmdset.add(cmdset) + + # copy over all scripts, if any + for script in original_object.scripts.all(): + ScriptDB.objects.copy_script(script, new_obj=new_object) + + # copy over all tags, if any + tags = ( + (t.db_key, t.db_category, t.db_data) for t in original_object.tags.all(return_objs=True) + ) + new_object.tags.batch_add(*tags) + + return new_object
+ +
[docs] def clear_all_sessids(self): + """ + Clear the db_sessid field of all objects having also the + db_account field set. + + """ + self.filter(db_sessid__isnull=False).update(db_sessid=None)
+ +
[docs] def create_object( + self, + typeclass=None, + key=None, + location=None, + home=None, + permissions=None, + locks=None, + aliases=None, + tags=None, + destination=None, + report_to=None, + nohome=False, + attributes=None, + nattributes=None, + ): + """ + + Create a new in-game object. + + Keyword Args: + typeclass (class or str): Class or python path to a typeclass. + key (str): Name of the new object. If not set, a name of + `#dbref` will be set. + location (Object or str): Obj or #dbref to use as the location of the new object. + home (Object or str): Obj or #dbref to use as the object's home location. + permissions (list): A list of permission strings or tuples (permstring, category). + locks (str): one or more lockstrings, separated by semicolons. + aliases (list): A list of alternative keys or tuples (aliasstring, category). + tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). + destination (Object or str): Obj or #dbref to use as an Exit's target. + report_to (Object): The object to return error messages to. + nohome (bool): This allows the creation of objects without a + default home location; only used when creating the default + location itself or during unittests. + attributes (list): Tuples on the form (key, value) or (key, value, category), + (key, value, lockstring) or (key, value, lockstring, default_access). + to set as Attributes on the new object. + nattributes (list): Non-persistent tuples on the form (key, value). Note that + adding this rarely makes sense since this data will not survive a reload. + + Returns: + object (Object): A newly created object of the given typeclass. + + Raises: + ObjectDB.DoesNotExist: If trying to create an Object with + `location` or `home` that can't be found. + + """ + typeclass = typeclass if typeclass else settings.BASE_OBJECT_TYPECLASS + + # convenience converters to avoid common usage mistake + permissions = make_iter(permissions) if permissions is not None else None + locks = make_iter(locks) if locks is not None else None + aliases = make_iter(aliases) if aliases is not None else None + tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None + + if isinstance(typeclass, str): + # a path is given. Load the actual typeclass + typeclass = class_from_module(typeclass, settings.TYPECLASS_PATHS) + + # Setup input for the create command. We use ObjectDB as baseclass here + # to give us maximum freedom (the typeclasses will load + # correctly when each object is recovered). + + location = dbid_to_obj(location, self.model) + destination = dbid_to_obj(destination, self.model) + + if home: + home_obj_or_dbref = home + elif nohome: + home_obj_or_dbref = None + else: + home_obj_or_dbref = settings.DEFAULT_HOME + + try: + home = dbid_to_obj(home_obj_or_dbref, self.model) + except self.model.DoesNotExist: + if settings.TEST_ENVIRONMENT: + # this happens for databases where the #1 location is flushed during tests + home = None + else: + raise self.model.DoesNotExist( + f"settings.DEFAULT_HOME (= '{settings.DEFAULT_HOME}') does not exist, " + "or the setting is malformed." + ) + + # create new instance + new_object = typeclass( + db_key=key, + db_location=location, + db_destination=destination, + db_home=home, + db_typeclass_path=typeclass.path, + ) + # store the call signature for the signal + new_object._createdict = dict( + key=key, + location=location, + destination=destination, + home=home, + typeclass=typeclass.path, + permissions=permissions, + locks=locks, + aliases=aliases, + tags=tags, + report_to=report_to, + nohome=nohome, + attributes=attributes, + nattributes=nattributes, + ) + # this will trigger the save signal which in turn calls the + # at_first_save hook on the typeclass, where the _createdict can be + # used. + new_object.save() + + signals.SIGNAL_OBJECT_POST_CREATE.send(sender=new_object) + + return new_object
+ + +
[docs]class ObjectManager(ObjectDBManager, TypeclassManager): + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/objects/models.html b/docs/latest/_modules/evennia/objects/models.html new file mode 100644 index 0000000000..25b3b9b93e --- /dev/null +++ b/docs/latest/_modules/evennia/objects/models.html @@ -0,0 +1,497 @@ + + + + + + + + evennia.objects.models — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.objects.models

+"""
+This module defines the database models for all in-game objects, that
+is, all objects that has an actual existence in-game.
+
+Each database object is 'decorated' with a 'typeclass', a normal
+python class that implements all the various logics needed by the game
+in question. Objects created of this class transparently communicate
+with its related database object for storing all attributes. The
+admin should usually not have to deal directly with this database
+object layer.
+
+Attributes are separate objects that store values persistently onto
+the database object. Like everything else, they can be accessed
+transparently through the decorating TypeClass.
+"""
+from collections import defaultdict
+
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
+from django.core.validators import validate_comma_separated_integer_list
+from django.db import models
+
+from evennia.objects.manager import ObjectDBManager
+from evennia.typeclasses.models import TypedObject
+from evennia.utils import logger
+from evennia.utils.utils import dbref, lazy_property, make_iter
+
+
+
[docs]class ContentsHandler: + """ + Handles and caches the contents of an object to avoid excessive + lookups (this is done very often due to cmdhandler needing to look + for object-cmdsets). It is stored on the 'contents_cache' property + of the ObjectDB. + """ + +
[docs] def __init__(self, obj): + """ + Sets up the contents handler. + + Args: + obj (Object): The object on which the + handler is defined + + Notes: + This was changed from using `set` to using `dict` internally + in order to retain insertion order. + + """ + self.obj = obj + self._pkcache = {} + self._idcache = obj.__class__.__instance_cache__ + self._typecache = defaultdict(dict) + self.init()
+ +
[docs] def load(self): + """ + Retrieves all objects from database. Used for initializing. + + Returns: + Objects (list of ObjectDB) + """ + return list(self.obj.locations_set.all())
+ +
[docs] def init(self): + """ + Re-initialize the content cache + + """ + objects = self.load() + self._pkcache = {obj.pk: True for obj in objects} + for obj in objects: + for ctype in obj._content_types: + self._typecache[ctype][obj.pk] = True
+ +
[docs] def get(self, exclude=None, content_type=None): + """ + Return the contents of the cache. + + Args: + exclude (Object or list of Object): object(s) to ignore + content_type (str or None): Filter list by a content-type. If None, don't filter. + + Returns: + objects (list): the Objects inside this location + + """ + if content_type is not None: + pks = self._typecache[content_type].keys() + else: + pks = self._pkcache.keys() + if exclude: + pks = set(pks) - {excl.pk for excl in make_iter(exclude)} + try: + return [self._idcache[pk] for pk in pks] + except KeyError: + # this can happen if the idmapper cache was cleared for an object + # in the contents cache. If so we need to re-initialize and try again. + self.init() + try: + return [self._idcache[pk] for pk in pks] + except KeyError: + # this means an actual failure of caching. Return real database match. + logger.log_err("contents cache failed for %s." % self.obj.key) + return self.load()
+ +
[docs] def add(self, obj): + """ + Add a new object to this location + + Args: + obj (Object): object to add + + """ + self._pkcache[obj.pk] = obj + for ctype in obj._content_types: + self._typecache[ctype][obj.pk] = True
+ +
[docs] def remove(self, obj): + """ + Remove object from this location + + Args: + obj (Object): object to remove + + """ + self._pkcache.pop(obj.pk, None) + for ctype in obj._content_types: + if obj.pk in self._typecache[ctype]: + self._typecache[ctype].pop(obj.pk, None)
+ +
[docs] def clear(self): + """ + Clear the contents cache and re-initialize + + """ + self._pkcache = {} + self._typecache = defaultdict(dict) + self.init()
+ + +# ------------------------------------------------------------- +# +# ObjectDB +# +# ------------------------------------------------------------- + + +
[docs]class ObjectDB(TypedObject): + """ + All objects in the game use the ObjectDB model to store + data in the database. This is handled transparently through + the typeclass system. + + Note that the base objectdb is very simple, with + few defined fields. Use attributes to extend your + type class with new database-stored variables. + + The TypedObject supplies the following (inherited) properties: + + - key - main name + - name - alias for key + - db_typeclass_path - the path to the decorating typeclass + - db_date_created - time stamp of object creation + - permissions - perm strings + - locks - lock definitions (handler) + - dbref - #id of object + - db - persistent attribute storage + - ndb - non-persistent attribute storage + + The ObjectDB adds the following properties: + + - account - optional connected account (always together with sessid) + - sessid - optional connection session id (always together with account) + - location - in-game location of object + - home - safety location for object (handler) + - scripts - scripts assigned to object (handler from typeclass) + - cmdset - active cmdset on object (handler from typeclass) + - aliases - aliases for this object (property) + - nicks - nicknames for *other* things in Evennia (handler) + - sessions - sessions connected to this object (see also account) + - has_account - bool if an active account is currently connected + - contents - other objects having this object as location + - exits - exits from this object + + """ + + # + # ObjectDB Database model setup + # + # + # inherited fields (from TypedObject): + # db_key (also 'name' works), db_typeclass_path, db_date_created, + # db_permissions + # + # These databse fields (including the inherited ones) should normally be + # managed by their corresponding wrapper properties, named same as the + # field, but without the db_* prefix (e.g. the db_key field is set with + # self.key instead). The wrappers are created at the metaclass level and + # will automatically save and cache the data more efficiently. + + # If this is a character object, the account is connected here. + db_account = models.ForeignKey( + "accounts.AccountDB", + null=True, + verbose_name="account", + on_delete=models.SET_NULL, + help_text="an Account connected to this object, if any.", + ) + + # the session id associated with this account, if any + db_sessid = models.CharField( + null=True, + max_length=32, + validators=[validate_comma_separated_integer_list], + verbose_name="session id", + help_text="csv list of session ids of connected Account, if any.", + ) + # The location in the game world. Since this one is likely + # to change often, we set this with the 'location' property + # to transparently handle Typeclassing. + db_location = models.ForeignKey( + "self", + related_name="locations_set", + db_index=True, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name="game location", + ) + # a safety location, this usually don't change much. + db_home = models.ForeignKey( + "self", + related_name="homes_set", + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name="home location", + ) + # destination of this object - primarily used by exits. + db_destination = models.ForeignKey( + "self", + related_name="destinations_set", + db_index=True, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name="destination", + help_text="a destination, used only by exit objects.", + ) + # database storage of persistant cmdsets. + db_cmdset_storage = models.CharField( + "cmdset", + max_length=255, + null=True, + blank=True, + help_text="optional python path to a cmdset class.", + ) + + # Database manager + objects = ObjectDBManager() + + # defaults + __settingsclasspath__ = settings.BASE_OBJECT_TYPECLASS + __defaultclasspath__ = "evennia.objects.objects.DefaultObject" + __applabel__ = "objects" + +
[docs] @lazy_property + def contents_cache(self): + return ContentsHandler(self)
+ + # cmdset_storage property handling + def __cmdset_storage_get(self): + """getter""" + storage = self.db_cmdset_storage + return [path.strip() for path in storage.split(",")] if storage else [] + + def __cmdset_storage_set(self, value): + """setter""" + self.db_cmdset_storage = ",".join(str(val).strip() for val in make_iter(value)) + self.save(update_fields=["db_cmdset_storage"]) + + def __cmdset_storage_del(self): + """deleter""" + self.db_cmdset_storage = None + self.save(update_fields=["db_cmdset_storage"]) + + cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set, __cmdset_storage_del) + + # location getsetter + def __location_get(self): + """Get location""" + return self.db_location + + def __location_set(self, location): + """Set location, checking for loops and allowing dbref""" + if isinstance(location, (str, int)): + # allow setting of #dbref + dbid = dbref(location, reqhash=False) + if dbid: + try: + location = ObjectDB.objects.get(id=dbid) + except ObjectDoesNotExist: + # maybe it is just a name that happens to look like a dbid + pass + try: + + def is_loc_loop(loc, depth=0): + """Recursively traverse target location, trying to catch a loop.""" + if depth > 10: + return None + elif loc == self: + raise RuntimeError + elif loc is None: + raise RuntimeWarning + return is_loc_loop(loc.db_location, depth + 1) + + try: + is_loc_loop(location) + except RuntimeWarning: + # we caught an infinite location loop! + # (location1 is in location2 which is in location1 ...) + pass + + # if we get to this point we are ready to change location + + old_location = self.db_location + + # this is checked in _db_db_location_post_save below + self._safe_contents_update = True + + # actually set the field (this will error if location is invalid) + self.db_location = location + self.save(update_fields=["db_location"]) + + # remove the safe flag + del self._safe_contents_update + + # update the contents cache + if old_location: + old_location.contents_cache.remove(self) + if self.db_location: + self.db_location.contents_cache.add(self) + + except RuntimeError: + errmsg = "Error: %s.location = %s creates a location loop." % (self.key, location) + raise RuntimeError(errmsg) + except Exception: + # raising here gives more info for now + raise + # errmsg = "Error (%s): %s is not a valid location." % (str(e), location) + # raise RuntimeError(errmsg) + return + + def __location_del(self): + """Cleanly delete the location reference""" + self.db_location = None + self.save(update_fields=["db_location"]) + + location = property(__location_get, __location_set, __location_del) + +
[docs] def at_db_location_postsave(self, new): + """ + This is called automatically after the location field was + saved, no matter how. It checks for a variable + _safe_contents_update to know if the save was triggered via + the location handler (which updates the contents cache) or + not. + + Args: + new (bool): Set if this location has not yet been saved before. + + """ + if not hasattr(self, "_safe_contents_update"): + # changed/set outside of the location handler + if new: + # if new, there is no previous location to worry about + if self.db_location: + self.db_location.contents_cache.add(self) + else: + # Since we cannot know at this point was old_location was, we + # trigger a full-on contents_cache update here. + logger.log_warn( + "db_location direct save triggered contents_cache.init() for all objects!" + ) + [o.contents_cache.init() for o in self.__dbclass__.get_all_cached_instances()]
+ + class Meta: + """Define Django meta options""" + + verbose_name = "Object" + verbose_name_plural = "Objects"
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/objects/objects.html b/docs/latest/_modules/evennia/objects/objects.html new file mode 100644 index 0000000000..bf7653f093 --- /dev/null +++ b/docs/latest/_modules/evennia/objects/objects.html @@ -0,0 +1,3374 @@ + + + + + + + + evennia.objects.objects — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.objects.objects

+"""
+This module defines the basic `DefaultObject` and its children
+`DefaultCharacter`, `DefaultAccount`, `DefaultRoom` and `DefaultExit`.
+These are the (default) starting points for all in-game visible
+entities.
+
+This is the v1.0 develop version (for ref in doc building).
+
+"""
+import time
+import typing
+from collections import defaultdict
+
+import evennia
+import inflect
+from django.conf import settings
+from django.utils.translation import gettext as _
+from evennia.commands import cmdset
+from evennia.commands.cmdsethandler import CmdSetHandler
+from evennia.objects.manager import ObjectManager
+from evennia.objects.models import ObjectDB
+from evennia.scripts.scripthandler import ScriptHandler
+from evennia.server.signals import SIGNAL_EXIT_TRAVERSED
+from evennia.typeclasses.attributes import ModelAttributeBackend, NickHandler
+from evennia.typeclasses.models import TypeclassBase
+from evennia.utils import ansi, create, funcparser, logger, search
+from evennia.utils.utils import (
+    class_from_module,
+    is_iter,
+    iter_to_str,
+    lazy_property,
+    make_iter,
+    to_str,
+    variable_from_module,
+)
+
+_INFLECT = inflect.engine()
+_MULTISESSION_MODE = settings.MULTISESSION_MODE
+
+_ScriptDB = None
+_CMDHANDLER = None
+
+_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1))
+_COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
+# the sessid_max is based on the length of the db_sessid csv field (excluding commas)
+_SESSID_MAX = 16 if _MULTISESSION_MODE in (1, 3) else 1
+
+# init the actor-stance funcparser for msg_contents
+_MSG_CONTENTS_PARSER = funcparser.FuncParser(funcparser.ACTOR_STANCE_CALLABLES)
+
+
+
[docs]class ObjectSessionHandler: + """ + Handles the get/setting of the sessid comma-separated integer field + + """ + +
[docs] def __init__(self, obj): + """ + Initializes the handler. + + Args: + obj (Object): The object on which the handler is defined. + + """ + self.obj = obj + self._sessid_cache = [] + self._recache()
+ + def _recache(self): + self._sessid_cache = list( + set(int(val) for val in (self.obj.db_sessid or "").split(",") if val) + ) + if any(sessid for sessid in self._sessid_cache if sessid not in evennia.SESSION_HANDLER): + # cache is out of sync with sessionhandler! Only retain the ones in the handler. + self._sessid_cache = [ + sessid for sessid in self._sessid_cache if sessid in evennia.SESSION_HANDLER + ] + self.obj.db_sessid = ",".join(str(val) for val in self._sessid_cache) + self.obj.save(update_fields=["db_sessid"]) + +
[docs] def get(self, sessid=None): + """ + Get the sessions linked to this Object. + + Args: + sessid (int, optional): A specific session id. + + Returns: + sessions (list): The sessions connected to this object. If `sessid` is given, + this is a list of one (or zero) elements. + + Notes: + Aliased to `self.all()`. + + """ + + if sessid: + sessions = ( + [evennia.SESSION_HANDLER[sessid] if sessid in evennia.SESSION_HANDLER else None] + if sessid in self._sessid_cache + else [] + ) + else: + sessions = [ + evennia.SESSION_HANDLER[ssid] if ssid in evennia.SESSION_HANDLER else None + for ssid in self._sessid_cache + ] + if None in sessions: + # this happens only if our cache has gone out of sync with the SessionHandler. + self._recache() + return self.get(sessid=sessid) + return sessions
+ +
[docs] def all(self): + """ + Alias to get(), returning all sessions. + + Returns: + sessions (list): All sessions. + + """ + return self.get()
+ +
[docs] def add(self, session): + """ + Add session to handler. + + Args: + session (Session or int): Session or session id to add. + + Notes: + We will only add a session/sessid if this actually also exists + in the the core sessionhandler. + + """ + try: + sessid = session.sessid + except AttributeError: + sessid = session + + sessid_cache = self._sessid_cache + if sessid in evennia.SESSION_HANDLER and sessid not in sessid_cache: + if len(sessid_cache) >= _SESSID_MAX: + return + sessid_cache.append(sessid) + self.obj.db_sessid = ",".join(str(val) for val in sessid_cache) + self.obj.save(update_fields=["db_sessid"])
+ +
[docs] def remove(self, session): + """ + Remove session from handler. + + Args: + session (Session or int): Session or session id to remove. + + """ + try: + sessid = session.sessid + except AttributeError: + sessid = session + + sessid_cache = self._sessid_cache + if sessid in sessid_cache: + sessid_cache.remove(sessid) + self.obj.db_sessid = ",".join(str(val) for val in sessid_cache) + self.obj.save(update_fields=["db_sessid"])
+ +
[docs] def clear(self): + """ + Clear all handled sessids. + + """ + self._sessid_cache = [] + self.obj.db_sessid = None + self.obj.save(update_fields=["db_sessid"])
+ +
[docs] def count(self): + """ + Get amount of sessions connected. + + Returns: + sesslen (int): Number of sessions handled. + + """ + return len(self._sessid_cache)
+ + +# +# Base class to inherit from. + + +
[docs]class DefaultObject(ObjectDB, metaclass=TypeclassBase): + """ + This is the root typeclass object, representing all entities that + have an actual presence in-game. DefaultObjects generally have a + location. They can also be manipulated and looked at. Game + entities you define should inherit from DefaultObject at some distance. + + It is recommended to create children of this class using the + `evennia.create_object()` function rather than to initialize the class + directly - this will both set things up and efficiently save the object + without `obj.save()` having to be called explicitly. + + """ + + # Determines which order command sets begin to be assembled from. + # Objects are usually third. + cmdset_provider_order = 100 + cmdset_provider_error_order = 100 + cmdset_provider_type = "object" + + # Used for sorting / filtering in inventories / room contents. + _content_types = ("object",) + + objects = ObjectManager() + + # populated by `return_appearance` + appearance_template = """ +{header} +|c{name}|n +{desc} +{exits}{characters}{things} +{footer} + """ + # on-object properties + +
[docs] @lazy_property + def cmdset(self): + return CmdSetHandler(self, True)
+ +
[docs] @lazy_property + def scripts(self): + return ScriptHandler(self)
+ +
[docs] @lazy_property + def nicks(self): + return NickHandler(self, ModelAttributeBackend)
+ +
[docs] @lazy_property + def sessions(self): + return ObjectSessionHandler(self)
+ + @property + def is_connected(self): + # we get an error for objects subscribed to channels without this + if self.account: # seems sane to pass on the account + return self.account.is_connected + else: + return False + + @property + def has_account(self): + """ + Convenience property for checking if an active account is + currently connected to this object. + + """ + return self.sessions.count() + +
[docs] def get_cmdset_providers(self) -> dict[str, "CmdSetProvider"]: + """ + Overrideable method which returns a dictionary of every kind of object which + has a cmdsethandler linked to this Object, and should participate in cmdset + merging. + + Objects might be aware of an Account. Otherwise, just themselves, by default. + + Returns: + dict[str, CmdSetProvider]: The CmdSetProviders linked to this Object. + """ + out = {"object": self} + if self.account: + out["account"] = self.account + return out
+ + @property + def is_superuser(self): + """ + Check if user has an account, and if so, if it is a superuser. + + """ + return ( + self.db_account + and self.db_account.is_superuser + and not self.db_account.attributes.get("_quell") + ) + +
[docs] def contents_get(self, exclude=None, content_type=None): + """ + Returns the contents of this object, i.e. all + objects that has this object set as its location. + This should be publically available. + + Args: + exclude (Object): Object to exclude from returned + contents list + content_type (str): A content_type to filter by. None for no + filtering. + + Returns: + contents (list): List of contents of this Object. + + Notes: + Also available as the `contents` property, minus exclusion + and filtering. + + """ + return self.contents_cache.get(exclude=exclude, content_type=content_type)
+ +
[docs] def contents_set(self, *args): + "You cannot replace this property" + raise AttributeError( + "{}.contents is read-only. Use obj.move_to or " + "obj.location to move an object here.".format(self.__class__) + )
+ + contents = property(contents_get, contents_set, contents_set) + + @property + def exits(self): + """ + Returns all exits from this object, i.e. all objects at this + location having the property destination != `None`. + + """ + return [exi for exi in self.contents if exi.destination] + + # main methods + +
[docs] def search( + self, + searchdata, + global_search=False, + use_nicks=True, + typeclass=None, + location=None, + attribute_name=None, + quiet=False, + exact=False, + candidates=None, + use_locks=True, + nofound_string=None, + multimatch_string=None, + use_dbref=None, + tags=None, + stacked=0, + ): + """ + Returns an Object matching a search string/condition + + Perform a standard object search in the database, handling + multiple results and lack thereof gracefully. By default, only + objects in the current `location` of `self` or its inventory are searched for. + + Args: + searchdata (str or obj): Primary search criterion. Will be matched + against `object.key` (with `object.aliases` second) unless + the keyword attribute_name specifies otherwise. + + Special keywords: + + - `#<num>`: search by unique dbref. This is always a global search. + - `me,self`: self-reference to this object + - `<num>-<string>` - can be used to differentiate + between multiple same-named matches. The exact form of this input + is given by `settings.SEARCH_MULTIMATCH_REGEX`. + + global_search (bool): Search all objects globally. This overrules 'location' data. + use_nicks (bool): Use nickname-replace (nicktype "object") on `searchdata`. + typeclass (str or Typeclass, or list of either): Limit search only + to `Objects` with this typeclass. May be a list of typeclasses + for a broader search. + location (Object or list): Specify a location or multiple locations + to search. Note that this is used to query the *contents* of a + location and will not match for the location itself - + if you want that, don't set this or use `candidates` to specify + exactly which objects should be searched. If this nor candidates are + given, candidates will include caller's inventory, current location and + all objects in the current location. + attribute_name (str): Define which property to search. If set, no + key+alias search will be performed. This can be used + to search database fields (db_ will be automatically + prepended), and if that fails, it will try to return + objects having Attributes with this name and value + equal to searchdata. A special use is to search for + "key" here if you want to do a key-search without + including aliases. + quiet (bool): don't display default error messages - this tells the + search method that the user wants to handle all errors + themselves. It also changes the return value type, see + below. + exact (bool): if unset (default) - prefers to match to beginning of + string rather than not matching at all. If set, requires + exact matching of entire string. + candidates (list of objects): this is an optional custom list of objects + to search (filter) between. It is ignored if `global_search` + is given. If not set, this list will automatically be defined + to include the location, the contents of location and the + caller's contents (inventory). + use_locks (bool): If True (default) - removes search results which + fail the "search" lock. + nofound_string (str): optional custom string for not-found error message. + multimatch_string (str): optional custom string for multimatch error header. + use_dbref (bool or None, optional): If `True`, allow to enter e.g. a query "#123" + to find an object (globally) by its database-id 123. If `False`, the string "#123" + will be treated like a normal string. If `None` (default), the ability to query by + #dbref is turned on if `self` has the permission 'Builder' and is turned off + otherwise. + tags (list or tuple): Find objects matching one or more Tags. This should be one or + more tag definitions on the form `tagname` or `(tagname, tagcategory)`. + stacked (int, optional): If > 0, multimatches will be analyzed to determine if they + only contains identical objects; these are then assumed 'stacked' and no multi-match + error will be generated, instead `stacked` number of matches will be returned. If + `stacked` is larger than number of matches, returns that number of matches. If + the found stack is a mix of objects, return None and handle the multi-match + error depending on the value of `quiet`. + + Returns: + Object, None or list: Will return an `Object` or `None` if `quiet=False`. Will return + a `list` with 0, 1 or more matches if `quiet=True`. If `stacked` is a positive integer, + this list may contain all stacked identical matches. + + Notes: + To find Accounts, use eg. `evennia.account_search`. If + `quiet=False`, error messages will be handled by + `settings.SEARCH_AT_RESULT` and echoed automatically (on + error, return will be `None`). If `quiet=True`, the error + messaging is assumed to be handled by the caller. + + """ + is_string = isinstance(searchdata, str) + + if is_string: + # searchdata is a string; wrap some common self-references + if searchdata.lower() in ("here",): + return [self.location] if quiet else self.location + if searchdata.lower() in ("me", "self"): + return [self] if quiet else self + + if use_dbref is None: + use_dbref = self.locks.check_lockstring(self, "_dummy:perm(Builder)") + + if use_nicks: + # do nick-replacement on search + searchdata = self.nicks.nickreplace( + searchdata, categories=("object", "account"), include_account=True + ) + + if global_search or ( + is_string + and searchdata.startswith("#") + and len(searchdata) > 1 + and searchdata[1:].isdigit() + ): + # only allow exact matching if searching the entire database + # or unique #dbrefs + exact = True + candidates = None + + elif candidates is None: + # no custom candidates given - get them automatically + if location: + # location(s) were given + candidates = [] + for obj in make_iter(location): + candidates.extend(obj.contents) + else: + # local search. Candidates are taken from + # self.contents, self.location and + # self.location.contents + location = self.location + candidates = self.contents + if location: + candidates = candidates + [location] + location.contents + else: + # normally we don't need this since we are + # included in location.contents + candidates.append(self) + + if tags: + tags = [(tagkey, tagcat[0] if tagcat else None) for tagkey, *tagcat in make_iter(tags)] + + results = ObjectDB.objects.search_object( + searchdata, + attribute_name=attribute_name, + typeclass=typeclass, + candidates=candidates, + exact=exact, + use_dbref=use_dbref, + tags=tags, + ) + + if use_locks: + results = [x for x in list(results) if x.access(self, "search", default=True)] + + nresults = len(results) + if stacked > 0 and nresults > 1: + # handle stacks, disable multimatch errors + nstack = nresults + if not exact: + # we re-run exact match against one of the matches to + # make sure we were not catching partial matches not belonging + # to the stack + nstack = len( + ObjectDB.objects.get_objs_with_key_or_alias( + results[0].key, + exact=True, + candidates=list(results), + typeclasses=[typeclass] if typeclass else None, + ) + ) + if nstack == nresults: + # a valid stack, return multiple results + return list(results)[:stacked] + + if quiet: + # don't auto-handle error messaging + return list(results) + + # handle error messages + return _AT_SEARCH_RESULT( + results, + self, + query=searchdata, + nofound_string=nofound_string, + multimatch_string=multimatch_string, + )
+ +
[docs] def search_account(self, searchdata, quiet=False): + """ + Simple shortcut wrapper to search for accounts, not characters. + + Args: + searchdata (str): Search criterion - the key or dbref of the account + to search for. If this is "here" or "me", search + for the account connected to this object. + quiet (bool): Returns the results as a list rather than + echo eventual standard error messages. Default `False`. + + Returns: + result (Account, None or list): Just what is returned depends on + the `quiet` setting: + - `quiet=True`: No match or multumatch auto-echoes errors + to self.msg, then returns `None`. The esults are passed + through `settings.SEARCH_AT_RESULT` and + `settings.SEARCH_AT_MULTIMATCH_INPUT`. If there is a + unique match, this will be returned. + - `quiet=True`: No automatic error messaging is done, and + what is returned is always a list with 0, 1 or more + matching Accounts. + + """ + if isinstance(searchdata, str): + # searchdata is a string; wrap some common self-references + if searchdata.lower() in ("me", "self"): + return [self.account] if quiet else self.account + + results = search.search_account(searchdata) + + if quiet: + return results + return _AT_SEARCH_RESULT(results, self, query=searchdata)
+ +
[docs] def execute_cmd(self, raw_string, session=None, **kwargs): + """ + Do something as this object. This is never called normally, + it's only used when wanting specifically to let an object be + the caller of a command. It makes use of nicks of eventual + connected accounts as well. + + Args: + raw_string (string): Raw command input + session (Session, optional): Session to + return results to + + Keyword Args: + Other keyword arguments will be added to the found command + object instace as variables before it executes. This is + unused by default Evennia but may be used to set flags and + change operating paramaters for commands at run-time. + + Returns: + defer (Deferred): This is an asynchronous Twisted object that + will not fire until the command has actually finished + executing. To overload this one needs to attach + callback functions to it, with addCallback(function). + This function will be called with an eventual return + value from the command execution. This return is not + used at all by Evennia by default, but might be useful + for coders intending to implement some sort of nested + command structure. + + """ + # break circular import issues + global _CMDHANDLER + if not _CMDHANDLER: + from evennia.commands.cmdhandler import cmdhandler as _CMDHANDLER + + # nick replacement - we require full-word matching. + # do text encoding conversion + raw_string = self.nicks.nickreplace( + raw_string, categories=("inputline", "channel"), include_account=True + ) + return _CMDHANDLER(self, raw_string, callertype="object", session=session, **kwargs)
+ +
[docs] def msg(self, text=None, from_obj=None, session=None, options=None, **kwargs): + """ + Emits something to a session attached to the object. + + Args: + text (str or tuple, optional): The message to send. This + is treated internally like any send-command, so its + value can be a tuple if sending multiple arguments to + the `text` oob command. + from_obj (obj or list, optional): object that is sending. If + given, at_msg_send will be called. This value will be + passed on to the protocol. If iterable, will execute hook + on all entities in it. + session (Session or list, optional): Session or list of + Sessions to relay data to, if any. If set, will force send + to these sessions. If unset, who receives the message + depends on the MULTISESSION_MODE. + options (dict, optional): Message-specific option-value + pairs. These will be applied at the protocol level. + Keyword Args: + any (string or tuples): All kwarg keys not listed above + will be treated as send-command names and their arguments + (which can be a string or a tuple). + + Notes: + `at_msg_receive` will be called on this Object. + All extra kwargs will be passed on to the protocol. + + """ + # try send hooks + if from_obj: + for obj in make_iter(from_obj): + try: + obj.at_msg_send(text=text, to_obj=self, **kwargs) + except Exception: + logger.log_trace() + kwargs["options"] = options + try: + if not self.at_msg_receive(text=text, from_obj=from_obj, **kwargs): + # if at_msg_receive returns false, we abort message to this object + return + except Exception: + logger.log_trace() + + if text is not None: + if not (isinstance(text, str) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text) + except Exception: + text = repr(text) + kwargs["text"] = text + + # relay to session(s) + sessions = make_iter(session) if session else self.sessions.all() + for session in sessions: + session.data_out(**kwargs)
+ +
[docs] def for_contents(self, func, exclude=None, **kwargs): + """ + Runs a function on every object contained within this one. + + Args: + func (callable): Function to call. This must have the + formal call sign func(obj, **kwargs), where obj is the + object currently being processed and `**kwargs` are + passed on from the call to `for_contents`. + exclude (list, optional): A list of object not to call the + function on. + + Keyword Args: + Keyword arguments will be passed to the function for all objects. + + """ + contents = self.contents + if exclude: + exclude = make_iter(exclude) + contents = [obj for obj in contents if obj not in exclude] + for obj in contents: + func(obj, **kwargs)
+ +
[docs] def msg_contents( + self, + text=None, + exclude=None, + from_obj=None, + mapping=None, + raise_funcparse_errors=False, + **kwargs, + ): + """ + Emits a message to all objects inside this object. + + Args: + text (str or tuple): Message to send. If a tuple, this should be + on the valid OOB outmessage form `(message, {kwargs})`, + where kwargs are optional data passed to the `text` + outputfunc. The message will be parsed for `{key}` formatting and + `$You/$you()/$You()`, `$obj(name)`, `$conj(verb)` and `$pron(pronoun, option)` + inline function callables. + The `name` is taken from the `mapping` kwarg {"name": object, ...}`. + The `mapping[key].get_display_name(looker=recipient)` will be called + for that key for every recipient of the string. + exclude (list, optional): A list of objects not to send to. + from_obj (Object, optional): An object designated as the + "sender" of the message. See `DefaultObject.msg()` for + more info. This will be used for `$You/you` if using funcparser inlines. + mapping (dict, optional): A mapping of formatting keys + `{"key":<object>, "key2":<object2>,...}. + The keys must either match `{key}` or `$You(key)/$you(key)` markers + in the `text` string. If `<object>` doesn't have a `get_display_name` + method, it will be returned as a string. Pass "you" to represent the caller, + this can be skipped if `from_obj` is provided (that will then act as 'you'). + raise_funcparse_errors (bool, optional): If set, a failing `$func()` will + lead to an outright error. If unset (default), the failing `$func()` + will instead appear in output unparsed. + + **kwargs: Keyword arguments will be passed on to `obj.msg()` for all + messaged objects. + + Notes: + For 'actor-stance' reporting (You say/Name says), use the + `$You()/$you()/$You(key)` and `$conj(verb)` (verb-conjugation) + inline callables. This will use the respective `get_display_name()` + for all onlookers except for `from_obj or self`, which will become + 'You/you'. If you use `$You/you(key)`, the key must be in `mapping`. + + For 'director-stance' reporting (Name says/Name says), use {key} + syntax directly. For both `{key}` and `You/you(key)`, + `mapping[key].get_display_name(looker=recipient)` may be called + depending on who the recipient is. + + Examples: + + Let's assume + - `player1.key -> "Player1"`, + `player1.get_display_name(looker=player2) -> "The First girl"` + - `player2.key -> "Player2"`, + `player2.get_display_name(looker=player1) -> "The Second girl"` + + Actor-stance: + :: + + char.location.msg_contents( + "$You() $conj(attack) $you(defender).", + from_obj=player1, + mapping={"defender": player2}) + + - player1 will see `You attack The Second girl.` + - player2 will see 'The First girl attacks you.' + + Director-stance: + :: + + char.location.msg_contents( + "{attacker} attacks {defender}.", + mapping={"attacker":player1, "defender":player2}) + + - player1 will see: 'Player1 attacks The Second girl.' + - player2 will see: 'The First girl attacks Player2' + + """ + # we also accept an outcommand on the form (message, {kwargs}) + is_outcmd = text and is_iter(text) + inmessage = text[0] if is_outcmd else text + outkwargs = text[1] if is_outcmd and len(text) > 1 else {} + mapping = mapping or {} + you = from_obj or self + + if "you" not in mapping: + mapping[you] = you + + contents = self.contents + if exclude: + exclude = make_iter(exclude) + contents = [obj for obj in contents if obj not in exclude] + + for receiver in contents: + # actor-stance replacements + outmessage = _MSG_CONTENTS_PARSER.parse( + inmessage, + raise_errors=raise_funcparse_errors, + return_string=True, + caller=you, + receiver=receiver, + mapping=mapping, + ) + + # director-stance replacements + outmessage = outmessage.format_map( + { + key: obj.get_display_name(looker=receiver) + if hasattr(obj, "get_display_name") + else str(obj) + for key, obj in mapping.items() + } + ) + + receiver.msg(text=(outmessage, outkwargs), from_obj=from_obj, **kwargs)
+ +
[docs] def move_to( + self, + destination, + quiet=False, + emit_to_obj=None, + use_destination=True, + to_none=False, + move_hooks=True, + move_type="move", + **kwargs, + ): + """ + Moves this object to a new location. + + Args: + destination (Object): Reference to the object to move to. This + can also be an exit object, in which case the + destination property is used as destination. + quiet (bool): If true, turn off the calling of the emit hooks + (announce_move_to/from etc) + emit_to_obj (Object): object to receive error messages + use_destination (bool): Default is for objects to use the "destination" + property of destinations as the target to move to. Turning off this + keyword allows objects to move "inside" exit objects. + to_none (bool): Allow destination to be None. Note that no hooks are run when + moving to a None location. If you want to run hooks, run them manually + (and make sure they can manage None locations). + move_hooks (bool): If False, turn off the calling of move-related hooks + (at_pre/post_move etc) with quiet=True, this is as quiet a move + as can be done. + move_type (str): The "kind of move" being performed, such as "teleport", "traverse", + "get", "give", or "drop". The value can be arbitrary. By default, it only affects + the text message generated by announce_move_to and announce_move_from by defining + their {"type": move_type} for outgoing text. This can be used for altering + messages and/or overloaded hook behaviors. + + Keyword Args: + Passed on to announce_move_to and announce_move_from hooks. + Exits will set the "exit_obj" kwarg to themselves. + + Returns: + result (bool): True/False depending on if there were problems with the move. + This method may also return various error messages to the + `emit_to_obj`. + + Notes: + No access checks are done in this method, these should be handled before + calling `move_to`. + + The `DefaultObject` hooks called (if `move_hooks=True`) are, in order: + + 1. `self.at_pre_move(destination)` (abort if return False) + 2. `source_location.at_pre_object_leave(self, destination)` (abort if return False) + 3. `destination.at_pre_object_receive(self, source_location)` (abort if return False) + 4. `source_location.at_object_leave(self, destination)` + 5. `self.announce_move_from(destination)` + 6. (move happens here) + 7. `self.announce_move_to(source_location)` + 8. `destination.at_object_receive(self, source_location)` + 9. `self.at_post_move(source_location)` + + """ + + def logerr(string="", err=None): + """Simple log helper method""" + logger.log_trace() + self.msg("%s%s" % (string, "" if err is None else " (%s)" % err)) + return + + errtxt = _("Couldn't perform move ({err}). Contact an admin.") + if not emit_to_obj: + emit_to_obj = self + + if not destination: + if to_none: + # immediately move to None. There can be no hooks called since + # there is no destination to call them with. + self.location = None + return True + emit_to_obj.msg(_("The destination doesn't exist.")) + return False + if destination.destination and use_destination: + # traverse exits + destination = destination.destination + + # Save the old location + source_location = self.location + + # Before the move, call pre-hooks + if move_hooks: + # check if we are okay to move + try: + if not self.at_pre_move(destination, move_type=move_type, **kwargs): + return False + except Exception as err: + logerr(errtxt.format(err="at_pre_move()"), err) + return False + # check if source location lets us go + try: + if source_location and not source_location.at_pre_object_leave( + self, destination, **kwargs + ): + return False + except Exception as err: + logerr(errtxt.format(err="at_pre_object_leave()"), err) + return False + # check if destination accepts us + try: + if destination and not destination.at_pre_object_receive( + self, source_location, **kwargs + ): + return False + except Exception as err: + logerr(errtxt.format(err="at_pre_object_receive()"), err) + return False + + # Call hook on source location + if move_hooks and source_location: + try: + source_location.at_object_leave(self, destination, move_type=move_type, **kwargs) + except Exception as err: + logerr(errtxt.format(err="at_object_leave()"), err) + return False + + if not quiet: + # tell the old room we are leaving + try: + self.announce_move_from(destination, move_type=move_type, **kwargs) + except Exception as err: + logerr(errtxt.format(err="announce_move_from()"), err) + return False + + # Perform move + try: + self.location = destination + except Exception as err: + logerr(errtxt.format(err="location change"), err) + return False + + if not quiet: + # Tell the new room we are there. + try: + self.announce_move_to(source_location, move_type=move_type, **kwargs) + except Exception as err: + logerr(errtxt.format(err="announce_move_to()"), err) + return False + + if move_hooks: + # Perform eventual extra commands on the receiving location + # (the object has already arrived at this point) + try: + destination.at_object_receive(self, source_location, move_type=move_type, **kwargs) + except Exception as err: + logerr(errtxt.format(err="at_object_receive()"), err) + return False + + # Execute eventual extra commands on this object after moving it + # (usually calling 'look') + if move_hooks: + try: + self.at_post_move(source_location, move_type=move_type, **kwargs) + except Exception as err: + logerr(errtxt.format(err="at_post_move"), err) + return False + return True
+ +
[docs] def clear_exits(self): + """ + Destroys all of the exits and any exits pointing to this + object as a destination. + + """ + for out_exit in [exi for exi in ObjectDB.objects.get_contents(self) if exi.db_destination]: + out_exit.delete() + for in_exit in ObjectDB.objects.filter(db_destination=self): + in_exit.delete()
+ +
[docs] def clear_contents(self): + """ + Moves all objects (accounts/things) to their home location or + to default home. + + """ + # Gather up everything that thinks this is its location. + default_home_id = int(settings.DEFAULT_HOME.lstrip("#")) + try: + default_home = ObjectDB.objects.get(id=default_home_id) + if default_home.dbid == self.dbid: + # we are deleting default home! + default_home = None + except Exception: + string = _("Could not find default home '(#{dbid})'.") + logger.log_err(string.format(dbid=default_home_id)) + default_home = None + + for obj in self.contents: + home = obj.home + # Obviously, we can't send it back to here. + if not home or (home and home.dbid == self.dbid): + obj.home = default_home + home = default_home + + # If for some reason it's still None... + if not home: + obj.location = None + obj.msg(_("Something went wrong! You are dumped into nowhere. Contact an admin.")) + logger.log_err( + "Missing default home - '{name}(#{dbid})' now has a null location.".format( + name=obj.name, dbid=obj.dbid + ) + ) + return + + if obj.has_account: + if home: + string = "Your current location has ceased to exist," + string += " moving you to (#{dbid})." + obj.msg(_(string).format(dbid=home.dbid)) + else: + # Famous last words: The account should never see this. + string = "This place should not exist ... contact an admin." + obj.msg(_(string)) + obj.move_to(home, move_type="teleport")
+ +
[docs] @classmethod + def get_default_lockstring( + cls, account: "DefaultAccount" = None, caller: "DefaultObject" = None, **kwargs + ): + """ + Classmethod called during .create() to determine default locks for the object. + + Args: + account (Account): Account to attribute this object to. + caller (DefaultObject): The object which is creating this one. + **kwargs: Arbitrary input. + + Returns: + lockstring (str): A lockstring to use for this object. + """ + pid = f"pid({account.id})" if account else None + cid = f"id({caller.id})" if caller else None + admin = "perm(Admin)" + trio = " or ".join([x for x in [pid, cid, admin] if x]) + return ";".join([f"{x}:{trio}" for x in ["control", "delete", "edit"]])
+ +
[docs] @classmethod + def create( + cls, + key: str, + account: "DefaultAccount" = None, + caller: "DefaultObject" = None, + method: str = "create", + **kwargs, + ): + """ + Creates a basic object with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_object() function. + + Args: + key (str): Name of the new object. + + + Keyword Args: + account (Account): Account to attribute this object to. + caller (DefaultObject): The object which is creating this one. + description (str): Brief description for this object. + ip (str): IP address of creator (for object auditing). + method (str): The method of creation. Defaults to "create". + + Returns: + object (Object): A newly created object of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + # Get IP address of creator, if available + ip = kwargs.pop("ip", "") + + # If no typeclass supplied, use this class + kwargs["typeclass"] = kwargs.pop("typeclass", cls) + + # Set the supplied key as the name of the intended object + kwargs["key"] = key + + # Get a supplied description, if any + description = kwargs.pop("description", "") + + # Create a sane lockstring if one wasn't supplied + lockstring = kwargs.get("locks") + if (account or caller) and not lockstring: + lockstring = cls.get_default_lockstring(account=account, caller=caller, **kwargs) + kwargs["locks"] = lockstring + + # Create object + try: + obj = create.create_object(**kwargs) + + # Record creator id and creation IP + if ip: + obj.db.creator_ip = ip + if account: + obj.db.creator_id = account.id + + # Set description if there is none, or update it if provided + if description or not obj.db.desc: + desc = description if description else "You see nothing special." + obj.db.desc = desc + + except Exception as e: + errors.append(f"An error occurred while creating this '{key}' object: {e}") + logger.log_err(e) + + return obj, errors
+ +
[docs] def copy(self, new_key=None, **kwargs): + """ + Makes an identical copy of this object, identical except for a + new dbref in the database. If you want to customize the copy + by changing some settings, use ObjectDB.object.copy_object() + directly. + + Args: + new_key (string): New key/name of copied object. If new_key is not + specified, the copy will be named <old_key>_copy by default. + Returns: + copy (Object): A copy of this object. + + """ + + def find_clone_key(): + """ + Append 01, 02 etc to obj.key. Checks next higher number in the + same location, then adds the next number available + + returns the new clone name on the form keyXX + """ + key = self.key + num = sum( + 1 + for obj in self.location.contents + if obj.key.startswith(key) and obj.key.lstrip(key).isdigit() + ) + return "%s%03i" % (key, num) + + new_key = new_key or find_clone_key() + new_obj = ObjectDB.objects.copy_object(self, new_key=new_key, **kwargs) + self.at_object_post_copy(new_obj, **kwargs) + return new_obj
+ +
[docs] def at_object_post_copy(self, new_obj, **kwargs): + """ + Called by DefaultObject.copy(). Meant to be overloaded. In case there's extra data not + covered by .copy(), this can be used to deal with it. + + Args: + new_obj (Object): The new Copy of this object. + + Returns: + None + """ + pass
+ +
[docs] def delete(self): + """ + Deletes this object. Before deletion, this method makes sure + to move all contained objects to their respective home + locations, as well as clean up all exits to/from the object. + + Returns: + noerror (bool): Returns whether or not the delete completed + successfully or not. + + """ + global _ScriptDB + if not _ScriptDB: + from evennia.scripts.models import ScriptDB as _ScriptDB + + if not self.pk or not self.at_object_delete(): + # This object has already been deleted, + # or the pre-delete check return False + return False + + # See if we need to kick the account off. + + for session in self.sessions.all(): + session.msg(_("Your character {key} has been destroyed.").format(key=self.key)) + # no need to disconnect, Account just jumps to OOC mode. + # sever the connection (important!) + if self.account: + # Remove the object from playable characters list + self.account.characters.remove(self) + for session in self.sessions.all(): + self.account.unpuppet_object(session) + + # unlink account/home to avoid issues with saving + self.db_account = None + self.db_home = None + + for script in _ScriptDB.objects.get_all_scripts_on_obj(self): + script.delete() + + # Destroy any exits to and from this room, if any + self.clear_exits() + # Clear out any non-exit objects located within the object + self.clear_contents() + self.attributes.clear() + self.nicks.clear() + self.aliases.clear() + self.location = None # this updates contents_cache for our location + + # Perform the deletion of the object + super().delete() + return True
+ +
[docs] def access( + self, accessing_obj, access_type="read", default=False, no_superuser_bypass=False, **kwargs + ): + """ + Determines if another object has permission to access this object + in whatever way. + + Args: + accessing_obj (Object): Object trying to access this one. + access_type (str, optional): Type of access sought. + default (bool, optional): What to return if no lock of access_type was found. + no_superuser_bypass (bool, optional): If `True`, don't skip + lock check for superuser (be careful with this one). + + Keyword Args: + Passed on to the at_access hook along with the result of the access check. + + """ + result = super().access( + accessing_obj, + access_type=access_type, + default=default, + no_superuser_bypass=no_superuser_bypass, + ) + self.at_access(result, accessing_obj, access_type, **kwargs) + return result
+ + # name and return_appearance hooks + +
[docs] def get_display_name(self, looker=None, **kwargs): + """ + Displays the name of the object in a viewer-aware manner. + + Args: + looker (TypedObject): The object or account that is looking + at/getting inforamtion for this object. If not given, `.name` will be + returned, which can in turn be used to display colored data. + + Returns: + str: A name to display for this object. This can contain color codes and may + be customized based on `looker`. By default this contains the `.key` of the object, + followed by the DBREF if this user is privileged to control said object. + + Notes: + This function could be extended to change how object names appear to users in character, + but be wary. This function does not change an object's keys or aliases when searching, + and is expected to produce something useful for builders. + + """ + if looker and self.locks.check_lockstring(looker, "perm(Builder)"): + return "{}(#{})".format(self.name, self.id) + return self.name
+ +
[docs] def get_numbered_name(self, count, looker, **kwargs): + """ + Return the numbered (singular, plural) forms of this object's key. This is by default called + by return_appearance and is used for grouping multiple same-named of this object. Note that + this will be called on *every* member of a group even though the plural name will be only + shown once. Also the singular display version, such as 'an apple', 'a tree' is determined + from this method. + + Args: + count (int): Number of objects of this type + looker (Object): Onlooker. Not used by default. + + Keyword Args: + key (str): Optional key to pluralize. If not given, the object's `.name` property is + used. + + Returns: + tuple: This is a tuple `(str, str)` with the singular and plural forms of the key + including the count. + + Examples: + :: + obj.get_numbered_name(3, looker, key="foo") -> ("a foo", "three foos") + + """ + plural_category = "plural_key" + key = kwargs.get("key", self.name) + key = ansi.ANSIString(key) # this is needed to allow inflection of colored names + try: + plural = _INFLECT.plural(key, count) + plural = "{} {}".format(_INFLECT.number_to_words(count, threshold=12), plural) + except IndexError: + # this is raised by inflect if the input is not a proper noun + plural = key + singular = _INFLECT.an(key) + if not self.aliases.get(plural, category=plural_category): + # we need to wipe any old plurals/an/a in case key changed in the interrim + self.aliases.clear(category=plural_category) + self.aliases.add(plural, category=plural_category) + # save the singular form as an alias here too so we can display "an egg" and also + # look at 'an egg'. + self.aliases.add(singular, category=plural_category) + return singular, plural
+ +
[docs] def get_display_header(self, looker, **kwargs): + """ + Get the 'header' component of the object description. Called by `return_appearance`. + + Args: + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The header display string. + + """ + return ""
+ +
[docs] def get_display_desc(self, looker, **kwargs): + """ + Get the 'desc' component of the object description. Called by `return_appearance`. + + Args: + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The desc display string. + + """ + return self.db.desc or "You see nothing special."
+ +
[docs] def get_display_exits(self, looker, **kwargs): + """ + Get the 'exits' component of the object description. Called by `return_appearance`. + + Args: + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The exits display data. + + """ + + def _filter_visible(obj_list): + return (obj for obj in obj_list if obj != looker and obj.access(looker, "view")) + + exits = _filter_visible(self.contents_get(content_type="exit")) + exit_names = iter_to_str(exi.get_display_name(looker, **kwargs) for exi in exits) + + return f"|wExits:|n {exit_names}" if exit_names else ""
+ +
[docs] def get_display_characters(self, looker, **kwargs): + """ + Get the 'characters' component of the object description. Called by `return_appearance`. + + Args: + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The character display data. + + """ + + def _filter_visible(obj_list): + return (obj for obj in obj_list if obj != looker and obj.access(looker, "view")) + + characters = _filter_visible(self.contents_get(content_type="character")) + character_names = iter_to_str( + char.get_display_name(looker, **kwargs) for char in characters + ) + + return f"\n|wCharacters:|n {character_names}" if character_names else ""
+ +
[docs] def get_display_things(self, looker, **kwargs): + """ + Get the 'things' component of the object description. Called by `return_appearance`. + + Args: + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The things display data. + + """ + + def _filter_visible(obj_list): + return (obj for obj in obj_list if obj != looker and obj.access(looker, "view")) + + # sort and handle same-named things + things = _filter_visible(self.contents_get(content_type="object")) + + grouped_things = defaultdict(list) + for thing in things: + grouped_things[thing.get_display_name(looker, **kwargs)].append(thing) + + thing_names = [] + for thingname, thinglist in sorted(grouped_things.items()): + nthings = len(thinglist) + thing = thinglist[0] + singular, plural = thing.get_numbered_name(nthings, looker, key=thingname) + thing_names.append(singular if nthings == 1 else plural) + thing_names = iter_to_str(thing_names) + return f"\n|wYou see:|n {thing_names}" if thing_names else ""
+ + + +
[docs] def format_appearance(self, appearance, looker, **kwargs): + """ + Final processing of the entire appearance string. Called by `return_appearance`. + + Args: + appearance (str): The compiled appearance string. + looker (Object): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + str: The final formatted output. + + """ + return appearance.strip()
+ +
[docs] def return_appearance(self, looker, **kwargs): + """ + Main callback used by 'look' for the object to describe itself. + This formats a description. By default, this looks for the `appearance_template` + string set on this class and populates it with formatting keys + 'name', 'desc', 'exits', 'characters', 'things' as well as + (currently empty) 'header'/'footer'. Each of these values are + retrieved by a matching method `.get_display_*`, such as `get_display_name`, + `get_display_footer` etc. + + Args: + looker (Object): Object doing the looking. Passed into all helper methods. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call. This is passed into all helper methods. + + Returns: + str: The description of this entity. By default this includes + the entity's name, description and any contents inside it. + + Notes: + To simply change the layout of how the object displays itself (like + adding some line decorations or change colors of different sections), + you can simply edit `.appearance_template`. You only need to override + this method (and/or its helpers) if you want to change what is passed + into the template or want the most control over output. + + """ + + if not looker: + return "" + + # populate the appearance_template string. + return self.format_appearance( + self.appearance_template.format( + name=self.get_display_name(looker, **kwargs), + desc=self.get_display_desc(looker, **kwargs), + header=self.get_display_header(looker, **kwargs), + footer=self.get_display_footer(looker, **kwargs), + exits=self.get_display_exits(looker, **kwargs), + characters=self.get_display_characters(looker, **kwargs), + things=self.get_display_things(looker, **kwargs), + ), + looker, + **kwargs, + )
+ + # + # Hook methods + # + +
[docs] def at_first_save(self): + """ + This is called by the typeclass system whenever an instance of + this class is saved for the first time. It is a generic hook + for calling the startup hooks for the various game entities. + When overloading you generally don't overload this but + overload the hooks called by this method. + + """ + self.basetype_setup() + self.at_object_creation() + # initialize Attribute/TagProperties + self.init_evennia_properties() + + if hasattr(self, "_createdict"): + # this will only be set if the utils.create function + # was used to create the object. We want the create + # call's kwargs to override the values set by hooks. + cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#%i" % self.dbid + updates.append("db_key") + elif self.key != cdict.get("key"): + updates.append("db_key") + self.db_key = cdict["key"] + if cdict.get("location") and self.location != cdict["location"]: + self.db_location = cdict["location"] + updates.append("db_location") + if cdict.get("home") and self.home != cdict["home"]: + self.home = cdict["home"] + updates.append("db_home") + if cdict.get("destination") and self.destination != cdict["destination"]: + self.destination = cdict["destination"] + updates.append("db_destination") + if updates: + self.save(update_fields=updates) + + if cdict.get("permissions"): + self.permissions.batch_add(*cdict["permissions"]) + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("aliases"): + self.aliases.batch_add(*cdict["aliases"]) + if cdict.get("location"): + cdict["location"].at_object_receive(self, None) + self.at_post_move(None) + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"].items(): + self.nattributes.add(key, value) + + del self._createdict + + self.basetype_posthook_setup()
+ + # hooks called by the game engine # + +
[docs] def basetype_setup(self): + """ + This sets up the default properties of an Object, just before + the more general at_object_creation. + + You normally don't need to change this unless you change some + fundamental things like names of permission groups. + + """ + # the default security setup fallback for a generic + # object. Overload in child for a custom setup. Also creation + # commands may set this (create an item and you should be its + # controller, for example) + + self.locks.add( + ";".join( + [ + "control:perm(Developer)", # edit locks/permissions, delete + "examine:perm(Builder)", # examine properties + "view:all()", # look at object (visibility) + "edit:perm(Admin)", # edit properties/attributes + "delete:perm(Admin)", # delete object + "get:all()", # pick up object + "drop:holds()", # drop only that which you hold + "call:true()", # allow to call commands on this object + "tell:perm(Admin)", # allow emits to this object + "puppet:pperm(Developer)", + "teleport:true()", + "teleport_here:true()", + ] + ) + ) # lock down puppeting only to staff by default
+ +
[docs] def basetype_posthook_setup(self): + """ + Called once, after basetype_setup and at_object_creation. This + should generally not be overloaded unless you are redefining + how a room/exit/object works. It allows for basetype-like + setup after the object is created. An example of this is + EXITs, who need to know keys, aliases, locks etc to set up + their exit-cmdsets. + + """ + pass
+ +
[docs] def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + + """ + pass
+ +
[docs] def at_object_delete(self): + """ + Called just before the database object is persistently + delete()d from the database. If this method returns False, + deletion is aborted. + + """ + return True
+ +
[docs] def at_init(self): + """ + This is always called whenever this object is initiated -- + that is, whenever it its typeclass is cached from memory. This + happens on-demand first time the object is used or activated + in some way after being created but also after each server + restart or reload. + + """ + pass
+ +
[docs] def at_cmdset_get(self, **kwargs): + """ + Called just before cmdsets on this object are requested by the + command handler. If changes need to be done on the fly to the + cmdset before passing them on to the cmdhandler, this is the + place to do it. This is called also if the object currently + have no cmdsets. + + Keyword Args: + caller (Object, Account or Session): The object requesting the cmdsets. + current (CmdSet): The current merged cmdset. + force_init (bool): If `True`, force a re-build of the cmdset. (seems unused) + **kwargs: Arbitrary input for overloads. + + """ + pass
+ +
[docs] def get_cmdsets(self, caller, current, **kwargs): + """ + Called by the CommandHandler to get a list of cmdsets to merge. + + Args: + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + **kwargs: Arbitrary input for overloads. + + Returns: + tuple: A tuple of (current, cmdsets), which is probably self.cmdset.current and self.cmdset.cmdset_stack + """ + return self.cmdset.current, list(self.cmdset.cmdset_stack)
+ +
[docs] def at_pre_puppet(self, account, session=None, **kwargs): + """ + Called just before an Account connects to this object to puppet + it. + + Args: + account (Account): This is the connecting account. + session (Session): Session controlling the connection. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_post_puppet(self, **kwargs): + """ + Called just after puppeting has been completed and all + Account<->Object links have been established. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + Note: + You can use `self.account` and `self.sessions.get()` to get + account and sessions at this point; the last entry in the + list from `self.sessions.get()` is the latest Session + puppeting this Object. + + """ + self.msg(f"You become |w{self.key}|n.") + self.account.db._last_puppet = self
+ +
[docs] def at_pre_unpuppet(self, **kwargs): + """ + Called just before beginning to un-connect a puppeting from + this Account. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + Note: + You can use `self.account` and `self.sessions.get()` to get + account and sessions at this point; the last entry in the + list from `self.sessions.get()` is the latest Session + puppeting this Object. + + """ + pass
+ +
[docs] def at_post_unpuppet(self, account=None, session=None, **kwargs): + """ + Called just after the Account successfully disconnected from + this object, severing all connections. + + Args: + account (Account): The account object that just disconnected + from this object. This can be `None` if this is called + automatically (such as after a cleanup operation). + session (Session): Session id controlling the connection that + just disconnected. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_server_reload(self): + """ + This hook is called whenever the server is shutting down for + restart/reboot. If you want to, for example, save non-persistent + properties across a restart, this is the place to do it. + + """ + pass
+ +
[docs] def at_server_shutdown(self): + """ + This hook is called whenever the server is shutting down fully + (i.e. not for a restart). + + """ + pass
+ +
[docs] def at_access(self, result, accessing_obj, access_type, **kwargs): + """ + This is called with the result of an access call, along with + any kwargs used for that call. The return of this method does + not affect the result of the lock check. It can be used e.g. to + customize error messages in a central location or other effects + based on the access result. + + Args: + result (bool): The outcome of the access call. + accessing_obj (Object or Account): The entity trying to gain access. + access_type (str): The type of access that was requested. + + Keyword Args: + Unused by default, added for possible expandability in a game. + + """ + pass
+ + # hooks called when moving the object + +
[docs] def at_pre_move(self, destination, move_type="move", **kwargs): + """ + Called just before starting to move this object to + destination. Return False to abort move. + + Args: + destination (Object): The object we are moving to + move_type (str): The type of move. "give", "traverse", etc. + This is an arbitrary string provided to obj.move_to(). + Useful for altering messages or altering logic depending + on the kind of movement. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + bool: If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + return True
+ +
[docs] def at_pre_object_leave(self, leaving_object, destination, **kwargs): + """ + Called just before this object is about lose an object that was + previously 'inside' it. Return False to abort move. + + Args: + leaving_object (Object): The object that is about to leave. + destination (Object): Where object is going to. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + Returns: + bool: If `leaving_object` should be allowed to leave or not. + + Notes: If this method returns False, None, the move is canceled before + it even started. + + """ + return True
+ +
[docs] def at_pre_object_receive(self, arriving_object, source_location, **kwargs): + """ + Called just before this object received another object. If this + method returns `False`, the move is aborted and the moved entity + remains where it was. + + Args: + arriving_object (Object): The object moved into this one + source_location (Object): Where `moved_object` came from. + Note that this could be `None`. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + bool: If False, abort move and `moved_obj` remains where it was. + + Notes: If this method returns False, None, the move is canceled before + it even started. + + """ + return True
+ + # deprecated alias + at_before_move = at_pre_move + +
[docs] def announce_move_from(self, destination, msg=None, mapping=None, move_type="move", **kwargs): + """ + Called if the move is to be announced. This is + called while we are still standing in the old + location. + + Args: + destination (Object): The place we are going to. + msg (str, optional): a replacement message. + mapping (dict, optional): additional mapping objects. + move_type (str): The type of move. "give", "traverse", etc. + This is an arbitrary string provided to obj.move_to(). + Useful for altering messages or altering logic depending + on the kind of movement. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + You can override this method and call its parent with a + message to simply change the default message. In the string, + you can use the following as mappings (between braces): + object: the object which is moving. + exit: the exit from which the object is moving (if found). + origin: the location of the object before the move. + destination: the location of the object after moving. + + """ + if not self.location: + return + if msg: + string = msg + else: + string = "{object} is leaving {origin}, heading for {destination}." + + location = self.location + exits = [ + o for o in location.contents if o.location is location and o.destination is destination + ] + if not mapping: + mapping = {} + + mapping.update( + { + "object": self, + "exit": exits[0] if exits else "somewhere", + "origin": location or "nowhere", + "destination": destination or "nowhere", + } + ) + + location.msg_contents( + (string, {"type": move_type}), exclude=(self,), from_obj=self, mapping=mapping + )
+ +
[docs] def announce_move_to(self, source_location, msg=None, mapping=None, move_type="move", **kwargs): + """ + Called after the move if the move was not quiet. At this point + we are standing in the new location. + + Args: + source_location (Object): The place we came from + msg (str, optional): the replacement message if location. + mapping (dict, optional): additional mapping objects. + move_type (str): The type of move. "give", "traverse", etc. + This is an arbitrary string provided to obj.move_to(). + Useful for altering messages or altering logic depending + on the kind of movement. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + You can override this method and call its parent with a + message to simply change the default message. In the string, + you can use the following as mappings (between braces): + object: the object which is moving. + exit: the exit from which the object is moving (if found). + origin: the location of the object before the move. + destination: the location of the object after moving. + + """ + + if not source_location and self.location.has_account: + # This was created from nowhere and added to an account's + # inventory; it's probably the result of a create command. + string = _("You now have {name} in your possession.").format( + name=self.get_display_name(self.location) + ) + self.location.msg(string) + return + + if source_location: + if msg: + string = msg + else: + string = _("{object} arrives to {destination} from {origin}.") + else: + string = _("{object} arrives to {destination}.") + + origin = source_location + destination = self.location + exits = [] + if origin: + exits = [ + o + for o in destination.contents + if o.location is destination and o.destination is origin + ] + + if not mapping: + mapping = {} + + mapping.update( + { + "object": self, + "exit": exits[0] if exits else "somewhere", + "origin": origin or "nowhere", + "destination": destination or "nowhere", + } + ) + + destination.msg_contents( + (string, {"type": move_type}), exclude=(self,), from_obj=self, mapping=mapping + )
+ +
[docs] def at_post_move(self, source_location, move_type="move", **kwargs): + """ + Called after move has completed, regardless of quiet mode or + not. Allows changes to the object due to the location it is + now in. + + Args: + source_location (Object): Where we came from. This may be `None`. + move_type (str): The type of move. "give", "traverse", etc. + This is an arbitrary string provided to obj.move_to(). + Useful for altering messages or altering logic depending + on the kind of movement. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ + # deprecated + at_after_move = at_post_move + +
[docs] def at_object_leave(self, moved_obj, target_location, move_type="move", **kwargs): + """ + Called just before an object leaves from inside this object + + Args: + moved_obj (Object): The object leaving + target_location (Object): Where `moved_obj` is going. + move_type (str): The type of move. "give", "traverse", etc. + This is an arbitrary string provided to obj.move_to(). + Useful for altering messages or altering logic depending + on the kind of movement. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_object_receive(self, moved_obj, source_location, move_type="move", **kwargs): + """ + Called after an object has been moved into this object. + + Args: + moved_obj (Object): The object moved into this one + source_location (Object): Where `moved_object` came from. + Note that this could be `None`. + move_type (str): The type of move. "give", "traverse", etc. + This is an arbitrary string provided to obj.move_to(). + Useful for altering messages or altering logic depending + on the kind of movement. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_traverse(self, traversing_object, target_location, **kwargs): + """ + This hook is responsible for handling the actual traversal, + normally by calling + `traversing_object.move_to(target_location)`. It is normally + only implemented by Exit objects. If it returns False (usually + because `move_to` returned False), `at_post_traverse` below + should not be called and instead `at_failed_traverse` should be + called. + + Args: + traversing_object (Object): Object traversing us. + target_location (Object): Where target is going. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_post_traverse(self, traversing_object, source_location, **kwargs): + """ + Called just after an object successfully used this object to + traverse to another object (i.e. this object is a type of + Exit) + + Args: + traversing_object (Object): The object traversing us. + source_location (Object): Where `traversing_object` came from. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + The target location should normally be available as `self.destination`. + """ + pass
+ + # deprecated + at_after_traverse = at_post_traverse + +
[docs] def at_failed_traverse(self, traversing_object, **kwargs): + """ + This is called if an object fails to traverse this object for + some reason. + + Args: + traversing_object (Object): The object that failed traversing us. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + Using the default exits, this hook will not be called if an + Attribute `err_traverse` is defined - this will in that case be + read for an error string instead. + + """ + pass
+ +
[docs] def at_msg_receive(self, text=None, from_obj=None, **kwargs): + """ + This hook is called whenever someone sends a message to this + object using the `msg` method. + + Note that from_obj may be None if the sender did not include + itself as an argument to the obj.msg() call - so you have to + check for this. . + + Consider this a pre-processing method before msg is passed on + to the user session. If this method returns False, the msg + will not be passed on. + + Args: + text (str, optional): The message received. + from_obj (any, optional): The object sending the message. + + Keyword Args: + This includes any keywords sent to the `msg` method. + + Returns: + receive (bool): If this message should be received. + + Notes: + If this method returns False, the `msg` operation + will abort without sending the message. + + """ + return True
+ +
[docs] def at_msg_send(self, text=None, to_obj=None, **kwargs): + """ + This is a hook that is called when *this* object sends a + message to another object with `obj.msg(text, to_obj=obj)`. + + Args: + text (str, optional): Text to send. + to_obj (any, optional): The object to send to. + + Keyword Args: + Keywords passed from msg() + + Notes: + Since this method is executed by `from_obj`, if no `from_obj` + was passed to `DefaultCharacter.msg` this hook will never + get called. + + """ + pass
+ + # hooks called by the default cmdset. + +
[docs] def get_visible_contents(self, looker, **kwargs): + """ + Get all contents of this object that a looker can see (whatever that means, by default it + checks the 'view' and 'search' locks), grouped by type. Helper method to return_appearance. + + Args: + looker (Object): The entity looking. + **kwargs (any): Passed from `return_appearance`. Unused by default. + + Returns: + dict: A dict of lists categorized by type. Byt default this + contains 'exits', 'characters' and 'things'. The elements of these + lists are the actual objects. + + """ + + def filter_visible(obj_list): + return [ + obj + for obj in obj_list + if obj != looker + and obj.access(looker, "view") + and obj.access(looker, "search", default=True) + ] + + return { + "exits": filter_visible(self.contents_get(content_type="exit")), + "characters": filter_visible(self.contents_get(content_type="character")), + "things": filter_visible(self.contents_get(content_type="object")), + }
+ +
[docs] def get_content_names(self, looker, **kwargs): + """ + Get the proper names for all contents of this object. Helper method + for return_appearance. + + Args: + looker (Object): The entity looking. + **kwargs (any): Passed from `return_appearance`. Passed into + `get_display_name` for each found entity. + + Returns: + dict: A dict of lists categorized by type. Byt default this + contains 'exits', 'characters' and 'things'. The elements + of these lists are strings - names of the objects that + can depend on the looker and also be grouped in the case + of multiple same-named things etc. + + Notes: + This method shouldn't add extra coloring to the names beyond what is + already given by the .get_display_name() (and the .name field) already. + Per-type coloring can be applied in `return_appearance`. + + """ + # a mapping {'exits': [...], 'characters': [...], 'things': [...]} + contents_map = self.get_visible_contents(looker, **kwargs) + + character_names = [ + char.get_display_name(looker, **kwargs) for char in contents_map["characters"] + ] + exit_names = [exi.get_display_name(looker, **kwargs) for exi in contents_map["exits"]] + + # group all same-named things under one name + things = defaultdict(list) + for thing in contents_map["things"]: + things[thing.get_display_name(looker, **kwargs)].append(thing) + + # pluralize same-named things + thing_names = [] + for thingname, thinglist in sorted(things.items()): + nthings = len(thinglist) + thing = thinglist[0] + singular, plural = thing.get_numbered_name(nthings, looker, key=thingname) + thing_names.append(singular if nthings == 1 else plural) + + return {"exits": exit_names, "characters": character_names, "things": thing_names}
+ +
[docs] def at_look(self, target, **kwargs): + """ + Called when this object performs a look. It allows to + customize just what this means. It will not itself + send any data. + + Args: + target (Object): The target being looked at. This is + commonly an object or the current location. It will + be checked for the "view" type access. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call. This will be passed into + return_appearance, get_display_name and at_desc but is not used + by default. + + Returns: + lookstring (str): A ready-processed look string + potentially ready to return to the looker. + + """ + if not target.access(self, "view"): + try: + return "Could not view '%s'." % target.get_display_name(self, **kwargs) + except AttributeError: + return "Could not view '%s'." % target.key + + description = target.return_appearance(self, **kwargs) + + # the target's at_desc() method. + # this must be the last reference to target so it may delete itself when acted on. + target.at_desc(looker=self, **kwargs) + + return description
+ +
[docs] def at_desc(self, looker=None, **kwargs): + """ + This is called whenever someone looks at this object. + + Args: + looker (Object, optional): The object requesting the description. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_pre_get(self, getter, **kwargs): + """ + Called by the default `get` command before this object has been + picked up. + + Args: + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldget (bool): If the object should be gotten or not. + + Notes: + If this method returns False/None, the getting is cancelled + before it is even started. + """ + return True
+ + # deprecated + at_before_get = at_pre_get + +
[docs] def at_get(self, getter, **kwargs): + """ + Called by the default `get` command when this object has been + picked up. + + Args: + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the pickup from happening. Use + permissions or the at_pre_get() hook for that. + + """ + pass
+ +
[docs] def at_pre_give(self, giver, getter, **kwargs): + """ + Called by the default `give` command before this object has been + given. + + Args: + giver (Object): The object about to give this object. + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldgive (bool): If the object should be given or not. + + Notes: + If this method returns False/None, the giving is cancelled + before it is even started. + + """ + return True
+ + # deprecated + at_before_give = at_pre_give + +
[docs] def at_give(self, giver, getter, **kwargs): + """ + Called by the default `give` command when this object has been + given. + + Args: + giver (Object): The object giving this object. + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the give from happening. Use + permissions or the at_pre_give() hook for that. + + """ + pass
+ +
[docs] def at_pre_drop(self, dropper, **kwargs): + """ + Called by the default `drop` command before this object has been + dropped. + + Args: + dropper (Object): The object which will drop this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shoulddrop (bool): If the object should be dropped or not. + + Notes: + If this method returns False/None, the dropping is cancelled + before it is even started. + + """ + if not self.locks.get("drop"): + # TODO: This if-statment will be removed in Evennia 1.0 + return True + if not self.access(dropper, "drop", default=False): + dropper.msg(f"You cannot drop {self.get_display_name(dropper)}") + return False + return True
+ + # deprecated + at_before_drop = at_pre_drop + +
[docs] def at_drop(self, dropper, **kwargs): + """ + Called by the default `drop` command when this object has been + dropped. + + Args: + dropper (Object): The object which just dropped this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the drop from happening. Use + permissions or the at_pre_drop() hook for that. + + """ + pass
+ +
[docs] def at_pre_say(self, message, **kwargs): + """ + Before the object says something. + + This hook is by default used by the 'say' and 'whisper' + commands as used by this command it is called before the text + is said/whispered and can be used to customize the outgoing + text from the object. Returning `None` aborts the command. + + Args: + message (str): The suggested say/whisper text spoken by self. + Keyword Args: + whisper (bool): If True, this is a whisper rather than + a say. This is sent by the whisper command by default. + Other verbal commands could use this hook in similar + ways. + receivers (Object or iterable): If set, this is the target or targets for the + say/whisper. + + Returns: + message (str): The (possibly modified) text to be spoken. + + """ + return message
+ + # deprecated + at_before_say = at_pre_say + +
[docs] def at_say( + self, + message, + msg_self=None, + msg_location=None, + receivers=None, + msg_receivers=None, + **kwargs, + ): + """ + Display the actual say (or whisper) of self. + + This hook should display the actual say/whisper of the object in its + location. It should both alert the object (self) and its + location that some text is spoken. The overriding of messages or + `mapping` allows for simple customization of the hook without + re-writing it completely. + + Args: + message (str): The message to convey. + msg_self (bool or str, optional): If boolean True, echo `message` to self. If a string, + return that message. If False or unset, don't echo to self. + msg_location (str, optional): The message to echo to self's location. + receivers (Object or iterable, optional): An eventual receiver or receivers of the + message (by default only used by whispers). + msg_receivers(str): Specific message to pass to the receiver(s). This will parsed + with the {receiver} placeholder replaced with the given receiver. + Keyword Args: + whisper (bool): If this is a whisper rather than a say. Kwargs + can be used by other verbal commands in a similar way. + mapping (dict): Pass an additional mapping to the message. + + Notes: + + + Messages can contain {} markers. These are substituted against the values + passed in the `mapping` argument. + + msg_self = 'You say: "{speech}"' + msg_location = '{object} says: "{speech}"' + msg_receivers = '{object} whispers: "{speech}"' + + Supported markers by default: + {self}: text to self-reference with (default 'You') + {speech}: the text spoken/whispered by self. + {object}: the object speaking. + {receiver}: replaced with a single receiver only for strings meant for a specific + receiver (otherwise 'None'). + {all_receivers}: comma-separated list of all receivers, + if more than one, otherwise same as receiver + {location}: the location where object is. + + """ + msg_type = "say" + if kwargs.get("whisper", False): + # whisper mode + msg_type = "whisper" + 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: "|n{speech}|n"' + msg_location = None + else: + 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 + + custom_mapping = kwargs.get("mapping", {}) + receivers = make_iter(receivers) if receivers else None + location = self.location + + if msg_self: + self_mapping = { + "self": "You", + "object": self.get_display_name(self), + "location": location.get_display_name(self) if location else None, + "receiver": None, + "all_receivers": ", ".join(recv.get_display_name(self) for recv in receivers) + if receivers + else None, + "speech": message, + } + self_mapping.update(custom_mapping) + self.msg(text=(msg_self.format_map(self_mapping), {"type": msg_type}), from_obj=self) + + if receivers and msg_receivers: + receiver_mapping = { + "self": "You", + "object": None, + "location": None, + "receiver": None, + "all_receivers": None, + "speech": message, + } + for receiver in make_iter(receivers): + individual_mapping = { + "object": self.get_display_name(receiver), + "location": location.get_display_name(receiver), + "receiver": receiver.get_display_name(receiver), + "all_receivers": ", ".join(recv.get_display_name(recv) for recv in receivers) + if receivers + else None, + } + receiver_mapping.update(individual_mapping) + receiver_mapping.update(custom_mapping) + receiver.msg( + text=(msg_receivers.format_map(receiver_mapping), {"type": msg_type}), + from_obj=self, + ) + + if self.location and msg_location: + location_mapping = { + "self": "You", + "object": self, + "location": location, + "all_receivers": ", ".join(str(recv) for recv in receivers) if receivers else None, + "receiver": None, + "speech": message, + } + location_mapping.update(custom_mapping) + exclude = [] + if msg_self: + exclude.append(self) + if receivers: + exclude.extend(receivers) + self.location.msg_contents( + text=(msg_location, {"type": msg_type}), + from_obj=self, + exclude=exclude, + mapping=location_mapping, + )
+ + +# +# Base Character object +# + + +
[docs]class DefaultCharacter(DefaultObject): + """ + This implements an Object puppeted by a Session - that is, + a character avatar controlled by an account. + + """ + + # Tuple of types used for indexing inventory contents. Characters generally wouldn't be in + # anyone's inventory, but this also governs displays in room contents. + _content_types = ("character",) + # lockstring of newly created rooms, for easy overloading. + # Will be formatted with the appropriate attributes. + lockstring = ( + "puppet:id({character_id}) or pid({account_id}) or perm(Developer) or pperm(Developer);" + "delete:id({account_id}) or perm(Admin);" + "edit:pid({account_id}) or perm(Admin)" + ) + +
[docs] @classmethod + def get_default_lockstring( + cls, account: "DefaultAccount" = None, caller: "DefaultObject" = None, **kwargs + ): + """ + Classmethod called during .create() to determine default locks for the object. + + Args: + account (Account): Account to attribute this object to. + caller (DefaultObject): The object which is creating this one. + **kwargs: Arbitrary input. + + Returns: + lockstring (str): A lockstring to use for this object. + """ + pid = f"pid({account.id})" if account else None + character = kwargs.get("character", None) + cid = f"id({character})" if character else None + + puppet = "puppet:" + " or ".join( + [x for x in [pid, cid, "perm(Developer)", "pperm(Developer)"] if x] + ) + delete = "delete:" + " or ".join([x for x in [pid, "perm(Admin)"] if x]) + edit = "edit:" + " or ".join([x for x in [pid, "perm(Admin)"] if x]) + + return ";".join([puppet, delete, edit])
+ +
[docs] @classmethod + def create(cls, key, account=None, **kwargs): + """ + Creates a basic Character with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_character() function. + + Args: + key (str): Name of the new Character. + account (obj, optional): Account to associate this Character with. + If unset supplying None-- it will + change the default lockset and skip creator attribution. + + Keyword Args: + description (str): Brief description for this object. + ip (str): IP address of creator (for object auditing). + All other kwargs will be passed into the create_object call. + + Returns: + tuple: `(new_character, errors)`. On error, the `new_character` is `None` and + `errors` is a `list` of error strings (an empty list otherwise). + + """ + errors = [] + obj = None + # Get IP address of creator, if available + ip = kwargs.pop("ip", "") + + # If no typeclass supplied, use this class + kwargs["typeclass"] = kwargs.pop("typeclass", cls) + + # Normalize to latin characters and validate, if necessary, the supplied key + key = cls.normalize_name(key) + + if val_err := cls.validate_name(key, account=account): + errors.append(val_err) + return obj, errors + + # Set the supplied key as the name of the intended object + kwargs["key"] = key + + # Get permissions + kwargs["permissions"] = kwargs.get("permissions", settings.PERMISSION_ACCOUNT_DEFAULT) + + # Get description if provided + description = kwargs.pop("description", "") + + # Get locks if provided + locks = kwargs.pop("locks", "") + + try: + # Check to make sure account does not have too many chars + if account: + avail = account.check_available_slots() + if avail: + errors.append(avail) + return obj, errors + + # Create the Character + obj = create.create_object(**kwargs) + + # Record creator id and creation IP + if ip: + obj.db.creator_ip = ip + if account: + obj.db.creator_id = account.id + account.characters.add(obj) + + # Add locks + if not locks: + # Allow only the character itself and the creator account to puppet this character + # (and Developers). + locks = cls.get_default_lockstring(account=account, character=obj) + + if locks: + obj.locks.add(locks) + + # If no description is set, set a default description + if description or not obj.db.desc: + obj.db.desc = description if description else _("This is a character.") + + except Exception as e: + errors.append(f"An error occurred while creating object '{key} object: {e}") + logger.log_err(e) + + return obj, errors
+ +
[docs] @classmethod + def normalize_name(cls, name): + """ + Normalize the character name prior to creating. Note that this should be refactored to + support i18n for non-latin scripts, but as we (currently) have no bug reports requesting + better support of non-latin character sets, requiring character names to be latinified is an + acceptable option. + + Args: + name (str) : The name of the character + + Returns: + latin_name (str) : A valid name. + """ + + from evennia.utils.utils import latinify + + latin_name = latinify(name, default="X") + return latin_name
+ +
[docs] @classmethod + def validate_name(cls, name, account=None) -> typing.Optional[str]: + """ + Validate the character name prior to creating. Overload this function to add custom validators + + Args: + name (str) : The name of the character + Kwargs: + account (DefaultAccount, optional) : The account creating the character. + Returns: + error (str, optional) : A non-empty error message if there is a problem, otherwise False. + + """ + if account and cls.objects.filter_family(db_key__iexact=name): + return f"|rA character named '|w{name}|r' already exists.|n"
+ +
[docs] def basetype_setup(self): + """ + Setup character-specific security. + + You should normally not need to overload this, but if you do, + make sure to reproduce at least the two last commands in this + method (unless you want to fundamentally change how a + Character object works). + + """ + super().basetype_setup() + self.locks.add( + ";".join( + [ + "get:false()", + "call:false()", + "teleport:perm(Admin)", + "teleport_here:perm(Admin)", + ] + ) # noone can pick up the character + ) # no commands can be called on character from outside + # add the default cmdset + self.cmdset.add_default(settings.CMDSET_CHARACTER, persistent=True)
+ +
[docs] def at_post_move(self, source_location, move_type="move", **kwargs): + """ + We make sure to look around after a move. + + """ + if self.location.access(self, "view"): + self.msg(text=(self.at_look(self.location), {"type": "look"}))
+ + # deprecated + at_after_move = at_post_move + +
[docs] def at_pre_puppet(self, account, session=None, **kwargs): + """ + Return the character from storage in None location in `at_post_unpuppet`. + Args: + account (Account): This is the connecting account. + session (Session): Session controlling the connection. + + """ + if self.location is None: + # Make sure character's location is never None before being puppeted. + # Return to last location (or home, which should always exist) + location = self.db.prelogout_location if self.db.prelogout_location else self.home + if location: + self.location = location + self.location.at_object_receive(self, None) + + if self.location: + self.db.prelogout_location = self.location # save location again to be sure. + else: + account.msg( + _("|r{obj} has no location and no home is set.|n").format(obj=self), session=session + )
+ +
[docs] def at_post_puppet(self, **kwargs): + """ + Called just after puppeting has been completed and all + Account<->Object links have been established. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + Note: + You can use `self.account` and `self.sessions.get()` to get + account and sessions at this point; the last entry in the + list from `self.sessions.get()` is the latest Session + puppeting this Object. + + """ + self.msg(_("\nYou become |c{name}|n.\n").format(name=self.key)) + self.msg((self.at_look(self.location), {"type": "look"}), options=None) + + def message(obj, from_obj): + obj.msg( + _("{name} has entered the game.").format(name=self.get_display_name(obj)), + from_obj=from_obj, + ) + + self.location.for_contents(message, exclude=[self], from_obj=self)
+ +
[docs] def at_post_unpuppet(self, account=None, session=None, **kwargs): + """ + We stove away the character when the account goes ooc/logs off, + otherwise the character object will remain in the room also + after the account logged off ("headless", so to say). + + Args: + account (Account): The account object that just disconnected + from this object. + session (Session): Session controlling the connection that + just disconnected. + Keyword Args: + reason (str): If given, adds a reason for the unpuppet. This + is set when the user is auto-unpuppeted due to being link-dead. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + """ + if not self.sessions.count(): + # only remove this char from grid if no sessions control it anymore. + if self.location: + + def message(obj, from_obj): + obj.msg( + _("{name} has left the game{reason}.").format( + name=self.get_display_name(obj), + reason=kwargs.get("reason", ""), + ), + from_obj=from_obj, + ) + + self.location.for_contents(message, exclude=[self], from_obj=self) + self.db.prelogout_location = self.location + self.location = None
+ + @property + def idle_time(self): + """ + Returns the idle time of the least idle session in seconds. If + no sessions are connected it returns nothing. + + """ + idle = [session.cmd_last_visible for session in self.sessions.all()] + if idle: + return time.time() - float(max(idle)) + return None + + @property + def connection_time(self): + """ + Returns the maximum connection time of all connected sessions + in seconds. Returns nothing if there are no sessions. + + """ + conn = [session.conn_time for session in self.sessions.all()] + if conn: + return time.time() - float(min(conn)) + return None
+ + +# +# Base Room object + + +
[docs]class DefaultRoom(DefaultObject): + """ + This is the base room object. It's just like any Object except its + location is always `None`. + """ + + # A tuple of strings used for indexing this object inside an inventory. + # Generally, a room isn't expected to HAVE a location, but maybe in some games? + _content_types = ("room",) + +
[docs] @classmethod + def create( + cls, + key: str, + account: "DefaultAccount" = None, + caller: DefaultObject = None, + method: str = "create", + **kwargs, + ): + """ + Creates a basic Room with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_object() function. + + Args: + key (str): Name of the new Room. + + Keyword Args: + account (DefaultAccount, optional): Account to associate this Room with. If + given, it will be given specific control/edit permissions to this + object (along with normal Admin perms). If not given, default + caller (DefaultObject): The object which is creating this one. + description (str): Brief description for this object. + ip (str): IP address of creator (for object auditing). + method (str): The method used to create the room. Defaults to "create". + + Returns: + room (Object): A newly created Room of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + # Get IP address of creator, if available + ip = kwargs.pop("ip", "") + + # If no typeclass supplied, use this class + kwargs["typeclass"] = kwargs.pop("typeclass", cls) + + # Set the supplied key as the name of the intended object + kwargs["key"] = key + + # Get who to send errors to + kwargs["report_to"] = kwargs.pop("report_to", account) + + # Get description, if provided + description = kwargs.pop("description", "") + + # get locks if provided + locks = kwargs.pop("locks", "") + + try: + # Create the Room + obj = create.create_object(**kwargs) + + # Add locks + if not locks: + locks = cls.get_default_lockstring(account=account, caller=caller, room=obj) + if locks: + obj.locks.add(locks) + + # Record creator id and creation IP + if ip: + obj.db.creator_ip = ip + if account: + obj.db.creator_id = account.id + + # If no description is set, set a default description + if description or not obj.db.desc: + obj.db.desc = description if description else _("This is a room.") + + except Exception as e: + errors.append(f"An error occurred while creating this '{key}' object: {e}") + logger.log_err(e) + + return obj, errors
+ +
[docs] def basetype_setup(self): + """ + Simple room setup setting locks to make sure the room + cannot be picked up. + + """ + + super().basetype_setup() + self.locks.add( + ";".join(["get:false()", "puppet:false()", "teleport:false()", "teleport_here:true()"]) + ) # would be weird to puppet a room ... + self.location = None
+ + +# +# Default Exit command, used by the base exit object +# + + +
[docs]class ExitCommand(_COMMAND_DEFAULT_CLASS): + """ + This is a command that simply cause the caller to traverse + the object it is attached to. + + """ + + obj = None + +
[docs] def func(self): + """ + Default exit traverse if no syscommand is defined. + """ + + if self.obj.access(self.caller, "traverse"): + # we may traverse the exit. + self.obj.at_traverse(self.caller, self.obj.destination) + SIGNAL_EXIT_TRAVERSED.send(sender=self.obj, traverser=self.caller) + else: + # exit is locked + if self.obj.db.err_traverse: + # if exit has a better error message, let's use it. + self.caller.msg(self.obj.db.err_traverse) + else: + # No shorthand error message. Call hook. + self.obj.at_failed_traverse(self.caller)
+ +
[docs] def get_extra_info(self, caller, **kwargs): + """ + Shows a bit of information on where the exit leads. + + Args: + caller (Object): The object (usually a character) that entered an ambiguous command. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + A string with identifying information to disambiguate the command, conventionally with a + preceding space. + + """ + if self.obj.destination: + return " (exit to %s)" % self.obj.destination.get_display_name(caller, **kwargs) + else: + return " (%s)" % self.obj.get_display_name(caller, **kwargs)
+ + +# +# Base Exit object + + +
[docs]class DefaultExit(DefaultObject): + """ + This is the base exit object - it connects a location to another. + This is done by the exit assigning a "command" on itself with the + same name as the exit object (to do this we need to remember to + re-create the command when the object is cached since it must be + created dynamically depending on what the exit is called). This + command (which has a high priority) will thus allow us to traverse + exits simply by giving the exit-object's name on its own. + + """ + + _content_types = ("exit",) + exit_command = ExitCommand + priority = 101 + + # Helper classes and methods to implement the Exit. These need not + # be overloaded unless one want to change the foundation for how + # Exits work. See the end of the class for hook methods to overload. + +
[docs] def create_exit_cmdset(self, exidbobj): + """ + Helper function for creating an exit command set + command. + + The command of this cmdset has the same name as the Exit + object and allows the exit to react when the account enter the + exit's name, triggering the movement between rooms. + + Args: + exidbobj (Object): The DefaultExit object to base the command on. + + """ + + # create an exit command. We give the properties here, + # to always trigger metaclass preparations + cmd = self.exit_command( + key=exidbobj.db_key.strip().lower(), + aliases=exidbobj.aliases.all(), + locks=str(exidbobj.locks), + auto_help=False, + destination=exidbobj.db_destination, + arg_regex=r"^$", + is_exit=True, + obj=exidbobj, + ) + # create a cmdset + exit_cmdset = cmdset.CmdSet(None) + exit_cmdset.key = "ExitCmdSet" + exit_cmdset.priority = self.priority + exit_cmdset.duplicates = True + # add command to cmdset + exit_cmdset.add(cmd) + return exit_cmdset
+ + # Command hooks + +
[docs] @classmethod + def create( + cls, + key: str, + location: DefaultRoom = None, + destination: DefaultRoom = None, + account: "DefaultAccount" = None, + caller: DefaultObject = None, + method: str = "create", + **kwargs, + ) -> tuple[typing.Optional["DefaultExit"], list[str]]: + """ + Creates a basic Exit with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_object() function. + + Args: + key (str): Name of the new Exit, as it should appear from the + source room. + location (Room): The room to create this exit in. + + Keyword Args: + account (AccountDB): Account to associate this Exit with. + caller (ObjectDB): The Object creating this Object. + description (str): Brief description for this object. + ip (str): IP address of creator (for object auditing). + destination (Room): The room to which this exit should go. + + Returns: + exit (Object): A newly created Room of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + # Get IP address of creator, if available + ip = kwargs.pop("ip", "") + + # If no typeclass supplied, use this class + kwargs["typeclass"] = kwargs.pop("typeclass", cls) + + # Set the supplied key as the name of the intended object + kwargs["key"] = key + + # Get who to send errors to + kwargs["report_to"] = kwargs.pop("report_to", account) + + # Set to/from rooms + kwargs["location"] = location + kwargs["destination"] = destination + + description = kwargs.pop("description", "") + + locks = kwargs.get("locks", "") + + try: + # Create the Exit + obj = create.create_object(**kwargs) + + # Set appropriate locks + if not locks: + locks = cls.get_default_lockstring(account=account, caller=caller, exit=obj) + if locks: + obj.locks.add(locks) + + # Record creator id and creation IP + if ip: + obj.db.creator_ip = ip + if account: + obj.db.creator_id = account.id + + # If no description is set, set a default description + if description or not obj.db.desc: + obj.db.desc = description if description else _("This is an exit.") + + except Exception as e: + errors.append(f"An error occurred while creating this '{key}' object: {e}") + logger.log_err(e) + + return obj, errors
+ +
[docs] def basetype_setup(self): + """ + Setup exit-security + + You should normally not need to overload this - if you do make + sure you include all the functionality in this method. + + """ + super().basetype_setup() + + # setting default locks (overload these in at_object_creation() + self.locks.add( + ";".join( + [ + "puppet:false()", # would be weird to puppet an exit ... + "traverse:all()", # who can pass through exit by default + "get:false()", # noone can pick up the exit + "teleport:false()", + "teleport_here:false()", + ] + ) + ) + + # an exit should have a destination - try to make sure it does + if self.location and not self.destination: + self.destination = self.location
+ +
[docs] def at_cmdset_get(self, **kwargs): + """ + Called just before cmdsets on this object are requested by the + command handler. If changes need to be done on the fly to the + cmdset before passing them on to the cmdhandler, this is the + place to do it. This is called also if the object currently + has no cmdsets. + + Keyword Args: + caller (Object, Account or Session): The object requesting the cmdsets. + current (CmdSet): The current merged cmdset. + force_init (bool): If `True`, force a re-build of the cmdset + (for example to update aliases). + + """ + + if "force_init" in kwargs or not self.cmdset.has_cmdset("ExitCmdSet", must_be_default=True): + # we are resetting, or no exit-cmdset was set. Create one dynamically. + self.cmdset.add_default(self.create_exit_cmdset(self), persistent=False)
+ +
[docs] def at_init(self): + """ + This is called when this objects is re-loaded from cache. When + that happens, we make sure to remove any old ExitCmdSet cmdset + (this most commonly occurs when renaming an existing exit) + """ + self.cmdset.remove_default()
+ +
[docs] def at_traverse(self, traversing_object, target_location, **kwargs): + """ + This implements the actual traversal. The traverse lock has + already been checked (in the Exit command) at this point. + + Args: + traversing_object (Object): Object traversing us. + target_location (Object): Where target is going. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + source_location = traversing_object.location + if traversing_object.move_to(target_location, move_type="traverse", exit_obj=self): + self.at_post_traverse(traversing_object, source_location) + else: + if self.db.err_traverse: + # if exit has a better error message, let's use it. + traversing_object.msg(self.db.err_traverse) + else: + # No shorthand error message. Call hook. + self.at_failed_traverse(traversing_object)
+ +
[docs] def at_failed_traverse(self, traversing_object, **kwargs): + """ + Overloads the default hook to implement a simple default error message. + + Args: + traversing_object (Object): The object that failed traversing us. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + Using the default exits, this hook will not be called if an + Attribute `err_traverse` is defined - this will in that case be + read for an error string instead. + + """ + traversing_object.msg(_("You cannot go there."))
+ +
[docs] def get_return_exit(self, return_all=False): + """ + Get the exits that pair with this one in its destination room + (i.e. returns to its location) + + Args: + return_all (bool): Whether to return available results as a + list or single matching exit. + + Returns: + queryset or exit (Exit): The matching exit(s). + """ + query = ObjectDB.objects.filter(db_location=self.destination, db_destination=self.location) + if return_all: + return query + return query.first()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/prototypes/menus.html b/docs/latest/_modules/evennia/prototypes/menus.html new file mode 100644 index 0000000000..e9100d224c --- /dev/null +++ b/docs/latest/_modules/evennia/prototypes/menus.html @@ -0,0 +1,2852 @@ + + + + + + + + evennia.prototypes.menus — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.prototypes.menus

+"""
+
+OLC Prototype menu nodes
+
+"""
+
+import json
+import re
+from random import choice
+
+from django.conf import settings
+from django.db.models import Q
+
+from evennia.locks.lockhandler import get_all_lockfuncs
+from evennia.objects.models import ObjectDB
+from evennia.prototypes import prototypes as protlib
+from evennia.prototypes import spawner
+from evennia.utils import evmore, utils
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.evmenu import EvMenu, list_node
+
+# ------------------------------------------------------------
+#
+# OLC Prototype design menu
+#
+# ------------------------------------------------------------
+
+_MENU_CROP_WIDTH = 15
+_MENU_ATTR_LITERAL_EVAL_ERROR = (
+    "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n"
+    "You also need to use correct Python syntax. Remember especially to put quotes around all "
+    "strings inside lists and dicts.|n"
+)
+
+
+# Helper functions
+
+
+def _get_menu_prototype(caller):
+    """Return currently active menu prototype."""
+    prototype = None
+    if hasattr(caller.ndb._menutree, "olc_prototype"):
+        prototype = caller.ndb._menutree.olc_prototype
+    if not prototype:
+        caller.ndb._menutree.olc_prototype = prototype = {}
+        caller.ndb._menutree.olc_new = True
+    return prototype
+
+
+def _get_flat_menu_prototype(caller, refresh=False, validate=False):
+    """Return prototype where parent values are included"""
+    flat_prototype = None
+    if not refresh and hasattr(caller.ndb._menutree, "olc_flat_prototype"):
+        flat_prototype = caller.ndb._menutree.olc_flat_prototype
+    if not flat_prototype:
+        prot = _get_menu_prototype(caller)
+        caller.ndb._menutree.olc_flat_prototype = flat_prototype = spawner.flatten_prototype(
+            prot, validate=validate
+        )
+    return flat_prototype
+
+
+def _get_unchanged_inherited(caller, protname):
+    """Return prototype values inherited from parent(s), which are not replaced in child"""
+    prototype = _get_menu_prototype(caller)
+    if protname in prototype:
+        return protname[protname], False
+    else:
+        flattened = _get_flat_menu_prototype(caller)
+        if protname in flattened:
+            return protname[protname], True
+    return None, False
+
+
+def _set_menu_prototype(caller, prototype):
+    """Set the prototype with existing one"""
+    caller.ndb._menutree.olc_prototype = prototype
+    caller.ndb._menutree.olc_new = False
+    return prototype
+
+
+def _is_new_prototype(caller):
+    """Check if prototype is marked as new or was loaded from a saved one."""
+    return hasattr(caller.ndb._menutree, "olc_new")
+
+
+def _format_option_value(prop, required=False, prototype=None, cropper=None):
+    """
+    Format wizard option values.
+
+    Args:
+        prop (str): Name or value to format.
+        required (bool, optional): The option is required.
+        prototype (dict, optional): If given, `prop` will be considered a key in this prototype.
+        cropper (callable, optional): A function to crop the value to a certain width.
+
+    Returns:
+        value (str): The formatted value.
+    """
+    if prototype is not None:
+        prop = prototype.get(prop, "")
+
+    out = prop
+    if callable(prop):
+        if hasattr(prop, "__name__"):
+            out = "<{}>".format(prop.__name__)
+        else:
+            out = repr(prop)
+    if utils.is_iter(prop):
+        out = ", ".join(str(pr) for pr in prop)
+    if not out and required:
+        out = "|runset"
+    if out:
+        return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH))
+    return ""
+
+
+def _set_prototype_value(caller, field, value, parse=True):
+    """Set prototype's field in a safe way."""
+    prototype = _get_menu_prototype(caller)
+    prototype[field] = value
+    caller.ndb._menutree.olc_prototype = prototype
+    return prototype
+
+
+def _set_property(caller, raw_string, **kwargs):
+    """
+    Add or update a property. To be called by the 'goto' option variable.
+
+    Args:
+        caller (Object, Account): The user of the wizard.
+        raw_string (str): Input from user on given node - the new value to set.
+
+    Keyword Args:
+        test_parse (bool): If set (default True), parse raw_string for protfuncs and obj-refs and
+            try to run result through literal_eval. The parser will be run in 'testing' mode and any
+            parsing errors will shown to the user. Note that this is just for testing, the original
+            given string will be what is inserted.
+        prop (str): Property name to edit with `raw_string`.
+        processor (callable): Converts `raw_string` to a form suitable for saving.
+        next_node (str): Where to redirect to after this has run.
+
+    Returns:
+        next_node (str): Next node to go to.
+
+    """
+    prop = kwargs.get("prop", "prototype_key")
+    processor = kwargs.get("processor", None)
+    next_node = kwargs.get("next_node", None)
+
+    if callable(processor):
+        try:
+            value = processor(raw_string)
+        except Exception as err:
+            caller.msg(
+                "Could not set {prop} to {value} ({err})".format(
+                    prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err)
+                )
+            )
+            # this means we'll re-run the current node.
+            return None
+    else:
+        value = raw_string
+
+    if not value:
+        return next_node
+
+    prototype = _set_prototype_value(caller, prop, value)
+    caller.ndb._menutree.olc_prototype = prototype
+
+    try:
+        # TODO simple way to get rid of the u'' markers in list reprs, remove this when on py3.
+        repr_value = json.dumps(value)
+    except Exception:
+        repr_value = value
+
+    out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=repr_value, typ=type(value))]
+
+    if kwargs.get("test_parse", True):
+        out.append(" Simulating prototype-func parsing ...")
+        parsed_value = protlib.protfunc_parser(value, testing=True, prototype=prototype)
+        if parsed_value != value:
+            out.append(
+                " |g(Example-)value when parsed ({}):|n {}".format(type(parsed_value), parsed_value)
+            )
+        else:
+            out.append(" |gNo change when parsed.")
+
+    caller.msg("\n".join(out))
+
+    return next_node
+
+
+def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False):
+    """Creates default navigation options available in the wizard."""
+    options = []
+    if prev_node:
+        options.append(
+            {
+                "key": ("|wB|Wack", "b"),
+                "desc": "{color}({node})|n".format(color=color, node=prev_node.replace("_", "-")),
+                "goto": "node_{}".format(prev_node),
+            }
+        )
+    if next_node:
+        options.append(
+            {
+                "key": ("|wF|Worward", "f"),
+                "desc": "{color}({node})|n".format(color=color, node=next_node.replace("_", "-")),
+                "goto": "node_{}".format(next_node),
+            }
+        )
+
+    options.append({"key": ("|wI|Wndex", "i"), "goto": "node_index"})
+
+    if curr_node:
+        options.append(
+            {
+                "key": ("|wV|Walidate prototype", "validate", "v"),
+                "goto": ("node_validate_prototype", {"back": curr_node}),
+            }
+        )
+        if search:
+            options.append(
+                {
+                    "key": ("|wSE|Warch objects", "search object", "search", "se"),
+                    "goto": ("node_search_object", {"back": curr_node}),
+                }
+            )
+
+    return options
+
+
+def _set_actioninfo(caller, string):
+    caller.ndb._menutree.actioninfo = string
+
+
+def _path_cropper(pythonpath):
+    "Crop path to only the last component"
+    return pythonpath.split(".")[-1]
+
+
+def _validate_prototype(prototype):
+    """Run validation on prototype"""
+
+    txt = protlib.prototype_to_str(prototype)
+    errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)"
+    err = False
+    try:
+        # validate, don't spawn
+        spawner.spawn(prototype, only_validate=True)
+    except RuntimeError as exc:
+        errors = "\n\n|r{}|n".format(exc)
+        err = True
+    except RuntimeWarning as exc:
+        errors = "\n\n|y{}|n".format(exc)
+        err = True
+
+    text = txt + errors
+    return err, text
+
+
+def _format_protfuncs():
+    out = []
+    sorted_funcs = [
+        (key, func)
+        for key, func in sorted(protlib.FUNC_PARSER.callables.items(), key=lambda tup: tup[0])
+    ]
+    for protfunc_name, protfunc in sorted_funcs:
+        out.append(
+            "- |c${name}|n - |W{docs}".format(
+                name=protfunc_name,
+                docs=utils.justify(protfunc.__doc__.strip(), align="l", indent=10).strip(),
+            )
+        )
+    return "\n       ".join(out)
+
+
+def _format_lockfuncs():
+    out = []
+    sorted_funcs = [
+        (key, func) for key, func in sorted(get_all_lockfuncs().items(), key=lambda tup: tup[0])
+    ]
+    for lockfunc_name, lockfunc in sorted_funcs:
+        doc = (lockfunc.__doc__ or "").strip()
+        out.append(
+            "- |c${name}|n - |W{docs}".format(
+                name=lockfunc_name, docs=utils.justify(doc, align="l", indent=10).strip()
+            )
+        )
+    return "\n".join(out)
+
+
+def _format_list_actions(*args, **kwargs):
+    """Create footer text for nodes with extra list actions
+
+    Args:
+        actions (str): Available actions. The first letter of the action name will be assumed
+            to be a shortcut.
+    Keyword Args:
+        prefix (str): Default prefix to use.
+    Returns:
+        string (str): Formatted footer for adding to the node text.
+
+    """
+    actions = []
+    prefix = kwargs.get("prefix", "|WSelect with |w<num>|W. Other actions:|n ")
+    for action in args:
+        actions.append("|w{}|n|W{} |w<num>|n".format(action[0], action[1:]))
+    return prefix + " |W|||n ".join(actions)
+
+
+def _get_current_value(caller, keyname, comparer=None, formatter=str, only_inherit=False):
+    """
+    Return current value, marking if value comes from parent or set in this prototype.
+
+    Args:
+        keyname (str): Name of prototoype key to get current value of.
+        comparer (callable, optional): This will be called as comparer(prototype_value,
+            flattened_value) and is expected to return the value to show as the current
+            or inherited one. If not given, a straight comparison is used and what is returned
+            depends on the only_inherit setting.
+        formatter (callable, optional)): This will be called with the result of comparer.
+        only_inherit (bool, optional): If a current value should only be shown if all
+            the values are inherited from the prototype parent (otherwise, show an empty string).
+    Returns:
+        current (str): The current value.
+
+    """
+
+    def _default_comparer(protval, flatval):
+        if only_inherit:
+            return "" if protval else flatval
+        else:
+            return protval if protval else flatval
+
+    if not callable(comparer):
+        comparer = _default_comparer
+
+    prot = _get_menu_prototype(caller)
+    flat_prot = _get_flat_menu_prototype(caller)
+
+    out = ""
+    if keyname in prot:
+        if keyname in flat_prot:
+            out = formatter(comparer(prot[keyname], flat_prot[keyname]))
+            if only_inherit:
+                if str(out).strip():
+                    return "|WCurrent|n {} |W(|binherited|W):|n {}".format(keyname, out)
+                return ""
+            else:
+                if out:
+                    return "|WCurrent|n {}|W:|n {}".format(keyname, out)
+                return "|W[No {} set]|n".format(keyname)
+        elif only_inherit:
+            return ""
+        else:
+            out = formatter(prot[keyname])
+            return "|WCurrent|n {}|W:|n {}".format(keyname, out)
+    elif keyname in flat_prot:
+        out = formatter(flat_prot[keyname])
+        if out:
+            return "|WCurrent|n {} |W(|n|binherited|W):|n {}".format(keyname, out)
+        else:
+            return ""
+    elif only_inherit:
+        return ""
+    else:
+        return "|W[No {} set]|n".format(keyname)
+
+
+def _default_parse(raw_inp, choices, *args):
+    """
+    Helper to parse default input to a node decorated with the node_list decorator on
+    the form l1, l 2, look 1, etc. Spaces are ignored, as is case.
+
+    Args:
+        raw_inp (str): Input from the user.
+        choices (list): List of available options on the node listing (list of strings).
+        args (tuples): The available actions, each specifed as a tuple (name, alias, ...)
+    Returns:
+        choice (str): A choice among the choices, or None if no match was found.
+        action (str): The action operating on the choice, or None.
+
+    """
+    raw_inp = raw_inp.lower().strip()
+    mapping = {t.lower(): tup[0] for tup in args for t in tup}
+    match = re.match(r"(%s)\s*?(\d+)$" % "|".join(mapping.keys()), raw_inp)
+    if match:
+        action = mapping.get(match.group(1), None)
+        num = int(match.group(2)) - 1
+        num = num if 0 <= num < len(choices) else None
+        if action is not None and num is not None:
+            return choices[num], action
+    return None, None
+
+
+# Menu nodes ------------------------------
+
+# helper nodes
+
+# validate prototype (available as option from all nodes)
+
+
+
[docs]def node_validate_prototype(caller, raw_string, **kwargs): + """General node to view and validate a protototype""" + prototype = _get_flat_menu_prototype(caller, refresh=True, validate=False) + prev_node = kwargs.get("back", "index") + + _, text = _validate_prototype(prototype) + + helptext = """ + The validator checks if the prototype's various values are on the expected form. It also tests + any $protfuncs. + + """ + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", "goto": "node_" + prev_node}) + + return text, options
+ + +# node examine_entity + + +
[docs]def node_examine_entity(caller, raw_string, **kwargs): + """ + General node to view a text and then return to previous node. Kwargs should contain "text" for + the text to show and 'back" pointing to the node to return to. + """ + text = kwargs.get("text", "Nothing was found here.") + helptext = "Use |wback|n to return to the previous node." + prev_node = kwargs.get("back", "index") + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", "goto": "node_" + prev_node}) + + return text, options
+ + +# node object_search + + +def _search_object(caller): + "update search term based on query stored on menu; store match too" + try: + searchstring = caller.ndb._menutree.olc_search_object_term.strip() + caller.ndb._menutree.olc_search_object_matches = [] + except AttributeError: + return [] + + if not searchstring: + caller.msg("Must specify a search criterion.") + return [] + + is_dbref = utils.dbref(searchstring) + is_account = searchstring.startswith("*") + + if is_dbref or is_account: + if is_dbref: + # a dbref search + results = caller.search(searchstring, global_search=True, quiet=True) + else: + # an account search + searchstring = searchstring.lstrip("*") + results = caller.search_account(searchstring, quiet=True) + else: + keyquery = Q(db_key__istartswith=searchstring) + aliasquery = Q( + db_tags__db_key__istartswith=searchstring, db_tags__db_tagtype__iexact="alias" + ) + results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() + + caller.msg("Searching for '{}' ...".format(searchstring)) + caller.ndb._menutree.olc_search_object_matches = results + return ["{}(#{})".format(obj.key, obj.id) for obj in results] + + +def _object_search_select(caller, obj_entry, **kwargs): + choices = kwargs["available_choices"] + num = choices.index(obj_entry) + matches = caller.ndb._menutree.olc_search_object_matches + obj = matches[num] + + if not obj.access(caller, "examine"): + caller.msg("|rYou don't have 'examine' access on this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + prot = spawner.prototype_from_object(obj) + txt = protlib.prototype_to_str(prot) + return "node_examine_entity", {"text": txt, "back": "search_object"} + + +def _object_search_actions(caller, raw_inp, **kwargs): + "All this does is to queue a search query" + choices = kwargs["available_choices"] + obj_entry, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("create prototype from object", "create", "c") + ) + + raw_inp = raw_inp.strip() + + if obj_entry: + num = choices.index(obj_entry) + matches = caller.ndb._menutree.olc_search_object_matches + obj = matches[num] + prot = spawner.prototype_from_object(obj) + + if action == "examine": + if not obj.access(caller, "examine"): + caller.msg("\n|rYou don't have 'examine' access on this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + txt = protlib.prototype_to_str(prot) + return "node_examine_entity", {"text": txt, "back": "search_object"} + else: + # load prototype + + if not obj.access(caller, "edit"): + caller.msg("|rYou don't have access to do this with this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + _set_menu_prototype(caller, prot) + caller.msg("Created prototype from object.") + return "node_index" + elif raw_inp: + caller.ndb._menutree.olc_search_object_term = raw_inp + return "node_search_object", kwargs + else: + # empty input - exit back to previous node + prev_node = "node_" + kwargs.get("back", "index") + return prev_node + + +@list_node(_search_object, _object_search_select) +def node_search_object(caller, raw_inp, **kwargs): + """ + Node for searching for an existing object. + """ + try: + matches = caller.ndb._menutree.olc_search_object_matches + except AttributeError: + matches = [] + nmatches = len(matches) + prev_node = kwargs.get("back", "index") + + if matches: + text = """ + Found {num} match{post}. + + (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format( + num=nmatches, post="es" if nmatches > 1 else "" + ) + _set_actioninfo( + caller, + _format_list_actions("examine", "create prototype from object", prefix="Actions: "), + ) + else: + text = "Enter search criterion." + + helptext = """ + You can search objects by specifying partial key, alias or its exact #dbref. Use *query to + search for an Account instead. + + Once having found any matches you can choose to examine it or use |ccreate prototype from + object|n. If doing the latter, a prototype will be calculated from the selected object and + loaded as the new 'current' prototype. This is useful for having a base to build from but be + careful you are not throwing away any existing, unsaved, prototype work! + """ + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", "goto": (_object_search_actions, {"back": prev_node})}) + + return text, options + + +# main index (start page) node + + +
[docs]def node_index(caller): + prototype = _get_menu_prototype(caller) + + text = """ + |c --- Prototype wizard --- |n + %s + + A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype + can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to + randomize the value every time a new entity is spawned. The fields whose names start with + 'Prototype-' are not fields on the object itself but are used for prototype-inheritance, or + when saving and loading. + + Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at + any menu node for more info. + + """ + helptxt = """ + |c- prototypes |n + + A prototype is really just a Python dictionary. When spawning, this dictionary is essentially + passed into `|wevennia.utils.create.create_object(**prototype)|n` to create a new object. By + using different prototypes you can customize instances of objects without having to do code + changes to their typeclass (something which requires code access). The classical example is + to spawn goblins with different names, looks, equipment and skill, each based on the same + `Goblin` typeclass. + + At any time you can [|wV|n]alidate that the prototype works correctly and use it to + [|wSP|n]awn a new entity. You can also [|wSA|n]ve|n your work, [|wLO|n]oad an existing + prototype to [|wSE|n]arch for existing objects to use as a base. Use [|wL|n]ook to re-show a + menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive + help. + + + |c- $protfuncs |n + + Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are + entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n + only. They can also be nested for combined effects. + + {pfuncs} + """.format( + pfuncs=_format_protfuncs() + ) + + # If a prototype is being edited, show its key and + # prototype_key under the title + loaded_prototype = "" + if "prototype_key" in prototype or "key" in prototype: + loaded_prototype = " --- Editing: |y{}({})|n --- ".format( + prototype.get("key", ""), prototype.get("prototype_key", "") + ) + text = text % (loaded_prototype) + + text = (text, helptxt) + + options = [] + options.append( + { + "desc": "|WPrototype-Key|n|n{}".format( + _format_option_value("Key", "prototype_key" not in prototype, prototype, None) + ), + "goto": "node_prototype_key", + } + ) + for key in ( + "Prototype_Parent", + "Typeclass", + "Key", + "Aliases", + "Attrs", + "Tags", + "Locks", + "Permissions", + "Location", + "Home", + "Destination", + ): + required = False + cropper = None + if key in ("Prototype_Parent", "Typeclass"): + required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype) + if key == "Typeclass": + cropper = _path_cropper + options.append( + { + "desc": "{}{}|n{}".format( + "|W" if key == "Prototype_Parent" else "|w", + key.replace("_", "-"), + _format_option_value(key, required, prototype, cropper=cropper), + ), + "goto": "node_{}".format(key.lower()), + } + ) + required = False + for key in ("Desc", "Tags", "Locks"): + options.append( + { + "desc": "|WPrototype-{}|n|n{}".format( + key, _format_option_value(key, required, prototype, None) + ), + "goto": "node_prototype_{}".format(key.lower()), + } + ) + + options.extend( + ( + {"key": ("|wV|Walidate prototype", "validate", "v"), "goto": "node_validate_prototype"}, + {"key": ("|wSA|Wve prototype", "save", "sa"), "goto": "node_prototype_save"}, + {"key": ("|wSP|Wawn prototype", "spawn", "sp"), "goto": "node_prototype_spawn"}, + {"key": ("|wLO|Wad prototype", "load", "lo"), "goto": "node_prototype_load"}, + {"key": ("|wSE|Warch objects|n", "search", "se"), "goto": "node_search_object"}, + ) + ) + + return text, options
+ + +# prototype_key node + + +def _check_prototype_key(caller, key): + old_prototype = protlib.search_prototype(key) + olc_new = _is_new_prototype(caller) + key = key.strip().lower() + if old_prototype: + old_prototype = old_prototype[0] + # we are starting a new prototype that matches an existing + if not caller.locks.check_lockstring( + caller, old_prototype["prototype_locks"], access_type="edit" + ): + # return to the node_prototype_key to try another key + caller.msg( + "Prototype '{key}' already exists and you don't " + "have permission to edit it.".format(key=key) + ) + return "node_prototype_key" + elif olc_new: + # we are selecting an existing prototype to edit. Reset to index. + del caller.ndb._menutree.olc_new + caller.ndb._menutree.olc_prototype = old_prototype + caller.msg("Prototype already exists. Reloading.") + return "node_index" + + return _set_property(caller, key, prop="prototype_key") + + +
[docs]def node_prototype_key(caller): + text = """ + The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to + find and use the prototype to spawn new entities. It is not case sensitive. + + (To set a new value, just write it and press enter) + + {current}""".format( + current=_get_current_value(caller, "prototype_key") + ) + + helptext = """ + The prototype-key is not itself used when spawnng the new object, but is only used for + managing, storing and loading the prototype. It must be globally unique, so existing keys + will be checked before a new key is accepted. If an existing key is picked, the existing + prototype will be loaded. + """ + + options = _wizard_options("prototype_key", "index", "prototype_parent") + options.append({"key": "_default", "goto": _check_prototype_key}) + + text = (text, helptext) + return text, options
+ + +# prototype_parents node + + +def _all_prototype_parents(caller): + """Return prototype_key of all available prototypes for listing in menu""" + return [ + prototype["prototype_key"] + for prototype in protlib.search_prototype() + if "prototype_key" in prototype + ] + + +def _prototype_parent_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype_parent, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("add", "a"), ("remove", "r", "delete", "d") + ) + + if prototype_parent: + # a selection of parent was made + prototype_parent = protlib.search_prototype(key=prototype_parent)[0] + prototype_parent_key = prototype_parent["prototype_key"] + + # which action to apply on the selection + if action == "examine": + # examine the prototype + txt = protlib.prototype_to_str(prototype_parent) + kwargs["text"] = txt + kwargs["back"] = "prototype_parent" + return "node_examine_entity", kwargs + elif action == "add": + # add/append parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get("prototype_parent", None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + if prototype_parent_key in current_prot_parent: + caller.msg("Prototype_parent {} is already used.".format(prototype_parent_key)) + return "node_prototype_parent" + else: + current_prot_parent.append(prototype_parent_key) + caller.msg("Add prototype parent for multi-inheritance.") + else: + current_prot_parent = prototype_parent_key + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent, validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg( + "Selected prototype-parent {} " + "caused Error(s):\n|r{}|n".format(prototype_parent, err) + ) + return "node_prototype_parent" + _set_prototype_value(caller, "prototype_parent", current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + elif action == "remove": + # remove prototype parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get("prototype_parent", None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + try: + current_prot_parent.remove(prototype_parent_key) + _set_prototype_value(caller, "prototype_parent", current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Removed prototype parent {}.".format(prototype_parent_key)) + except ValueError: + caller.msg( + "|rPrototype-parent {} could not be removed.".format(prototype_parent_key) + ) + return "node_prototype_parent" + + +def _prototype_parent_select(caller, new_parent): + ret = None + prototype_parent = protlib.search_prototype(new_parent) + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent[0], validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg( + "Selected prototype-parent {} " "caused Error(s):\n|r{}|n".format(new_parent, err) + ) + else: + ret = _set_property( + caller, + new_parent, + prop="prototype_parent", + processor=str, + next_node="node_prototype_parent", + ) + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Selected prototype parent |c{}|n.".format(new_parent)) + return ret + + +@list_node(_all_prototype_parents, _prototype_parent_select) +def node_prototype_parent(caller): + prototype = _get_menu_prototype(caller) + + prot_parent_keys = prototype.get("prototype_parent") + + text = """ + The |cPrototype Parent|n allows you to |winherit|n prototype values from another named + prototype (given as that prototype's |wprototype_key|n). If not changing these values in + the current prototype, the parent's value will be used. Pick the available prototypes below. + + Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no + parent is given, this prototype must define the typeclass (next menu node). + + {current} + """ + helptext = """ + Prototypes can inherit from one another. Changes in the child replace any values set in a + parent. The |wtypeclass|n key must exist |wsomewhere|n in the parent chain for the + prototype to be valid. + """ + + _set_actioninfo(caller, _format_list_actions("examine", "add", "remove")) + + ptexts = [] + if prot_parent_keys: + for pkey in utils.make_iter(prot_parent_keys): + prot_parent = protlib.search_prototype(pkey) + if prot_parent: + prot_parent = prot_parent[0] + ptexts.append( + "|c -- {pkey} -- |n\n{prot}".format( + pkey=pkey, prot=protlib.prototype_to_str(prot_parent) + ) + ) + else: + ptexts.append("Prototype parent |r{pkey} was not found.".format(pkey=pkey)) + + if not ptexts: + ptexts.append("[No prototype_parent set]") + + text = text.format(current="\n\n".join(ptexts)) + + text = (text, helptext) + + options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") + options.append({"key": "_default", "goto": _prototype_parent_actions}) + + return text, options + + +# typeclasses node + + +def _all_typeclasses(caller): + """Get name of available typeclasses.""" + return list( + name + for name in sorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys()) + if name != "evennia.objects.models.ObjectDB" + ) + + +def _typeclass_actions(caller, raw_inp, **kwargs): + """Parse actions for typeclass listing""" + + choices = kwargs.get("available_choices", []) + typeclass_path, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("remove", "r", "delete", "d") + ) + + if typeclass_path: + if action == "examine": + typeclass = utils.get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = "\n".join(docstr) if docstr else "<empty>" + txt = ( + "Typeclass |c{typeclass_path}|n; " + "First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr + ) + ) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + return "node_examine_entity", {"text": txt, "back": "typeclass"} + elif action == "remove": + prototype = _get_menu_prototype(caller) + old_typeclass = prototype.pop("typeclass", None) + if old_typeclass: + _set_menu_prototype(caller, prototype) + caller.msg("Cleared typeclass {}.".format(old_typeclass)) + else: + caller.msg("No typeclass to remove.") + return "node_typeclass" + + +def _typeclass_select(caller, typeclass, **kwargs): + """Select typeclass from list and add it to prototype. Return next node to go to.""" + ret = _set_property(caller, typeclass, prop="typeclass", processor=str) + caller.msg("Selected typeclass |c{}|n.".format(typeclass)) + return ret + + +@list_node(_all_typeclasses, _typeclass_select) +def node_typeclass(caller): + text = """ + The |cTypeclass|n defines what 'type' of object this is - the actual working code to use. + + All spawned objects must have a typeclass. If not given here, the typeclass must be set in + one of the prototype's |cparents|n. + + {current} + """.format( + current=_get_current_value(caller, "typeclass"), + actions="|WSelect with |w<num>|W. Other actions: " + "|we|Wxamine |w<num>|W, |wr|Wemove selection", + ) + + helptext = """ + A |nTypeclass|n is specified by the actual python-path to the class definition in the + Evennia code structure. + + Which |cAttributes|n, |cLocks|n and other properties have special + effects or expects certain values depend greatly on the code in play. + """ + + text = (text, helptext) + + options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") + options.append({"key": "_default", "goto": _typeclass_actions}) + return text, options + + +# key node + + +
[docs]def node_key(caller): + text = """ + The |cKey|n is the given name of the object to spawn. This will retain the given case. + + {current} + """.format( + current=_get_current_value(caller, "key") + ) + + helptext = """ + The key should often not be identical for every spawned object. Using a randomising + $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three + names every time an object of this prototype is spawned. + + |c$protfuncs|n + {pfuncs} + """.format( + pfuncs=_format_protfuncs() + ) + + text = (text, helptext) + + options = _wizard_options("key", "typeclass", "aliases") + options.append( + { + "key": "_default", + "goto": (_set_property, dict(prop="key", processor=lambda s: s.strip())), + } + ) + return text, options
+ + +# aliases node + + +def _all_aliases(caller): + "Get aliases in prototype" + prototype = _get_menu_prototype(caller) + return prototype.get("aliases", []) + + +def _aliases_select(caller, alias): + "Add numbers as aliases" + aliases = _all_aliases(caller) + try: + ind = str(aliases.index(alias) + 1) + if ind not in aliases: + aliases.append(ind) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(ind)) + except (IndexError, ValueError) as err: + caller.msg("Error: {}".format(err)) + + return "node_aliases" + + +def _aliases_actions(caller, raw_inp, **kwargs): + """Parse actions for aliases listing""" + choices = kwargs.get("available_choices", []) + alias, action = _default_parse(raw_inp, choices, ("remove", "r", "delete", "d")) + + aliases = _all_aliases(caller) + if alias and action == "remove": + try: + aliases.remove(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Removed alias '{}'.".format(alias)) + except ValueError: + caller.msg("No matching alias found to remove.") + else: + # if not a valid remove, add as a new alias + alias = raw_inp.lower().strip() + if alias and alias not in aliases: + aliases.append(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(alias)) + else: + caller.msg("Alias '{}' was already set.".format(alias)) + return "node_aliases" + + +@list_node(_all_aliases, _aliases_select) +def node_aliases(caller): + text = """ + |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not + case sensitive. + + {current} + """.format( + current=_get_current_value( + caller, + "aliases", + comparer=lambda propval, flatval: [al for al in flatval if al not in propval], + formatter=lambda lst: "\n" + ", ".join(lst), + only_inherit=True, + ) + ) + _set_actioninfo( + caller, _format_list_actions("remove", prefix="|w<text>|W to add new alias. Other action: ") + ) + + helptext = """ + Aliases are fixed alternative identifiers and are stored with the new object. + + |c$protfuncs|n + + {pfuncs} + """.format( + pfuncs=_format_protfuncs() + ) + + text = (text, helptext) + + options = _wizard_options("aliases", "key", "attrs") + options.append({"key": "_default", "goto": _aliases_actions}) + return text, options + + +# attributes node + + +def _caller_attrs(caller): + prototype = _get_menu_prototype(caller) + attrs = [ + "{}={}".format(tup[0], utils.crop(utils.to_str(tup[1]), width=10)) + for tup in prototype.get("attrs", []) + ] + return attrs + + +def _get_tup_by_attrname(caller, attrname): + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) + try: + inp = [tup[0] for tup in attrs].index(attrname) + return attrs[inp] + except ValueError: + return None + + +def _display_attribute(attr_tuple): + """Pretty-print attribute tuple""" + attrkey, value, category, locks = attr_tuple + value = protlib.protfunc_parser(value) + typ = type(value) + out = "{attrkey} |c=|n {value} |W({typ}{category}{locks})|n".format( + attrkey=attrkey, + value=value, + typ=typ, + category=", category={}".format(category) if category else "", + locks=", locks={}".format(";".join(locks)) if any(locks) else "", + ) + + return out + + +def _add_attr(caller, attr_string, **kwargs): + """ + Add new attribute, parsing input. + + Args: + caller (Object): Caller of menu. + attr_string (str): Input from user + attr is entered on these forms + attr = value + attr;category = value + attr;category;lockstring = value + Keyword Args: + delete (str): If this is set, attr_string is + considered the name of the attribute to delete and + no further parsing happens. + Returns: + result (str): Result string of action. + """ + attrname = "" + value = "" + category = None + locks = "" + + if "delete" in kwargs: + attrname = attr_string.lower().strip() + elif "=" in attr_string: + attrname, value = (part.strip() for part in attr_string.split("=", 1)) + attrname = attrname.lower() + nameparts = attrname.split(";", 2) + nparts = len(nameparts) + if nparts == 2: + attrname, category = nameparts + elif nparts > 2: + attrname, category, locks = nameparts + attr_tuple = (attrname, value, category, str(locks)) + + if attrname: + prot = _get_menu_prototype(caller) + attrs = prot.get("attrs", []) + + if "delete" in kwargs: + try: + ind = [tup[0] for tup in attrs].index(attrname) + del attrs[ind] + _set_prototype_value(caller, "attrs", attrs) + return "Removed Attribute '{}'".format(attrname) + except IndexError: + return "Attribute to delete not found." + + try: + # replace existing attribute with the same name in the prototype + ind = [tup[0] for tup in attrs].index(attrname) + attrs[ind] = attr_tuple + text = "Edited Attribute '{}'.".format(attrname) + except ValueError: + attrs.append(attr_tuple) + text = "Added Attribute " + _display_attribute(attr_tuple) + + _set_prototype_value(caller, "attrs", attrs) + else: + text = "Attribute must be given as 'attrname[;category;locks] = <value>'." + + return text + + +def _attr_select(caller, attrstr): + attrname, _ = attrstr.split("=", 1) + attrname = attrname.strip() + + attr_tup = _get_tup_by_attrname(caller, attrname) + if attr_tup: + return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}) + else: + caller.msg("Attribute not found.") + return "node_attrs" + + +def _attrs_actions(caller, raw_inp, **kwargs): + """Parse actions for attribute listing""" + choices = kwargs.get("available_choices", []) + attrstr, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d") + ) + if attrstr is None: + attrstr = raw_inp + try: + attrname, _ = attrstr.split("=", 1) + except ValueError: + caller.msg("|rNeed to enter the attribute on the form attrname=value.|n") + return "node_attrs" + + attrname = attrname.strip() + attr_tup = _get_tup_by_attrname(caller, attrname) + + if action and attr_tup: + if action == "examine": + return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}) + elif action == "remove": + res = _add_attr(caller, attrname, delete=True) + caller.msg(res) + else: + res = _add_attr(caller, raw_inp) + caller.msg(res) + return "node_attrs" + + +@list_node(_caller_attrs, _attr_select) +def node_attrs(caller): + def _currentcmp(propval, flatval): + "match by key + category" + cmp1 = [(tup[0].lower(), tup[2].lower() if tup[2] else None) for tup in propval] + return [ + tup + for tup in flatval + if (tup[0].lower(), tup[2].lower() if tup[2] else None) not in cmp1 + ] + + text = """ + |cAttributes|n are custom properties of the object. Enter attributes on one of these forms: + + attrname=value + attrname;category=value + attrname;category;lockstring=value + + To give an attribute without a category but with a lockstring, leave that spot empty + (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. + + {current} + """.format( + current=_get_current_value( + caller, + "attrs", + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(_display_attribute(tup) for tup in lst), + only_inherit=True, + ) + ) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) + + helptext = """ + Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types + 'attredit' and 'attrread' are used to limit editing and viewing of the Attribute. Putting + the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders + from adding new Attributes. + + |c$protfuncs + + {pfuncs} + """.format( + pfuncs=_format_protfuncs() + ) + + text = (text, helptext) + + options = _wizard_options("attrs", "aliases", "tags") + options.append({"key": "_default", "goto": _attrs_actions}) + return text, options + + +# tags node + + +def _caller_tags(caller): + prototype = _get_menu_prototype(caller) + tags = [tup[0] for tup in prototype.get("tags", [])] + return tags + + +def _get_tup_by_tagname(caller, tagname): + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags", []) + try: + inp = [tup[0] for tup in tags].index(tagname) + return tags[inp] + except ValueError: + return None + + +def _display_tag(tag_tuple): + """Pretty-print tag tuple""" + tagkey, category, data = tag_tuple + out = "Tag: '{tagkey}' (category: {category}{dat})".format( + tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "" + ) + return out + + +def _add_tag(caller, tag_string, **kwargs): + """ + Add tags to the system, parsing input + + Args: + caller (Object): Caller of menu. + tag_string (str): Input from user on one of these forms + tagname + tagname;category + tagname;category;data + + Keyword Args: + delete (str): If this is set, tag_string is considered + the name of the tag to delete. + + Returns: + result (str): Result string of action. + + """ + tag = tag_string.strip().lower() + category = None + data = "" + + if "delete" in kwargs: + tag = tag_string.lower().strip() + else: + nameparts = tag.split(";", 2) + ntuple = len(nameparts) + if ntuple == 2: + tag, category = nameparts + elif ntuple > 2: + tag, category, data = nameparts[:3] + + tag_tuple = (tag.lower(), category.lower() if category else None, data) + + if tag: + prot = _get_menu_prototype(caller) + tags = prot.get("tags", []) + + old_tag = _get_tup_by_tagname(caller, tag) + + if "delete" in kwargs: + if old_tag: + tags.pop(tags.index(old_tag)) + text = "Removed Tag '{}'.".format(tag) + else: + text = "Found no Tag to remove." + elif not old_tag: + # a fresh, new tag + tags.append(tag_tuple) + text = "Added Tag '{}'".format(tag) + else: + # old tag exists; editing a tag means replacing old with new + ind = tags.index(old_tag) + tags[ind] = tag_tuple + text = "Edited Tag '{}'".format(tag) + + _set_prototype_value(caller, "tags", tags) + else: + text = "Tag must be given as 'tag[;category;data]'." + + return text + + +def _tag_select(caller, tagname): + tag_tup = _get_tup_by_tagname(caller, tagname) + if tag_tup: + return "node_examine_entity", {"text": _display_tag(tag_tup), "back": "attrs"} + else: + caller.msg("Tag not found.") + return "node_attrs" + + +def _tags_actions(caller, raw_inp, **kwargs): + """Parse actions for tags listing""" + choices = kwargs.get("available_choices", []) + tagname, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d") + ) + + if tagname is None: + tagname = raw_inp.lower().strip() + + tag_tup = _get_tup_by_tagname(caller, tagname) + + if tag_tup: + if action == "examine": + return ("node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"}) + elif action == "remove": + res = _add_tag(caller, tagname, delete=True) + caller.msg(res) + else: + res = _add_tag(caller, raw_inp) + caller.msg(res) + return "node_tags" + + +@list_node(_caller_tags, _tag_select) +def node_tags(caller): + def _currentcmp(propval, flatval): + "match by key + category" + cmp1 = [(tup[0].lower(), tup[1].lower() if tup[2] else None) for tup in propval] + return [ + tup + for tup in flatval + if (tup[0].lower(), tup[1].lower() if tup[1] else None) not in cmp1 + ] + + text = """ + |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of + the following forms: + tagname + tagname;category + tagname;category;data + + {current} + """.format( + current=_get_current_value( + caller, + "tags", + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(_display_tag(tup) for tup in lst), + only_inherit=True, + ) + ) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) + + helptext = """ + Tags are shared between all objects with that tag. So the 'data' field (which is not + commonly used) can only hold eventual info about the Tag itself, not about the individual + object on which it sits. + + All objects created with this prototype will automatically get assigned a tag named the same + as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to + optionally update previously spawned objects when their prototype changes. + """.format( + tag_category=protlib.PROTOTYPE_TAG_CATEGORY + ) + + text = (text, helptext) + options = _wizard_options("tags", "attrs", "locks") + options.append({"key": "_default", "goto": _tags_actions}) + return text, options + + +# locks node + + +def _caller_locks(caller): + locks = _get_menu_prototype(caller).get("locks", "") + return [lck for lck in locks.split(";") if lck] + + +def _locks_display(caller, lock): + return lock + + +def _lock_select(caller, lockstr): + return ("node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"}) + + +def _lock_add(caller, lock, **kwargs): + locks = _caller_locks(caller) + + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + return "Lockstring lacks ':'." + + locktype = locktype.strip().lower() + + if "delete" in kwargs: + try: + ind = locks.index(lock) + locks.pop(ind) + _set_prototype_value(caller, "locks", ";".join(locks), parse=False) + ret = "Lock {} deleted.".format(lock) + except ValueError: + ret = "No lock found to delete." + return ret + try: + locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks] + ind = locktypes.index(locktype) + locks[ind] = lock + ret = "Lock with locktype '{}' updated.".format(locktype) + except ValueError: + locks.append(lock) + ret = "Added lock '{}'.".format(lock) + _set_prototype_value(caller, "locks", ";".join(locks)) + return ret + + +def _locks_actions(caller, raw_inp, **kwargs): + choices = kwargs.get("available_choices", []) + lock, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d") + ) + + if lock: + if action == "examine": + return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}) + elif action == "remove": + ret = _lock_add(caller, lock, delete=True) + caller.msg(ret) + else: + ret = _lock_add(caller, raw_inp) + caller.msg(ret) + + return "node_locks" + + +@list_node(_caller_locks, _lock_select) +def node_locks(caller): + def _currentcmp(propval, flatval): + "match by locktype" + cmp1 = [lck.split(":", 1)[0] for lck in propval.split(";")] + return ";".join(lstr for lstr in flatval.split(";") if lstr.split(":", 1)[0] not in cmp1) + + text = """ + The |cLock string|n defines limitations for accessing various properties of the object once + it's spawned. The string should be on one of the following forms: + + locktype:[NOT] lockfunc(args) + locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ... + + {current}{action} + """.format( + current=_get_current_value( + caller, + "locks", + comparer=_currentcmp, + formatter=lambda lockstr: "\n".join( + _locks_display(caller, lstr) for lstr in lockstr.split(";") + ), + only_inherit=True, + ), + action=_format_list_actions("examine", "remove", prefix="Actions: "), + ) + + helptext = """ + Here is an example of two lock strings: + + edit:false() + call:tag(Foo) OR perm(Builder) + + Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked + depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone + while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the + |cPermission|n 'Builder'. + + |cAvailable lockfuncs:|n + + {lfuncs} + """.format( + lfuncs=_format_lockfuncs() + ) + + text = (text, helptext) + + options = _wizard_options("locks", "tags", "permissions") + options.append({"key": "_default", "goto": _locks_actions}) + + return text, options + + +# permissions node + + +def _caller_permissions(caller): + prototype = _get_menu_prototype(caller) + perms = prototype.get("permissions", []) + return perms + + +def _display_perm(caller, permission, only_hierarchy=False): + hierarchy = settings.PERMISSION_HIERARCHY + perm_low = permission.lower() + txt = "" + if perm_low in [prm.lower() for prm in hierarchy]: + txt = "Permission (in hieararchy): {}".format( + ", ".join( + [ + "|w[{}]|n".format(prm) if prm.lower() == perm_low else "|W{}|n".format(prm) + for prm in hierarchy + ] + ) + ) + elif not only_hierarchy: + txt = "Permission: '{}'".format(permission) + return txt + + +def _permission_select(caller, permission, **kwargs): + return ( + "node_examine_entity", + {"text": _display_perm(caller, permission), "back": "permissions"}, + ) + + +def _add_perm(caller, perm, **kwargs): + if perm: + perm_low = perm.lower() + perms = _caller_permissions(caller) + perms_low = [prm.lower() for prm in perms] + if "delete" in kwargs: + try: + ind = perms_low.index(perm_low) + del perms[ind] + text = "Removed Permission '{}'.".format(perm) + except ValueError: + text = "Found no Permission to remove." + else: + if perm_low in perms_low: + text = "Permission already set." + else: + perms.append(perm) + _set_prototype_value(caller, "permissions", perms) + text = "Added Permission '{}'".format(perm) + return text + + +def _permissions_actions(caller, raw_inp, **kwargs): + """Parse actions for permission listing""" + choices = kwargs.get("available_choices", []) + perm, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d") + ) + + if perm: + if action == "examine": + return ( + "node_examine_entity", + {"text": _display_perm(caller, perm), "back": "permissions"}, + ) + elif action == "remove": + res = _add_perm(caller, perm, delete=True) + caller.msg(res) + else: + res = _add_perm(caller, raw_inp.strip()) + caller.msg(res) + return "node_permissions" + + +@list_node(_caller_permissions, _permission_select) +def node_permissions(caller): + def _currentcmp(pval, fval): + cmp1 = [perm.lower() for perm in pval] + return [perm for perm in fval if perm.lower() not in cmp1] + + text = """ + |cPermissions|n are simple strings used to grant access to this object. A permission is used + when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. Certain + permissions belong in the |cpermission hierarchy|n together with the |Wperm()|n lock + function. + + {current} + """.format( + current=_get_current_value( + caller, + "permissions", + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(prm for prm in lst), + only_inherit=True, + ) + ) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) + + helptext = """ + Any string can act as a permission as long as a lock is set to look for it. Depending on the + lock, having a permission could even be negative (i.e. the lock is only passed if you + |wdon't|n have the 'permission'). The most common permissions are the hierarchical + permissions: + + {permissions}. + + For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors + having the |cpermission|n "Builder" or higher. + """.format( + permissions=", ".join(settings.PERMISSION_HIERARCHY) + ) + + text = (text, helptext) + + options = _wizard_options("permissions", "locks", "location") + options.append({"key": "_default", "goto": _permissions_actions}) + + return text, options + + +# location node + + +
[docs]def node_location(caller): + text = """ + The |cLocation|n of this object in the world. If not given, the object will spawn in the + inventory of |c{caller}|n by default. + + {current} + """.format( + caller=caller.key, current=_get_current_value(caller, "location") + ) + + helptext = """ + You get the most control by not specifying the location - you can then teleport the spawned + objects as needed later. Setting the location may be useful for quickly populating a given + location. One could also consider randomizing the location using a $protfunc. + + |c$protfuncs|n + {pfuncs} + """.format( + pfuncs=_format_protfuncs() + ) + + text = (text, helptext) + + options = _wizard_options("location", "permissions", "home", search=True) + options.append( + { + "key": "_default", + "goto": (_set_property, dict(prop="location", processor=lambda s: s.strip())), + } + ) + return text, options
+ + +# home node + + +
[docs]def node_home(caller): + text = """ + The |cHome|n location of an object is often only used as a backup - this is where the object + will be moved to if its location is deleted. The home location can also be used as an actual + home for characters to quickly move back to. + + If unset, the global home default (|w{default}|n) will be used. + + {current} + """.format( + default=settings.DEFAULT_HOME, current=_get_current_value(caller, "home") + ) + helptext = """ + The home can be given as a #dbref but can also be specified using the protfunc + '$obj(name)'. Use |wSE|nearch to find objects in the database. + + The home location is commonly not used except as a backup; using the global default is often + enough. + + |c$protfuncs|n + {pfuncs} + """.format( + pfuncs=_format_protfuncs() + ) + + text = (text, helptext) + + options = _wizard_options("home", "location", "destination", search=True) + options.append( + { + "key": "_default", + "goto": (_set_property, dict(prop="home", processor=lambda s: s.strip())), + } + ) + return text, options
+ + +# destination node + + +
[docs]def node_destination(caller): + text = """ + The object's |cDestination|n is generally only used by Exit-like objects to designate where + the exit 'leads to'. It's usually unset for all other types of objects. + + {current} + """.format( + current=_get_current_value(caller, "destination") + ) + + helptext = """ + The destination can be given as a #dbref but can also be specified using the protfunc + '$obj(name)'. Use |wSEearch to find objects in the database. + + |c$protfuncs|n + {pfuncs} + """.format( + pfuncs=_format_protfuncs() + ) + + text = (text, helptext) + + options = _wizard_options("destination", "home", "prototype_desc", search=True) + options.append( + { + "key": "_default", + "goto": (_set_property, dict(prop="destination", processor=lambda s: s.strip())), + } + ) + return text, options
+ + +# prototype_desc node + + +
[docs]def node_prototype_desc(caller): + text = """ + The |cPrototype-Description|n briefly describes the prototype when it's viewed in listings. + + {current} + """.format( + current=_get_current_value(caller, "prototype_desc") + ) + + helptext = """ + Giving a brief description helps you and others to locate the prototype for use later. + """ + + text = (text, helptext) + + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") + options.append( + { + "key": "_default", + "goto": ( + _set_property, + dict( + prop="prototype_desc", + processor=lambda s: s.strip(), + next_node="node_prototype_desc", + ), + ), + } + ) + + return text, options
+ + +# prototype_tags node + + +def _caller_prototype_tags(caller): + prototype = _get_menu_prototype(caller) + tags = prototype.get("prototype_tags", []) + tags = [tag[0] if isinstance(tag, tuple) else tag for tag in tags] + return tags + + +def _add_prototype_tag(caller, tag_string, **kwargs): + """ + Add prototype_tags to the system. We only support straight tags, no + categories (category is assigned automatically). + + Args: + caller (Object): Caller of menu. + tag_string (str): Input from user - only tagname + + Keyword Args: + delete (str): If this is set, tag_string is considered + the name of the tag to delete. + + Returns: + result (str): Result string of action. + + """ + tag = tag_string.strip().lower() + + if tag: + tags = _caller_prototype_tags(caller) + exists = tag in tags + + if "delete" in kwargs: + if exists: + tags.pop(tags.index(tag)) + text = "Removed Prototype-Tag '{}'.".format(tag) + else: + text = "Found no Prototype-Tag to remove." + elif not exists: + # a fresh, new tag + tags.append(tag) + text = "Added Prototype-Tag '{}'.".format(tag) + else: + text = "Prototype-Tag already added." + + _set_prototype_value(caller, "prototype_tags", tags) + else: + text = "No Prototype-Tag specified." + + return text + + +def _prototype_tag_select(caller, tagname): + caller.msg("Prototype-Tag: {}".format(tagname)) + return "node_prototype_tags" + + +def _prototype_tags_actions(caller, raw_inp, **kwargs): + """Parse actions for tags listing""" + choices = kwargs.get("available_choices", []) + tagname, action = _default_parse(raw_inp, choices, ("remove", "r", "delete", "d")) + + if tagname: + if action == "remove": + res = _add_prototype_tag(caller, tagname, delete=True) + caller.msg(res) + else: + res = _add_prototype_tag(caller, raw_inp.lower().strip()) + caller.msg(res) + return "node_prototype_tags" + + +@list_node(_caller_prototype_tags, _prototype_tag_select) +def node_prototype_tags(caller): + text = """ + |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not + case-sensitive and can have not have a custom category. + + {current} + """.format( + current=_get_current_value( + caller, + "prototype_tags", + formatter=lambda lst: ", ".join(tg for tg in lst), + only_inherit=True, + ) + ) + _set_actioninfo( + caller, _format_list_actions("remove", prefix="|w<text>|n|W to add Tag. Other Action:|n ") + ) + helptext = """ + Using prototype-tags is a good way to organize and group large numbers of prototypes by + genre, type etc. Under the hood, prototypes' tags will all be stored with the category + '{tagmetacategory}'. + """.format( + tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY + ) + + text = (text, helptext) + + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") + options.append({"key": "_default", "goto": _prototype_tags_actions}) + + return text, options + + +# prototype_locks node + + +def _caller_prototype_locks(caller): + locks = _get_menu_prototype(caller).get("prototype_locks", "") + return [lck for lck in locks.split(";") if lck] + + +def _prototype_lock_select(caller, lockstr): + return ( + "node_examine_entity", + {"text": _locks_display(caller, lockstr), "back": "prototype_locks"}, + ) + + +def _prototype_lock_add(caller, lock, **kwargs): + locks = _caller_prototype_locks(caller) + + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + return "Lockstring lacks ':'." + + locktype = locktype.strip().lower() + + if "delete" in kwargs: + try: + ind = locks.index(lock) + locks.pop(ind) + _set_prototype_value(caller, "prototype_locks", ";".join(locks), parse=False) + ret = "Prototype-lock {} deleted.".format(lock) + except ValueError: + ret = "No Prototype-lock found to delete." + return ret + try: + locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks] + ind = locktypes.index(locktype) + locks[ind] = lock + ret = "Prototype-lock with locktype '{}' updated.".format(locktype) + except ValueError: + locks.append(lock) + ret = "Added Prototype-lock '{}'.".format(lock) + _set_prototype_value(caller, "prototype_locks", ";".join(locks)) + return ret + + +def _prototype_locks_actions(caller, raw_inp, **kwargs): + choices = kwargs.get("available_choices", []) + lock, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d") + ) + + if lock: + if action == "examine": + return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}) + elif action == "remove": + ret = _prototype_lock_add(caller, lock.strip(), delete=True) + caller.msg(ret) + else: + ret = _prototype_lock_add(caller, raw_inp.strip()) + caller.msg(ret) + + return "node_prototype_locks" + + +@list_node(_caller_prototype_locks, _prototype_lock_select) +def node_prototype_locks(caller): + text = """ + |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying + to access it. By default any prototype can be edited only by the creator and by Admins while + they can be used by anyone with access to the spawn command. There are two valid lock types + the prototype access tools look for: + + - 'edit': Who can edit the prototype. + - 'spawn': Who can spawn new objects with this prototype. + + If unsure, keep the open defaults. + + {current} + """.format( + current=_get_current_value( + caller, + "prototype_locks", + formatter=lambda lstring: "\n".join( + _locks_display(caller, lstr) for lstr in lstring.split(";") + ), + only_inherit=True, + ) + ) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) + + helptext = """ + Prototype locks can be used to vary access for different tiers of builders. It also allows + developers to produce 'base prototypes' only meant for builders to inherit and expand on + rather than tweak in-place. + """ + + text = (text, helptext) + + options = _wizard_options("prototype_locks", "prototype_tags", "index") + options.append({"key": "_default", "goto": _prototype_locks_actions}) + + return text, options + + +# update existing objects node + + +def _apply_diff(caller, **kwargs): + """update existing objects""" + prototype = kwargs["prototype"] + objects = kwargs["objects"] + back_node = kwargs["back_node"] + diff = kwargs.get("diff", None) + num_changed = spawner.batch_update_objects_with_prototype( + prototype, diff=diff, objects=objects, caller=caller + ) + caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) + return back_node + + +def _keep_diff(caller, **kwargs): + """Change to KEEP setting for a given section of a diff""" + # from evennia import set_trace;set_trace(term_size=(182, 50)) + path = kwargs["path"] + diff = kwargs["diff"] + tmp = diff + for key in path[:-1]: + tmp = tmp[key] + tmp[path[-1]] = tuple(list(tmp[path[-1]][:-1]) + ["KEEP"]) + + +def _format_diff_text_and_options(diff, minimal=True, **kwargs): + """ + Reformat the diff in a way suitable for the olc menu. + + Args: + diff (dict): A diff as produced by `prototype_diff`. + minimal (bool, optional): Don't show KEEPs. + + Keyword Args: + any (any): Forwarded into the generated options as arguments to the callable. + + Returns: + texts (list): List of texts. + options (list): List of options dict. + + """ + valid_instructions = ("KEEP", "REMOVE", "ADD", "UPDATE") + + def _visualize(obj, rootname, get_name=False): + if utils.is_iter(obj): + if not obj: + return str(obj) + if get_name: + return obj[0] if obj[0] else "<unset>" + if rootname == "attrs": + return "{} |W=|n {} |W(category:|n {}|W, locks:|n {}|W)|n".format(*obj) + elif rootname == "tags": + return "{} |W(category:|n {}|W)|n".format(obj[0], obj[1]) + + return "{}".format(obj) + + def _parse_diffpart(diffpart, optnum, *args): + typ = type(diffpart) + texts = [] + options = [] + if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions: + rootname = args[0] + old, new, instruction = diffpart + if instruction == "KEEP": + if not minimal: + texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old, rootname))) + else: + # instructions we should be able to revert by a menu choice + vold = _visualize(old, rootname) + vnew = _visualize(new, rootname) + vsep = "" if len(vold) < 78 else "\n" + + if instruction == "ADD": + texts.append( + " |c[{optnum}] |yADD|n: {new}".format( + optnum=optnum, new=_visualize(new, rootname) + ) + ) + elif instruction == "REMOVE" and not new: + if rootname == "tags" and old[1] == protlib.PROTOTYPE_TAG_CATEGORY: + # special exception for the prototype-tag mechanism + # this is added post-spawn automatically and should + # not be listed as REMOVE. + return texts, options, optnum + + texts.append( + " |c[{optnum}] |rREMOVE|n: {old}".format( + optnum=optnum, old=_visualize(old, rootname) + ) + ) + else: + vinst = "|y{}|n".format(instruction) + texts.append( + " |c[{num}] {inst}|W:|n {old} |W->|n{sep} {new}".format( + inst=vinst, num=optnum, old=vold, sep=vsep, new=vnew + ) + ) + options.append( + { + "key": str(optnum), + "desc": "|gKEEP|n ({}) {}".format( + rootname, _visualize(old, args[-1], get_name=True) + ), + "goto": (_keep_diff, dict((("path", args), ("diff", diff)), **kwargs)), + } + ) + optnum += 1 + else: + for key in sorted(list(diffpart.keys())): + subdiffpart = diffpart[key] + text, option, optnum = _parse_diffpart(subdiffpart, optnum, *(args + (key,))) + texts.extend(text) + options.extend(option) + return texts, options, optnum + + texts = [] + options = [] + # we use this to allow for skipping full KEEP instructions + optnum = 1 + + for root_key in sorted(diff): + diffpart = diff[root_key] + text, option, optnum = _parse_diffpart(diffpart, optnum, root_key) + heading = "- |w{}:|n ".format(root_key) + if text: + text = [heading + text[0]] + text[1:] + else: + text = [heading] + + texts.extend(text) + options.extend(option) + + return texts, options + + +
[docs]def node_apply_diff(caller, **kwargs): + """Offer options for updating objects""" + + def _keep_option(keyname, prototype, base_obj, obj_prototype, diff, objects, back_node): + """helper returning an option dict""" + options = { + "desc": "Keep {} as-is".format(keyname), + "goto": ( + _keep_diff, + { + "key": keyname, + "prototype": prototype, + "base_obj": base_obj, + "obj_prototype": obj_prototype, + "diff": diff, + "objects": objects, + "back_node": back_node, + }, + ), + } + return options + + prototype = kwargs.get("prototype", None) + update_objects = kwargs.get("objects", None) + back_node = kwargs.get("back_node", "node_index") + obj_prototype = kwargs.get("obj_prototype", None) + base_obj = kwargs.get("base_obj", None) + diff = kwargs.get("diff", None) + custom_location = kwargs.get("custom_location", None) + + if not update_objects: + text = "There are no existing objects to update." + options = {"key": "_default", "goto": back_node} + return text, options + + if not diff: + # use one random object as a reference to calculate a diff + base_obj = choice(update_objects) + + diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj) + + helptext = """ + This will go through all existing objects and apply the changes you accept. + + Be careful with this operation! The upgrade mechanism will try to automatically estimate + what changes need to be applied. But the estimate is |wonly based on the analysis of one + randomly selected object|n among all objects spawned by this prototype. If that object + happens to be unusual in some way the estimate will be off and may lead to unexpected + results for other objects. Always test your objects carefully after an upgrade and consider + being conservative (switch to KEEP) for things you are unsure of. For complex upgrades it + may be better to get help from an administrator with access to the `@py` command for doing + this manually. + + Note that the `location` will never be auto-adjusted because it's so rare to want to + homogenize the location of all object instances.""" + + if not custom_location: + diff.pop("location", None) + + txt, options = _format_diff_text_and_options( + diff, objects=update_objects, base_obj=base_obj, prototype=prototype + ) + + if options: + text = [ + "Suggested changes to {} objects. ".format(len(update_objects)), + "Showing random example obj to change: {name} ({dbref}))\n".format( + name=base_obj.key, dbref=base_obj.dbref + ), + ] + txt + options.extend( + [ + { + "key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), + "desc": "Update {} objects".format(len(update_objects)), + "goto": ( + _apply_diff, + { + "prototype": prototype, + "objects": update_objects, + "back_node": back_node, + "diff": diff, + "base_obj": base_obj, + }, + ), + }, + { + "key": ("|wr|Weset changes", "reset", "r"), + "goto": ( + "node_apply_diff", + {"prototype": prototype, "back_node": back_node, "objects": update_objects}, + ), + }, + ] + ) + else: + text = [ + "Analyzed a random sample object (out of {}) - " + "found no changes to apply.".format(len(update_objects)) + ] + + options.extend(_wizard_options("update_objects", back_node[5:], None)) + options.append({"key": "_default", "goto": back_node}) + + text = "\n".join(text) + text = (text, helptext) + + return text, options
+ + +# prototype save node + + +
[docs]def node_prototype_save(caller, **kwargs): + """Save prototype to disk""" + # these are only set if we selected 'yes' to save on a previous pass + prototype = kwargs.get("prototype", None) + # set to True/False if answered, None if first pass + accept_save = kwargs.get("accept_save", None) + + if accept_save and prototype: + # we already validated and accepted the save, so this node acts as a goto callback and + # should now only return the next node + prototype_key = prototype.get("prototype_key") + try: + protlib.save_prototype(prototype) + except Exception as exc: + text = "|rCould not save:|n {}\n(press Return to continue)".format(exc) + options = {"key": "_default", "goto": "node_index"} + return text, options + + spawned_objects = protlib.search_objects_with_prototype(prototype_key) + nspawned = spawned_objects.count() + + text = ["|gPrototype saved.|n"] + + if nspawned: + text.append( + "\nDo you want to update {} object(s) " + "already using this prototype?".format(nspawned) + ) + options = ( + { + "key": ("|wY|Wes|n", "yes", "y"), + "desc": "Go to updating screen", + "goto": ( + "node_apply_diff", + { + "accept_update": True, + "objects": spawned_objects, + "prototype": prototype, + "back_node": "node_prototype_save", + }, + ), + }, + {"key": ("[|wN|Wo|n]", "n"), "desc": "Return to index", "goto": "node_index"}, + {"key": "_default", "goto": "node_index"}, + ) + else: + text.append("(press Return to continue)") + options = {"key": "_default", "goto": "node_index"} + + text = "\n".join(text) + + helptext = """ + Updating objects means that the spawner will find all objects previously created by this + prototype. You will be presented with a list of the changes the system will try to apply to + each of these objects and you can choose to customize that change if needed. If you have + done a lot of manual changes to your objects after spawning, you might want to update those + objects manually instead. + """ + + text = (text, helptext) + + return text, options + + # not validated yet + prototype = _get_menu_prototype(caller) + error, text = _validate_prototype(prototype) + + text = [text] + + if error: + # abort save + text.append( + "\n|yValidation errors were found. They need to be corrected before this prototype " + "can be saved (or used to spawn).|n" + ) + options = _wizard_options("prototype_save", "index", None) + options.append({"key": "_default", "goto": "node_index"}) + return "\n".join(text), options + + prototype_key = prototype["prototype_key"] + if protlib.search_prototype(prototype_key): + text.append( + "\nDo you want to save/overwrite the existing prototype '{name}'?".format( + name=prototype_key + ) + ) + else: + text.append("\nDo you want to save the prototype as '{name}'?".format(name=prototype_key)) + + text = "\n".join(text) + + helptext = """ + Saving the prototype makes it available for use later. It can also be used to inherit from, + by name. Depending on |cprototype-locks|n it also makes the prototype usable and/or + editable by others. Consider setting good |cPrototype-tags|n and to give a useful, brief + |cPrototype-desc|n to make the prototype easy to find later. + + """ + + text = (text, helptext) + + options = ( + { + "key": ("[|wY|Wes|n]", "yes", "y"), + "desc": "Save prototype", + "goto": ("node_prototype_save", {"accept_save": True, "prototype": prototype}), + }, + {"key": ("|wN|Wo|n", "n"), "desc": "Abort and return to Index", "goto": "node_index"}, + { + "key": "_default", + "goto": ("node_prototype_save", {"accept_save": True, "prototype": prototype}), + }, + ) + + return text, options
+ + +# spawning node + + +def _spawn(caller, **kwargs): + """Spawn prototype""" + prototype = kwargs["prototype"].copy() + new_location = kwargs.get("location", None) + if new_location: + prototype["location"] = new_location + if not prototype.get("location"): + prototype["location"] = caller + + obj = spawner.spawn(prototype, caller=caller) + if obj: + obj = obj[0] + text = "|gNew instance|n {key} ({dbref}) |gspawned at location |n{loc}|n|g.|n".format( + key=obj.key, dbref=obj.dbref, loc=prototype["location"] + ) + else: + text = "|rError: Spawner did not return a new instance.|n" + return "node_examine_entity", {"text": text, "back": "prototype_spawn"} + + +
[docs]def node_prototype_spawn(caller, **kwargs): + """Submenu for spawning the prototype""" + + prototype = _get_menu_prototype(caller) + + already_validated = kwargs.get("already_validated", False) + + if already_validated: + error, text = None, [] + else: + error, text = _validate_prototype(prototype) + text = [text] + + if error: + text.append("\n|rPrototype validation failed. Correct the errors before spawning.|n") + options = _wizard_options("prototype_spawn", "index", None) + return "\n".join(text), options + + text = "\n".join(text) + + helptext = """ + Spawning is the act of instantiating a prototype into an actual object. As a new object is + spawned, every $protfunc in the prototype is called anew. Since this is a common thing to + do, you may also temporarily change the |clocation|n of this prototype to bypass whatever + value is set in the prototype. + + """ + text = (text, helptext) + + # show spawn submenu options + options = [] + prototype_key = prototype["prototype_key"] + location = prototype.get("location", None) + + if location: + options.append( + { + "desc": "Spawn in prototype's defined location ({loc})".format(loc=location), + "goto": ( + _spawn, + dict(prototype=prototype, location=location, custom_location=True), + ), + } + ) + caller_loc = caller.location + if location != caller_loc: + options.append( + { + "desc": "Spawn in {caller}'s location ({loc})".format( + caller=caller, loc=caller_loc + ), + "goto": (_spawn, dict(prototype=prototype, location=caller_loc)), + } + ) + if location != caller_loc != caller: + options.append( + { + "desc": "Spawn in {caller}'s inventory".format(caller=caller), + "goto": (_spawn, dict(prototype=prototype, location=caller)), + } + ) + + spawned_objects = protlib.search_objects_with_prototype(prototype_key) + nspawned = spawned_objects.count() + if spawned_objects: + options.append( + { + "desc": "Update {num} existing objects with this prototype".format(num=nspawned), + "goto": ( + "node_apply_diff", + { + "objects": list(spawned_objects), + "prototype": prototype, + "back_node": "node_prototype_spawn", + }, + ), + } + ) + options.extend(_wizard_options("prototype_spawn", "index", None)) + options.append({"key": "_default", "goto": "node_index"}) + + return text, options
+ + +# prototype load node + + +def _prototype_load_select(caller, prototype_key, **kwargs): + matches = protlib.search_prototype(key=prototype_key) + if matches: + prototype = matches[0] + _set_menu_prototype(caller, prototype) + return ( + "node_examine_entity", + { + "text": "|gLoaded prototype {}.|n".format(prototype["prototype_key"]), + "back": "index", + }, + ) + else: + caller.msg("|rFailed to load prototype '{}'.".format(prototype_key)) + return None + + +def _prototype_load_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("delete", "del", "d") + ) + + if prototype: + # which action to apply on the selection + if action == "examine": + # examine the prototype + prototype = protlib.search_prototype(key=prototype)[0] + txt = protlib.prototype_to_str(prototype) + return "node_examine_entity", {"text": txt, "back": "prototype_load"} + elif action == "delete": + # delete prototype from disk + try: + protlib.delete_prototype(prototype, caller=caller) + except protlib.PermissionError as err: + txt = "|rDeletion error:|n {}".format(err) + else: + txt = "|gPrototype {} was deleted.|n".format(prototype) + return "node_examine_entity", {"text": txt, "back": "prototype_load"} + + return "node_prototype_load" + + +@list_node(_all_prototype_parents, _prototype_load_select) +def node_prototype_load(caller, **kwargs): + """Load prototype""" + + text = """ + Select a prototype to load. This will replace any prototype currently being edited! + """ + _set_actioninfo(caller, _format_list_actions("examine", "delete")) + + helptext = """ + Loading a prototype will load it and return you to the main index. It can be a good idea + to examine the prototype before loading it. + """ + + text = (text, helptext) + + options = _wizard_options("prototype_load", "index", None) + options.append({"key": "_default", "goto": _prototype_load_actions}) + + return text, options + + +# EvMenu definition, formatting and access functions + + +
[docs]class OLCMenu(EvMenu): + """ + A custom EvMenu with a different formatting for the options. + + """ + +
[docs] def nodetext_formatter(self, nodetext): + """ + Format the node text itself. + + """ + return super().nodetext_formatter(nodetext)
+ +
[docs] def options_formatter(self, optionlist): + """ + Split the options into two blocks - olc options and normal options + + """ + olc_keys = ( + "index", + "forward", + "back", + "previous", + "next", + "validate prototype", + "save prototype", + "load prototype", + "spawn prototype", + "search objects", + ) + actioninfo = self.actioninfo + "\n" if hasattr(self, "actioninfo") else "" + self.actioninfo = "" # important, or this could bleed over to other nodes + olc_options = [] + other_options = [] + for key, desc in optionlist: + raw_key = strip_ansi(key).lower() + if raw_key in olc_keys: + desc = " {}".format(desc) if desc else "" + olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) + else: + other_options.append((key, desc)) + + olc_options = ( + actioninfo + " |W|||n ".join(olc_options) + " |W|||n " + "|wQ|Wuit" + if olc_options + else "" + ) + other_options = super().options_formatter(other_options) + sep = "\n\n" if olc_options and other_options else "" + + return "{}{}{}".format(olc_options, sep, other_options)
+ +
[docs] def helptext_formatter(self, helptext): + """ + Show help text + """ + return "|c --- Help ---|n\n" + utils.dedent(helptext)
+ +
[docs] def display_helptext(self): + evmore.msg(self.caller, self.helptext, session=self._session, exit_cmd="look")
+ + +
[docs]def start_olc(caller, session=None, prototype=None): + """ + Start menu-driven olc system for prototypes. + + Args: + caller (Object or Account): The entity starting the menu. + session (Session, optional): The individual session to get data. + prototype (dict, optional): Given when editing an existing + prototype rather than creating a new one. + + """ + menudata = { + "node_index": node_index, + "node_validate_prototype": node_validate_prototype, + "node_examine_entity": node_examine_entity, + "node_search_object": node_search_object, + "node_prototype_key": node_prototype_key, + "node_prototype_parent": node_prototype_parent, + "node_typeclass": node_typeclass, + "node_key": node_key, + "node_aliases": node_aliases, + "node_attrs": node_attrs, + "node_tags": node_tags, + "node_locks": node_locks, + "node_permissions": node_permissions, + "node_location": node_location, + "node_home": node_home, + "node_destination": node_destination, + "node_apply_diff": node_apply_diff, + "node_prototype_desc": node_prototype_desc, + "node_prototype_tags": node_prototype_tags, + "node_prototype_locks": node_prototype_locks, + "node_prototype_load": node_prototype_load, + "node_prototype_save": node_prototype_save, + "node_prototype_spawn": node_prototype_spawn, + } + OLCMenu( + caller, + menudata, + startnode="node_index", + session=session, + olc_prototype=prototype, + debug=True, + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/prototypes/protfuncs.html b/docs/latest/_modules/evennia/prototypes/protfuncs.html new file mode 100644 index 0000000000..d9c2687ff3 --- /dev/null +++ b/docs/latest/_modules/evennia/prototypes/protfuncs.html @@ -0,0 +1,191 @@ + + + + + + + + evennia.prototypes.protfuncs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.prototypes.protfuncs

+"""
+Protfuncs are FuncParser-callables that can be embedded in a prototype to provide custom logic
+without having access to Python. The protfunc is parsed at the time of spawning, using the creating
+object's session as input. If the protfunc returns a non-string, this is what will be added to the
+prototype.
+
+In the prototype dict, the protfunc is specified as a string inside the prototype, e.g.:
+
+```python
+{ ...
+
+"key": "$funcname(args, kwargs)"
+
+...  }
+```
+
+Available protfuncs are either all callables in one of the modules of `settings.PROT_FUNC_MODULES`
+or all callables added to a dict FUNCPARSER_CALLABLES in such a module. By default, base inlinefuncs
+for text manipulation and searching are included, as well as the special `$protkey` function.
+See the Prototypes and Spawner documentation for more info.
+
+```python
+def funcname (*args, **kwargs):
+    return "replacement text"
+```
+
+At spawn-time the spawner passes the following extra kwargs into each callable (in addition to
+what is added in the call itself):
+
+- `session` (Session): The Session of the entity spawning using this prototype.
+- `prototype` (dict): The dict this protfunc is a part of.
+- `current_key` (str): The active key this value belongs to in the prototype.
+
+Any traceback raised by this function will be handled at the time of spawning and abort the spawn
+before any object is created/updated. It must otherwise return the value to store for the specified
+prototype key (this value must be possible to serialize in an Attribute).
+
+"""
+
+from evennia.utils import funcparser
+
+
+
[docs]def protfunc_callable_protkey(*args, **kwargs): + """ + Usage: $protkey(keyname) + + Returns the value of another key in this prototoype. Will raise an error if + the key is not found in this prototype. + + """ + if not args: + return "" + + prototype = kwargs.get("prototype", {}) + fieldname = args[0] + prot_value = None + if fieldname in prototype: + prot_value = prototype[fieldname] + else: + # check if it's an attribute + for attrtuple in prototype.get("attrs", []): + if attrtuple[0] == fieldname: + prot_value = attrtuple[1] + break + else: + raise AttributeError( + f"{fieldname} not found in prototype\n{prototype}\n" + "(neither as prototype-field or as an Attribute" + ) + if callable(prot_value): + raise RuntimeError( + f"Error in prototype\n{prototype}\n$protkey can only reference static " + f"values/attributes (found {prot_value})" + ) + try: + return funcparser.funcparser_callable_eval(prot_value, **kwargs) + except funcparser.ParsingError: + return prot_value
+ + +# this is picked up by FuncParser +FUNCPARSER_CALLABLES = { + "protkey": protfunc_callable_protkey, + **funcparser.FUNCPARSER_CALLABLES, + **funcparser.SEARCHING_CALLABLES, +} +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/prototypes/prototypes.html b/docs/latest/_modules/evennia/prototypes/prototypes.html new file mode 100644 index 0000000000..615348e6f0 --- /dev/null +++ b/docs/latest/_modules/evennia/prototypes/prototypes.html @@ -0,0 +1,1345 @@ + + + + + + + + evennia.prototypes.prototypes — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.prototypes.prototypes

+"""
+
+Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules
+(Read-only prototypes). Also contains utility functions, formatters and manager functions.
+
+"""
+
+import hashlib
+import time
+
+from django.conf import settings
+from django.core.paginator import Paginator
+from django.db.models import Q
+from django.utils.translation import gettext as _
+
+from evennia.locks.lockhandler import check_lockstring, validate_lockstring
+from evennia.objects.models import ObjectDB
+from evennia.scripts.scripts import DefaultScript
+from evennia.typeclasses.attributes import Attribute
+from evennia.utils import dbserialize, logger
+from evennia.utils.create import create_script
+from evennia.utils.evmore import EvMore
+from evennia.utils.evtable import EvTable
+from evennia.utils.funcparser import FuncParser
+from evennia.utils.utils import (
+    all_from_module,
+    class_from_module,
+    dbid_to_obj,
+    is_iter,
+    justify,
+    make_iter,
+    variable_from_module,
+)
+
+_MODULE_PROTOTYPE_MODULES = {}
+_MODULE_PROTOTYPES = {}
+_PROTOTYPE_META_NAMES = (
+    "prototype_key",
+    "prototype_desc",
+    "prototype_tags",
+    "prototype_locks",
+    "prototype_parent",
+)
+_PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + (
+    "key",
+    "aliases",
+    "typeclass",
+    "location",
+    "home",
+    "destination",
+    "permissions",
+    "locks",
+    "tags",
+    "attrs",
+)
+_ERRSTR = _("Error")
+_WARNSTR = _("Warning")
+PROTOTYPE_TAG_CATEGORY = "from_prototype"
+_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
+
+_PROTOTYPE_FALLBACK_LOCK = "spawn:all();edit:all()"
+
+# the protfunc parser
+FUNC_PARSER = FuncParser(settings.PROT_FUNC_MODULES)
+
+
+
[docs]class PermissionError(RuntimeError): + pass
+ + +
[docs]class ValidationError(RuntimeError): + """ + Raised on prototype validation errors + """ + + pass
+ + +
[docs]def homogenize_prototype(prototype, custom_keys=None): + """ + Homogenize the more free-form prototype supported pre Evennia 0.7 into the stricter form. + + Args: + prototype (dict): Prototype. + custom_keys (list, optional): Custom keys which should not be interpreted as attrs, beyond + the default reserved keys. + + Returns: + homogenized (dict): Prototype where all non-identified keys grouped as attributes and other + homogenizations like adding missing prototype_keys and setting a default typeclass. + + """ + if not prototype or isinstance(prototype, str): + return prototype + + reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ()) + + # correct cases of setting None for certain values + for protkey in prototype: + if prototype[protkey] is None: + if protkey in ("attrs", "tags", "prototype_tags"): + prototype[protkey] = [] + elif protkey in ("prototype_key", "prototype_desc"): + prototype[protkey] = "" + + homogenized = {} + homogenized_tags = [] + homogenized_attrs = [] + homogenized_parents = [] + + for key, val in prototype.items(): + if key in reserved: + # check all reserved keys + if key == "tags": + # tags must be on form [(tag, category, data), ...] + tags = make_iter(prototype.get("tags", [])) + for tag in tags: + if not is_iter(tag): + homogenized_tags.append((tag, None, None)) + elif tag: + ntag = len(tag) + if ntag == 1: + homogenized_tags.append((tag[0], None, None)) + elif ntag == 2: + homogenized_tags.append((tag[0], tag[1], None)) + else: + homogenized_tags.append(tag[:3]) + + elif key == "attrs": + attrs = list(prototype.get("attrs", [])) # break reference + for attr in attrs: + # attrs must be on form [(key, value, category, lockstr)] + if not is_iter(attr): + logger.log_err( + f"Prototype's 'attr' field must be a list of tuples: {prototype}" + ) + elif attr: + nattr = len(attr) + if nattr == 1: + # we assume a None-value + homogenized_attrs.append((attr[0], None, None, "")) + elif nattr == 2: + homogenized_attrs.append((attr[0], attr[1], None, "")) + elif nattr == 3: + homogenized_attrs.append((attr[0], attr[1], attr[2], "")) + else: + homogenized_attrs.append(attr[:4]) + + elif key == "prototype_parent": + # homogenize any prototype-parents embedded directly as dicts + protparents = prototype.get("prototype_parent", []) + if isinstance(protparents, dict): + protparents = [protparents] + for parent in make_iter(protparents): + if isinstance(parent, dict): + # recursively homogenize directly embedded prototype parents + homogenized_parents.append( + homogenize_prototype(parent, custom_keys=custom_keys) + ) + else: + # normal prototype-parent names are added as-is + homogenized_parents.append(parent) + + else: + # another reserved key + homogenized[key] = val + else: + # unreserved keys -> attrs + homogenized_attrs.append((key, val, None, "")) + if homogenized_attrs: + homogenized["attrs"] = homogenized_attrs + if homogenized_tags: + homogenized["tags"] = homogenized_tags + if homogenized_parents: + homogenized["prototype_parent"] = homogenized_parents + + # add required missing parts that had defaults before + + homogenized["prototype_key"] = homogenized.get( + "prototype_key", + # assign a random hash as key + "prototype-{}".format(hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7]), + ) + homogenized["prototype_tags"] = homogenized.get("prototype_tags", []) + homogenized["prototype_locks"] = homogenized.get("prototype_lock", _PROTOTYPE_FALLBACK_LOCK) + homogenized["prototype_desc"] = homogenized.get("prototype_desc", "") + if "typeclass" not in prototype and "prototype_parent" not in prototype: + homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS + + return homogenized
+ + +# module/dict-based prototypes + + +
[docs]def load_module_prototypes(*mod_or_prototypes, override=True): + """ + Load module prototypes. Also prototype-dicts passed directly to this function are considered + 'module' prototypes (they are impossible to change) but will have a module of None. + + Args: + *mod_or_prototypes (module or dict): Each arg should be a separate module or + prototype-dict to load. If none are given, `settings.PROTOTYPE_MODULES` will be used. + override (bool, optional): If prototypes should override existing ones already loaded. + Disabling this can allow for injecting prototypes into the system dynamically while + still allowing same prototype-keys to be overridden from settings (even though settings + is usually loaded before dynamic loading). + + Note: + This is called (without arguments) by `evennia.__init__` as Evennia initializes. It's + important to do this late so as to not interfere with evennia initialization. But it can + also be used later to add more prototypes to the library on the fly. This is requried + before a module-based prototype can be accessed by prototype-key. + + """ + global _MODULE_PROTOTYPE_MODULES, _MODULE_PROTOTYPES + + def _prototypes_from_module(mod): + """ + Load prototypes from a module, first by looking for a global list PROTOTYPE_LIST (a list of + dict-prototypes), and if not found, assuming all global-level dicts in the module are + prototypes. + + Args: + mod (module): The module to load from.evennia + + Returns: + list: A list of tuples `(prototype_key, prototype-dict)` where the prototype + has been homogenized. + + """ + prots = [] + prototype_list = variable_from_module(mod, "PROTOTYPE_LIST") + if prototype_list: + # found mod.PROTOTYPE_LIST - this should be a list of valid + # prototype dicts that must have 'prototype_key' set. + for prot in prototype_list: + if not isinstance(prot, dict): + logger.log_err( + f"Prototype read from {mod}.PROTOTYPE_LIST is not a dict (skipping): {prot}" + ) + continue + elif "prototype_key" not in prot: + logger.log_err( + f"Prototype read from {mod}.PROTOTYPE_LIST " + f"is missing the 'prototype_key' (skipping): {prot}" + ) + continue + prots.append((prot["prototype_key"], homogenize_prototype(prot))) + else: + # load all global dicts in module as prototypes. If the prototype_key + # is not given, the variable name will be used. + for variable_name, prot in all_from_module(mod).items(): + if isinstance(prot, dict): + if "prototype_key" not in prot: + prot["prototype_key"] = variable_name.lower() + prots.append((prot["prototype_key"], homogenize_prototype(prot))) + return prots + + def _cleanup_prototype(prototype_key, prototype, mod=None): + """ + We need to handle externally determined prototype-keys and to make sure + the prototype contains all needed meta information. + + Args: + prototype_key (str): The determined name of the prototype. + prototype (dict): The prototype itself. + mod (module, optional): The module the prototype was loaded from, if any. + + Returns: + dict: The cleaned up prototype. + + """ + actual_prot_key = prototype.get("prototype_key", prototype_key).lower() + prototype.update( + { + "prototype_key": actual_prot_key, + "prototype_desc": ( + prototype["prototype_desc"] if "prototype_desc" in prototype else (mod or "N/A") + ), + "prototype_locks": ( + prototype["prototype_locks"] + if "prototype_locks" in prototype + else "use:all();edit:false()" + ), + "prototype_tags": list( + set(list(make_iter(prototype.get("prototype_tags", []))) + ["module"]) + ), + } + ) + return prototype + + if not mod_or_prototypes: + # in principle this means PROTOTYPE_MODULES could also contain prototypes, but that is + # rarely useful ... + mod_or_prototypes = settings.PROTOTYPE_MODULES + + for mod_or_dict in mod_or_prototypes: + if isinstance(mod_or_dict, dict): + # a single prototype; we must make sure it has its key + prototype_key = mod_or_dict.get("prototype_key") + if not prototype_key: + raise ValidationError( + f"The prototype {mod_or_dict} does not contain a 'prototype_key'" + ) + prots = [(prototype_key, mod_or_dict)] + mod = None + else: + # a module (or path to module). This can contain many prototypes; they can be keyed by + # variable-name too + prots = _prototypes_from_module(mod_or_dict) + mod = repr(mod_or_dict) + + # store all found prototypes + for prototype_key, prot in prots: + prototype = _cleanup_prototype(prototype_key, prot, mod=mod) + # the key can change since in-proto key is given prio over variable-name-based keys + actual_prototype_key = prototype["prototype_key"] + + if actual_prototype_key in _MODULE_PROTOTYPES and not override: + # don't override - useful to still let settings replace dynamic inserts + continue + + # make sure the prototype contains all meta info + _MODULE_PROTOTYPES[actual_prototype_key] = prototype + # track module path for display purposes + _MODULE_PROTOTYPE_MODULES[actual_prototype_key.lower()] = mod
+ + +# Db-based prototypes + + +
[docs]class DBPrototypeCache: + """ + Cache DB-stored prototypes; it can still be slow to initially load 1000s of + prototypes, due to having to deserialize all prototype-dicts, but after the + first time the cache will be populated and things will be fast. + + """ + +
[docs] def __init__(self): + self._cache = {}
+ +
[docs] def get(self, db_prot_id): + return self._cache.get(db_prot_id, None)
+ +
[docs] def add(self, db_prot_id, prototype): + self._cache[db_prot_id] = prototype
+ +
[docs] def remove(self, db_prot_id): + self._cache.pop(db_prot_id, None)
+ +
[docs] def clear(self): + self._cache = {}
+ +
[docs] def replace(self, all_data): + self._cache = all_data
+ + +DB_PROTOTYPE_CACHE = DBPrototypeCache() + + +
[docs]class DbPrototype(DefaultScript): + """ + This stores a single prototype, in an Attribute `prototype`. + + """ + +
[docs] def at_script_creation(self): + self.key = "empty prototype" # prototype_key + self.desc = "A prototype" # prototype_desc (.tags are used for prototype_tags) + self.db.prototype = {} # actual prototype
+ + @property + def prototype(self): + "Make sure to decouple from db!" + return dbserialize.deserialize(self.attributes.get("prototype", {})) + + @prototype.setter + def prototype(self, prototype): + self.attributes.add("prototype", prototype)
+ + +# Prototype manager functions + + +
[docs]def save_prototype(prototype): + """ + Create/Store a prototype persistently. + + Args: + prototype (dict): The prototype to save. A `prototype_key` key is + required. + + Returns: + prototype (dict or None): The prototype stored using the given kwargs, None if deleting. + + Raises: + prototypes.ValidationError: If prototype does not validate. + + Note: + No edit/spawn locks will be checked here - if this function is called the caller + is expected to have valid permissions. + + """ + in_prototype = prototype + in_prototype = homogenize_prototype(in_prototype) + + def _to_batchtuple(inp, *args): + "build tuple suitable for batch-creation" + if is_iter(inp): + # already a tuple/list, use as-is + return inp + return (inp,) + args + + prototype_key = in_prototype.get("prototype_key") + if not prototype_key: + raise ValidationError(_("Prototype requires a prototype_key")) + + prototype_key = str(prototype_key).lower() + + # we can't edit a prototype defined in a module + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key) + if mod: + err = _("{protkey} is a read-only prototype (defined as code in {module}).") + else: + err = _("{protkey} is a read-only prototype (passed directly as a dict).") + raise PermissionError(err.format(protkey=prototype_key, module=mod)) + + # make sure meta properties are included with defaults + in_prototype["prototype_desc"] = in_prototype.get( + "prototype_desc", prototype.get("prototype_desc", "") + ) + prototype_locks = in_prototype.get( + "prototype_locks", prototype.get("prototype_locks", _PROTOTYPE_FALLBACK_LOCK) + ) + is_valid, err = validate_lockstring(prototype_locks) + if not is_valid: + raise ValidationError("Lock error: {}".format(err)) + in_prototype["prototype_locks"] = prototype_locks + + prototype_tags = [ + _to_batchtuple(tag, _PROTOTYPE_TAG_META_CATEGORY) + for tag in make_iter( + in_prototype.get("prototype_tags", prototype.get("prototype_tags", [])) + ) + ] + in_prototype["prototype_tags"] = prototype_tags + + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + if stored_prototype: + # edit existing prototype + stored_prototype = stored_prototype[0] + stored_prototype.desc = in_prototype["prototype_desc"] + if prototype_tags: + stored_prototype.tags.clear(category=PROTOTYPE_TAG_CATEGORY) + stored_prototype.tags.batch_add(*in_prototype["prototype_tags"]) + stored_prototype.locks.add(in_prototype["prototype_locks"]) + stored_prototype.attributes.add("prototype", in_prototype) + else: + # create a new prototype + stored_prototype = create_script( + DbPrototype, + key=prototype_key, + desc=in_prototype["prototype_desc"], + persistent=True, + locks=prototype_locks, + tags=in_prototype["prototype_tags"], + attributes=[("prototype", in_prototype)], + ) + DB_PROTOTYPE_CACHE.add(stored_prototype.id, stored_prototype.prototype) + return stored_prototype.prototype
+ + +create_prototype = save_prototype # alias + + +
[docs]def delete_prototype(prototype_key, caller=None): + """ + Delete a stored prototype + + Args: + key (str): The persistent prototype to delete. + caller (Account or Object, optionsl): Caller aiming to delete a prototype. + Note that no locks will be checked if`caller` is not passed. + Returns: + success (bool): If deletion worked or not. + Raises: + PermissionError: If 'edit' lock was not passed or deletion failed for some other reason. + + """ + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key) + if mod: + err = _("{protkey} is a read-only prototype (defined as code in {module}).") + else: + err = _("{protkey} is a read-only prototype (passed directly as a dict).") + raise PermissionError(err.format(protkey=prototype_key, module=mod)) + + stored_prototype = DbPrototype.objects.filter(db_key__iexact=prototype_key) + + if not stored_prototype: + raise PermissionError( + _("Prototype {prototype_key} was not found.").format(prototype_key=prototype_key) + ) + + stored_prototype = stored_prototype[0] + if caller: + if not stored_prototype.access(caller, "edit"): + raise PermissionError( + _( + "{caller} needs explicit 'edit' permissions to " + "delete prototype {prototype_key}." + ).format(caller=caller, prototype_key=prototype_key) + ) + DB_PROTOTYPE_CACHE.remove(stored_prototype.id) + stored_prototype.delete() + return True
+ + +
[docs]def search_prototype( + key=None, + tags=None, + require_single=False, + return_iterators=False, + no_db=False, +): + """ + Find prototypes based on key and/or tags, or all prototypes. + + Keyword Args: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'db_protototype' + tag category. + require_single (bool): If set, raise KeyError if the result + was not found or if there are multiple matches. + return_iterators (bool): Optimized return for large numbers of db-prototypes. + If set, separate returns of module based prototypes and paginate + the db-prototype return. + no_db (bool): Optimization. If set, skip querying for database-generated prototypes and only + include module-based prototypes. This can lead to a dramatic speedup since + module-prototypes are static and require no db-lookup. + + Return: + matches (list): Default return, all found prototype dicts. Empty list if + no match was found. Note that if neither `key` nor `tags` + were given, *all* available prototypes will be returned. + list, queryset: If `return_iterators` are found, this is a list of + module-based prototypes followed by a queryset of + db-prototypes. + + Raises: + KeyError: If `require_single` is True and there are 0 or >1 matches. + + Note: + The available prototypes is a combination of those supplied in + PROTOTYPE_MODULES and those stored in the database. Note that if + tags are given and the prototype has no tags defined, it will not + be found as a match. + + """ + + def _search_module_based_prototypes(key, tags): + """ + Helper function to load module-based prots. + + """ + # This will load the prototypes the first time they are searched + loaded = getattr(load_module_prototypes, "_LOADED", False) + if not loaded: + load_module_prototypes() + setattr(load_module_prototypes, "_LOADED", True) + + # search module prototypes + + mod_matches = {} + if tags: + # use tags to limit selection + tagset = set(tags) + mod_matches = { + prototype_key: prototype + for prototype_key, prototype in _MODULE_PROTOTYPES.items() + if tagset.intersection(prototype.get("prototype_tags", [])) + } + else: + mod_matches = _MODULE_PROTOTYPES + + fuzzy_match_db = True + if key: + if key in mod_matches: + # exact match + module_prototypes = [mod_matches[key].copy()] + fuzzy_match_db = False + else: + # fuzzy matching + module_prototypes = [ + prototype + for prototype_key, prototype in mod_matches.items() + if key in prototype_key + ] + else: + # note - we return a copy of the prototype dict, otherwise using this with e.g. + # prototype_from_object will modify the base prototype for every object + module_prototypes = [match.copy() for match in mod_matches.values()] + + return module_prototypes, fuzzy_match_db + + def _search_db_based_prototypes(key, tags, fuzzy_matching): + """ + Helper function for loading db-based prots. + + """ + # search db-stored prototypes + if tags: + # exact match on tag(s) + tags = make_iter(tags) + tag_categories = ["db_prototype" for _ in tags] + query = DbPrototype.objects.get_by_tag(tags, tag_categories) + else: + query = DbPrototype.objects.all() + + if key: + # exact or partial match on key + exact_match = query.filter(Q(db_key__iexact=key)) + if not exact_match and fuzzy_matching: + # try with partial match instead + query = query.filter(Q(db_key__icontains=key)) + else: + query = exact_match + + # convert to prototype, cached or from db + + db_matches = [] + not_found = [] + for db_id in query.values_list("id", flat=True).order_by("db_key"): + prot = DB_PROTOTYPE_CACHE.get(db_id) + if prot: + db_matches.append(prot) + else: + not_found.append(db_id) + + if not_found: + new_db_matches = ( + Attribute.objects.filter(scriptdb__pk__in=not_found, db_key="prototype") + .values_list("db_value", flat=True) + .order_by("scriptdb__db_key") + ) + for db_id, prot in zip(not_found, new_db_matches): + DB_PROTOTYPE_CACHE.add(db_id, prot) + db_matches.extend(list(new_db_matches)) + + return db_matches + + if key: + key = key.lower() + + module_prototypes, fuzzy_match_db = _search_module_based_prototypes(key, tags) + + db_prototypes = [] if no_db else _search_db_based_prototypes(key, tags, fuzzy_match_db) + + if key and require_single: + num = len(module_prototypes) + len(db_prototypes) + if num != 1: + raise KeyError(_(f"Found {num} matching prototypes.")) + + if return_iterators: + # trying to get the entire set of prototypes - we must paginate + # the result instead of trying to fetch the entire set at once + return db_prototypes, module_prototypes + else: + # full fetch, no pagination (compatibility mode) + return list(db_prototypes) + module_prototypes
+ + +
[docs]def search_objects_with_prototype(prototype_key): + """ + Retrieve all object instances created by a given prototype. + + Args: + prototype_key (str): The exact (and unique) prototype identifier to query for. + + Returns: + matches (Queryset): All matching objects spawned from this prototype. + + """ + return ObjectDB.objects.get_by_tag(key=prototype_key, category=PROTOTYPE_TAG_CATEGORY)
+ + +
[docs]class PrototypeEvMore(EvMore): + """ + Listing 1000+ prototypes can be very slow. So we customize EvMore to + display an EvTable per paginated page rather than to try creating an + EvTable for the entire dataset and then paginate it. + + """ + +
[docs] def __init__(self, caller, *args, session=None, **kwargs): + """ + Store some extra properties on the EvMore class + + """ + self.show_non_use = kwargs.pop("show_non_use", False) + self.show_non_edit = kwargs.pop("show_non_edit", False) + super().__init__(caller, *args, session=session, **kwargs)
+ +
[docs] def init_pages(self, inp): + """ + This will be initialized with a tuple (mod_prototype_list, paginated_db_query) + and we must handle these separately since they cannot be paginated in the same + way. We will build the prototypes so that the db-prototypes come first (they + are likely the most volatile), followed by the mod-prototypes. + + """ + dbprot_query, modprot_list = inp + # set the number of entries per page to half the reported height of the screen + # to account for long descs etc + dbprot_paged = Paginator(dbprot_query, max(1, int(self.height / 2))) + + # we separate the different types of data, so we track how many pages there are + # of each. + n_mod = len(modprot_list) + self._npages_mod = n_mod // self.height + (0 if n_mod % self.height == 0 else 1) + self._db_count = dbprot_paged.count if dbprot_paged else 0 + self._npages_db = dbprot_paged.num_pages if self._db_count > 0 else 0 + # total number of pages + self._npages = self._npages_mod + self._npages_db + self._data = (dbprot_paged, modprot_list) + self._paginator = self.prototype_paginator
+ +
[docs] def prototype_paginator(self, pageno): + """ + The listing is separated in db/mod prototypes, so we need to figure out which + one to pick based on the page number. Also, pageno starts from 0. + + """ + dbprot_pages, modprot_list = self._data + + if self._db_count and pageno < self._npages_db: + return dbprot_pages.page(pageno + 1) + else: + # get the correct slice, adjusted for the db-prototypes + pageno = max(0, pageno - self._npages_db) + return modprot_list[pageno * self.height : pageno * self.height + self.height]
+ +
[docs] def page_formatter(self, page): + """ + Input is a queryset page from django.Paginator + + """ + caller = self._caller + + # get use-permissions of readonly attributes (edit is always False) + table = EvTable( + "|wKey|n", + "|wSpawn/Edit|n", + "|wTags|n", + "|wDesc|n", + border="tablecols", + crop=True, + width=self.width, + ) + + for prototype in page: + lock_use = caller.locks.check_lockstring( + caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True + ) + if not self.show_non_use and not lock_use: + continue + if prototype.get("prototype_key", "") in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get("prototype_locks", ""), access_type="edit", default=True + ) + if not self.show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get("prototype_tags", []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{}".format(ptag[0])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + table.add_row( + prototype.get("prototype_key", "<unset>"), + "{}/{}".format("Y" if lock_use else "N", "Y" if lock_edit else "N"), + ", ".join(list(set(ptags))), + prototype.get("prototype_desc", "<unset>"), + ) + + return str(table)
+ + +
[docs]def list_prototypes( + caller, key=None, tags=None, show_non_use=False, show_non_edit=True, session=None +): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial prototype key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + session (Session, optional): If given, this is used for display formatting. + Returns: + PrototypeEvMore: An EvMore subclass optimized for prototype listings. + None: If no matches were found. In this case the caller has already been notified. + + """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + + dbprot_query, modprot_list = search_prototype(key, tags, return_iterators=True) + + if not dbprot_query and not modprot_list: + caller.msg(_("No prototypes found."), session=session) + return None + + # get specific prototype (one value or exception) + return PrototypeEvMore( + caller, + (dbprot_query, modprot_list), + session=session, + show_non_use=show_non_use, + show_non_edit=show_non_edit, + )
+ + +
[docs]def validate_prototype( + prototype, protkey=None, protparents=None, is_prototype_base=True, strict=True, _flags=None +): + """ + Run validation on a prototype, checking for inifinite regress. + + Args: + prototype (dict): Prototype to validate. + protkey (str, optional): The name of the prototype definition. If not given, the prototype + dict needs to have the `prototype_key` field set. + protparents (dict, optional): Additional prototype-parents, supposedly provided specifically + for this prototype. If given, matching parents will first be taken from this + dict rather than from the global set of prototypes found via settings/database. + is_prototype_base (bool, optional): We are trying to create a new object *based on this + object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent + etc. + strict (bool, optional): If unset, don't require needed keys, only check against infinite + recursion etc. + _flags (dict, optional): Internal work dict that should not be set externally. + Raises: + RuntimeError: If prototype has invalid structure. + RuntimeWarning: If prototype has issues that would make it unsuitable to build an object + with (it may still be useful as a mix-in prototype). + + """ + assert isinstance(prototype, dict) + protparents = {} if protparents is None else protparents + + if _flags is None: + _flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []} + + protkey = protkey and protkey.lower() or prototype.get("prototype_key", None) + + if strict and not bool(protkey): + _flags["errors"].append(_("Prototype lacks a 'prototype_key'.")) + protkey = "[UNSET]" + + typeclass = prototype.get("typeclass") + prototype_parent = prototype.get("prototype_parent", []) + + if strict and not (typeclass or prototype_parent): + if is_prototype_base: + _flags["errors"].append( + _("Prototype {protkey} requires `typeclass` or 'prototype_parent'.").format( + protkey=protkey + ) + ) + else: + _flags["warnings"].append( + _( + "Prototype {protkey} can only be used as a mixin since it lacks " + "'typeclass' or 'prototype_parent' keys." + ).format(protkey=protkey) + ) + + if strict and typeclass: + try: + class_from_module(typeclass) + except ImportError as err: + _flags["errors"].append( + _( + "{err}: Prototype {protkey} is based on typeclass {typeclass}, " + "which could not be imported!" + ).format(err=err, protkey=protkey, typeclass=typeclass) + ) + + if prototype_parent and isinstance(prototype_parent, dict): + # the protparent is already embedded as a dict; + prototype_parent = [prototype_parent] + + # recursively traverse prototype_parent chain + for protstring in make_iter(prototype_parent): + if isinstance(protstring, dict): + # an already embedded prototype_parent + protparent = protstring + protstring = None + else: + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + _flags["errors"].append( + _("Prototype {protkey} tries to parent itself.").format(protkey=protkey) + ) + + # get prototype parent, first try custom set, then search globally + protparent = protparents.get(protstring) + if not protparent: + protparent = search_prototype(key=protstring, require_single=True) + if protparent: + protparent = protparent[0] + else: + _flags["errors"].append( + _( + "Prototype {protkey}'s `prototype_parent` (named '{parent}') was not" + " found." + ).format(protkey=protkey, parent=protstring) + ) + + # check for infinite recursion + if id(prototype) in _flags["visited"]: + _flags["errors"].append( + _("{protkey} has infinite nesting of prototypes.").format( + protkey=protkey or prototype + ) + ) + + if _flags["errors"]: + raise RuntimeError(f"{_ERRSTR}: " + f"\n{_ERRSTR}: ".join(_flags["errors"])) + _flags["visited"].append(id(prototype)) + _flags["depth"] += 1 + + # next step of recursive validation + validate_prototype( + protparent, + protkey=protstring, + protparents=protparents, + is_prototype_base=is_prototype_base, + _flags=_flags, + ) + + _flags["visited"].pop() + _flags["depth"] -= 1 + + if typeclass and not _flags["typeclass"]: + _flags["typeclass"] = typeclass + + # if we get back to the current level without a typeclass it's an error. + if strict and is_prototype_base and _flags["depth"] <= 0 and not _flags["typeclass"]: + _flags["errors"].append( + _( + "Prototype {protkey} has no `typeclass` defined anywhere in its parent\n " + "chain. Add `typeclass`, or a `prototype_parent` pointing to a " + "prototype with a typeclass." + ).format(protkey=protkey) + ) + + if _flags["depth"] <= 0: + if _flags["errors"]: + raise RuntimeError(f"{_ERRSTR}:_" + f"\n{_ERRSTR}: ".join(_flags["errors"])) + if _flags["warnings"]: + raise RuntimeWarning(f"{_WARNSTR}: " + f"\n{_WARNSTR}: ".join(_flags["warnings"])) + + # make sure prototype_locks are set to defaults + prototype_locks = [ + lstring.split(":", 1) + for lstring in prototype.get("prototype_locks", "").split(";") + if ":" in lstring + ] + locktypes = [tup[0].strip() for tup in prototype_locks] + if "spawn" not in locktypes: + prototype_locks.append(("spawn", "all()")) + if "edit" not in locktypes: + prototype_locks.append(("edit", "all()")) + prototype_locks = ";".join(":".join(tup) for tup in prototype_locks) + prototype["prototype_locks"] = prototype_locks
+ + +
[docs]def protfunc_parser( + value, + available_functions=None, + testing=False, + stacktrace=False, + caller=None, + raise_errors=True, + **kwargs, +): + """ + Parse a prototype value string for a protfunc and process it. + + Available protfuncs are specified as callables in one of the modules of + `settings.PROTFUNC_MODULES`, or specified on the command line. + + Args: + value (any): The value to test for a parseable protfunc. Only strings will be parsed for + protfuncs, all other types are returned as-is. + available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. + If not set, use default sources. + stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser. + raise_errors (bool, optional): Raise explicit errors from malformed/not found protfunc + calls. + + Keyword Args: + session (Session): Passed to protfunc. Session of the entity spawning the prototype. + protototype (dict): Passed to protfunc. The dict this protfunc is a part of. + current_key(str): Passed to protfunc. The key in the prototype that will hold this value. + caller (Object or Account): This is necessary for certain protfuncs that perform object + searches and have to check permissions. + any (any): Passed on to the protfunc. + + Returns: + any: A structure to replace the string on the prototype leve. Note + that FunctionParser functions $funcname(*args, **kwargs) can return any + data type to insert into the prototype. + + """ + if not isinstance(value, str): + return value + + result = FUNC_PARSER.parse_to_any(value, raise_errors=raise_errors, caller=caller, **kwargs) + + return result
+ + +# Various prototype utilities + + +
[docs]def format_available_protfuncs(): + """ + Get all protfuncs in a pretty-formatted form. + + Args: + clr (str, optional): What coloration tag to use. + """ + out = [] + for protfunc_name, protfunc in FUNC_PARSER.callables.items(): + out.append( + "- |c${name}|n - |W{docs}".format( + name=protfunc_name, docs=protfunc.__doc__.strip().replace("\n", "") + ) + ) + return justify("\n".join(out), indent=8)
+ + +
[docs]def prototype_to_str(prototype): + """ + Format a prototype to a nice string representation. + + Args: + prototype (dict): The prototype. + """ + + prototype = homogenize_prototype(prototype) + + header = """ +|cprototype-key:|n {prototype_key}, |c-tags:|n {prototype_tags}, |c-locks:|n {prototype_locks}|n +|c-desc|n: {prototype_desc} +|cprototype-parent:|n {prototype_parent} + \n""".format( + prototype_key=prototype.get("prototype_key", "|r[UNSET](required)|n"), + prototype_tags=prototype.get("prototype_tags", "|wNone|n"), + prototype_locks=prototype.get("prototype_locks", "|wNone|n"), + prototype_desc=prototype.get("prototype_desc", "|wNone|n"), + prototype_parent=prototype.get("prototype_parent", "|wNone|n"), + ) + key = aliases = attrs = tags = locks = permissions = location = home = destination = "" + if "key" in prototype: + key = prototype["key"] + key = "|ckey:|n {key}".format(key=key) + if "aliases" in prototype: + aliases = prototype["aliases"] + aliases = "|caliases:|n {aliases}".format(aliases=", ".join(aliases)) + if "attrs" in prototype: + attrs = prototype["attrs"] + out = [] + for attrkey, value, category, locks in attrs: + locks = locks if isinstance(locks, str) else ", ".join(lock for lock in locks if lock) + category = "|ccategory:|n {}".format(category) if category else "" + cat_locks = "" + if category or locks: + cat_locks = " (|ccategory:|n {category}, ".format( + category=category if category else "|wNone|n" + ) + out.append( + "{attrkey}{cat_locks}{locks} |c=|n {value}".format( + attrkey=attrkey, + cat_locks=cat_locks, + locks=" |w(locks:|n {locks})".format(locks=locks) if locks else "", + value=value, + ) + ) + attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out)) + if "tags" in prototype: + tags = prototype["tags"] + out = [] + for tagkey, category, data in tags: + out.append( + "{tagkey} (category: {category}{dat})".format( + tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "" + ) + ) + tags = "|ctags:|n\n {tags}".format(tags=", ".join(out)) + if "locks" in prototype: + locks = prototype["locks"] + locks = "|clocks:|n\n {locks}".format(locks=locks) + if "permissions" in prototype: + permissions = prototype["permissions"] + permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions)) + if "location" in prototype: + location = prototype["location"] + location = "|clocation:|n {location}".format(location=location) + if "home" in prototype: + home = prototype["home"] + home = "|chome:|n {home}".format(home=home) + if "destination" in prototype: + destination = prototype["destination"] + destination = "|cdestination:|n {destination}".format(destination=destination) + + body = "\n".join( + part + for part in (key, aliases, attrs, tags, locks, permissions, location, home, destination) + if part + ) + + return header.lstrip() + body.strip()
+ + +
[docs]def check_permission(prototype_key, action, default=True): + """ + Helper function to check access to actions on given prototype. + + Args: + prototype_key (str): The prototype to affect. + action (str): One of "spawn" or "edit". + default (str): If action is unknown or prototype has no locks + + Returns: + passes (bool): If permission for action is granted or not. + + """ + if action == "edit": + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key) + if mod: + err = _("{protkey} is a read-only prototype (defined as code in {module}).") + else: + err = _("{protkey} is a read-only prototype (passed directly as a dict).") + logger.log_err(err.format(protkey=prototype_key, module=mod)) + return False + + prototype = search_prototype(key=prototype_key, require_single=True) + if prototype: + prototype = prototype[0] + else: + logger.log_err("Prototype {} not found.".format(prototype_key)) + return False + + lockstring = prototype.get("prototype_locks") + + if lockstring: + return check_lockstring(None, lockstring, default=default, access_type=action) + return default
+ + +
[docs]def init_spawn_value( + value, validator=None, caller=None, prototype=None, protfunc_raise_errors=True +): + """ + Analyze the prototype value and produce a value useful at the point of spawning. + + Args: + value (any): This can be: + callable - will be called as callable() + (callable, (args,)) - will be called as callable(*args) + other - will be assigned depending on the variable type + validator (callable, optional): If given, this will be called with the value to + check and guarantee the outcome is of a given type. + caller (Object or Account): This is necessary for certain protfuncs that perform object + searches and have to check permissions. + prototype (dict): Prototype this is to be used for. Necessary for certain protfuncs. + + Returns: + any (any): The (potentially pre-processed value to use for this prototype key) + + """ + validator = validator if validator else lambda o: o + if callable(value): + value = validator(value()) + elif value and isinstance(value, (list, tuple)) and callable(value[0]): + # a structure (callable, (args, )) + args = value[1:] + value = validator(value[0](*make_iter(args))) + else: + value = validator(value) + result = protfunc_parser( + value, caller=caller, prototype=prototype, raise_errors=protfunc_raise_errors + ) + if result != value: + return validator(result) + return result
+ + +
[docs]def value_to_obj_or_any(value): + "Convert value(s) to Object if possible, otherwise keep original value" + stype = type(value) + if is_iter(value): + if stype == dict: + return { + value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.items() + } + else: + return stype([value_to_obj_or_any(val) for val in value]) + obj = dbid_to_obj(value, ObjectDB) + return obj if obj is not None else value
+ + +
[docs]def value_to_obj(value, force=True): + "Always convert value(s) to Object, or None" + stype = type(value) + if is_iter(value): + if stype == dict: + return { + value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.items() + } + else: + return stype([value_to_obj_or_any(val) for val in value]) + return dbid_to_obj(value, ObjectDB)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/prototypes/spawner.html b/docs/latest/_modules/evennia/prototypes/spawner.html new file mode 100644 index 0000000000..a2c39d4c78 --- /dev/null +++ b/docs/latest/_modules/evennia/prototypes/spawner.html @@ -0,0 +1,1176 @@ + + + + + + + + evennia.prototypes.spawner — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.prototypes.spawner

+"""
+Spawner
+
+The spawner takes input files containing object definitions in
+dictionary forms. These use a prototype architecture to define
+unique objects without having to make a Typeclass for each.
+
+There  main function is `spawn(*prototype)`, where the `prototype`
+is a dictionary like this:
+
+```python
+from evennia.prototypes import prototypes, spawner
+
+prot = {
+ "prototype_key": "goblin",
+ "typeclass": "types.objects.Monster",
+ "key": "goblin grunt",
+ "health": lambda: randint(20,30),
+ "resists": ["cold", "poison"],
+ "attacks": ["fists"],
+ "weaknesses": ["fire", "light"]
+ "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)
+
+```
+
+Possible keywords are:
+    prototype_key (str):  name of this prototype. This is used when storing prototypes and should
+        be unique. This should always be defined but for prototypes defined in modules, the
+        variable holding the prototype dict will become the prototype_key if it's not explicitly
+        given.
+    prototype_desc (str, optional): describes prototype in listings
+    prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes
+        supported are 'edit' and 'use'.
+    prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype
+        in listings
+    prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent
+        prototype, or a list of parents, for multiple left-to-right inheritance.
+    prototype: Deprecated. Same meaning as 'parent'.
+
+    typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use
+        `settings.BASE_OBJECT_TYPECLASS`
+    key (str or callable, optional): the name of the spawned object. If not given this will set to a
+        random hash
+    location (obj, str or callable, optional): location of the object - a valid object or #dbref
+    home (obj, str or callable, optional): valid object or #dbref
+    destination (obj, str or callable, optional): only valid for exits (object or #dbref)
+
+    permissions (str, list or callable, optional): which permissions for spawned object to have
+    locks (str or callable, optional): lock-string for the spawned object
+    aliases (str, list or callable, optional): Aliases for the spawned object
+    exec (str or callable, optional): this is a string of python code to execute or a list of such
+        codes.  This can be used e.g. to trigger custom handlers on the object. The execution
+        namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit
+        this functionality to Developer/superusers. Usually it's better to use callables or
+        prototypefuncs instead of this.
+    tags (str, tuple, list or callable, optional): string or list of strings or tuples
+        `(tagstr, category)`. Plain strings will be result in tags with no category (default tags).
+    attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This
+        form allows more complex Attributes to be set. Tuples at least specify `(key, value)`
+        but can also specify up to `(key, value, category, lockstring)`. If you want to specify a
+        lockstring but not a category, set the category to `None`.
+    ndb_<name> (any): value of a nattribute (ndb_ is stripped) - this is of limited use.
+    other (any): any other name is interpreted as the key of an Attribute with
+        its value. Such Attributes have no categories.
+
+Each value can also be a callable that takes no arguments. It should
+return the value to enter into the field and will be called every time
+the prototype is used to spawn an object. Note, if you want to store
+a callable in an Attribute, embed it in a tuple to the `args` keyword.
+
+By specifying the "prototype_parent" key, the prototype becomes a child of
+the given prototype, inheritng all prototype slots it does not explicitly
+define itself, while overloading those that it does specify.
+
+```python
+import random
+
+
+{
+ "prototype_key": "goblin_wizard",
+ "prototype_parent": "GOBLIN",
+ "key": "goblin wizard",
+ "spells": ["fire ball", "lighting bolt"]
+ }
+
+GOBLIN_ARCHER = {
+ "prototype_parent": "GOBLIN",
+ "key": "goblin archer",
+ "attack_skill": (random, (5, 10))"
+ "attacks": ["short bow"]
+}
+```
+
+One can also have multiple prototypes. These are inherited from the
+left, with the ones further to the right taking precedence.
+
+```python
+ARCHWIZARD = {
+ "attack": ["archwizard staff", "eye of doom"]
+
+GOBLIN_ARCHWIZARD = {
+ "key" : "goblin archwizard"
+ "prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD"),
+}
+```
+
+The *goblin archwizard* will have some different attacks, but will
+otherwise have the same spells as a *goblin wizard* who in turn shares
+many traits with a normal *goblin*.
+
+
+Storage mechanism:
+
+This sets up a central storage for prototypes. The idea is to make these
+available in a repository for buildiers to use. Each prototype is stored
+in a Script so that it can be tagged for quick sorting/finding and locked for limiting
+access.
+
+This system also takes into consideration prototypes defined and stored in modules.
+Such prototypes are considered 'read-only' to the system and can only be modified
+in code. To replace a default prototype, add the same-name prototype in a
+custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default
+prototype, override its name with an empty dict.
+
+
+"""
+
+
+import copy
+import hashlib
+import time
+
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+import evennia
+from evennia.objects.models import ObjectDB
+from evennia.prototypes import prototypes as protlib
+from evennia.prototypes.prototypes import (
+    PROTOTYPE_TAG_CATEGORY,
+    init_spawn_value,
+    search_prototype,
+    value_to_obj,
+    value_to_obj_or_any,
+)
+from evennia.utils import logger
+from evennia.utils.utils import class_from_module, is_iter, make_iter
+
+_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination")
+_PROTOTYPE_META_NAMES = (
+    "prototype_key",
+    "prototype_desc",
+    "prototype_tags",
+    "prototype_locks",
+    "prototype_parent",
+)
+_PROTOTYPE_ROOT_NAMES = (
+    "typeclass",
+    "key",
+    "aliases",
+    "attrs",
+    "tags",
+    "locks",
+    "permissions",
+    "location",
+    "home",
+    "destination",
+)
+_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES
+
+
+
[docs]class Unset: + """ + Helper class representing a non-set diff element. + + """ + + def __bool__(self): + return False + + def __str__(self): + return "<Unset>"
+ + +# Helper + + +def _get_prototype(inprot, protparents=None, uninherited=None, _workprot=None): + """ + Recursively traverse a prototype dictionary, including multiple + inheritance. Use validate_prototype before this, we don't check + for infinite recursion here. + + Args: + inprot (dict): Prototype dict (the individual prototype, with no inheritance included). + protparents (dict): Custom protparents, supposedly provided specifically for this `inprot`. + If given, any parents will first be looked up in this dict, and then by searching + the global prototype store given by settings/db. + uninherited (dict): Parts of prototype to not inherit. + _workprot (dict, optional): Work dict for the recursive algorithm. + + Returns: + merged (dict): A prototype where parent's have been merged as needed (the + `prototype_parent` key is removed). + + """ + + def _inherit_tags(old_tags, new_tags): + old = {(tup[0], tup[1]): tup for tup in old_tags} + new = {(tup[0], tup[1]): tup for tup in new_tags} + old.update(new) + return list(old.values()) + + def _inherit_attrs(old_attrs, new_attrs): + old = {(tup[0], tup[2]): tup for tup in old_attrs} + new = {(tup[0], tup[2]): tup for tup in new_attrs} + old.update(new) + return list(old.values()) + + protparents = {} if protparents is None else protparents + + _workprot = {} if _workprot is None else _workprot + if "prototype_parent" in inprot: + # move backwards through the inheritance + + prototype_parents = inprot["prototype_parent"] + if isinstance(prototype_parents, dict): + # protparent already embedded as-is + prototype_parents = [prototype_parents] + + for prototype in make_iter(prototype_parents): + if isinstance(prototype, dict): + # protparent already embedded as-is + parent_prototype = prototype + else: + # protparent given by-name, first search provided parents, then global store + parent_prototype = protparents.get(prototype.lower()) + if not parent_prototype: + parent_prototype = search_prototype(key=prototype.lower()) or {} + if parent_prototype: + parent_prototype = parent_prototype[0] + + # Build the prot dictionary in reverse order, overloading + new_prot = _get_prototype(parent_prototype, protparents, _workprot=_workprot) + + # attrs, tags have internal structure that should be inherited separately + new_prot["attrs"] = _inherit_attrs( + _workprot.get("attrs", {}), new_prot.get("attrs", []) + ) + new_prot["tags"] = _inherit_tags(_workprot.get("tags", []), new_prot.get("tags", [])) + + _workprot.update(new_prot) + # the inprot represents a higher level (a child prot), which should override parents + + inprot["attrs"] = _inherit_attrs(_workprot.get("attrs", []), inprot.get("attrs", [])) + inprot["tags"] = _inherit_tags(_workprot.get("tags", []), inprot.get("tags", [])) + _workprot.update(inprot) + if uninherited: + # put back the parts that should not be inherited + _workprot.update(uninherited) + _workprot.pop("prototype_parent", None) # we don't need this for spawning + return _workprot + + +
[docs]def flatten_prototype(prototype, validate=False, no_db=False): + """ + Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been + merged into a final prototype. + + Args: + prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed. + validate (bool, optional): Validate for valid keys etc. + no_db (bool, optional): Don't search db-based prototypes. This can speed up + searching dramatically since module-based prototypes are static. + + Returns: + flattened (dict): The final, flattened prototype. + + """ + + if prototype: + prototype = protlib.homogenize_prototype(prototype) + protlib.validate_prototype(prototype, is_prototype_base=validate, strict=validate) + return _get_prototype( + prototype, uninherited={"prototype_key": prototype.get("prototype_key")} + ) + return {}
+ + +# obj-related prototype functions + + +
[docs]def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=PROTOTYPE_TAG_CATEGORY, return_list=True) + if prot: + prot = protlib.search_prototype(prot[0]) + + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot["prototype_key"] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7] + ) + prot["prototype_desc"] = "Built from {}".format(str(obj)) + prot["prototype_locks"] = "spawn:all();edit:all()" + prot["prototype_tags"] = [] + else: + prot = prot[0] + + prot["key"] = obj.db_key or hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:6] + prot["typeclass"] = obj.db_typeclass_path + + location = obj.db_location + if location: + prot["location"] = location.dbref + home = obj.db_home + if home: + prot["home"] = home.dbref + destination = obj.db_destination + if destination: + prot["destination"] = destination.dbref + locks = obj.locks.all() + if locks: + prot["locks"] = ";".join(locks) + perms = obj.permissions.get(return_list=True) + if perms: + prot["permissions"] = make_iter(perms) + aliases = obj.aliases.get(return_list=True) + if aliases: + prot["aliases"] = aliases + tags = sorted( + [(tag.db_key, tag.db_category, tag.db_data) for tag in obj.tags.all(return_objs=True)], + key=lambda tup: (str(tup[0]), tup[1] or "", tup[2] or ""), + ) + if tags: + prot["tags"] = tags + attrs = sorted( + [ + (attr.key, attr.value, attr.category, ";".join(attr.locks.all())) + for attr in obj.attributes.all() + ], + key=lambda tup: (str(tup[0]), tup[1] or "", tup[2] or "", tup[3]), + ) + if attrs: + prot["attrs"] = attrs + + return prot
+ + +
[docs]def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implicit_keep=False): + """ + A 'detailed' diff specifies differences down to individual sub-sections + of the prototype, like individual attributes, permissions etc. It is used + by the menu to allow a user to customize what should be kept. + + Args: + prototype1 (dict): Original prototype. + prototype2 (dict): Comparison prototype. + maxdepth (int, optional): The maximum depth into the diff we go before treating the elements + of iterables as individual entities to compare. This is important since a single + attr/tag (for example) are represented by a tuple. + homogenize (bool, optional): Auto-homogenize both prototypes for the best comparison. + This is most useful for displaying. + implicit_keep (bool, optional): If set, the resulting diff will assume KEEP unless the new + prototype explicitly change them. That is, if a key exists in `prototype1` and + not in `prototype2`, it will not be REMOVEd but set to KEEP instead. This is + particularly useful for auto-generated prototypes when updating objects. + + Returns: + diff (dict): A structure detailing how to convert prototype1 to prototype2. All + nested structures are dicts with keys matching either the prototype's matching + key or the first element in the tuple describing the prototype value (so for + a tag tuple `(tagname, category)` the second-level key in the diff would be tagname). + The the bottom level of the diff consist of tuples `(old, new, instruction)`, where + instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP". + + """ + _unset = Unset() + + def _recursive_diff(old, new, depth=0): + old_type = type(old) + new_type = type(new) + + if old_type == new_type and not (old or new): + # both old and new are unset, like [] or None + return (None, None, "KEEP") + if old_type != new_type: + if old and not new: + if depth < maxdepth and old_type == dict: + return {key: (part, None, "REMOVE") for key, part in old.items()} + elif depth < maxdepth and is_iter(old): + return { + part[0] if is_iter(part) else part: (part, None, "REMOVE") for part in old + } + if isinstance(new, Unset) and implicit_keep: + # the new does not define any change, use implicit-keep + return (old, None, "KEEP") + return (old, new, "REMOVE") + elif not old and new: + if depth < maxdepth and new_type == dict: + return {key: (None, part, "ADD") for key, part in new.items()} + elif depth < maxdepth and is_iter(new): + return {part[0] if is_iter(part) else part: (None, part, "ADD") for part in new} + return (old, new, "ADD") + else: + # this condition should not occur in a standard diff + return (old, new, "UPDATE") + elif depth < maxdepth and new_type == dict: + all_keys = set(list(old.keys()) + list(new.keys())) + return { + key: _recursive_diff(old.get(key, _unset), new.get(key, _unset), depth=depth + 1) + for key in all_keys + } + elif depth < maxdepth and is_iter(new): + old_map = {part[0] if is_iter(part) else part: part for part in old} + new_map = {part[0] if is_iter(part) else part: part for part in new} + all_keys = set(list(old_map.keys()) + list(new_map.keys())) + return { + key: _recursive_diff( + old_map.get(key, _unset), new_map.get(key, _unset), depth=depth + 1 + ) + for key in all_keys + } + elif old != new: + return (old, new, "UPDATE") + else: + return (old, new, "KEEP") + + prot1 = protlib.homogenize_prototype(prototype1) if homogenize else prototype1 + prot2 = protlib.homogenize_prototype(prototype2) if homogenize else prototype2 + + diff = _recursive_diff(prot1, prot2) + + return diff
+ + +
[docs]def flatten_diff(diff): + """ + For spawning, a 'detailed' diff is not necessary, rather we just want instructions on how to + handle each root key. + + Args: + diff (dict): Diff produced by `prototype_diff` and + possibly modified by the user. Note that also a pre-flattened diff will come out + unchanged by this function. + + Returns: + flattened_diff (dict): A flat structure detailing how to operate on each + root component of the prototype. + + Notes: + The flattened diff has the following possible instructions: + UPDATE, REPLACE, REMOVE + Many of the detailed diff's values can hold nested structures with their own + individual instructions. A detailed diff can have the following instructions: + REMOVE, ADD, UPDATE, KEEP + Here's how they are translated: + - All REMOVE -> REMOVE + - All ADD|UPDATE -> UPDATE + - All KEEP -> KEEP + - Mix KEEP, UPDATE, ADD -> UPDATE + - Mix REMOVE, KEEP, UPDATE, ADD -> REPLACE + """ + + valid_instructions = ("KEEP", "REMOVE", "ADD", "UPDATE") + + def _get_all_nested_diff_instructions(diffpart): + "Started for each root key, returns all instructions nested under it" + out = [] + typ = type(diffpart) + if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions: + out = [diffpart[2]] + elif typ == dict: + # all other are dicts + for val in diffpart.values(): + out.extend(_get_all_nested_diff_instructions(val)) + else: + raise RuntimeError( + _( + "Diff contains non-dicts that are not on the " + "form (old, new, action_to_take): {diffpart}" + ).format(diffpart) + ) + return out + + flat_diff = {} + + # flatten diff based on rules + for rootkey, diffpart in diff.items(): + insts = _get_all_nested_diff_instructions(diffpart) + if all(inst == "KEEP" for inst in insts): + rootinst = "KEEP" + elif all(inst in ("ADD", "UPDATE") for inst in insts): + rootinst = "UPDATE" + elif all(inst == "REMOVE" for inst in insts): + rootinst = "REMOVE" + elif "REMOVE" in insts: + rootinst = "REPLACE" + else: + rootinst = "UPDATE" + + flat_diff[rootkey] = rootinst + + return flat_diff
+ + +
[docs]def prototype_diff_from_object(prototype, obj, implicit_keep=True): + """ + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. + + Args: + prototype (dict): New prototype. + obj (Object): Object to compare prototype against. + + Returns: + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + obj_prototype (dict): The prototype calculated for the given object. The diff is how to + convert this prototype into the new prototype. + implicit_keep (bool, optional): This is usually what one wants for object updating. When + set, this means the prototype diff will assume KEEP on differences + between the object-generated prototype and that which is not explicitly set in the + new prototype. This means e.g. that even though the object has a location, and the + prototype does not specify the location, it will not be unset. + + Notes: + The `diff` is on the following form: + + {"key": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), + "attrs": {"attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), + "attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), ...}, + "aliases": {"aliasname": (old, new, "KEEP...", ...}, + ... } + + """ + obj_prototype = prototype_from_object(obj) + diff = prototype_diff( + obj_prototype, protlib.homogenize_prototype(prototype), implicit_keep=implicit_keep + ) + return diff, obj_prototype
+ + +
[docs]def format_diff(diff, minimal=True): + """ + Reformat a diff for presentation. This is a shortened version + of the olc _format_diff_text_and_options without the options. + + Args: + diff (dict): A diff as produced by `prototype_diff`. + minimal (bool, optional): Only show changes (remove KEEPs) + + Returns: + texts (str): The formatted text. + + """ + + valid_instructions = ("KEEP", "REMOVE", "ADD", "UPDATE") + + def _visualize(obj, rootname, get_name=False): + if is_iter(obj): + if not obj: + return str(obj) + if get_name: + return obj[0] if obj[0] else "<unset>" + if rootname == "attrs": + return "{} |w=|n {} |w(category:|n |n{}|w, locks:|n {}|w)|n".format(*obj) + elif rootname == "tags": + return "{} |w(category:|n {}|w)|n".format(obj[0], obj[1]) + return "{}".format(obj) + + def _parse_diffpart(diffpart, rootname): + typ = type(diffpart) + texts = [] + if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions: + old, new, instruction = diffpart + if instruction == "KEEP": + if not minimal: + texts.append(" |gKEEP|n: {old}".format(old=_visualize(old, rootname))) + elif instruction == "ADD": + texts.append(" |yADD|n: {new}".format(new=_visualize(new, rootname))) + elif instruction == "REMOVE" and not new: + texts.append(" |rREMOVE|n: {old}".format(old=_visualize(old, rootname))) + else: + vold = _visualize(old, rootname) + vnew = _visualize(new, rootname) + vsep = "" if len(vold) < 78 else "\n" + vinst = " |rREMOVE|n" if instruction == "REMOVE" else "|y{}|n".format(instruction) + varrow = "|r->|n" if instruction == "REMOVE" else "|y->|n" + texts.append( + " {inst}|W:|n {old} |W{varrow}|n{sep} {new}".format( + inst=vinst, old=vold, varrow=varrow, sep=vsep, new=vnew + ) + ) + else: + for key in sorted(list(diffpart.keys())): + subdiffpart = diffpart[key] + text = _parse_diffpart(subdiffpart, rootname) + texts.extend(text) + return texts + + texts = [] + + for root_key in sorted(diff): + diffpart = diff[root_key] + text = _parse_diffpart(diffpart, root_key) + if text or not minimal: + heading = "- |w{}:|n\n".format(root_key) + if text: + text = [heading + text[0]] + text[1:] + else: + text = [heading] + + texts.extend(text) + + return "\n ".join(line for line in texts if line)
+ + +
[docs]def batch_update_objects_with_prototype( + prototype, diff=None, objects=None, exact=False, caller=None, protfunc_raise_errors=True +): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + diff (dict, optional): This a diff structure that describes how to update the protototype. + If not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + exact (bool, optional): By default (`False`), keys not explicitly in the prototype will + not be applied to the object, but will be retained as-is. This is usually what is + expected - for example, one usually do not want to remove the object's location even + if it's not set in the prototype. With `exact=True`, all un-specified properties of the + objects will be removed if they exist. This will lead to a more accurate 1:1 correlation + between the object and the prototype but is usually impractical. + caller (Object or Account, optional): This may be used by protfuncs to do permission checks. + protfunc_raise_errors (bool): Have protfuncs raise explicit errors if malformed/not found. + This is highly recommended. + Returns: + changed (int): The number of objects that had changes applied to them. + + """ + prototype = protlib.homogenize_prototype(prototype) + + if isinstance(prototype, str): + new_prototype = protlib.search_prototype(prototype) + if new_prototype: + new_prototype = new_prototype[0] + else: + new_prototype = prototype + + prototype_key = new_prototype["prototype_key"] + + if not objects: + objects = ObjectDB.objects.get_by_tag(prototype_key, category=PROTOTYPE_TAG_CATEGORY) + + if not objects: + return 0 + + if not diff: + diff, _ = prototype_diff_from_object(new_prototype, objects[0]) + + # make sure the diff is flattened + diff = flatten_diff(diff) + + changed = 0 + for obj in objects: + do_save = False + + old_prot_key = obj.tags.get(category=PROTOTYPE_TAG_CATEGORY, return_list=True) + old_prot_key = old_prot_key[0] if old_prot_key else None + + try: + for key, directive in diff.items(): + if key not in new_prototype and not exact: + # we don't update the object if the prototype does not actually + # contain the key (the diff will report REMOVE but we ignore it + # since exact=False) + continue + + if directive in ("UPDATE", "REPLACE"): + if key in _PROTOTYPE_META_NAMES: + # prototype meta keys are not stored on-object + continue + + val = new_prototype[key] + do_save = True + + def _init(val, typ): + return init_spawn_value( + val, + typ, + caller=caller, + prototype=new_prototype, + protfunc_raise_errors=protfunc_raise_errors, + ) + + if key == "key": + obj.db_key = _init(val, str) + elif key == "typeclass": + obj.db_typeclass_path = _init(val, str) + elif key == "location": + obj.db_location = _init(val, value_to_obj) + elif key == "home": + obj.db_home = _init(val, value_to_obj) + elif key == "destination": + obj.db_destination = _init(val, value_to_obj) + elif key == "locks": + if directive == "REPLACE": + obj.locks.clear() + obj.locks.add(_init(val, str)) + elif key == "permissions": + if directive == "REPLACE": + obj.permissions.clear() + obj.permissions.batch_add(*(_init(perm, str) for perm in val)) + elif key == "aliases": + if directive == "REPLACE": + obj.aliases.clear() + obj.aliases.batch_add(*(_init(alias, str) for alias in val)) + elif key == "tags": + if directive == "REPLACE": + obj.tags.clear() + obj.tags.batch_add( + *( + (_init(ttag, str), tcategory, tdata) + for ttag, tcategory, tdata in val + ) + ) + elif key == "attrs": + if directive == "REPLACE": + obj.attributes.clear() + obj.attributes.batch_add( + *( + ( + _init(akey, str), + _init(aval, value_to_obj), + acategory, + alocks, + ) + for akey, aval, acategory, alocks in val + ) + ) + elif key == "exec": + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, _init(val, value_to_obj)) + elif directive == "REMOVE": + do_save = True + if key == "key": + obj.db_key = "" + elif key == "typeclass": + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == "location": + obj.db_location = None + elif key == "home": + obj.db_home = None + elif key == "destination": + obj.db_destination = None + elif key == "locks": + obj.locks.clear() + elif key == "permissions": + obj.permissions.clear() + elif key == "aliases": + obj.aliases.clear() + elif key == "tags": + obj.tags.clear() + elif key == "attrs": + obj.attributes.clear() + elif key == "exec": + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + except Exception: + logger.log_trace(f"Failed to apply prototype '{prototype_key}' to {obj}.") + finally: + # we must always make sure to re-add the prototype tag + obj.tags.clear(category=PROTOTYPE_TAG_CATEGORY) + obj.tags.add(prototype_key, category=PROTOTYPE_TAG_CATEGORY) + + if do_save: + changed += 1 + obj.save() + + return changed
+ + +
[docs]def batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters + within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + objs = [] + for objparam in objparams: + obj = ObjectDB(**objparam[0]) + + # setup + obj._createdict = { + "permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6]), + } + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs
+ + +# Spawner mechanism + + +
[docs]def spawn(*prototypes, caller=None, **kwargs): + """ + Spawn a number of prototyped objects. + + Args: + prototypes (str or dict): Each argument should either be a + prototype_key (will be used to find the prototype) or a full prototype + dictionary. These will be batched-spawned as one object each. + Keyword Args: + caller (Object or Account, optional): This may be used by protfuncs to do access checks. + prototype_modules (str or list): A python-path to a prototype + module, or a list of such paths. These will be used to build + the global protparents dictionary accessible by the input + prototypes. If not given, it will instead look for modules + defined by settings.PROTOTYPE_MODULES. + prototype_parents (dict): A dictionary holding a custom + prototype-parent dictionary. Will overload same-named + prototypes from prototype_modules. + only_validate (bool): Only run validation of prototype/parents + (no object creation) and return the create-kwargs. + protfunc_raise_errors (bool): Raise explicit exceptions on a malformed/not-found + protfunc. Defaults to True. + + Returns: + object (Object, dict or list): Spawned object(s). If `only_validate` is given, return + a list of the creation kwargs to build the object(s) without actually creating it. + + """ + # search string (=prototype_key) from input + prototypes = [ + protlib.search_prototype(prot, require_single=True)[0] if isinstance(prot, str) else prot + for prot in prototypes + ] + + if not kwargs.get("only_validate"): + # homogenization to be more lenient about prototype format when entering the prototype + # manually + prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes] + + # overload module's protparents with specifically given protparents + # we allow prototype_key to be the key of the protparent dict, to allow for module-level + # prototype imports. We need to insert prototype_key in this case + custom_protparents = {} + for key, protparent in kwargs.get("prototype_parents", {}).items(): + key = str(key).lower() + protparent["prototype_key"] = str(protparent.get("prototype_key", key)).lower() + custom_protparents[key] = protlib.homogenize_prototype(protparent) + + objsparams = [] + for prototype in prototypes: + # run validation and homogenization of provided prototypes + protlib.validate_prototype( + prototype, None, protparents=custom_protparents, is_prototype_base=True + ) + prot = _get_prototype( + prototype, + protparents=custom_protparents, + uninherited={"prototype_key": prototype.get("prototype_key")}, + ) + if not prot: + continue + + # extract the keyword args we need to create the object itself. If we get a callable, + # call that to get the value (don't catch errors) + create_kwargs = {} + init_spawn_kwargs = dict( + caller=caller, + prototype=prototype, + protfunc_raise_errors=kwargs.get("protfunc_raise_errors", True), + ) + + # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) + # chance this is not unique but it should usually not be a problem. + val = prot.pop( + "key", + "Spawned-{}".format(hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:6]), + ) + create_kwargs["db_key"] = init_spawn_value(val, str, **init_spawn_kwargs) + + val = prot.pop("location", None) + create_kwargs["db_location"] = init_spawn_value(val, value_to_obj, **init_spawn_kwargs) + + val = prot.pop("home", None) + if val: + create_kwargs["db_home"] = init_spawn_value(val, value_to_obj, **init_spawn_kwargs) + else: + try: + create_kwargs["db_home"] = init_spawn_value( + settings.DEFAULT_HOME, value_to_obj, **init_spawn_kwargs + ) + except ObjectDB.DoesNotExist: + # settings.DEFAULT_HOME not existing is common for unittests + pass + + val = prot.pop("destination", None) + create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj, **init_spawn_kwargs) + + # we need the 'true' path to the typeclass (not its alias), so we make sure to load the typeclass + # and use its path directly + val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + typeclass = class_from_module( + init_spawn_value(val, str, **init_spawn_kwargs), settings.TYPECLASS_PATHS + ) + create_kwargs["db_typeclass_path"] = f"{typeclass.__module__}.{typeclass.__name__}" + + # extract calls to handlers + val = prot.pop("permissions", []) + permission_string = init_spawn_value(val, make_iter, **init_spawn_kwargs) + val = prot.pop("locks", "") + lock_string = init_spawn_value(val, str, **init_spawn_kwargs) + val = prot.pop("aliases", []) + alias_string = init_spawn_value(val, make_iter, **init_spawn_kwargs) + + val = prot.pop("tags", []) + tags = [] + for tag, category, *data in val: + tags.append( + ( + init_spawn_value(tag, str, **init_spawn_kwargs), + category, + data[0] if data else None, + ) + ) + + prototype_key = prototype.get("prototype_key", None) + if prototype_key: + # we make sure to add a tag identifying which prototype created this object + tags.append((prototype_key, PROTOTYPE_TAG_CATEGORY)) + + val = prot.pop("exec", "") + execs = init_spawn_value(val, make_iter, **init_spawn_kwargs) + + # extract ndb assignments + nattributes = dict( + ( + key.split("_", 1)[1], + init_spawn_value(val, value_to_obj, **init_spawn_kwargs), + ) + for key, val in prot.items() + if key.startswith("ndb_") + ) + + # the rest are attribute tuples (attrname, value, category, locks) + val = make_iter(prot.pop("attrs", [])) + attributes = [] + for attrname, value, *rest in val: + attributes.append( + ( + attrname, + init_spawn_value(value, **init_spawn_kwargs), + rest[0] if rest else None, + rest[1] if len(rest) > 1 else None, + ) + ) + + simple_attributes = [] + for key, value in ( + (key, value) for key, value in prot.items() if not (key.startswith("ndb_")) + ): + # we don't support categories, nor locks for simple attributes + if key in _PROTOTYPE_META_NAMES: + continue + else: + simple_attributes.append( + ( + key, + init_spawn_value(value, value_to_obj_or_any, **init_spawn_kwargs), + None, + None, + ) + ) + + attributes = attributes + simple_attributes + attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] + + # pack for call into _batch_create_object + objsparams.append( + ( + create_kwargs, + permission_string, + lock_string, + alias_string, + nattributes, + attributes, + tags, + execs, + ) + ) + + if kwargs.get("only_validate"): + return objsparams + return batch_create_object(*objsparams)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/scripts/manager.html b/docs/latest/_modules/evennia/scripts/manager.html new file mode 100644 index 0000000000..77df52dbba --- /dev/null +++ b/docs/latest/_modules/evennia/scripts/manager.html @@ -0,0 +1,433 @@ + + + + + + + + evennia.scripts.manager — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.scripts.manager

+"""
+The custom manager for Scripts.
+"""
+
+from django.conf import settings
+from django.db.models import Q
+
+from evennia.server import signals
+from evennia.typeclasses.managers import TypeclassManager, TypedObjectManager
+from evennia.utils.utils import class_from_module, dbid_to_obj, make_iter
+
+__all__ = ("ScriptManager", "ScriptDBManager")
+_GA = object.__getattribute__
+
+_ObjectDB = None
+_AccountDB = None
+
+
+VALIDATE_ITERATION = 0
+
+
+
[docs]class ScriptDBManager(TypedObjectManager): + """ + This Scriptmanager implements methods for searching + and manipulating Scripts directly from the database. + + Evennia-specific search methods (will return Typeclasses or + lists of Typeclasses, whereas Django-general methods will return + Querysets or database objects). + + dbref (converter) + dbref_search + get_dbref_range + object_totals + typeclass_search + get_all_scripts_on_obj + get_all_scripts + delete_script + remove_non_persistent + validate + script_search (equivalent to evennia.search_script) + copy_script + + """ + +
[docs] def get_all_scripts_on_obj(self, obj, key=None): + """ + Find all Scripts related to a particular object. + + Args: + obj (Object): Object whose Scripts we are looking for. + key (str, optional): Script identifier - can be given as a + dbref or name string. If given, only scripts matching the + key on the object will be returned. + Returns: + matches (list): Matching scripts. + + """ + if not obj: + return [] + account = _GA(_GA(obj, "__dbclass__"), "__name__") == "AccountDB" + if key: + dbref = self.dbref(key) + if dbref or dbref == 0: + if account: + return self.filter(db_account=obj, id=dbref) + else: + return self.filter(db_obj=obj, id=dbref) + elif account: + return self.filter(db_account=obj, db_key=key) + else: + return self.filter(db_obj=obj, db_key=key) + elif account: + return self.filter(db_account=obj) + else: + return self.filter(db_obj=obj)
+ +
[docs] def get_all_scripts(self, key=None): + """ + Get all scripts in the database. + + Args: + key (str or int, optional): Restrict result to only those + with matching key or dbref. + + Returns: + scripts (list): All scripts found, or those matching `key`. + + """ + if key: + dbref = self.dbref(key) + if dbref: + return self.filter(id=dbref) + return self.filter(db_key__iexact=key.strip()) + return self.all()
+ +
[docs] def delete_script(self, dbref): + """ + This stops and deletes a specific script directly from the + script database. + + Args: + dbref (int): Database unique id. + + Notes: + This might be needed for global scripts not tied to a + specific game object + + """ + scripts = self.get_id(dbref) + for script in make_iter(scripts): + script.stop() + script.delete()
+ +
[docs] def update_scripts_after_server_start(self): + """ + Update/sync/restart/delete scripts after server shutdown/restart. + + """ + for script in self.filter(db_is_active=True, db_persistent=False): + script._stop_task() + + for script in self.filter(db_is_active=True): + script._unpause_task(auto_unpause=True) + script.at_server_start() + + for script in self.filter(db_is_active=False): + script.at_server_start()
+ +
[docs] def search_script(self, ostring, obj=None, only_timed=False, typeclass=None): + """ + Search for a particular script. + + Args: + ostring (str): Search criterion - a script dbef or key. + obj (Object, optional): Limit search to scripts defined on + this object + only_timed (bool): Limit search only to scripts that run + on a timer. + typeclass (class or str): Typeclass or path to typeclass. + + Returns: + Queryset: An iterable with 0, 1 or more results. + + """ + + ostring = ostring.strip() + + dbref = self.dbref(ostring) + if dbref: + # this is a dbref, try to find the script directly + dbref_match = self.dbref_search(dbref) + if dbref_match: + dmatch = dbref_match[0] + if not (obj and obj != dmatch.obj) or (only_timed and dmatch.interval): + return dbref_match + + if typeclass: + if callable(typeclass): + typeclass = "%s.%s" % (typeclass.__module__, typeclass.__name__) + else: + typeclass = "%s" % typeclass + + # not a dbref; normal search + obj_restriction = obj and Q(db_obj=obj) or Q() + timed_restriction = only_timed and Q(db_interval__gt=0) or Q() + typeclass_restriction = typeclass and Q(db_typeclass_path=typeclass) or Q() + scripts = self.filter( + timed_restriction & obj_restriction & typeclass_restriction & Q(db_key__iexact=ostring) + ) + return scripts
+ + # back-compatibility alias + script_search = search_script + +
[docs] def copy_script(self, original_script, new_key=None, new_obj=None, new_locks=None): + """ + Make an identical copy of the original_script. + + Args: + original_script (Script): The Script to copy. + new_key (str, optional): Rename the copy. + new_obj (Object, optional): Place copy on different Object. + new_locks (str, optional): Give copy different locks from + the original. + + Returns: + script_copy (Script): A new Script instance, copied from + the original. + """ + typeclass = original_script.typeclass_path + new_key = new_key if new_key is not None else original_script.key + new_obj = new_obj if new_obj is not None else original_script.obj + new_locks = new_locks if new_locks is not None else original_script.db_lock_storage + + from evennia.utils import create + + new_script = create.create_script( + typeclass, key=new_key, obj=new_obj, locks=new_locks, autostart=True + ) + return new_script
+ +
[docs] def create_script( + self, + typeclass=None, + key=None, + obj=None, + account=None, + locks=None, + interval=None, + start_delay=None, + repeats=None, + persistent=None, + autostart=True, + report_to=None, + desc=None, + tags=None, + attributes=None, + ): + """ + Create a new script. All scripts are a combination of a database + object that communicates with the database, and an typeclass that + 'decorates' the database object into being different types of + scripts. It's behaviour is similar to the game objects except + scripts has a time component and are more limited in scope. + + Keyword Args: + typeclass (class or str): Class or python path to a typeclass. + key (str): Name of the new object. If not set, a name of + #dbref will be set. + obj (Object): The entity on which this Script sits. If this + is `None`, we are creating a "global" script. + account (Account): The account on which this Script sits. It is + exclusiv to `obj`. + locks (str): one or more lockstrings, separated by semicolons. + interval (int): The triggering interval for this Script, in + seconds. If unset, the Script will not have a timing + component. + start_delay (bool): If `True`, will wait `interval` seconds + before triggering the first time. + repeats (int): The number of times to trigger before stopping. + If unset, will repeat indefinitely. + persistent (bool): If this Script survives a server shutdown + or not (all Scripts will survive a reload). + autostart (bool): If this Script will start immediately when + created or if the `start` method must be called explicitly. + report_to (Object): The object to return error messages to. + desc (str): Optional description of script + tags (list): List of tags or tuples (tag, category). + attributes (list): List if tuples (key, value) or (key, value, category) + (key, value, lockstring) or (key, value, lockstring, default_access). + + Returns: + script (obj): An instance of the script created + + See evennia.scripts.manager for methods to manipulate existing + scripts in the database. + + """ + global _ObjectDB, _AccountDB + if not _ObjectDB: + from evennia.accounts.models import AccountDB as _AccountDB + from evennia.objects.models import ObjectDB as _ObjectDB + + typeclass = typeclass if typeclass else settings.BASE_SCRIPT_TYPECLASS + + if isinstance(typeclass, str): + # a path is given. Load the actual typeclass + typeclass = class_from_module(typeclass, settings.TYPECLASS_PATHS) + + # validate input + kwarg = {} + if key: + kwarg["db_key"] = key + if account: + kwarg["db_account"] = dbid_to_obj(account, _AccountDB) + if obj: + kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB) + if interval: + kwarg["db_interval"] = max(0, interval) + if start_delay: + kwarg["db_start_delay"] = start_delay + if repeats: + kwarg["db_repeats"] = max(0, repeats) + if persistent: + kwarg["db_persistent"] = persistent + if desc: + kwarg["db_desc"] = desc + tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None + + # create new instance + new_script = typeclass(**kwarg) + + # store the call signature for the signal + new_script._createdict = dict( + key=key, + obj=obj, + account=account, + locks=locks, + interval=interval, + start_delay=start_delay, + repeats=repeats, + persistent=persistent, + autostart=autostart, + report_to=report_to, + desc=desc, + tags=tags, + attributes=attributes, + ) + # this will trigger the save signal which in turn calls the + # at_first_save hook on the typeclass, where the _createdict + # can be used. + new_script.save() + + if not new_script.id: + # this happens in the case of having a repeating script with `repeats=1` and + # `start_delay=False` - the script will run once and immediately stop before + # save is over. + return None + + signals.SIGNAL_SCRIPT_POST_CREATE.send(sender=new_script) + + return new_script
+ + +
[docs]class ScriptManager(ScriptDBManager, TypeclassManager): + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/scripts/models.html b/docs/latest/_modules/evennia/scripts/models.html new file mode 100644 index 0000000000..e6452d7d29 --- /dev/null +++ b/docs/latest/_modules/evennia/scripts/models.html @@ -0,0 +1,285 @@ + + + + + + + + evennia.scripts.models — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.scripts.models

+"""
+Scripts are entities that perform some sort of action, either only
+once or repeatedly. They can be directly linked to a particular
+Evennia Object or be stand-alonw (in the latter case it is considered
+a 'global' script). Scripts can indicate both actions related to the
+game world as well as pure behind-the-scenes events and effects.
+Everything that has a time component in the game (i.e. is not
+hard-coded at startup or directly created/controlled by players) is
+handled by Scripts.
+
+Scripts have to check for themselves that they should be applied at a
+particular moment of time; this is handled by the is_valid() hook.
+Scripts can also implement at_start and at_end hooks for preparing and
+cleaning whatever effect they have had on the game object.
+
+Common examples of uses of Scripts:
+
+- Load the default cmdset to the account object's cmdhandler
+  when logging in.
+- Switch to a different state, such as entering a text editor,
+  start combat or enter a dark room.
+- Merge a new cmdset with the default one for changing which
+  commands are available at a particular time
+- Give the account/object a time-limited bonus/effect
+
+"""
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import models
+
+from evennia.scripts.manager import ScriptDBManager
+from evennia.typeclasses.models import TypedObject
+from evennia.utils.utils import dbref, to_str
+
+__all__ = ("ScriptDB",)
+_GA = object.__getattribute__
+_SA = object.__setattr__
+
+
+# ------------------------------------------------------------
+#
+# ScriptDB
+#
+# ------------------------------------------------------------
+
+
+
[docs]class ScriptDB(TypedObject): + """ + The Script database representation. + + The TypedObject supplies the following (inherited) properties: + key - main name + name - alias for key + typeclass_path - the path to the decorating typeclass + typeclass - auto-linked typeclass + date_created - time stamp of object creation + permissions - perm strings + dbref - #id of object + db - persistent attribute storage + ndb - non-persistent attribute storage + + The ScriptDB adds the following properties: + desc - optional description of script + obj - the object the script is linked to, if any + account - the account the script is linked to (exclusive with obj) + interval - how often script should run + start_delay - if the script should start repeating right away + repeats - how many times the script should repeat + persistent - if script should survive a server reboot + is_active - bool if script is currently running + + """ + + # + # ScriptDB Database Model setup + # + # These database fields are all set using their corresponding properties, + # named same as the field, but withtou the db_* prefix. + + # inherited fields (from TypedObject): + # db_key, db_typeclass_path, db_date_created, db_permissions + + # optional description. + db_desc = models.CharField("desc", max_length=255, blank=True) + # A reference to the database object affected by this Script, if any. + db_obj = models.ForeignKey( + "objects.ObjectDB", + null=True, + blank=True, + on_delete=models.CASCADE, + verbose_name="scripted object", + help_text="the object to store this script on, if not a global script.", + ) + db_account = models.ForeignKey( + "accounts.AccountDB", + null=True, + blank=True, + on_delete=models.CASCADE, + verbose_name="scripted account", + help_text="the account to store this script on (should not be set if db_obj is set)", + ) + + # how often to run Script (secs). -1 means there is no timer + db_interval = models.IntegerField( + "interval", default=-1, help_text="how often to repeat script, in seconds. <= 0 means off." + ) + # start script right away or wait interval seconds first + db_start_delay = models.BooleanField( + "start delay", default=False, help_text="pause interval seconds before starting." + ) + # how many times this script is to be repeated, if interval!=0. + db_repeats = models.IntegerField("number of repeats", default=0, help_text="0 means off.") + # defines if this script should survive a reboot or not + db_persistent = models.BooleanField("survive server reboot", default=True) + # defines if this script has already been started in this session + db_is_active = models.BooleanField("script active", default=False) + + # Database manager + objects = ScriptDBManager() + + # defaults + __settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS + __defaultclasspath__ = "evennia.scripts.scripts.DefaultScript" + __applabel__ = "scripts" + + class Meta(object): + "Define Django meta options" + verbose_name = "Script" + + # + # + # ScriptDB class properties + # + # + + # obj property + def __get_obj(self): + """ + Property wrapper that homogenizes access to either the + db_account or db_obj field, using the same object property + name. + + """ + obj = _GA(self, "db_account") + if not obj: + obj = _GA(self, "db_obj") + return obj + + def __set_obj(self, value): + """ + Set account or obj to their right database field. If + a dbref is given, assume ObjectDB. + + """ + try: + value = _GA(value, "dbobj") + except AttributeError: + # deprecated ... + pass + if isinstance(value, (str, int)): + value = to_str(value) + if value.isdigit() or value.startswith("#"): + dbid = dbref(value, reqhash=False) + if dbid: + try: + value = ObjectDB.objects.get(id=dbid) + except ObjectDoesNotExist: + # maybe it is just a name that happens to look like a dbid + pass + if value.__class__.__name__ == "AccountDB": + fname = "db_account" + _SA(self, fname, value) + else: + fname = "db_obj" + _SA(self, fname, value) + # saving the field + _GA(self, "save")(update_fields=[fname]) + + obj = property(__get_obj, __set_obj) + object = property(__get_obj, __set_obj)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/scripts/monitorhandler.html b/docs/latest/_modules/evennia/scripts/monitorhandler.html new file mode 100644 index 0000000000..3832a2bc75 --- /dev/null +++ b/docs/latest/_modules/evennia/scripts/monitorhandler.html @@ -0,0 +1,328 @@ + + + + + + + + evennia.scripts.monitorhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.scripts.monitorhandler

+"""
+Monitors - catch changes to model fields and Attributes.
+
+The MONITOR_HANDLER singleton from this module offers the following
+functionality:
+
+- Field-monitor - track a object's specific database field and perform
+    an action whenever that field *changes* for whatever reason.
+- Attribute-monitor tracks an object's specific Attribute and perform
+    an action whenever that Attribute *changes* for whatever reason.
+
+"""
+import inspect
+from collections import defaultdict
+
+from evennia.server.models import ServerConfig
+from evennia.utils import logger, variable_from_module
+from evennia.utils.dbserialize import dbserialize, dbunserialize
+
+_SA = object.__setattr__
+_GA = object.__getattribute__
+_DA = object.__delattr__
+
+
+
[docs]class MonitorHandler(object): + """ + This is a resource singleton that allows for registering + callbacks for when a field or Attribute is updated (saved). + + """ + +
[docs] def __init__(self): + """ + Initialize the handler. + + """ + self.savekey = "_monitorhandler_save" + self.monitors = defaultdict(lambda: defaultdict(dict))
+ +
[docs] def save(self): + """ + Store our monitors to the database. This is called + by the server process. + + Since dbserialize can't handle defaultdicts, we convert to an + intermediary save format ((obj,fieldname, idstring, callback, kwargs), ...) + + """ + savedata = [] + if self.monitors: + for obj in self.monitors: + for fieldname in self.monitors[obj]: + for idstring, (callback, persistent, kwargs) in self.monitors[obj][ + fieldname + ].items(): + path = "%s.%s" % (callback.__module__, callback.__name__) + savedata.append((obj, fieldname, idstring, path, persistent, kwargs)) + savedata = dbserialize(savedata) + ServerConfig.objects.conf(key=self.savekey, value=savedata)
+ +
[docs] def restore(self, server_reload=True): + """ + Restore our monitors after a reload. This is called + by the server process. + + Args: + server_reload (bool, optional): If this is False, it means + the server went through a cold reboot and all + non-persistent tickers must be killed. + + """ + self.monitors = defaultdict(lambda: defaultdict(dict)) + restored_monitors = ServerConfig.objects.conf(key=self.savekey) + if restored_monitors: + restored_monitors = dbunserialize(restored_monitors) + for obj, fieldname, idstring, path, persistent, kwargs in restored_monitors: + try: + if not server_reload and not persistent: + # this monitor will not be restarted + continue + if "session" in kwargs and not kwargs["session"]: + # the session was removed because it no longer + # exists. Don't restart the monitor. + continue + modname, varname = path.rsplit(".", 1) + callback = variable_from_module(modname, varname) + + if obj and hasattr(obj, fieldname): + self.monitors[obj][fieldname][idstring] = (callback, persistent, kwargs) + except Exception: + continue + # make sure to clean data from database + ServerConfig.objects.conf(key=self.savekey, delete=True)
+ + def _attr_category_fieldname(self, fieldname, category): + """ + Modify the saved fieldname to make sure to differentiate between Attributes + with different categories. + + """ + return f"{fieldname}[{category}]" if category else fieldname + +
[docs] def at_update(self, obj, fieldname): + """ + Called by the field/attribute as it saves. + + """ + # if this an Attribute with a category we should differentiate + fieldname = self._attr_category_fieldname( + fieldname, + obj.db_category if fieldname == "db_value" and hasattr(obj, "db_category") else None, + ) + + to_delete = [] + if obj in self.monitors and fieldname in self.monitors[obj]: + for idstring, (callback, persistent, kwargs) in self.monitors[obj][fieldname].items(): + try: + callback(obj=obj, fieldname=fieldname, **kwargs) + except Exception: + to_delete.append((obj, fieldname, idstring)) + logger.log_trace("Monitor callback was removed.") + # we cleanup non-found monitors (has to be done after loop) + for obj, fieldname, idstring in to_delete: + del self.monitors[obj][fieldname][idstring]
+ +
[docs] def add(self, obj, fieldname, callback, idstring="", persistent=False, category=None, **kwargs): + """ + Add monitoring to a given field or Attribute. A field must + be specified with the full db_* name or it will be assumed + to be an Attribute (so `db_key`, not just `key`). + + Args: + obj (Typeclassed Entity): The entity on which to monitor a + field or Attribute. + fieldname (str): Name of field (db_*) or Attribute to monitor. + callback (callable): A callable on the form `callable(**kwargs), + where kwargs holds keys fieldname and obj. + idstring (str, optional): An id to separate this monitor from other monitors + of the same field and object. + persistent (bool, optional): If False, the monitor will survive + a server reload but not a cold restart. This is default. + category (str, optional): This is only used if `fieldname` refers to + an Attribute (i.e. it does not start with `db_`). You must specify this + if you want to target an Attribute with a category. + + Keyword Args: + session (Session): If this keyword is given, the monitorhandler will + correctly analyze it and remove the monitor if after a reload/reboot + the session is no longer valid. + any (any): Any other kwargs are passed on to the callback. Remember that + all kwargs must be possible to pickle! + + """ + if not fieldname.startswith("db_") or not hasattr(obj, fieldname): + # an Attribute - we track its db_value field + obj = obj.attributes.get(fieldname, category=category, return_obj=True) + if not obj: + return + fieldname = self._attr_category_fieldname("db_value", category) + + # we try to serialize this data to test it's valid. Otherwise we won't accept it. + try: + if not inspect.isfunction(callback): + raise TypeError("callback is not a function.") + dbserialize((obj, fieldname, callback, idstring, persistent, kwargs)) + except Exception: + err = "Invalid monitor definition: \n" " (%s, %s, %s, %s, %s, %s)" % ( + obj, + fieldname, + callback, + idstring, + persistent, + kwargs, + ) + logger.log_trace(err) + else: + self.monitors[obj][fieldname][idstring] = (callback, persistent, kwargs)
+ +
[docs] def remove(self, obj, fieldname, idstring="", category=None): + """ + Remove a monitor. + """ + if not fieldname.startswith("db_") or not hasattr(obj, fieldname): + obj = obj.attributes.get(fieldname, return_obj=True) + if not obj: + return + fieldname = self._attr_category_fieldname("db_value", category) + + idstring_dict = self.monitors[obj][fieldname] + if idstring in idstring_dict: + del self.monitors[obj][fieldname][idstring]
+ +
[docs] def clear(self): + """ + Delete all monitors. + """ + self.monitors = defaultdict(lambda: defaultdict(dict))
+ +
[docs] def all(self, obj=None): + """ + List all monitors or all monitors of a given object. + + Args: + obj (Object): The object on which to list all monitors. + + Returns: + monitors (list): The handled monitors. + + """ + output = [] + objs = [obj] if obj else self.monitors + + for obj in objs: + for fieldname in self.monitors[obj]: + for idstring, (callback, persistent, kwargs) in self.monitors[obj][ + fieldname + ].items(): + output.append((obj, fieldname, idstring, persistent, kwargs)) + return output
+ + +# access object +MONITOR_HANDLER = MonitorHandler() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/scripts/scripthandler.html b/docs/latest/_modules/evennia/scripts/scripthandler.html new file mode 100644 index 0000000000..d98cad6505 --- /dev/null +++ b/docs/latest/_modules/evennia/scripts/scripthandler.html @@ -0,0 +1,282 @@ + + + + + + + + evennia.scripts.scripthandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.scripts.scripthandler

+"""
+The script handler makes sure to check through all stored scripts to
+make sure they are still relevant. A scripthandler is automatically
+added to all game objects. You access it through the property
+`scripts` on the game object.
+
+"""
+from django.utils.translation import gettext as _
+
+from evennia.scripts.models import ScriptDB
+from evennia.utils import create, logger
+
+
+
[docs]class ScriptHandler(object): + """ + Implements the handler. This sits on each game object. + + """ + +
[docs] def __init__(self, obj): + """ + Set up internal state. + + Args: + obj (Object): A reference to the object this handler is + attached to. + + """ + self.obj = obj
+ + def __str__(self): + """ + List the scripts tied to this object. + + """ + scripts = ScriptDB.objects.get_all_scripts_on_obj(self.obj) + string = "" + for script in scripts: + interval = "inf" + next_repeat = "inf" + repeats = "inf" + if script.interval > 0: + interval = script.interval + if script.repeats: + repeats = script.repeats + try: + next_repeat = script.time_until_next_repeat() + except Exception: + next_repeat = "?" + string += _("\n '{key}' ({next_repeat}/{interval}, {repeats} repeats): {desc}").format( + key=script.key, + next_repeat=next_repeat, + interval=interval, + repeats=repeats, + desc=script.desc, + ) + return string.strip() + +
[docs] def add(self, scriptclass, key=None, autostart=True): + """ + Add a script to this object. + + Args: + scriptclass (Scriptclass, Script or str): Either a class + object inheriting from DefaultScript, an instantiated + script object or a python path to such a class object. + key (str, optional): Identifier for the script (often set + in script definition and listings) + autostart (bool, optional): Start the script upon adding it. + + Returns: + Script: The newly created Script. + + """ + if self.obj.__dbclass__.__name__ == "AccountDB": + # we add to an Account, not an Object + script = create.create_script( + scriptclass, key=key, account=self.obj, autostart=autostart + ) + elif isinstance(scriptclass, str) or callable(scriptclass): + # a str or class to use create before adding to an Object. We wait to autostart + # so we can differentiate a failing creation from a script that immediately starts/stops. + script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False) + else: + # already an instantiated class + script = scriptclass + + if not script: + logger.log_err(f"Script {scriptclass} failed to be created.") + return None + if autostart: + script.start() + if not script.id: + # this can happen if the script has repeats=1 or calls stop() in at_repeat. + logger.log_info( + f"Script {scriptclass} started and then immediately stopped; " + "it could probably be a normal function." + ) + return script
+ +
[docs] def start(self, key): + """ + Find scripts and force-start them + + Args: + key (str): The script's key or dbref. + + Returns: + nr_started (int): The number of started scripts found. + + """ + scripts = ScriptDB.objects.get_all_scripts_on_obj(self.obj, key=key) + num = 0 + for script in scripts: + script.start() + num += 1 + return num
+ +
[docs] def has(self, key): + """ + Determine if a given script exists on this object. + + Args: + key (str): Search criterion, the script's key or dbref. + + Returns: + bool: If the script exists or not. + + """ + return ScriptDB.objects.get_all_scripts_on_obj(self.obj, key=key).exists()
+ +
[docs] def get(self, key): + """ + Search scripts on this object. + + Args: + key (str): Search criterion, the script's key or dbref. + + Returns: + scripts (queryset): The found scripts matching `key`. + + """ + return ScriptDB.objects.get_all_scripts_on_obj(self.obj, key=key)
+ +
[docs] def remove(self, key=None): + """ + Forcibly delete a script from this object. + + Args: + key (str, optional): A script key or the path to a script (in the + latter case all scripts with this path will be deleted!) + If no key is given, delete *all* scripts on the object! + + """ + delscripts = ScriptDB.objects.get_all_scripts_on_obj(self.obj, key=key) + if not delscripts: + delscripts = [ + script + for script in ScriptDB.objects.get_all_scripts_on_obj(self.obj) + if script.path == key + ] + num = 0 + for script in delscripts: + script.delete() + num += 1 + return num
+ + # legacy aliases to remove + delete = remove + stop = delete + +
[docs] def all(self): + """ + Get all scripts stored in this handler. + + """ + return ScriptDB.objects.get_all_scripts_on_obj(self.obj)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/scripts/scripts.html b/docs/latest/_modules/evennia/scripts/scripts.html new file mode 100644 index 0000000000..4714916715 --- /dev/null +++ b/docs/latest/_modules/evennia/scripts/scripts.html @@ -0,0 +1,941 @@ + + + + + + + + evennia.scripts.scripts — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.scripts.scripts

+"""
+This module defines Scripts, out-of-character entities that can store
+data both on themselves and on other objects while also having the
+ability to run timers.
+
+"""
+
+from django.utils.translation import gettext as _
+from twisted.internet.defer import Deferred, maybeDeferred
+from twisted.internet.task import LoopingCall
+
+from evennia.scripts.manager import ScriptManager
+from evennia.scripts.models import ScriptDB
+from evennia.typeclasses.models import TypeclassBase
+from evennia.utils import create, logger
+
+__all__ = ["DefaultScript", "DoNothing", "Store"]
+
+
+class ExtendedLoopingCall(LoopingCall):
+    """
+    Custom child of LoopingCall that can start at a delay different than
+    `self.interval` and self.count=0. This allows it to support pausing
+    by resuming at a later period.
+
+    """
+
+    start_delay = None
+    callcount = 0
+
+    def start(self, interval, now=True, start_delay=None, count_start=0):
+        """
+        Start running function every interval seconds.
+
+        This overloads the LoopingCall default by offering the
+        start_delay keyword and ability to repeat.
+
+        Args:
+            interval (int): Repeat interval in seconds.
+            now (bool, optional): Whether to start immediately or after
+                `start_delay` seconds.
+            start_delay (int, optional): This only applies is `now=False`. It gives
+                number of seconds to wait before starting. If `None`, use
+                `interval` as this value instead. Internally, this is used as a
+                way to start with a variable start time after a pause.
+            count_start (int): Number of repeats to start at. The  count
+                goes up every time the system repeats. This is used to
+                implement something repeating `N` number of times etc.
+
+        Raises:
+            AssertError: if trying to start a task which is already running.
+            ValueError: If interval is set to an invalid value < 0.
+
+        Notes:
+            As opposed to Twisted's inbuilt count mechanism, this
+            system will count also if force_repeat() was called rather
+            than just the number of `interval` seconds since the start.
+            This allows us to force-step through a limited number of
+            steps if we want.
+
+        """
+        assert not self.running, "Tried to start an already running ExtendedLoopingCall."
+        if interval < 0:
+            raise ValueError("interval must be >= 0")
+        self.running = True
+        deferred = self._deferred = Deferred()
+        self.starttime = self.clock.seconds()
+        self.interval = interval
+        self._runAtStart = now
+        self.callcount = max(0, count_start)
+        self.start_delay = start_delay if start_delay is None else max(0, start_delay)
+
+        if now:
+            # run immediately
+            self()
+        elif start_delay is not None and start_delay >= 0:
+            # start after some time: for this to work we need to
+            # trick _scheduleFrom by temporarily setting a different
+            # self.interval for it to check.
+            real_interval, self.interval = self.interval, start_delay
+            self._scheduleFrom(self.starttime)
+            # re-set the actual interval (this will be picked up
+            # next time it runs
+            self.interval = real_interval
+        else:
+            self._scheduleFrom(self.starttime)
+        return deferred
+
+    def __call__(self):
+        """
+        Tick one step. We update callcount (tracks number of calls) as
+        well as null start_delay (needed in order to correctly
+        estimate next_call_time at all times).
+
+        """
+        self.callcount += 1
+        if self.start_delay:
+            self.start_delay = None
+            self.starttime = self.clock.seconds()
+        if self._deferred:
+            LoopingCall.__call__(self)
+
+    def force_repeat(self):
+        """
+        Force-fire the callback
+
+        Raises:
+            AssertionError: When trying to force a task that is not
+                running.
+
+        """
+        assert self.running, "Tried to fire an ExtendedLoopingCall that was not running."
+        self.call.cancel()
+        self.call = None
+        self.starttime = self.clock.seconds()
+        self()
+
+    def next_call_time(self):
+        """
+        Get the next call time. This also takes the eventual effect
+        of start_delay into account.
+
+        Returns:
+            int or None: The time in seconds until the next call. This
+                takes `start_delay` into account. Returns `None` if
+                the task is not running.
+
+        """
+        if self.running and self.interval > 0:
+            total_runtime = self.clock.seconds() - self.starttime
+            interval = self.start_delay or self.interval
+            return max(0, interval - (total_runtime % self.interval))
+
+
+class ScriptBase(ScriptDB, metaclass=TypeclassBase):
+    """
+    Base class for scripts. Don't inherit from this, inherit from the
+    class `DefaultScript` below instead.
+
+    This handles the timer-component of the Script.
+
+    """
+
+    objects = ScriptManager()
+
+    def __str__(self):
+        return "<{cls} {key}>".format(cls=self.__class__.__name__, key=self.key)
+
+    def __repr__(self):
+        return str(self)
+
+    def at_idmapper_flush(self):
+        """
+        If we're flushing this object, make sure the LoopingCall is gone too.
+        """
+        ret = super().at_idmapper_flush()
+        if ret and self.ndb._task:
+            self.ndb._pause_task(auto_pause=True)
+        # TODO - restart anew ?
+        return ret
+
+    def _start_task(
+        self,
+        interval=None,
+        start_delay=None,
+        repeats=None,
+        force_restart=False,
+        auto_unpause=False,
+        **kwargs,
+    ):
+        """
+        Start/Unpause task runner, optionally with new values. If given, this will
+        update the Script's fields.
+
+        Keyword Args:
+            interval (int): How often to tick the task, in seconds. If this is <= 0,
+                no task will start and properties will not be updated on the Script.
+            start_delay (int): If the start should be delayed.
+            repeats (int): How many repeats. 0 for infinite repeats.
+            force_restart (bool): If set, always create a new task running even if an
+                old one already was running. Otherwise this will only happen if
+                new script properties were passed.
+            auto_unpause (bool): This is an automatic unpaused (used e.g by Evennia after
+                a reload) and should not un-pause manually paused Script timers.
+        Note:
+            If setting the `start-delay` of a *paused* Script, the Script will
+            restart exactly after that new start-delay, ignoring the time it
+            was paused at. If only changing the `interval`, the Script will
+            come out of pause comparing the time it spent in the *old* interval
+            with the *new* interval in order to determine when next to fire.
+
+        Examples:
+            - Script previously had an interval of 10s and was paused 5s into that interval.
+              Script is now restarted with a 20s interval. It will next fire after 15s.
+            - Same Script is restarted with a 3s interval. It will fire immediately.
+
+        """
+        if self.pk is None:
+            # script object already deleted from db - don't start a new timer
+            raise ScriptDB.DoesNotExist
+
+        # handle setting/updating fields
+        update_fields = []
+        old_interval = self.db_interval
+        if interval is not None:
+            self.db_interval = interval
+            update_fields.append("db_interval")
+        if start_delay is not None:
+            # note that for historical reasons, the start_delay is a boolean field, not an int; the
+            # actual value is only used with the task.
+            self.db_start_delay = bool(start_delay)
+            update_fields.append("db_start_delay")
+        if repeats is not None:
+            self.db_repeats = repeats
+            update_fields.append("db_repeats")
+
+        # validate interval
+        if self.db_interval and self.db_interval > 0:
+            if not self.is_active:
+                self.db_is_active = True
+                update_fields.append("db_is_active")
+        else:
+            # no point in starting a task with no interval.
+            return
+
+        restart = bool(update_fields) or force_restart
+        self.save(update_fields=update_fields)
+
+        if self.ndb._task and self.ndb._task.running:
+            if restart:
+                # a change needed/forced; stop/remove old task
+                self._stop_task()
+            else:
+                # task alreaady running and no changes needed
+                return
+
+        if not self.ndb._task:
+            # we should have a fresh task after this point
+            self.ndb._task = ExtendedLoopingCall(self._step_task)
+
+        self._unpause_task(
+            interval=interval,
+            start_delay=start_delay,
+            auto_unpause=auto_unpause,
+            old_interval=old_interval,
+        )
+
+        if not self.ndb._task.running:
+            # if not unpausing started it, start script anew with the new values
+            self.ndb._task.start(
+                self.db_interval, now=not self.db_start_delay, start_delay=start_delay
+            )
+
+        self.at_start(**kwargs)
+
+    def _pause_task(self, auto_pause=False, **kwargs):
+        """
+        Pause task where it is, saving the current status.
+
+        Args:
+            auto_pause (str):
+
+        """
+        if not self.db._paused_time:
+            # only allow pause if not already paused
+            task = self.ndb._task
+            if task:
+                self.db._paused_time = task.next_call_time()
+                self.db._paused_callcount = task.callcount
+                self.db._manually_paused = not auto_pause
+                if task.running:
+                    task.stop()
+            self.ndb._task = None
+
+            self.at_pause(auto_pause=auto_pause, **kwargs)
+
+    def _unpause_task(
+        self, interval=None, start_delay=None, auto_unpause=False, old_interval=0, **kwargs
+    ):
+        """
+        Unpause task from paused status. This is used for auto-paused tasks, such
+        as tasks paused on a server reload.
+
+        Args:
+            interval (int): How often to tick the task, in seconds.
+            start_delay (int): If the start should be delayed.
+            auto_unpause (bool): If set, this will only unpause scripts that were unpaused
+                automatically (useful during a system reload/shutdown).
+            old_interval (int): The old Script interval (or current one if nothing changed). Used
+                to recalculate the unpause startup interval.
+
+        """
+        paused_time = self.db._paused_time
+        if paused_time:
+            if auto_unpause and self.db._manually_paused:
+                # this was manually paused.
+                return
+
+            # task was paused. This will use the new values as needed.
+            callcount = self.db._paused_callcount or 0
+            if start_delay is None and interval is not None:
+                # adjust start-delay based on how far we were into previous interval
+                start_delay = max(0, interval - (old_interval - paused_time))
+            else:
+                start_delay = paused_time
+
+            if not self.ndb._task:
+                self.ndb._task = ExtendedLoopingCall(self._step_task)
+
+            self.ndb._task.start(
+                self.db_interval, now=False, start_delay=start_delay, count_start=callcount
+            )
+            self.db._paused_time = None
+            self.db._paused_callcount = None
+            self.db._manually_paused = None
+
+            self.at_start(**kwargs)
+
+    def _stop_task(self, **kwargs):
+        """
+        Stop task runner and delete the task.
+
+        """
+        task_stopped = False
+        task = self.ndb._task
+        if task and task.running:
+            task.stop()
+            task_stopped = True
+
+        self.ndb._task = None
+        self.db_is_active = False
+
+        # make sure this is not confused as a paused script
+        self.db._paused_time = None
+        self.db._paused_callcount = None
+        self.db._manually_paused = None
+
+        self.save(update_fields=["db_is_active"])
+        if task_stopped:
+            self.at_stop(**kwargs)
+
+    def _step_errback(self, e):
+        """
+        Callback for runner errors
+
+        """
+        cname = self.__class__.__name__
+        estring = _(
+            "Script {key}(#{dbid}) of type '{name}': at_repeat() error '{err}'.".format(
+                key=self.key, dbid=self.dbid, name=cname, err=e.getErrorMessage()
+            )
+        )
+        try:
+            self.db_obj.msg(estring)
+        except Exception:
+            # we must not crash inside the errback, even if db_obj is None.
+            pass
+        logger.log_err(estring)
+
+    def _step_callback(self):
+        """
+        Step task runner. No try..except needed due to defer wrap.
+
+        """
+        if not self.ndb._task:
+            # if there is no task, we have no business using this method
+            return
+
+        if not self.is_valid():
+            self.stop()
+            return
+
+        # call hook
+        try:
+            self.at_repeat()
+        except Exception:
+            logger.log_trace()
+            raise
+
+        # check repeats
+        if self.ndb._task:
+            # we need to check for the task in case stop() was called
+            # inside at_repeat() and it already went away.
+            callcount = self.ndb._task.callcount
+            maxcount = self.db_repeats
+            if maxcount > 0 and maxcount <= callcount:
+                self.stop()
+
+    def _step_task(self):
+        """
+        Step task. This groups error handling.
+        """
+        try:
+            return maybeDeferred(self._step_callback).addErrback(self._step_errback)
+        except Exception:
+            logger.log_trace()
+        return None
+
+    # Access methods / hooks
+
+    def at_first_save(self, **kwargs):
+        """
+        This is called after very first time this object is saved.
+        Generally, you don't need to overload this, but only the hooks
+        called by this method.
+
+        Args:
+            **kwargs (dict): Arbitrary, optional arguments for users
+                overriding the call (unused by default).
+
+        """
+        self.basetype_setup()
+        self.at_script_creation()
+        # initialize Attribute/TagProperties
+        self.init_evennia_properties()
+
+        if hasattr(self, "_createdict"):
+            # this will only be set if the utils.create_script
+            # function was used to create the object. We want
+            # the create call's kwargs to override the values
+            # set by hooks.
+            cdict = self._createdict
+            updates = []
+            if not cdict.get("key"):
+                if not self.db_key:
+                    self.db_key = "#%i" % self.dbid
+                    updates.append("db_key")
+            elif self.db_key != cdict["key"]:
+                self.db_key = cdict["key"]
+                updates.append("db_key")
+            if cdict.get("interval") and self.interval != cdict["interval"]:
+                self.db_interval = max(0, cdict["interval"])
+                updates.append("db_interval")
+            if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]:
+                self.db_start_delay = cdict["start_delay"]
+                updates.append("db_start_delay")
+            if cdict.get("repeats") and self.repeats != cdict["repeats"]:
+                self.db_repeats = max(0, cdict["repeats"])
+                updates.append("db_repeats")
+            if cdict.get("persistent") and self.persistent != cdict["persistent"]:
+                self.db_persistent = cdict["persistent"]
+                updates.append("db_persistent")
+            if cdict.get("desc") and self.desc != cdict["desc"]:
+                self.db_desc = cdict["desc"]
+                updates.append("db_desc")
+            if updates:
+                self.save(update_fields=updates)
+
+            if cdict.get("permissions"):
+                self.permissions.batch_add(*cdict["permissions"])
+            if cdict.get("locks"):
+                self.locks.add(cdict["locks"])
+            if cdict.get("tags"):
+                # this should be a list of tags, tuples (key, category) or (key, category, data)
+                self.tags.batch_add(*cdict["tags"])
+            if cdict.get("attributes"):
+                # this should be tuples (key, val, ...)
+                self.attributes.batch_add(*cdict["attributes"])
+            if cdict.get("nattributes"):
+                # this should be a dict of nattrname:value
+                for key, value in cdict["nattributes"]:
+                    self.nattributes.add(key, value)
+
+            if cdict.get("autostart"):
+                # autostart the script
+                self._start_task(force_restart=True)
+
+    def delete(self):
+        """
+        Delete the Script. Normally stops any timer task. This fires at_script_delete before
+        deletion.
+
+        Returns:
+            bool: If deletion was successful or not. Only time this can fail would be if
+                the script was already previously deleted, or `at_script_delete` returns
+                False.
+
+        """
+        if not self.pk or not self.at_script_delete():
+            return False
+
+        self._stop_task()
+        super().delete()
+        return True
+
+    def basetype_setup(self):
+        """
+        Changes fundamental aspects of the type. Usually changes are made in at_script creation
+        instead.
+
+        """
+        pass
+
+    def at_init(self):
+        """
+        Called when the Script is cached in the idmapper. This is usually more reliable
+        than overriding `__init__` since the latter can be called at unexpected times.
+
+        """
+        pass
+
+    def at_script_creation(self):
+        """
+        Should be overridden in child.
+
+        """
+        pass
+
+    def at_script_delete(self):
+        """
+        Called when script is deleted, before the script timer stops.
+
+        Returns:
+            bool: If False, deletion is aborted.
+
+        """
+        return True
+
+    def is_valid(self):
+        """
+        If returning False, `at_repeat` will not be called and timer will stop
+        updating.
+        """
+        return True
+
+    def at_repeat(self, **kwargs):
+        """
+        Called repeatedly every `interval` seconds, once `.start()` has
+        been called on the Script at least once.
+
+        Args:
+            **kwargs (dict): Arbitrary, optional arguments for users
+                overriding the call (unused by default).
+
+        """
+        pass
+
+    def at_start(self, **kwargs):
+        pass
+
+    def at_pause(self, **kwargs):
+        pass
+
+    def at_stop(self, **kwargs):
+        pass
+
+    def start(self, interval=None, start_delay=None, repeats=None, **kwargs):
+        """
+        Start/Unpause timer component, optionally with new values. If given,
+        this will update the Script's fields. This will start `at_repeat` being
+        called every `interval` seconds.
+
+        Keyword Args:
+            interval (int): How often to fire `at_repeat` in seconds.
+            start_delay (int): If the start of ticking should be delayed and by how much.
+            repeats (int): How many repeats. 0 for infinite repeats.
+            **kwargs: Optional (default unused) kwargs passed on into the `at_start` hook.
+
+        Notes:
+            If setting the `start-delay` of a *paused* Script, the Script will
+            restart exactly after that new start-delay, ignoring the time it
+            was paused at. If only changing the `interval`, the Script will
+            come out of pause comparing the time it spent in the *old* interval
+            with the *new* interval in order to determine when next to fire.
+
+        Examples:
+            - Script previously had an interval of 10s and was paused 5s into that interval.
+              Script is now restarted with a 20s interval. It will next fire after 15s.
+            - Same Script is restarted with a 3s interval. It will fire immediately.
+
+        """
+        self._start_task(interval=interval, start_delay=start_delay, repeats=repeats, **kwargs)
+
+    # legacy alias
+    update = start
+
+    def stop(self, **kwargs):
+        """
+        Stop the Script's timer component. This will not delete the Sctipt,
+        just stop the regular firing of `at_repeat`. Running `.start()` will
+        start the timer anew, optionally with new settings..
+
+        Args:
+            **kwargs: Optional (default unused) kwargs passed on into the `at_stop` hook.
+
+        """
+        self._stop_task(**kwargs)
+
+    def pause(self, **kwargs):
+        """
+        Manually the Script's timer component manually.
+
+        Args:
+            **kwargs: Optional (default unused) kwargs passed on into the `at_pause` hook.
+
+        """
+        self._pause_task(manual_pause=True, **kwargs)
+
+    def unpause(self, **kwargs):
+        """
+        Manually unpause a Paused Script.
+
+        Args:
+            **kwargs: Optional (default unused) kwargs passed on into the `at_start` hook.
+
+        """
+        self._unpause_task(**kwargs)
+
+    def time_until_next_repeat(self):
+        """
+        Get time until the script fires it `at_repeat` hook again.
+
+        Returns:
+            int or None: Time in seconds until the script runs again.
+                If not a timed script, return `None`.
+
+        Notes:
+            This hook is not used in any way by the script's stepping
+            system; it's only here for the user to be able to check in
+            on their scripts and when they will next be run.
+
+        """
+        task = self.ndb._task
+        if task:
+            try:
+                return int(round(task.next_call_time()))
+            except TypeError:
+                pass
+        return None
+
+    def remaining_repeats(self):
+        """
+        Get the number of returning repeats for limited Scripts.
+
+        Returns:
+            int or None: The number of repeats remaining until the Script
+                stops. Returns `None` if it has unlimited repeats.
+
+        """
+        task = self.ndb._task
+        if task:
+            return max(0, self.db_repeats - task.callcount)
+        return None
+
+    def reset_callcount(self, value=0):
+        """
+        Reset the count of the number of calls done.
+
+        Args:
+            value (int, optional): The repeat value to reset to. Default
+                is to set it all the way back to 0.
+
+        Notes:
+            This is only useful if repeats != 0.
+
+        """
+        task = self.ndb._task
+        if task:
+            task.callcount = max(0, int(value))
+
+    def force_repeat(self):
+        """
+        Fire a premature triggering of the script callback. This
+        will reset the timer and count down repeats as if the script
+        had fired normally.
+        """
+        task = self.ndb._task
+        if task:
+            task.force_repeat()
+
+
+
[docs]class DefaultScript(ScriptBase): + """ + This is the base TypeClass for all Scripts. Scripts describe + events, timers and states in game, they can have a time component + or describe a state that changes under certain conditions. + + """ + +
[docs] @classmethod + def create(cls, key, **kwargs): + """ + Provides a passthrough interface to the utils.create_script() function. + + Args: + key (str): Name of the new object. + + Returns: + object (Object): A newly created object of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + kwargs["key"] = key + + # If no typeclass supplied, use this class + kwargs["typeclass"] = kwargs.pop("typeclass", cls) + + try: + obj = create.create_script(**kwargs) + except Exception: + logger.log_trace() + errors.append("The script '%s' encountered errors and could not be created." % key) + + return obj, errors
+ +
[docs] def at_script_creation(self): + """ + Only called once, when script is first created. + + """ + pass
+ +
[docs] def is_valid(self): + """ + Is called to check if the script's timer is valid to run at this time. + Should return a boolean. If False, the timer will be stopped. + + """ + return True
+ +
[docs] def at_start(self, **kwargs): + """ + Called whenever the script timer is started, which for persistent + timed scripts is at least once every server start. It will also be + called when starting again after a pause (including after a + server reload). + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_repeat(self, **kwargs): + """ + Called repeatedly if this Script is set to repeat regularly. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_pause(self, manual_pause=True, **kwargs): + """ + Called when this script's timer pauses. + + Args: + manual_pause (bool): If set, pausing was done by a direct call. The + non-manual pause indicates the script was paused as part of + the server reload. + + """ + pass
+ +
[docs] def at_stop(self, **kwargs): + """ + Called whenever when it's time for this script's timer to stop (either + because is_valid returned False, it ran out of iterations or it was manuallys + stopped. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass
+ +
[docs] def at_script_delete(self): + """ + Called when the Script is deleted, before stopping the timer. + + Returns: + bool: If False, the deletion is aborted. + + """ + return True
+ +
[docs] def at_server_reload(self): + """ + This hook is called whenever the server is shutting down for + restart/reboot. If you want to, for example, save + non-persistent properties across a restart, this is the place + to do it. + """ + pass
+ +
[docs] def at_server_shutdown(self): + """ + This hook is called whenever the server is shutting down fully + (i.e. not for a restart). + """ + pass
+ +
[docs] def at_server_start(self): + """ + This hook is called after the server has started. It can be used to add + post-startup setup for Scripts without a timer component (for which at_start + could be used). + + """ + pass
+ + +# Some useful default Script types used by Evennia. + + +
[docs]class DoNothing(DefaultScript): + """ + A script that does nothing. Used as default fallback. + """ + +
[docs] def at_script_creation(self): + """ + Setup the script + """ + self.key = "sys_do_nothing" + self.desc = "This is an empty placeholder script."
+ + +
[docs]class Store(DefaultScript): + """ + Simple storage script + """ + +
[docs] def at_script_creation(self): + """ + Setup the script + """ + self.key = "sys_storage" + self.desc = "This is a generic storage container."
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/scripts/taskhandler.html b/docs/latest/_modules/evennia/scripts/taskhandler.html new file mode 100644 index 0000000000..0bcbed4f3e --- /dev/null +++ b/docs/latest/_modules/evennia/scripts/taskhandler.html @@ -0,0 +1,716 @@ + + + + + + + + evennia.scripts.taskhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.scripts.taskhandler

+"""
+Module containing the task handler for Evennia deferred tasks, persistent or not.
+"""
+
+from datetime import datetime, timedelta
+from pickle import PickleError
+
+from twisted.internet import reactor
+from twisted.internet.defer import CancelledError as DefCancelledError
+from twisted.internet.task import deferLater
+
+from evennia.server.models import ServerConfig
+from evennia.utils.dbserialize import dbserialize, dbunserialize
+from evennia.utils.logger import log_err
+
+TASK_HANDLER = None
+
+
+
[docs]def handle_error(*args, **kwargs): + """Handle errors within deferred objects.""" + for arg in args: + # suppress cancel errors + if arg.type == DefCancelledError: + continue + raise arg
+ + +
[docs]class TaskHandlerTask: + """An object to represent a single TaskHandler task. + + Instance Attributes: + task_id (int): the global id for this task + deferred (deferred): a reference to this task's deferred + Property Attributes: + paused (bool): check if the deferred instance of a task has been paused. + called(self): A task attribute to check if the deferred instance of a task has been called. + + Methods: + pause(): Pause the callback of a task. + unpause(): Process all callbacks made since pause() was called. + do_task(): Execute the task (call its callback). + call(): Call the callback of this task. + remove(): Remove a task without executing it. + cancel(): Stop a task from automatically executing. + active(): Check if a task is active (has not been called yet). + exists(): Check if a task exists. + get_id(): Returns the global id for this task. For use with + + """ + +
[docs] def __init__(self, task_id): + self.task_id = task_id + self.deferred = TASK_HANDLER.get_deferred(task_id)
+ +
[docs] def get_deferred(self): + """Return the instance of the deferred the task id is using. + + Returns: + bool or deferred: An instance of a deferred or False if there is no task with the id. + None is returned if there is no deferred affiliated with this id. + + """ + return TASK_HANDLER.get_deferred(self.task_id)
+ +
[docs] def pause(self): + """ + Pause the callback of a task. + To resume use `TaskHandlerTask.unpause`. + + """ + d = self.deferred + if d: + d.pause()
+ +
[docs] def unpause(self): + """ + Unpause a task, run the task if it has passed delay time. + + """ + d = self.deferred + if d: + d.unpause()
+ + @property + def paused(self): + """ + A task attribute to check if the deferred instance of a task has been paused. + + This exists to mock usage of a twisted deferred object. + + Returns: + bool or None: True if the task was properly paused. None if the task does not have + a deferred instance. + + """ + d = self.deferred + if d: + return d.paused + else: + return None + +
[docs] def do_task(self): + """ + Execute the task (call its callback). + If calling before timedelay, cancel the deferred instance affliated to this task. + Remove the task from the dictionary of current tasks on a successful + callback. + + Returns: + bool or any: Set to `False` if the task does not exist in task + handler. Otherwise it will be the return of the task's callback. + + """ + return TASK_HANDLER.do_task(self.task_id)
+ +
[docs] def call(self): + """ + Call the callback of a task. + Leave the task unaffected otherwise. + This does not use the task's deferred instance. + The only requirement is that the task exist in task handler. + + Returns: + bool or any: Set to `False` if the task does not exist in task + handler. Otherwise it will be the return of the task's callback. + + """ + return TASK_HANDLER.call_task(self.task_id)
+ +
[docs] def remove(self): + """Remove a task without executing it. + Deletes the instance of the task's deferred. + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True if the removal completed successfully. + + """ + return TASK_HANDLER.remove(self.task_id)
+ +
[docs] def cancel(self): + """Stop a task from automatically executing. + This will not remove the task. + + Returns: + bool: True if the cancel completed successfully. + False if the cancel did not complete successfully. + + """ + return TASK_HANDLER.cancel(self.task_id)
+ +
[docs] def active(self): + """Check if a task is active (has not been called yet). + + Returns: + bool: True if a task is active (has not been called yet). False if + it is not (has been called) or if the task does not exist. + + """ + return TASK_HANDLER.active(self.task_id)
+ + @property + def called(self): + """ + A task attribute to check if the deferred instance of a task has been called. + + This exists to mock usage of a twisted deferred object. + It will not set to True if Task.call has been called. This only happens if + task's deferred instance calls the callback. + + Returns: + bool: True if the deferred instance of this task has called the callback. + False if the deferred instnace of this task has not called the callback. + + """ + d = self.deferred + if d: + return d.called + else: + return None + +
[docs] def exists(self): + """ + Check if a task exists. + Most task handler methods check for existence for you. + + Returns: + bool: True the task exists False if it does not. + + """ + return TASK_HANDLER.exists(self.task_id)
+ +
[docs] def get_id(self): + """ + Returns the global id for this task. For use with + `evennia.scripts.taskhandler.TASK_HANDLER`. + + Returns: + task_id (int): global task id for this task. + + """ + return self.task_id
+ + +
[docs]class TaskHandler(object): + + """A light singleton wrapper allowing to access permanent tasks. + + When `utils.delay` is called, the task handler is used to create + the task. + + Task handler will automatically remove uncalled but canceled from task + handler. By default this will not occur until a canceled task + has been uncalled for 60 second after the time it should have been called. + To adjust this time use TASK_HANDLER.stale_timeout. If stale_timeout is 0 + stale tasks will not be automatically removed. + This is not done on a timer. I is done as new tasks are added or the load method is called. + + """ + +
[docs] def __init__(self): + self.tasks = {} + self.to_save = {} + self.clock = reactor + # number of seconds before an uncalled canceled task is removed from TaskHandler + self.stale_timeout = 60 + self._now = False # used in unit testing to manually set now time
+ +
[docs] def load(self): + """Load from the ServerConfig. + + This should be automatically called when Evennia starts. + It populates `self.tasks` according to the ServerConfig. + + """ + to_save = False + value = ServerConfig.objects.conf("delayed_tasks", default={}) + if isinstance(value, str): + tasks = dbunserialize(value) + else: + tasks = value + + # At this point, `tasks` contains a dictionary of still-serialized tasks + for task_id, value in tasks.items(): + date, callback, args, kwargs = dbunserialize(value) + if isinstance(callback, tuple): + # `callback` can be an object and name for instance methods + obj, method = callback + if obj is None: + to_save = True + continue + + callback = getattr(obj, method) + self.tasks[task_id] = (date, callback, args, kwargs, True, None) + + if self.stale_timeout > 0: # cleanup stale tasks. + self.clean_stale_tasks() + if to_save: + self.save()
+ +
[docs] def clean_stale_tasks(self): + """remove uncalled but canceled from task handler. + + By default this will not occur until a canceled task + has been uncalled for 60 second after the time it should have been called. + To adjust this time use TASK_HANDLER.stale_timeout. + + """ + clean_ids = [] + for task_id, (date, callback, args, kwargs, persistent, _) in self.tasks.items(): + if not self.active(task_id): + stale_date = date + timedelta(seconds=self.stale_timeout) + # if a now time is provided use it (intended for unit testing) + now = self._now if self._now else datetime.now() + # the task was canceled more than stale_timeout seconds ago + if now > stale_date: + clean_ids.append(task_id) + for task_id in clean_ids: + self.remove(task_id) + return True
+ +
[docs] def save(self): + """ + Save the tasks in ServerConfig. + + """ + + for task_id, (date, callback, args, kwargs, persistent, _) in self.tasks.items(): + if task_id in self.to_save: + continue + if not persistent: + continue + + safe_callback = callback + if getattr(callback, "__self__", None): + # `callback` is an instance method + obj = callback.__self__ + name = callback.__name__ + safe_callback = (obj, name) + + # Check if callback can be pickled. args and kwargs have been checked + try: + dbserialize(safe_callback) + except (TypeError, AttributeError, PickleError) as err: + raise ValueError( + "the specified callback {callback} cannot be pickled. " + "It must be a top-level function in a module or an " + "instance method ({err}).".format(callback=callback, err=err) + ) + + self.to_save[task_id] = dbserialize((date, safe_callback, args, kwargs)) + + ServerConfig.objects.conf("delayed_tasks", self.to_save)
+ +
[docs] def add(self, timedelay, callback, *args, **kwargs): + """ + Add a new task. + + If the persistent kwarg is truthy: + The callback, args and values for kwarg will be serialized. Type + and attribute errors during the serialization will be logged, + but will not throw exceptions. + For persistent tasks do not use memory references in the callback + function or arguments. After a restart those memory references are no + longer accurate. + + Args: + timedelay (int or float): time in seconds before calling the callback. + callback (function or instance method): the callback itself + any (any): any additional positional arguments to send to the callback + *args: positional arguments to pass to callback. + **kwargs: keyword arguments to pass to callback. + - persistent (bool, optional): persist the task (stores it). + Persistent key and value is removed from kwargs it will + not be passed to callback. + + Returns: + TaskHandlerTask: An object to represent a task. + Reference `evennia.scripts.taskhandler.TaskHandlerTask` for complete details. + + """ + # set the completion time + # Only used on persistent tasks after a restart + now = datetime.now() + delta = timedelta(seconds=timedelay) + comp_time = now + delta + # get an open task id + used_ids = list(self.tasks.keys()) + task_id = 1 + while task_id in used_ids: + task_id += 1 + + # record the task to the tasks dictionary + persistent = kwargs.get("persistent", False) + if "persistent" in kwargs: + del kwargs["persistent"] + if persistent: + safe_args = [] + safe_kwargs = {} + + # Check that args and kwargs contain picklable information + for arg in args: + try: + dbserialize(arg) + except (TypeError, AttributeError, PickleError): + log_err( + "The positional argument {} cannot be " + "pickled and will not be present in the arguments " + "fed to the callback {}".format(arg, callback) + ) + else: + safe_args.append(arg) + + for key, value in kwargs.items(): + try: + dbserialize(value) + except (TypeError, AttributeError, PickleError): + log_err( + "The {} keyword argument {} cannot be " + "pickled and will not be present in the arguments " + "fed to the callback {}".format(key, value, callback) + ) + else: + safe_kwargs[key] = value + + self.tasks[task_id] = (comp_time, callback, safe_args, safe_kwargs, persistent, None) + self.save() + else: # this is a non-persitent task + self.tasks[task_id] = (comp_time, callback, args, kwargs, persistent, None) + + # defer the task + callback = self.do_task + args = [task_id] + kwargs = {} + d = deferLater(self.clock, timedelay, callback, *args, **kwargs) + d.addErrback(handle_error) + + # some tasks may complete before the deferred can be added + if task_id in self.tasks: + task = self.tasks.get(task_id) + task = list(task) + task[4] = persistent + task[5] = d + self.tasks[task_id] = task + else: # the task already completed + return False + if self.stale_timeout > 0: + self.clean_stale_tasks() + return TaskHandlerTask(task_id)
+ +
[docs] def exists(self, task_id): + """ + Check if a task exists. + Most task handler methods check for existence for you. + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True the task exists False if it does not. + + """ + if task_id in self.tasks: + return True + else: + return False
+ +
[docs] def active(self, task_id): + """ + Check if a task is active (has not been called yet). + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True if a task is active (has not been called yet). False if + it is not (has been called) or if the task does not exist. + + """ + if task_id in self.tasks: + # if the task has not been run, cancel it + deferred = self.get_deferred(task_id) + return not (deferred and deferred.called) + else: + return False
+ +
[docs] def cancel(self, task_id): + """ + Stop a task from automatically executing. + This will not remove the task. + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True if the cancel completed successfully. + False if the cancel did not complete successfully. + + """ + if task_id in self.tasks: + # if the task has not been run, cancel it + d = self.get_deferred(task_id) + if d: # it is remotely possible for a task to not have a deferred + if d.called: + return False + else: # the callback has not been called yet. + d.cancel() + return True + else: # this task has no deferred instance + return False + else: + return False
+ +
[docs] def remove(self, task_id): + """ + Remove a task without executing it. + Deletes the instance of the task's deferred. + + Args: + task_id (int): an existing task ID. + + Returns: + bool: True if the removal completed successfully. + + """ + d = None + # delete the task from the tasks dictionary + if task_id in self.tasks: + # if the task has not been run, cancel it + self.cancel(task_id) + del self.tasks[task_id] # delete the task from the tasks dictionary + # remove the task from the persistent dictionary and ServerConfig + if task_id in self.to_save: + del self.to_save[task_id] + self.save() # remove from ServerConfig.objects + # delete the instance of the deferred + if d: + del d + return True
+ +
[docs] def clear(self, save=True, cancel=True): + """ + Clear all tasks. By default tasks are canceled and removed from the database as well. + + Args: + save=True (bool): Should changes to persistent tasks be saved to database. + cancel=True (bool): Cancel scheduled tasks before removing it from task handler. + + Returns: + True (bool): if the removal completed successfully. + + """ + if self.tasks: + for task_id in self.tasks.keys(): + if cancel: + self.cancel(task_id) + self.tasks = {} + if self.to_save: + self.to_save = {} + if save: + self.save() + return True
+ +
[docs] def call_task(self, task_id): + """ + Call the callback of a task. + Leave the task unaffected otherwise. + This does not use the task's deferred instance. + The only requirement is that the task exist in task handler. + + Args: + task_id (int): an existing task ID. + + Returns: + bool or any: Set to `False` if the task does not exist in task + handler. Otherwise it will be the return of the task's callback. + + """ + if task_id in self.tasks: + date, callback, args, kwargs, persistent, d = self.tasks.get(task_id) + else: # the task does not exist + return False + return callback(*args, **kwargs)
+ +
[docs] def do_task(self, task_id): + """ + Execute the task (call its callback). + If calling before timedelay cancel the deferred instance affliated to this task. + Remove the task from the dictionary of current tasks on a successful + callback. + + Args: + task_id (int): a valid task ID. + + Returns: + bool or any: Set to `False` if the task does not exist in task + handler. Otherwise it will be the return of the task's callback. + + """ + callback_return = False + if task_id in self.tasks: + date, callback, args, kwargs, persistent, d = self.tasks.get(task_id) + else: # the task does not exist + return False + if d: # it is remotely possible for a task to not have a deferred + if not d.called: # the task's deferred has not been called yet + d.cancel() # cancel the automated callback + else: # this task has no deferred, and should not be called + return False + callback_return = callback(*args, **kwargs) + self.remove(task_id) + return callback_return
+ +
[docs] def get_deferred(self, task_id): + """ + Return the instance of the deferred the task id is using. + + Args: + task_id (int): a valid task ID. + + Returns: + bool or deferred: An instance of a deferred or False if there is no task with the id. + None is returned if there is no deferred affiliated with this id. + + """ + if task_id in self.tasks: + return self.tasks[task_id][5] + else: + return None
+ +
[docs] def create_delays(self): + """ + Create the delayed tasks for the persistent tasks. + This method should be automatically called when Evennia starts. + + """ + now = datetime.now() + for task_id, (date, callback, args, kwargs, _, _) in self.tasks.items(): + self.tasks[task_id] = date, callback, args, kwargs, True, None + seconds = max(0, (date - now).total_seconds()) + d = deferLater(self.clock, seconds, self.do_task, task_id) + d.addErrback(handle_error) + # some tasks may complete before the deferred can be added + if self.tasks.get(task_id, False): + self.tasks[task_id] = date, callback, args, kwargs, True, d
+ + +# Create the soft singleton +TASK_HANDLER = TaskHandler() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/scripts/tickerhandler.html b/docs/latest/_modules/evennia/scripts/tickerhandler.html new file mode 100644 index 0000000000..c693f320e3 --- /dev/null +++ b/docs/latest/_modules/evennia/scripts/tickerhandler.html @@ -0,0 +1,748 @@ + + + + + + + + evennia.scripts.tickerhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.scripts.tickerhandler

+"""
+TickerHandler
+
+This implements an efficient Ticker which uses a subscription
+model to 'tick' subscribed objects at regular intervals.
+
+The ticker mechanism is used by importing and accessing
+the instantiated TICKER_HANDLER instance in this module. This
+instance is run by the server; it will save its status across
+server reloads and be started automaticall on boot.
+
+Example:
+
+```python
+    from evennia.scripts.tickerhandler import TICKER_HANDLER
+
+    # call tick myobj.at_tick(*args, **kwargs) every 15 seconds
+    TICKER_HANDLER.add(15, myobj.at_tick, *args, **kwargs)
+```
+
+You supply the interval to tick and a callable to call regularly with
+any extra args/kwargs. The callable should either be a stand-alone
+function in a module *or* the method on a *typeclassed* entity (that
+is, on an object that can be safely and stably returned from the
+database).  Functions that are dynamically created or sits on
+in-memory objects cannot be used by the tickerhandler (there is no way
+to reference them safely across reboots and saves).
+
+The handler will transparently set
+up and add new timers behind the scenes to tick at given intervals,
+using a TickerPool - all callables with the same interval will share
+the interval ticker.
+
+To remove:
+
+```python
+    TICKER_HANDLER.remove(15, myobj.at_tick)
+```
+
+Both interval and callable must be given since a single object can be subscribed
+to many different tickers at the same time. You can also supply `idstring`
+as an identifying string if you ever want to tick the callable at the same interval
+but with different arguments (args/kwargs are not used for identifying the ticker). There
+is also `persistent=False` if you don't want to make a ticker that don't survive a reload.
+If either or both `idstring` or `persistent` has been changed from their defaults, they
+must be supplied to the `TICKER_HANDLER.remove` call to properly identify the ticker
+to remove.
+
+The TickerHandler's functionality can be overloaded by modifying the
+Ticker class and then changing TickerPool and TickerHandler to use the
+custom classes
+
+```python
+class MyTicker(Ticker):
+    # [doing custom stuff]
+
+class MyTickerPool(TickerPool):
+    ticker_class = MyTicker
+class MyTickerHandler(TickerHandler):
+    ticker_pool_class = MyTickerPool
+```
+
+If one wants to duplicate TICKER_HANDLER's auto-saving feature in
+a  custom handler one can make a custom `AT_STARTSTOP_MODULE` entry to
+call the handler's `save()` and `restore()` methods when the server reboots.
+
+"""
+import inspect
+
+from django.core.exceptions import ObjectDoesNotExist
+from twisted.internet.defer import inlineCallbacks
+
+from evennia.scripts.scripts import ExtendedLoopingCall
+from evennia.server.models import ServerConfig
+from evennia.utils import inherits_from, variable_from_module
+from evennia.utils.dbserialize import dbserialize, dbunserialize, pack_dbobj
+from evennia.utils.logger import log_err, log_trace
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+
+
+_ERROR_ADD_TICKER = """TickerHandler: Tried to add an invalid ticker:
+{store_key}
+Ticker was not added."""
+
+_ERROR_ADD_TICKER_SUB_SECOND = """You are trying to add a ticker running faster
+than once per second. This is not supported and also probably not useful:
+Spamming messages to the user faster than once per second serves no purpose in
+a text-game, and if you want to update some property, consider doing so
+on-demand rather than using a ticker.
+"""
+
+
+
[docs]class Ticker(object): + """ + Represents a repeatedly running task that calls + hooks repeatedly. Overload `_callback` to change the + way it operates. + """ + + @inlineCallbacks + def _callback(self): + """ + This will be called repeatedly every `self.interval` seconds. + `self.subscriptions` contain tuples of (obj, args, kwargs) for + each subscribing object. + + If overloading, this callback is expected to handle all + subscriptions when it is triggered. It should not return + anything and should not traceback on poorly designed hooks. + The callback should ideally work under @inlineCallbacks so it + can yield appropriately. + + The _hook_key, which is passed down through the handler via + kwargs is used here to identify which hook method to call. + + """ + self._to_add = [] + self._to_remove = [] + self._is_ticking = True + for store_key, (args, kwargs) in self.subscriptions.items(): + callback = yield kwargs.pop("_callback", "at_tick") + obj = yield kwargs.pop("_obj", None) + try: + if callable(callback): + # call directly + yield callback(*args, **kwargs) + continue + # try object method + if not obj or not obj.pk: + # object was deleted between calls + self._to_remove.append(store_key) + continue + else: + yield _GA(obj, callback)(*args, **kwargs) + except ObjectDoesNotExist: + log_trace("Removing ticker.") + self._to_remove.append(store_key) + except Exception: + log_trace() + finally: + # make sure to re-store + kwargs["_callback"] = callback + kwargs["_obj"] = obj + # cleanup - we do this here to avoid changing the subscription dict while it loops + self._is_ticking = False + for store_key in self._to_remove: + self.remove(store_key) + for store_key, (args, kwargs) in self._to_add: + self.add(store_key, *args, **kwargs) + self._to_remove = [] + self._to_add = [] + +
[docs] def __init__(self, interval): + """ + Set up the ticker + + Args: + interval (int): The stepping interval. + + """ + self.interval = interval + self.subscriptions = {} + self._is_ticking = False + self._to_remove = [] + self._to_add = [] + # set up a twisted asynchronous repeat call + self.task = ExtendedLoopingCall(self._callback)
+ +
[docs] def validate(self, start_delay=None): + """ + Start/stop the task depending on how many subscribers we have + using it. + + Args: + start_delay (int, optional): Time to way before starting. + + """ + subs = self.subscriptions + if self.task.running: + if not subs: + self.task.stop() + elif subs: + self.task.start(self.interval, now=False, start_delay=start_delay)
+ +
[docs] def add(self, store_key, *args, **kwargs): + """ + Sign up a subscriber to this ticker. + Args: + store_key (str): Unique storage hash for this ticker subscription. + args (any, optional): Arguments to call the hook method with. + + Keyword Args: + _start_delay (int): If set, this will be + used to delay the start of the trigger instead of + `interval`. + + """ + if self._is_ticking: + # protects the subscription dict from + # updating while it is looping + self._to_add.append((store_key, (args, kwargs))) + else: + start_delay = kwargs.pop("_start_delay", None) + self.subscriptions[store_key] = (args, kwargs) + self.validate(start_delay=start_delay)
+ +
[docs] def remove(self, store_key): + """ + Unsubscribe object from this ticker + + Args: + store_key (str): Unique store key. + + """ + if self._is_ticking: + # this protects the subscription dict from + # updating while it is looping + self._to_remove.append(store_key) + else: + self.subscriptions.pop(store_key, False) + self.validate()
+ +
[docs] def stop(self): + """ + Kill the Task, regardless of subscriptions. + + """ + self.subscriptions = {} + self.validate()
+ + +
[docs]class TickerPool(object): + """ + This maintains a pool of + `evennia.scripts.scripts.ExtendedLoopingCall` tasks for calling + subscribed objects at given times. + + """ + + ticker_class = Ticker + +
[docs] def __init__(self): + """ + Initialize the pool. + + """ + self.tickers = {}
+ +
[docs] def add(self, store_key, *args, **kwargs): + """ + Add new ticker subscriber. + + Args: + store_key (str): Unique storage hash. + args (any, optional): Arguments to send to the hook method. + + """ + _, _, _, interval, _, _ = store_key + if not interval: + log_err(_ERROR_ADD_TICKER.format(store_key=store_key)) + return + + if interval not in self.tickers: + self.tickers[interval] = self.ticker_class(interval) + self.tickers[interval].add(store_key, *args, **kwargs)
+ +
[docs] def remove(self, store_key): + """ + Remove subscription from pool. + + Args: + store_key (str): Unique storage hash to remove + + """ + _, _, _, interval, _, _ = store_key + if interval in self.tickers: + self.tickers[interval].remove(store_key) + if not self.tickers[interval]: + del self.tickers[interval]
+ +
[docs] def stop(self, interval=None): + """ + Stop all scripts in pool. This is done at server reload since + restoring the pool will automatically re-populate the pool. + + Args: + interval (int, optional): Only stop tickers with this interval. + + """ + if interval and interval in self.tickers: + self.tickers[interval].stop() + else: + for ticker in self.tickers.values(): + ticker.stop()
+ + +
[docs]class TickerHandler(object): + """ + The Tickerhandler maintains a pool of tasks for subscribing + objects to various tick rates. The pool maintains creation + instructions and and re-applies them at a server restart. + + """ + + ticker_pool_class = TickerPool + +
[docs] def __init__(self, save_name="ticker_storage"): + """ + Initialize handler + + save_name (str, optional): The name of the ServerConfig + instance to store the handler state persistently. + + """ + self.ticker_storage = {} + self.save_name = save_name + self.ticker_pool = self.ticker_pool_class()
+ + def _get_callback(self, callback): + """ + Analyze callback and determine its consituents + + Args: + callback (function or method): This is either a stand-alone + function or class method on a typeclassed entitye (that is, + an entity that can be saved to the database). + Returns: + ret (tuple): This is a tuple of the form `(obj, path, callfunc)`, + where `obj` is the database object the callback is defined on + if it's a method (otherwise `None`) and vice-versa, `path` is + the python-path to the stand-alone function (`None` if a method). + The `callfunc` is either the name of the method to call or the + callable function object itself. + Raises: + TypeError: If the callback is of an unsupported type. + + """ + outobj, outpath, outcallfunc = None, None, None + if callable(callback): + if inspect.ismethod(callback): + outobj = callback.__self__ + outcallfunc = callback.__func__.__name__ + elif inspect.isfunction(callback): + outpath = "%s.%s" % (callback.__module__, callback.__name__) + outcallfunc = callback + else: + raise TypeError(f"{callback} is not a method or function.") + else: + raise TypeError(f"{callback} is not a callable function or method.") + + if outobj and not inherits_from(outobj, "evennia.typeclasses.models.TypedObject"): + raise TypeError( + f"{callback} is a method on a normal object - it must " + "be either a method on a typeclass, or a stand-alone function." + ) + + return outobj, outpath, outcallfunc + + def _store_key(self, obj, path, interval, callfunc, idstring="", persistent=True): + """ + Tries to create a store_key for the object. + + Args: + obj (Object, tuple or None): Subscribing object if any. If a tuple, this is + a packed_obj tuple from dbserialize. + path (str or None): Python-path to callable, if any. + interval (int): Ticker interval. Floats will be converted to + nearest lower integer value. + callfunc (callable or str): This is either the callable function or + the name of the method to call. Note that the callable is never + stored in the key; that is uniquely identified with the python-path. + idstring (str, optional): Additional separator between + different subscription types. + persistent (bool, optional): If this ticker should survive a system + shutdown or not. + + Returns: + store_key (tuple): A tuple `(packed_obj, methodname, outpath, interval, + idstring, persistent)` that uniquely identifies the + ticker. Here, `packed_obj` is the unique string representation of the + object or `None`. The `methodname` is the string name of the method on + `packed_obj` to call, or `None` if `packed_obj` is unset. `path` is + the Python-path to a non-method callable, or `None`. Finally, `interval` + `idstring` and `persistent` are integers, strings and bools respectively. + + """ + if interval < 1: + raise RuntimeError(_ERROR_ADD_TICKER_SUB_SECOND) + + interval = int(interval) + persistent = bool(persistent) + packed_obj = pack_dbobj(obj) + methodname = callfunc if callfunc and isinstance(callfunc, str) else None + outpath = path if path and isinstance(path, str) else None + return (packed_obj, methodname, outpath, interval, idstring, persistent) + +
[docs] def save(self): + """ + Save ticker_storage as a serialized string into a temporary + ServerConf field. Whereas saving is done on the fly, if called + by server when it shuts down, the current timer of each ticker + will be saved so it can start over from that point. + + """ + if self.ticker_storage: + # get the current times so the tickers can be restarted with a delay later + start_delays = dict( + (interval, ticker.task.next_call_time()) + for interval, ticker in self.ticker_pool.tickers.items() + ) + + to_save = {} + + # remove any subscription that lost its object and update the timers for the tickers + for store_key, (args, kwargs) in self.ticker_storage.items(): + # unpack the store_key to reference its parts + packedobj, callfunc, path, interval, idstring, persistent = store_key + # verify that there's a valid obj+method or function path + if ( + callfunc + and ("_obj" in kwargs and kwargs["_obj"].pk) + and hasattr(kwargs["_obj"], callfunc) + ) or path: + # this is a mutable, so it's updated in-place in ticker_storage + kwargs["_start_delay"] = start_delays.get(interval, None) + to_save[store_key] = (args, kwargs) + ServerConfig.objects.conf(key=self.save_name, value=dbserialize(to_save)) + else: + # make sure we have nothing lingering in the database + ServerConfig.objects.conf(key=self.save_name, delete=True)
+ +
[docs] def restore(self, server_reload=True): + """ + Restore ticker_storage from database and re-initialize the + handler from storage. This is triggered by the server at + restart. + + Args: + server_reload (bool, optional): If this is False, it means + the server went through a cold reboot and all + non-persistent tickers must be killed. + + """ + # load stored command instructions and use them to re-initialize handler + restored_tickers = ServerConfig.objects.conf(key=self.save_name) + if restored_tickers: + # the dbunserialize will convert all serialized dbobjs to real objects + + restored_tickers = dbunserialize(restored_tickers) + self.ticker_storage = {} + for store_key, (args, kwargs) in restored_tickers.items(): + try: + # at this point obj is the actual object (or None) due to how + # the dbunserialize works + obj, callfunc, path, interval, idstring, persistent = store_key + if not persistent and not server_reload: + # this ticker will not be restarted + continue + if isinstance(callfunc, str) and not obj: + # methods must have an existing object + continue + # we must rebuild the store_key here since obj must not be + # stored as the object itself for the store_key to be hashable. + store_key = self._store_key(obj, path, interval, callfunc, idstring, persistent) + + if obj and callfunc: + kwargs["_callback"] = callfunc + kwargs["_obj"] = obj + elif path: + modname, varname = path.rsplit(".", 1) + callback = variable_from_module(modname, varname) + kwargs["_callback"] = callback + kwargs["_obj"] = None + else: + # Neither object nor path - discard this ticker + log_err("Tickerhandler: Removing malformed ticker: %s" % str(store_key)) + continue + except Exception: + # this suggests a malformed save or missing objects + log_trace("Tickerhandler: Removing malformed ticker: %s" % str(store_key)) + continue + # if we get here we should create a new ticker + self.ticker_storage[store_key] = (args, kwargs) + self.ticker_pool.add(store_key, *args, **kwargs)
+ +
[docs] def add(self, interval=60, callback=None, idstring="", persistent=True, *args, **kwargs): + """ + Add subscription to tickerhandler + + Args: + + *args: Will be passed into the callback every time it's called. This must be + data possible to pickle. + + Keyword Args: + interval (int): Interval in seconds between calling + `callable(*args, **kwargs)` + callable (callable function or method): This + should either be a stand-alone function or a method on a + typeclassed entity (that is, one that can be saved to the + database). + idstring (str): Identifier for separating + this ticker-subscription from others with the same + interval. Allows for managing multiple calls with + the same time interval and callback. + persistent (bool): A ticker will always survive + a server reload. If this is unset, the ticker will be + deleted by a server shutdown. + **kwargs Will be passed into the callback every time it is called. + This must be data possible to pickle. + + Returns: + store_key (tuple): The immutable store-key for this ticker. This can + be stored and passed into `.remove(store_key=store_key)` later to + easily stop this ticker later. + + Notes: + The callback will be identified by type and stored either as + as combination of serialized database object + methodname or + as a python-path to the module + funcname. These strings will + be combined iwth `interval` and `idstring` to define a + unique storage key for saving. These must thus all be supplied + when wanting to modify/remove the ticker later. + + """ + obj, path, callfunc = self._get_callback(callback) + store_key = self._store_key(obj, path, interval, callfunc, idstring, persistent) + kwargs["_obj"] = obj + kwargs["_callback"] = callfunc # either method-name or callable + self.ticker_storage[store_key] = (args, kwargs) + self.ticker_pool.add(store_key, *args, **kwargs) + self.save() + return store_key
+ +
[docs] def remove(self, interval=60, callback=None, idstring="", persistent=True, store_key=None): + """ + Remove ticker subscription from handler. + + Keyword Args: + interval (int): Interval of ticker to remove. + callback (callable function or method): Either a function or + the method of a typeclassed object. + idstring (str): Identifier id of ticker to remove. + persistent (bool): Whether this ticker is persistent or not. + store_key (str): If given, all other kwargs are ignored and only + this is used to identify the ticker. + + Raises: + KeyError: If no matching ticker was found to remove. + + Notes: + The store-key is normally built from the interval/callback/idstring/persistent values; + but if the `store_key` is explicitly given, this is used instead. + + """ + if isinstance(callback, int): + raise RuntimeError( + "TICKER_HANDLER.remove has changed: " + "the interval is now the first argument, callback the second." + ) + if not store_key: + obj, path, callfunc = self._get_callback(callback) + store_key = self._store_key(obj, path, interval, callfunc, idstring, persistent) + to_remove = self.ticker_storage.pop(store_key, None) + if to_remove: + self.ticker_pool.remove(store_key) + self.save() + else: + raise KeyError(f"No Ticker was found matching the store-key {store_key}.")
+ +
[docs] def clear(self, interval=None): + """ + Stop/remove tickers from handler. + + Args: + interval (int, optional): Only stop tickers with this interval. + + Notes: + This is the only supported way to kill tickers related to + non-db objects. + + """ + self.ticker_pool.stop(interval) + if interval: + self.ticker_storage = dict( + (store_key, store_value) + for store_key, store_value in self.ticker_storage.items() + if store_key[3] != interval + ) + else: + self.ticker_storage = {} + self.save()
+ +
[docs] def all(self, interval=None): + """ + Get all ticker subscriptions. + + Args: + interval (int, optional): Limit match to tickers with this interval. + + Returns: + list or dict: If `interval` was given, this is a list of tickers using that interval. + If `interval` was *not* given, this is a dict + `{interval1: [ticker1, ticker2, ...], ...}` + + """ + if interval is None: + # return dict of all, ordered by interval + return dict( + (interval, ticker.subscriptions) + for interval, ticker in self.ticker_pool.tickers.items() + ) + else: + # get individual interval + ticker = self.ticker_pool.tickers.get(interval, None) + if ticker: + return {interval: ticker.subscriptions} + return None
+ +
[docs] def all_display(self): + """ + Get all tickers on an easily displayable form. + + Returns: + tickers (dict): A list of all storekeys + + """ + store_keys = [] + for ticker in self.ticker_pool.tickers.values(): + for ( + (objtup, callfunc, path, interval, idstring, persistent), + (args, kwargs), + ) in ticker.subscriptions.items(): + store_keys.append( + (kwargs.get("_obj", None), callfunc, path, interval, idstring, persistent) + ) + return store_keys
+ + +# main tickerhandler +TICKER_HANDLER = TickerHandler() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/amp_client.html b/docs/latest/_modules/evennia/server/amp_client.html new file mode 100644 index 0000000000..75227868a6 --- /dev/null +++ b/docs/latest/_modules/evennia/server/amp_client.html @@ -0,0 +1,359 @@ + + + + + + + + evennia.server.amp_client — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.amp_client

+"""
+The Evennia Server service acts as an AMP-client when talking to the
+Portal. This module sets up the Client-side communication.
+
+"""
+
+import os
+
+from django.conf import settings
+from twisted.internet import protocol
+
+import evennia
+from evennia.server.portal import amp
+from evennia.utils import logger
+from evennia.utils.utils import class_from_module
+
+
+
[docs]class AMPClientFactory(protocol.ReconnectingClientFactory): + """ + This factory creates an instance of an AMP client connection. This handles communication from + the be the Evennia 'Server' service to the 'Portal'. The client will try to auto-reconnect on a + connection error. + + """ + + # Initial reconnect delay in seconds. + initialDelay = 1 + factor = 1.5 + maxDelay = 1 + noisy = False + +
[docs] def __init__(self, server): + """ + Initializes the client factory. + + Args: + server (server): server instance. + + """ + self.server = server + self.protocol = class_from_module(settings.AMP_CLIENT_PROTOCOL_CLASS) + self.maxDelay = 10 + # not really used unless connecting to multiple servers, but + # avoids having to check for its existence on the protocol + self.broadcasts = []
+ +
[docs] def startedConnecting(self, connector): + """ + Called when starting to try to connect to the Portal AMP server. + + Args: + connector (Connector): Twisted Connector instance representing + this connection. + + """ + pass
+ +
[docs] def buildProtocol(self, addr): + """ + Creates an AMPProtocol instance when connecting to the AMP server. + + Args: + addr (str): Connection address. Not used. + + """ + self.resetDelay() + self.server.amp_protocol = AMPServerClientProtocol() + self.server.amp_protocol.factory = self + return self.server.amp_protocol
+ +
[docs] def clientConnectionLost(self, connector, reason): + """ + Called when the AMP connection to the MUD server is lost. + + Args: + connector (Connector): Twisted Connector instance representing + this connection. + reason (str): Eventual text describing why connection was lost. + + """ + logger.log_info("Server disconnected from the portal.") + protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason)
+ +
[docs] def clientConnectionFailed(self, connector, reason): + """ + Called when an AMP connection attempt to the MUD server fails. + + Args: + connector (Connector): Twisted Connector instance representing + this connection. + reason (str): Eventual text describing why connection failed. + + """ + logger.log_msg("Attempting to reconnect to Portal ...") + protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
+ + +
[docs]class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): + """ + This protocol describes the Server service (acting as an AMP-client)'s communication with the + Portal (which acts as the AMP-server) + + """ + + # sending AMP data + +
[docs] def connectionMade(self): + """ + Called when a new connection is established. + + """ + # print("AMPClient new connection {}".format(self)) + info_dict = self.factory.server.get_info_dict() + super().connectionMade() + # first thing we do is to request the Portal to sync all sessions + # back with the Server side. We also need the startup mode (reload, reset, shutdown) + self.send_AdminServer2Portal( + amp.DUMMYSESSION, operation=amp.PSYNC, spid=os.getpid(), info_dict=info_dict + ) + # run the intial setup if needed + self.factory.server.run_initial_setup()
+ +
[docs] def data_to_portal(self, command, sessid, **kwargs): + """ + Send data across the wire to the Portal + + Args: + command (AMP Command): A protocol send command. + sessid (int): A unique Session id. + kwargs (any): Any data to pickle into the command. + + Returns: + deferred (deferred or None): A deferred with an errback. + + Notes: + Data will be sent across the wire pickled as a tuple + (sessid, kwargs). + + """ + # print("server data_to_portal: {}, {}, {}".format(command, sessid, kwargs)) + return self.callRemote(command, packed_data=amp.dumps((sessid, kwargs))).addErrback( + self.errback, command.key + )
+ +
[docs] def send_MsgServer2Portal(self, session, **kwargs): + """ + Access method - executed on the Server for sending data + to Portal. + + Args: + session (Session): Unique Session. + kwargs (any, optiona): Extra data. + + """ + return self.data_to_portal(amp.MsgServer2Portal, session.sessid, **kwargs)
+ +
[docs] def send_AdminServer2Portal(self, session, operation="", **kwargs): + """ + Administrative access method called by the Server to send an + instruction to the Portal. + + Args: + session (Session): Session. + operation (char, optional): Identifier for the server + operation, as defined by the global variables in + `evennia/server/amp.py`. + kwargs (dict, optional): Data going into the adminstrative. + + """ + return self.data_to_portal( + amp.AdminServer2Portal, session.sessid, operation=operation, **kwargs + )
+ + # receiving AMP data + +
[docs] @amp.MsgStatus.responder + def server_receive_status(self, question): + return {"status": "OK"}
+ + @amp.MsgPortal2Server.responder + @amp.catch_traceback + def server_receive_msgportal2server(self, packed_data): + """ + Receives message arriving to server. This method is executed + on the Server. + + Args: + packed_data (str): Data to receive (a pickled tuple (sessid,kwargs)) + + """ + sessid, kwargs = self.data_in(packed_data) + session = evennia.SERVER_SESSION_HANDLER.get(sessid, None) + if session: + evennia.SERVER_SESSION_HANDLER.data_in(session, **kwargs) + return {} + + @amp.AdminPortal2Server.responder + @amp.catch_traceback + def server_receive_adminportal2server(self, packed_data): + """ + Receives admin data from the Portal (allows the portal to + perform admin operations on the server). This is executed on + the Server. + + Args: + packed_data (str): Incoming, pickled data. + + """ + sessid, kwargs = self.data_in(packed_data) + operation = kwargs.pop("operation", "") + + if operation == amp.PCONN: # portal_session_connect + # create a new session and sync it + evennia.SERVER_SESSION_HANDLER.portal_connect(kwargs.get("sessiondata")) + + elif operation == amp.PCONNSYNC: # portal_session_sync + evennia.SERVER_SESSION_HANDLER.portal_session_sync(kwargs.get("sessiondata")) + + elif operation == amp.PDISCONN: # portal_session_disconnect + # session closed from portal sid + session = evennia.SERVER_SESSION_HANDLER.get(sessid) + if session: + evennia.SERVER_SESSION_HANDLER.portal_disconnect(session) + + elif operation == amp.PDISCONNALL: # portal_disconnect_all + # portal orders all sessions to close + evennia.SERVER_SESSION_HANDLER.portal_disconnect_all() + + elif operation == amp.PSYNC: # portal_session_sync + # force a resync of sessions from the portal side. This happens on + # first server-connect. + server_restart_mode = kwargs.get("server_restart_mode", "shutdown") + evennia.EVENNIA_SERVER_SERVICE.run_init_hooks(server_restart_mode) + evennia.SERVER_SESSION_HANDLER.portal_sessions_sync(kwargs.get("sessiondata")) + evennia.SERVER_SESSION_HANDLER.portal_start_time = kwargs.get("portal_start_time") + + elif operation == amp.SRELOAD: # server reload + # shut down in reload mode + evennia.SERVER_SESSION_HANDLER.all_sessions_portal_sync() + evennia.EVENNIA_SERVER_SERVICE.shutdown(mode="reload") + + elif operation == amp.SRESET: + # shut down in reset mode + evennia.SERVER_SESSION_HANDLER.all_sessions_portal_sync() + evennia.EVENNIA_SERVER_SERVICE.shutdown(mode="reset") + + elif operation == amp.SSHUTD: # server shutdown + # shutdown in stop mode + evennia.EVENNIA_SERVER_SERVICE.shutdown(mode="shutdown") + + else: + raise Exception("operation %(op)s not recognized." % {"op": operation}) + + return {}
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/connection_wizard.html b/docs/latest/_modules/evennia/server/connection_wizard.html new file mode 100644 index 0000000000..0c5d6c0ac1 --- /dev/null +++ b/docs/latest/_modules/evennia/server/connection_wizard.html @@ -0,0 +1,626 @@ + + + + + + + + evennia.server.connection_wizard — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.connection_wizard

+"""
+Link Evennia to external resources (wizard plugin for evennia_launcher)
+
+"""
+import pprint
+import sys
+from os import path
+
+from django.conf import settings
+
+from evennia.utils.utils import list_to_string, mod_import
+
+
+
[docs]class ConnectionWizard(object): +
[docs] def __init__(self): + self.data = {} + self.prev_node = None
+ +
[docs] def display(self, text): + "Show text" + print(text)
+ +
[docs] def ask_continue(self): + "'Press return to continue'-prompt" + input(" (Press return to continue)")
+ +
[docs] def ask_node(self, options, prompt="Enter choice: ", default=None): + """ + Retrieve options and jump to different menu nodes + + Args: + options (dict): Node options on the form {key: (desc, callback), } + prompt (str, optional): Question to ask + default (str, optional): Default value to use if user hits return. + + """ + + opt_txt = "\n".join(f" {key}: {desc}" for key, (desc, _, _) in options.items()) + self.display(opt_txt + "\n") + + while True: + resp = input(prompt).strip() + + if not resp: + if default: + resp = str(default) + + if resp.lower() in options: + # self.display(f" Selected '{resp}'.") + desc, callback, kwargs = options[resp.lower()] + callback(self, **kwargs) + elif resp.lower() in ("quit", "q"): + sys.exit() + elif resp: + # input, but nothing was recognized + self.display(" Choose one of: {}".format(list_to_string(list(options))))
+ +
[docs] def ask_yesno(self, prompt, default="yes"): + """ + Ask a yes/no question inline. + + Keyword Args: + prompt (str): The prompt to ask. + default (str): "yes" or "no", used if pressing return. + Returns: + reply (str): Either 'yes' or 'no'. + + """ + prompt = prompt + (" [Y]/N? " if default == "yes" else " Y/[N]? ") + + while True: + resp = input(prompt).lstrip().lower() + if not resp: + resp = default.lower() + if resp in ("yes", "y"): + self.display(" Answered Yes.") + return "yes" + elif resp in ("no", "n"): + self.display(" Answered No.") + return "no" + elif resp.lower() in ("quit", "q"): + sys.exit()
+ +
[docs] def ask_choice(self, prompt=" > ", options=None, default=None): + """ + Ask multiple-choice question, get response inline. + + Keyword Args: + prompt (str): Input prompt. + options (list): List of options. Will be indexable by sequence number 1... + default (int): The list index+1 of the default choice, if any + Returns: + reply (str): The answered reply. + + """ + opt_txt = "\n".join(f" {ind + 1}: {desc}" for ind, desc in enumerate(options)) + self.display(opt_txt + "\n") + + while True: + resp = input(prompt).strip() + + if not resp: + if default: + return options[int(default)] + if resp.lower() in ("quit", "q"): + sys.exit() + if resp.isdigit(): + resp = int(resp) - 1 + if 0 <= resp < len(options): + selection = options[resp] + self.display(f" Selected '{selection}'.") + return selection + self.display(" Select one of the given options.")
+ +
[docs] def ask_input(self, prompt=" > ", default=None, validator=None): + """ + Get arbitrary input inline. + + Keyword Args: + prompt (str): The display prompt. + default (str): If empty input, use this. + validator (callable): If given, the input will be passed + into this callable. It should return True unless validation + fails (and is expected to echo why if so). + + Returns: + inp (str): The input given, or default. + + """ + while True: + resp = input(prompt).strip() + + if not resp and default: + resp = str(default) + + if resp.lower() in ("q", "quit"): + sys.exit() + + if resp.lower() == "none": + resp = "" + + if validator and not validator(resp): + continue + + ok = input("\n Leave blank? [Y]/N: ") + if ok.lower() in ("n", "no"): + continue + elif ok.lower() in ("q", "quit"): + sys.exit() + return resp + + if validator and not validator(resp): + continue + + self.display(resp) + ok = input("\n Is this correct? [Y]/N: ") + if ok.lower() in ("n", "no"): + continue + elif ok.lower() in ("q", "quit"): + sys.exit() + return resp
+ + +
[docs]def node_start(wizard): + text = """ + This wizard helps to attach your Evennia server to external networks. It + will save to a file `server/conf/connection_settings.py` that will be + imported from the bottom of your game settings file. Once generated you can + also modify that file directly. + + Make sure you have at least started the game once before continuing! + + Use `quit` at any time to abort and throw away unsaved changes. + """ + options = { + "1": ( + "Add your game to the Evennia game index (also for closed-dev games)", + node_game_index_start, + {}, + ), + "2": ("MSSP setup (for mud-list crawlers)", node_mssp_start, {}), + # "3": ("Add Grapevine listing", + # node_grapevine_start, {}), + # "4": ("Add IRC link", + # "node_irc_start", {}), + # "5" ("Add RSS feed", + # "node_rss_start", {}), + "s": ("View and (optionally) Save created settings", node_view_and_apply_settings, {}), + "q": ("Quit", lambda *args: sys.exit(), {}), + } + + wizard.display(text) + wizard.ask_node(options)
+ + +# Evennia game index + + +
[docs]def node_game_index_start(wizard, **kwargs): + text = """ + The Evennia game index (http://games.evennia.com) lists both active Evennia + games as well as games in various stages of development. + + You can put up your game in the index also if you are not (yet) open for + players. If so, put 'None' for the connection details - you are just telling + us that you are out there, making us excited about your upcoming game! + + Please check the listing online first to see that your exact game name is + not colliding with an existing game-name in the list (be nice!). + """ + + wizard.display(text) + if wizard.ask_yesno("Continue adding/editing an Index entry?") == "yes": + node_game_index_fields(wizard) + else: + node_start(wizard)
+ + +
[docs]def node_game_index_fields(wizard, status=None): + # reset the listing if needed + if not hasattr(wizard, "game_index_listing"): + wizard.game_index_listing = settings.GAME_INDEX_LISTING + + # game status + + status_default = wizard.game_index_listing["game_status"] + text = f""" + What is the status of your game? + - pre-alpha: a game in its very early stages, mostly unfinished or unstarted + - alpha: a working concept, probably lots of bugs and incomplete features + - beta: a working game, but expect bugs and changing features + - launched: a full, working game (that may still be expanded upon and improved later) + + Current value (return to keep): + {status_default} + """ + + options = ["pre-alpha", "alpha", "beta", "launched"] + + wizard.display(text) + wizard.game_index_listing["game_status"] = wizard.ask_choice("Select one: ", options) + + # game name + + name_default = settings.SERVERNAME + text = f""" + Your game's name should usually be the same as `settings.SERVERNAME`, but + you can set it to something else here if you want. + + Current value: + {name_default} + """ + + def name_validator(inp): + tmax = 80 + tlen = len(inp) + if tlen > tmax: + print(f"The name must be shorter than {tmax} characters (was {tlen}).") + wizard.ask_continue() + return False + return True + + wizard.display(text) + wizard.game_index_listing["game_name"] = wizard.ask_input( + default=name_default, validator=name_validator + ) + + # short desc + + sdesc_default = wizard.game_index_listing.get("short_description", None) + + text = f""" + Enter a short description of your game. Make it snappy and interesting! + This should be at most one or two sentences (255 characters) to display by + '{settings.SERVERNAME}' in the main game list. Line breaks will be ignored. + + Current value: + {sdesc_default} + """ + + def sdesc_validator(inp): + tmax = 255 + tlen = len(inp) + if tlen > tmax: + print(f"The short desc must be shorter than {tmax} characters (was {tlen}).") + wizard.ask_continue() + return False + return True + + wizard.display(text) + wizard.game_index_listing["short_description"] = wizard.ask_input( + default=sdesc_default, validator=sdesc_validator + ) + + # long desc + + long_default = wizard.game_index_listing.get("long_description", None) + + text = f""" + Enter a longer, full-length description. This will be shown when clicking + on your game's listing. You can use \\n to create line breaks and may use + Markdown formatting like *bold*, _italic_, [linkname](http://link) etc. + + Current value: + {long_default} + """ + + wizard.display(text) + wizard.game_index_listing["long_description"] = wizard.ask_input(default=long_default) + + # listing contact + + listing_default = wizard.game_index_listing.get("listing_contact", None) + text = f""" + Enter a listing email-contact. This will not be visible in the listing, but + allows us to get in touch with you should there be some listing issue (like + a name collision) or some bug with the listing (us actually using this is + likely to be somewhere between super-rarely and never). + + Current value: + {listing_default} + """ + + def contact_validator(inp): + if not inp or "@" not in inp: + print("This should be an email and cannot be blank.") + wizard.ask_continue() + return False + return True + + wizard.display(text) + wizard.game_index_listing["listing_contact"] = wizard.ask_input( + default=listing_default, validator=contact_validator + ) + + # telnet hostname + + hostname_default = wizard.game_index_listing.get("telnet_hostname", None) + text = f""" + Enter the hostname to which third-party telnet mud clients can connect to + your game. This would be the name of the server your game is hosted on, + like `coolgame.games.com`, or `mygreatgame.se`. + + Write 'None' if you are not offering public telnet connections at this time. + + Current value: + {hostname_default} + """ + + wizard.display(text) + wizard.game_index_listing["telnet_hostname"] = wizard.ask_input(default=hostname_default) + + # telnet port + + port_default = wizard.game_index_listing.get("telnet_port", None) + text = f""" + Enter the main telnet port. The Evennia default is 4000. You can change + this with the TELNET_PORTS server setting. + + Write 'None' if you are not offering public telnet connections at this time. + + Current value: + {port_default} + """ + + wizard.display(text) + wizard.game_index_listing["telnet_port"] = wizard.ask_input(default=port_default) + + # website + + website_default = wizard.game_index_listing.get("game_website", None) + text = f""" + Evennia is its own web server and runs your game's website. Enter the + URL of the website here, like http://yourwebsite.com, here. + + Write 'None' if you are not offering a publicly visible website at this time. + + Current value: + {website_default} + """ + + wizard.display(text) + wizard.game_index_listing["game_website"] = wizard.ask_input(default=website_default) + + # webclient + + webclient_default = wizard.game_index_listing.get("web_client_url", None) + text = f""" + Evennia offers its own native webclient. Normally it will be found from the + game homepage at something like http://yourwebsite.com/webclient. Enter + your specific URL here (when clicking this link you should launch into the + web client) + + Write 'None' if you don't want to list a publicly accessible webclient. + + Current value: + {webclient_default} + """ + + wizard.display(text) + wizard.game_index_listing["web_client_url"] = wizard.ask_input(default=webclient_default) + + if not ( + wizard.game_index_listing.get("web_client_url") + or (wizard.game_index_listing.get("telnet_host")) + ): + wizard.display( + "\nNote: You have not specified any connection options. This means " + "your game \nwill be marked as being in 'closed development' in " + "the index." + ) + + wizard.display("\nDon't forget to inspect and save your changes.") + + node_start(wizard)
+ + +# MSSP + + +
[docs]def node_mssp_start(wizard): + mssp_module = mod_import(settings.MSSP_META_MODULE or "server.conf.mssp") + try: + filename = mssp_module.__file__ + except AttributeError: + filename = "server/conf/mssp.py" + + text = f""" + MSSP (Mud Server Status Protocol) has a vast amount of options so it must + be modified outside this wizard by directly editing its config file here: + + '{filename}' + + MSSP allows traditional online MUD-listing sites/crawlers to continuously + monitor your game and list information about it. Some of this, like active + player-count, Evennia will automatically add for you, whereas most fields + you need to set manually. + + To use MSSP you should generally have a publicly open game that external + players can connect to. You also need to register at a MUD listing site to + tell them to crawl your game. + """ + + wizard.display(text) + wizard.ask_continue() + node_start(wizard)
+ + +# Admin + + +def _save_changes(wizard): + """ + Perform the save + """ + + # add import statement to settings file + import_stanza = "from .connection_settings import *" + setting_module = mod_import("server.conf.settings") + with open(setting_module.__file__, "r+") as f: + txt = f.read() # moves pointer to end of file + if import_stanza not in txt: + # add to the end of the file + f.write( + "\n\n" + "try:\n" + " # Created by the `evennia connections` wizard\n" + f" {import_stanza}\n" + "except ImportError:\n" + " pass" + ) + + connect_settings_file = path.join(settings.GAME_DIR, "server", "conf", "connection_settings.py") + with open(connect_settings_file, "w") as f: + f.write( + "# This file is auto-generated by the `evennia connections` wizard.\n" + "# Don't edit manually, your changes will be overwritten.\n\n" + ) + + f.write(wizard.save_output) + wizard.display(f"saving to {connect_settings_file} ...") + + +
[docs]def node_view_and_apply_settings(wizard): + """ + Inspect and save the data gathered in the other nodes + + """ + pp = pprint.PrettyPrinter(indent=4) + saves = False + + # game index + game_index_save_text = "" + game_index_listing = ( + wizard.game_index_listing if hasattr(wizard, "game_index_listing") else None + ) + if not game_index_listing and settings.GAME_INDEX_ENABLED: + game_index_listing = settings.GAME_INDEX_LISTING + if game_index_listing: + game_index_save_text = ( + "GAME_INDEX_ENABLED = True\n" + "GAME_INDEX_LISTING = \\\n" + pp.pformat(game_index_listing) + ) + saves = True + else: + game_index_save_text = "# No Game Index settings found." + + # potentially add other wizards in the future + text = game_index_save_text + + wizard.display(f"Settings to save:\n\n{text}") + + if saves: + if wizard.ask_yesno("\nDo you want to save these settings?") == "yes": + wizard.save_output = text + _save_changes(wizard) + wizard.display("... saved!\nThe changes will apply after you reload your server.") + else: + wizard.display("... cancelled.") + wizard.ask_continue() + node_start(wizard)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/deprecations.html b/docs/latest/_modules/evennia/server/deprecations.html new file mode 100644 index 0000000000..d2c3dc7519 --- /dev/null +++ b/docs/latest/_modules/evennia/server/deprecations.html @@ -0,0 +1,286 @@ + + + + + + + + evennia.server.deprecations — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.deprecations

+"""
+This module contains historical deprecations that the Evennia launcher
+checks for.
+
+These all print to the terminal.
+"""
+import os
+
+
+
[docs]def check_errors(settings): + """ + Check for deprecations that are critical errors and should stop + the launcher. + + Args: + settings (Settings): The Django settings file + + Raises: + DeprecationWarning if a critical deprecation is found. + + """ + deprstring = ( + "settings.%s should be renamed to %s. If defaults are used, " + "their path/classname must be updated " + "(see evennia/settings_default.py)." + ) + if hasattr(settings, "CMDSET_DEFAULT"): + raise DeprecationWarning(deprstring % ("CMDSET_DEFAULT", "CMDSET_CHARACTER")) + if hasattr(settings, "CMDSET_OOC"): + raise DeprecationWarning(deprstring % ("CMDSET_OOC", "CMDSET_ACCOUNT")) + if settings.WEBSERVER_ENABLED and not isinstance(settings.WEBSERVER_PORTS[0], tuple): + raise DeprecationWarning( + "settings.WEBSERVER_PORTS must be on the form [(proxyport, serverport), ...]" + ) + if hasattr(settings, "BASE_COMM_TYPECLASS"): + raise DeprecationWarning(deprstring % ("BASE_COMM_TYPECLASS", "BASE_CHANNEL_TYPECLASS")) + if hasattr(settings, "COMM_TYPECLASS_PATHS"): + raise DeprecationWarning(deprstring % ("COMM_TYPECLASS_PATHS", "CHANNEL_TYPECLASS_PATHS")) + if hasattr(settings, "CHARACTER_DEFAULT_HOME"): + raise DeprecationWarning( + "settings.CHARACTER_DEFAULT_HOME should be renamed to " + "DEFAULT_HOME. See also settings.START_LOCATION " + "(see evennia/settings_default.py)." + ) + deprstring = ( + "settings.%s is now merged into settings.TYPECLASS_PATHS. Update your settings file." + ) + if hasattr(settings, "OBJECT_TYPECLASS_PATHS"): + raise DeprecationWarning(deprstring % "OBJECT_TYPECLASS_PATHS") + if hasattr(settings, "SCRIPT_TYPECLASS_PATHS"): + raise DeprecationWarning(deprstring % "SCRIPT_TYPECLASS_PATHS") + if hasattr(settings, "ACCOUNT_TYPECLASS_PATHS"): + raise DeprecationWarning(deprstring % "ACCOUNT_TYPECLASS_PATHS") + if hasattr(settings, "CHANNEL_TYPECLASS_PATHS"): + raise DeprecationWarning(deprstring % "CHANNEL_TYPECLASS_PATHS") + if hasattr(settings, "SEARCH_MULTIMATCH_SEPARATOR"): + raise DeprecationWarning( + "settings.SEARCH_MULTIMATCH_SEPARATOR was replaced by " + "SEARCH_MULTIMATCH_REGEX and SEARCH_MULTIMATCH_TEMPLATE. " + "Update your settings file (see evennia/settings_default.py " + "for more info)." + ) + depstring = ( + "settings.{} was renamed to {}. Update your settings file (the FuncParser " + "replaces and generalizes that which inlinefuncs used to do)." + ) + if hasattr(settings, "INLINEFUNC_ENABLED"): + raise DeprecationWarning( + depstring.format( + "settings.INLINEFUNC_ENABLED", "FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED" + ) + ) + if hasattr(settings, "INLINEFUNC_STACK_MAXSIZE"): + raise DeprecationWarning( + depstring.format("settings.INLINEFUNC_STACK_MAXSIZE", "FUNCPARSER_MAX_NESTING") + ) + if hasattr(settings, "INLINEFUNC_MODULES"): + raise DeprecationWarning( + depstring.format("settings.INLINEFUNC_MODULES", "FUNCPARSER_OUTGOING_MESSAGES_MODULES") + ) + if hasattr(settings, "PROTFUNC_MODULES"): + raise DeprecationWarning( + depstring.format("settings.PROTFUNC_MODULES", "FUNCPARSER_PROTOTYPE_VALUE_MODULES") + ) + + gametime_deprecation = ( + "The settings TIME_SEC_PER_MIN, TIME_MIN_PER_HOUR," + "TIME_HOUR_PER_DAY, TIME_DAY_PER_WEEK, \n" + "TIME_WEEK_PER_MONTH and TIME_MONTH_PER_YEAR " + "are no longer supported. Remove them from your " + "settings file to continue.\nIf you want to use " + "and manipulate these time units, the tools from utils.gametime " + "are now found in contrib/convert_gametime.py instead." + ) + if any( + hasattr(settings, value) + for value in ( + "TIME_SEC_PER_MIN", + "TIME_MIN_PER_HOUR", + "TIME_HOUR_PER_DAY", + "TIME_DAY_PER_WEEK", + "TIME_WEEK_PER_MONTH", + "TIME_MONTH_PER_YEAR", + ) + ): + raise DeprecationWarning(gametime_deprecation) + + game_directory_deprecation = ( + "The setting GAME_DIRECTORY_LISTING was removed. It must be " + "renamed to GAME_INDEX_LISTING instead." + ) + if hasattr(settings, "GAME_DIRECTORY_LISTING"): + raise DeprecationWarning(game_directory_deprecation) + + chan_connectinfo = settings.CHANNEL_CONNECTINFO + if chan_connectinfo is not None and not isinstance(chan_connectinfo, dict): + raise DeprecationWarning( + "settings.CHANNEL_CONNECTINFO has changed. It " + "must now be either None or a dict " + "specifying the properties of the channel to create." + ) + if hasattr(settings, "CYCLE_LOGFILES"): + raise DeprecationWarning( + "settings.CYCLE_LOGFILES is unused and should be removed. " + "Use PORTAL/SERVER_LOG_DAY_ROTATION and PORTAL/SERVER_LOG_MAX_SIZE " + "to control log cycling." + ) + if hasattr(settings, "CHANNEL_COMMAND_CLASS") or hasattr(settings, "CHANNEL_HANDLER_CLASS"): + raise DeprecationWarning( + "settings.CHANNEL_HANDLER_CLASS and CHANNEL COMMAND_CLASS are " + "unused and should be removed. The ChannelHandler is no more; " + "channels are now handled by aliasing the default 'channel' command." + ) + + template_overrides_dir = os.path.join(settings.GAME_DIR, "web", "template_overrides") + static_overrides_dir = os.path.join(settings.GAME_DIR, "web", "static_overrides") + if os.path.exists(template_overrides_dir): + raise DeprecationWarning( + f"The template_overrides directory ({template_overrides_dir}) has changed name.\n" + " - Rename your existing `template_overrides` folder to `templates` instead." + ) + if os.path.exists(static_overrides_dir): + raise DeprecationWarning( + f"The static_overrides directory ({static_overrides_dir}) has changed name.\n" + " 1. Delete any existing `web/static` folder and all its contents (this " + "was auto-generated)\n" + " 2. Rename your existing `static_overrides` folder to `static` instead." + ) + + if settings.MULTISESSION_MODE < 2 and settings.MAX_NR_SIMULTANEOUS_PUPPETS > 1: + raise DeprecationWarning( + f"settings.MULTISESSION_MODE={settings.MULTISESSION_MODE} is not compatible with " + f"settings.MAX_NR_SIMULTANEOUS_PUPPETS={settings.MAX_NR_SIMULTANEOUS_PUPPETS}. " + "To allow multiple simultaneous puppets, the multi-session mode must be higher than 1." + )
+ + +
[docs]def check_warnings(settings): + """ + Check conditions and deprecations that should produce warnings but which + does not stop launch. + """ + if settings.DEBUG: + print(" [Devel: settings.DEBUG is True. Important to turn off in production.]") + if settings.IN_GAME_ERRORS: + print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]") + if settings.ALLOWED_HOSTS == ["*"]: + print(" [Devel: settings.ALLOWED_HOSTS set to '*' (all). Limit in production.]") + if settings.SERVER_HOSTNAME == "localhost": + print( + " [Devel: settings.SERVER_HOSTNAME is set to 'localhost'. " + "Update to the actual hostname in production.]" + ) + + for dbentry in settings.DATABASES.values(): + if "psycopg" in dbentry.get("ENGINE", ""): + print( + 'Deprecation: postgresql_psycopg2 backend is deprecated". ' + "Switch settings.DATABASES to use " + '"ENGINE": "django.db.backends.postgresql instead"' + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/evennia_launcher.html b/docs/latest/_modules/evennia/server/evennia_launcher.html new file mode 100644 index 0000000000..ac777ba0d8 --- /dev/null +++ b/docs/latest/_modules/evennia/server/evennia_launcher.html @@ -0,0 +1,2547 @@ + + + + + + + + evennia.server.evennia_launcher — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.evennia_launcher

+#!/usr/bin/python
+"""
+Evennia launcher program
+
+This is the start point for running Evennia.
+
+Sets the appropriate environmental variables for managing an Evennia game. It will start and connect
+to the Portal, through which the Server is also controlled. This pprogram
+
+Run the script with the -h flag to see usage information.
+
+"""
+
+import argparse
+import importlib
+import os
+import pickle
+import re
+import shutil
+import signal
+import sys
+from argparse import ArgumentParser
+from distutils.version import LooseVersion
+from subprocess import STDOUT, CalledProcessError, Popen, call, check_output
+
+import django
+from django.core.management import execute_from_command_line
+from django.db.utils import ProgrammingError
+from twisted.internet import endpoints, reactor
+from twisted.protocols import amp
+
+# Signal processing
+SIG = signal.SIGINT
+CTRL_C_EVENT = 0  # Windows SIGINT-like signal
+
+# Set up the main python paths to Evennia
+EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import evennia  # noqa
+
+EVENNIA_LIB = os.path.join(EVENNIA_ROOT, "evennia")
+EVENNIA_SERVER = os.path.join(EVENNIA_LIB, "server")
+EVENNIA_TEMPLATE = os.path.join(EVENNIA_LIB, "game_template")
+EVENNIA_PROFILING = os.path.join(EVENNIA_SERVER, "profiling")
+EVENNIA_DUMMYRUNNER = os.path.join(EVENNIA_PROFILING, "dummyrunner.py")
+
+TWISTED_BINARY = "twistd"
+
+# Game directory structure
+SETTINGFILE = "settings.py"
+SERVERDIR = "server"
+CONFDIR = os.path.join(SERVERDIR, "conf")
+SETTINGS_PATH = os.path.join(CONFDIR, SETTINGFILE)
+SETTINGS_DOTPATH = "server.conf.settings"
+CURRENT_DIR = os.getcwd()
+GAMEDIR = CURRENT_DIR
+
+# Operational setup
+
+SERVER_LOGFILE = None
+PORTAL_LOGFILE = None
+HTTP_LOGFILE = None
+
+SERVER_PIDFILE = None
+PORTAL_PIDFILE = None
+
+SERVER_PY_FILE = None
+PORTAL_PY_FILE = None
+
+SPROFILER_LOGFILE = None
+PPROFILER_LOGFILE = None
+
+TEST_MODE = False
+ENFORCED_SETTING = False
+
+REACTOR_RUN = False
+NO_REACTOR_STOP = False
+
+# communication constants
+
+AMP_PORT = None
+AMP_HOST = None
+AMP_INTERFACE = None
+AMP_CONNECTION = None
+
+SRELOAD = chr(14)  # server reloading (have portal start a new server)
+SSTART = chr(15)  # server start
+PSHUTD = chr(16)  # portal (+server) shutdown
+SSHUTD = chr(17)  # server-only shutdown
+PSTATUS = chr(18)  # ping server or portal status
+SRESET = chr(19)  # shutdown server in reset mode
+
+# live version requirement checks (from VERSION_REQS.txt file)
+PYTHON_MIN = None
+PYTHON_MAX_TESTED = None
+TWISTED_MIN = None
+DJANGO_MIN = None
+DJANGO_MAX_TESTED = None
+
+with open(os.path.join(EVENNIA_LIB, "VERSION_REQS.txt")) as fil:
+    for line in fil.readlines():
+        if line.startswith("#") or "=" not in line:
+            continue
+        key, *value = (part.strip() for part in line.split("=", 1))
+        if key == "PYTHON_MIN":
+            PYTHON_MIN = value[0] if value else "0"
+        elif key == "PYTHON_MAX_TESTED":
+            PYTHON_MAX_TESTED = value[0] if value else "100"
+        elif key == "TWISTED_MIN":
+            TWISTED_MIN = value[0] if value else "0"
+        elif key == "DJANGO_MIN":
+            DJANGO_MIN = value[0] if value else "0"
+        elif key == "DJANGO_MAX_TESTED":
+            DJANGO_MAX_TESTED = value[0] if value else "100"
+
+try:
+    sys.path[1] = EVENNIA_ROOT
+except IndexError:
+    sys.path.append(EVENNIA_ROOT)
+
+# ------------------------------------------------------------
+#
+# Messages
+#
+# ------------------------------------------------------------
+
+CREATED_NEW_GAMEDIR = """
+    Welcome to Evennia!
+    Created a new Evennia game directory '{gamedir}'.
+
+    You can now optionally edit your new settings file
+    at {settings_path}. If you don't, the defaults
+    will work out of the box. When ready to continue, 'cd' to your
+    game directory and run:
+
+       evennia migrate
+
+    This initializes the database. To start the server for the first
+    time, run:
+
+       evennia start
+
+    Make sure to create a superuser when asked for it (the email is optional)
+    You should now be able to connect to your server on 'localhost', port 4000
+    using a telnet/mud client or http://localhost:4001 using your web browser.
+    If things don't work, check the log with `evennia --log`. Also make sure
+    ports are open.
+
+    (Finally, why not run `evennia connections` and make the world aware of
+    your new Evennia project!)
+    """
+
+ERROR_INPUT = """
+    Command
+      {args} {kwargs}
+    raised an error: '{traceback}'.
+"""
+
+ERROR_NO_ALT_GAMEDIR = """
+    The path '{gamedir}' could not be found.
+"""
+
+ERROR_NO_GAMEDIR = """
+    ERROR: No Evennia settings file was found. Evennia looks for the
+    file in your game directory as ./server/conf/settings.py.
+
+    You must run this command from somewhere inside a valid game
+    directory first created with
+
+        evennia --init mygamename
+
+    If you are in a game directory but is missing a settings.py file,
+    it may be because you have git-cloned an existing game directory.
+    The settings.py file is not cloned by git (it's in .gitignore)
+    since it can contain sensitive and/or server-specific information.
+    You can create a new, empty settings file with
+
+        evennia --initsettings
+
+    If cloning the settings file is not a problem you could manually
+    copy over the old settings file or remove its entry in .gitignore
+
+    """
+
+WARNING_MOVING_SUPERUSER = """
+    WARNING: Evennia expects an Account superuser with id=1. No such
+    Account was found. However, another superuser ('{other_key}',
+    id={other_id}) was found in the database. If you just created this
+    superuser and still see this text it is probably due to the
+    database being flushed recently - in this case the database's
+    internal auto-counter might just start from some value higher than
+    one.
+
+    We will fix this by assigning the id 1 to Account '{other_key}'.
+    Please confirm this is acceptable before continuing.
+    """
+
+WARNING_RUNSERVER = """
+    WARNING: There is no need to run the Django development
+    webserver to test out Evennia web features (the web client
+    will in fact not work since the Django test server knows
+    nothing about MUDs).  Instead, just start Evennia with the
+    webserver component active (this is the default).
+    """
+
+ERROR_SETTINGS = """
+    ERROR: There was an error importing Evennia's config file
+    {settingspath}.
+    There is usually one of three reasons for this:
+        1) You are not running this command from your game directory.
+           Change directory to your game directory and try again (or
+           create a new game directory using evennia --init <dirname>)
+        2) The settings file contains a syntax error. If you see a
+           traceback above, review it, resolve the problem and try again.
+        3) Django is not correctly installed. This usually shows as
+           errors mentioning 'DJANGO_SETTINGS_MODULE'. If you run a
+           virtual machine, it might be worth to restart it to see if
+           this resolves the issue.
+    """.format(
+    settingspath=SETTINGS_PATH
+)
+
+ERROR_INITSETTINGS = """
+u   ERROR: 'evennia --initsettings' must be called from the root of
+    your game directory, since it tries to (re)create the new
+    settings.py file in a subfolder server/conf/.
+    """
+
+RECREATED_SETTINGS = """
+    (Re)created an empty settings file in server/conf/settings.py.
+
+    Note that if you were using an existing database, the password
+    salt of this new settings file will be different from the old one.
+    This means that any existing accounts may not be able to log in to
+    their accounts with their old passwords.
+    """
+
+ERROR_INITMISSING = """
+    ERROR: 'evennia --initmissing' must be called from the root of
+    your game directory, since it tries to create any missing files
+    in the server/ subfolder.
+    """
+
+RECREATED_MISSING = """
+    (Re)created any missing directories or files.  Evennia should
+    be ready to run now!
+    """
+
+ERROR_DATABASE = """
+    ERROR: Your database does not exist or is not set up correctly.
+    (error was '{traceback}')
+
+    If you think your database should work, make sure you are running your
+    commands from inside your game directory. If this error persists, run
+
+       evennia migrate
+
+    to initialize/update the database according to your settings.
+    """
+
+ERROR_WINDOWS_WIN32API = """
+    ERROR: Unable to import win32api, which Twisted requires to run.
+    You may download it with pip in your Python environment:
+
+    pip install --upgrade pywin32
+
+    """
+
+INFO_WINDOWS_BATFILE = """
+    INFO: Since you are running Windows, a file 'twistd.bat' was
+    created for you. This is a simple batch file that tries to call
+    the twisted executable. Evennia determined this to be:
+
+       {twistd_path}
+
+    If you run into errors at startup you might need to edit
+    twistd.bat to point to the actual location of the Twisted
+    executable (usually called twistd.py) on your machine.
+
+    This procedure is only done once. Run `evennia` again when you
+    are ready to start the server.
+    """
+
+CMDLINE_HELP = """Starts, initializes, manages and operates the Evennia MU* server.
+Most standard django management commands are also accepted."""
+
+
+VERSION_INFO = """
+    Evennia {version}
+    OS: {os}
+    Python: {python}
+    Twisted: {twisted}
+    Django: {django}{about}
+    """
+
+ABOUT_INFO = """
+    Evennia MUD/MUX/MU* development system
+
+    Licence: BSD 3-Clause Licence
+    Web: http://www.evennia.com
+    Chat: https://discord.gg/AJJpcRUhtF
+    Forum: http://www.evennia.com/discussions
+    Maintainer (2006-10): Greg Taylor
+    Maintainer (2010-):   Griatch (griatch AT gmail DOT com)
+
+    Use -h for command line options.
+    """
+
+HELP_ENTRY = """
+    Evennia has two processes, the 'Server' and the 'Portal'.
+    External users connect to the Portal while the Server runs the
+    game/database. Restarting the Server will refresh code but not
+    disconnect users.
+
+    To start a new game, use 'evennia --init mygame'.
+    For more ways to operate and manage Evennia, see 'evennia -h'.
+
+    If you want to add unit tests to your game, see
+        https://github.com/evennia/evennia/wiki/Unit-Testing
+
+    Evennia's manual is found here:
+        https://github.com/evennia/evennia/wiki
+    """
+
+MENU = """
+    +----Evennia Launcher-------------------------------------------+
+    {gameinfo}
+    +--- Common operations -----------------------------------------+
+    |  1) Start                       (also restart stopped Server) |
+    |  2) Reload               (stop/start Server in 'reload' mode) |
+    |  3) Stop                         (shutdown Portal and Server) |
+    |  4) Reboot                            (shutdown then restart) |
+    +--- Other operations ------------------------------------------+
+    |  5) Reset              (stop/start Server in 'shutdown' mode) |
+    |  6) Stop Server only                                          |
+    |  7) Kill Server only            (send kill signal to process) |
+    |  8) Kill Portal + Server                                      |
+    +--- Information -----------------------------------------------+
+    |  9) Tail log files      (quickly see errors - Ctrl-C to exit) |
+    | 10) Status                                                    |
+    | 11) Port info                                                 |
+    +--- Testing ---------------------------------------------------+
+    | 12) Test gamedir             (run gamedir test suite, if any) |
+    | 13) Test Evennia                     (run Evennia test suite) |
+    +---------------------------------------------------------------+
+    |  h) Help               i) About info                q) Abort  |
+    +---------------------------------------------------------------+"""
+
+ERROR_AMP_UNCONFIGURED = """
+    Can't find server info for connecting. Either run this command from
+    the game dir (it will then use the game's settings file) or specify
+    the path to your game's settings file manually with the --settings
+    option.
+    """
+
+ERROR_LOGDIR_MISSING = """
+    ERROR: One or more log-file directory locations could not be
+    found:
+
+    {logfiles}
+
+    This is simple to fix: Just manually create the missing log
+    directory (or directories) and re-launch the server (the log files
+    will be created automatically).
+
+    (Explanation: Evennia creates the log directory automatically when
+    initializing a new game directory. This error usually happens if
+    you used git to clone a pre-created game directory - since log
+    files are in .gitignore they will not be cloned, which leads to
+    the log directory also not being created.)
+    """
+
+ERROR_PYTHON_VERSION = """
+    ERROR: Python {python_version} used. Evennia requires version
+    {python_min} or higher.
+    """
+
+WARNING_PYTHON_MAX_TESTED_VERSION = """
+    WARNING: Python {python_version} used. Evennia is only tested with Python
+    versions {python_min} to {python_max_tested}. If you see unexpected errors, try
+    reinstalling with a tested Python version instead.
+    """
+
+ERROR_TWISTED_VERSION = """
+    ERROR: Twisted {twisted_version} found. Evennia requires
+    version {twisted_min} or higher.
+    """
+
+ERROR_NOTWISTED = """
+    ERROR: Twisted does not seem to be installed.
+    """
+
+ERROR_DJANGO_MIN = """
+    ERROR: Django {django_version} found. Evennia supports Django
+    {django_min} - {django_max_tested}. Using an older version is not supported.
+
+    If you are using a virtualenv, use the command `pip install --upgrade -e evennia` where
+    `evennia` is the folder to where you cloned the Evennia library. If not
+    in a virtualenv you can install django with for example `pip install --upgrade django`
+    or with `pip install django=={django_min}` to get a specific version.
+
+    It's also a good idea to run `evennia migrate` after this upgrade. Ignore
+    any warnings and don't run `makemigrate` even if told to.
+    """
+
+NOTE_DJANGO_NEW = """
+    NOTE: Django {django_version} found. This is newer than Evennia's
+    recommended version ({django_max_tested}). It might work, but is new
+    enough to not be fully tested yet. Report any issues.
+    """
+
+ERROR_NODJANGO = """
+    ERROR: Django does not seem to be installed.
+    """
+
+NOTE_KEYBOARDINTERRUPT = """
+    STOP: Caught keyboard interrupt while in interactive mode.
+    """
+
+NOTE_TEST_DEFAULT = """
+    TESTING: Using Evennia's default settings file (evennia.settings_default).
+    (use 'evennia test --settings settings.py .' to run only your custom game tests)
+    """
+
+NOTE_TEST_CUSTOM = """
+    TESTING: Using specified settings file '{settings_dotpath}'.
+
+    OBS: Evennia's full test suite may not pass if the settings are very
+    different from the default (use 'evennia test evennia' to run core tests)
+    """
+
+PROCESS_ERROR = """
+    {component} process error: {traceback}.
+    """
+
+PORTAL_INFO = """{servername} Portal {version}
+    external ports:
+        {telnet}
+        {telnet_ssl}
+        {ssh}
+        {webserver_proxy}
+        {webclient}
+    internal_ports (to Server):
+        {webserver_internal}
+        {amp}
+"""
+
+
+SERVER_INFO = """{servername} Server {version}
+    internal ports (to Portal):
+        {webserver}
+        {amp}
+    {irc_rss}
+    {info}
+    {errors}"""
+
+
+ARG_OPTIONS = """Actions on installed server. One of:
+ start       - launch server+portal if not running
+ reload      - restart server in 'reload' mode
+ stop        - shutdown server+portal
+ reboot      - shutdown server+portal, then start again
+ reset       - restart server in 'shutdown' mode
+ istart      - start server in foreground (until reload)
+ ipstart     - start portal in foreground
+ sstop       - stop only server
+ kill        - send kill signal to portal+server (force)
+ skill       - send kill signal only to server
+ status      - show server and portal run state
+ info        - show server and portal port info
+ menu        - show a menu of options
+ connections - show connection wizard
+Others, like migrate, test and shell is passed on to Django."""
+
+# ------------------------------------------------------------
+#
+# Private helper functions
+#
+# ------------------------------------------------------------
+
+
+def _is_windows():
+    return os.name == "nt"
+
+
+def _file_names_compact(filepath1, filepath2):
+    "Compact the output of filenames with same base dir"
+    dirname1 = os.path.dirname(filepath1)
+    dirname2 = os.path.dirname(filepath2)
+    if dirname1 == dirname2:
+        name2 = os.path.basename(filepath2)
+        return "{} and {}".format(filepath1, name2)
+    else:
+        return "{} and {}".format(filepath1, filepath2)
+
+
+def _print_info(portal_info_dict, server_info_dict):
+    """
+    Format info dicts from the Portal/Server for display
+
+    """
+    ind = " " * 8
+
+    def _prepare_dict(dct):
+        out = {}
+        for key, value in dct.items():
+            if isinstance(value, list):
+                value = "\n{}".format(ind).join(str(val) for val in value)
+            out[key] = value
+        return out
+
+    def _strip_empty_lines(string):
+        return "\n".join(line for line in string.split("\n") if line.strip())
+
+    pstr, sstr = "", ""
+    if portal_info_dict:
+        pdict = _prepare_dict(portal_info_dict)
+        pstr = _strip_empty_lines(PORTAL_INFO.format_map(pdict))
+
+    if server_info_dict:
+        sdict = _prepare_dict(server_info_dict)
+        sstr = _strip_empty_lines(SERVER_INFO.format_map(sdict))
+
+    info = pstr + ("\n\n" + sstr if sstr else "")
+    maxwidth = max(len(line) for line in info.split("\n"))
+    top_border = "-" * (maxwidth - 11) + " Evennia " + "---"
+    border = "-" * (maxwidth + 1)
+    print(top_border + "\n" + info + "\n" + border)
+
+
+def _parse_status(response):
+    "Unpack the status information"
+    return pickle.loads(response["status"])
+
+
+def _get_twistd_cmdline(pprofiler, sprofiler):
+    """
+    Compile the command line for starting a Twisted application using the 'twistd' executable.
+
+    """
+    portal_cmd = [
+        TWISTED_BINARY,
+        f"--python={PORTAL_PY_FILE}",
+        "--logger=evennia.utils.logger.GetPortalLogObserver",
+    ]
+    server_cmd = [
+        TWISTED_BINARY,
+        f"--python={SERVER_PY_FILE}",
+        "--logger=evennia.utils.logger.GetServerLogObserver",
+    ]
+
+    if os.name != "nt":
+        # PID files only for UNIX
+        portal_cmd.append("--pidfile={}".format(PORTAL_PIDFILE))
+        server_cmd.append("--pidfile={}".format(SERVER_PIDFILE))
+
+    if pprofiler:
+        portal_cmd.extend(
+            ["--savestats", "--profiler=cprofile", "--profile={}".format(PPROFILER_LOGFILE)]
+        )
+    if sprofiler:
+        server_cmd.extend(
+            ["--savestats", "--profiler=cprofile", "--profile={}".format(SPROFILER_LOGFILE)]
+        )
+
+    return portal_cmd, server_cmd
+
+
+def _reactor_stop():
+    if not NO_REACTOR_STOP:
+        reactor.stop()
+
+
+# ------------------------------------------------------------
+#
+#  Protocol Evennia launcher - Portal/Server communication
+#
+# ------------------------------------------------------------
+
+
+
[docs]class MsgStatus(amp.Command): + """ + Ping between AMP services + + """ + + key = "MsgStatus" + arguments = [(b"status", amp.String())] + errors = {Exception: b"EXCEPTION"} + response = [(b"status", amp.String())]
+ + +
[docs]class MsgLauncher2Portal(amp.Command): + """ + Message Launcher -> Portal + + """ + + key = "MsgLauncher2Portal" + arguments = [(b"operation", amp.String()), (b"arguments", amp.String())] + errors = {Exception: b"EXCEPTION"} + response = []
+ + +
[docs]class AMPLauncherProtocol(amp.AMP): + """ + Defines callbacks to the launcher + + """ + +
[docs] def __init__(self): + self.on_status = []
+ +
[docs] def wait_for_status(self, callback): + """ + Register a waiter for a status return. + + """ + self.on_status.append(callback)
+ +
[docs] @MsgStatus.responder + def receive_status_from_portal(self, status): + """ + Get a status signal from portal - fire next queued + callback + + """ + try: + callback = self.on_status.pop() + except IndexError: + pass + else: + status = pickle.loads(status) + callback(status) + return {"status": pickle.dumps(b"")}
+ + +
[docs]def send_instruction(operation, arguments, callback=None, errback=None): + """ + Send instruction and handle the response. + + """ + global AMP_CONNECTION, REACTOR_RUN + + if None in (AMP_HOST, AMP_PORT, AMP_INTERFACE): + print(ERROR_AMP_UNCONFIGURED) + sys.exit() + + def _callback(result): + if callback: + callback(result) + + def _errback(fail): + if errback: + errback(fail) + + def _on_connect(prot): + """ + This fires with the protocol when connection is established. We + immediately send off the instruction + + """ + global AMP_CONNECTION + AMP_CONNECTION = prot + _send() + + def _on_connect_fail(fail): + "This is called if portal is not reachable." + errback(fail) + + def _send(): + if operation == PSTATUS: + return AMP_CONNECTION.callRemote(MsgStatus, status=b"").addCallbacks( + _callback, _errback + ) + else: + return AMP_CONNECTION.callRemote( + MsgLauncher2Portal, + operation=bytes(operation, "utf-8"), + arguments=pickle.dumps(arguments, pickle.HIGHEST_PROTOCOL), + ).addCallbacks(_callback, _errback) + + if AMP_CONNECTION: + # already connected - send right away + return _send() + else: + # we must connect first, send once connected + point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) + deferred = endpoints.connectProtocol(point, AMPLauncherProtocol()) + deferred.addCallbacks(_on_connect, _on_connect_fail) + REACTOR_RUN = True + return deferred
+ + +
[docs]def query_status(callback=None): + """ + Send status ping to portal + + """ + wmap = {True: "RUNNING", False: "NOT RUNNING"} + + def _callback(response): + if callback: + callback(response) + else: + pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response) + print( + "Portal: {}{}\nServer: {}{}".format( + wmap[pstatus], + " (pid {})".format(get_pid(PORTAL_PIDFILE, ppid)) if pstatus else "", + wmap[sstatus], + " (pid {})".format(get_pid(SERVER_PIDFILE, spid)) if sstatus else "", + ) + ) + _reactor_stop() + + def _errback(fail): + pstatus, sstatus = False, False + print("Portal: {}\nServer: {}".format(wmap[pstatus], wmap[sstatus])) + _reactor_stop() + + send_instruction(PSTATUS, None, _callback, _errback)
+ + +
[docs]def wait_for_status_reply(callback): + """ + Wait for an explicit STATUS signal to be sent back from Evennia. + """ + if AMP_CONNECTION: + AMP_CONNECTION.wait_for_status(callback) + else: + print("No Evennia connection established.")
+ + +
[docs]def wait_for_status( + portal_running=True, server_running=True, callback=None, errback=None, rate=0.5, retries=20 +): + """ + Repeat the status ping until the desired state combination is achieved. + + Args: + portal_running (bool or None): Desired portal run-state. If None, any state + is accepted. + server_running (bool or None): Desired server run-state. If None, any state + is accepted. The portal must be running. + callback (callable): Will be called with portal_state, server_state when + condition is fulfilled. + errback (callable): Will be called with portal_state, server_state if the + request is timed out. + rate (float): How often to retry. + retries (int): How many times to retry before timing out and calling `errback`. + """ + + def _callback(response): + prun, srun, _, _, _, _ = _parse_status(response) + if (portal_running is None or prun == portal_running) and ( + server_running is None or srun == server_running + ): + # the correct state was achieved + if callback: + callback(prun, srun) + else: + _reactor_stop() + else: + if retries <= 0: + if errback: + errback(prun, srun) + else: + print("Connection to Evennia timed out. Try again.") + _reactor_stop() + else: + reactor.callLater( + rate, + wait_for_status, + portal_running, + server_running, + callback, + errback, + rate, + retries - 1, + ) + + def _errback(fail): + """ + Portal not running + """ + if not portal_running: + # this is what we want + if callback: + callback(portal_running, server_running) + else: + _reactor_stop() + else: + if retries <= 0: + if errback: + errback(portal_running, server_running) + else: + print("Connection to Evennia timed out. Try again.") + _reactor_stop() + else: + reactor.callLater( + rate, + wait_for_status, + portal_running, + server_running, + callback, + errback, + rate, + retries - 1, + ) + + return send_instruction(PSTATUS, None, _callback, _errback)
+ + +# ------------------------------------------------------------ +# +# Operational functions +# +# ------------------------------------------------------------ + + +
[docs]def collectstatic(): + "Run the collectstatic django command" + django.core.management.call_command("collectstatic", interactive=False, verbosity=0)
+ + +
[docs]def start_evennia(pprofiler=False, sprofiler=False): + """ + This will start Evennia anew by launching the Evennia Portal (which in turn + will start the Server) + + """ + portal_cmd, server_cmd = _get_twistd_cmdline(pprofiler, sprofiler) + + def _fail(fail): + print(fail) + _reactor_stop() + + def _server_started(response): + print("... Server started.\nEvennia running.") + if response: + _, _, _, _, pinfo, sinfo = response + _print_info(pinfo, sinfo) + _reactor_stop() + + def _portal_started(*args): + print( + "... Portal started.\nServer starting {} ...".format( + "(under cProfile)" if sprofiler else "" + ) + ) + wait_for_status_reply(_server_started) + send_instruction(SSTART, server_cmd) + + def _portal_running(response): + prun, srun, ppid, spid, _, _ = _parse_status(response) + print("Portal is already running as process {pid}. Not restarted.".format(pid=ppid)) + if srun: + print("Server is already running as process {pid}. Not restarted.".format(pid=spid)) + _reactor_stop() + else: + print("Server starting {}...".format("(under cProfile)" if sprofiler else "")) + send_instruction(SSTART, server_cmd, _server_started, _fail) + + def _portal_not_running(fail): + print("Portal starting {}...".format("(under cProfile)" if pprofiler else "")) + try: + if _is_windows(): + # Windows requires special care + create_no_window = 0x08000000 + Popen(portal_cmd, env=getenv(), bufsize=-1, creationflags=create_no_window) + else: + Popen(portal_cmd, env=getenv(), bufsize=-1) + except Exception as e: + print(PROCESS_ERROR.format(component="Portal", traceback=e)) + _reactor_stop() + wait_for_status(True, None, _portal_started) + + collectstatic() + send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+ + +
[docs]def reload_evennia(sprofiler=False, reset=False): + """ + This will instruct the Portal to reboot the Server component. We + do this manually by telling the server to shutdown (in reload mode) + and wait for the portal to report back, at which point we start the + server again. This way we control the process exactly. + + """ + _, server_cmd = _get_twistd_cmdline(False, sprofiler) + + def _server_restarted(*args): + print("... Server re-started.") + _reactor_stop() + + def _server_reloaded(status): + print("... Server {}.".format("reset" if reset else "reloaded")) + _reactor_stop() + + def _server_stopped(status): + wait_for_status_reply(_server_reloaded) + send_instruction(SSTART, server_cmd) + + def _portal_running(response): + _, srun, _, _, _, _ = _parse_status(response) + if srun: + print("Server {}...".format("resetting" if reset else "reloading")) + wait_for_status_reply(_server_stopped) + send_instruction(SRESET if reset else SRELOAD, {}) + else: + print("Server down. Re-starting ...") + wait_for_status_reply(_server_restarted) + send_instruction(SSTART, server_cmd) + + def _portal_not_running(fail): + print("Evennia not running. Starting ...") + start_evennia() + + collectstatic() + send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+ + +
[docs]def stop_evennia(): + """ + This instructs the Portal to stop the Server and then itself. + + """ + + def _portal_stopped(*args): + print("... Portal stopped.\nEvennia shut down.") + _reactor_stop() + + def _server_stopped(*args): + print("... Server stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_running(response): + prun, srun, ppid, spid, _, _ = _parse_status(response) + if srun: + print("Server stopping ...") + send_instruction(SSHUTD, {}) + wait_for_status_reply(_server_stopped) + else: + print("Server already stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_not_running(fail): + print("Evennia not running.") + _reactor_stop() + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+ + +
[docs]def reboot_evennia(pprofiler=False, sprofiler=False): + """ + This is essentially an evennia stop && evennia start except we make sure + the system has successfully shut down before starting it again. + + If evennia was not running, start it. + + """ + global AMP_CONNECTION + + def _portal_stopped(*args): + print("... Portal stopped. Evennia shut down. Rebooting ...") + global AMP_CONNECTION + AMP_CONNECTION = None + start_evennia(pprofiler, sprofiler) + + def _server_stopped(*args): + print("... Server stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_running(response): + prun, srun, ppid, spid, _, _ = _parse_status(response) + if srun: + print("Server stopping ...") + send_instruction(SSHUTD, {}) + wait_for_status_reply(_server_stopped) + else: + print("Server already stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_not_running(fail): + print("Evennia not running. Starting ...") + start_evennia() + + collectstatic() + send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+ + +
[docs]def start_only_server(): + """ + Tell portal to start server (debug) + """ + portal_cmd, server_cmd = _get_twistd_cmdline(False, False) + print("launcher: Sending to portal: SSTART + {}".format(server_cmd)) + collectstatic() + send_instruction(SSTART, server_cmd)
+ + +
[docs]def start_server_interactive(): + """ + Start the Server under control of the launcher process (foreground) + + """ + + def _iserver(): + _, server_twistd_cmd = _get_twistd_cmdline(False, False) + server_twistd_cmd.append("--nodaemon") + print("Starting Server in interactive mode (stop with Ctrl-C)...") + try: + Popen(server_twistd_cmd, env=getenv(), stderr=STDOUT).wait() + except KeyboardInterrupt: + print("... Stopped Server with Ctrl-C.") + else: + print("... Server stopped (leaving interactive mode).") + + collectstatic() + stop_server_only(when_stopped=_iserver, interactive=True)
+ + +
[docs]def start_portal_interactive(): + """ + Start the Portal under control of the launcher process (foreground) + + Notes: + In a normal start, the launcher waits for the Portal to start, then + tells it to start the Server. Since we can't do this here, we instead + start the Server first and then starts the Portal - the Server will + auto-reconnect to the Portal. To allow the Server to be reloaded, this + relies on a fixed server server-cmdline stored as a fallback on the + portal application in evennia/server/portal/portal.py. + + """ + + def _iportal(fail): + portal_twistd_cmd, server_twistd_cmd = _get_twistd_cmdline(False, False) + portal_twistd_cmd.append("--nodaemon") + + # starting Server first - it will auto-connect once Portal comes up + if _is_windows(): + # Windows requires special care + create_no_window = 0x08000000 + Popen(server_twistd_cmd, env=getenv(), bufsize=-1, creationflags=create_no_window) + else: + Popen(server_twistd_cmd, env=getenv(), bufsize=-1) + + print("Starting Portal in interactive mode (stop with Ctrl-C)...") + try: + Popen(portal_twistd_cmd, env=getenv(), stderr=STDOUT).wait() + except KeyboardInterrupt: + print("... Stopped Portal with Ctrl-C.") + else: + print("... Portal stopped (leaving interactive mode).") + + def _portal_running(response): + print("Evennia must be shut down completely before running Portal in interactive mode.") + _reactor_stop() + + send_instruction(PSTATUS, None, _portal_running, _iportal)
+ + +
[docs]def stop_server_only(when_stopped=None, interactive=False): + """ + Only stop the Server-component of Evennia (this is not useful except for debug) + + Args: + when_stopped (callable): This will be called with no arguments when Server has stopped (or + if it had already stopped when this is called). + interactive (bool, optional): Set if this is called as part of the interactive reload + mechanism. + + """ + + def _server_stopped(*args): + if when_stopped: + when_stopped() + else: + print("... Server stopped.") + _reactor_stop() + + def _portal_running(response): + _, srun, _, _, _, _ = _parse_status(response) + if srun: + print("Server stopping ...") + wait_for_status_reply(_server_stopped) + if interactive: + send_instruction(SRELOAD, {}) + else: + send_instruction(SSHUTD, {}) + else: + if when_stopped: + when_stopped() + else: + print("Server is not running.") + _reactor_stop() + + def _portal_not_running(fail): + print("Evennia is not running.") + if interactive: + print("Start Evennia normally first, then use `istart` to switch to interactive mode.") + _reactor_stop() + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+ + +
[docs]def query_info(): + """ + Display the info strings from the running Evennia + + """ + + def _got_status(status): + _, _, _, _, pinfo, sinfo = _parse_status(status) + _print_info(pinfo, sinfo) + _reactor_stop() + + def _portal_running(response): + query_status(_got_status) + + def _portal_not_running(fail): + print("Evennia is not running.") + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+ + +
[docs]def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate=1): + """ + Tail two logfiles interactively, combining their output to stdout + + When first starting, this will display the tail of the log files. After + that it will poll the log files repeatedly and display changes. + + Args: + filename1 (str): Path to first log file. + filename2 (str): Path to second log file. + start_lines1 (int): How many lines to show from existing first log. + start_lines2 (int): How many lines to show from existing second log. + rate (int, optional): How often to poll the log file. + + """ + global REACTOR_RUN + + def _file_changed(filename, prev_size): + "Get size of file in bytes, get diff compared with previous size" + try: + new_size = os.path.getsize(filename) + except FileNotFoundError: + return False, 0 + return new_size != prev_size, new_size + + def _get_new_lines(filehandle, old_linecount): + "count lines, get the ones not counted before" + + def _block(filehandle, size=65536): + "File block generator for quick traversal" + while True: + dat = filehandle.read(size) + if not dat: + break + yield dat + + # count number of lines in file + new_linecount = sum(blck.count("\n") for blck in _block(filehandle)) + + if new_linecount < old_linecount: + # this happens if the file was cycled or manually deleted/edited. + print( + " ** Log file {filename} has cycled or been edited. Restarting log. ".format( + filename=filehandle.name + ) + ) + new_linecount = 0 + old_linecount = 0 + + lines_to_get = max(0, new_linecount - old_linecount) + + if not lines_to_get: + return [], old_linecount + + lines_found = [] + buffer_size = 4098 + block_count = -1 + + while len(lines_found) < lines_to_get: + try: + # scan backwards in file, starting from the end + filehandle.seek(block_count * buffer_size, os.SEEK_END) + except IOError: + # file too small for current seek, include entire file + filehandle.seek(0) + lines_found = filehandle.readlines() + break + lines_found = filehandle.readlines() + block_count -= 1 + + # only actually return the new lines + return lines_found[-lines_to_get:], new_linecount + + def _tail_file(filename, file_size, line_count, max_lines=None): + """This will cycle repeatedly, printing new lines""" + + # poll for changes + has_changed, file_size = _file_changed(filename, file_size) + + if has_changed: + try: + with open(filename, "r") as filehandle: + new_lines, line_count = _get_new_lines(filehandle, line_count) + except IOError: + # the log file might not exist yet. Wait a little, then try again ... + pass + else: + if max_lines == 0: + # don't show any lines from old file + new_lines = [] + elif max_lines: + # show some lines from first startup + new_lines = new_lines[-max_lines:] + + # print to stdout without line break (log has its own line feeds) + sys.stdout.write("".join(new_lines)) + sys.stdout.flush() + + # set up the next poll + reactor.callLater(rate, _tail_file, filename, file_size, line_count, max_lines=100) + + reactor.callLater(0, _tail_file, filename1, 0, 0, max_lines=start_lines1) + reactor.callLater(0, _tail_file, filename2, 0, 0, max_lines=start_lines2) + + REACTOR_RUN = True
+ + +# ------------------------------------------------------------ +# +# Environment setup +# +# ------------------------------------------------------------ + + +
[docs]def evennia_version(): + """ + Get the Evennia version info from the main package. + + """ + version = "Unknown" + try: + version = evennia.__version__ + except (ImportError, AttributeError): + # even if evennia is not found, we should not crash here. + pass + else: + return version + try: + rev = ( + check_output("git rev-parse --short HEAD", shell=True, cwd=EVENNIA_ROOT, stderr=STDOUT) + .strip() + .decode() + ) + version = "%s (rev %s)" % (version, rev) + except (IOError, CalledProcessError, OSError): + # move on if git is not answering + pass + return version
+ + +EVENNIA_VERSION = evennia_version() + + +
[docs]def check_main_evennia_dependencies(): + """ + Checks and imports the Evennia dependencies. This must be done + already before the paths are set up. + + Returns: + not_error (bool): True if no dependency error was found. + + """ + + def _test_python_version(): + """Test Python version""" + python_version = ".".join(str(num) for num in sys.version_info if isinstance(num, int)) + python_curr = LooseVersion(python_version) + python_min = LooseVersion(PYTHON_MIN) + python_max = LooseVersion(PYTHON_MAX_TESTED) + + if python_curr < python_min: + print(ERROR_PYTHON_VERSION.format(python_version=python_version, python_min=PYTHON_MIN)) + return False + elif python_curr > python_max: + print( + WARNING_PYTHON_MAX_TESTED_VERSION.format( + python_version=python_version, + python_min=PYTHON_MIN, + python_max_tested=PYTHON_MAX_TESTED, + ) + ) + return True + + def _test_twisted_version(): + """Test Twisted version""" + try: + import twisted + except ImportError: + print(ERROR_NOTWISTED) + return False + else: + twisted_version = twisted.version.short() + twisted_curr = LooseVersion(twisted_version) + twisted_min = LooseVersion(TWISTED_MIN) + + if twisted_curr < twisted_min: + print( + ERROR_TWISTED_VERSION.format( + twisted_version=twisted_version, twisted_min=TWISTED_MIN + ) + ) + return False + else: + return True + + def _test_django_version(): + """Test Django version""" + try: + import django + except ImportError: + print(ERROR_NODJANGO) + return False + else: + django_version = ".".join(str(num) for num in django.VERSION if isinstance(num, int)) + # only the main version (1.5, not 1.5.4.0) + django_version = ".".join(django_version.split(".")[:2]) + django_curr = LooseVersion(django_version) + django_min = LooseVersion(DJANGO_MIN) + django_max = LooseVersion(DJANGO_MAX_TESTED) + + if django_curr < django_min: + print( + ERROR_DJANGO_MIN.format( + django_version=django_version, + django_min=DJANGO_MIN, + django_max_tested=DJANGO_MAX_TESTED, + ) + ) + return False + elif django_curr > django_max: + print( + NOTE_DJANGO_NEW.format( + django_version=django_version, django_max_tested=DJANGO_MAX_TESTED + ) + ) + return True + + # return True/False if error was reported or not + return all((_test_python_version(), _test_twisted_version(), _test_django_version()))
+ + +
[docs]def set_gamedir(path): + """ + Set GAMEDIR based on path, by figuring out where the setting file + is inside the directory tree. This allows for running the launcher + from elsewhere than the top of the gamedir folder. + + """ + global GAMEDIR + + Ndepth = 10 + settings_path = SETTINGS_DOTPATH.replace(".", os.sep) + ".py" + os.chdir(path) + for i in range(Ndepth): + gpath = os.getcwd() + if "server" in os.listdir(gpath): + if os.path.isfile(settings_path): + GAMEDIR = gpath + return + os.chdir(os.pardir) + print(ERROR_NO_GAMEDIR) + sys.exit()
+ + +
[docs]def create_secret_key(): + """ + Randomly create the secret key for the settings file + + """ + import random + import string + + secret_key = list( + (string.ascii_letters + string.digits + string.punctuation) + .replace("\\", "") + .replace("'", '"') + .replace("{", "_") + .replace("}", "-") + ) + random.shuffle(secret_key) + secret_key = "".join(secret_key[:40]) + return secret_key
+ + +
[docs]def create_settings_file(init=True, secret_settings=False): + """ + Uses the template settings file to build a working settings file. + + Args: + init (bool): This is part of the normal evennia --init + operation. If false, this function will copy a fresh + template file in (asking if it already exists). + secret_settings (bool, optional): If False, create settings.py, otherwise + create the secret_settings.py file. + + """ + if secret_settings: + settings_path = os.path.join(GAMEDIR, "server", "conf", "secret_settings.py") + setting_dict = {"secret_key": "'%s'" % create_secret_key()} + else: + settings_path = os.path.join(GAMEDIR, "server", "conf", "settings.py") + setting_dict = { + "settings_default": os.path.join(EVENNIA_LIB, "settings_default.py"), + "servername": '"%s"' % GAMEDIR.rsplit(os.path.sep, 1)[1], + "secret_key": "'%s'" % create_secret_key(), + } + + if not init: + # if not --init mode, settings file may already exist from before + if os.path.exists(settings_path): + inp = input("%s already exists. Do you want to reset it? y/[N]> " % settings_path) + if not inp.lower() == "y": + print("Aborted.") + sys.exit() + else: + print("Reset the settings file.") + + if secret_settings: + default_settings_path = os.path.join( + EVENNIA_TEMPLATE, "server", "conf", "secret_settings.py" + ) + else: + default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py") + shutil.copy(default_settings_path, settings_path) + + with open(settings_path, "r") as f: + settings_string = f.read() + + settings_string = settings_string.format_map(setting_dict) + + with open(settings_path, "w") as f: + f.write(settings_string)
+ + +
[docs]def create_game_directory(dirname): + """ + Initialize a new game directory named dirname + at the current path. This means copying the + template directory from evennia's root. + + Args: + dirname (str): The directory name to create. + + """ + global GAMEDIR + GAMEDIR = os.path.abspath(os.path.join(CURRENT_DIR, dirname)) + if os.path.exists(GAMEDIR): + print("Cannot create new Evennia game dir: '%s' already exists." % dirname) + sys.exit() + # copy template directory + shutil.copytree(EVENNIA_TEMPLATE, GAMEDIR) + # rename gitignore to .gitignore + os.rename(os.path.join(GAMEDIR, "gitignore"), os.path.join(GAMEDIR, ".gitignore")) + + # pre-build settings file in the new GAMEDIR + create_settings_file() + create_settings_file(secret_settings=True)
+ + +
[docs]def create_superuser(): + """ + Create the superuser account + + """ + print( + "\nCreate a superuser below. The superuser is Account #1, the 'owner' " + "account of the server. Email is optional and can be empty.\n" + ) + from os import environ + + username = environ.get("EVENNIA_SUPERUSER_USERNAME") + email = environ.get("EVENNIA_SUPERUSER_EMAIL") + password = environ.get("EVENNIA_SUPERUSER_PASSWORD") + + if (username is not None) and (password is not None) and len(password) > 0: + from evennia.accounts.models import AccountDB + + superuser = AccountDB.objects.create_superuser(username, email, password) + superuser.save() + else: + django.core.management.call_command("createsuperuser", interactive=True)
+ + +
[docs]def check_database(always_return=False): + """ + Check so the database exists. + + Args: + always_return (bool, optional): If set, will always return True/False + also on critical errors. No output will be printed. + Returns: + exists (bool): `True` if the database exists, otherwise `False`. + + + """ + # Check so a database exists and is accessible + from django.db import connection + + tables = connection.introspection.get_table_list(connection.cursor()) + if not tables or not isinstance(tables[0], str): # django 1.8+ + tables = [tableinfo.name for tableinfo in tables] + if tables and "accounts_accountdb" in tables: + # database exists and seems set up. Initialize evennia. + evennia._init() + # Try to get Account#1 + from evennia.accounts.models import AccountDB + + try: + AccountDB.objects.get(id=1) + except (django.db.utils.OperationalError, ProgrammingError) as e: + if always_return: + return False + print(ERROR_DATABASE.format(traceback=e)) + sys.exit() + except AccountDB.DoesNotExist: + # no superuser yet. We need to create it. + + other_superuser = AccountDB.objects.filter(is_superuser=True) + if other_superuser: + # Another superuser was found, but not with id=1. This may + # happen if using flush (the auto-id starts at a higher + # value). Wwe copy this superuser into id=1. To do + # this we must deepcopy it, delete it then save the copy + # with the new id. This allows us to avoid the UNIQUE + # constraint on usernames. + other = other_superuser[0] + other_id = other.id + other_key = other.username + print(WARNING_MOVING_SUPERUSER.format(other_key=other_key, other_id=other_id)) + res = "" + while res.upper() != "Y": + # ask for permission + res = eval(input("Continue [Y]/N: ")) + if res.upper() == "N": + sys.exit() + elif not res: + break + # continue with the + from copy import deepcopy + + new = deepcopy(other) + other.delete() + new.id = 1 + new.save() + else: + create_superuser() + check_database(always_return=always_return) + return True
+ + +
[docs]def getenv(): + """ + Get current environment and add PYTHONPATH. + + Returns: + env (dict): Environment global dict. + + """ + sep = ";" if _is_windows() else ":" + env = os.environ.copy() + env["PYTHONPATH"] = sep.join(sys.path) + return env
+ + +
[docs]def get_pid(pidfile, default=None): + """ + Get the PID (Process ID) by trying to access an PID file. + + Args: + pidfile (str): The path of the pid file. + default (int, optional): What to return if file does not exist. + + Returns: + pid (str): The process id or `default`. + + """ + if os.path.exists(pidfile): + with open(pidfile, "r") as f: + pid = f.read() + return pid + return default
+ + +
[docs]def del_pid(pidfile): + """ + The pidfile should normally be removed after a process has + finished, but when sending certain signals they remain, so we need + to clean them manually. + + Args: + pidfile (str): The path of the pid file. + + """ + if os.path.exists(pidfile): + os.remove(pidfile)
+ + +
[docs]def kill(pidfile, component="Server", callback=None, errback=None, killsignal=SIG): + """ + Send a kill signal to a process based on PID. A customized + success/error message will be returned. If clean=True, the system + will attempt to manually remove the pid file. On Windows, no arguments + are useful since Windows has no ability to direct signals except to all + children of a console. + + Args: + pidfile (str): The path of the pidfile to get the PID from. This is ignored + on Windows. + component (str, optional): Usually one of 'Server' or 'Portal'. This is + ignored on Windows. + errback (callable, optional): Called if signal failed to send. This + is ignored on Windows. + callback (callable, optional): Called if kill signal was sent successfully. + This is ignored on Windows. + killsignal (int, optional): Signal identifier for signal to send. This is + ignored on Windows. + + """ + if _is_windows(): + # Windows signal sending is very limited. + from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler + + try: + # Windows can only send a SIGINT-like signal to + # *every* process spawned off the same console, so we must + # avoid killing ourselves here. + SetConsoleCtrlHandler(None, True) + GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) + except KeyboardInterrupt: + # We must catch and ignore the interrupt sent. + pass + print("Sent kill signal to all spawned processes") + + else: + # Linux/Unix/Mac can send kill signal directly to specific PIDs. + pid = get_pid(pidfile) + if pid: + if _is_windows(): + os.remove(pidfile) + try: + os.kill(int(pid), killsignal) + except OSError: + print( + "{component} ({pid}) cannot be stopped. " + "The PID file '{pidfile}' seems stale. " + "Try removing it manually.".format( + component=component, pid=pid, pidfile=pidfile + ) + ) + return + if callback: + callback() + else: + print("Sent kill signal to {component}.".format(component=component)) + return + if errback: + errback() + else: + print( + "Could not send kill signal - {component} does not appear to be running.".format( + component=component + ) + )
+ + +
[docs]def show_version_info(about=False): + """ + Display version info. + + Args: + about (bool): Include ABOUT info as well as version numbers. + + Returns: + version_info (str): A complete version info string. + + """ + import sys + + import twisted + + return VERSION_INFO.format( + version=EVENNIA_VERSION, + about=ABOUT_INFO if about else "", + os=os.name, + python=sys.version.split()[0], + twisted=twisted.version.short(), + django=django.get_version(), + )
+ + +
[docs]def error_check_python_modules(show_warnings=False): + """ + Import settings modules in settings. This will raise exceptions on + pure python-syntax issues which are hard to catch gracefully with + exceptions in the engine (since they are formatting errors in the + python source files themselves). Best they fail already here + before we get any further. + + Keyword Args: + show_warnings (bool): If non-fatal warning messages should be shown. + + """ + + from django.conf import settings + + def _imp(path, split=True): + "helper method" + mod, fromlist = path, "None" + if split: + mod, fromlist = path.rsplit(".", 1) + __import__(mod, fromlist=[fromlist]) + + # check the historical deprecations + from evennia.server import deprecations + + try: + deprecations.check_errors(settings) + except DeprecationWarning as err: + print(err) + sys.exit() + + if show_warnings: + deprecations.check_warnings(settings) + + # core modules + _imp(settings.COMMAND_PARSER) + _imp(settings.SEARCH_AT_RESULT) + _imp(settings.CONNECTION_SCREEN_MODULE) + # imp(settings.AT_INITIAL_SETUP_HOOK_MODULE, split=False) + for path in settings.LOCK_FUNC_MODULES: + _imp(path, split=False) + + from evennia.commands import cmdsethandler + + if not cmdsethandler.import_cmdset(settings.CMDSET_UNLOGGEDIN, None): + print("Warning: CMDSET_UNLOGGED failed to load!") + if not cmdsethandler.import_cmdset(settings.CMDSET_CHARACTER, None): + print("Warning: CMDSET_CHARACTER failed to load") + if not cmdsethandler.import_cmdset(settings.CMDSET_ACCOUNT, None): + print("Warning: CMDSET_ACCOUNT failed to load") + # typeclasses + _imp(settings.BASE_ACCOUNT_TYPECLASS) + _imp(settings.BASE_OBJECT_TYPECLASS) + _imp(settings.BASE_CHARACTER_TYPECLASS) + _imp(settings.BASE_ROOM_TYPECLASS) + _imp(settings.BASE_EXIT_TYPECLASS) + _imp(settings.BASE_SCRIPT_TYPECLASS)
+ + +# ------------------------------------------------------------ +# +# Options +# +# ------------------------------------------------------------ + + +
[docs]def init_game_directory(path, check_db=True, need_gamedir=True): + """ + Try to analyze the given path to find settings.py - this defines + the game directory and also sets PYTHONPATH as well as the django + path. + + Args: + path (str): Path to new game directory, including its name. + check_db (bool, optional): Check if the databae exists. + need_gamedir (bool, optional): set to False if Evennia doesn't require to + be run in a valid game directory. + + """ + # set the GAMEDIR path + if need_gamedir: + set_gamedir(path) + + # Add gamedir to python path + sys.path.insert(0, GAMEDIR) + + if TEST_MODE or not need_gamedir: + if ENFORCED_SETTING: + print(NOTE_TEST_CUSTOM.format(settings_dotpath=SETTINGS_DOTPATH)) + os.environ["DJANGO_SETTINGS_MODULE"] = SETTINGS_DOTPATH + else: + print(NOTE_TEST_DEFAULT) + os.environ["DJANGO_SETTINGS_MODULE"] = "evennia.settings_default" + else: + os.environ["DJANGO_SETTINGS_MODULE"] = SETTINGS_DOTPATH + + # required since django1.7 + django.setup() + + # test existence of the settings module + try: + from django.conf import settings + except Exception as ex: + if not str(ex).startswith("No module named"): + import traceback + + print(traceback.format_exc().strip()) + print(ERROR_SETTINGS) + sys.exit() + + # this will both check the database and initialize the evennia dir. + if check_db: + check_database() + + # if we don't have to check the game directory, return right away + if not need_gamedir: + return + + # set up the Evennia executables and log file locations + global AMP_PORT, AMP_HOST, AMP_INTERFACE + global SERVER_PY_FILE, PORTAL_PY_FILE + global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE + global SERVER_PIDFILE, PORTAL_PIDFILE + global SPROFILER_LOGFILE, PPROFILER_LOGFILE + global EVENNIA_VERSION + + AMP_PORT = settings.AMP_PORT + AMP_HOST = settings.AMP_HOST + AMP_INTERFACE = settings.AMP_INTERFACE + + SERVER_PY_FILE = os.path.join(EVENNIA_LIB, "server", "server.py") + PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "server", "portal", "portal.py") + + SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid") + PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid") + + SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof") + PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof") + + SERVER_LOGFILE = settings.SERVER_LOG_FILE + PORTAL_LOGFILE = settings.PORTAL_LOG_FILE + HTTP_LOGFILE = settings.HTTP_LOG_FILE + + # verify existence of log file dir (this can be missing e.g. + # if the game dir itself was cloned since log files are in .gitignore) + logdirs = [ + logfile.rsplit(os.path.sep, 1) for logfile in (SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE) + ] + if not all(os.path.isdir(pathtup[0]) for pathtup in logdirs): + errstr = "\n ".join( + "%s (log file %s)" % (pathtup[0], pathtup[1]) + for pathtup in logdirs + if not os.path.isdir(pathtup[0]) + ) + print(ERROR_LOGDIR_MISSING.format(logfiles=errstr)) + sys.exit() + + if _is_windows(): + # We need to handle Windows twisted separately. We create a + # batchfile in game/server, linking to the actual binary + + global TWISTED_BINARY + # Windows requires us to use the absolute path for the bat file. + server_path = os.path.dirname(os.path.abspath(__file__)) + TWISTED_BINARY = os.path.join(server_path, "twistd.bat") + + # add path so system can find the batfile + sys.path.insert(1, os.path.join(GAMEDIR, SERVERDIR)) + + try: + importlib.import_module("win32api") + except ImportError: + print(ERROR_WINDOWS_WIN32API) + sys.exit() + + batpath = os.path.join(EVENNIA_SERVER, TWISTED_BINARY) + if not os.path.exists(batpath): + # Test for executable twisted batch file. This calls the + # twistd.py executable that is usually not found on the + # path in Windows. It's not enough to locate + # scripts.twistd, what we want is the executable script + # C:\PythonXX/Scripts/twistd.py. Alas we cannot hardcode + # this location since we don't know if user has Python in + # a non-standard location. So we try to figure it out. + twistd = importlib.import_module("twisted.scripts.twistd") + twistd_dir = os.path.dirname(twistd.__file__) + + # note that we hope the twistd package won't change here, since we + # try to get to the executable by relative path. + # Update: In 2016, it seems Twisted 16 has changed the name of + # of its executable from 'twistd.py' to 'twistd.exe'. + twistd_path = os.path.abspath( + os.path.join( + twistd_dir, os.pardir, os.pardir, os.pardir, os.pardir, "scripts", "twistd.exe" + ) + ) + + with open(batpath, "w") as bat_file: + # build a custom bat file for windows + bat_file.write('@"%s" %%*' % twistd_path) + + print(INFO_WINDOWS_BATFILE.format(twistd_path=twistd_path))
+ + +
[docs]def run_dummyrunner(number_of_dummies): + """ + Start an instance of the dummyrunner + + Args: + number_of_dummies (int): The number of dummy accounts to start. + + Notes: + The dummy accounts' behavior can be customized by adding a + `dummyrunner_settings.py` config file in the game's conf/ + directory. + + """ + number_of_dummies = str(int(number_of_dummies)) if number_of_dummies else 1 + cmdstr = [sys.executable, EVENNIA_DUMMYRUNNER, "-N", number_of_dummies] + config_file = os.path.join(SETTINGS_PATH, "dummyrunner_settings.py") + if os.path.exists(config_file): + cmdstr.extend(["--config", config_file]) + try: + call(cmdstr, env=getenv()) + except KeyboardInterrupt: + # this signals the dummyrunner to stop cleanly and should + # not lead to a traceback here. + pass
+ + +
[docs]def run_connect_wizard(): + """ + Run the linking wizard, for adding new external connections. + + """ + from .connection_wizard import ConnectionWizard, node_start + + wizard = ConnectionWizard() + node_start(wizard)
+ + +
[docs]def list_settings(keys): + """ + Display the server settings. We only display the Evennia specific + settings here. The result will be printed to the terminal. + + Args: + keys (str or list): Setting key or keys to inspect. + + """ + from importlib import import_module + + from evennia.utils import evtable + + evsettings = import_module(SETTINGS_DOTPATH) + if len(keys) == 1 and keys[0].upper() == "ALL": + # show a list of all keys + # a specific key + table = evtable.EvTable() + confs = [key for key in sorted(evsettings.__dict__) if key.isupper()] + for i in range(0, len(confs), 4): + table.add_row(*confs[i : i + 4]) + else: + # a specific key + table = evtable.EvTable(width=131) + keys = [key.upper() for key in keys] + confs = dict((key, var) for key, var in evsettings.__dict__.items() if key in keys) + for key, val in confs.items(): + table.add_row(key, str(val)) + print(table)
+ + +
[docs]def run_custom_commands(option, *args): + """ + Inject a custom option into the evennia launcher command chain. + + Args: + option (str): Incoming option - the first argument after `evennia` on + the command line. + *args: All args will passed to a found callable.__dict__ + + Returns: + bool: If a custom command was found and handled the option. + + Notes: + Provide new commands in settings with + + CUSTOM_EVENNIA_LAUNCHER_COMMANDS = {"mycmd": "path.to.callable", ...} + + The callable will be passed any `*args` given on the command line and is expected to + handle/validate the input correctly. Use like any other evennia command option on + in the terminal/console, for example: + + evennia mycmd foo bar + + """ + import importlib + + from django.conf import settings + + try: + # a dict of {option: callable(*args), ...} + custom_commands = settings.EXTRA_LAUNCHER_COMMANDS + except AttributeError: + return False + cmdpath = custom_commands.get(option) + if cmdpath: + modpath, *cmdname = cmdpath.rsplit(".", 1) + if cmdname: + cmdname = cmdname[0] + mod = importlib.import_module(modpath) + command = mod.__dict__.get(cmdname) + if command: + command(*args) + return True + return False
+ + +
[docs]def run_menu(): + """ + This launches an interactive menu. + + """ + while True: + # menu loop + gamedir = "/{}".format(os.path.basename(GAMEDIR)) + leninfo = len(gamedir) + line = "|" + " " * (61 - leninfo) + gamedir + " " * 2 + "|" + + print(MENU.format(gameinfo=line)) + inp = input(" option > ") + + # quitting and help + if inp.lower() == "q": + return + elif inp.lower() == "h": + print(HELP_ENTRY) + eval(input("press <return> to continue ...")) + continue + elif inp.lower() in ("v", "i", "a"): + print(show_version_info(about=True)) + eval(input("press <return> to continue ...")) + continue + + # options + try: + inp = int(inp) + except ValueError: + print("Not a valid option.") + continue + if inp == 1: + start_evennia(False, False) + elif inp == 2: + reload_evennia(False, False) + elif inp == 3: + stop_evennia() + elif inp == 4: + reboot_evennia(False, False) + elif inp == 5: + reload_evennia(False, True) + elif inp == 6: + stop_server_only() + elif inp == 7: + if _is_windows(): + print("This option is not supported on Windows.") + else: + kill(SERVER_PIDFILE, "Server") + elif inp == 8: + if _is_windows(): + print("This option is not supported on Windows.") + else: + kill(SERVER_PIDFILE, "Server") + kill(PORTAL_PIDFILE, "Portal") + elif inp == 9: + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=False) + tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, 20, 20) + print( + " Tailing logfiles {} (Ctrl-C to exit) ...".format( + _file_names_compact(SERVER_LOGFILE, PORTAL_LOGFILE) + ) + ) + elif inp == 10: + query_status() + elif inp == 11: + query_info() + elif inp == 12: + print("Running 'evennia --settings settings.py test .' ...") + Popen( + [sys.executable, __file__, "--settings", "settings.py", "test", "."], env=getenv() + ).wait() + elif inp == 13: + print("Running 'evennia test evennia' ...") + Popen([sys.executable, __file__, "test", "evennia"], env=getenv()).wait() + else: + print("Not a valid option.") + continue + return
+ + +
[docs]def main(): + """ + Run the evennia launcher main program. + + """ + # set up argument parser + + parser = ArgumentParser(description=CMDLINE_HELP, formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument( + "--gamedir", + nargs=1, + action="store", + dest="altgamedir", + metavar="<path>", + help="location of gamedir (default: current location)", + ) + parser.add_argument( + "--init", + action="store", + dest="init", + metavar="<gamename>", + help="creates a new gamedir 'name' at current location", + ) + parser.add_argument( + "--log", + "-l", + action="store_true", + dest="tail_log", + default=False, + help="tail the portal and server logfiles and print to stdout", + ) + parser.add_argument( + "--list", + nargs="+", + action="store", + dest="listsetting", + metavar="all|<key>", + help="list settings, use 'all' to list all available keys", + ) + parser.add_argument( + "--settings", + nargs=1, + action="store", + dest="altsettings", + default=None, + metavar="<path>", + help=( + "start evennia with alternative settings file from\n" + " gamedir/server/conf/. (default is settings.py)" + ), + ) + parser.add_argument( + "--initsettings", + action="store_true", + dest="initsettings", + default=False, + help="create a new, empty settings file as\n gamedir/server/conf/settings.py", + ) + parser.add_argument( + "--initmissing", + action="store_true", + dest="initmissing", + default=False, + help=( + "checks for missing secret_settings or server logs\n directory, and adds them if needed" + ), + ) + parser.add_argument( + "--profiler", + action="store_true", + dest="profiler", + default=False, + help="start given server component under the Python profiler", + ) + parser.add_argument( + "--dummyrunner", + nargs=1, + action="store", + dest="dummyrunner", + metavar="<N>", + help="test a server by connecting <N> dummy accounts to it", + ) + parser.add_argument( + "-v", + "--version", + action="store_true", + dest="show_version", + default=False, + help="show version info", + ) + + parser.add_argument("operation", nargs="?", default="noop", help=ARG_OPTIONS) + parser.epilog = ( + "Common Django-admin commands are shell, dbshell, test and migrate.\n" + "See the Django documentation for more management commands." + ) + + args, unknown_args = parser.parse_known_args() + + # handle arguments + option = args.operation + + # make sure we have everything + check_main_evennia_dependencies() + + if not args: + # show help pane + print(CMDLINE_HELP) + sys.exit() + + if args.altgamedir: + # use alternative gamedir path + global GAMEDIR + altgamedir = args.altgamedir[0] + if not os.path.isdir(altgamedir) and not args.init: + print(ERROR_NO_ALT_GAMEDIR.format(gamedir=altgamedir)) + sys.exit() + GAMEDIR = altgamedir + + if args.init: + # initialization of game directory + create_game_directory(args.init) + print( + CREATED_NEW_GAMEDIR.format( + gamedir=args.init, settings_path=os.path.join(args.init, SETTINGS_PATH) + ) + ) + sys.exit() + + if args.show_version: + # show the version info + print(show_version_info(option == "help")) + sys.exit() + + if args.altsettings: + # use alternative settings file + global SETTINGSFILE, SETTINGS_DOTPATH, ENFORCED_SETTING + sfile = args.altsettings[0] + SETTINGSFILE = sfile + ENFORCED_SETTING = True + SETTINGS_DOTPATH = "server.conf.%s" % sfile.rstrip(".py") + print("Using settings file '%s' (%s)." % (SETTINGSFILE, SETTINGS_DOTPATH)) + + if args.initsettings: + # create new settings file + try: + create_settings_file(init=False) + print(RECREATED_SETTINGS) + except IOError: + print(ERROR_INITSETTINGS) + sys.exit() + + if args.initmissing: + created = False + try: + log_path = os.path.join(SERVERDIR, "logs") + if not os.path.exists(log_path): + os.makedirs(log_path) + print(f" ... Created missing log dir {log_path}.") + created = True + + settings_path = os.path.join(CONFDIR, "secret_settings.py") + if not os.path.exists(settings_path): + create_settings_file(init=False, secret_settings=True) + print(f" ... Created missing secret_settings.py file as {settings_path}.") + created = True + + if created: + print(RECREATED_MISSING) + else: + print(" ... No missing resources to create/init. You are good to go.") + except IOError: + print(ERROR_INITMISSING) + sys.exit() + + if args.tail_log: + # set up for tailing the log files + global NO_REACTOR_STOP + NO_REACTOR_STOP = True + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=False) + + # adjust how many lines we show from existing logs + start_lines1, start_lines2 = 20, 20 + if option not in ("reload", "reset", "noop"): + start_lines1, start_lines2 = 0, 0 + + tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, start_lines1, start_lines2) + print( + " Tailing logfiles {} (Ctrl-C to exit) ...".format( + _file_names_compact(SERVER_LOGFILE, PORTAL_LOGFILE) + ) + ) + if args.dummyrunner: + # launch the dummy runner + init_game_directory(CURRENT_DIR, check_db=True) + run_dummyrunner(args.dummyrunner[0]) + elif args.listsetting: + # display all current server settings + init_game_directory(CURRENT_DIR, check_db=False) + list_settings(args.listsetting) + elif option == "menu": + # launch menu for operation + init_game_directory(CURRENT_DIR, check_db=True) + run_menu() + elif option in ( + "status", + "info", + "start", + "istart", + "ipstart", + "reload", + "restart", + "reboot", + "reset", + "stop", + "sstop", + "kill", + "skill", + "sstart", + "connections", + ): + # operate the server directly + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=True) + if option == "status": + query_status() + elif option == "info": + query_info() + elif option == "start": + init_game_directory(CURRENT_DIR, check_db=True) + error_check_python_modules(show_warnings=args.tail_log) + start_evennia(args.profiler, args.profiler) + elif option == "istart": + init_game_directory(CURRENT_DIR, check_db=True) + error_check_python_modules(show_warnings=args.tail_log) + start_server_interactive() + elif option == "ipstart": + start_portal_interactive() + elif option in ("reload", "restart"): + reload_evennia(args.profiler) + elif option == "reboot": + reboot_evennia(args.profiler, args.profiler) + elif option == "reset": + reload_evennia(args.profiler, reset=True) + elif option == "stop": + stop_evennia() + elif option == "sstop": + stop_server_only() + elif option == "sstart": + start_only_server() + elif option == "kill": + if _is_windows(): + print("This option is not supported on Windows.") + else: + kill(SERVER_PIDFILE, "Server") + kill(PORTAL_PIDFILE, "Portal") + elif option == "skill": + if _is_windows(): + print("This option is not supported on Windows.") + else: + kill(SERVER_PIDFILE, "Server") + elif option == "connections": + run_connect_wizard() + + elif option != "noop": + # pass-through to django manager, but set things up first + check_db = False + need_gamedir = True + + # handle special django commands + if option in ("runserver", "testserver"): + # we don't want the django test-webserver + print(WARNING_RUNSERVER) + if option in ("makemessages", "compilemessages"): + # some commands don't require the presence of a game directory to work + need_gamedir = False + if CURRENT_DIR != EVENNIA_LIB: + print( + "You must stand in the evennia/evennia/ folder (where the 'locale/' " + "folder is located) to run this command." + ) + sys.exit() + + if option in ("shell", "check", "makemigrations", "createsuperuser", "shell_plus"): + # some django commands requires the database to exist, + # or evennia._init to have run before they work right. + check_db = True + if option == "test": + global TEST_MODE + TEST_MODE = True + + # init the db/game dir, if needed + init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir) + + if option == "migrate": + # we need to bypass some checks here for the first db creation + if not check_database(always_return=True): + django.core.management.call_command(*([option] + unknown_args)) + sys.exit(0) + + if option in ("createsuperuser",): + print( + "Note: Don't create an additional superuser this way. It will not be set up " + "correctly.\n Instead, use the web admin or the in-game `py` command to " + "set `is_superuser=True` on a existing Account." + ) + sys.exit() + + if run_custom_commands(option, *unknown_args): + # run any custom commands + sys.exit() + else: + # pass on to the core django manager - re-parse the entire input line + # but keep 'evennia' as the name instead of django-admin. This is + # an exit condition. + sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0]) + sys.exit(execute_from_command_line(sys.argv)) + + elif not args.tail_log: + # no input; print evennia info (don't pring if we're tailing log) + print(ABOUT_INFO) + + if REACTOR_RUN: + reactor.run()
+ + +if __name__ == "__main__": + # start Evennia from the command line + main() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/game_index_client/client.html b/docs/latest/_modules/evennia/server/game_index_client/client.html new file mode 100644 index 0000000000..5670d96cfe --- /dev/null +++ b/docs/latest/_modules/evennia/server/game_index_client/client.html @@ -0,0 +1,283 @@ + + + + + + + + evennia.server.game_index_client.client — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.game_index_client.client

+"""
+The client for sending data to the Evennia Game Index
+
+"""
+import platform
+import urllib.error
+import urllib.parse
+import urllib.request
+
+import django
+from django.conf import settings
+from twisted.internet import defer, protocol, reactor
+from twisted.internet.defer import inlineCallbacks
+from twisted.web.client import Agent, HTTPConnectionPool, _HTTP11ClientFactory
+from twisted.web.http_headers import Headers
+from twisted.web.iweb import IBodyProducer
+from zope.interface import implementer
+
+import evennia
+from evennia.accounts.models import AccountDB
+from evennia.utils import get_evennia_version, logger
+
+_EGI_HOST = "http://evennia-game-index.appspot.com"
+_EGI_REPORT_PATH = "/api/v1/game/check_in"
+
+
+
[docs]class EvenniaGameIndexClient: + """ + This client class is used for gathering and sending game details to the + Evennia Game Index. Since EGI is in the early goings, this isn't + incredibly configurable as far as to what is being sent. + """ + +
[docs] def __init__(self, on_bad_request=None): + """ + on_bad_request (callable, optional): Callable to trigger when a bad request was sent. + + """ + self.report_host = _EGI_HOST + self.report_path = _EGI_REPORT_PATH + self.report_url = self.report_host + self.report_path + self.logged_first_connect = False + + self._on_bad_request = on_bad_request + # Oh, the humanity. Silence the factory start/stop messages. + self._conn_pool = HTTPConnectionPool(reactor) + self._conn_pool._factory = QuietHTTP11ClientFactory
+ +
[docs] @inlineCallbacks + def send_game_details(self): + """ + This is where the magic happens. Send details about the game to the + Evennia Game Index. + """ + status_code, response_body = yield self._form_and_send_request() + if status_code == 200: + if not self.logged_first_connect: + logger.log_infomsg("Successfully sent game details to Evennia Game Index.") + self.logged_first_connect = True + return + # At this point, either EGD is having issues or the payload we sent + # is improperly formed (probably due to mis-configuration). + logger.log_errmsg( + "Failed to send game details to Evennia Game Index. HTTP " + "status code was %s. Message was: %s" % (status_code, response_body) + ) + + if status_code == 400 and self._on_bad_request: + # Improperly formed request. Defer to the callback as far as what + # to do. Probably not a great idea to continue attempting to send + # to EGD, though. + self._on_bad_request()
+ + def _form_and_send_request(self): + """ + Build the request to send to the index. + + """ + agent = Agent(reactor, pool=self._conn_pool) + headers = { + b"User-Agent": [b"Evennia Game Index Client"], + b"Content-Type": [b"application/x-www-form-urlencoded"], + } + egi_config = settings.GAME_INDEX_LISTING + # We are using `or` statements below with dict.get() to avoid sending + # stringified 'None' values to the server. + try: + values = { + # Game listing stuff + "game_name": egi_config.get("game_name", settings.SERVERNAME), + "game_status": egi_config["game_status"], + "game_website": egi_config.get("game_website", ""), + "short_description": egi_config["short_description"], + "long_description": egi_config.get("long_description", ""), + "listing_contact": egi_config["listing_contact"], + # How to play + "telnet_hostname": egi_config.get("telnet_hostname", ""), + "telnet_port": egi_config.get("telnet_port", ""), + "web_client_url": egi_config.get("web_client_url", ""), + # Game stats + "connected_account_count": evennia.SESSION_HANDLER.account_count(), + "total_account_count": AccountDB.objects.num_total_accounts() or 0, + # System info + "evennia_version": get_evennia_version(), + "python_version": platform.python_version(), + "django_version": django.get_version(), + "server_platform": platform.platform(), + } + except KeyError as err: + raise KeyError(f"Error loading GAME_INDEX_LISTING: {err}") + + data = urllib.parse.urlencode(values) + + d = agent.request( + b"POST", + bytes(self.report_url, "utf-8"), + headers=Headers(headers), + bodyProducer=StringProducer(data), + ) + + d.addCallback(self.handle_egd_response) + return d + +
[docs] def handle_egd_response(self, response): + if 200 <= response.code < 300: + d = defer.succeed((response.code, "OK")) + else: + # Go through the horrifying process of getting the response body + # out of Twisted's plumbing. + d = defer.Deferred() + response.deliverBody(SimpleResponseReceiver(response.code, d)) + return d
+ + +
[docs]class SimpleResponseReceiver(protocol.Protocol): + """ + Used for pulling the response body out of an HTTP response. + """ + +
[docs] def __init__(self, status_code, d): + self.status_code = status_code + self.buf = "" + self.d = d
+ +
[docs] def dataReceived(self, data): + self.buf += data
+ +
[docs] def connectionLost(self, reason=protocol.connectionDone): + self.d.callback((self.status_code, self.buf))
+ + +
[docs]@implementer(IBodyProducer) +class StringProducer: + """ + Used for feeding a request body to the tx HTTP client. + """ + +
[docs] def __init__(self, body): + self.body = bytes(body, "utf-8") + self.length = len(body)
+ +
[docs] def startProducing(self, consumer): + consumer.write(self.body) + return defer.succeed(None)
+ +
[docs] def pauseProducing(self): + pass
+ +
[docs] def stopProducing(self): + pass
+ + +
[docs]class QuietHTTP11ClientFactory(_HTTP11ClientFactory): + """ + Silences the obnoxious factory start/stop messages in the default client. + """ + + noisy = False
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/game_index_client/service.html b/docs/latest/_modules/evennia/server/game_index_client/service.html new file mode 100644 index 0000000000..3f75914e42 --- /dev/null +++ b/docs/latest/_modules/evennia/server/game_index_client/service.html @@ -0,0 +1,164 @@ + + + + + + + + evennia.server.game_index_client.service — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.game_index_client.service

+"""
+Service for integrating the Evennia Game Index client into Evennia.
+
+"""
+from twisted.application.service import Service
+from twisted.internet import reactor
+from twisted.internet.task import LoopingCall
+
+from evennia.utils import logger
+
+from .client import EvenniaGameIndexClient
+
+# How many seconds to wait before triggering the first EGI check-in.
+_FIRST_UPDATE_DELAY = 10
+# How often to sync to the server
+_CLIENT_UPDATE_RATE = 60 * 30
+
+
+
[docs]class EvenniaGameIndexService(Service): + """ + Twisted Service that contains a LoopingCall for regularly sending game details + to the Evennia Game Index. + + """ + + # We didn't stick the Evennia prefix on here because it'd get marked as + # a core system service. + name = "GameIndexClient" + +
[docs] def __init__(self): + self.client = EvenniaGameIndexClient(on_bad_request=self._die_on_bad_request) + self.loop = LoopingCall(self.client.send_game_details)
+ +
[docs] def startService(self): + super().startService() + # Check to make sure that the client is configured. + # Start the loop, but only after a short delay. This allows the + # portal and the server time to sync up as far as total player counts. + # Prevents always reporting a count of 0. + reactor.callLater(_FIRST_UPDATE_DELAY, self.loop.start, _CLIENT_UPDATE_RATE)
+ +
[docs] def stopService(self): + if self.running == 0: + # reload errors if we've stopped this service. + return + super().stopService() + if self.loop.running: + self.loop.stop()
+ + def _die_on_bad_request(self): + """ + If it becomes apparent that our configuration is generating improperly + formed messages to EGI, we don't want to keep sending bad messages. + Stop the service so we're not wasting resources. + """ + logger.log_infomsg( + "Shutting down Evennia Game Index client service due to invalid configuration." + ) + self.stopService()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/initial_setup.html b/docs/latest/_modules/evennia/server/initial_setup.html new file mode 100644 index 0000000000..95abbafe0a --- /dev/null +++ b/docs/latest/_modules/evennia/server/initial_setup.html @@ -0,0 +1,331 @@ + + + + + + + + evennia.server.initial_setup — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.initial_setup

+"""
+This module handles initial database propagation, which is only run the first time the game starts.
+It will create some default objects (notably give #1 its evennia-specific properties, and create the
+Limbo room). It will also hooks, and then perform an initial restart.
+
+Everything starts at handle_setup()
+"""
+
+
+import time
+
+import evennia
+from django.conf import settings
+from django.utils.translation import gettext as _
+from evennia.accounts.models import AccountDB
+from evennia.server.models import ServerConfig
+from evennia.utils import create, logger
+
+ERROR_NO_SUPERUSER = """
+    No superuser exists yet. The superuser is the 'owner' account on
+    the Evennia server. Create a new superuser using the command
+
+       evennia createsuperuser
+
+    Follow the prompts, then restart the server.
+    """
+
+
+LIMBO_DESC = _(
+    """
+Welcome to your new |wEvennia|n-based game! Visit https://www.evennia.com if you need
+help, want to contribute, report issues or just join the community.
+
+As a privileged user, write |wbatchcommand tutorial_world.build|n to build
+tutorial content. Once built, try |wintro|n for starting help and |wtutorial|n to
+play the demo game.
+"""
+)
+
+
+WARNING_POSTGRESQL_FIX = """
+    PostgreSQL-psycopg2 compatibility fix:
+    The in-game channels {chan1}, {chan2} and {chan3} were created,
+    but the superuser was not yet connected to them. Please use in
+    game commands to connect Account #1 to those channels when first
+    logging in.
+"""
+
+
+def _get_superuser_account():
+    """
+    Get the superuser (created at the command line) and don't take no for an answer.
+
+    Returns:
+        Account: The first superuser (User #1).
+
+    Raises:
+        AccountDB.DoesNotExist: If the superuser couldn't be found.
+
+    """
+    try:
+        superuser = AccountDB.objects.get(id=1)
+    except AccountDB.DoesNotExist:
+        raise AccountDB.DoesNotExist(ERROR_NO_SUPERUSER)
+    return superuser
+
+
+
[docs]def create_objects(): + """ + Creates the #1 account and Limbo room. + + """ + + logger.log_info("Initial setup: Creating objects (Account #1 and Limbo room) ...") + + # Set the initial User's account object's username on the #1 object. + # This object is pure django and only holds name, email and password. + superuser = _get_superuser_account() + from evennia.objects.models import ObjectDB + + # Create an Account 'user profile' object to hold eventual + # mud-specific settings for the AccountDB object. + account_typeclass = settings.BASE_ACCOUNT_TYPECLASS + + # run all creation hooks on superuser (we must do so manually + # since the manage.py command does not) + superuser.swap_typeclass(account_typeclass, clean_attributes=True) + superuser.basetype_setup() + superuser.at_account_creation() + superuser.locks.add( + "examine:perm(Developer);edit:false();delete:false();boot:false();msg:all()" + ) + # this is necessary for quelling to work correctly. + superuser.permissions.add("Developer") + + # Limbo is the default "nowhere" starting room + + # Create the in-game god-character for account #1 and set + # it to exist in Limbo. + try: + superuser_character = ObjectDB.objects.get(id=1) + except ObjectDB.DoesNotExist: + superuser_character, errors = superuser.create_character( + key=superuser.username, nohome=True, description=_("This is User #1.") + ) + if errors: + raise Exception(str(errors)) + + superuser_character.locks.add( + "examine:perm(Developer);edit:false();delete:false();boot:false();msg:all();puppet:false()" + ) + # we set this low so that quelling is more useful + superuser_character.permissions.add("Developer") + superuser_character.save() + + superuser.attributes.add("_first_login", True) + superuser.attributes.add("_last_puppet", superuser_character) + + room_typeclass = settings.BASE_ROOM_TYPECLASS + try: + limbo_obj = ObjectDB.objects.get(id=2) + except ObjectDB.DoesNotExist: + limbo_obj = create.create_object(room_typeclass, _("Limbo"), nohome=True) + + limbo_obj.db_typeclass_path = room_typeclass + limbo_obj.db.desc = LIMBO_DESC.strip() + limbo_obj.save() + + # Now that Limbo exists, try to set the user up in Limbo (unless + # the creation hooks already fixed this). + if not superuser_character.location: + superuser_character.location = limbo_obj + if not superuser_character.home: + superuser_character.home = limbo_obj
+ + +
[docs]def at_initial_setup(): + """ + Custom hook for users to overload some or all parts of the initial + setup. Called very last in the sequence. It tries to import and + srun a module settings.AT_INITIAL_SETUP_HOOK_MODULE and will fail + silently if this does not exist or fails to load. + + """ + modname = settings.AT_INITIAL_SETUP_HOOK_MODULE + if not modname: + return + try: + mod = __import__(modname, fromlist=[None]) + except (ImportError, ValueError): + return + logger.log_info("Initial setup: Running at_initial_setup() hook.") + if mod.__dict__.get("at_initial_setup", None): + mod.at_initial_setup()
+ + +
[docs]def collectstatic(): + """ + Run collectstatic to make sure all web assets are loaded. + + """ + from django.core.management import call_command + + logger.log_info("Initial setup: Gathering static resources using 'collectstatic'") + call_command("collectstatic", "--noinput")
+ + +
[docs]def reset_server(): + """ + We end the initialization by resetting the server. This makes sure + the first login is the same as all the following ones, + particularly it cleans all caches for the special objects. It + also checks so the warm-reset mechanism works as it should. + + """ + if settings.TEST_ENVIRONMENT: + return + ServerConfig.objects.conf("server_epoch", time.time()) + + logger.log_info("Initial setup complete. Restarting Server once.") + evennia.SESSION_HANDLER.portal_reset_server()
+ + +
[docs]def handle_setup(last_step=None): + """ + Main logic for the module. It allows for restarting the + initialization at any point if one of the modules should crash. + + Args: + last_step (str, None): The last stored successful step, for starting + over on errors. None if starting from scratch. If this is 'done', + the function will exit immediately. + + """ + # setup sequence + setup_sequence = { + "create_objects": create_objects, + "at_initial_setup": at_initial_setup, + "collectstatic": collectstatic, + "done": reset_server, + } + + if last_step in ("done", -1): + # this means we don't need to handle setup since + # it already ran sucessfully once. -1 is the legacy + # value for existing databases. + return + + # determine the sequence so we can skip ahead + steps = list(setup_sequence) + steps = steps[steps.index(last_step) + 1 if last_step is not None else 0 :] + + # step through queue from last completed function. Once completed, + # the 'done' key should be set. + for stepname in steps: + try: + setup_sequence[stepname]() + except Exception: + # we re-raise to make sure to stop startup + raise + else: + # save the step + ServerConfig.objects.conf("last_initial_setup_step", stepname) + if stepname == "done": + # always exit on 'done' + break
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/inputfuncs.html b/docs/latest/_modules/evennia/server/inputfuncs.html new file mode 100644 index 0000000000..1f7be4539b --- /dev/null +++ b/docs/latest/_modules/evennia/server/inputfuncs.html @@ -0,0 +1,775 @@ + + + + + + + + evennia.server.inputfuncs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.inputfuncs

+"""
+Functions for processing input commands.
+
+All global functions in this module whose name does not start with "_"
+is considered an inputfunc. Each function must have the following
+callsign (where inputfunc name is always lower-case, no matter what the
+OOB input name looked like):
+
+    inputfunc(session, *args, **kwargs)
+
+Where "options" is always one of the kwargs, containing eventual
+protocol-options.
+There is one special function, the "default" function, which is called
+on a no-match. It has this callsign:
+
+    default(session, cmdname, *args, **kwargs)
+
+Evennia knows which modules to use for inputfuncs by
+settings.INPUT_FUNC_MODULES.
+
+"""
+
+import importlib
+from codecs import lookup as codecs_lookup
+
+from django.conf import settings
+
+from evennia.accounts.models import AccountDB
+from evennia.commands.cmdhandler import cmdhandler
+from evennia.utils.logger import log_err
+from evennia.utils.utils import to_str
+
+BrowserSessionStore = importlib.import_module(settings.SESSION_ENGINE).SessionStore
+
+
+# always let "idle" work since we use this in the webclient
+_IDLE_COMMAND = settings.IDLE_COMMAND
+_IDLE_COMMAND = (_IDLE_COMMAND,) if _IDLE_COMMAND == "idle" else (_IDLE_COMMAND, "idle")
+_GA = object.__getattribute__
+_SA = object.__setattr__
+
+
+_STRIP_INCOMING_MXP = settings.MXP_ENABLED and settings.MXP_OUTGOING_ONLY
+_STRIP_MXP = None
+
+
+def _NA(o):
+    return "N/A"
+
+
+def _maybe_strip_incoming_mxp(txt):
+    global _STRIP_MXP
+    if _STRIP_INCOMING_MXP:
+        if not _STRIP_MXP:
+            from evennia.utils.ansi import strip_mxp as _STRIP_MXP
+        return _STRIP_MXP(txt)
+    return txt
+
+
+_ERROR_INPUT = "Inputfunc {name}({session}): Wrong/unrecognized input: {inp}"
+
+
+# All global functions are inputfuncs available to process inputs
+
+
+
[docs]def text(session, *args, **kwargs): + """ + Main text input from the client. This will execute a command + string on the server. + + Args: + session (Session): The active Session to receive the input. + text (str): First arg is used as text-command input. Other + arguments are ignored. + + """ + + # from evennia.server.profiling.timetrace import timetrace + # text = timetrace(text, "ServerSession.data_in") + + txt = args[0] if args else None + + # explicitly check for None since text can be an empty string, which is + # also valid + if txt is None: + return + # this is treated as a command input + # handle the 'idle' command + if txt.strip() in _IDLE_COMMAND: + session.update_session_counters(idle=True) + return + + txt = _maybe_strip_incoming_mxp(txt) + + if session.account: + # nick replacement + puppet = session.puppet + if puppet: + txt = puppet.nicks.nickreplace(txt, categories=("inputline"), include_account=True) + else: + txt = session.account.nicks.nickreplace( + txt, categories=("inputline"), include_account=False + ) + kwargs.pop("options", None) + cmdhandler(session, txt, callertype="session", session=session, **kwargs) + session.update_session_counters()
+ + +
[docs]def bot_data_in(session, *args, **kwargs): + """ + Text input from the IRC and RSS bots. + This will trigger the execute_cmd method on the bots in-game counterpart. + + Args: + session (Session): The active Session to receive the input. + text (str): First arg is text input. Other arguments are ignored. + + """ + + txt = args[0] if args else None + + # Explicitly check for None since text can be an empty string, which is + # also valid + if txt is None: + return + # this is treated as a command input + # handle the 'idle' command + if txt.strip() in _IDLE_COMMAND: + session.update_session_counters(idle=True) + return + + txt = _maybe_strip_incoming_mxp(txt) + + kwargs.pop("options", None) + # Trigger the execute_cmd method of the corresponding bot. + session.account.execute_cmd(session=session, txt=txt, **kwargs) + session.update_session_counters()
+ + +
[docs]def echo(session, *args, **kwargs): + """ + Echo test function + """ + if _STRIP_INCOMING_MXP: + txt = strip_mxp(txt) + + session.data_out(text="Echo returns: %s" % args)
+ + +
[docs]def default(session, cmdname, *args, **kwargs): + """ + Default catch-function. This is like all other input functions except + it will get `cmdname` as the first argument. + + """ + err = ( + "Session {sessid}: Input command not recognized:\n" + " name: '{cmdname}'\n" + " args, kwargs: {args}, {kwargs}".format( + sessid=session.sessid, cmdname=cmdname, args=args, kwargs=kwargs + ) + ) + if session.protocol_flags.get("INPUTDEBUG", False): + session.msg(err) + log_err(err)
+ + +_CLIENT_OPTIONS = ( + "ANSI", + "XTERM256", + "MXP", + "UTF-8", + "SCREENREADER", + "ENCODING", + "MCCP", + "SCREENHEIGHT", + "SCREENWIDTH", + "INPUTDEBUG", + "RAW", + "NOCOLOR", + "NOGOAHEAD", + "LOCALECHO", +) + + +
[docs]def client_options(session, *args, **kwargs): + """ + This allows the client an OOB way to inform us about its name and capabilities. + This will be integrated into the session settings + + Keyword Args: + get (bool): If this is true, return the settings as a dict + (ignore all other kwargs). + client (str): A client identifier, like "mushclient". + version (str): A client version + ansi (bool): Supports ansi colors + xterm256 (bool): Supports xterm256 colors or not + mxp (bool): Supports MXP or not + utf-8 (bool): Supports UTF-8 or not + screenreader (bool): Screen-reader mode on/off + mccp (bool): MCCP compression on/off + screenheight (int): Screen height in lines + screenwidth (int): Screen width in characters + inputdebug (bool): Debug input functions + nocolor (bool): Strip color + raw (bool): Turn off parsing + localecho (bool): Turn on server-side echo (for clients not supporting it) + + """ + old_flags = session.protocol_flags + if not kwargs or kwargs.get("get", False): + # return current settings + options = dict((key, old_flags[key]) for key in old_flags if key.upper() in _CLIENT_OPTIONS) + session.msg(client_options=options) + return + + def validate_encoding(val): + # helper: change encoding + try: + codecs_lookup(val) + except LookupError: + raise RuntimeError("The encoding '|w%s|n' is invalid. " % val) + return val + + def validate_size(val): + return {0: int(val)} + + def validate_bool(val): + if isinstance(val, str): + return True if val.lower() in ("true", "on", "1") else False + return bool(val) + + flags = {} + for key, value in kwargs.items(): + key = key.lower() + if key == "client": + flags["CLIENTNAME"] = to_str(value) + elif key == "version": + if "CLIENTNAME" in flags: + flags["CLIENTNAME"] = "%s %s" % (flags["CLIENTNAME"], to_str(value)) + elif key == "ENCODING": + flags["ENCODING"] = validate_encoding(value) + elif key == "ansi": + flags["ANSI"] = validate_bool(value) + elif key == "xterm256": + flags["XTERM256"] = validate_bool(value) + elif key == "mxp": + flags["MXP"] = validate_bool(value) + elif key == "utf-8": + flags["UTF-8"] = validate_bool(value) + elif key == "screenreader": + flags["SCREENREADER"] = validate_bool(value) + elif key == "mccp": + flags["MCCP"] = validate_bool(value) + elif key == "screenheight": + flags["SCREENHEIGHT"] = validate_size(value) + elif key == "screenwidth": + flags["SCREENWIDTH"] = validate_size(value) + elif key == "inputdebug": + flags["INPUTDEBUG"] = validate_bool(value) + elif key == "nocolor": + flags["NOCOLOR"] = validate_bool(value) + elif key == "raw": + flags["RAW"] = validate_bool(value) + elif key == "nogoahead": + flags["NOGOAHEAD"] = validate_bool(value) + elif key == "localecho": + flags["LOCALECHO"] = validate_bool(value) + elif key in ( + "Char 1", + "Char.Skills 1", + "Char.Items 1", + "Room 1", + "IRE.Rift 1", + "IRE.Composer 1", + ): + # ignore mudlet's default send (aimed at IRE games) + pass + elif key not in ("options", "cmdid"): + err = _ERROR_INPUT.format(name="client_settings", session=session, inp=key) + session.msg(text=err) + + session.protocol_flags.update(flags) + # we must update the protocol flags on the portal session copy as well + session.sessionhandler.session_portal_partial_sync({session.sessid: {"protocol_flags": flags}})
+ + +
[docs]def get_client_options(session, *args, **kwargs): + """ + Alias wrapper for getting options. + """ + client_options(session, get=True)
+ + +
[docs]def get_inputfuncs(session, *args, **kwargs): + """ + Get the keys of all available inputfuncs. Note that we don't get + it from this module alone since multiple modules could be added. + So we get it from the sessionhandler. + """ + inputfuncsdict = dict( + (key, func.__doc__) for key, func in session.sessionhandler.get_inputfuncs().items() + ) + session.msg(get_inputfuncs=inputfuncsdict)
+ + +
[docs]def login(session, *args, **kwargs): + """ + Peform a login. This only works if session is currently not logged + in. This will also automatically throttle too quick attempts. + + Keyword Args: + name (str): Account name + password (str): Plain-text password + + """ + if not session.logged_in and "name" in kwargs and "password" in kwargs: + from evennia.commands.default.unloggedin import create_normal_account + + account = create_normal_account(session, kwargs["name"], kwargs["password"]) + if account: + session.sessionhandler.login(session, account)
+ + +_gettable = { + "name": lambda obj: obj.key, + "key": lambda obj: obj.key, + "location": lambda obj: obj.location.key if obj.location else "None", + "servername": lambda obj: settings.SERVERNAME, +} + + +
[docs]def get_value(session, *args, **kwargs): + """ + Return the value of a given attribute or db_property on the + session's current account or character. + + Keyword Args: + name (str): Name of info value to return. Only names + in the _gettable dictionary earlier in this module + are accepted. + + """ + name = kwargs.get("name", "") + obj = session.puppet or session.account + if name in _gettable: + session.msg(get_value={"name": name, "value": _gettable[name](obj)})
+ + +def _testrepeat(**kwargs): + """ + This is a test function for using with the repeat + inputfunc. + + Keyword Args: + session (Session): Session to return to. + """ + import time + + kwargs["session"].msg(repeat="Repeat called: %s" % time.time()) + + +_repeatable = {"test1": _testrepeat, "test2": _testrepeat} # example only # " + + +
[docs]def repeat(session, *args, **kwargs): + """ + Call a named function repeatedly. Note that + this is meant as an example of limiting the number of + possible call functions. + + Keyword Args: + callback (str): The function to call. Only functions + from the _repeatable dictionary earlier in this + module are available. + interval (int): How often to call function (s). + Defaults to once every 60 seconds with a minimum + of 5 seconds. + stop (bool): Stop a previously assigned ticker with + the above settings. + + """ + from evennia.scripts.tickerhandler import TICKER_HANDLER + + name = kwargs.get("callback", "") + interval = max(5, int(kwargs.get("interval", 60))) + + if name in _repeatable: + if kwargs.get("stop", False): + TICKER_HANDLER.remove( + interval, _repeatable[name], idstring=session.sessid, persistent=False + ) + else: + TICKER_HANDLER.add( + interval, + _repeatable[name], + idstring=session.sessid, + persistent=False, + session=session, + ) + else: + session.msg("Allowed repeating functions are: %s" % (", ".join(_repeatable)))
+ + +
[docs]def unrepeat(session, *args, **kwargs): + "Wrapper for OOB use" + kwargs["stop"] = True + repeat(session, *args, **kwargs)
+ + +_monitorable = {"name": "db_key", "location": "db_location", "desc": "desc"} + + +def _on_monitor_change(**kwargs): + fieldname = kwargs["fieldname"] + obj = kwargs["obj"] + name = kwargs["name"] + session = kwargs["session"] + outputfunc_name = kwargs["outputfunc_name"] + category = None + + # Attributes stored in the MonitorHandler with categories are + # stored as fieldname "db_value[category_name]", but we need to + # separate [category_name] because the actual attribute is stored on + # the object as "db_value" with a separate "category" field. + if hasattr(obj, "db_category") and obj.db_category != None: + category = obj.db_category + fieldname = fieldname.replace("[{}]".format(obj.db_category), '') + + # the session may be None if the char quits and someone + # else then edits the object + + if session: + callsign = { + outputfunc_name: { + "name": name, + **({"category": category} if category is not None else {}), + "value": _GA(obj, fieldname) + } + } + session.msg(**callsign) + + +
[docs]def monitor(session, *args, **kwargs): + """ + Adds monitoring to a given property or Attribute. + + Keyword Args: + name (str): The name of the property or Attribute + to report. No db_* prefix is needed. Only names + in the _monitorable dict earlier in this module + are accepted. + stop (bool): Stop monitoring the above name. + outputfunc_name (str, optional): Change the name of + the outputfunc name. This is used e.g. by MSDP which + has its own specific output format. + + """ + from evennia.scripts.monitorhandler import MONITOR_HANDLER + + name = kwargs.get("name", None) + outputfunc_name = kwargs.get("outputfunc_name", "monitor") + category = kwargs.get("category", None) + if name and name in _monitorable and session.puppet: + field_name = _monitorable[name] + obj = session.puppet + if kwargs.get("stop", False): + MONITOR_HANDLER.remove(obj, field_name, idstring=session.sessid) + else: + # the handler will add fieldname and obj to the kwargs automatically + MONITOR_HANDLER.add( + obj, + field_name, + _on_monitor_change, + idstring=session.sessid, + persistent=False, + name=name, + session=session, + outputfunc_name=outputfunc_name, + category=category, + )
+ + +
[docs]def unmonitor(session, *args, **kwargs): + """ + Wrapper for turning off monitoring + """ + kwargs["stop"] = True + monitor(session, *args, **kwargs)
+ + +
[docs]def monitored(session, *args, **kwargs): + """ + Report on what is being monitored + + """ + from evennia.scripts.monitorhandler import MONITOR_HANDLER + + obj = session.puppet + monitors = MONITOR_HANDLER.all(obj=obj) + session.msg(monitored=(monitors, {}))
+ + +def _on_webclient_options_change(**kwargs): + """ + Called when the webclient options stored on the account changes. + Inform the interested clients of this change. + """ + session = kwargs["session"] + obj = kwargs["obj"] + fieldname = kwargs["fieldname"] + clientoptions = _GA(obj, fieldname) + + # the session may be None if the char quits and someone + # else then edits the object + if session: + session.msg(webclient_options=clientoptions) + + +
[docs]def webclient_options(session, *args, **kwargs): + """ + Handles retrieving and changing of options related to the webclient. + + If kwargs is empty (or contains just a "cmdid"), the saved options will be + sent back to the session. + A monitor handler will be created to inform the client of any future options + that changes. + + If kwargs is not empty, the key/values stored in there will be persisted + to the account object. + + Keyword Args: + <option name>: an option to save + """ + account = session.account + + clientoptions = account.db._saved_webclient_options + if not clientoptions: + # No saved options for this account, copy and save the default. + account.db._saved_webclient_options = settings.WEBCLIENT_OPTIONS.copy() + # Get the _SaverDict created by the database. + clientoptions = account.db._saved_webclient_options + + # The webclient adds a cmdid to every kwargs, but we don't need it. + try: + del kwargs["cmdid"] + except KeyError: + pass + + if not kwargs: + # No kwargs: we are getting the stored options + # Convert clientoptions to regular dict for sending. + session.msg(webclient_options=dict(clientoptions)) + + # Create a monitor. If a monitor already exists then it will replace + # the previous one since it would use the same idstring + from evennia.scripts.monitorhandler import MONITOR_HANDLER + + MONITOR_HANDLER.add( + account, + "_saved_webclient_options", + _on_webclient_options_change, + idstring=session.sessid, + persistent=False, + session=session, + ) + else: + # kwargs provided: persist them to the account object. + clientoptions.update(kwargs)
+ + +# OOB protocol-specific aliases and wrappers + +# GMCP aliases +hello = client_options +supports_set = client_options + + +# MSDP aliases (some of the the generic MSDP commands defined in the MSDP spec are prefixed +# by msdp_ at the protocol level) +# See https://tintin.sourceforge.io/protocols/msdp/ + + +
[docs]def msdp_list(session, *args, **kwargs): + """ + MSDP LIST command + + """ + from evennia.scripts.monitorhandler import MONITOR_HANDLER + + args_lower = [arg.lower() for arg in args] + if "commands" in args_lower: + inputfuncs = [ + key[5:] if key.startswith("msdp_") else key + for key in session.sessionhandler.get_inputfuncs().keys() + ] + session.msg(commands=(inputfuncs, {})) + if "lists" in args_lower: + session.msg( + lists=( + [ + "commands", + "lists", + "configurable_variables", + "reportable_variables", + "reported_variables", + "sendable_variables", + ], + {}, + ) + ) + if "configurable_variables" in args_lower: + session.msg(configurable_variables=(_CLIENT_OPTIONS, {})) + if "reportable_variables" in args_lower: + session.msg(reportable_variables=(_monitorable, {})) + if "reported_variables" in args_lower: + obj = session.puppet + monitor_infos = MONITOR_HANDLER.all(obj=obj) + fieldnames = [tup[1] for tup in monitor_infos] + session.msg(reported_variables=(fieldnames, {})) + if "sendable_variables" in args_lower: + session.msg(sendable_variables=(_monitorable, {}))
+ + +
[docs]def msdp_report(session, *args, **kwargs): + """ + MSDP REPORT command + + """ + kwargs["outputfunc_name":"report"] + monitor(session, *args, **kwargs)
+ + +
[docs]def msdp_unreport(session, *args, **kwargs): + """ + MSDP UNREPORT command + + """ + unmonitor(session, *args, **kwargs)
+ + +
[docs]def msdp_send(session, *args, **kwargs): + """ + MSDP SEND command + """ + out = {} + for varname in args: + if varname.lower() in _monitorable: + out[varname] = _monitorable[varname.lower()] + session.msg(send=((), out))
+ + +# client specific + + +def _not_implemented(session, *args, **kwargs): + """ + Dummy used to swallow missing-inputfunc errors for + common clients. + """ + pass + + +# GMCP External.Discord.Hello is sent by Mudlet as a greeting +# (see https://wiki.mudlet.org/w/Manual:Technical_Manual) +external_discord_hello = _not_implemented + + +# GMCP Client.Gui is sent by Mudlet for gui setup. +client_gui = _not_implemented +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/manager.html b/docs/latest/_modules/evennia/server/manager.html new file mode 100644 index 0000000000..192e8c3776 --- /dev/null +++ b/docs/latest/_modules/evennia/server/manager.html @@ -0,0 +1,158 @@ + + + + + + + + evennia.server.manager — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.manager

+"""
+Custom manager for ServerConfig objects.
+"""
+from django.db import models
+
+
+
[docs]class ServerConfigManager(models.Manager): + """ + This ServerConfigManager implements methods for searching and + manipulating ServerConfigs directly from the database. + + These methods will all return database objects (or QuerySets) + directly. + + ServerConfigs are used to store certain persistent settings for + the server at run-time. + + """ + +
[docs] def conf(self, key=None, value=None, delete=False, default=None): + """ + Add, retrieve and manipulate config values. + + Args: + key (str, optional): Name of config. + value (str, optional): Data to store in this config value. + delete (bool, optional): If `True`, delete config with `key`. + default (str, optional): Use when retrieving a config value + by a key that does not exist. + Returns: + all (list): If `key` was not given - all stored config values. + value (str): If `key` was given, this is the stored value, or + `default` if no matching `key` was found. + + """ + if not key: + return self.all() + elif delete is True: + for conf in self.filter(db_key=key): + conf.delete() + elif value is not None: + conf = self.filter(db_key=key) + if conf: + conf = conf[0] + else: + conf = self.model(db_key=key) + conf.value = value # this will pickle + else: + conf = self.filter(db_key=key) + if not conf: + return default + return conf[0].value + return None
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/models.html b/docs/latest/_modules/evennia/server/models.html new file mode 100644 index 0000000000..e2de7b3a54 --- /dev/null +++ b/docs/latest/_modules/evennia/server/models.html @@ -0,0 +1,236 @@ + + + + + + + + evennia.server.models — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.models

+"""
+
+Server Configuration flags
+
+This holds persistent server configuration flags.
+
+Config values should usually be set through the
+manager's conf() method.
+
+"""
+from django.db import models
+
+from evennia.server.manager import ServerConfigManager
+from evennia.utils import logger, picklefield, utils
+from evennia.utils.dbserialize import from_pickle, to_pickle
+from evennia.utils.idmapper.models import WeakSharedMemoryModel
+
+# ------------------------------------------------------------
+#
+# ServerConfig
+#
+# ------------------------------------------------------------
+
+
+
[docs]class ServerConfig(WeakSharedMemoryModel): + """ + On-the fly storage of global settings. + + Properties defined on ServerConfig: + + - key: Main identifier + - value: Value stored in key. This is a pickled storage. + + """ + + # + # ServerConfig database model setup + # + # + # These database fields are all set using their corresponding properties, + # named same as the field, but without the db_* prefix. + + # main name of the database entry + db_key = models.CharField(max_length=64, unique=True) + # config value + # db_value = models.BinaryField(blank=True) + + db_value = picklefield.PickledObjectField( + "value", + null=True, + help_text="The data returned when the config value is accessed. Must be " + "written as a Python literal if editing through the admin " + "interface. Attribute values which are not Python literals " + "cannot be edited through the admin interface.", + ) + + objects = ServerConfigManager() + _is_deleted = False + + # Wrapper properties to easily set database fields. These are + # @property decorators that allows to access these fields using + # normal python operations (without having to remember to save() + # etc). So e.g. a property 'attr' has a get/set/del decorator + # defined that allows the user to do self.attr = value, + # value = self.attr and del self.attr respectively (where self + # is the object in question). + + # key property (wraps db_key) + # @property + def __key_get(self): + "Getter. Allows for value = self.key" + return self.db_key + + # @key.setter + def __key_set(self, value): + "Setter. Allows for self.key = value" + self.db_key = value + self.save() + + # @key.deleter + def __key_del(self): + "Deleter. Allows for del self.key. Deletes entry." + self.delete() + + key = property(__key_get, __key_set, __key_del) + + # value property (wraps db_value) + # @property + def __value_get(self): + "Getter. Allows for value = self.value" + return from_pickle(self.db_value, db_obj=self) + + # @value.setter + def __value_set(self, value): + "Setter. Allows for self.value = value" + if utils.has_parent("django.db.models.base.Model", value): + # we have to protect against storing db objects. + logger.log_err("ServerConfig cannot store db objects! (%s)" % value) + return + self.db_value = to_pickle(value) + self.save() + + # @value.deleter + def __value_del(self): + "Deleter. Allows for del self.value. Deletes entry." + self.delete() + + value = property(__value_get, __value_set, __value_del) + + class Meta: + "Define Django meta options" + verbose_name = "Server Config value" + verbose_name_plural = "Server Config values" + + # + # ServerConfig other methods + # + def __repr__(self): + return "<{} {}>".format(self.__class__.__name__, self.key) + +
[docs] def store(self, key, value): + """ + Wrap the storage. + + Args: + key (str): The name of this store. + value (str): The data to store with this `key`. + + """ + self.key = key + self.value = value
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/amp.html b/docs/latest/_modules/evennia/server/portal/amp.html new file mode 100644 index 0000000000..aa7f64d09f --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/amp.html @@ -0,0 +1,698 @@ + + + + + + + + evennia.server.portal.amp — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.amp

+"""
+The AMP (Asynchronous Message Protocol)-communication commands and constants used by Evennia.
+
+This module acts as a central place for AMP-servers and -clients to get commands to use.
+
+"""
+
+import pickle
+import time
+import zlib  # Used in Compressed class
+from collections import defaultdict, namedtuple
+from functools import wraps
+from io import BytesIO
+from itertools import count
+
+from twisted.internet.defer import Deferred, DeferredList
+from twisted.protocols import amp
+
+from evennia.utils.utils import variable_from_module
+
+# delayed import
+_LOGGER = None
+
+# communication bits
+# (chr(9) and chr(10) are \t and \n, so skipping them)
+
+PCONN = chr(1)  # portal session connect
+PDISCONN = chr(2)  # portal session disconnect
+PSYNC = chr(3)  # portal session sync
+SLOGIN = chr(4)  # server session login
+SDISCONN = chr(5)  # server session disconnect
+SDISCONNALL = chr(6)  # server session disconnect all
+SSHUTD = chr(7)  # server shutdown
+SSYNC = chr(8)  # server session sync
+SCONN = chr(11)  # server creating new connection (for irc bots and etc)
+PCONNSYNC = chr(12)  # portal post-syncing a session
+PDISCONNALL = chr(13)  # portal session disconnect all
+SRELOAD = chr(14)  # server shutdown in reload mode
+SSTART = chr(15)  # server start (portal must already be running anyway)
+PSHUTD = chr(16)  # portal (+server) shutdown
+SSHUTD = chr(17)  # server shutdown
+PSTATUS = chr(18)  # ping server or portal status
+SRESET = chr(19)  # server shutdown in reset mode
+
+NUL = b"\x00"
+NULNUL = b"\x00\x00"
+
+AMP_MAXLEN = amp.MAX_VALUE_LENGTH  # max allowed data length in AMP protocol (cannot be changed)
+
+# amp internal
+ASK = b"_ask"
+ANSWER = b"_answer"
+ERROR = b"_error"
+ERROR_CODE = b"_error_code"
+ERROR_DESCRIPTION = b"_error_description"
+UNKNOWN_ERROR_CODE = b"UNKNOWN"
+
+# buffers
+_SENDBATCH = defaultdict(list)
+_MSGBUFFER = defaultdict(list)
+
+# resources
+
+DUMMYSESSION = namedtuple("DummySession", ["sessid"])(0)
+
+
+_HTTP_WARNING = bytes(
+    """
+HTTP/1.1 200 OK
+Content-Type: text/html
+
+<html>
+  <body>
+    This is Evennia's internal AMP port. It handles communication
+    between Evennia's different processes.
+    <p>
+        <h3>This port should NOT be publicly visible.</h3>
+    </p>
+  </body>
+</html>""".strip(),
+    "utf-8",
+)
+
+
+# Helper functions for pickling.
+
+
+
[docs]def dumps(data): + return pickle.dumps(data, pickle.HIGHEST_PROTOCOL)
+ + +
[docs]def loads(data): + return pickle.loads(data)
+ + +def _get_logger(): + """ + Delay import of logger until absolutely necessary + + """ + global _LOGGER + if not _LOGGER: + from evennia.utils import logger as _LOGGER + return _LOGGER + + +@wraps +def catch_traceback(func): + """ + Helper decorator + + """ + + def decorator(*args, **kwargs): + try: + func(*args, **kwargs) + except Exception as err: + _get_logger().log_trace() + raise # make sure the error is visible on the other side of the connection too + print(err) + + return decorator + + +# AMP Communication Command types + + +
[docs]class Compressed(amp.String): + """ + This is a custom AMP command Argument that both handles too-long + sends as well as uses zlib for compression across the wire. The + batch-grouping of too-long sends is borrowed from the "mediumbox" + recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox. + + """ + +
[docs] def fromBox(self, name, strings, objects, proto): + """ + Converts from box string representation to python. We read back too-long batched data and + put it back together here. + + """ + + value = BytesIO() + value.write(self.fromStringProto(strings.get(name), proto)) + for counter in count(2): + # count from 2 upwards + chunk = strings.get(b"%s.%d" % (name, counter)) + if chunk is None: + break + value.write(self.fromStringProto(chunk, proto)) + objects[str(name, "utf-8")] = value.getvalue()
+ +
[docs] def toBox(self, name, strings, objects, proto): + """ + Convert from python object to string box representation. + we break up too-long data snippets into multiple batches here. + + """ + + # print("toBox: name={}, strings={}, objects={}, proto{}".format(name, strings, objects, proto)) + + value = BytesIO(objects[str(name, "utf-8")]) + strings[name] = self.toStringProto(value.read(AMP_MAXLEN), proto) + + # print("toBox strings[name] = {}".format(strings[name])) + + for counter in count(2): + chunk = value.read(AMP_MAXLEN) + if not chunk: + break + strings[b"%s.%d" % (name, counter)] = self.toStringProto(chunk, proto)
+ +
[docs] def toString(self, inObject): + """ + Convert to send as a bytestring on the wire, with compression. + + Note: In Py3 this is really a byte stream. + + """ + return zlib.compress(super().toString(inObject), 9)
+ +
[docs] def fromString(self, inString): + """ + Convert (decompress) from the string-representation on the wire to Python. + + """ + return super().fromString(zlib.decompress(inString))
+ + +
[docs]class MsgLauncher2Portal(amp.Command): + """ + Message Launcher -> Portal + + """ + + key = "MsgLauncher2Portal" + arguments = [(b"operation", amp.String()), (b"arguments", amp.String())] + errors = {Exception: b"EXCEPTION"} + response = []
+ + +
[docs]class MsgPortal2Server(amp.Command): + """ + Message Portal -> Server + + """ + + key = b"MsgPortal2Server" + arguments = [(b"packed_data", Compressed())] + errors = {Exception: b"EXCEPTION"} + response = []
+ + +
[docs]class MsgServer2Portal(amp.Command): + """ + Message Server -> Portal + + """ + + key = "MsgServer2Portal" + arguments = [(b"packed_data", Compressed())] + errors = {Exception: b"EXCEPTION"} + response = []
+ + +
[docs]class AdminPortal2Server(amp.Command): + """ + Administration Portal -> Server + + Sent when the portal needs to perform admin operations on the + server, such as when a new session connects or resyncs + + """ + + key = "AdminPortal2Server" + arguments = [(b"packed_data", Compressed())] + errors = {Exception: b"EXCEPTION"} + response = []
+ + +
[docs]class AdminServer2Portal(amp.Command): + """ + Administration Server -> Portal + + Sent when the server needs to perform admin operations on the + portal. + + """ + + key = "AdminServer2Portal" + arguments = [(b"packed_data", Compressed())] + errors = {Exception: b"EXCEPTION"} + response = []
+ + +
[docs]class MsgStatus(amp.Command): + """ + Check Status between AMP services + + """ + + key = "MsgStatus" + arguments = [(b"status", amp.String())] + errors = {Exception: b"EXCEPTION"} + response = [(b"status", amp.String())]
+ + +
[docs]class FunctionCall(amp.Command): + """ + Bidirectional Server <-> Portal + + Sent when either process needs to call an arbitrary function in + the other. This does not use the batch-send functionality. + + """ + + key = "FunctionCall" + arguments = [ + (b"module", amp.String()), + (b"function", amp.String()), + (b"args", amp.String()), + (b"kwargs", amp.String()), + ] + errors = {Exception: b"EXCEPTION"} + response = [(b"result", amp.String())]
+ + +# ------------------------------------------------------------- +# Core AMP protocol for communication Server <-> Portal +# ------------------------------------------------------------- + + +
[docs]class AMPMultiConnectionProtocol(amp.AMP): + """ + AMP protocol that safely handle multiple connections to the same + server without dropping old ones - new clients will receive + all server returns (broadcast). Will also correctly handle + erroneous HTTP requests on the port and return a HTTP error response. + + """ + + # helper methods + +
[docs] def __init__(self, *args, **kwargs): + """ + Initialize protocol with some things that need to be in place + already before connecting both on portal and server. + + """ + self.send_batch_counter = 0 + self.send_reset_time = time.time() + self.send_mode = True + self.send_task = None + self.multibatches = 0 + # later twisted amp has its own __init__ + super().__init__(*args, **kwargs)
+ + def _commandReceived(self, box): + """ + This overrides the default Twisted AMP error handling which is not + passing enough of the traceback through to the other side. Instead we + add a specific log of the problem on the erroring side. + + """ + + def formatAnswer(answerBox): + answerBox[ANSWER] = box[ASK] + return answerBox + + def formatError(error): + if error.check(amp.RemoteAmpError): + code = error.value.errorCode + desc = error.value.description + + # Evennia extra logging + desc += " (error logged on other side)" + _get_logger().log_err(f"AMP caught exception ({desc}):\n{error.value}") + + if isinstance(desc, str): + desc = desc.encode("utf-8", "replace") + if error.value.fatal: + errorBox = amp.QuitBox() + else: + errorBox = amp.AmpBox() + else: + errorBox = amp.QuitBox() + _get_logger().log_err(error) # server-side logging if unhandled error + code = UNKNOWN_ERROR_CODE + desc = b"Unknown Error" + errorBox[ERROR] = box[ASK] + errorBox[ERROR_DESCRIPTION] = desc + errorBox[ERROR_CODE] = code + return errorBox + + deferred = self.dispatchCommand(box) + if ASK in box: + deferred.addCallbacks(formatAnswer, formatError) + deferred.addCallback(self._safeEmit) + deferred.addErrback(self.unhandledError) + +
[docs] def stringReceived(self, string): + """ + Overrides the base stringReceived of twisted in order to handle + the strange error reported in https://github.com/evennia/evennia/issues/2053, + which can lead to the amp connection locking up. + + Args: + string (str): the data coming in. + + Notes: + + To test, add the following code to the beginning of + `evennia.server.amp_client.AMPServerClientProtocol.data_to_portal`, then + run multiple commands until the error trigger: + :: + + import random + from twisted.protocols.amp import AmpBox + always_fail = False + if always_fail or random.random() < 0.05: + breaker = AmpBox() + breaker['_answer'.encode()]='13541'.encode() + self.transport.write(breaker.serialize()) + + """ + try: + pto = "proto_" + self.state + statehandler = getattr(self, pto) + except AttributeError: + log.msg("callback", self.state, "not found") + else: + try: + # make sure to catch a KeyError cleanly here + self.state = statehandler(string) + if self.state == "done": + self.transport.loseConnection() + except KeyError as err: + _get_logger().log_err( + f"AMP error (KeyError: {err}). Discarded data (see " + "https://github.com/evennia/evennia/issues/2053)" + )
+ +
[docs] def dataReceived(self, data): + """ + Handle non-AMP messages, such as HTTP communication. + + """ + # print("dataReceived: {}".format(data)) + if data[:1] == NUL: + # an AMP communication + if data[-2:] != NULNUL: + # an incomplete AMP box means more batches are forthcoming. + self.multibatches += 1 + try: + super().dataReceived(data) + except KeyError: + _get_logger().log_trace( + "Discarded incoming partial (packed) data (len {})".format(len(data)) + ) + elif self.multibatches: + # invalid AMP, but we have a pending multi-batch that is not yet complete + if data[-2:] == NULNUL: + # end of existing multibatch + self.multibatches = max(0, self.multibatches - 1) + try: + super().dataReceived(data) + except KeyError: + _get_logger().log_trace( + "Discarded incoming multi-batch (packed) data (len {})".format(len(data)) + ) + else: + # not an AMP communication, return warning + self.transport.write(_HTTP_WARNING) + self.transport.loseConnection() + print("HTTP received (the AMP port should not receive http, only AMP!) %s" % data)
+ +
[docs] def makeConnection(self, transport): + """ + Swallow connection log message here. Copied from original + in the amp protocol. + + """ + # copied from original, removing the log message + if not self._ampInitialized: + amp.AMP.__init__(self) + self._transportPeer = transport.getPeer() + self._transportHost = transport.getHost() + amp.BinaryBoxProtocol.makeConnection(self, transport)
+ +
[docs] def connectionMade(self): + """ + This is called when an AMP connection is (re-)established. AMP calls it on both sides. + + """ + # print("connectionMade: {}".format(self)) + self.factory.broadcasts.append(self)
+ +
[docs] def connectionLost(self, reason): + """ + We swallow connection errors here. The reason is that during a + normal reload/shutdown there will almost always be cases where + either the portal or server shuts down before a message has + returned its (empty) return, triggering a connectionLost error + that is irrelevant. If a true connection error happens, the + portal will continuously try to reconnect, showing the problem + that way. + + """ + # print("ConnectionLost: {}: {}".format(self, reason)) + try: + self.factory.broadcasts.remove(self) + except ValueError: + pass
+ + # Error handling + +
[docs] def errback(self, err, info): + """ + Error callback. + Handles errors to avoid dropping connections on server tracebacks. + + Args: + err (Failure): Deferred error instance. + info (str): Error string. + + """ + err.trap(Exception) + _get_logger().log_err( + "AMP Error from {info}: {trcbck} {err}".format( + info=info, trcbck=err.getTraceback(), err=err.getErrorMessage() + ) + )
+ +
[docs] def data_in(self, packed_data): + """ + Process incoming packed data. + + Args: + packed_data (bytes): Pickled data. + Returns: + unpaced_data (any): Unpickled package + + """ + msg = loads(packed_data) + return msg
+ +
[docs] def broadcast(self, command, sessid, **kwargs): + """ + Send data across the wire to all connections. + + Args: + command (AMP Command): A protocol send command. + sessid (int): A unique Session id. + + Returns: + deferred (deferred or None): A deferred with an errback. + + Notes: + Data will be sent across the wire pickled as a tuple + (sessid, kwargs). + + """ + deferreds = [] + # print("broadcast: {} {}: {}".format(command, sessid, kwargs)) + + for protcl in self.factory.broadcasts: + deferreds.append( + protcl.callRemote(command, **kwargs).addErrback(self.errback, command.key) + ) + + return DeferredList(deferreds)
+ + # generic function send/recvs + +
[docs] def send_FunctionCall(self, modulepath, functionname, *args, **kwargs): + """ + Access method called by either process. This will call an arbitrary + function on the other process (On Portal if calling from Server and + vice versa). + + Inputs: + modulepath (str) - python path to module holding function to call + functionname (str) - name of function in given module + *args, **kwargs will be used as arguments/keyword args for the + remote function call + Returns: + A deferred that fires with the return value of the remote + function call + + """ + return ( + self.callRemote( + FunctionCall, + module=modulepath, + function=functionname, + args=dumps(args), + kwargs=dumps(kwargs), + ) + .addCallback(lambda r: loads(r["result"])) + .addErrback(self.errback, "FunctionCall") + )
+ +
[docs] @FunctionCall.responder + @catch_traceback + def receive_functioncall(self, module, function, func_args, func_kwargs): + """ + This allows Portal- and Server-process to call an arbitrary + function in the other process. It is intended for use by + plugin modules. + + Args: + module (str or module): The module containing the + `function` to call. + function (str): The name of the function to call in + `module`. + func_args (str): Pickled args tuple for use in `function` call. + func_kwargs (str): Pickled kwargs dict for use in `function` call. + + """ + args = loads(func_args) + kwargs = loads(func_kwargs) + + # call the function (don't catch tracebacks here) + result = variable_from_module(module, function)(*args, **kwargs) + + if isinstance(result, Deferred): + # if result is a deferred, attach handler to properly + # wrap the return value + result.addCallback(lambda r: {"result": dumps(r)}) + return result + else: + return {"result": dumps(result)}
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/amp_server.html b/docs/latest/_modules/evennia/server/portal/amp_server.html new file mode 100644 index 0000000000..40d6aa1a45 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/amp_server.html @@ -0,0 +1,595 @@ + + + + + + + + evennia.server.portal.amp_server — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.amp_server

+"""
+The Evennia Portal service acts as an AMP-server, handling AMP
+communication to the AMP clients connecting to it (by default
+these are the Evennia Server and the evennia launcher).
+
+"""
+import os
+import sys
+from subprocess import STDOUT, Popen
+
+from django.conf import settings
+from twisted.internet import protocol
+
+import evennia
+from evennia.server.portal import amp
+from evennia.utils import logger
+from evennia.utils.utils import class_from_module
+
+
+def _is_windows():
+    return os.name == "nt"
+
+
+
[docs]def getenv(): + """ + Get current environment and add PYTHONPATH. + + Returns: + env (dict): Environment global dict. + + """ + sep = ";" if _is_windows() else ":" + env = os.environ.copy() + env["PYTHONPATH"] = sep.join(sys.path) + return env
+ + +
[docs]class AMPServerFactory(protocol.ServerFactory): + + """ + This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the + 'Server' process. + + """ + + noisy = False + +
[docs] def logPrefix(self): + """ + How this is named in logs + + """ + return "AMP"
+ +
[docs] def __init__(self, portal): + """ + Initialize the factory. This is called as the Portal service starts. + + Args: + portal (Portal): The Evennia Portal service instance. + protocol (Protocol): The protocol the factory creates + instances of. + + """ + self.portal = portal + self.protocol = class_from_module(settings.AMP_SERVER_PROTOCOL_CLASS) + self.broadcasts = [] + self.server_connection = None + self.launcher_connection = None + self.disconnect_callbacks = {} + self.server_connect_callbacks = []
+ +
[docs] def buildProtocol(self, addr): + """ + Start a new connection, and store it on the service object. + + Args: + addr (str): Connection address. Not used. + + Returns: + protocol (Protocol): The created protocol. + + """ + self.portal.amp_protocol = self.protocol() + self.portal.amp_protocol.factory = self + return self.portal.amp_protocol
+ + +
[docs]class AMPServerProtocol(amp.AMPMultiConnectionProtocol): + """ + Protocol subclass for the AMP-server run by the Portal. + + """ + +
[docs] def connectionLost(self, reason): + """ + Set up a simple callback mechanism to let the amp-server wait for a connection to close. + + """ + # wipe broadcast and data memory + super().connectionLost(reason) + if self.factory.server_connection == self: + self.factory.server_connection = None + self.factory.portal.server_info_dict = {} + if self.factory.launcher_connection == self: + self.factory.launcher_connection = None + + callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None)) + if callback: + try: + callback(*args, **kwargs) + except Exception: + logger.log_trace()
+ +
[docs] def get_status(self): + """ + Return status for the Evennia infrastructure. + + Returns: + status (tuple): The portal/server status and pids + (portal_live, server_live, portal_PID, server_PID). + + """ + server_connected = bool( + self.factory.server_connection and self.factory.server_connection.transport.connected + ) + portal_info_dict = self.factory.portal.get_info_dict() + server_info_dict = self.factory.portal.server_info_dict + server_pid = self.factory.portal.server_process_id + portal_pid = os.getpid() + return (True, server_connected, portal_pid, server_pid, portal_info_dict, server_info_dict)
+ +
[docs] def data_to_server(self, command, sessid, **kwargs): + """ + Send data across the wire to the Server. + + Args: + command (AMP Command): A protocol send command. + sessid (int): A unique Session id. + kwargs (any): Data to send. This will be pickled. + + Returns: + deferred (deferred or None): A deferred with an errback. + + Notes: + Data will be sent across the wire pickled as a tuple + (sessid, kwargs). + + """ + # print("portal data_to_server: {}, {}, {}".format(command, sessid, kwargs)) + if self.factory.server_connection: + return self.factory.server_connection.callRemote( + command, packed_data=amp.dumps((sessid, kwargs)) + ).addErrback(self.errback, command.key) + else: + # if no server connection is available, broadcast + return self.broadcast(command, sessid, packed_data=amp.dumps((sessid, kwargs)))
+ +
[docs] def start_server(self, server_twistd_cmd): + """ + (Re-)Launch the Evennia server. + + Args: + server_twisted_cmd (list): The server start instruction + to pass to POpen to start the server. + + """ + # start the Server + print("Portal starting server ... ") + process = None + with open(settings.SERVER_LOG_FILE, "a") as logfile: + # we link stdout to a file in order to catch + # eventual errors happening before the Server has + # opened its logger. + try: + if _is_windows(): + # Windows requires special care + create_no_window = 0x08000000 + process = Popen( + server_twistd_cmd, + env=getenv(), + bufsize=-1, + stdout=logfile, + stderr=STDOUT, + creationflags=create_no_window, + ) + + else: + process = Popen( + server_twistd_cmd, env=getenv(), bufsize=-1, stdout=logfile, stderr=STDOUT + ) + except Exception: + logger.log_trace() + + self.factory.portal.server_twistd_cmd = server_twistd_cmd + logfile.flush() + if process and not _is_windows(): + # avoid zombie-process on Unix/BSD + process.wait() + # unset the reset-mode flag on the portal + self.factory.portal.server_restart_mode = None + return
+ +
[docs] def wait_for_disconnect(self, callback, *args, **kwargs): + """ + Add a callback for when this connection is lost. + + Args: + callback (callable): Will be called with *args, **kwargs + once this protocol is disconnected. + + """ + self.factory.disconnect_callbacks[self] = (callback, args, kwargs)
+ +
[docs] def wait_for_server_connect(self, callback, *args, **kwargs): + """ + Add a callback for when the Server is sure to have connected. + + Args: + callback (callable): Will be called with *args, **kwargs + once the Server handshake with Portal is complete. + + """ + self.factory.server_connect_callbacks.append((callback, args, kwargs))
+ +
[docs] def stop_server(self, mode="shutdown"): + """ + Shut down server in one or more modes. + + Args: + mode (str): One of 'shutdown', 'reload' or 'reset'. + + """ + if mode == "reload": + self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRELOAD) + elif mode == "reset": + self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRESET) + elif mode == "shutdown": + self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SSHUTD) + self.factory.portal.server_restart_mode = mode
+ + # sending amp data + +
[docs] def send_Status2Launcher(self): + """ + Send a status stanza to the launcher. + + """ + # print("send status to launcher") + # print("self.get_status(): {}".format(self.get_status())) + if self.factory.launcher_connection: + self.factory.launcher_connection.callRemote( + amp.MsgStatus, status=amp.dumps(self.get_status()) + ).addErrback(self.errback, amp.MsgStatus.key)
+ +
[docs] def send_MsgPortal2Server(self, session, **kwargs): + """ + Access method called by the Portal and executed on the Portal. + + Args: + session (session): Session + kwargs (any, optional): Optional data. + + Returns: + deferred (Deferred): Asynchronous return. + + """ + return self.data_to_server(amp.MsgPortal2Server, session.sessid, **kwargs)
+ +
[docs] def send_AdminPortal2Server(self, session, operation="", **kwargs): + """ + Send Admin instructions from the Portal to the Server. + Executed on the Portal. + + Args: + session (Session): Session. + operation (char, optional): Identifier for the server operation, as defined by the + global variables in `evennia/server/amp.py`. + data (str or dict, optional): Data used in the administrative operation. + + """ + return self.data_to_server( + amp.AdminPortal2Server, session.sessid, operation=operation, **kwargs + )
+ + # receive amp data + + @amp.MsgStatus.responder + @amp.catch_traceback + def portal_receive_status(self, status): + """ + Returns run-status for the server/portal. + + Args: + status (str): Not used. + Returns: + status (dict): The status is a tuple + (portal_running, server_running, portal_pid, server_pid). + + """ + # print('Received PSTATUS request') + return {"status": amp.dumps(self.get_status())} + + @amp.MsgLauncher2Portal.responder + @amp.catch_traceback + def portal_receive_launcher2portal(self, operation, arguments): + """ + Receives message arriving from evennia_launcher. + This method is executed on the Portal. + + Args: + operation (str): The action to perform. + arguments (str): Possible argument to the instruction, or the empty string. + + Returns: + result (dict): The result back to the launcher. + + Notes: + This is the entrypoint for controlling the entire Evennia system from the evennia + launcher. It can obviously only accessed when the Portal is already up and running. + + """ + # Since the launcher command uses amp.String() we need to convert from byte here. + operation = str(operation, "utf-8") + self.factory.launcher_connection = self + _, server_connected, _, _, _, _ = self.get_status() + + # logger.log_msg("Evennia Launcher->Portal operation %s:%s received" % (ord(operation), arguments)) + + # logger.log_msg("operation == amp.SSTART: {}: {}".format(operation == amp.SSTART, amp.loads(arguments))) + + if operation == amp.SSTART: # portal start #15 + # first, check if server is already running + if not server_connected: + self.wait_for_server_connect(self.send_Status2Launcher) + self.start_server(amp.loads(arguments)) + + elif operation == amp.SRELOAD: # reload server #14 + if server_connected: + # We let the launcher restart us once they get the signal + self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher) + self.stop_server(mode="reload") + else: + self.wait_for_server_connect(self.send_Status2Launcher) + self.start_server(amp.loads(arguments)) + + elif operation == amp.SRESET: # reload server #19 + if server_connected: + self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher) + self.stop_server(mode="reset") + else: + self.wait_for_server_connect(self.send_Status2Launcher) + self.start_server(amp.loads(arguments)) + + elif operation == amp.SSHUTD: # server-only shutdown #17 + if server_connected: + self.factory.server_connection.wait_for_disconnect(self.send_Status2Launcher) + self.stop_server(mode="shutdown") + + elif operation == amp.PSHUTD: # portal + server shutdown #16 + if server_connected: + self.factory.server_connection.wait_for_disconnect(self.factory.portal.shutdown) + else: + self.factory.portal.shutdown() + + else: + logger.log_err("Operation {} not recognized".format(operation)) + raise Exception("operation %(op)s not recognized." % {"op": operation}) + + return {} + + @amp.MsgServer2Portal.responder + @amp.catch_traceback + def portal_receive_server2portal(self, packed_data): + """ + Receives message arriving to Portal from Server. + This method is executed on the Portal. + + Args: + packed_data (str): Pickled data (sessid, kwargs) coming over the wire. + + """ + try: + sessid, kwargs = self.data_in(packed_data) + session = evennia.PORTAL_SESSION_HANDLER.get(sessid, None) + if session: + evennia.PORTAL_SESSION_HANDLER.data_out(session, **kwargs) + except Exception: + logger.log_trace("packed_data len {}".format(len(packed_data))) + return {} + + @amp.AdminServer2Portal.responder + @amp.catch_traceback + def portal_receive_adminserver2portal(self, packed_data): + """ + + Receives and handles admin operations sent to the Portal + This is executed on the Portal. + + Args: + packed_data (str): Data received, a pickled tuple (sessid, kwargs). + + """ + self.factory.server_connection = self + + sessid, kwargs = self.data_in(packed_data) + + # logger.log_msg("Evennia Server->Portal admin data %s:%s received" % (sessid, kwargs)) + + operation = kwargs.pop("operation") + portal_sessionhandler = evennia.PORTAL_SESSION_HANDLER + + if operation == amp.SLOGIN: # server_session_login + # a session has authenticated; sync it. + session = portal_sessionhandler.get(sessid) + if session: + portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata")) + + elif operation == amp.SDISCONN: # server_session_disconnect + # the server is ordering to disconnect the session + session = portal_sessionhandler.get(sessid) + if session: + portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason")) + + elif operation == amp.SDISCONNALL: # server_session_disconnect_all + # server orders all sessions to disconnect + portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason")) + + elif operation == amp.SRELOAD: # server reload + self.factory.server_connection.wait_for_disconnect( + self.start_server, self.factory.portal.server_twistd_cmd + ) + self.stop_server(mode="reload") + + elif operation == amp.SRESET: # server reset + self.factory.server_connection.wait_for_disconnect( + self.start_server, self.factory.portal.server_twistd_cmd + ) + self.stop_server(mode="reset") + + elif operation == amp.SSHUTD: # server-only shutdown + self.stop_server(mode="shutdown") + + elif operation == amp.PSHUTD: # full server+server shutdown + self.factory.server_connection.wait_for_disconnect(self.factory.portal.shutdown) + self.stop_server(mode="shutdown") + + elif operation == amp.PSYNC: # portal sync + # Server has (re-)connected and wants the session data from portal + self.factory.portal.server_info_dict = kwargs.get("info_dict", {}) + self.factory.portal.server_process_id = kwargs.get("spid", None) + # this defaults to 'shutdown' or whatever value set in server_stop + server_restart_mode = self.factory.portal.server_restart_mode + + sessdata = evennia.PORTAL_SESSION_HANDLER.get_all_sync_data() + self.send_AdminPortal2Server( + amp.DUMMYSESSION, + amp.PSYNC, + server_restart_mode=server_restart_mode, + sessiondata=sessdata, + portal_start_time=self.factory.portal.start_time, + ) + evennia.PORTAL_SESSION_HANDLER.at_server_connection() + + if self.factory.server_connection: + # this is an indication the server has successfully connected, so + # we trigger any callbacks (usually to tell the launcher server is up) + for callback, args, kwargs in self.factory.server_connect_callbacks: + try: + callback(*args, **kwargs) + except Exception: + logger.log_trace() + self.factory.server_connect_callbacks = [] + + elif operation == amp.SSYNC: # server_session_sync + # server wants to save session data to the portal, + # maybe because it's about to shut down. + portal_sessionhandler.server_session_sync( + kwargs.get("sessiondata"), kwargs.get("clean", True) + ) + + # set a flag in case we are about to shut down soon + self.factory.server_restart_mode = True + + elif operation == amp.SCONN: # server_force_connection (for irc/etc) + portal_sessionhandler.server_connect(**kwargs) + + else: + raise Exception("operation %(op)s not recognized." % {"op": operation}) + return {}
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/discord.html b/docs/latest/_modules/evennia/server/portal/discord.html new file mode 100644 index 0000000000..ae5c18b8e3 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/discord.html @@ -0,0 +1,677 @@ + + + + + + + + evennia.server.portal.discord — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.discord

+"""
+Implements Discord chat channel integration.
+
+The Discord API uses a mix of websockets and REST API endpoints.
+
+In order for this integration to work, you need to have your own
+discord bot set up via https://discord.com/developers/applications
+with the MESSAGE CONTENT toggle switched on, and your bot token
+added to `server/conf/secret_settings.py` as your  DISCORD_BOT_TOKEN
+"""
+import json
+import os
+from io import BytesIO
+from random import random
+
+from autobahn.twisted.websocket import (
+    WebSocketClientFactory,
+    WebSocketClientProtocol,
+    connectWS,
+)
+from django.conf import settings
+from twisted.internet import protocol, reactor, ssl, task
+from twisted.web.client import Agent, FileBodyProducer, HTTPConnectionPool, readBody
+from twisted.web.http_headers import Headers
+
+from evennia.server.session import Session
+from evennia.utils import class_from_module, get_evennia_version, logger
+from evennia.utils.utils import delay
+
+_BASE_SESSION_CLASS = class_from_module(settings.BASE_SESSION_CLASS)
+
+DISCORD_API_VERSION = 10
+# include version number to prevent automatically updating to breaking changes
+DISCORD_API_BASE_URL = f"https://discord.com/api/v{DISCORD_API_VERSION}"
+
+DISCORD_USER_AGENT = f"Evennia (https://www.evennia.com, {get_evennia_version(mode='short')})"
+DISCORD_BOT_TOKEN = settings.DISCORD_BOT_TOKEN
+DISCORD_BOT_INTENTS = settings.DISCORD_BOT_INTENTS
+
+# Discord OP codes, alphabetic
+OP_DISPATCH = 0
+OP_HEARTBEAT = 1
+OP_HEARTBEAT_ACK = 11
+OP_HELLO = 10
+OP_IDENTIFY = 2
+OP_INVALID_SESSION = 9
+OP_RECONNECT = 7
+OP_RESUME = 6
+
+
+# create quiet HTTP pool to muffle GET/POST requests
+
[docs]class QuietConnectionPool(HTTPConnectionPool): + """ + A quiet version of the HTTPConnectionPool which sets the factory's + `noisy` property to False to muffle log output. + """ + +
[docs] def __init__(self, reactor, persistent=True): + super().__init__(reactor, persistent) + self._factory.noisy = False
+ + +_AGENT = Agent(reactor, pool=QuietConnectionPool(reactor)) + + +
[docs]def should_retry(status_code): + """ + Helper function to check if the request should be retried later. + + Args: + status_code (int) - The HTTP status code + + Returns: + retry (bool) - True if request should be retried False otherwise + """ + if status_code >= 500 and status_code <= 504: + # these are common server error codes when the server is temporarily malfunctioning + # in these cases, we should retry + return True + else: + # handle all other cases; this can be expanded later if needed for special cases + return False
+ + +
[docs]class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.ReconnectingClientFactory): + """ + A customized websocket client factory that navigates the Discord gateway process. + + """ + + initialDelay = 1 + factor = 1.5 + maxDelay = 60 + noisy = False + gateway = None + resume_url = None + is_connecting = False + +
[docs] def __init__(self, sessionhandler, *args, **kwargs): + self.uid = kwargs.get("uid") + self.sessionhandler = sessionhandler + self.port = None + self.bot = None
+ +
[docs] def get_gateway_url(self, *args, **kwargs): + # get the websocket gateway URL from Discord + d = _AGENT.request( + b"GET", + f"{DISCORD_API_BASE_URL}/gateway".encode("utf-8"), + Headers( + { + "User-Agent": [DISCORD_USER_AGENT], + "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"], + "Content-Type": ["application/json"], + } + ), + None, + ) + + def cbResponse(response): + if response.code == 200: + d = readBody(response) + d.addCallback(self.websocket_init, *args, **kwargs) + return d + else: + logger.log_warn("Discord gateway request failed.") + + d.addCallback(cbResponse)
+ +
[docs] def websocket_init(self, payload, *args, **kwargs): + """ + callback for when the URL is gotten + """ + data = json.loads(str(payload, "utf-8")) + self.is_connecting = False + if url := data.get("url"): + self.gateway = f"{url}/?v={DISCORD_API_VERSION}&encoding=json".encode("utf-8") + useragent = kwargs.pop("useragent", DISCORD_USER_AGENT) + headers = kwargs.pop( + "headers", + { + "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"], + "Content-Type": ["application/json"], + }, + ) + + logger.log_info("Connecting to Discord Gateway...") + WebSocketClientFactory.__init__( + self, url, *args, headers=headers, useragent=useragent, **kwargs + ) + self.start() + else: + logger.log_err("Discord did not return a websocket URL; connection cancelled.")
+ +
[docs] def buildProtocol(self, addr): + """ + Build new instance of protocol + + Args: + addr (str): Not used, using factory/settings data + + """ + if hasattr(settings, "DISCORD_SESSION_CLASS"): + protocol_class = class_from_module( + settings.DISCORD_SESSION_CLASS, fallback=DiscordClient + ) + protocol = protocol_class() + else: + protocol = DiscordClient() + + protocol.factory = self + protocol.sessionhandler = self.sessionhandler + return protocol
+ +
[docs] def startedConnecting(self, connector): + """ + Tracks reconnections for debugging. + + Args: + connector (Connector): Represents the connection. + + """ + logger.log_info("Connecting to Discord...")
+ +
[docs] def reconnect(self): + """ + Force a reconnection of the bot protocol. This requires + de-registering the session and then reattaching a new one. + + """ + # set up the reconnection + if self.resume_url: + self.url = self.resume_url + elif self.gateway: + self.url = self.gateway + else: + # we don't know where to reconnect to! we'll start from the beginning + self.url = None + # reset the internal delay, since this is a deliberate disconnect + self.delay = self.initialDelay + # disconnect to allow the reconnection process to kick in + self.bot.sendClose() + self.sessionhandler.server_disconnect(self.bot)
+ +
[docs] def start(self): + "Connect protocol to remote server" + + if not self.gateway: + # we don't know where to connect to + # get the gateway URL from Discord + self.is_connecting = True + self.get_gateway_url() + elif not self.is_connecting: + # everything is good, connect + connectWS(self)
+ + +
[docs]class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): + """ + Implements the Discord client + """ + + nextHeartbeatCall = None + pending_heartbeat = False + heartbeat_interval = None + last_sequence = 0 + session_id = None + discord_id = None + +
[docs] def __init__(self): + WebSocketClientProtocol.__init__(self) + _BASE_SESSION_CLASS.__init__(self)
+ +
[docs] def at_login(self): + pass
+ +
[docs] def onOpen(self): + """ + Called when connection is established. + + """ + logger.log_msg("Discord connection established.") + self.factory.bot = self + + self.init_session("discord", "discord.gg", self.factory.sessionhandler) + self.uid = int(self.factory.uid) + self.logged_in = True + self.sessionhandler.connect(self)
+ +
[docs] def onMessage(self, payload, isBinary): + """ + Callback fired when a complete WebSocket message was received. + + Args: + payload (bytes): The WebSocket message received. + isBinary (bool): Flag indicating whether payload is binary or + UTF-8 encoded text. + + """ + if isBinary: + logger.log_info("DISCORD: got a binary payload for some reason") + return + data = json.loads(str(payload, "utf-8")) + if seqid := data.get("s"): + self.last_sequence = seqid + + # not sure if that error json format is for websockets, so + # check for it just in case + if "errors" in data: + self.handle_error(data) + return + + # check for discord gateway API op codes first + if data["op"] == OP_HELLO: + self.interval = data["d"]["heartbeat_interval"] / 1000 # convert millisec to seconds + if self.nextHeartbeatCall: + self.nextHeartbeatCall.cancel() + self.nextHeartbeatCall = self.factory._batched_timer.call_later( + self.interval * random(), + self.doHeartbeat, + ) + if self.session_id: + # we already have a session; try to resume instead + self.resume() + else: + self.identify() + elif data["op"] == OP_HEARTBEAT_ACK: + # our last heartbeat was acknowledged, so reset the "pending" flag + self.pending_heartbeat = False + elif data["op"] == OP_HEARTBEAT: + # Discord wants us to send a heartbeat immediately + self.doHeartbeat(force=True) + elif data["op"] == OP_INVALID_SESSION: + # Discord doesn't like our current session; reconnect for a new one + logger.log_msg("Discord: received 'Invalid Session' opcode. Reconnecting.") + if data["d"] == False: + # can't resume, clear existing resume data + self.session_id = None + self.factory.resume_url = None + self.factory.reconnect() + elif data["op"] == OP_RECONNECT: + # reconnect as requested; Discord does this regularly for server load balancing + logger.log_msg("Discord: received 'Reconnect' opcode. Reconnecting.") + self.factory.reconnect() + elif data["op"] == OP_DISPATCH: + # handle the general dispatch opcode events by type + if data["t"] == "READY": + # our recent identification is valid; process new session info + self.connection_ready(data["d"]) + else: + # general message, pass on to data_in + self.data_in(data=data)
+ +
[docs] def onClose(self, wasClean, code=None, reason=None): + """ + This is executed when the connection is lost for whatever + reason. it can also be called directly, from the disconnect + method. + + Args: + wasClean (bool): ``True`` if the WebSocket was closed cleanly. + code (int or None): Close status as sent by the WebSocket peer. + reason (str or None): Close reason as sent by the WebSocket peer. + + """ + self.sessionhandler.disconnect(self) + if self.nextHeartbeatCall: + self.nextHeartbeatCall.cancel() + self.nextHeartbeatCall = None + if wasClean: + logger.log_info(f"Discord connection closed ({code}) reason: {reason}") + else: + logger.log_info(f"Discord connection lost.")
+ + def _send_json(self, data): + """ + Post JSON data to the websocket + + Args: + data (dict): content to send. + + """ + return self.sendMessage(json.dumps(data).encode("utf-8")) + + def _post_json(self, url, data, **kwargs): + """ + Post JSON data to a REST API endpoint + + Args: + url (str) - The API path which is being posted to + data (dict) - Content to be sent + """ + url = f"{DISCORD_API_BASE_URL}/{url}" + body = FileBodyProducer(BytesIO(json.dumps(data).encode("utf-8"))) + request_type = kwargs.pop("type", "POST") + + d = _AGENT.request( + request_type.encode("utf-8"), + url.encode("utf-8"), + Headers( + { + "User-Agent": [DISCORD_USER_AGENT], + "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"], + "Content-Type": ["application/json"], + } + ), + body, + ) + + def cbResponse(response): + if response.code == 200 or response.code == 204: + d = readBody(response) + d.addCallback(self.post_response) + return d + elif should_retry(response.code): + delay(300, self._post_json, url, data, **kwargs) + + d.addCallback(cbResponse) + +
[docs] def post_response(self, body, **kwargs): + """ + Process the response from sending a POST request + + Args: + body (bytes) - The post response body + """ + data = json.loads(body) + if "errors" in data: + self.handle_error(data)
+ +
[docs] def handle_error(self, data, **kwargs): + """ + General hook for processing errors. + + Args: + data (dict) - The received error data + + """ + logger.log_err(str(data))
+ +
[docs] def resume(self): + """ + Called after a reconnection to re-identify and replay missed events + + """ + if not self.last_sequence or not self.session_id: + # we have no known state to resume from, identify normally + self.identify() + + # build a RESUME request for Discord and send it + data = { + "op": OP_RESUME, + "d": { + "token": DISCORD_BOT_TOKEN, + "session_id": self.session_id, + "s": self.sequence_id, + }, + } + self._send_json(data)
+ +
[docs] def disconnect(self, reason=None): + """ + Generic hook for the engine to call in order to + disconnect this protocol. + + Args: + reason (str or None): Motivation for the disconnection. + + """ + self.sendClose(self.CLOSE_STATUS_CODE_NORMAL, reason)
+ +
[docs] def identify(self, *args, **kwargs): + """ + Send Discord authentication. This should be sent once heartbeats begin. + + """ + data = { + "op": 2, + "d": { + "token": DISCORD_BOT_TOKEN, + "intents": DISCORD_BOT_INTENTS, + "properties": { + "os": os.name, + "browser": DISCORD_USER_AGENT, + "device": DISCORD_USER_AGENT, + }, + }, + } + self._send_json(data)
+ +
[docs] def connection_ready(self, data): + """ + Process READY data for relevant bot info. + """ + self.factory.resume_url = data["resume_gateway_url"] + self.session_id = data["session_id"] + self.discord_id = data["user"]["id"]
+ +
[docs] def doHeartbeat(self, *args, **kwargs): + """ + Send heartbeat to Discord. + + """ + if not self.pending_heartbeat or kwargs.get("force"): + if self.nextHeartbeatCall: + self.nextHeartbeatCall.cancel() + # send the heartbeat + data = {"op": 1, "d": self.last_sequence} + self._send_json(data) + # track that we sent a heartbeat, in case we don't receive an ACK + self.pending_heartbeat = True + self.nextHeartbeatCall = self.factory._batched_timer.call_later( + self.interval, + self.doHeartbeat, + ) + else: + # we didn't get a response since the last heartbeat; reconnect + self.factory.reconnect()
+ +
[docs] def send_channel(self, text, channel_id, **kwargs): + """ + Send a message from an Evennia channel to a Discord channel. + + Use with session.msg(channel=(message, channel, sender)) + + """ + + data = {"content": text} + data.update(kwargs) + self._post_json(f"channels/{channel_id}/messages", data)
+ +
[docs] def send_nickname(self, text, guild_id, user_id, **kwargs): + """ + Changes a user's nickname on a Discord server. + + Use with session.msg(nickname=(new_nickname, guild_id, user_id)) + """ + + data = {"nick": text} + data.update(kwargs) + self._post_json(f"guilds/{guild_id}/members/{user_id}", data, type="PATCH")
+ +
[docs] def send_role(self, role_id, guild_id, user_id, **kwargs): + data = kwargs + self._post_json(f"guilds/{guild_id}/members/{user_id}/roles/{role_id}", data, type="PUT")
+ +
[docs] def send_default(self, *args, **kwargs): + """ + Ignore other outputfuncs + + """ + pass
+ +
[docs] def data_in(self, data, **kwargs): + """ + Process incoming data from Discord and sent to the Evennia server + + Args: + data (dict): Converted json data. + + """ + action_type = data.get("t", "UNKNOWN") + + if action_type == "MESSAGE_CREATE": + # someone posted a message on Discord that the bot can see + data = data["d"] + if data["author"]["id"] == self.discord_id: + # it's by the bot itself! disregard + return + message = data["content"] + channel_id = data["channel_id"] + keywords = {"channel_id": channel_id} + if "guild_id" in data: + # message received to a Discord channel + keywords["type"] = "channel" + author = data["member"]["nick"] or data["author"]["username"] + author_id = data["author"]["id"] + keywords["sender"] = (author_id, author) + keywords["guild_id"] = data["guild_id"] + + else: + # message sent directly to the bot account via DM + keywords["type"] = "direct" + author = data["author"]["username"] + author_id = data["author"]["id"] + keywords["sender"] = (author_id, author) + + # pass the processed data to the server + self.sessionhandler.data_in(self, bot_data_in=(message, keywords)) + + elif action_type in ("GUILD_CREATE", "GUILD_UPDATE"): + # we received the current status of a guild the bot is on; process relevant info + data = data["d"] + keywords = {"type": "guild", "guild_id": data["id"], "guild_name": data["name"]} + keywords["channels"] = { + chan["id"]: {"name": chan["name"], "guild": data["name"]} + for chan in data["channels"] + if chan["type"] == 0 + } + # send the possibly-updated guild and channel data to the server + self.sessionhandler.data_in(self, bot_data_in=("", keywords)) + + elif "DELETE" in action_type: + # deletes should possibly be handled separately to check for channel removal + # for now, just ignore + pass + + else: + # send the data for any other action types on to the bot as-is for optional server-side handling + keywords = {"type": action_type} + keywords.update(data["d"]) + self.sessionhandler.data_in(self, bot_data_in=("", keywords))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/grapevine.html b/docs/latest/_modules/evennia/server/portal/grapevine.html new file mode 100644 index 0000000000..f936570450 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/grapevine.html @@ -0,0 +1,469 @@ + + + + + + + + evennia.server.portal.grapevine — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.grapevine

+"""
+Grapevine network connection
+
+This is an implementation of the Grapevine Websocket protocol v 1.0.0 as
+outlined here: https://grapevine.haus/docs
+
+This will allow the linked game to transfer status as well as connects
+the grapevine client to in-game channels.
+
+"""
+
+import json
+
+from autobahn.twisted.websocket import (
+    WebSocketClientFactory,
+    WebSocketClientProtocol,
+    connectWS,
+)
+from django.conf import settings
+from twisted.internet import protocol
+
+from evennia.server.session import Session
+from evennia.utils import get_evennia_version
+from evennia.utils.logger import log_err, log_info
+
+# There is only one at this time
+GRAPEVINE_URI = "wss://grapevine.haus/socket"
+
+GRAPEVINE_CLIENT_ID = settings.GRAPEVINE_CLIENT_ID
+GRAPEVINE_CLIENT_SECRET = settings.GRAPEVINE_CLIENT_SECRET
+GRAPEVINE_CHANNELS = settings.GRAPEVINE_CHANNELS
+
+# defined error codes
+CLOSE_NORMAL = 1000
+GRAPEVINE_AUTH_ERROR = 4000
+GRAPEVINE_HEARTBEAT_FAILURE = 4001
+
+
+
[docs]class RestartingWebsocketServerFactory(WebSocketClientFactory, protocol.ReconnectingClientFactory): + """ + A variant of the websocket-factory that auto-reconnects. + + """ + + initialDelay = 1 + factor = 1.5 + maxDelay = 60 + +
[docs] def __init__(self, sessionhandler, *args, **kwargs): + self.uid = kwargs.pop("uid") + self.channel = kwargs.pop("grapevine_channel") + self.sessionhandler = sessionhandler + + # self.noisy = False + self.port = None + self.bot = None + + WebSocketClientFactory.__init__(self, GRAPEVINE_URI, *args, **kwargs)
+ +
[docs] def buildProtocol(self, addr): + """ + Build new instance of protocol + + Args: + addr (str): Not used, using factory/settings data + + """ + protocol = GrapevineClient() + protocol.factory = self + protocol.channel = self.channel + protocol.sessionhandler = self.sessionhandler + return protocol
+ +
[docs] def startedConnecting(self, connector): + """ + Tracks reconnections for debugging. + + Args: + connector (Connector): Represents the connection. + + """ + log_info("(re)connecting to grapevine channel '%s'" % self.channel)
+ +
[docs] def clientConnectionFailed(self, connector, reason): + """ + Called when Client failed to connect. + + Args: + connector (Connection): Represents the connection. + reason (str): The reason for the failure. + + """ + protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason)
+ +
[docs] def clientConnectionLost(self, connector, reason): + """ + Called when Client loses connection. + + Args: + connector (Connection): Represents the connection. + reason (str): The reason for the failure. + + """ + if not (self.bot or (self.bot and self.bot.stopping)): + self.retry(connector)
+ +
[docs] def reconnect(self): + """ + Force a reconnection of the bot protocol. This requires + de-registering the session and then reattaching a new one, + otherwise you end up with an ever growing number of bot + sessions. + + """ + self.bot.stopping = True + self.bot.transport.loseConnection() + self.sessionhandler.server_disconnect(self.bot) + self.start()
+ +
[docs] def start(self): + "Connect protocol to remote server" + + try: + from twisted.internet import ssl + except ImportError: + log_err("To use Grapevine, The PyOpenSSL module must be installed.") + else: + context_factory = ssl.ClientContextFactory() if self.isSecure else None + connectWS(self, context_factory)
+ # service.name = "websocket/grapevine" + # self.sessionhandler.portal.services.addService(service) + + +
[docs]class GrapevineClient(WebSocketClientProtocol, Session): + """ + Implements the grapevine client + """ + +
[docs] def __init__(self): + WebSocketClientProtocol.__init__(self) + Session.__init__(self) + self.restart_downtime = None
+ +
[docs] def at_login(self): + pass
+ +
[docs] def onOpen(self): + """ + Called when connection is established. + + """ + self.restart_downtime = None + self.restart_task = None + + self.stopping = False + self.factory.bot = self + + self.init_session("grapevine", GRAPEVINE_URI, self.factory.sessionhandler) + self.uid = int(self.factory.uid) + self.logged_in = True + self.sessionhandler.connect(self) + + self.send_authenticate()
+ +
[docs] def onMessage(self, payload, isBinary): + """ + Callback fired when a complete WebSocket message was received. + + Args: + payload (bytes): The WebSocket message received. + isBinary (bool): Flag indicating whether payload is binary or + UTF-8 encoded text. + + """ + if not isBinary: + data = json.loads(str(payload, "utf-8")) + self.data_in(data=data) + self.retry_task = None
+ +
[docs] def onClose(self, wasClean, code=None, reason=None): + """ + This is executed when the connection is lost for whatever + reason. it can also be called directly, from the disconnect + method. + + Args: + wasClean (bool): ``True`` if the WebSocket was closed cleanly. + code (int or None): Close status as sent by the WebSocket peer. + reason (str or None): Close reason as sent by the WebSocket peer. + + """ + self.disconnect(reason) + + if code == GRAPEVINE_HEARTBEAT_FAILURE: + log_err("Grapevine connection lost (Heartbeat error)") + elif code == GRAPEVINE_AUTH_ERROR: + log_err("Grapevine connection lost (Auth error)") + elif self.restart_downtime: + # server previously warned us about downtime and told us to be + # ready to reconnect. + log_info("Grapevine connection lost (Server restart).")
+ + def _send_json(self, data): + """ + Send (json-) data to client. + + Args: + data (str): Text to send. + + """ + return self.sendMessage(json.dumps(data).encode("utf-8")) + +
[docs] def disconnect(self, reason=None): + """ + Generic hook for the engine to call in order to + disconnect this protocol. + + Args: + reason (str or None): Motivation for the disconnection. + + """ + self.sessionhandler.disconnect(self) + # autobahn-python: 1000 for a normal close, 3000-4999 for app. specific, + # in case anyone wants to expose this functionality later. + # + # sendClose() under autobahn/websocket/interfaces.py + self.sendClose(CLOSE_NORMAL, reason)
+ + # send_* method are automatically callable through .msg(heartbeat={}) etc + +
[docs] def send_authenticate(self, *args, **kwargs): + """ + Send grapevine authentication. This should be send immediately upon connection. + + """ + data = { + "event": "authenticate", + "payload": { + "client_id": GRAPEVINE_CLIENT_ID, + "client_secret": GRAPEVINE_CLIENT_SECRET, + "supports": ["channels"], + "channels": GRAPEVINE_CHANNELS, + "version": "1.0.0", + "user_agent": get_evennia_version("pretty"), + }, + } + # override on-the-fly + data.update(kwargs) + + self._send_json(data)
+ +
[docs] def send_heartbeat(self, *args, **kwargs): + """ + Send heartbeat to remote grapevine server. + + """ + # pass along all connected players + data = {"event": "heartbeat", "payload": {}} + sessions = self.sessionhandler.get_sessions(include_unloggedin=False) + data["payload"]["players"] = [ + sess.account.key for sess in sessions if hasattr(sess, "account") + ] + + self._send_json(data)
+ +
[docs] def send_subscribe(self, channelname, *args, **kwargs): + """ + Subscribe to new grapevine channel + + Use with session.msg(subscribe="channelname") + """ + data = {"event": "channels/subscribe", "payload": {"channel": channelname}} + self._send_json(data)
+ +
[docs] def send_unsubscribe(self, channelname, *args, **kwargs): + """ + Un-subscribe to a grapevine channel + + Use with session.msg(unsubscribe="channelname") + """ + data = {"event": "channels/unsubscribe", "payload": {"channel": channelname}} + self._send_json(data)
+ +
[docs] def send_channel(self, text, channel, sender, *args, **kwargs): + """ + Send text type Evennia -> grapevine + + This is the channels/send message type + + Use with session.msg(channel=(message, channel, sender)) + + """ + + data = { + "event": "channels/send", + "payload": {"message": text, "channel": channel, "name": sender}, + } + self._send_json(data)
+ +
[docs] def send_default(self, *args, **kwargs): + """ + Ignore other outputfuncs + + """ + pass
+ +
[docs] def data_in(self, data, **kwargs): + """ + Send data grapevine -> Evennia + + Keyword Args: + data (dict): Converted json data. + + """ + event = data["event"] + if event == "authenticate": + # server replies to our auth handshake + if data["status"] != "success": + log_err("Grapevine authentication failed.") + self.disconnect() + else: + log_info("Connected and authenticated to Grapevine network.") + elif event == "heartbeat": + # server sends heartbeat - we have to send one back + self.send_heartbeat() + elif event == "restart": + # set the expected downtime + self.restart_downtime = data["payload"]["downtime"] + elif event == "channels/subscribe": + # subscription verification + if data.get("status", "success") == "failure": + err = data.get("error", "N/A") + self.sessionhandler.data_in( + bot_data_in=((f"Grapevine error: {err}"), {"event": event}) + ) + elif event == "channels/unsubscribe": + # unsubscribe-verification + pass + elif event == "channels/broadcast": + # incoming broadcast from network + payload = data["payload"] + + # print("channels/broadcast:", payload["channel"], self.channel) + if str(payload["channel"]) != self.channel: + # only echo from channels this particular bot actually listens to + return + else: + # correct channel + self.sessionhandler.data_in( + self, + bot_data_in=( + str(payload["message"]), + { + "event": event, + "grapevine_channel": str(payload["channel"]), + "sender": str(payload["name"]), + "game": str(payload["game"]), + }, + ), + ) + elif event == "channels/send": + pass + else: + self.sessionhandler.data_in(self, bot_data_in=("", kwargs))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/irc.html b/docs/latest/_modules/evennia/server/portal/irc.html new file mode 100644 index 0000000000..2e5409f1b3 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/irc.html @@ -0,0 +1,584 @@ + + + + + + + + evennia.server.portal.irc — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.irc

+"""
+This connects to an IRC network/channel and launches an 'bot' onto it.
+The bot then pipes what is being said between the IRC channel and one or
+more Evennia channels.
+"""
+
+import re
+
+from twisted.application import internet
+from twisted.internet import protocol, reactor
+from twisted.words.protocols import irc
+
+from evennia.server.session import Session
+from evennia.utils import ansi, logger, utils
+
+# IRC colors
+
+IRC_BOLD = "\002"
+IRC_COLOR = "\003"
+IRC_RESET = "\017"
+IRC_ITALIC = "\026"
+IRC_INVERT = "\x16"
+IRC_NORMAL = "99"
+IRC_UNDERLINE = "37"
+
+IRC_WHITE = "0"
+IRC_BLACK = "1"
+IRC_DBLUE = "2"
+IRC_DGREEN = "3"
+IRC_RED = "4"
+IRC_DRED = "5"
+IRC_DMAGENTA = "6"
+IRC_DYELLOW = "7"
+IRC_YELLOW = "8"
+IRC_GREEN = "9"
+IRC_DCYAN = "10"
+IRC_CYAN = "11"
+IRC_BLUE = "12"
+IRC_MAGENTA = "13"
+IRC_DGREY = "14"
+IRC_GREY = "15"
+
+# obsolete test:
+
+# test evennia->irc:
+# |rred |ggreen |yyellow |bblue |mmagenta |ccyan |wwhite |xdgrey
+# |Rdred |Gdgreen |Ydyellow |Bdblue |Mdmagenta |Cdcyan |Wlgrey |Xblack
+# |[rredbg |[ggreenbg |[yyellowbg |[bbluebg |[mmagentabg |[ccyanbg |[wlgreybg |[xblackbg
+
+# test irc->evennia
+# Use Ctrl+C <num> to produce mIRC colors in e.g. irssi
+
+IRC_COLOR_MAP = dict(
+    (
+        (r"|n", IRC_COLOR + IRC_NORMAL),  # normal mode
+        (r"|H", IRC_RESET),  # un-highlight
+        (r"|/", "\n"),  # line break
+        (r"|t", "    "),  # tab
+        (r"|-", "    "),  # fixed tab
+        (r"|_", " "),  # space
+        (r"|*", IRC_INVERT),  # invert
+        (r"|^", ""),  # blinking text
+        (r"|h", IRC_BOLD),  # highlight, use bold instead
+        (r"|r", IRC_COLOR + IRC_RED),
+        (r"|g", IRC_COLOR + IRC_GREEN),
+        (r"|y", IRC_COLOR + IRC_YELLOW),
+        (r"|b", IRC_COLOR + IRC_BLUE),
+        (r"|m", IRC_COLOR + IRC_MAGENTA),
+        (r"|c", IRC_COLOR + IRC_CYAN),
+        (r"|w", IRC_COLOR + IRC_WHITE),  # pure white
+        (r"|x", IRC_COLOR + IRC_DGREY),  # dark grey
+        (r"|R", IRC_COLOR + IRC_DRED),
+        (r"|G", IRC_COLOR + IRC_DGREEN),
+        (r"|Y", IRC_COLOR + IRC_DYELLOW),
+        (r"|B", IRC_COLOR + IRC_DBLUE),
+        (r"|M", IRC_COLOR + IRC_DMAGENTA),
+        (r"|C", IRC_COLOR + IRC_DCYAN),
+        (r"|W", IRC_COLOR + IRC_GREY),  # light grey
+        (r"|X", IRC_COLOR + IRC_BLACK),  # pure black
+        (r"|[r", IRC_COLOR + IRC_NORMAL + "," + IRC_DRED),
+        (r"|[g", IRC_COLOR + IRC_NORMAL + "," + IRC_DGREEN),
+        (r"|[y", IRC_COLOR + IRC_NORMAL + "," + IRC_DYELLOW),
+        (r"|[b", IRC_COLOR + IRC_NORMAL + "," + IRC_DBLUE),
+        (r"|[m", IRC_COLOR + IRC_NORMAL + "," + IRC_DMAGENTA),
+        (r"|[c", IRC_COLOR + IRC_NORMAL + "," + IRC_DCYAN),
+        (r"|[w", IRC_COLOR + IRC_NORMAL + "," + IRC_GREY),  # light grey background
+        (r"|[x", IRC_COLOR + IRC_NORMAL + "," + IRC_BLACK),  # pure black background
+    )
+)
+# ansi->irc
+RE_ANSI_COLOR = re.compile(r"|".join([re.escape(key) for key in IRC_COLOR_MAP.keys()]), re.DOTALL)
+RE_MXP = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL)
+RE_ANSI_ESCAPES = re.compile(r"(%s)" % "|".join(("{{", "%%", "\\\\")), re.DOTALL)
+# irc->ansi
+_CLR_LIST = [
+    re.escape(val) for val in sorted(IRC_COLOR_MAP.values(), key=len, reverse=True) if val.strip()
+]
+_CLR_LIST = _CLR_LIST[-2:] + _CLR_LIST[:-2]
+RE_IRC_COLOR = re.compile(r"|".join(_CLR_LIST), re.DOTALL)
+ANSI_COLOR_MAP = dict((tup[1], tup[0]) for tup in IRC_COLOR_MAP.items() if tup[1].strip())
+
+
+
[docs]def parse_ansi_to_irc(string): + """ + Parse |-type syntax and replace with IRC color markers + + Args: + string (str): String to parse for ANSI colors. + + Returns: + parsed_string (str): String with replaced ANSI colors. + + """ + + def _sub_to_irc(ansi_match): + return IRC_COLOR_MAP.get(ansi_match.group(), "") + + in_string = utils.to_str(string) + parsed_string = [] + parts = RE_ANSI_ESCAPES.split(in_string) + [" "] + for part, sep in zip(parts[::2], parts[1::2]): + pstring = RE_ANSI_COLOR.sub(_sub_to_irc, part) + parsed_string.append("%s%s" % (pstring, sep[0].strip())) + # strip mxp + parsed_string = RE_MXP.sub(r"\2", "".join(parsed_string)) + return parsed_string
+ + +
[docs]def parse_irc_to_ansi(string): + """ + Parse IRC mIRC color syntax and replace with Evennia ANSI color markers + + Args: + string (str): String to parse for IRC colors. + + Returns: + parsed_string (str): String with replaced IRC colors. + + """ + + def _sub_to_ansi(irc_match): + return ANSI_COLOR_MAP.get(irc_match.group(), "") + + in_string = utils.to_str(string) + pstring = RE_IRC_COLOR.sub(_sub_to_ansi, in_string) + return pstring
+ + +# IRC bot + + +
[docs]class IRCBot(irc.IRCClient, Session): + """ + An IRC bot that tracks activity in a channel as well + as sends text to it when prompted + + """ + + lineRate = 1 + + # assigned by factory at creation + + nickname = None + logger = None + factory = None + channel = None + sourceURL = "http://code.evennia.com" + +
[docs] def signedOn(self): + """ + This is called when we successfully connect to the network. We + make sure to now register with the game as a full session. + + """ + self.join(self.channel) + self.stopping = False + self.factory.bot = self + address = "%s@%s" % (self.channel, self.network) + self.init_session("ircbot", address, self.factory.sessionhandler) + # we link back to our bot and log in + self.uid = int(self.factory.uid) + self.logged_in = True + self.factory.sessionhandler.connect(self) + logger.log_info( + "IRC bot '%s' connected to %s at %s:%s." + % (self.nickname, self.channel, self.network, self.port) + )
+ +
[docs] def disconnect(self, reason=""): + """ + Called by sessionhandler to disconnect this protocol. + + Args: + reason (str): Motivation for the disconnect. + + """ + self.sessionhandler.disconnect(self) + self.stopping = True + self.transport.loseConnection()
+ +
[docs] def at_login(self): + pass
+ +
[docs] def privmsg(self, user, channel, msg): + """ + Called when the connected channel receives a message. + + Args: + user (str): User name sending the message. + channel (str): Channel name seeing the message. + msg (str): The message arriving from channel. + + """ + if channel == self.nickname: + # private message + user = user.split("!", 1)[0] + self.data_in(text=msg, type="privmsg", user=user, channel=channel) + elif not msg.startswith("***"): + # channel message + user = user.split("!", 1)[0] + user = ansi.raw(user) + self.data_in(text=msg, type="msg", user=user, channel=channel)
+ +
[docs] def action(self, user, channel, msg): + """ + Called when an action is detected in channel. + + Args: + user (str): User name sending the message. + channel (str): Channel name seeing the message. + msg (str): The message arriving from channel. + + """ + if not msg.startswith("**"): + user = user.split("!", 1)[0] + self.data_in(text=msg, type="action", user=user, channel=channel)
+ +
[docs] def get_nicklist(self): + """ + Retrieve name list from the channel. The return + is handled by the catch methods below. + + """ + if not self.nicklist: + self.sendLine("NAMES %s" % self.channel)
+ +
[docs] def irc_RPL_NAMREPLY(self, prefix, params): + """ "Handles IRC NAME request returns (nicklist)""" + channel = params[2].lower() + if channel != self.channel.lower(): + return + self.nicklist += params[3].split(" ")
+ +
[docs] def irc_RPL_ENDOFNAMES(self, prefix, params): + """Called when the nicklist has finished being returned.""" + channel = params[1].lower() + if channel != self.channel.lower(): + return + self.data_in( + text="", type="nicklist", user="server", channel=channel, nicklist=self.nicklist + ) + self.nicklist = []
+ +
[docs] def pong(self, user, time): + """ + Called with the return timing from a PING. + + Args: + user (str): Name of user + time (float): Ping time in secs. + + """ + self.data_in(text="", type="ping", user="server", channel=self.channel, timing=time)
+ +
[docs] def data_in(self, text=None, **kwargs): + """ + Data IRC -> Server. + + Keyword Args: + text (str): Ingoing text. + kwargs (any): Other data from protocol. + + """ + self.sessionhandler.data_in(self, bot_data_in=[parse_irc_to_ansi(text), kwargs])
+ +
[docs] def send_channel(self, *args, **kwargs): + """ + Send channel text to IRC channel (visible to all). Note that + we don't handle the "text" send (it's rerouted to send_default + which does nothing) - this is because the IRC bot is a normal + session and would otherwise report anything that happens to it + to the IRC channel (such as it seeing server reload messages). + + Args: + text (str): Outgoing text + + """ + text = args[0] if args else "" + if text: + text = parse_ansi_to_irc(text) + self.say(self.channel, text)
+ +
[docs] def send_privmsg(self, *args, **kwargs): + """ + Send message only to specific user. + + Args: + text (str): Outgoing text. + + Keyword Args: + user (str): the nick to send + privately to. + + """ + text = args[0] if args else "" + user = kwargs.get("user", None) + if text and user: + text = parse_ansi_to_irc(text) + self.msg(user, text)
+ +
[docs] def send_request_nicklist(self, *args, **kwargs): + """ + Send a request for the channel nicklist. The return (handled + by `self.irc_RPL_ENDOFNAMES`) will be sent back as a message + with type `nicklist'. + """ + self.get_nicklist()
+ +
[docs] def send_ping(self, *args, **kwargs): + """ + Send a ping. The return (handled by `self.pong`) will be sent + back as a message of type 'ping'. + """ + self.ping(self.nickname)
+ +
[docs] def send_reconnect(self, *args, **kwargs): + """ + The server instructs us to rebuild the connection by force, + probably because the client silently lost connection. + """ + self.factory.reconnect()
+ +
[docs] def send_default(self, *args, **kwargs): + """ + Ignore other types of sends. + + """ + pass
+ + +
[docs]class IRCBotFactory(protocol.ReconnectingClientFactory): + """ + Creates instances of IRCBot, connecting with a staggered + increase in delay + + """ + + # scaling reconnect time + initialDelay = 1 + factor = 1.5 + maxDelay = 60 + +
[docs] def __init__( + self, + sessionhandler, + uid=None, + botname=None, + channel=None, + network=None, + port=None, + ssl=None, + ): + """ + Storing some important protocol properties. + + Args: + sessionhandler (SessionHandler): Reference to the main Sessionhandler. + + Keyword Args: + uid (int): Bot user id. + botname (str): Bot name (seen in IRC channel). + channel (str): IRC channel to connect to. + network (str): Network address to connect to. + port (str): Port of the network. + ssl (bool): Indicates SSL connection. + + """ + self.sessionhandler = sessionhandler + self.uid = uid + self.nickname = str(botname) + self.channel = str(channel) + self.network = str(network) + self.port = port + self.ssl = ssl + self.bot = None + self.nicklists = {}
+ +
[docs] def buildProtocol(self, addr): + """ + Build the protocol and assign it some properties. + + Args: + addr (str): Not used; using factory data. + + """ + protocol = IRCBot() + protocol.factory = self + protocol.nickname = self.nickname + protocol.channel = self.channel + protocol.network = self.network + protocol.port = self.port + protocol.ssl = self.ssl + protocol.nicklist = [] + return protocol
+ +
[docs] def startedConnecting(self, connector): + """ + Tracks reconnections for debugging. + + Args: + connector (Connector): Represents the connection. + + """ + logger.log_info("(re)connecting to %s" % self.channel)
+ +
[docs] def clientConnectionFailed(self, connector, reason): + """ + Called when Client failed to connect. + + Args: + connector (Connection): Represents the connection. + reason (str): The reason for the failure. + + """ + self.retry(connector)
+ +
[docs] def clientConnectionLost(self, connector, reason): + """ + Called when Client loses connection. + + Args: + connector (Connection): Represents the connection. + reason (str): The reason for the failure. + + """ + if not (self.bot or (self.bot and self.bot.stopping)): + self.retry(connector)
+ +
[docs] def reconnect(self): + """ + Force a reconnection of the bot protocol. This requires + de-registering the session and then reattaching a new one, + otherwise you end up with an ever growing number of bot + sessions. + + """ + self.bot.stopping = True + self.bot.transport.loseConnection() + self.sessionhandler.server_disconnect(self.bot) + self.start()
+ +
[docs] def start(self): + """ + Connect session to sessionhandler. + + """ + if self.port: + if self.ssl: + try: + from twisted.internet import ssl + + service = reactor.connectSSL( + self.network, int(self.port), self, ssl.ClientContextFactory() + ) + except ImportError: + logger.log_err("To use SSL, the PyOpenSSL module must be installed.") + else: + service = internet.TCPClient(self.network, int(self.port), self) + self.sessionhandler.portal.services.addService(service)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/mccp.html b/docs/latest/_modules/evennia/server/portal/mccp.html new file mode 100644 index 0000000000..9ebabb942b --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/mccp.html @@ -0,0 +1,194 @@ + + + + + + + + evennia.server.portal.mccp — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.mccp

+"""
+
+MCCP - Mud Client Compression Protocol
+
+This implements the MCCP v2 telnet protocol as per
+http://tintin.sourceforge.net/mccp/. MCCP allows for the server to
+compress data when sending to supporting clients, reducing bandwidth
+by 70-90%.. The compression is done using Python's builtin zlib
+library. If the client doesn't support MCCP, server sends uncompressed
+as normal.  Note: On modern hardware you are not likely to notice the
+effect of MCCP unless you have extremely heavy traffic or sits on a
+terribly slow connection.
+
+This protocol is implemented by the telnet protocol importing
+mccp_compress and calling it from its write methods.
+"""
+import zlib
+
+# negotiations for v1 and v2 of the protocol
+MCCP = bytes([86])  # b"\x56"
+FLUSH = zlib.Z_SYNC_FLUSH
+
+
+
[docs]def mccp_compress(protocol, data): + """ + Handles zlib compression, if applicable. + + Args: + data (str): Incoming data to compress. + + Returns: + stream (binary): Zlib-compressed data. + + """ + if hasattr(protocol, "zlib"): + return protocol.zlib.compress(data) + protocol.zlib.flush(FLUSH) + return data
+ + +
[docs]class Mccp: + """ + Implements the MCCP protocol. Add this to a + variable on the telnet protocol to set it up. + + """ + +
[docs] def __init__(self, protocol): + """ + initialize MCCP by storing protocol on + ourselves and calling the client to see if + it supports MCCP. Sets callbacks to + start zlib compression in that case. + + Args: + protocol (Protocol): The active protocol instance. + + """ + + self.protocol = protocol + self.protocol.protocol_flags["MCCP"] = False + # ask if client will mccp, connect callbacks to handle answer + self.protocol.will(MCCP).addCallbacks(self.do_mccp, self.no_mccp)
+ +
[docs] def no_mccp(self, option): + """ + Called if client doesn't support mccp or chooses to turn it off. + + Args: + option (Option): Option dict (not used). + + """ + if hasattr(self.protocol, "zlib"): + del self.protocol.zlib + self.protocol.protocol_flags["MCCP"] = False + self.protocol.handshake_done()
+ +
[docs] def do_mccp(self, option): + """ + The client supports MCCP. Set things up by + creating a zlib compression stream. + + Args: + option (Option): Option dict (not used). + + """ + self.protocol.protocol_flags["MCCP"] = True + self.protocol.requestNegotiation(MCCP, b"") + self.protocol.zlib = zlib.compressobj(9) + self.protocol.handshake_done()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/mssp.html b/docs/latest/_modules/evennia/server/portal/mssp.html new file mode 100644 index 0000000000..a3cc80c930 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/mssp.html @@ -0,0 +1,240 @@ + + + + + + + + evennia.server.portal.mssp — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.mssp

+"""
+
+MSSP - Mud Server Status Protocol
+
+This implements the MSSP telnet protocol as per
+http://tintin.sourceforge.net/mssp/.  MSSP allows web portals and
+listings to have their crawlers find the mud and automatically
+extract relevant information about it, such as genre, how many
+active players and so on.
+
+
+"""
+from django.conf import settings
+
+from evennia.utils import utils
+
+MSSP = bytes([70])  # b"\x46"
+MSSP_VAR = bytes([1])  # b"\x01"
+MSSP_VAL = bytes([2])  # b"\x02"
+
+# try to get the customized mssp info, if it exists.
+MSSPTable_CUSTOM = utils.variable_from_module(settings.MSSP_META_MODULE, "MSSPTable", default={})
+
+
+
[docs]class Mssp: + """ + Implements the MSSP protocol. Add this to a variable on the telnet + protocol to set it up. + + """ + +
[docs] def __init__(self, protocol): + """ + initialize MSSP by storing protocol on ourselves and calling + the client to see if it supports MSSP. + + Args: + protocol (Protocol): The active protocol instance. + + """ + self.protocol = protocol + self.protocol.will(MSSP).addCallbacks(self.do_mssp, self.no_mssp)
+ +
[docs] def get_player_count(self): + """ + Get number of logged-in players. + + Returns: + count (int): The number of players in the MUD. + + """ + return str(self.protocol.sessionhandler.count_loggedin())
+ +
[docs] def get_uptime(self): + """ + Get how long the portal has been online (reloads are not counted). + + Returns: + uptime (int): Number of seconds of uptime. + + """ + return str(self.protocol.sessionhandler.uptime)
+ +
[docs] def no_mssp(self, option): + """ + Called when mssp is not requested. This is the normal + operation. + + Args: + option (Option): Not used. + + """ + self.protocol.handshake_done()
+ +
[docs] def do_mssp(self, option): + """ + Negotiate all the information. + + Args: + option (Option): Not used. + + """ + + self.mssp_table = { + # Required fields + "NAME": settings.SERVERNAME, + "PLAYERS": self.get_player_count, + "UPTIME": self.get_uptime, + "PORT": list( + str(port) for port in reversed(settings.TELNET_PORTS) + ), # most important port should be last in list + # Evennia auto-filled + "CRAWL DELAY": "-1", + "CODEBASE": utils.get_evennia_version(mode="pretty"), + "FAMILY": "Custom", + "ANSI": "1", + "GMCP": "1" if settings.TELNET_OOB_ENABLED else "0", + "ATCP": "0", + "MCCP": "1", + "MCP": "0", + "MSDP": "1" if settings.TELNET_OOB_ENABLED else "0", + "MSP": "0", + "MXP": "1", + "PUEBLO": "0", + "SSL": "1" if settings.SSL_ENABLED else "0", + "UTF-8": "1", + "ZMP": "0", + "VT100": "1", + "XTERM 256 COLORS": "1", + } + + # update the static table with the custom one + if MSSPTable_CUSTOM: + self.mssp_table.update(MSSPTable_CUSTOM) + + varlist = b"" + for variable, value in self.mssp_table.items(): + if callable(value): + value = value() + if utils.is_iter(value): + for partval in value: + varlist += ( + MSSP_VAR + + bytes(str(variable), "utf-8") + + MSSP_VAL + + bytes(str(partval), "utf-8") + ) + else: + varlist += ( + MSSP_VAR + bytes(str(variable), "utf-8") + MSSP_VAL + bytes(str(value), "utf-8") + ) + + # send to crawler by subnegotiation + self.protocol.requestNegotiation(MSSP, varlist) + self.protocol.handshake_done()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/mxp.html b/docs/latest/_modules/evennia/server/portal/mxp.html new file mode 100644 index 0000000000..8915bac07e --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/mxp.html @@ -0,0 +1,197 @@ + + + + + + + + evennia.server.portal.mxp — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.mxp

+"""
+MXP - Mud eXtension Protocol.
+
+Partial implementation of the MXP protocol.
+The MXP protocol allows more advanced formatting options for telnet clients
+that supports it (mudlet, zmud, mushclient are a few)
+
+This only implements the SEND tag.
+
+More information can be found on the following links:
+http://www.zuggsoft.com/zmud/mxp.htm
+http://www.mushclient.com/mushclient/mxp.htm
+http://www.gammon.com.au/mushclient/addingservermxp.htm
+
+"""
+import re
+
+from django.conf import settings
+
+LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL)
+URL_SUB = re.compile(r"\|lu(.*?)\|lt(.*?)\|le", re.DOTALL)
+
+# MXP Telnet option
+MXP = bytes([91])  # b"\x5b"
+
+MXP_TEMPSECURE = "\x1B[4z"
+MXP_SEND = MXP_TEMPSECURE + '<SEND HREF="\\1">' + "\\2" + MXP_TEMPSECURE + "</SEND>"
+MXP_URL = MXP_TEMPSECURE + '<A HREF="\\1">' + "\\2" + MXP_TEMPSECURE + "</A>"
+
+
+
[docs]def mxp_parse(text): + """ + Replaces links to the correct format for MXP. + + Args: + text (str): The text to parse. + + Returns: + parsed (str): The parsed text. + + """ + text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") + + text = LINKS_SUB.sub(MXP_SEND, text) + text = URL_SUB.sub(MXP_URL, text) + return text
+ + +
[docs]class Mxp: + """ + Implements the MXP protocol. + + """ + +
[docs] def __init__(self, protocol): + """ + Initializes the protocol by checking if the client supports it. + + Args: + protocol (Protocol): The active protocol instance. + + """ + self.protocol = protocol + self.protocol.protocol_flags["MXP"] = False + if settings.MXP_ENABLED: + self.protocol.will(MXP).addCallbacks(self.do_mxp, self.no_mxp)
+ +
[docs] def no_mxp(self, option): + """ + Called when the Client reports to not support MXP. + + Args: + option (Option): Not used. + + """ + self.protocol.protocol_flags["MXP"] = False + self.protocol.handshake_done()
+ +
[docs] def do_mxp(self, option): + """ + Called when the Client reports to support MXP. + + Args: + option (Option): Not used. + + """ + if settings.MXP_ENABLED: + self.protocol.protocol_flags["MXP"] = True + self.protocol.requestNegotiation(MXP, b"") + else: + self.protocol.wont(MXP) + self.protocol.handshake_done()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/naws.html b/docs/latest/_modules/evennia/server/portal/naws.html new file mode 100644 index 0000000000..37a66aef0e --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/naws.html @@ -0,0 +1,190 @@ + + + + + + + + evennia.server.portal.naws — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.naws

+"""
+
+NAWS - Negotiate About Window Size
+
+This implements the NAWS telnet option as per
+https://www.ietf.org/rfc/rfc1073.txt
+
+NAWS allows telnet clients to report their current window size to the
+client and update it when the size changes
+
+"""
+from codecs import encode as codecs_encode
+
+from django.conf import settings
+
+NAWS = bytes([31])  # b"\x1f"
+IS = bytes([0])  # b"\x00"
+
+# default taken from telnet specification
+DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+DEFAULT_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT
+
+# try to get the customized mssp info, if it exists.
+
+
+
[docs]class Naws: + """ + Implements the NAWS protocol. Add this to a variable on the telnet + protocol to set it up. + + """ + +
[docs] def __init__(self, protocol): + """ + initialize NAWS by storing protocol on ourselves and calling + the client to see if it supports NAWS. + + Args: + protocol (Protocol): The active protocol instance. + + """ + self.naws_step = 0 + self.protocol = protocol + self.protocol.protocol_flags["SCREENWIDTH"] = { + 0: DEFAULT_WIDTH + } # windowID (0 is root):width + self.protocol.protocol_flags["SCREENHEIGHT"] = {0: DEFAULT_HEIGHT} # windowID:width + self.protocol.negotiationMap[NAWS] = self.negotiate_sizes + self.protocol.do(NAWS).addCallbacks(self.do_naws, self.no_naws)
+ +
[docs] def no_naws(self, option): + """ + Called when client is not reporting NAWS. This is the normal + operation. + + Args: + option (Option): Not used. + + """ + self.protocol.handshake_done()
+ +
[docs] def do_naws(self, option): + """ + Client wants to negotiate all the NAWS information. + + Args: + option (Option): Not used. + + """ + self.protocol.handshake_done()
+ +
[docs] def negotiate_sizes(self, options): + """ + Step through the NAWS handshake. + + Args: + option (list): The incoming NAWS options. + + """ + if len(options) == 4: + # NAWS is negotiated with 16bit words + width = options[0] + options[1] + self.protocol.protocol_flags["SCREENWIDTH"][0] = int(codecs_encode(width, "hex"), 16) + height = options[2] + options[3] + self.protocol.protocol_flags["SCREENHEIGHT"][0] = int(codecs_encode(height, "hex"), 16)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/portal.html b/docs/latest/_modules/evennia/server/portal/portal.html new file mode 100644 index 0000000000..ebe5beff5e --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/portal.html @@ -0,0 +1,553 @@ + + + + + + + + evennia.server.portal.portal — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.portal

+"""
+This module implements the main Evennia server process, the core of
+the game engine.
+
+This module should be started with the 'twistd' executable since it
+sets up all the networking features.  (this is done automatically
+by game/evennia.py).
+
+"""
+import os
+import sys
+import time
+from os.path import abspath, dirname
+
+import django
+from twisted.application import internet, service
+from twisted.internet import protocol, reactor
+from twisted.internet.task import LoopingCall
+from twisted.logger import globalLogPublisher
+
+django.setup()
+from django.conf import settings
+from django.db import connection
+
+import evennia
+
+evennia._init(portal_mode=True)
+from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS
+
+from evennia.server.webserver import EvenniaReverseProxyResource
+from evennia.utils import logger
+from evennia.utils.utils import (
+    class_from_module,
+    get_evennia_version,
+    make_iter,
+    mod_import,
+)
+
+# we don't need a connection to the database so close it right away
+try:
+    connection.close()
+except Exception:
+    pass
+
+PORTAL_SERVICES_PLUGIN_MODULES = [
+    mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)
+]
+LOCKDOWN_MODE = settings.LOCKDOWN_MODE
+
+# -------------------------------------------------------------
+# Evennia Portal settings
+# -------------------------------------------------------------
+
+VERSION = get_evennia_version()
+
+SERVERNAME = settings.SERVERNAME
+
+PORTAL_RESTART = os.path.join(settings.GAME_DIR, "server", "portal.restart")
+
+TELNET_PORTS = settings.TELNET_PORTS
+SSL_PORTS = settings.SSL_PORTS
+SSH_PORTS = settings.SSH_PORTS
+WEBSERVER_PORTS = settings.WEBSERVER_PORTS
+WEBSOCKET_CLIENT_PORT = settings.WEBSOCKET_CLIENT_PORT
+
+TELNET_INTERFACES = ["127.0.0.1"] if LOCKDOWN_MODE else settings.TELNET_INTERFACES
+SSL_INTERFACES = ["127.0.0.1"] if LOCKDOWN_MODE else settings.SSL_INTERFACES
+SSH_INTERFACES = ["127.0.0.1"] if LOCKDOWN_MODE else settings.SSH_INTERFACES
+WEBSERVER_INTERFACES = ["127.0.0.1"] if LOCKDOWN_MODE else settings.WEBSERVER_INTERFACES
+WEBSOCKET_CLIENT_INTERFACE = "127.0.0.1" if LOCKDOWN_MODE else settings.WEBSOCKET_CLIENT_INTERFACE
+WEBSOCKET_CLIENT_URL = settings.WEBSOCKET_CLIENT_URL
+
+TELNET_ENABLED = settings.TELNET_ENABLED and TELNET_PORTS and TELNET_INTERFACES
+SSL_ENABLED = settings.SSL_ENABLED and SSL_PORTS and SSL_INTERFACES
+SSH_ENABLED = settings.SSH_ENABLED and SSH_PORTS and SSH_INTERFACES
+WEBSERVER_ENABLED = settings.WEBSERVER_ENABLED and WEBSERVER_PORTS and WEBSERVER_INTERFACES
+WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED
+WEBSOCKET_CLIENT_ENABLED = (
+    settings.WEBSOCKET_CLIENT_ENABLED and WEBSOCKET_CLIENT_PORT and WEBSOCKET_CLIENT_INTERFACE
+)
+
+AMP_HOST = settings.AMP_HOST
+AMP_PORT = settings.AMP_PORT
+AMP_INTERFACE = settings.AMP_INTERFACE
+AMP_ENABLED = AMP_HOST and AMP_PORT and AMP_INTERFACE
+
+INFO_DICT = {
+    "servername": SERVERNAME,
+    "version": VERSION,
+    "errors": "",
+    "info": "",
+    "lockdown_mode": "",
+    "amp": "",
+    "telnet": [],
+    "telnet_ssl": [],
+    "ssh": [],
+    "webclient": [],
+    "webserver_proxy": [],
+    "webserver_internal": [],
+}
+
+try:
+    WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
+except ImportError:
+    WEB_PLUGINS_MODULE = None
+    INFO_DICT["errors"] = (
+        "WARNING: settings.WEB_PLUGINS_MODULE not found - "
+        "copy 'evennia/game_template/server/conf/web_plugins.py to "
+        "mygame/server/conf."
+    )
+
+
+_MAINTENANCE_COUNT = 0
+
+
+def _portal_maintenance():
+    """
+    Repeated maintenance tasks for the portal.
+
+    """
+    global _MAINTENANCE_COUNT
+
+    _MAINTENANCE_COUNT += 1
+
+    if _MAINTENANCE_COUNT % (60 * 7) == 0:
+        # drop database connection every 7 hrs to avoid default timeouts on MySQL
+        # (see https://github.com/evennia/evennia/issues/1376)
+        connection.close()
+
+
+# -------------------------------------------------------------
+# Portal Service object
+# -------------------------------------------------------------
+
+
+
[docs]class Portal(object): + + """ + The main Portal server handler. This object sets up the database + and tracks and interlinks all the twisted network services that + make up Portal. + + """ + +
[docs] def __init__(self, application): + """ + Setup the server. + + Args: + application (Application): An instantiated Twisted application + + """ + sys.path.append(".") + + # create a store of services + self.services = service.MultiService() + self.services.setServiceParent(application) + self.amp_protocol = None # set by amp factory + self.sessions = PORTAL_SESSIONS + self.sessions.portal = self + self.process_id = os.getpid() + + self.server_process_id = None + self.server_restart_mode = "shutdown" + self.server_info_dict = {} + + self.start_time = time.time() + + self.maintenance_task = LoopingCall(_portal_maintenance) + self.maintenance_task.start(60, now=True) # call every minute + + # in non-interactive portal mode, this gets overwritten by + # cmdline sent by the evennia launcher + self.server_twistd_cmd = self._get_backup_server_twistd_cmd() + + # set a callback if the server is killed abruptly, + # by Ctrl-C, reboot etc. + reactor.addSystemEventTrigger( + "before", "shutdown", self.shutdown, _reactor_stopping=True, _stop_server=True + )
+ + def _get_backup_server_twistd_cmd(self): + """ + For interactive Portal mode there is no way to get the server cmdline from the launcher, so + we need to guess it here (it's very likely to not change) + + Returns: + server_twistd_cmd (list): An instruction for starting the server, to pass to Popen. + + """ + server_twistd_cmd = [ + "twistd", + "--python={}".format(os.path.join(dirname(dirname(abspath(__file__))), "server.py")), + ] + if os.name != "nt": + gamedir = os.getcwd() + server_twistd_cmd.append( + "--pidfile={}".format(os.path.join(gamedir, "server", "server.pid")) + ) + return server_twistd_cmd + +
[docs] def get_info_dict(self): + """ + Return the Portal info, for display. + + """ + return INFO_DICT
+ +
[docs] def shutdown(self, _reactor_stopping=False, _stop_server=False): + """ + Shuts down the server from inside it. + + Args: + _reactor_stopping (bool, optional): This is set if server + is already in the process of shutting down; in this case + we don't need to stop it again. + _stop_server (bool, optional): Only used in portal-interactive mode; + makes sure to stop the Server cleanly. + + Note that restarting (regardless of the setting) will not work + if the Portal is currently running in daemon mode. In that + case it always needs to be restarted manually. + + """ + if _reactor_stopping and hasattr(self, "shutdown_complete"): + # we get here due to us calling reactor.stop below. No need + # to do the shutdown procedure again. + return + + self.sessions.disconnect_all() + if _stop_server: + self.amp_protocol.stop_server(mode="shutdown") + if not _reactor_stopping: + # shutting down the reactor will trigger another signal. We set + # a flag to avoid loops. + self.shutdown_complete = True + reactor.callLater(0, reactor.stop)
+ + +# ------------------------------------------------------------- +# +# Start the Portal proxy server and add all active services +# +# ------------------------------------------------------------- + + +# twistd requires us to define the variable 'application' so it knows +# what to execute from. +application = service.Application("Portal") + + +if "--nodaemon" not in sys.argv and "test" not in sys.argv: + # activate logging for interactive/testing mode + logfile = logger.WeeklyLogFile( + os.path.basename(settings.PORTAL_LOG_FILE), + os.path.dirname(settings.PORTAL_LOG_FILE), + day_rotation=settings.PORTAL_LOG_DAY_ROTATION, + max_size=settings.PORTAL_LOG_MAX_SIZE, + ) + globalLogPublisher.addObserver(logger.GetPortalLogObserver()(logfile)) + +# The main Portal server program. This sets up the database +# and is where we store all the other services. +PORTAL = Portal(application) + +if LOCKDOWN_MODE: + + INFO_DICT["lockdown_mode"] = " LOCKDOWN_MODE active: Only local connections." + +if AMP_ENABLED: + + # The AMP protocol handles the communication between + # the portal and the mud server. Only reason to ever deactivate + # it would be during testing and debugging. + + from evennia.server.portal import amp_server + + INFO_DICT["amp"] = "amp: %s" % AMP_PORT + + factory = amp_server.AMPServerFactory(PORTAL) + amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE) + amp_service.setName("PortalAMPServer") + PORTAL.services.addService(amp_service) + + +# We group all the various services under the same twisted app. +# These will gradually be started as they are initialized below. + +if TELNET_ENABLED: + + # Start telnet game connections + + from evennia.server.portal import telnet + + _telnet_protocol = class_from_module(settings.TELNET_PROTOCOL_CLASS) + + for interface in TELNET_INTERFACES: + ifacestr = "" + if interface not in ("0.0.0.0", "::") or len(TELNET_INTERFACES) > 1: + ifacestr = "-%s" % interface + for port in TELNET_PORTS: + pstring = "%s:%s" % (ifacestr, port) + factory = telnet.TelnetServerFactory() + factory.noisy = False + factory.protocol = _telnet_protocol + factory.sessionhandler = PORTAL_SESSIONS + telnet_service = internet.TCPServer(port, factory, interface=interface) + telnet_service.setName("EvenniaTelnet%s" % pstring) + PORTAL.services.addService(telnet_service) + + INFO_DICT["telnet"].append("telnet%s: %s" % (ifacestr, port)) + + +if SSL_ENABLED: + + # Start Telnet+SSL game connection (requires PyOpenSSL). + + from evennia.server.portal import telnet_ssl + + _ssl_protocol = class_from_module(settings.SSL_PROTOCOL_CLASS) + + for interface in SSL_INTERFACES: + ifacestr = "" + if interface not in ("0.0.0.0", "::") or len(SSL_INTERFACES) > 1: + ifacestr = "-%s" % interface + for port in SSL_PORTS: + pstring = "%s:%s" % (ifacestr, port) + factory = protocol.ServerFactory() + factory.noisy = False + factory.sessionhandler = PORTAL_SESSIONS + factory.protocol = _ssl_protocol + + ssl_context = telnet_ssl.getSSLContext() + if ssl_context: + ssl_service = internet.SSLServer( + port, factory, telnet_ssl.getSSLContext(), interface=interface + ) + ssl_service.setName("EvenniaSSL%s" % pstring) + PORTAL.services.addService(ssl_service) + + INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port)) + else: + INFO_DICT["telnet_ssl"].append( + "telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port) + ) + + +if SSH_ENABLED: + + # Start SSH game connections. Will create a keypair in + # evennia/game if necessary. + + from evennia.server.portal import ssh + + _ssh_protocol = class_from_module(settings.SSH_PROTOCOL_CLASS) + + for interface in SSH_INTERFACES: + ifacestr = "" + if interface not in ("0.0.0.0", "::") or len(SSH_INTERFACES) > 1: + ifacestr = "-%s" % interface + for port in SSH_PORTS: + pstring = "%s:%s" % (ifacestr, port) + factory = ssh.makeFactory( + {"protocolFactory": _ssh_protocol, "protocolArgs": (), "sessions": PORTAL_SESSIONS} + ) + factory.noisy = False + ssh_service = internet.TCPServer(port, factory, interface=interface) + ssh_service.setName("EvenniaSSH%s" % pstring) + PORTAL.services.addService(ssh_service) + + INFO_DICT["ssh"].append("ssh%s: %s" % (ifacestr, port)) + + +if WEBSERVER_ENABLED: + from evennia.server.webserver import Website + + # Start a reverse proxy to relay data to the Server-side webserver + + websocket_started = False + _websocket_protocol = class_from_module(settings.WEBSOCKET_PROTOCOL_CLASS) + for interface in WEBSERVER_INTERFACES: + ifacestr = "" + if interface not in ("0.0.0.0", "::") or len(WEBSERVER_INTERFACES) > 1: + ifacestr = "-%s" % interface + for proxyport, serverport in WEBSERVER_PORTS: + web_root = EvenniaReverseProxyResource("127.0.0.1", serverport, "") + webclientstr = "" + if WEBCLIENT_ENABLED: + # create ajax client processes at /webclientdata + from evennia.server.portal import webclient_ajax + + ajax_webclient = webclient_ajax.AjaxWebClient() + ajax_webclient.sessionhandler = PORTAL_SESSIONS + web_root.putChild(b"webclientdata", ajax_webclient) + webclientstr = "webclient (ajax only)" + + if WEBSOCKET_CLIENT_ENABLED and not websocket_started: + # start websocket client port for the webclient + # we only support one websocket client + from autobahn.twisted.websocket import WebSocketServerFactory + + from evennia.server.portal import webclient # noqa + + w_interface = WEBSOCKET_CLIENT_INTERFACE + w_ifacestr = "" + if w_interface not in ("0.0.0.0", "::") or len(WEBSERVER_INTERFACES) > 1: + w_ifacestr = "-%s" % w_interface + port = WEBSOCKET_CLIENT_PORT + +
[docs] class Websocket(WebSocketServerFactory): + "Only here for better naming in logs" + pass
+ + factory = Websocket() + factory.noisy = False + factory.protocol = _websocket_protocol + factory.sessionhandler = PORTAL_SESSIONS + websocket_service = internet.TCPServer(port, factory, interface=w_interface) + websocket_service.setName("EvenniaWebSocket%s:%s" % (w_ifacestr, port)) + PORTAL.services.addService(websocket_service) + websocket_started = True + webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port) + INFO_DICT["webclient"].append(webclientstr) + + if WEB_PLUGINS_MODULE: + try: + web_root = WEB_PLUGINS_MODULE.at_webproxy_root_creation(web_root) + except Exception: + # Legacy user has not added an at_webproxy_root_creation function in existing + # web plugins file + INFO_DICT["errors"] = ( + "WARNING: WEB_PLUGINS_MODULE is enabled but at_webproxy_root_creation() " + "not found copy 'evennia/game_template/server/conf/web_plugins.py to " + "mygame/server/conf." + ) + web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE) + web_root.is_portal = True + proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) + proxy_service.setName("EvenniaWebProxy%s:%s" % (ifacestr, proxyport)) + PORTAL.services.addService(proxy_service) + INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport)) + INFO_DICT["webserver_internal"].append("webserver: %s" % serverport) + + +for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES: + # external plugin services to start + if plugin_module: + plugin_module.start_plugin_services(PORTAL) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/portalsessionhandler.html b/docs/latest/_modules/evennia/server/portal/portalsessionhandler.html new file mode 100644 index 0000000000..af0895d711 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/portalsessionhandler.html @@ -0,0 +1,602 @@ + + + + + + + + evennia.server.portal.portalsessionhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.portalsessionhandler

+"""
+Sessionhandler for portal sessions.
+
+"""
+
+
+import time
+from collections import deque, namedtuple
+
+import evennia
+from django.conf import settings
+from django.utils.translation import gettext as _
+from evennia.server.portal.amp import PCONN, PCONNSYNC, PDISCONN, PDISCONNALL
+from evennia.server.sessionhandler import SessionHandler
+from evennia.utils.logger import log_trace
+from evennia.utils.utils import class_from_module
+from twisted.internet import reactor
+
+# module import
+_MOD_IMPORT = None
+
+# global throttles
+_MAX_CONNECTION_RATE = float(settings.MAX_CONNECTION_RATE)
+# per-session throttles
+_MAX_COMMAND_RATE = float(settings.MAX_COMMAND_RATE)
+_MAX_CHAR_LIMIT = int(settings.MAX_CHAR_LIMIT)
+
+_MIN_TIME_BETWEEN_CONNECTS = 1.0 / float(_MAX_CONNECTION_RATE)
+_MIN_TIME_BETWEEN_COMMANDS = 1.0 / float(_MAX_COMMAND_RATE)
+
+_ERROR_COMMAND_OVERFLOW = settings.COMMAND_RATE_WARNING
+_ERROR_MAX_CHAR = settings.MAX_CHAR_LIMIT_WARNING
+
+_CONNECTION_QUEUE = deque()
+
+DUMMYSESSION = namedtuple("DummySession", ["sessid"])(0)
+
+# -------------------------------------------------------------
+# Portal-SessionHandler class
+# -------------------------------------------------------------
+
+DOS_PROTECTION_MSG = _(
+    "{servername} DoS protection is active.You are queued to connect in {num} seconds ..."
+)
+
+
+
[docs]class PortalSessionHandler(SessionHandler): + """ + This object holds the sessions connected to the portal at any time. + It is synced with the server's equivalent SessionHandler over the AMP + connection. + + Sessions register with the handler using the connect() method. This + will assign a new unique sessionid to the session and send that sessid + to the server using the AMP connection. + + """ + +
[docs] def __init__(self, *args, **kwargs): + """ + Init the handler + + """ + super().__init__(*args, **kwargs) + self.latest_sessid = 0 + self.uptime = time.time() + self.connection_time = 0 + + self.connection_last = self.uptime + self.connection_task = None
+ +
[docs] def at_server_connection(self): + """ + Called when the Portal establishes connection with the Server. + At this point, the AMP connection is already established. + + """ + self.connection_time = time.time()
+ +
[docs] def generate_sessid(self): + """ + Simply generates a sessid that's guaranteed to be unique for this Portal run. + + Returns: + sessid + + """ + self.latest_sessid += 1 + if self.latest_sessid in self: + return self.generate_sessid() + return self.latest_sessid
+ +
[docs] def connect(self, session): + """ + Called by protocol at first connect. This adds a not-yet + authenticated session using an ever-increasing counter for + sessid. + + Args: + session (PortalSession): The Session connecting. + + Notes: + We implement a throttling mechanism here to limit the speed at + which new connections are accepted - this is both a stop + against DoS attacks as well as helps using the Dummyrunner + tester with a large number of connector dummies. + + """ + global _CONNECTION_QUEUE + + if session: + # assign if we are first-connectors + if not session.sessid: + # if the session already has a sessid (e.g. being inherited in the + # case of a webclient auto-reconnect), keep it + session.sessid = self.generate_sessid() + session.server_connected = False + _CONNECTION_QUEUE.appendleft(session) + if len(_CONNECTION_QUEUE) > 1: + session.data_out( + text=( + ( + DOS_PROTECTION_MSG.format( + servername=settings.SERVERNAME, + num=len(_CONNECTION_QUEUE) * _MIN_TIME_BETWEEN_CONNECTS, + ), + ), + {}, + ) + ) + now = time.time() + if ( + now - self.connection_last < _MIN_TIME_BETWEEN_CONNECTS + ) or not evennia.EVENNIA_PORTAL_SERVICE.amp_protocol: + if not session or not self.connection_task: + self.connection_task = reactor.callLater( + _MIN_TIME_BETWEEN_CONNECTS, self.connect, None + ) + self.connection_last = now + return + elif not session: + if _CONNECTION_QUEUE: + # keep launching tasks until queue is empty + self.connection_task = reactor.callLater( + _MIN_TIME_BETWEEN_CONNECTS, self.connect, None + ) + else: + self.connection_task = None + self.connection_last = now + + if _CONNECTION_QUEUE: + # sync with server-side + session = _CONNECTION_QUEUE.pop() + sessdata = session.get_sync_data() + + self[session.sessid] = session + session.server_connected = True + evennia.EVENNIA_PORTAL_SERVICE.amp_protocol.send_AdminPortal2Server( + session, operation=PCONN, sessiondata=sessdata + )
+ +
[docs] def sync(self, session): + """ + Called by the protocol of an already connected session. This + can be used to sync the session info in a delayed manner, such + as when negotiation and handshakes are delayed. + + Args: + session (PortalSession): Session to sync. + + """ + if session.sessid and session.server_connected: + # only use if session already has sessid and has already connected + # once to the server - if so we must re-sync woth the server, otherwise + # we skip this step. + sessdata = session.get_sync_data() + if evennia.EVENNIA_PORTAL_SERVICE.amp_protocol: + # we only send sessdata that should not have changed + # at the server level at this point + sessdata = dict( + (key, val) + for key, val in sessdata.items() + if key + in ( + "protocol_key", + "address", + "sessid", + "csessid", + "conn_time", + "protocol_flags", + "server_data", + ) + ) + evennia.EVENNIA_PORTAL_SERVICE.amp_protocol.send_AdminPortal2Server( + session, operation=PCONNSYNC, sessiondata=sessdata + )
+ +
[docs] def disconnect(self, session): + """ + Called from portal when the connection is closed from the + portal side. + + Args: + session (PortalSession): Session to disconnect. + delete (bool, optional): Delete the session from + the handler. Only time to not do this is when + this is called from a loop, such as from + self.disconnect_all(). + + """ + global _CONNECTION_QUEUE + if session in _CONNECTION_QUEUE: + # connection was already dropped before we had time + # to forward this to the Server, so now we just remove it. + _CONNECTION_QUEUE.remove(session) + return + + if session.sessid in self and not hasattr(self, "_disconnect_all"): + # if this was called directly from the protocol, the + # connection is already dead and we just need to cleanup + del self[session.sessid] + + # Tell the Server to disconnect its version of the Session as well. + evennia.EVENNIA_PORTAL_SERVICE.amp_protocol.send_AdminPortal2Server( + session, operation=PDISCONN + )
+ +
[docs] def disconnect_all(self): + """ + Disconnect all sessions, informing the Server. + + """ + if settings.TEST_ENVIRONMENT: + return + + def _callback(result, sessionhandler): + # we set a watchdog to stop self.disconnect from deleting + # sessions while we are looping over them. + sessionhandler._disconnect_all = True + for session in sessionhandler.values(): + session.disconnect() + del sessionhandler._disconnect_all + + # inform Server; wait until finished sending before we continue + # removing all the sessions. + + evennia.EVENNIA_PORTAL_SERVICE.amp_protocol.send_AdminPortal2Server( + DUMMYSESSION, operation=PDISCONNALL + ).addCallback(_callback, self)
+ +
[docs] 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. + + Args: + 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. + + Raises: + RuntimeError: If The correct factory class is not found. + + Notes: + The called protocol class must have a method start() + that calls the portalsession.connect() as a normal protocol. + + """ + global _MOD_IMPORT + if not _MOD_IMPORT: + from evennia.utils.utils import variable_from_module as _MOD_IMPORT + path, clsname = protocol_path.rsplit(".", 1) + cls = _MOD_IMPORT(path, clsname) + if not cls: + raise RuntimeError("ServerConnect: protocol factory '%s' not found." % protocol_path) + protocol = cls(self, **config) + protocol.start()
+ +
[docs] def server_disconnect(self, session, reason=""): + """ + Called by server to force a disconnect by sessid. + + Args: + session (portalsession): Session to disconnect. + reason (str, optional): Motivation for disconnect. + + """ + if session: + session.disconnect(reason) + if session.sessid in self: + # in case sess.disconnect doesn't delete it + del self[session.sessid] + del session
+ +
[docs] def server_disconnect_all(self, reason=""): + """ + Called by server when forcing a clean disconnect for everyone. + + Args: + reason (str, optional): Motivation for disconnect. + + """ + for session in list(self.values()): + session.disconnect(reason) + del session + self.clear()
+ +
[docs] def server_logged_in(self, session, data): + """ + The server tells us that the session has been authenticated. + Update it. Called by the Server. + + Args: + session (Session): Session logging in. + data (dict): The session sync data. + + """ + session.load_sync_data(data) + session.at_login()
+ +
[docs] def server_session_sync(self, serversessions, clean=True): + """ + Server wants to save data to the portal, maybe because it's + about to shut down. We don't overwrite any sessions here, just + update them in-place. + + Args: + serversessions (dict): This is a dictionary + + `{sessid:{property:value},...}` describing + the properties to sync on all sessions. + clean (bool): If True, remove any Portal sessions that are + not included in serversessions. + """ + to_save = [sessid for sessid in serversessions if sessid in self] + # save protocols + for sessid in to_save: + self[sessid].load_sync_data(serversessions[sessid]) + if clean: + # disconnect out-of-sync missing protocols + to_delete = [sessid for sessid in self if sessid not in to_save] + for sessid in to_delete: + self.server_disconnect(sessid)
+ +
[docs] def count_loggedin(self, include_unloggedin=False): + """ + Count loggedin connections, alternatively count all connections. + + Args: + include_unloggedin (bool): Also count sessions that have + not yet authenticated. + + Returns: + count (int): Number of sessions. + + """ + return len(self.get_sessions(include_unloggedin=include_unloggedin))
+ +
[docs] def sessions_from_csessid(self, csessid): + """ + Given a session id, retrieve the session (this is primarily + intended to be called by web clients) + + Args: + csessid (int): Session id. + + Returns: + session (list): The matching session, if found. + + """ + return [ + sess + for sess in self.get_sessions(include_unloggedin=True) + if hasattr(sess, "csessid") and sess.csessid and sess.csessid == csessid + ]
+ +
[docs] def announce_all(self, message): + """ + Send message to all connected sessions. + + Args: + message (str): Message to relay. + + Notes: + This will create an on-the fly text-type + send command. + + """ + for session in self.values(): + self.data_out(session, text=[[message], {}])
+ +
[docs] def data_in(self, session, **kwargs): + """ + Called by portal sessions for relaying data coming + in from the protocol to the server. + + Args: + session (PortalSession): Session receiving data. + + Keyword Args: + kwargs (any): Other data from protocol. + + Notes: + Data is serialized before passed on. + + """ + try: + text = kwargs["text"] + if (_MAX_CHAR_LIMIT > 0) and len(text) > _MAX_CHAR_LIMIT: + if session: + self.data_out(session, text=[[_ERROR_MAX_CHAR], {}]) + return + except Exception: + # if there is a problem to send, we continue + pass + if session: + now = time.time() + + try: + command_counter_reset = session.command_counter_reset + except AttributeError: + command_counter_reset = session.command_counter_reset = now + session.command_counter = 0 + + # global command-rate limit + if max(0, now - command_counter_reset) > 1.0: + # more than a second since resetting the counter. Refresh. + session.command_counter_reset = now + session.command_counter = 0 + + session.command_counter += 1 + + if session.command_counter * _MIN_TIME_BETWEEN_COMMANDS > 1.0: + self.data_out(session, text=[[_ERROR_COMMAND_OVERFLOW], {}]) + return + + if not evennia.EVENNIA_PORTAL_SERVICE.amp_protocol: + # this can happen if someone connects before AMP connection + # was established (usually on first start) + reactor.callLater(1.0, self.data_in, session, **kwargs) + return + + # scrub data + kwargs = self.clean_senddata(session, kwargs) + + # relay data to Server + session.cmd_last = now + evennia.EVENNIA_PORTAL_SERVICE.amp_protocol.send_MsgPortal2Server(session, **kwargs) + + # eventual local echo (text input only) + if "text" in kwargs and session.protocol_flags.get("LOCALECHO", False): + self.data_out(session, text=kwargs["text"])
+ +
[docs] def data_out(self, session, **kwargs): + """ + Called by server for having the portal relay messages and data + to the correct session protocol. + + Args: + session (Session): Session sending data. + + Keyword Args: + kwargs (any): Each key is a command instruction to the + protocol on the form key = [[args],{kwargs}]. This will + call a method send_<key> on the protocol. If no such + method exixts, it sends the data to a method send_default. + + """ + # from evennia.server.profiling.timetrace import timetrace # DEBUG + # text = timetrace(text, "portalsessionhandler.data_out") # DEBUG + + # distribute outgoing data to the correct session methods. + if session: + for cmdname, (cmdargs, cmdkwargs) in kwargs.items(): + funcname = "send_%s" % cmdname.strip().lower() + if hasattr(session, funcname): + # better to use hassattr here over try..except + # - avoids hiding AttributeErrors in the call. + try: + getattr(session, funcname)(*cmdargs, **cmdkwargs) + except Exception: + log_trace() + else: + try: + # note that send_default always takes cmdname + # as arg too. + session.send_default(cmdname, *cmdargs, **cmdkwargs) + except Exception: + log_trace()
+ + +# This will be filled in when the portal boots. +PORTAL_SESSIONS = None +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/rss.html b/docs/latest/_modules/evennia/server/portal/rss.html new file mode 100644 index 0000000000..e1a134d66b --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/rss.html @@ -0,0 +1,270 @@ + + + + + + + + evennia.server.portal.rss — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.rss

+"""
+RSS parser for Evennia
+
+This connects an RSS feed to an in-game Evennia channel, sending messages
+to the channel whenever the feed updates.
+
+"""
+from django.conf import settings
+from twisted.internet import task, threads
+
+from evennia.server.session import Session
+from evennia.utils import logger
+
+RSS_ENABLED = settings.RSS_ENABLED
+# RETAG = re.compile(r'<[^>]*?>')
+
+if RSS_ENABLED:
+    try:
+        import feedparser
+    except ImportError:
+        raise ImportError(
+            "RSS requires python-feedparser to be installed. Install or set RSS_ENABLED=False."
+        )
+
+
+
[docs]class RSSReader(Session): + """ + A simple RSS reader using the feedparser module. + + """ + +
[docs] def __init__(self, factory, url, rate): + """ + Initialize the reader. + + Args: + factory (RSSFactory): The protocol factory. + url (str): The RSS url. + rate (int): The seconds between RSS lookups. + + """ + self.url = url + self.rate = rate + self.factory = factory + self.old_entries = {}
+ +
[docs] def get_new(self): + """ + Returns list of new items. + + """ + feed = feedparser.parse(self.url) + new_entries = [] + for entry in feed["entries"]: + idval = entry["id"] + entry.get("updated", "") + if idval not in self.old_entries: + self.old_entries[idval] = entry + new_entries.append(entry) + return new_entries
+ +
[docs] def disconnect(self, reason=None): + """ + Disconnect from feed. + + Args: + reason (str, optional): Motivation for the disconnect. + + """ + if self.factory.task and self.factory.task.running: + self.factory.task.stop() + self.sessionhandler.disconnect(self)
+ + def _callback(self, new_entries, init): + """ + Called when RSS returns. + + Args: + new_entries (list): List of new RSS entries since last. + init (bool): If this is a startup operation (at which + point all entries are considered new). + + """ + if not init: + # for initialization we just ignore old entries + for entry in reversed(new_entries): + self.data_in(entry) + +
[docs] def data_in(self, text=None, **kwargs): + """ + Data RSS -> Evennia. + + Keyword Args: + text (str): Incoming text + kwargs (any): Options from protocol. + + """ + self.sessionhandler.data_in(self, bot_data_in=text, **kwargs)
+ + def _errback(self, fail): + "Report error" + logger.log_err("RSS feed error: %s" % fail.value) + +
[docs] def update(self, init=False): + """ + Request the latest version of feed. + + Args: + init (bool, optional): If this is an initialization call + or not (during init, all entries are conidered new). + + Notes: + This call is done in a separate thread to avoid blocking + on slow connections. + + """ + return ( + threads.deferToThread(self.get_new) + .addCallback(self._callback, init) + .addErrback(self._errback) + )
+ + +
[docs]class RSSBotFactory(object): + """ + Initializes new bots. + """ + +
[docs] def __init__(self, sessionhandler, uid=None, url=None, rate=None): + """ + Initialize the bot. + + Args: + sessionhandler (PortalSessionHandler): The main sessionhandler object. + uid (int): User id for the bot. + url (str): The RSS URL. + rate (int): How often for the RSS to request the latest RSS entries. + + """ + self.sessionhandler = sessionhandler + self.url = url + self.rate = rate + self.uid = uid + self.bot = RSSReader(self, url, rate) + self.task = None
+ +
[docs] def start(self): + """ + Called by portalsessionhandler. Starts the bot. + + """ + + def errback(fail): + logger.log_err(fail.value) + + # set up session and connect it to sessionhandler + self.bot.init_session("rssbot", self.url, self.sessionhandler) + self.bot.uid = self.uid + self.bot.logged_in = True + self.sessionhandler.connect(self.bot) + + # start repeater task + self.bot.update(init=True) + self.task = task.LoopingCall(self.bot.update) + if self.rate: + self.task.start(self.rate, now=False).addErrback(errback)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/service.html b/docs/latest/_modules/evennia/server/portal/service.html new file mode 100644 index 0000000000..1aefc091f1 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/service.html @@ -0,0 +1,471 @@ + + + + + + + + evennia.server.portal.service — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.service

+import os
+import sys
+import time
+from os.path import abspath, dirname
+
+from django.conf import settings
+from django.db import connection
+from twisted.application import internet, service
+from twisted.application.service import MultiService
+from twisted.internet import protocol, reactor
+from twisted.internet.task import LoopingCall
+
+import evennia
+from evennia.utils.utils import (
+    class_from_module,
+    get_evennia_version,
+    make_iter,
+    mod_import,
+)
+
+
+
[docs]class EvenniaPortalService(MultiService): +
[docs] def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.amp_protocol = None + self.server_process_id = None + self.server_restart_mode = "shutdown" + self.server_info_dict = dict() + self.plugins = list() + + self.start_time = 0 + self._maintenance_count = 0 + self.maintenance_task = None + + self.info_dict = { + "servername": settings.SERVERNAME, + "version": get_evennia_version(), + "errors": "", + "info": "", + "lockdown_mode": "", + "amp": "", + "telnet": [], + "telnet_ssl": [], + "ssh": [], + "webclient": [], + "webserver_proxy": [], + "webserver_internal": [], + } + + # in non-interactive portal mode, this gets overwritten by + # cmdline sent by the evennia launcher + self.server_twistd_cmd = self._get_backup_server_twistd_cmd()
+ +
[docs] def portal_maintenance(self): + """ + Repeated maintenance tasks for the portal. + + """ + + self._maintenance_count += 1 + + if self._maintenance_count % (60 * 7) == 0: + # drop database connection every 7 hrs to avoid default timeouts on MySQL + # (see https://github.com/evennia/evennia/issues/1376) + connection.close()
+ +
[docs] def privilegedStartService(self): + self.start_time = time.time() + self.maintenance_task = LoopingCall(self.portal_maintenance) + self.maintenance_task.start(60, now=True) # call every minute + # set a callback if the server is killed abruptly, + # by Ctrl-C, reboot etc. + reactor.addSystemEventTrigger( + "before", "shutdown", self.shutdown, _reactor_stopping=True, _stop_server=True + ) + + if settings.AMP_HOST and settings.AMP_PORT and settings.AMP_INTERFACE: + self.register_amp() + + if settings.TELNET_ENABLED and settings.TELNET_PORTS and settings.TELNET_INTERFACES: + self.register_telnet() + + if settings.SSL_ENABLED and settings.SSL_PORTS and settings.SSL_INTERFACES: + self.register_ssl() + + if settings.SSH_ENABLED and settings.SSH_PORTS and settings.SSH_INTERFACES: + self.register_ssh() + + if settings.WEBSERVER_ENABLED: + self.register_webserver() + + if settings.LOCKDOWN_MODE: + self.info_dict["lockdown_mode"] = " LOCKDOWN_MODE active: Only local connections." + + self.register_plugins() + + super().privilegedStartService()
+ +
[docs] def register_plugins(self): + self.plugins.extend( + mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES) + ) + for plugin_module in self.plugins: + # external plugin services to start + if plugin_module: + plugin_module.start_plugin_services(self)
+ +
[docs] def check_lockdown(self, interfaces: list[str]): + if settings.LOCKDOWN_MODE: + return ["127.0.0.1"] + return interfaces
+ +
[docs] def register_ssl(self): + # Start Telnet+SSL game connection (requires PyOpenSSL). + + from evennia.server.portal import telnet_ssl + + _ssl_protocol = class_from_module(settings.SSL_PROTOCOL_CLASS) + + interfaces = self.check_lockdown(settings.SSL_INTERFACES) + + for interface in interfaces: + ifacestr = "" + if interface not in ("0.0.0.0", "::") or len(interfaces) > 1: + ifacestr = "-%s" % interface + for port in settings.SSL_PORTS: + pstring = "%s:%s" % (ifacestr, port) + factory = protocol.ServerFactory() + factory.noisy = False + factory.sessionhandler = evennia.PORTAL_SESSION_HANDLER + factory.protocol = _ssl_protocol + + ssl_context = telnet_ssl.getSSLContext() + if ssl_context: + ssl_service = internet.SSLServer( + port, factory, telnet_ssl.getSSLContext(), interface=interface + ) + ssl_service.setName("EvenniaSSL%s" % pstring) + ssl_service.setServiceParent(self) + + self.info_dict["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port)) + else: + self.info_dict["telnet_ssl"].append( + "telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port) + )
+ +
[docs] def register_ssh(self): + # Start SSH game connections. Will create a keypair in + # evennia/game if necessary. + + from evennia.server.portal import ssh + + _ssh_protocol = class_from_module(settings.SSH_PROTOCOL_CLASS) + + interfaces = self.check_lockdown(settings.SSH_INTERFACES) + + for interface in interfaces: + ifacestr = "" + if interface not in ("0.0.0.0", "::") or len(interfaces) > 1: + ifacestr = "-%s" % interface + for port in settings.SSH_PORTS: + pstring = "%s:%s" % (ifacestr, port) + factory = ssh.makeFactory( + { + "protocolFactory": _ssh_protocol, + "protocolArgs": (), + "sessions": evennia.PORTAL_SESSION_HANDLER, + } + ) + factory.noisy = False + ssh_service = internet.TCPServer(port, factory, interface=interface) + ssh_service.setName("EvenniaSSH%s" % pstring) + ssh_service.setServiceParent(self) + + self.info_dict["ssh"].append("ssh%s: %s" % (ifacestr, port))
+ +
[docs] def register_webserver(self): + from evennia.server.webserver import EvenniaReverseProxyResource, Website + + # Start a reverse proxy to relay data to the Server-side webserver + interfaces = self.check_lockdown(settings.WEBSERVER_INTERFACES) + websocket_started = False + _websocket_protocol = class_from_module(settings.WEBSOCKET_PROTOCOL_CLASS) + for interface in interfaces: + ifacestr = "" + if interface not in ("0.0.0.0", "::") or len(interfaces) > 1: + ifacestr = "-%s" % interface + + for proxyport, serverport in settings.WEBSERVER_PORTS: + web_root = EvenniaReverseProxyResource("127.0.0.1", serverport, "") + webclientstr = "" + if settings.WEBCLIENT_ENABLED: + # create ajax client processes at /webclientdata + ajax_class = class_from_module(settings.AJAX_CLIENT_CLASS) + ajax_webclient = ajax_class() + ajax_webclient.sessionhandler = evennia.PORTAL_SESSION_HANDLER + web_root.putChild(b"webclientdata", ajax_webclient) + webclientstr = "webclient (ajax only)" + + if ( + settings.WEBSOCKET_CLIENT_ENABLED + and settings.WEBSOCKET_CLIENT_PORT + and settings.WEBSOCKET_CLIENT_INTERFACE + ) and not websocket_started: + # start websocket client port for the webclient + # we only support one websocket client + from autobahn.twisted.websocket import WebSocketServerFactory + + from evennia.server.portal import webclient # noqa + + w_interface = ( + "127.0.0.1" + if settings.LOCKDOWN_MODE + else settings.WEBSOCKET_CLIENT_INTERFACE + ) + w_ifacestr = "" + if ( + w_interface not in ("0.0.0.0", "::") + or len(settings.WEBSERVER_INTERFACES) > 1 + ): + w_ifacestr = "-%s" % w_interface + port = settings.WEBSOCKET_CLIENT_PORT + + class Websocket(WebSocketServerFactory): + "Only here for better naming in logs" + pass + + factory = Websocket() + factory.noisy = False + factory.protocol = _websocket_protocol + factory.sessionhandler = evennia.PORTAL_SESSION_HANDLER + websocket_service = internet.TCPServer(port, factory, interface=w_interface) + websocket_service.setName("EvenniaWebSocket%s:%s" % (w_ifacestr, port)) + websocket_service.setServiceParent(self) + websocket_started = True + webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port) + self.info_dict["webclient"].append(webclientstr) + + try: + WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) + except ImportError: + WEB_PLUGINS_MODULE = None + self.info_dict["errors"] = ( + "WARNING: settings.WEB_PLUGINS_MODULE not found - " + "copy 'evennia/game_template/server/conf/web_plugins.py to " + "mygame/server/conf." + ) + + if WEB_PLUGINS_MODULE: + try: + web_root = WEB_PLUGINS_MODULE.at_webproxy_root_creation(web_root) + except Exception: + # Legacy user has not added an at_webproxy_root_creation function in existing + # web plugins file + self.info_dict["errors"] = ( + "WARNING: WEB_PLUGINS_MODULE is enabled but at_webproxy_root_creation() " + "not found copy 'evennia/game_template/server/conf/web_plugins.py to " + "mygame/server/conf." + ) + web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE) + web_root.is_portal = True + proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) + proxy_service.setName("EvenniaWebProxy%s:%s" % (ifacestr, proxyport)) + proxy_service.setServiceParent(self) + self.info_dict["webserver_proxy"].append( + "webserver-proxy%s: %s" % (ifacestr, proxyport) + ) + self.info_dict["webserver_internal"].append("webserver: %s" % serverport)
+ +
[docs] def register_telnet(self): + # Start telnet game connections + + from evennia.server.portal import telnet + + _telnet_protocol = class_from_module(settings.TELNET_PROTOCOL_CLASS) + + interfaces = self.check_lockdown(settings.TELNET_INTERFACES) + + for interface in interfaces: + ifacestr = "" + if interface not in ("0.0.0.0", "::") or len(interfaces) > 1: + ifacestr = "-%s" % interface + for port in settings.TELNET_PORTS: + pstring = "%s:%s" % (ifacestr, port) + factory = telnet.TelnetServerFactory() + factory.noisy = False + factory.protocol = _telnet_protocol + factory.sessionhandler = evennia.PORTAL_SESSION_HANDLER + telnet_service = internet.TCPServer(port, factory, interface=interface) + telnet_service.setName("EvenniaTelnet%s" % pstring) + telnet_service.setServiceParent(self) + + self.info_dict["telnet"].append("telnet%s: %s" % (ifacestr, port))
+ +
[docs] def register_amp(self): + # The AMP protocol handles the communication between + # the portal and the mud server. Only reason to ever deactivate + # it would be during testing and debugging. + + from evennia.server.portal import amp_server + + self.info_dict["amp"] = "amp: %s" % settings.AMP_PORT + + factory = amp_server.AMPServerFactory(self) + amp_service = internet.TCPServer( + settings.AMP_PORT, factory, interface=settings.AMP_INTERFACE + ) + amp_service.setName("PortalAMPServer") + amp_service.setServiceParent(self)
+ + def _get_backup_server_twistd_cmd(self): + """ + For interactive Portal mode there is no way to get the server cmdline from the launcher, so + we need to guess it here (it's very likely to not change) + + Returns: + server_twistd_cmd (list): An instruction for starting the server, to pass to Popen. + + """ + server_twistd_cmd = [ + "twistd", + "--python={}".format(os.path.join(dirname(dirname(abspath(__file__))), "server.py")), + ] + if os.name != "nt": + gamedir = os.getcwd() + server_twistd_cmd.append( + "--pidfile={}".format(os.path.join(gamedir, "server", "server.pid")) + ) + return server_twistd_cmd + +
[docs] def get_info_dict(self): + """ + Return the Portal info, for display. + + """ + return self.info_dict
+ +
[docs] def shutdown(self, _reactor_stopping=False, _stop_server=False): + """ + Shuts down the server from inside it. + + Args: + _reactor_stopping (bool, optional): This is set if server + is already in the process of shutting down; in this case + we don't need to stop it again. + _stop_server (bool, optional): Only used in portal-interactive mode; + makes sure to stop the Server cleanly. + + Note that restarting (regardless of the setting) will not work + if the Portal is currently running in daemon mode. In that + case it always needs to be restarted manually. + + """ + if _reactor_stopping and hasattr(self, "shutdown_complete"): + # we get here due to us calling reactor.stop below. No need + # to do the shutdown procedure again. + return + + evennia.PORTAL_SESSION_HANDLER.disconnect_all() + if _stop_server: + self.amp_protocol.stop_server(mode="shutdown") + if not _reactor_stopping: + # shutting down the reactor will trigger another signal. We set + # a flag to avoid loops. + self.shutdown_complete = True + reactor.callLater(0, reactor.stop)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/ssh.html b/docs/latest/_modules/evennia/server/portal/ssh.html new file mode 100644 index 0000000000..c67b7ac929 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/ssh.html @@ -0,0 +1,636 @@ + + + + + + + + evennia.server.portal.ssh — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.ssh

+"""
+This module implements the ssh (Secure SHell) protocol for encrypted
+connections.
+
+This depends on a generic session module that implements the actual
+login procedure of the game, tracks sessions etc.
+
+Using standard ssh client,
+
+"""
+
+import os
+import re
+
+from twisted.conch.interfaces import IConchUser
+from twisted.cred import checkers
+from twisted.cred.portal import Portal
+
+_SSH_IMPORT_ERROR = """
+ERROR: Missing crypto library for SSH. Install it with
+
+       pip install cryptography pyasn1 bcrypt
+
+(On older Twisted versions you may have to do 'pip install pycrypto pyasn1' instead).
+
+If you get a compilation error you must install a C compiler and the
+SSL dev headers (On Debian-derived systems this is the gcc and libssl-dev
+packages).
+"""
+
+try:
+    from twisted.conch.ssh.keys import Key
+except ImportError:
+    raise ImportError(_SSH_IMPORT_ERROR)
+
+from django.conf import settings
+from evennia.accounts.models import AccountDB
+from evennia.utils import ansi
+from evennia.utils.utils import class_from_module, to_str
+from twisted.conch import interfaces as iconch
+from twisted.conch.insults import insults
+from twisted.conch.manhole import Manhole, recvline
+from twisted.conch.manhole_ssh import ConchFactory, TerminalRealm, _Glue
+from twisted.conch.ssh import common
+from twisted.conch.ssh.userauth import SSHUserAuthServer
+from twisted.internet import defer, protocol
+from twisted.python import components
+
+_RE_N = re.compile(r"\|n$")
+_RE_SCREENREADER_REGEX = re.compile(
+    r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE
+)
+_GAME_DIR = settings.GAME_DIR
+_PRIVATE_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssh-private.key")
+_PUBLIC_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssh-public.key")
+_KEY_LENGTH = 2048
+
+CTRL_C = "\x03"
+CTRL_D = "\x04"
+CTRL_BACKSLASH = "\x1c"
+CTRL_L = "\x0c"
+
+_NO_AUTOGEN = f"""
+Evennia could not generate SSH private- and public keys ({{err}})
+Using conch default keys instead.
+
+If this error persists, create the keys manually (using the tools for your OS)
+and put them here:
+    {_PRIVATE_KEY_FILE}
+    {_PUBLIC_KEY_FILE}
+"""
+
+_BASE_SESSION_CLASS = class_from_module(settings.BASE_SESSION_CLASS)
+
+
+# not used atm
+
[docs]class SSHServerFactory(protocol.ServerFactory): + """ + This is only to name this better in logs + + """ + + noisy = False + +
[docs] def logPrefix(self): + return "SSH"
+ + +
[docs]class SshProtocol(Manhole, _BASE_SESSION_CLASS): + """ + Each account connecting over ssh gets this protocol assigned to + them. All communication between game and account goes through + here. + + """ + + noisy = False + +
[docs] def __init__(self, starttuple): + """ + For setting up the account. If account is not None then we'll + login automatically. + + Args: + starttuple (tuple): A (account, factory) tuple. + + """ + self.protocol_key = "ssh" + self.authenticated_account = starttuple[0] + # obs must not be called self.factory, that gets overwritten! + self.cfactory = starttuple[1]
+ +
[docs] def terminalSize(self, width, height): + """ + Initialize the terminal and connect to the new session. + + Args: + width (int): Width of terminal. + height (int): Height of terminal. + + """ + # Clear the previous input line, redraw it at the new + # cursor position + self.terminal.eraseDisplay() + self.terminal.cursorHome() + self.width = width + self.height = height + + # Set color defaults + for color in ("ANSI", "XTERM256", "TRUECOLOR"): + self.protocol_flags[color] = True + + # initialize the session + client_address = self.getClientAddress() + client_address = client_address.host if client_address else None + self.init_session("ssh", client_address, self.cfactory.sessionhandler) + + # since we might have authenticated already, we might set this here. + if self.authenticated_account: + self.logged_in = True + self.uid = self.authenticated_account.id + self.sessionhandler.connect(self)
+ +
[docs] def connectionMade(self): + """ + This is called when the connection is first established. + + """ + recvline.HistoricRecvLine.connectionMade(self) + self.keyHandlers[CTRL_C] = self.handle_INT + self.keyHandlers[CTRL_D] = self.handle_EOF + self.keyHandlers[CTRL_L] = self.handle_FF + self.keyHandlers[CTRL_BACKSLASH] = self.handle_QUIT
+ + # initalize + +
[docs] def handle_INT(self): + """ + Handle ^C as an interrupt keystroke by resetting the current + input variables to their initial state. + + """ + self.lineBuffer = [] + self.lineBufferIndex = 0 + + self.terminal.nextLine() + self.terminal.write("KeyboardInterrupt") + self.terminal.nextLine()
+ +
[docs] def handle_EOF(self): + """ + Handles EOF generally used to exit. + + """ + if self.lineBuffer: + self.terminal.write("\a") + else: + self.handle_QUIT()
+ +
[docs] def handle_FF(self): + """ + Handle a 'form feed' byte - generally used to request a screen + refresh/redraw. + + """ + self.terminal.eraseDisplay() + self.terminal.cursorHome()
+ +
[docs] def handle_QUIT(self): + """ + Quit, end, and lose the connection. + + """ + self.terminal.loseConnection()
+ +
[docs] def connectionLost(self, reason=None): + """ + This is executed when the connection is lost for whatever + reason. It can also be called directly, from the disconnect + method. + + Args: + reason (str): Motivation for loosing connection. + + """ + insults.TerminalProtocol.connectionLost(self, reason) + self.sessionhandler.disconnect(self) + self.terminal.loseConnection()
+ +
[docs] def getClientAddress(self): + """ + Get client address. + + Returns: + address_and_port (tuple): The client's address and port in + a tuple. For example `('127.0.0.1', 41917)`. + + """ + return self.terminal.transport.getPeer()
+ +
[docs] def lineReceived(self, string): + """ + Communication User -> Evennia. Any line return indicates a + command for the purpose of the MUD. So we take the user input + and pass it on to the game engine. + + Args: + string (str): Input text. + + """ + self.sessionhandler.data_in(self, text=string)
+ +
[docs] def sendLine(self, string): + """ + Communication Evennia -> User. Any string sent should + already have been properly formatted and processed before + reaching this point. + + Args: + string (str): Output text. + + """ + for line in string.split("\n"): + # the telnet-specific method for sending + self.terminal.write(line) + self.terminal.nextLine()
+ + # session-general method hooks + +
[docs] def at_login(self): + """ + Called when this session gets authenticated by the server. + """ + pass
+ +
[docs] def disconnect(self, reason="Connection closed. Goodbye for now."): + """ + Disconnect from server. + + Args: + reason (str): Motivation for disconnect. + + """ + if reason: + self.data_out(text=((reason,), {})) + self.connectionLost(reason)
+ +
[docs] def data_out(self, **kwargs): + """ + Data Evennia -> User + + Keyword Args: + kwargs (any): Options to the protocol. + + """ + self.sessionhandler.data_out(self, **kwargs)
+ +
[docs] def send_text(self, *args, **kwargs): + """ + Send text data. This is an in-band telnet operation. + + Args: + text (str): The first argument is always the text string to send. No other arguments + are considered. + Keyword Args: + options (dict): Send-option flags (booleans) + + - mxp: enforce mxp link support. + - ansi: enforce no ansi colors. + - xterm256: enforce xterm256 colors, regardless of ttype setting. + - nocolor: strip all colors. + - raw: pass string through without any ansi processing + (i.e. include evennia ansi markers but do not + convert them into ansi tokens) + - echo: turn on/off line echo on the client. turn + off line echo for client, for example for password. + note that it must be actively turned back on again! + + """ + # print "telnet.send_text", args,kwargs # DEBUG + text = args[0] if args else "" + if text is None: + return + text = to_str(text) + + # handle arguments + options = kwargs.get("options", {}) + flags = self.protocol_flags + xterm256 = options.get("xterm256", flags.get("XTERM256", True)) + useansi = options.get("ansi", flags.get("ANSI", True)) + raw = options.get("raw", flags.get("RAW", False)) + nocolor = options.get("nocolor", flags.get("NOCOLOR") or not (xterm256 or useansi)) + # echo = options.get("echo", None) # DEBUG + screenreader = options.get("screenreader", flags.get("SCREENREADER", False)) + + if screenreader: + # screenreader mode cleans up output + text = ansi.parse_ansi(text, strip_ansi=True, xterm256=False, mxp=False) + text = _RE_SCREENREADER_REGEX.sub("", text) + + if raw: + # no processing + self.sendLine(text) + return + else: + # we need to make sure to kill the color at the end in order + # to match the webclient output. + linetosend = ansi.parse_ansi( + _RE_N.sub("", text) + ("||n" if text.endswith("|") else "|n"), + strip_ansi=nocolor, + xterm256=xterm256, + mxp=False, + ) + self.sendLine(linetosend)
+ +
[docs] def send_prompt(self, *args, **kwargs): + self.send_text(*args, **kwargs)
+ +
[docs] def send_default(self, *args, **kwargs): + pass
+ + +
[docs]class ExtraInfoAuthServer(SSHUserAuthServer): + noisy = False + +
[docs] def auth_password(self, packet): + """ + Password authentication. + + Used mostly for setting up the transport so we can query + username and password later. + + Args: + packet (Packet): Auth packet. + + """ + password = common.getNS(packet[1:])[0] + c = checkers.UsernamePassword(self.user, password) + c.transport = self.transport + return self.portal.login(c, None, IConchUser).addErrback(self._ebPassword)
+ + +
[docs]class AccountDBPasswordChecker(object): + """ + Checks the django db for the correct credentials for + username/password otherwise it returns the account or None which is + useful for the Realm. + + """ + + noisy = False + credentialInterfaces = (checkers.IUsernamePassword,) + +
[docs] def __init__(self, factory): + """ + Initialize the factory. + + Args: + factory (SSHFactory): Checker factory. + + """ + self.factory = factory + super().__init__()
+ +
[docs] def requestAvatarId(self, c): + """ + Generic credentials. + + """ + up = checkers.IUsernamePassword(c, None) + username = up.username + password = up.password + account = AccountDB.objects.get_account_from_name(username) + res = (None, self.factory) + if account and account.check_password(password): + res = (account, self.factory) + return defer.succeed(res)
+ + +
[docs]class PassAvatarIdTerminalRealm(TerminalRealm): + """ + Returns an avatar that passes the avatarId through to the + protocol. This is probably not the best way to do it. + + """ + + noisy = False + + def _getAvatar(self, avatarId): + comp = components.Componentized() + user = self.userFactory(comp, avatarId) + sess = self.sessionFactory(comp) + + sess.transportFactory = self.transportFactory + sess.chainedProtocolFactory = lambda: self.chainedProtocolFactory(avatarId) + + comp.setComponent(iconch.IConchUser, user) + comp.setComponent(iconch.ISession, sess) + + return user
+ + +
[docs]class TerminalSessionTransport_getPeer(object): + """ + Taken from twisted's TerminalSessionTransport which doesn't + provide getPeer to the transport. This one does. + + """ + + noisy = False + +
[docs] def __init__(self, proto, chainedProtocol, avatar, width, height): + self.proto = proto + self.avatar = avatar + self.chainedProtocol = chainedProtocol + + session = self.proto.session + + self.proto.makeConnection( + _Glue( + write=self.chainedProtocol.dataReceived, + loseConnection=lambda: avatar.conn.sendClose(session), + name="SSH Proto Transport", + ) + ) + + def loseConnection(): + self.proto.loseConnection() + + def getPeer(): + return session.conn.transport.transport.getPeer() + + self.chainedProtocol.makeConnection( + _Glue( + getPeer=getPeer, + write=self.proto.write, + loseConnection=loseConnection, + name="Chained Proto Transport", + ) + ) + + self.chainedProtocol.terminalProtocol.terminalSize(width, height)
+ + +
[docs]def getKeyPair(pubkeyfile, privkeyfile): + """ + This function looks for RSA keypair files in the current directory. If they + do not exist, the keypair is created. + """ + + if not (os.path.exists(pubkeyfile) and os.path.exists(privkeyfile)): + # No keypair exists. Generate a new RSA keypair + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.asymmetric import rsa + + rsa_key = Key( + rsa.generate_private_key( + public_exponent=65537, key_size=_KEY_LENGTH, backend=default_backend() + ) + ) + public_key_string = rsa_key.public().toString(type="OPENSSH").decode() + private_key_string = rsa_key.toString(type="OPENSSH").decode() + + # save keys for the future. + with open(privkeyfile, "wt") as pfile: + pfile.write(private_key_string) + print("Created SSH private key in '{}'".format(_PRIVATE_KEY_FILE)) + with open(pubkeyfile, "wt") as pfile: + pfile.write(public_key_string) + print("Created SSH public key in '{}'".format(_PUBLIC_KEY_FILE)) + else: + with open(pubkeyfile) as pfile: + public_key_string = pfile.read() + with open(privkeyfile) as pfile: + private_key_string = pfile.read() + + return Key.fromString(public_key_string), Key.fromString(private_key_string)
+ + +
[docs]def makeFactory(configdict): + """ + Creates the ssh server factory. + """ + + def chainProtocolFactory(username=None): + return insults.ServerProtocol( + configdict["protocolFactory"], + *configdict.get("protocolConfigdict", (username,)), + **configdict.get("protocolKwArgs", {}), + ) + + rlm = PassAvatarIdTerminalRealm() + rlm.transportFactory = TerminalSessionTransport_getPeer + rlm.chainedProtocolFactory = chainProtocolFactory + factory = ConchFactory(Portal(rlm)) + factory.sessionhandler = configdict["sessions"] + + try: + # create/get RSA keypair + publicKey, privateKey = getKeyPair(_PUBLIC_KEY_FILE, _PRIVATE_KEY_FILE) + factory.publicKeys = {b"ssh-rsa": publicKey} + factory.privateKeys = {b"ssh-rsa": privateKey} + except Exception as err: + print(_NO_AUTOGEN.format(err=err)) + + factory.services = factory.services.copy() + factory.services["ssh-userauth"] = ExtraInfoAuthServer + + factory.portal.registerChecker(AccountDBPasswordChecker(factory)) + + return factory
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/ssl.html b/docs/latest/_modules/evennia/server/portal/ssl.html new file mode 100644 index 0000000000..385880860e --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/ssl.html @@ -0,0 +1,227 @@ + + + + + + + + evennia.server.portal.ssl — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.ssl

+"""
+This is a simple context factory for auto-creating
+SSL keys and certificates.
+
+"""
+import os
+import sys
+
+try:
+    import OpenSSL
+    from twisted.internet import ssl as twisted_ssl
+except ImportError as error:
+    errstr = """
+    {err}
+    SSL requires the PyOpenSSL library:
+        pip install pyopenssl
+    """
+    raise ImportError(errstr.format(err=error))
+
+from django.conf import settings
+
+from evennia.utils.utils import class_from_module
+
+_GAME_DIR = settings.GAME_DIR
+
+# messages
+
+NO_AUTOGEN = """
+
+{err}
+Evennia could not auto-generate the SSL private key. If this error
+persists, create {keyfile} yourself using third-party tools.
+"""
+
+NO_AUTOCERT = """
+
+{err}
+Evennia's SSL context factory could not automatically, create an SSL
+certificate {certfile}.
+
+A private key {keyfile} was already created. Please create {certfile}
+manually using the commands valid  for your operating system, for
+example (linux, using the openssl program):
+    {exestring}
+"""
+
+_TELNET_PROTOCOL_CLASS = class_from_module(settings.TELNET_PROTOCOL_CLASS)
+
+
+
[docs]class SSLProtocol(_TELNET_PROTOCOL_CLASS): + """ + Communication is the same as telnet, except data transfer + is done with encryption. + + """ + +
[docs] def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.protocol_name = "ssl"
+ + +
[docs]def verify_SSL_key_and_cert(keyfile, certfile): + """ + This function looks for RSA key and certificate in the current + directory. If files ssl.key and ssl.cert does not exist, they + are created. + + """ + + if not (os.path.exists(keyfile) and os.path.exists(certfile)): + # key/cert does not exist. Create. + import subprocess + + from Crypto.PublicKey import RSA + from twisted.conch.ssh.keys import Key + + print(" Creating SSL key and certificate ... ", end=" ") + + try: + # create the RSA key and store it. + KEY_LENGTH = 2048 + rsa_key = Key(RSA.generate(KEY_LENGTH)) + key_string = rsa_key.toString(type="OPENSSH") + with open(keyfile, "w+b") as fil: + fil.write(key_string) + except Exception as err: + print(NO_AUTOGEN.format(err=err, keyfile=keyfile)) + sys.exit(5) + + # try to create the certificate + CERT_EXPIRE = 365 * 20 # twenty years validity + # default: + # openssl req -new -x509 -key ssl.key -out ssl.cert -days 7300 + exestring = "openssl req -new -x509 -key %s -out %s -days %s" % ( + keyfile, + certfile, + CERT_EXPIRE, + ) + try: + subprocess.call(exestring) + except OSError as err: + raise OSError( + NO_AUTOCERT.format(err=err, certfile=certfile, keyfile=keyfile, exestring=exestring) + ) + print("done.")
+ + +
[docs]def getSSLContext(): + """ + This is called by the portal when creating the SSL context + server-side. + + Returns: + ssl_context (tuple): A key and certificate that is either + existing previously or or created on the fly. + + """ + keyfile = os.path.join(_GAME_DIR, "server", "ssl.key") + certfile = os.path.join(_GAME_DIR, "server", "ssl.cert") + + verify_SSL_key_and_cert(keyfile, certfile) + return twisted_ssl.DefaultOpenSSLContextFactory(keyfile, certfile)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/suppress_ga.html b/docs/latest/_modules/evennia/server/portal/suppress_ga.html new file mode 100644 index 0000000000..d3a1e1fa21 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/suppress_ga.html @@ -0,0 +1,173 @@ + + + + + + + + evennia.server.portal.suppress_ga — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.suppress_ga

+"""
+
+SUPPRESS-GO-AHEAD
+
+This supports suppressing or activating Evennia
+the GO-AHEAD telnet operation after every server reply.
+If the client sends no explicit DONT SUPRESS GO-AHEAD,
+Evennia will default to supressing it since many clients
+will fail to use it and has no knowledge of this standard.
+
+It is set as the NOGOAHEAD protocol_flag option.
+
+http://www.faqs.org/rfcs/rfc858.html
+
+"""
+
+SUPPRESS_GA = bytes([3])  # b"\x03"
+
+# default taken from telnet specification
+
+# try to get the customized mssp info, if it exists.
+
+
+
[docs]class SuppressGA: + """ + Implements the SUPRESS-GO-AHEAD protocol. Add this to a variable on the telnet + protocol to set it up. + + """ + +
[docs] def __init__(self, protocol): + """ + Initialize suppression of GO-AHEADs. + + Args: + protocol (Protocol): The active protocol instance. + + """ + self.protocol = protocol + + self.protocol.protocol_flags["NOGOAHEAD"] = True + self.protocol.protocol_flags[ + "NOPROMPTGOAHEAD" + ] = True # Used to send a GA after a prompt line only, set in TTYPE (per client) + # tell the client that we prefer to suppress GA ... + self.protocol.will(SUPPRESS_GA).addCallbacks(self.will_suppress_ga, self.wont_suppress_ga)
+ +
[docs] def wont_suppress_ga(self, option): + """ + Called when client requests to not suppress GA. + + Args: + option (Option): Not used. + + """ + self.protocol.protocol_flags["NOGOAHEAD"] = False + self.protocol.handshake_done()
+ +
[docs] def will_suppress_ga(self, option): + """ + Client will suppress GA + + Args: + option (Option): Not used. + + """ + self.protocol.protocol_flags["NOGOAHEAD"] = True + self.protocol.handshake_done()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/telnet.html b/docs/latest/_modules/evennia/server/portal/telnet.html new file mode 100644 index 0000000000..6966a94c37 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/telnet.html @@ -0,0 +1,623 @@ + + + + + + + + evennia.server.portal.telnet — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.telnet

+"""
+This module implements the telnet protocol.
+
+This depends on a generic session module that implements
+the actual login procedure of the game, tracks
+sessions etc.
+
+"""
+
+import re
+
+from django.conf import settings
+from twisted.conch.telnet import (
+    ECHO,
+    GA,
+    IAC,
+    LINEMODE,
+    LINEMODE_EDIT,
+    LINEMODE_TRAPSIG,
+    MODE,
+    NOP,
+    NULL,
+    WILL,
+    WONT,
+    StatefulTelnetProtocol,
+    Telnet,
+)
+from twisted.internet import protocol
+from twisted.internet.task import LoopingCall
+
+from evennia.server.portal import mssp, naws, suppress_ga, telnet_oob, ttype
+from evennia.server.portal.mccp import MCCP, Mccp, mccp_compress
+from evennia.server.portal.mxp import Mxp, mxp_parse
+from evennia.utils import ansi
+from evennia.utils.utils import class_from_module, to_bytes
+
+_RE_N = re.compile(r"\|n$")
+_RE_LEND = re.compile(rb"\n$|\r$|\r\n$|\r\x00$|", re.MULTILINE)
+_RE_LINEBREAK = re.compile(rb"\n\r|\r\n|\n|\r", re.DOTALL + re.MULTILINE)
+_RE_SCREENREADER_REGEX = re.compile(
+    r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE
+)
+_IDLE_COMMAND = str.encode(settings.IDLE_COMMAND + "\n")
+
+# identify HTTP indata
+_HTTP_REGEX = re.compile(
+    b"(GET|HEAD|POST|PUT|DELETE|TRACE|OPTIONS|CONNECT|PATCH) (.*? HTTP/[0-9]\.[0-9])", re.I
+)
+
+_HTTP_WARNING = bytes(
+    """
+    This is Evennia's Telnet port and cannot be used for regular HTTP traffic.
+    Use a telnet client to connect here and point your browser to the server's
+    dedicated web port instead.
+
+    """.strip(),
+    "utf-8",
+)
+
+
+_BASE_SESSION_CLASS = class_from_module(settings.BASE_SESSION_CLASS)
+
+
+
[docs]class TelnetServerFactory(protocol.ServerFactory): + """ + This exists only to name this better in logs. + + """ + + noisy = False + +
[docs] def logPrefix(self): + return "Telnet"
+ + +
[docs]class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS): + """ + Each player connecting over telnet (ie using most traditional mud + clients) gets a telnet protocol instance assigned to them. All + communication between game and player goes through here. + + """ + +
[docs] def __init__(self, *args, **kwargs): + self.protocol_key = "telnet" + super().__init__(*args, **kwargs)
+ +
[docs] def dataReceived(self, data): + """ + Unused by default, but a good place to put debug printouts + of incoming data. + + """ + # print(f"telnet dataReceived: {data}") + try: + super().dataReceived(data) + except ValueError as err: + from evennia.utils import logger + + logger.log_err(f"Malformed telnet input: {err}")
+ +
[docs] def connectionMade(self): + """ + This is called when the connection is first established. + + """ + # important in order to work normally with standard telnet + self.do(LINEMODE).addErrback(self._wont_linemode) + # initialize the session + self.line_buffer = b"" + client_address = self.transport.client + client_address = client_address[0] if client_address else None + # this number is counted down for every handshake that completes. + # when it reaches 0 the portal/server syncs their data + self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp + + self.init_session(self.protocol_key, client_address, self.factory.sessionhandler) + self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] if settings.ENCODINGS else "utf-8" + # add this new connection to sessionhandler so + # the Server becomes aware of it. + self.sessionhandler.connect(self) + # change encoding to ENCODINGS[0] which reflects Telnet default encoding + + # suppress go-ahead + self.sga = suppress_ga.SuppressGA(self) + # negotiate client size + self.naws = naws.Naws(self) + # negotiate ttype (client info) + # Obs: mudlet ttype does not seem to work if we start mccp before ttype. /Griatch + self.ttype = ttype.Ttype(self) + # negotiate mccp (data compression) - turn this off for wireshark analysis + self.mccp = Mccp(self) + # negotiate mssp (crawler communication) + self.mssp = mssp.Mssp(self) + # oob communication (MSDP, GMCP) - two handshake calls! + self.oob = telnet_oob.TelnetOOB(self) + # mxp support + self.mxp = Mxp(self) + + from evennia.utils.utils import delay + + # timeout the handshakes in case the client doesn't reply at all + self._handshake_delay = delay(2, callback=self.handshake_done, timeout=True) + + # TCP/IP keepalive watches for dead links + self.transport.setTcpKeepAlive(1) + # The TCP/IP keepalive is not enough for some networks; + # we have to complement it with a NOP keep-alive. + self.protocol_flags["NOPKEEPALIVE"] = True + self.nop_keep_alive = None + self.toggle_nop_keepalive()
+ + def _wont_linemode(self, *args): + """ + Client refuses do(linemode). This is common for MUD-specific + clients, but we must ask for the sake of raw telnet. We ignore + this error. + + """ + pass + + def _send_nop_keepalive(self): + """ + Send NOP keepalive unless flag is set + + """ + if self.protocol_flags.get("NOPKEEPALIVE"): + self._write(IAC + NOP) + +
[docs] def toggle_nop_keepalive(self): + """ + Allow to toggle the NOP keepalive for those sad clients that + can't even handle a NOP instruction. This is turned off by the + protocol_flag NOPKEEPALIVE (settable e.g. by the default + `option` command). + + """ + if self.nop_keep_alive and self.nop_keep_alive.running: + self.nop_keep_alive.stop() + else: + self.nop_keep_alive = LoopingCall(self._send_nop_keepalive) + self.nop_keep_alive.start(30, now=False)
+ +
[docs] def handshake_done(self, timeout=False): + """ + This is called by all telnet extensions once they are finished. + When all have reported, a sync with the server is performed. + The system will force-call this sync after a small time to handle + clients that don't reply to handshakes at all. + + """ + if timeout: + if self.handshakes > 0: + self.handshakes = 0 + self.sessionhandler.sync(self) + else: + self.handshakes -= 1 + if self.handshakes <= 0: + # do the sync + self.sessionhandler.sync(self)
+ +
[docs] def at_login(self): + """ + Called when this session gets authenticated by the server. + + """ + pass
+ +
[docs] def enableRemote(self, option): + """ + This sets up the remote-activated options we allow for this protocol. + + Args: + option (char): The telnet option to enable. + + Returns: + enable (bool): If this option should be enabled. + + """ + if option == LINEMODE: + # make sure to activate line mode with local editing for all clients + self.requestNegotiation( + LINEMODE, MODE + bytes(chr(ord(LINEMODE_EDIT) + ord(LINEMODE_TRAPSIG)), "ascii") + ) + return True + else: + return ( + option == ttype.TTYPE + or option == naws.NAWS + or option == MCCP + or option == mssp.MSSP + or option == ECHO + or option == suppress_ga.SUPPRESS_GA + )
+ +
[docs] def disableRemote(self, option): + return ( + option == LINEMODE + or option == ttype.TTYPE + or option == naws.NAWS + or option == MCCP + or option == mssp.MSSP + or option == ECHO + or option == suppress_ga.SUPPRESS_GA + )
+ +
[docs] def enableLocal(self, option): + """ + Call to allow the activation of options for this protocol + + Args: + option (char): The telnet option to enable locally. + + Returns: + enable (bool): If this option should be enabled. + + """ + return ( + option == LINEMODE + or option == MCCP + or option == ECHO + or option == suppress_ga.SUPPRESS_GA + )
+ +
[docs] def disableLocal(self, option): + """ + Disable a given option locally. + + Args: + option (char): The telnet option to disable locally. + + """ + if option == LINEMODE: + return True + if option == ECHO: + return True + if option == MCCP: + self.mccp.no_mccp(option) + return True + else: + try: + return super().disableLocal(option) + except Exception: + from evennia.utils import logger + + logger.log_trace()
+ +
[docs] def connectionLost(self, reason): + """ + this is executed when the connection is lost for whatever + reason. it can also be called directly, from the disconnect + method + + Args: + reason (str): Motivation for losing connection. + + """ + self.sessionhandler.disconnect(self) + self.transport.loseConnection()
+ +
[docs] def applicationDataReceived(self, data): + """ + Telnet method called when non-telnet-command data is coming in + over the telnet connection. We pass it on to the game engine + directly. + + Args: + data (str): Incoming data. + + """ + if not data: + data = [data] + elif data.strip() == NULL: + # this is an ancient type of keepalive used by some + # legacy clients. There should never be a reason to send a + # lone NULL character so this seems to be a safe thing to + # support for backwards compatibility. It also stops the + # NULL from continuously popping up as an unknown command. + data = [_IDLE_COMMAND] + else: + data = _RE_LINEBREAK.split(data) + + if len(data) > 2 and _HTTP_REGEX.match(data[0]): + # guard against HTTP request on the Telnet port; we + # block and kill the connection. + self.transport.write(_HTTP_WARNING) + self.transport.loseConnection() + return + + if self.line_buffer and len(data) > 1: + # buffer exists, it is terminated by the first line feed + data[0] = self.line_buffer + data[0] + self.line_buffer = b"" + # if the last data split is empty, it means all splits have + # line breaks, if not, it is unterminated and must be + # buffered. + self.line_buffer += data.pop() + # send all data chunks + for dat in data: + self.data_in(text=dat + b"\n")
+ + def _write(self, data): + """ + Hook overloading the one used in plain telnet + + """ + data = data.replace(b"\n", b"\r\n").replace(b"\r\r\n", b"\r\n") + super()._write(mccp_compress(self, data)) + +
[docs] def sendLine(self, line): + """ + Hook overloading the one used by linereceiver. + + Args: + line (str): Line to send. + + """ + line = to_bytes(line, self) + # escape IAC in line mode, and correctly add \r\n (the TELNET end-of-line) + line = line.replace(IAC, IAC + IAC) + line = line.replace(b"\n", b"\r\n") + if not line.endswith(b"\r\n") and self.protocol_flags.get("FORCEDENDLINE", True): + line += b"\r\n" + if not self.protocol_flags.get("NOGOAHEAD", True): + line += IAC + GA + return self.transport.write(mccp_compress(self, line))
+ + # Session hooks + +
[docs] def disconnect(self, reason=""): + """ + Generic hook for the engine to call in order to + disconnect this protocol. + + Args: + reason (str, optional): Reason for disconnecting. + + """ + self.data_out(text=((reason,), {})) + self.connectionLost(reason)
+ +
[docs] def data_in(self, **kwargs): + """ + Data User -> Evennia + + Keyword Args: + kwargs (any): Options from the protocol. + + """ + # from evennia.server.profiling.timetrace import timetrace # DEBUG + # text = timetrace(text, "telnet.data_in") # DEBUG + + self.sessionhandler.data_in(self, **kwargs)
+ +
[docs] def data_out(self, **kwargs): + """ + Data Evennia -> User + + Keyword Args: + kwargs (any): Options to the protocol + + """ + self.sessionhandler.data_out(self, **kwargs)
+ + # send_* methods + +
[docs] def send_text(self, *args, **kwargs): + """ + Send text data. This is an in-band telnet operation. + + Args: + text (str): The first argument is always the text string to send. No other arguments + are considered. + Keyword Args: + options (dict): Send-option flags + + - mxp: Enforce MXP link support. + - ansi: Enforce no ANSI colors. + - xterm256: Enforce xterm256 colors, regardless of TTYPE. + - noxterm256: Enforce no xterm256 color support, regardless of TTYPE. + - nocolor: Strip all Color, regardless of ansi/xterm256 setting. + - raw: Pass string through without any ansi processing + (i.e. include Evennia ansi markers but do not + convert them into ansi tokens) + - echo: Turn on/off line echo on the client. Turn + off line echo for client, for example for password. + Note that it must be actively turned back on again! + + """ + text = args[0] if args else "" + if text is None: + return + + # handle arguments + options = kwargs.get("options", {}) + flags = self.protocol_flags + xterm256 = options.get( + "xterm256", flags.get("XTERM256", False) if flags.get("TTYPE", False) else True + ) + useansi = options.get( + "ansi", flags.get("ANSI", False) if flags.get("TTYPE", False) else True + ) + raw = options.get("raw", flags.get("RAW", False)) + nocolor = options.get("nocolor", flags.get("NOCOLOR") or not (xterm256 or useansi)) + echo = options.get("echo", None) + mxp = options.get("mxp", flags.get("MXP", False)) + screenreader = options.get("screenreader", flags.get("SCREENREADER", False)) + + if screenreader: + # screenreader mode cleans up output + text = ansi.parse_ansi(text, strip_ansi=True, xterm256=False, mxp=False) + text = _RE_SCREENREADER_REGEX.sub("", text) + + if options.get("send_prompt"): + # send a prompt instead. + prompt = text + if not raw: + # processing + prompt = ansi.parse_ansi( + _RE_N.sub("", prompt) + ("||n" if prompt.endswith("|") else "|n"), + strip_ansi=nocolor, + xterm256=xterm256, + ) + if mxp: + prompt = mxp_parse(prompt) + prompt = to_bytes(prompt, self) + prompt = prompt.replace(IAC, IAC + IAC).replace(b"\n", b"\r\n") + if not self.protocol_flags.get( + "NOPROMPTGOAHEAD", self.protocol_flags.get("NOGOAHEAD", True) + ): + prompt += IAC + GA + self.transport.write(mccp_compress(self, prompt)) + else: + if echo is not None: + # turn on/off echo. Note that this is a bit turned around since we use + # echo as if we are "turning off the client's echo" when telnet really + # handles it the other way around. + if echo: + # by telling the client that WE WON'T echo, the client knows + # that IT should echo. This is the expected behavior from + # our perspective. + self.transport.write(mccp_compress(self, IAC + WONT + ECHO)) + else: + # by telling the client that WE WILL echo, the client can + # safely turn OFF its OWN echo. + self.transport.write(mccp_compress(self, IAC + WILL + ECHO)) + if raw: + # no processing + self.sendLine(text) + return + else: + # we need to make sure to kill the color at the end in order + # to match the webclient output. + linetosend = ansi.parse_ansi( + _RE_N.sub("", text) + ("||n" if text.endswith("|") else "|n"), + strip_ansi=nocolor, + xterm256=xterm256, + mxp=mxp, + ) + if mxp: + linetosend = mxp_parse(linetosend) + self.sendLine(linetosend)
+ +
[docs] def send_prompt(self, *args, **kwargs): + """ + Send a prompt - a text without a line end. See send_text for argument options. + + """ + kwargs["options"].update({"send_prompt": True}) + self.send_text(*args, **kwargs)
+ +
[docs] def send_default(self, cmdname, *args, **kwargs): + """ + Send other oob data + + """ + if not cmdname == "options": + self.oob.data_out(cmdname, *args, **kwargs)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/telnet_oob.html b/docs/latest/_modules/evennia/server/portal/telnet_oob.html new file mode 100644 index 0000000000..e0bb7ff455 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/telnet_oob.html @@ -0,0 +1,553 @@ + + + + + + + + evennia.server.portal.telnet_oob — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.telnet_oob

+"""
+
+Telnet OOB (Out of band communication)
+
+OOB protocols allow for asynchronous communication between Evennia and
+compliant telnet clients. The "text" type of send command will always
+be sent "in-band", appearing in the client's main text output. OOB
+commands, by contrast, can have many forms and it is up to the client
+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.
+
+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
+
+----
+
+"""
+import json
+import re
+
+# General Telnet
+from twisted.conch.telnet import IAC, SB, SE
+
+from evennia.utils.utils import is_iter
+
+# MSDP-relevant telnet cmd/opt-codes
+MSDP = bytes([69])
+MSDP_VAR = bytes([1])
+MSDP_VAL = bytes([2])
+MSDP_TABLE_OPEN = bytes([3])
+MSDP_TABLE_CLOSE = bytes([4])
+
+MSDP_ARRAY_OPEN = bytes([5])
+MSDP_ARRAY_CLOSE = bytes([6])
+
+# GMCP
+GMCP = bytes([201])
+
+
+# pre-compiled regexes
+# returns 2-tuple
+msdp_regex_table = re.compile(
+    rb"%s\s*(\w*?)\s*%s\s*%s(.*?)%s" % (MSDP_VAR, MSDP_VAL, MSDP_TABLE_OPEN, MSDP_TABLE_CLOSE)
+)
+# returns 2-tuple
+msdp_regex_array = re.compile(
+    rb"%s\s*(\w*?)\s*%s\s*%s(.*?)%s" % (MSDP_VAR, MSDP_VAL, MSDP_ARRAY_OPEN, MSDP_ARRAY_CLOSE)
+)
+msdp_regex_var = re.compile(rb"%s" % MSDP_VAR)
+msdp_regex_val = re.compile(rb"%s" % MSDP_VAL)
+
+EVENNIA_TO_GMCP = {
+    "client_options": "Core.Supports.Get",
+    "get_inputfuncs": "Core.Commands.Get",
+    "get_value": "Char.Value.Get",
+    "repeat": "Char.Repeat.Update",
+    "monitor": "Char.Monitor.Update",
+}
+
+
+# MSDP/GMCP communication handler
+
+
+
[docs]class TelnetOOB: + """ + Implements the MSDP and GMCP protocols. + """ + +
[docs] def __init__(self, protocol): + """ + Initiates by storing the protocol on itself and trying to + determine if the client supports MSDP. + + Args: + protocol (Protocol): The active protocol. + + """ + self.protocol = protocol + self.protocol.protocol_flags["OOB"] = False + self.MSDP = False + self.GMCP = False + # ask for the available protocols and assign decoders + # (note that handshake_done() will be called twice!) + self.protocol.negotiationMap[MSDP] = self.decode_msdp + self.protocol.negotiationMap[GMCP] = self.decode_gmcp + self.protocol.will(MSDP).addCallbacks(self.do_msdp, self.no_msdp) + self.protocol.will(GMCP).addCallbacks(self.do_gmcp, self.no_gmcp) + self.oob_reported = {}
+ +
[docs] def no_msdp(self, option): + """ + Client reports No msdp supported or wanted. + + Args: + option (Option): Not used. + + """ + # no msdp, check GMCP + self.protocol.handshake_done()
+ +
[docs] def do_msdp(self, option): + """ + Client reports that it supports msdp. + + Args: + option (Option): Not used. + + """ + self.MSDP = True + self.protocol.protocol_flags["OOB"] = True + self.protocol.handshake_done()
+ +
[docs] def no_gmcp(self, option): + """ + If this is reached, it means neither MSDP nor GMCP is + supported. + + Args: + option (Option): Not used. + + """ + self.protocol.handshake_done()
+ +
[docs] def do_gmcp(self, option): + """ + Called when client confirms that it can do MSDP or GMCP. + + Args: + option (Option): Not used. + + """ + self.GMCP = True + self.protocol.protocol_flags["OOB"] = True + self.protocol.handshake_done()
+ + # encoders + +
[docs] def encode_msdp(self, cmdname, *args, **kwargs): + """ + Encode into a valid MSDP command. + + Args: + cmdname (str): Name of send instruction. + args, kwargs (any): Arguments to OOB command. + + 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. + + """ + msdp_cmdname = "{msdp_var}{msdp_cmdname}{msdp_val}".format( + msdp_var=MSDP_VAR.decode(), msdp_cmdname=cmdname, msdp_val=MSDP_VAL.decode() + ) + + if not (args or kwargs): + return msdp_cmdname.encode() + + # print("encode_msdp in:", cmdname, args, kwargs) # DEBUG + + msdp_args = "" + if args: + msdp_args = msdp_cmdname + if len(args) == 1: + msdp_args += args[0] + else: + msdp_args += ( + "{msdp_array_open}" + "{msdp_args}" + "{msdp_array_close}".format( + msdp_array_open=MSDP_ARRAY_OPEN.decode(), + msdp_array_close=MSDP_ARRAY_CLOSE.decode(), + msdp_args="".join("%s%s" % (MSDP_VAL.decode(), val) for val in args), + ) + ) + + msdp_kwargs = "" + if kwargs: + msdp_kwargs = msdp_cmdname + msdp_kwargs += ( + "{msdp_table_open}" + "{msdp_kwargs}" + "{msdp_table_close}".format( + msdp_table_open=MSDP_TABLE_OPEN.decode(), + msdp_table_close=MSDP_TABLE_CLOSE.decode(), + msdp_kwargs="".join( + "%s%s%s%s" % (MSDP_VAR.decode(), key, MSDP_VAL.decode(), val) + for key, val in kwargs.items() + ), + ) + ) + + msdp_string = msdp_args + msdp_kwargs + + # print("msdp_string:", msdp_string) # DEBUG + return msdp_string.encode()
+ +
[docs] def encode_gmcp(self, cmdname, *args, **kwargs): + """ + Encode into GMCP messages. + + Args: + cmdname (str): GMCP OOB command name. + args, kwargs (any): Arguments to OOB command. + + Notes: + GMCP messages will be outgoing on the following + form (the non-JSON cmdname at the start is what + IRE games use, supposedly, and what clients appear + to have adopted). A cmdname without Package will end + 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}] + + For more flexibility with certain clients, if `cmd_name` is capitalized, + Evennia will leave its current capitalization (So CMD_nAmE would be sent + as CMD.nAmE but cMD_Name would be Cmd.Name) + + 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 + + """ + + if cmdname in EVENNIA_TO_GMCP: + gmcp_cmdname = EVENNIA_TO_GMCP[cmdname] + elif "_" in cmdname: + if cmdname.istitle(): + # leave without capitalization + gmcp_cmdname = ".".join(word for word in cmdname.split("_")) + else: + gmcp_cmdname = ".".join(word.capitalize() for word in cmdname.split("_")) + else: + gmcp_cmdname = "Core.%s" % (cmdname if cmdname.istitle() else cmdname.capitalize()) + + if not (args or kwargs): + gmcp_string = gmcp_cmdname + elif args: + if len(args) == 1: + args = args[0] + if kwargs: + gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps([args, kwargs])) + else: + gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps(args)) + else: # only kwargs + gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps(kwargs)) + + # print("gmcp string", gmcp_string) # DEBUG + return gmcp_string.encode()
+ +
[docs] def decode_msdp(self, data): + """ + Decodes incoming MSDP data. + + Args: + data (str or list): MSDP data. + + Notes: + 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 + given, they will be merged into one argument array, same + for tables. Different MSDP_VARS (outside tables) will be + identified as separate cmdnames. + + """ + if isinstance(data, list): + data = b"".join(data) + + # print("decode_msdp in:", data) # DEBUG + + tables = {} + arrays = {} + variables = {} + + # decode tables + for key, table in msdp_regex_table.findall(data): + key = key.decode() + tables[key] = {} if key not in tables else tables[key] + for varval in msdp_regex_var.split(table)[1:]: + var, val = msdp_regex_val.split(varval, 1) + var, val = var.decode(), val.decode() + if var: + tables[key][var] = val + + # decode arrays from all that was not a table + data_no_tables = msdp_regex_table.sub(b"", data) + for key, array in msdp_regex_array.findall(data_no_tables): + key = key.decode() + arrays[key] = [] if key not in arrays else arrays[key] + parts = msdp_regex_val.split(array) + parts = [part.decode() for part in parts] + if len(parts) == 2: + arrays[key].append(parts[1]) + elif len(parts) > 1: + arrays[key].extend(parts[1:]) + + # decode remainders from all that were not tables or arrays + data_no_tables_or_arrays = msdp_regex_array.sub(b"", data_no_tables) + for varval in msdp_regex_var.split(data_no_tables_or_arrays): + # get remaining varvals after cleaning away tables/arrays. If mathcing + # an existing key in arrays, it will be added as an argument to that command, + # otherwise it will be treated as a command without argument. + parts = msdp_regex_val.split(varval) + parts = [part.decode() for part in parts] + if len(parts) == 2: + variables[parts[0]] = parts[1] + elif len(parts) > 1: + variables[parts[0]] = parts[1:] + + cmds = {} + # merge matching table/array/variables together + for key, table in tables.items(): + args, kwargs = [], table + if key in arrays: + args.extend(arrays.pop(key)) + if key in variables: + args.append(variables.pop(key)) + cmds[key] = [args, kwargs] + + for key, arr in arrays.items(): + args, kwargs = arr, {} + if key in variables: + args.append(variables.pop(key)) + cmds[key] = [args, kwargs] + + for key, var in variables.items(): + cmds[key] = [[var], {}] + + # remap the 'generic msdp commands' to avoid colliding with builtins etc + # by prepending "msdp_" + lower_case = {key.lower(): key for key in cmds} + for remap in ("list", "report", "reset", "send", "unreport"): + if remap in lower_case: + cmds["msdp_{}".format(remap)] = cmds.pop(lower_case[remap]) + + # print("msdp data in:", cmds) # DEBUG + self.protocol.data_in(**cmds)
+ +
[docs] def decode_gmcp(self, data): + """ + Decodes incoming GMCP data on the form 'varname <structure>'. + + Args: + data (str or list): GMCP data. + + Notes: + Clients send data on the form "Module.Submodule.Cmdname <structure>". + We assume the structure is valid JSON. + + 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): + data = b"".join(data) + + # print("decode_gmcp in:", data) # DEBUG + if data: + try: + cmdname, structure = data.split(None, 1) + except ValueError: + cmdname, structure = data, b"" + cmdname = cmdname.replace(b".", b"_") + try: + structure = json.loads(structure) + except ValueError: + # maybe the structure is not json-serialized at all + pass + args, kwargs = [], {} + if is_iter(structure): + if isinstance(structure, dict): + kwargs = {key: value for key, value in structure.items() if key} + else: + args = list(structure) + else: + args = (structure,) + if cmdname.lower().startswith(b"core_"): + # if Core.cmdname, then use cmdname + cmdname = cmdname[5:] + self.protocol.data_in(**{cmdname.lower().decode(): [args, kwargs]})
+ + # access methods + +
[docs] def data_out(self, cmdname, *args, **kwargs): + """ + Return a MSDP- or GMCP-valid subnegotiation across the protocol. + + Args: + cmdname (str): OOB-command name. + args, kwargs (any): Arguments to OOB command. + + """ + kwargs.pop("options", None) + + if self.MSDP: + encoded_oob = self.encode_msdp(cmdname, *args, **kwargs) + self.protocol._write(IAC + SB + MSDP + encoded_oob + IAC + SE) + + if self.GMCP: + encoded_oob = self.encode_gmcp(cmdname, *args, **kwargs) + self.protocol._write(IAC + SB + GMCP + encoded_oob + IAC + SE)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/telnet_ssl.html b/docs/latest/_modules/evennia/server/portal/telnet_ssl.html new file mode 100644 index 0000000000..9f11c5a6cb --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/telnet_ssl.html @@ -0,0 +1,250 @@ + + + + + + + + evennia.server.portal.telnet_ssl — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.telnet_ssl

+"""
+This allows for running the telnet communication over an encrypted SSL tunnel. To use it, requires a
+client supporting Telnet SSL.
+
+The protocol will try to automatically create the private key and certificate on the server side
+when starting and will warn if this was not possible. These will appear as files ssl.key and
+ssl.cert in mygame/server/.
+
+"""
+import os
+
+try:
+    from OpenSSL import crypto
+    from twisted.internet import ssl as twisted_ssl
+except ImportError as error:
+    errstr = """
+    {err}
+    Telnet-SSL requires the PyOpenSSL library and dependencies:
+
+        pip install pyopenssl pycrypto enum pyasn1 service_identity
+
+    Stop and start Evennia again. If no certificate can be generated, you'll
+    get a suggestion for a (linux) command to generate this locally.
+
+    """
+    raise ImportError(errstr.format(err=error))
+
+from django.conf import settings
+
+from evennia.server.portal.telnet import TelnetProtocol
+
+_GAME_DIR = settings.GAME_DIR
+
+_PRIVATE_KEY_LENGTH = 2048
+_PRIVATE_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssl.key")
+_PUBLIC_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssl-public.key")
+_CERTIFICATE_FILE = os.path.join(_GAME_DIR, "server", "ssl.cert")
+_CERTIFICATE_EXPIRE = 365 * 24 * 60 * 60 * 20  # 20 years
+_CERTIFICATE_ISSUER = settings.SSL_CERTIFICATE_ISSUER
+
+# messages
+
+NO_AUTOGEN = f"""
+Evennia could not auto-generate the SSL private- and public keys ({{err}}).
+If this error persists, create them manually (using the tools for your OS). The files
+should be placed and named like this:
+    {_PRIVATE_KEY_FILE}
+    {_PUBLIC_KEY_FILE}
+"""
+
+NO_AUTOCERT = """
+Evennia's could not auto-generate the SSL certificate ({{err}}).
+The private key already exists here:
+    {_PRIVATE_KEY_FILE}
+If this error persists, create the certificate manually (using the private key and
+the tools for your OS). The file should be placed and named like this:
+    {_CERTIFICATE_FILE}
+"""
+
+
+
[docs]class SSLProtocol(TelnetProtocol): + """ + Communication is the same as telnet, except data transfer + is done with encryption set up by the portal at start time. + + """ + +
[docs] def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.protocol_key = "telnet/ssl"
+ + +
[docs]def verify_or_create_SSL_key_and_cert(keyfile, certfile): + """ + Verify or create new key/certificate files. + + Args: + keyfile (str): Path to ssl.key file. + certfile (str): Parth to ssl.cert file. + + Notes: + If files don't already exist, they are created. + + """ + + if not (os.path.exists(keyfile) and os.path.exists(certfile)): + # key/cert does not exist. Create. + try: + # generate the keypair + keypair = crypto.PKey() + keypair.generate_key(crypto.TYPE_RSA, _PRIVATE_KEY_LENGTH) + + with open(_PRIVATE_KEY_FILE, "wt") as pfile: + pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair).decode("utf-8")) + print("Created SSL private key in '{}'.".format(_PRIVATE_KEY_FILE)) + + with open(_PUBLIC_KEY_FILE, "wt") as pfile: + pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair).decode("utf-8")) + print("Created SSL public key in '{}'.".format(_PUBLIC_KEY_FILE)) + + except Exception as err: + print(NO_AUTOGEN.format(err=err)) + return False + + else: + try: + # create certificate + cert = crypto.X509() + subj = cert.get_subject() + for key, value in _CERTIFICATE_ISSUER.items(): + setattr(subj, key, value) + cert.set_issuer(subj) + + cert.set_serial_number(1000) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(_CERTIFICATE_EXPIRE) + cert.set_pubkey(keypair) + cert.sign(keypair, "sha1") + + with open(_CERTIFICATE_FILE, "wt") as cfile: + cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8")) + print("Created SSL certificate in '{}'.".format(_CERTIFICATE_FILE)) + + except Exception as err: + print(NO_AUTOCERT.format(err=err)) + return False + + return True
+ + +
[docs]def getSSLContext(): + """ + This is called by the portal when creating the SSL context + server-side. + + Returns: + ssl_context (tuple): A key and certificate that is either + existing previously or created on the fly. + + """ + + if verify_or_create_SSL_key_and_cert(_PRIVATE_KEY_FILE, _CERTIFICATE_FILE): + return twisted_ssl.DefaultOpenSSLContextFactory(_PRIVATE_KEY_FILE, _CERTIFICATE_FILE) + else: + return None
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/tests.html b/docs/latest/_modules/evennia/server/portal/tests.html new file mode 100644 index 0000000000..81728f1e00 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/tests.html @@ -0,0 +1,441 @@ + + + + + + + + evennia.server.portal.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.tests

+try:
+    from django.utils.unittest import TestCase
+except ImportError:
+    from django.test import TestCase
+
+try:
+    from django.utils import unittest
+except ImportError:
+    import unittest
+
+import json
+import pickle
+import string
+import sys
+
+import mock
+from autobahn.twisted.websocket import WebSocketServerFactory
+from mock import MagicMock, Mock
+from twisted.conch.telnet import DO, DONT, IAC, NAWS, SB, SE, WILL
+from twisted.internet.base import DelayedCall
+from twisted.test import proto_helpers
+from twisted.trial.unittest import TestCase as TwistedTestCase
+
+import evennia
+from evennia.server.portal import irc
+from evennia.server.portal.portalsessionhandler import PortalSessionHandler
+from evennia.server.portal.service import EvenniaPortalService
+from evennia.utils.test_resources import BaseEvenniaTest
+
+from .amp import (
+    AMP_MAXLEN,
+    AMPMultiConnectionProtocol,
+    MsgPortal2Server,
+    MsgServer2Portal,
+)
+from .amp_server import AMPServerFactory
+from .mccp import MCCP
+from .mssp import MSSP
+from .mxp import MXP
+from .naws import DEFAULT_HEIGHT, DEFAULT_WIDTH
+from .suppress_ga import SUPPRESS_GA
+from .telnet import TelnetProtocol, TelnetServerFactory
+from .telnet_oob import MSDP, MSDP_VAL, MSDP_VAR
+from .ttype import IS, TTYPE
+from .webclient import WebSocketClient
+
+
+
[docs]class TestAMPServer(TwistedTestCase): + """ + Test AMP communication + """ + +
[docs] def setUp(self): + super().setUp() + portal = Mock() + factory = AMPServerFactory(portal) + self.proto = factory.buildProtocol(("localhost", 0)) + self.transport = MagicMock() # proto_helpers.StringTransport() + self.transport.client = ["localhost"] + self.transport.write = MagicMock()
+ +
[docs] def test_amp_out(self): + self.proto.makeConnection(self.transport) + + self.proto.data_to_server(MsgServer2Portal, 1, test=2) + + if pickle.HIGHEST_PROTOCOL == 5: + # Python 3.8+ + byte_out = ( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b" + b"packed_data\x00 x\xdak`\x9d*\xc8\x00\x01\xde\x8c\xb5SzXJR" + b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00VU\x07u\x00\x00" + ) + elif pickle.HIGHEST_PROTOCOL == 4: + # Python 3.7 + byte_out = ( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b" + b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR" + b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00" + ) + self.transport.write.assert_called_with(byte_out) + with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv: + self.proto.dataReceived(byte_out) + mocked_amprecv.assert_called_with(byte_out)
+ +
[docs] def test_amp_in(self): + self.proto.makeConnection(self.transport) + + self.proto.data_to_server(MsgPortal2Server, 1, test=2) + if pickle.HIGHEST_PROTOCOL == 5: + # Python 3.8+ + byte_out = ( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b" + b"packed_data\x00 x\xdak`\x9d*\xc8\x00\x01\xde\x8c\xb5SzXJR" + b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00VU\x07u\x00\x00" + ) + elif pickle.HIGHEST_PROTOCOL == 4: + # Python 3.7 + byte_out = ( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b" + b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR" + b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00" + ) + self.transport.write.assert_called_with(byte_out) + with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv: + self.proto.dataReceived(byte_out) + mocked_amprecv.assert_called_with(byte_out)
+ +
[docs] def test_large_msg(self): + """ + Send message larger than AMP_MAXLEN - should be split into several + """ + self.proto.makeConnection(self.transport) + outstr = "test" * AMP_MAXLEN + self.proto.data_to_server(MsgServer2Portal, 1, test=outstr) + + if pickle.HIGHEST_PROTOCOL == 5: + # Python 3.8+ + self.transport.write.assert_called_with( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data" + b"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#=5Z\x0b\xb8\x80\x13\xe85h\x80\x8e\xbam`Dc\xf4><\xf8g" + b"\x1a[\xf8\xda\x97\xa3_\xb1\x95\xdaz\xbe\xe7\x1a\xde\x03\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe0\x1f\x1eP\x1d\x02\r\x00\rpacked_data.2" + b"\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a\xa3" + b"\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03m\xe0\x06" + b"\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a" + b"\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03n\x1c" + b"\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00\x00\x0c\x03\xa0\xb4O\xb0\xf5gA" + b"\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xdf\x0fnI\x06,\x00\rpacked_data.5\x00\x18x\xdaK-.)I\xc5\x8e\xa7\xb22@\xc0" + b"\x94\xe2\xb6)z\x00Z\x1e\x0e\xb6\x00\x00" + ) + elif pickle.HIGHEST_PROTOCOL == 4: + # Python 3.7 + self.transport.write.assert_called_with( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data" + b"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#o\x8e\xd6\x02-\xe0\x04z\r\x1a\xa0\xa3m+$\xd2" + b"\x18\xbe\x0f\x0f\xfe\x1d\xdf\x14\xfe\x8e\xedjO\xac\xb9\xd4v\xf6o\x0f\xf3\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00X\xc3\x00P\x10\x02\x0c\x00\rpacked_data.2\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08" + b"\xc0\xa0\xb4&\xf0\xfdg\x10a\xa3\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU" + b"\xf5\xfb\x03m\xe0\x06\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08" + b"\xc0\xa0\xb4&\xf0\xfdg\x10a\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU" + b"\xf5\xfb\x03n\x1c\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00\x00\x0c" + b"\x03\xa0\xb4O\xb0\xf5gA\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xdf\x0fnI\x06,\x00\rpacked_data.5" + b"\x00\x18x\xdaK-.)I\xc5\x8e\xa7\xb22@\xc0\x94\xe2\xb6)z\x00Z\x1e\x0e\xb6\x00\x00" + )
+ + +
[docs]class TestIRC(TestCase): +
[docs] def test_plain_ansi(self): + """ + Test that printable characters do not get mangled. + """ + irc_ansi = irc.parse_ansi_to_irc(string.printable) + ansi_irc = irc.parse_irc_to_ansi(string.printable) + self.assertEqual(irc_ansi, string.printable) + self.assertEqual(ansi_irc, string.printable)
+ +
[docs] def test_bold(self): + s_irc = "\x02thisisatest" + s_eve = r"|hthisisatest" + self.assertEqual(irc.parse_ansi_to_irc(s_eve), s_irc) + self.assertEqual(s_eve, irc.parse_irc_to_ansi(s_irc))
+ +
[docs] def test_italic(self): + s_irc = "\x02thisisatest" + s_eve = r"|hthisisatest" + self.assertEqual(irc.parse_ansi_to_irc(s_eve), s_irc)
+ +
[docs] def test_colors(self): + color_map = ( + ("\0030", r"|w"), + ("\0031", r"|X"), + ("\0032", r"|B"), + ("\0033", r"|G"), + ("\0034", r"|r"), + ("\0035", r"|R"), + ("\0036", r"|M"), + ("\0037", r"|Y"), + ("\0038", r"|y"), + ("\0039", r"|g"), + ("\00310", r"|C"), + ("\00311", r"|c"), + ("\00312", r"|b"), + ("\00313", r"|m"), + ("\00314", r"|x"), + ("\00315", r"|W"), + ("\00399,5", r"|[r"), + ("\00399,3", r"|[g"), + ("\00399,7", r"|[y"), + ("\00399,2", r"|[b"), + ("\00399,6", r"|[m"), + ("\00399,10", r"|[c"), + ("\00399,15", r"|[w"), + ("\00399,1", r"|[x"), + ) + + for m in color_map: + self.assertEqual(irc.parse_irc_to_ansi(m[0]), m[1]) + self.assertEqual(m[0], irc.parse_ansi_to_irc(m[1]))
+ +
[docs] def test_identity(self): + """ + Test that the composition of the function and + its inverse gives the correct string. + """ + + s = r"|wthis|Xis|gis|Ma|C|complex|*string" + + self.assertEqual(irc.parse_irc_to_ansi(irc.parse_ansi_to_irc(s)), s)
+ + +
[docs]class TestTelnet(TwistedTestCase): +
[docs] def setUp(self): + super().setUp() + self.portal = EvenniaPortalService() + evennia.EVENNIA_PORTAL_SERVICE = self.portal + self.amp_server_factory = AMPServerFactory(self.portal) + self.amp_server = self.amp_server_factory.buildProtocol("127.0.0.1") + factory = TelnetServerFactory() + factory.protocol = TelnetProtocol + evennia.PORTAL_SESSION_HANDLER = PortalSessionHandler() + factory.sessionhandler = evennia.PORTAL_SESSION_HANDLER + factory.sessionhandler.portal = Mock() + self.proto = factory.buildProtocol(("localhost", 0)) + self.transport = proto_helpers.StringTransport() + self.addCleanup(factory.sessionhandler.disconnect_all)
+ +
[docs] @mock.patch("evennia.server.portal.portalsessionhandler.reactor", new=MagicMock()) + def test_mudlet_ttype(self): + self.transport.client = ["localhost"] + self.transport.setTcpKeepAlive = Mock() + d = self.proto.makeConnection(self.transport) + # test suppress_ga + self.assertTrue(self.proto.protocol_flags["NOGOAHEAD"]) + self.proto.dataReceived(IAC + DONT + SUPPRESS_GA) + self.assertFalse(self.proto.protocol_flags["NOGOAHEAD"]) + self.assertEqual(self.proto.handshakes, 7) + # test naws + self.assertEqual(self.proto.protocol_flags["SCREENWIDTH"], {0: DEFAULT_WIDTH}) + self.assertEqual(self.proto.protocol_flags["SCREENHEIGHT"], {0: DEFAULT_HEIGHT}) + self.proto.dataReceived(IAC + WILL + NAWS) + self.proto.dataReceived(b"".join([IAC, SB, NAWS, b"", b"x", b"", b"d", IAC, SE])) + self.assertEqual(self.proto.protocol_flags["SCREENWIDTH"][0], 78) + self.assertEqual(self.proto.protocol_flags["SCREENHEIGHT"][0], 45) + self.assertEqual(self.proto.handshakes, 6) + # test ttype + self.assertFalse(self.proto.protocol_flags["TTYPE"]) + self.assertTrue(self.proto.protocol_flags["ANSI"]) + self.proto.dataReceived(IAC + WILL + TTYPE) + self.proto.dataReceived(b"".join([IAC, SB, TTYPE, IS, b"MUDLET", IAC, SE])) + self.assertTrue(self.proto.protocol_flags["XTERM256"]) + self.assertEqual(self.proto.protocol_flags["CLIENTNAME"], "MUDLET") + self.assertTrue(self.proto.protocol_flags["FORCEDENDLINE"]) + self.assertTrue(self.proto.protocol_flags["NOGOAHEAD"]) + self.assertFalse(self.proto.protocol_flags["NOPROMPTGOAHEAD"]) + self.proto.dataReceived(b"".join([IAC, SB, TTYPE, IS, b"XTERM", IAC, SE])) + self.proto.dataReceived(b"".join([IAC, SB, TTYPE, IS, b"MTTS 137", IAC, SE])) + self.assertEqual(self.proto.handshakes, 5) + # test mccp + self.proto.dataReceived(IAC + DONT + MCCP) + self.assertFalse(self.proto.protocol_flags["MCCP"]) + self.assertEqual(self.proto.handshakes, 4) + # test mssp + self.proto.dataReceived(IAC + DONT + MSSP) + self.assertEqual(self.proto.handshakes, 3) + # test oob + self.proto.dataReceived(IAC + DO + MSDP) + self.proto.dataReceived( + b"".join([IAC, SB, MSDP, MSDP_VAR, b"LIST", MSDP_VAL, b"COMMANDS", IAC, SE]) + ) + self.assertTrue(self.proto.protocol_flags["OOB"]) + self.assertEqual(self.proto.handshakes, 2) + # test mxp + self.proto.dataReceived(IAC + DONT + MXP) + self.assertFalse(self.proto.protocol_flags["MXP"]) + self.assertEqual(self.proto.handshakes, 1) + # clean up to prevent Unclean reactor + self.proto.nop_keep_alive.stop() + self.proto._handshake_delay.cancel() + return d
+ + +
[docs]class TestWebSocket(BaseEvenniaTest): +
[docs] def setUp(self): + super().setUp() + self.portal = EvenniaPortalService() + evennia.EVENNIA_PORTAL_SERVICE = self.portal + self.amp_server_factory = AMPServerFactory(self.portal) + self.amp_server = self.amp_server_factory.buildProtocol("127.0.0.1") + self.proto = WebSocketClient() + self.proto.factory = WebSocketServerFactory() + evennia.PORTAL_SESSION_HANDLER = PortalSessionHandler() + self.proto.factory.sessionhandler = evennia.PORTAL_SESSION_HANDLER + self.proto.sessionhandler = evennia.PORTAL_SESSION_HANDLER + self.proto.sessionhandler.portal = Mock() + self.proto.transport = proto_helpers.StringTransport() + # self.proto.transport = proto_helpers.FakeDatagramTransport() + self.proto.transport.client = ["localhost"] + self.proto.transport.setTcpKeepAlive = Mock() + self.proto.state = MagicMock() + self.addCleanup(self.proto.factory.sessionhandler.disconnect_all) + DelayedCall.debug = True
+ +
[docs] def tearDown(self): + super().tearDown()
+ +
[docs] @mock.patch("evennia.server.portal.portalsessionhandler.reactor", new=MagicMock()) + def test_data_in(self): + self.proto.sessionhandler.data_in = MagicMock() + self.proto.onOpen() + msg = json.dumps(["logged_in", (), {}]).encode() + self.proto.onMessage(msg, isBinary=False) + self.proto.sessionhandler.data_in.assert_called_with(self.proto, logged_in=[[], {}]) + sendStr = "You can get anything you want at Alice's Restaurant." + msg = json.dumps(["text", (sendStr,), {}]).encode() + self.proto.onMessage(msg, isBinary=False) + self.proto.sessionhandler.data_in.assert_called_with(self.proto, text=[[sendStr], {}])
+ +
[docs] @mock.patch("evennia.server.portal.portalsessionhandler.reactor", new=MagicMock()) + def test_data_out(self): + self.proto.onOpen() + self.proto.sendLine = MagicMock() + msg = json.dumps(["logged_in", (), {}]) + self.proto.sessionhandler.data_out(self.proto, text=[["Excepting Alice"], {}]) + self.proto.sendLine.assert_called_with(json.dumps(["text", ["Excepting Alice"], {}]))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/ttype.html b/docs/latest/_modules/evennia/server/portal/ttype.html new file mode 100644 index 0000000000..cd13709ec6 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/ttype.html @@ -0,0 +1,296 @@ + + + + + + + + evennia.server.portal.ttype — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.ttype

+"""
+TTYPE (MTTS) - Mud Terminal Type Standard
+
+This module implements the TTYPE telnet protocol as per
+http://tintin.sourceforge.net/mtts/. It allows the server to ask the
+client about its capabilities. If the client also supports TTYPE, it
+will return with information such as its name, if it supports colour
+etc. If the client does not support TTYPE, this will be ignored.
+
+All data will be stored on the protocol's protocol_flags dictionary,
+under the 'TTYPE' key.
+
+"""
+
+# telnet option codes
+TTYPE = bytes([24])  # b"\x18"
+IS = bytes([0])  # b"\x00"
+SEND = bytes([1])  # b"\x01"
+
+# terminal capabilities and their codes
+MTTS = [
+    (2048, "SSL"),
+    (1024, "MSLP"),
+    (512, "MNES"),
+    (256, "TRUECOLOR"),
+    (128, "PROXY"),
+    (64, "SCREENREADER"),
+    (32, "OSC_COLOR_PALETTE"),
+    (16, "MOUSE_TRACKING"),
+    (8, "XTERM256"),
+    (4, "UTF-8"),
+    (2, "VT100"),
+    (1, "ANSI"),
+]
+
+
+
[docs]class Ttype: + """ + Handles ttype negotiations. Called and initiated by the + telnet protocol. + + """ + +
[docs] def __init__(self, protocol): + """ + Initialize ttype by storing protocol on ourselves and calling + the client to see if it supporst ttype. + + Args: + protocol (Protocol): The protocol instance. + + Notes: + The `self.ttype_step` indicates how far in the data + retrieval we've gotten. + + """ + self.ttype_step = 0 + self.protocol = protocol + # we set FORCEDENDLINE for clients not supporting ttype + self.protocol.protocol_flags["FORCEDENDLINE"] = True + self.protocol.protocol_flags["TTYPE"] = False + # is it a safe bet to assume ANSI is always supported? + self.protocol.protocol_flags["ANSI"] = True + # setup protocol to handle ttype initialization and negotiation + self.protocol.negotiationMap[TTYPE] = self.will_ttype + # ask if client will ttype, connect callback if it does. + self.protocol.do(TTYPE).addCallbacks(self.will_ttype, self.wont_ttype)
+ +
[docs] def wont_ttype(self, option): + """ + Callback if ttype is not supported by client. + + Args: + option (Option): Not used. + + """ + self.protocol.protocol_flags["TTYPE"] = False + self.protocol.handshake_done()
+ +
[docs] def will_ttype(self, option): + """ + Handles negotiation of the ttype protocol once the client has + confirmed that it will respond with the ttype protocol. + + Args: + option (Option): Not used. + + Notes: + The negotiation proceeds in several steps, each returning a + certain piece of information about the client. All data is + stored on protocol.protocol_flags under the TTYPE key. + + """ + options = self.protocol.protocol_flags + + if options and options.get("TTYPE", False) or self.ttype_step > 3: + return + + try: + option = b"".join(option).lstrip(IS).decode() + except TypeError: + # option is not on a suitable form for joining + pass + + if self.ttype_step == 0: + # just start the request chain + self.protocol.requestNegotiation(TTYPE, SEND) + + elif self.ttype_step == 1: + # this is supposed to be the name of the client/terminal. + # For clients not supporting the extended TTYPE + # definition, subsequent calls will just repeat-return this. + try: + clientname = option.upper() + except AttributeError: + # malformed option (not a string) + clientname = "UNKNOWN" + + # use name to identify support for xterm256. Many of these + # only support after a certain version, but all support + # it since at least 4 years. We assume recent client here for now. + xterm256 = False + if clientname.startswith("MUDLET"): + # supports xterm256 stably since 1.1 (2010?) + xterm256 = clientname.split("MUDLET", 1)[1].strip() >= "1.1" + # Mudlet likes GA's on a prompt line for the prompt trigger to + # match, if it's not wanting NOGOAHEAD. + if not self.protocol.protocol_flags["NOGOAHEAD"]: + self.protocol.protocol_flags["NOGOAHEAD"] = True + self.protocol.protocol_flags["NOPROMPTGOAHEAD"] = False + + if ( + clientname.startswith("XTERM") + or clientname.endswith("-256COLOR") + or clientname + in ( + "ATLANTIS", # > 0.9.9.0 (aug 2009) + "CMUD", # > 3.04 (mar 2009) + "KILDCLIENT", # > 2.2.0 (sep 2005) + "MUDLET", # > beta 15 (sep 2009) + "MUSHCLIENT", # > 4.02 (apr 2007) + "PUTTY", # > 0.58 (apr 2005) + "BEIP", # > 2.00.206 (late 2009) (BeipMu) + "POTATO", # > 2.00 (maybe earlier) + "TINYFUGUE", # > 4.x (maybe earlier) + ) + ): + xterm256 = True + + # all clients supporting TTYPE at all seem to support ANSI + self.protocol.protocol_flags["ANSI"] = True + self.protocol.protocol_flags["XTERM256"] = xterm256 + self.protocol.protocol_flags["CLIENTNAME"] = clientname + self.protocol.requestNegotiation(TTYPE, SEND) + + elif self.ttype_step == 2: + # this is a term capabilities flag + term = option + tupper = term.upper() + # identify xterm256 based on flag + xterm256 = ( + tupper.endswith("-256COLOR") + or tupper.endswith("XTERM") # Apple Terminal, old Tintin + and not tupper.endswith("-COLOR") # old Tintin, Putty + ) + if xterm256: + self.protocol.protocol_flags["ANSI"] = True + self.protocol.protocol_flags["XTERM256"] = xterm256 + self.protocol.protocol_flags["TERM"] = term + # request next information + self.protocol.requestNegotiation(TTYPE, SEND) + + elif self.ttype_step == 3: + # the MTTS bitstring identifying term capabilities + if option.startswith("MTTS"): + option = option[4:].strip() + if option.isdigit(): + # a number - determine the actual capabilities + option = int(option) + support = dict( + (capability, True) for bitval, capability in MTTS if option & bitval > 0 + ) + self.protocol.protocol_flags.update(support) + else: + # some clients send erroneous MTTS as a string. Add directly. + self.protocol.protocol_flags[option.upper()] = True + + self.protocol.protocol_flags["TTYPE"] = True + # we must sync ttype once it'd done + self.protocol.handshake_done() + self.ttype_step += 1
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/webclient.html b/docs/latest/_modules/evennia/server/portal/webclient.html new file mode 100644 index 0000000000..16b4f3421d --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/webclient.html @@ -0,0 +1,426 @@ + + + + + + + + evennia.server.portal.webclient — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.webclient

+"""
+Webclient based on websockets.
+
+This implements a webclient with WebSockets (http://en.wikipedia.org/wiki/WebSocket)
+by use of the autobahn-python package's implementation (https://github.com/crossbario/autobahn-python).
+It is used together with evennia/web/media/javascript/evennia_websocket_webclient.js.
+
+All data coming into the webclient is in the form of valid JSON on the form
+
+`["inputfunc_name", [args], {kwarg}]`
+
+which represents an "inputfunc" to be called on the Evennia side with *args, **kwargs.
+The most common inputfunc is "text", which takes just the text input
+from the command line and interprets it as an Evennia Command: `["text", ["look"], {}]`
+
+"""
+import html
+import json
+import re
+
+from autobahn.exception import Disconnected
+from autobahn.twisted.websocket import WebSocketServerProtocol
+from django.conf import settings
+
+from evennia.utils.ansi import parse_ansi
+from evennia.utils.text2html import parse_html
+from evennia.utils.utils import class_from_module, mod_import
+
+_RE_SCREENREADER_REGEX = re.compile(
+    r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE
+)
+_CLIENT_SESSIONS = mod_import(settings.SESSION_ENGINE).SessionStore
+_UPSTREAM_IPS = settings.UPSTREAM_IPS
+
+# Status Code 1000: Normal Closure
+#   called when the connection was closed through JavaScript
+CLOSE_NORMAL = WebSocketServerProtocol.CLOSE_STATUS_CODE_NORMAL
+
+# Status Code 1001: Going Away
+#   called when the browser is navigating away from the page
+GOING_AWAY = WebSocketServerProtocol.CLOSE_STATUS_CODE_GOING_AWAY
+
+_BASE_SESSION_CLASS = class_from_module(settings.BASE_SESSION_CLASS)
+
+
+
[docs]class WebSocketClient(WebSocketServerProtocol, _BASE_SESSION_CLASS): + """ + Implements the server-side of the Websocket connection. + + """ + + # nonce value, used to prevent the webclient from erasing the + # webclient_authenticated_uid value of csession on disconnect + nonce = 0 + +
[docs] def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.protocol_key = "webclient/websocket" + self.browserstr = ""
+ +
[docs] def get_client_session(self): + """ + Get the Client browser session (used for auto-login based on browser session) + + Returns: + csession (ClientSession): This is a django-specific internal representation + of the browser session. + + """ + try: + # client will connect with wsurl?csessid&browserid + webarg = self.http_request_uri.split("?", 1)[1] + except IndexError: + # this may happen for custom webclients not caring for the + # browser session. + self.csessid = None + return None + except AttributeError: + from evennia.utils import logger + + self.csessid = None + logger.log_trace(str(self)) + return None + + self.csessid, *browserstr = webarg.split("&", 1) + if browserstr: + self.browserstr = str(browserstr[0]) + + if self.csessid: + return _CLIENT_SESSIONS(session_key=self.csessid)
+ +
[docs] def onOpen(self): + """ + This is called when the WebSocket connection is fully established. + + """ + client_address = self.transport.client + client_address = client_address[0] if client_address else None + + if client_address in _UPSTREAM_IPS and "x-forwarded-for" in self.http_headers: + addresses = [x.strip() for x in self.http_headers["x-forwarded-for"].split(",")] + addresses.reverse() + + for addr in addresses: + if addr not in _UPSTREAM_IPS: + client_address = addr + break + + self.init_session("websocket", client_address, self.factory.sessionhandler) + + csession = self.get_client_session() # this sets self.csessid + csessid = self.csessid + uid = csession and csession.get("webclient_authenticated_uid", None) + nonce = csession and csession.get("webclient_authenticated_nonce", 0) + if uid: + # the client session is already logged in. + self.uid = uid + self.nonce = nonce + self.logged_in = True + + for old_session in self.sessionhandler.sessions_from_csessid(csessid): + if ( + hasattr(old_session, "websocket_close_code") + and old_session.websocket_close_code != CLOSE_NORMAL + ): + # if we have old sessions with the same csession, they are remnants + self.sessid = old_session.sessid + self.sessionhandler.disconnect(old_session) + + browserstr = f":{self.browserstr}" if self.browserstr else "" + self.protocol_flags["CLIENTNAME"] = f"Evennia Webclient (websocket{browserstr})" + self.protocol_flags["UTF-8"] = True + self.protocol_flags["OOB"] = True + self.protocol_flags["TRUECOLOR"] = True + self.protocol_flags["XTERM256"] = True + self.protocol_flags["ANSI"] = True + + # watch for dead links + self.transport.setTcpKeepAlive(1) + # actually do the connection + self.sessionhandler.connect(self)
+ +
[docs] def disconnect(self, reason=None): + """ + Generic hook for the engine to call in order to + disconnect this protocol. + + Args: + reason (str or None): Motivation for the disconnection. + + """ + csession = self.get_client_session() + + if csession: + # if the nonce is different, webclient_authenticated_uid has been + # set *before* this disconnect (disconnect called after a new client + # connects, which occurs in some 'fast' browsers like Google Chrome + # and Mobile Safari) + if csession.get("webclient_authenticated_nonce", 0) == self.nonce: + csession["webclient_authenticated_uid"] = None + csession["webclient_authenticated_nonce"] = 0 + csession.save() + self.logged_in = False + + self.sessionhandler.disconnect(self) + # autobahn-python: + # 1000 for a normal close, 1001 if the browser window is closed, + # 3000-4999 for app. specific, + # in case anyone wants to expose this functionality later. + # + # sendClose() under autobahn/websocket/interfaces.py + self.sendClose(CLOSE_NORMAL, reason)
+ +
[docs] def onClose(self, wasClean, code=None, reason=None): + """ + This is executed when the connection is lost for whatever + reason. it can also be called directly, from the disconnect + method. + + Args: + wasClean (bool): ``True`` if the WebSocket was closed cleanly. + code (int or None): Close status as sent by the WebSocket peer. + reason (str or None): Close reason as sent by the WebSocket peer. + + """ + if code == CLOSE_NORMAL or code == GOING_AWAY: + self.disconnect(reason) + else: + self.websocket_close_code = code
+ +
[docs] def onMessage(self, payload, isBinary): + """ + Callback fired when a complete WebSocket message was received. + + Args: + payload (bytes): The WebSocket message received. + isBinary (bool): Flag indicating whether payload is binary or + UTF-8 encoded text. + + """ + cmdarray = json.loads(str(payload, "utf-8")) + if cmdarray: + self.data_in(**{cmdarray[0]: [cmdarray[1], cmdarray[2]]})
+ +
[docs] def sendLine(self, line): + """ + Send data to client. + + Args: + line (str): Text to send. + + """ + try: + return self.sendMessage(line.encode()) + except Disconnected: + # this can happen on an unclean close of certain browsers. + # it means this link is actually already closed. + self.disconnect(reason="Browser already closed.")
+ +
[docs] def at_login(self): + csession = self.get_client_session() + if csession: + csession["webclient_authenticated_uid"] = self.uid + csession.save()
+ +
[docs] def data_in(self, **kwargs): + """ + Data User > Evennia. + + Args: + text (str): Incoming text. + kwargs (any): Options from protocol. + + Notes: + At initilization, the client will send the special + 'csessid' command to identify its browser session hash + with the Evennia side. + + The websocket client will also pass 'websocket_close' command + to report that the client has been closed and that the + session should be disconnected. + + Both those commands are parsed and extracted already at + this point. + + """ + if "websocket_close" in kwargs: + self.disconnect() + return + + self.sessionhandler.data_in(self, **kwargs)
+ +
[docs] def send_text(self, *args, **kwargs): + """ + Send text data. This will pre-process the text for + color-replacement, conversion to html etc. + + Args: + text (str): Text to send. + + Keyword Args: + options (dict): Options-dict with the following keys understood: + - raw (bool): No parsing at all (leave ansi-to-html markers unparsed). + - nocolor (bool): Clean out all color. + - screenreader (bool): Use Screenreader mode. + - send_prompt (bool): Send a prompt with parsed html + + """ + if args: + args = list(args) + text = args[0] + if text is None: + return + else: + return + + flags = self.protocol_flags + + options = kwargs.pop("options", {}) + raw = options.get("raw", flags.get("RAW", False)) + client_raw = options.get("client_raw", False) + nocolor = options.get("nocolor", flags.get("NOCOLOR", False)) + screenreader = options.get("screenreader", flags.get("SCREENREADER", False)) + prompt = options.get("send_prompt", False) + + if screenreader: + # screenreader mode cleans up output + text = parse_ansi(text, strip_ansi=True, xterm256=False, mxp=False) + text = _RE_SCREENREADER_REGEX.sub("", text) + cmd = "prompt" if prompt else "text" + if raw: + if client_raw: + args[0] = text + else: + args[0] = html.escape(text) # escape html! + else: + args[0] = parse_html(text, strip_ansi=nocolor) + + # send to client on required form [cmdname, args, kwargs] + self.sendLine(json.dumps([cmd, args, kwargs]))
+ +
[docs] def send_prompt(self, *args, **kwargs): + kwargs["options"].update({"send_prompt": True}) + self.send_text(*args, **kwargs)
+ +
[docs] def send_default(self, cmdname, *args, **kwargs): + """ + Data Evennia -> User. + + Args: + cmdname (str): The first argument will always be the oob cmd name. + *args (any): Remaining args will be arguments for `cmd`. + + Keyword Args: + options (dict): These are ignored for oob commands. Use command + arguments (which can hold dicts) to send instructions to the + client instead. + + """ + if not cmdname == "options": + self.sendLine(json.dumps([cmdname, args, kwargs]))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/portal/webclient_ajax.html b/docs/latest/_modules/evennia/server/portal/webclient_ajax.html new file mode 100644 index 0000000000..7f88a5e730 --- /dev/null +++ b/docs/latest/_modules/evennia/server/portal/webclient_ajax.html @@ -0,0 +1,589 @@ + + + + + + + + evennia.server.portal.webclient_ajax — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.portal.webclient_ajax

+"""
+AJAX/COMET fallback webclient
+
+The AJAX/COMET web client consists of two components running on
+twisted and django. They are both a part of the Evennia website url
+tree (so the testing website might be located on
+http://localhost:4001/, whereas the webclient can be found on
+http://localhost:4001/webclient.)
+
+/webclient - this url is handled through django's template
+             system and serves the html page for the client
+             itself along with its javascript chat program.
+/webclientdata - this url is called by the ajax chat using
+                 POST requests (long-polling when necessary)
+                 The WebClient resource in this module will
+                 handle these requests and act as a gateway
+                 to sessions connected over the webclient.
+
+"""
+import html
+import json
+import re
+import time
+
+from django.conf import settings
+from django.utils.functional import Promise
+from evennia.server import session
+from evennia.utils import utils
+from evennia.utils.ansi import parse_ansi
+from evennia.utils.text2html import parse_html
+from evennia.utils.utils import class_from_module, ip_from_request, to_bytes
+from twisted.internet.task import LoopingCall
+from twisted.web import resource, server
+
+_CLIENT_SESSIONS = utils.mod_import(settings.SESSION_ENGINE).SessionStore
+_RE_SCREENREADER_REGEX = re.compile(
+    r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE
+)
+_SERVERNAME = settings.SERVERNAME
+_KEEPALIVE = 30  # how often to check keepalive
+
+
+# defining a simple json encoder for returning
+# django data to the client. Might need to
+# extend this if one wants to send more
+# complex database objects too.
+
+
+
[docs]class LazyEncoder(json.JSONEncoder): +
[docs] def default(self, obj): + if isinstance(obj, Promise): + return str(obj) + return super().default(obj)
+ + +
[docs]def jsonify(obj): + return to_bytes(json.dumps(obj, ensure_ascii=False, cls=LazyEncoder))
+ + +# +# A session type handling communication over the +# web client interface. +# + + +
[docs]class AjaxWebClientSession(session.Session): + """ + This represents a session running in an AjaxWebclient. + """ + +
[docs] def __init__(self, *args, **kwargs): + self.protocol_key = "webclient/ajax" + super().__init__(*args, **kwargs)
+ +
[docs] def get_client_session(self): + """ + Get the Client browser session (used for auto-login based on browser session) + + Returns: + csession (ClientSession): This is a django-specific internal representation + of the browser session. + + """ + if self.csessid: + return _CLIENT_SESSIONS(session_key=self.csessid)
+ +
[docs] def disconnect(self, reason="Server disconnected."): + """ + Disconnect from server. + + Args: + reason (str): Motivation for the disconnect. + """ + csession = self.get_client_session() + + if csession: + csession["webclient_authenticated_uid"] = None + csession.save() + self.logged_in = False + self.client.lineSend(self.csessid, ["connection_close", [reason], {}]) + self.client.client_disconnect(self.csessid) + self.sessionhandler.disconnect(self)
+ +
[docs] def at_login(self): + csession = self.get_client_session() + if csession: + csession["webclient_authenticated_uid"] = self.uid + csession.save()
+ +
[docs] def data_in(self, **kwargs): + """ + Data User -> Evennia + + Keyword Args: + kwargs (any): Incoming data. + + """ + self.sessionhandler.data_in(self, **kwargs)
+ +
[docs] def data_out(self, **kwargs): + """ + Data Evennia -> User + + Keyword Args: + kwargs (any): Options to the protocol + """ + self.sessionhandler.data_out(self, **kwargs)
+ +
[docs] def send_text(self, *args, **kwargs): + """ + Send text data. This will pre-process the text for + color-replacement, conversion to html etc. + + Args: + text (str): Text to send. + + Keyword Args: + options (dict): Options-dict with the following keys understood: + - raw (bool): No parsing at all (leave ansi-to-html markers unparsed). + - nocolor (bool): Remove all color. + - screenreader (bool): Use Screenreader mode. + - send_prompt (bool): Send a prompt with parsed html + + """ + if args: + args = list(args) + text = args[0] + if text is None: + return + else: + return + + flags = self.protocol_flags + text = utils.to_str(text) + + options = kwargs.pop("options", {}) + raw = options.get("raw", flags.get("RAW", False)) + xterm256 = options.get("xterm256", flags.get("XTERM256", True)) + useansi = options.get("ansi", flags.get("ANSI", True)) + nocolor = options.get("nocolor", flags.get("NOCOLOR") or not (xterm256 or useansi)) + screenreader = options.get("screenreader", flags.get("SCREENREADER", False)) + prompt = options.get("send_prompt", False) + + if screenreader: + # screenreader mode cleans up output + text = parse_ansi(text, strip_ansi=True, xterm256=False, mxp=False) + text = _RE_SCREENREADER_REGEX.sub("", text) + cmd = "prompt" if prompt else "text" + if raw: + args[0] = text + else: + args[0] = parse_html(text, strip_ansi=nocolor) + + # send to client on required form [cmdname, args, kwargs] + self.client.lineSend(self.csessid, [cmd, args, kwargs])
+ +
[docs] def send_prompt(self, *args, **kwargs): + kwargs["options"].update({"send_prompt": True}) + self.send_text(*args, **kwargs)
+ +
[docs] def send_default(self, cmdname, *args, **kwargs): + """ + Data Evennia -> User. + + Args: + cmdname (str): The first argument will always be the oob cmd name. + *args (any): Remaining args will be arguments for `cmd`. + + Keyword Args: + options (dict): These are ignored for oob commands. Use command + arguments (which can hold dicts) to send instructions to the + client instead. + + """ + if not cmdname == "options": + self.client.lineSend(self.csessid, [cmdname, args, kwargs])
+ + +# +# AjaxWebClient resource - this is called by the ajax client +# using POST requests to /webclientdata. +# + + +
[docs]class AjaxWebClient(resource.Resource): + """ + An ajax/comet long-polling transport + + """ + + client_protocol = class_from_module(settings.AJAX_PROTOCOL_CLASS) + + isLeaf = True + allowedMethods = ("POST",) + +
[docs] def __init__(self): + self.requests = {} + self.databuffer = {} + + self.last_alive = {} + self.keep_alive = None
+ + def _responseFailed(self, failure, csessid, request): + "callback if a request is lost/timed out" + try: + del self.requests[csessid] + except KeyError: + # nothing left to delete + pass + + def _keepalive(self): + """ + Callback for checking the connection is still alive. + """ + now = time.time() + to_remove = [] + keep_alives = ( + (csessid, remove) + for csessid, (t, remove) in self.last_alive.items() + if now - t > _KEEPALIVE + ) + for csessid, remove in keep_alives: + if remove: + # keepalive timeout. Line is dead. + to_remove.append(csessid) + else: + # normal timeout - send keepalive + self.last_alive[csessid] = (now, True) + self.lineSend(csessid, ["ajax_keepalive", [], {}]) + # remove timed-out sessions + for csessid in to_remove: + sessions = self.sessionhandler.sessions_from_csessid(csessid) + for sess in sessions: + sess.disconnect() + self.last_alive.pop(csessid, None) + if not self.last_alive: + # no more ajax clients. Stop the keepalive + self.keep_alive.stop() + self.keep_alive = None + +
[docs] def get_client_sessid(self, request): + """ + Helper to get the client session id out of the request. + + Args: + request (Request): Incoming request object. + Returns: + csessid (int): The client-session id. + + """ + return html.escape(request.args[b"csessid"][0].decode("utf-8"))
+ +
[docs] def get_browserstr(self, request): + """ + Get browser-string out of the request. + + Args: + request (Request): Incoming request object. + Returns: + str: The browser name. + + + """ + return html.escape(request.args[b"browserstr"][0].decode("utf-8"))
+ +
[docs] def at_login(self): + """ + Called when this session gets authenticated by the server. + """ + pass
+ +
[docs] def lineSend(self, csessid, data): + """ + This adds the data to the buffer and/or sends it to the client + as soon as possible. + + Args: + csessid (int): Session id. + data (list): A send structure [cmdname, [args], {kwargs}]. + + """ + request = self.requests.get(csessid) + if request: + # we have a request waiting. Return immediately. + request.write(jsonify(data)) + request.finish() + del self.requests[csessid] + else: + # no waiting request. Store data in buffer + dataentries = self.databuffer.get(csessid, []) + dataentries.append(jsonify(data)) + self.databuffer[csessid] = dataentries
+ +
[docs] def client_disconnect(self, csessid): + """ + Disconnect session with given csessid. + + Args: + csessid (int): Session id. + + """ + if csessid in self.requests: + self.requests[csessid].finish() + del self.requests[csessid] + if csessid in self.databuffer: + del self.databuffer[csessid]
+ +
[docs] def mode_init(self, request): + """ + This is called by render_POST when the client requests an init + mode operation (at startup) + + Args: + request (Request): Incoming request. + + """ + csessid = self.get_client_sessid(request) + browserstr = self.get_browserstr(request) + + remote_addr = ip_from_request(request) + + host_string = "%s (%s:%s)" % ( + _SERVERNAME, + request.getRequestHostname(), + request.getHost().port, + ) + + sess = self.client_protocol() + sess.client = self + sess.init_session("ajax/comet", remote_addr, self.sessionhandler) + + sess.csessid = csessid + sess.browserstr = browserstr + csession = _CLIENT_SESSIONS(session_key=sess.csessid) + uid = csession and csession.get("webclient_authenticated_uid", False) + if uid: + # the client session is already logged in + sess.uid = uid + sess.logged_in = True + + # watch for dead links + self.last_alive[csessid] = (time.time(), False) + if not self.keep_alive: + # the keepalive is not running; start it. + self.keep_alive = LoopingCall(self._keepalive) + self.keep_alive.start(_KEEPALIVE, now=False) + + browserstr = f":{browserstr}" if browserstr else "" + sess.protocol_flags["CLIENTNAME"] = f"Evennia Webclient (ajax{browserstr})" + sess.protocol_flags["UTF-8"] = True + sess.protocol_flags["OOB"] = True + + # actually do the connection + sess.sessionhandler.connect(sess) + + return jsonify({"msg": host_string, "csessid": csessid})
+ +
[docs] def mode_keepalive(self, request): + """ + This is called by render_POST when the + client is replying to the keepalive. + + Args: + request (Request): Incoming request. + + """ + csessid = self.get_client_sessid(request) + self.last_alive[csessid] = (time.time(), False) + return b'""'
+ +
[docs] def mode_input(self, request): + """ + This is called by render_POST when the client + is sending data to the server. + + Args: + request (Request): Incoming request. + + """ + csessid = self.get_client_sessid(request) + self.last_alive[csessid] = (time.time(), False) + cmdarray = json.loads(request.args.get(b"data")[0]) + for sess in self.sessionhandler.sessions_from_csessid(csessid): + sess.data_in(**{cmdarray[0]: [cmdarray[1], cmdarray[2]]}) + return b'""'
+ +
[docs] def mode_receive(self, request): + """ + This is called by render_POST when the client is telling us + that it is ready to receive data as soon as it is available. + This is the basis of a long-polling (comet) mechanism: the + server will wait to reply until data is available. + + Args: + request (Request): Incoming request. + + """ + csessid = html.escape(request.args[b"csessid"][0].decode("utf-8")) + self.last_alive[csessid] = (time.time(), False) + + dataentries = self.databuffer.get(csessid) + if dataentries: + # we have data that could not be sent earlier (because client was not + # ready to receive it). Return this buffered data immediately + return dataentries.pop(0) + else: + # we have no data to send. End the old request and start + # a new long-polling one + request.notifyFinish().addErrback(self._responseFailed, csessid, request) + if csessid in self.requests: + self.requests[csessid].finish() # Clear any stale request. + self.requests[csessid] = request + return server.NOT_DONE_YET
+ +
[docs] def mode_close(self, request): + """ + This is called by render_POST when the client is signalling + that it is about to be closed. + + Args: + request (Request): Incoming request. + + """ + csessid = self.get_client_sessid(request) + try: + sess = self.sessionhandler.sessions_from_csessid(csessid)[0] + sess.sessionhandler.disconnect(sess) + except IndexError: + self.client_disconnect(csessid) + return b'""'
+ +
[docs] def render_POST(self, request): + """ + This function is what Twisted calls with POST requests coming + in from the ajax client. The requests should be tagged with + different modes depending on what needs to be done, such as + initializing or sending/receving data through the request. It + uses a long-polling mechanism to avoid sending data unless + there is actual data available. + + Args: + request (Request): Incoming request. + + """ + dmode = request.args.get(b"mode", [b"None"])[0].decode("utf-8") + + if dmode == "init": + # startup. Setup the server. + return self.mode_init(request) + elif dmode == "input": + # input from the client to the server + return self.mode_input(request) + elif dmode == "receive": + # the client is waiting to receive data. + return self.mode_receive(request) + elif dmode == "close": + # the client is closing + return self.mode_close(request) + elif dmode == "keepalive": + # A reply to our keepalive request - all is well + return self.mode_keepalive(request) + else: + # This should not happen if client sends valid data. + return b'""'
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/profiling/dummyrunner.html b/docs/latest/_modules/evennia/server/profiling/dummyrunner.html new file mode 100644 index 0000000000..4988f7c3a7 --- /dev/null +++ b/docs/latest/_modules/evennia/server/profiling/dummyrunner.html @@ -0,0 +1,743 @@ + + + + + + + + evennia.server.profiling.dummyrunner — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.profiling.dummyrunner

+"""
+Dummy client runner
+
+This module implements a stand-alone launcher for stress-testing
+an Evennia game. It will launch any number of fake clients. These
+clients will log into the server and start doing random operations.
+Customizing and weighing these operations differently depends on
+which type of game is tested. The module contains a testing module
+for plain Evennia.
+
+Please note that you shouldn't run this on a production server!
+Launch the program without any arguments or options to see a
+full step-by-step setup help.
+
+Basically (for testing default Evennia):
+
+ - Use an empty/testing database.
+ - set PERMISSION_ACCOUNT_DEFAULT = "Builder"
+ - start server, eventually with profiling active
+ - launch this client runner
+
+If you want to customize the runner's client actions
+(because you changed the cmdset or needs to better
+match your use cases or add more actions), you can
+change which actions by adding a path to
+
+   DUMMYRUNNER_ACTIONS_MODULE = <path.to.your.module>
+
+in your settings. See utils.dummyrunner_actions.py
+for instructions on how to define this module.
+
+"""
+
+
+import random
+import sys
+import time
+from argparse import ArgumentParser
+
+import django
+from twisted.conch import telnet
+from twisted.internet import protocol, reactor
+from twisted.internet.task import LoopingCall
+
+django.setup()
+import evennia  # noqa
+
+evennia._init()
+
+from django.conf import settings  # noqa
+
+from evennia.commands.cmdset import CmdSet  # noqa
+from evennia.commands.command import Command  # noqa
+from evennia.utils import mod_import, time_format  # noqa
+from evennia.utils.ansi import strip_ansi  # noqa
+
+# Load the dummyrunner settings module
+
+DUMMYRUNNER_SETTINGS = mod_import(settings.DUMMYRUNNER_SETTINGS_MODULE)
+if not DUMMYRUNNER_SETTINGS:
+    raise IOError(
+        "Error: Dummyrunner could not find settings file at %s"
+        % settings.DUMMYRUNNER_SETTINGS_MODULE
+    )
+IDMAPPER_CACHE_MAXSIZE = settings.IDMAPPER_CACHE_MAXSIZE
+
+DATESTRING = "%Y%m%d%H%M%S"
+CLIENTS = []
+
+# Settings
+
+# number of clients to launch if no input is given on command line
+NCLIENTS = 1
+# time between each 'tick', in seconds, if not set on command
+# line. All launched clients will be called upon to possibly do an
+# action with this frequency.
+TIMESTEP = DUMMYRUNNER_SETTINGS.TIMESTEP
+# chance of a client performing an action, per timestep. This helps to
+# spread out usage randomly, like it would be in reality.
+CHANCE_OF_ACTION = DUMMYRUNNER_SETTINGS.CHANCE_OF_ACTION
+# spread out the login action separately, having many accounts create accounts
+# and connect simultaneously is generally unlikely.
+CHANCE_OF_LOGIN = DUMMYRUNNER_SETTINGS.CHANCE_OF_LOGIN
+# Port to use, if not specified on command line
+TELNET_PORT = DUMMYRUNNER_SETTINGS.TELNET_PORT or settings.TELNET_PORTS[0]
+#
+NCONNECTED = 0  # client has received a connection
+NLOGIN_SCREEN = 0  # client has seen the login screen (server responded)
+NLOGGING_IN = 0  # client starting login procedure
+NLOGGED_IN = 0  # client has authenticated and logged in
+
+# time when all clients have logged_in
+TIME_ALL_LOGIN = 0
+# actions since all logged in
+TOTAL_ACTIONS = 0
+TOTAL_LAG_MEASURES = 0
+# lag per 30s for all logged in
+TOTAL_LAG = 0
+TOTAL_LAG_IN = 0
+TOTAL_LAG_OUT = 0
+
+
+INFO_STARTING = """
+    Dummyrunner starting using {nclients} dummy account(s). If you don't see
+    any connection messages, make sure that the Evennia server is
+    running.
+
+    TELNET_PORT = {port}
+    IDMAPPER_CACHE_MAXSIZE = {idmapper_cache_size} MB
+    TIMESTEP = {timestep} (rate {rate}/s)
+    CHANCE_OF_LOGIN = {chance_of_login}% per time step
+    CHANCE_OF_ACTION = {chance_of_action}% per time step
+    -> avg rate (per client, after login): {avg_rate} cmds/s
+    -> total avg rate (after login): {avg_rate_total} cmds/s
+
+    Use Ctrl-C (or Cmd-C) to stop/disconnect all clients.
+
+    """
+
+ERROR_NO_MIXIN = """
+    Error: Evennia is not set up for dummyrunner. Before starting the
+    server, make sure to include the following at *the end* of your
+    settings file (remove when not using dummyrunner!):
+
+        from evennia.server.profiling.settings_mixin import *
+
+    This will change the settings in the following way:
+        - change PERMISSION_ACCOUNT_DEFAULT to 'Developer' to allow clients
+          to test all commands
+        - change PASSWORD_HASHERS to use a faster (but less safe) algorithm
+          when creating large numbers of accounts at the same time
+        - set LOGIN_THROTTLE/CREATION_THROTTLE=None to disable it
+
+    If you don't want to use the custom settings of the mixin for some
+    reason, you can change their values manually after the import, or
+    add DUMMYRUNNER_MIXIN=True to your settings file to avoid this
+    error completely.
+
+    Warning: Don't run dummyrunner on a production database! It will
+    create a lot of spammy objects and accounts!
+    """
+
+
+ERROR_FEW_ACTIONS = """
+    Dummyrunner settings error: The ACTIONS tuple is too short: it must
+    contain at least login- and logout functions.
+    """
+
+
+HELPTEXT = """
+DO NOT RUN THIS ON A PRODUCTION SERVER! USE A CLEAN/TESTING DATABASE!
+
+This stand-alone program launches dummy telnet clients against a
+running Evennia server. The idea is to mimic real accounts logging in
+and repeatedly doing resource-heavy commands so as to stress test the
+game. It uses the default command set to log in and issue commands, so
+if that was customized, some of the functionality will not be tested
+(it will not fail, the commands will just not be recognized).  The
+running clients will create new objects and rooms all over the place
+as part of their running, so using a clean/testing database is
+strongly recommended.
+
+Setup:
+  1) setup a fresh/clean database (if using sqlite, just safe-copy
+     away your real evennia.db3 file and create a new one with
+     `evennia migrate`)
+  2) in server/conf/settings.py, add
+
+        PERMISSION_ACCOUNT_DEFAULT="Builder"
+
+     This is so that the dummy accounts can test building operations.
+     You can also customize the dummyrunner by modifying a setting
+     file specified by DUMMYRUNNER_SETTINGS_MODULE
+
+  3) Start Evennia like normal, optionally with profiling (--profile)
+  4) Run this dummy runner via the evennia launcher:
+
+        evennia --dummyrunner <nr_of_clients>
+
+  5) Log on and determine if game remains responsive despite the
+     heavier load. Note that if you activated profiling, there is a
+     considerate additional overhead from the profiler too so you
+     should usually not consider game responsivity when using the
+     profiler at the same time.
+  6) If you use profiling, let the game run long enough to gather
+     data, then stop the server cleanly using evennia stop or @shutdown.
+     @shutdown. The profile appears as
+     server/logs/server.prof/portal.prof (see Python's manual on
+     cProfiler).
+
+Notes:
+
+The dummyrunner tends to create a lot of accounts all at once, which is
+a very heavy operation. This is not a realistic use-case - what you want
+to test is performance during run. A large
+number of clients here may lock up the client until all have been
+created. It may be better to connect multiple dummyrunners instead of
+starting one single one with a lot of accounts. Exactly what this number
+is depends on your computer power. So start with 10-20 clients and increase
+until you see the initial login slows things too much.
+
+"""
+
+
+
[docs]class CmdDummyRunnerEchoResponse(Command): + """ + Dummyrunner command measuring the round-about response time + from sending to receiving a result. + + Usage: + dummyrunner_echo_response <timestamp> + + Responds with + dummyrunner_echo_response:<timestamp>,<current_time> + + The dummyrunner will send this and then compare the send time + with the receive time on both ends. + + """ + + key = "dummyrunner_echo_response" + +
[docs] def func(self): + # returns (dummy_client_timestamp,current_time) + self.msg(f"dummyrunner_echo_response:{self.args},{time.time()}") + if self.caller.account.is_superuser: + print(f"cmddummyrunner lag in: {time.time() - float(self.args)}s")
+ + +
[docs]class DummyRunnerCmdSet(CmdSet): + """ + Dummyrunner injected cmdset. + + """ + +
[docs] def at_cmdset_creation(self): + self.add(CmdDummyRunnerEchoResponse())
+ + +# ------------------------------------------------------------ +# Helper functions +# ------------------------------------------------------------ + + +ICOUNT = 0 + + +
[docs]def idcounter(): + """ + Makes unique ids. + + Returns: + str: A globally unique id. + + """ + global ICOUNT + ICOUNT += 1 + return str("{:03d}".format(ICOUNT))
+ + +GCOUNT = 0 + + +
[docs]def gidcounter(): + """ + Makes globally unique ids. + + Returns: + count (int); A globally unique counter. + + """ + global GCOUNT + GCOUNT += 1 + return "%s_%s" % (time.strftime(DATESTRING), GCOUNT)
+ + +
[docs]def makeiter(obj): + """ + Makes everything iterable. + + Args: + obj (any): Object to turn iterable. + + Returns: + iterable (iterable): An iterable object. + """ + return obj if hasattr(obj, "__iter__") else [obj]
+ + +# ------------------------------------------------------------ +# Client classes +# ------------------------------------------------------------ + + +
[docs]class DummyClient(telnet.StatefulTelnetProtocol): + """ + Handles connection to a running Evennia server, + mimicking a real account by sending commands on + a timer. + + """ + +
[docs] def report(self, text, clientkey): + pad = " " * (25 - len(text)) + tim = round(time.time() - self.connection_timestamp) + print( + f"{text} {clientkey}{pad}\t" + f"conn: {NCONNECTED} -> " + f"welcome screen: {NLOGIN_SCREEN} -> " + f"authing: {NLOGGING_IN} -> " + f"loggedin/tot: {NLOGGED_IN}/{NCLIENTS} (after {tim}s)" + )
+ +
[docs] def connectionMade(self): + """ + Called when connection is first established. + + """ + global NCONNECTED + # public properties + self.cid = idcounter() + self.key = f"Dummy-{self.cid}" + self.gid = f"{time.strftime(DATESTRING)}_{self.cid}" + self.istep = 0 + self.exits = [] # exit names created + self.objs = [] # obj names created + self.connection_timestamp = time.time() + self.connection_attempt = 0 + self.action_started = 0 + + self._connected = False + self._loggedin = False + self._logging_out = False + self._ready = False + self._report = "" + self._cmdlist = [] # already stepping in a cmd definition + self._login = self.factory.actions[0] + self._logout = self.factory.actions[1] + self._actions = self.factory.actions[2:] + + reactor.addSystemEventTrigger("before", "shutdown", self.logout) + + NCONNECTED += 1 + self.report("-> connected", self.key) + + reactor.callLater(30, self._retry_welcome_screen)
+ + def _retry_welcome_screen(self): + if not self._connected and not self._ready: + # we have connected but not received anything for 30s. + # (unclear why this would be - overload?) + # try sending a look to get something to start with + self.report("?? retrying welcome screen", self.key) + self.sendLine(bytes("look", "utf-8")) + # make sure to check again later + reactor.callLater(30, self._retry_welcome_screen) + + def _print_statistics(self): + global TIME_ALL_LOGIN, TOTAL_ACTIONS + global TOTAL_LAG, TOTAL_LAG_MEASURES, TOTAL_LAG_IN, TOTAL_LAG_OUT + + tim = time.time() - TIME_ALL_LOGIN + avgrate = round(TOTAL_ACTIONS / tim) + lag = TOTAL_LAG / (TOTAL_LAG_MEASURES or 1) + lag_in = TOTAL_LAG_IN / (TOTAL_LAG_MEASURES or 1) + lag_out = TOTAL_LAG_OUT / (TOTAL_LAG_MEASURES or 1) + + TOTAL_ACTIONS = 0 + TOTAL_LAG = 0 + TOTAL_LAG_IN = 0 + TOTAL_LAG_OUT = 0 + TOTAL_LAG_MEASURES = 0 + TIME_ALL_LOGIN = time.time() + + print( + f".. running 30s average: ~{avgrate} actions/s " + f"lag: {lag:.2}s (in: {lag_in:.2}s, out: {lag_out:.2}s)" + ) + + reactor.callLater(30, self._print_statistics) + +
[docs] def dataReceived(self, data): + """ + Called when data comes in over the protocol. We wait to start + stepping until the server actually responds + + Args: + data (str): Incoming data. + + """ + global NLOGIN_SCREEN, NLOGGED_IN, NLOGGING_IN, NCONNECTED + global TOTAL_ACTIONS, TIME_ALL_LOGIN + global TOTAL_LAG, TOTAL_LAG_MEASURES, TOTAL_LAG_IN, TOTAL_LAG_OUT + + if not data.startswith(b"\xff"): + # regular text, not a telnet command + + if NCLIENTS == 1: + print("dummy-client sees:", str(data, "utf-8")) + + if not self._connected: + # waiting for connection + # wait until we actually get text back (not just telnet + # negotiation) + # start client tick + d = LoopingCall(self.step) + df = max(abs(TIMESTEP * 0.001), min(TIMESTEP / 10, 0.5)) + # dither next attempt with random time + timestep = TIMESTEP + (-df + (random.random() * df)) + d.start(timestep, now=True).addErrback(self.error) + self.connection_attempt += 1 + + self._connected = True + NLOGIN_SCREEN += 1 + NCONNECTED -= 1 + self.report("<- server sent login screen", self.key) + + elif self._loggedin: + if not self._ready: + # logged in, ready to run + NLOGGED_IN += 1 + NLOGGING_IN -= 1 + self._ready = True + self.report("== logged in", self.key) + if NLOGGED_IN == NCLIENTS and not TIME_ALL_LOGIN: + # all are logged in! We can start collecting statistics + print(".. All clients connected and logged in!") + TIME_ALL_LOGIN = time.time() + reactor.callLater(30, self._print_statistics) + + elif TIME_ALL_LOGIN: + TOTAL_ACTIONS += 1 + + try: + data = strip_ansi(str(data, "utf-8").strip()) + if data.startswith("dummyrunner_echo_response:"): + # handle special lag-measuring command. This returns + # dummyrunner_echo_response:<starttime>,<midpointtime> + now = time.time() + _, data = data.split(":", 1) + start_time, mid_time = (float(part) for part in data.split(",", 1)) + lag_in = mid_time - start_time + lag_out = now - mid_time + total_lag = now - start_time # full round-about time + + TOTAL_LAG += total_lag + TOTAL_LAG_IN += lag_in + TOTAL_LAG_OUT += lag_out + TOTAL_LAG_MEASURES += 1 + except Exception: + pass
+ +
[docs] def connectionLost(self, reason): + """ + Called when loosing the connection. + + Args: + reason (str): Reason for loosing connection. + + """ + if not self._logging_out: + self.report("XX lost connection", self.key)
+ +
[docs] def error(self, err): + """ + Error callback. + + Args: + err (Failure): Error instance. + """ + print(err)
+ +
[docs] def counter(self): + """ + Produces a unique id, also between clients. + + Returns: + counter (int): A unique counter. + + """ + return gidcounter()
+ +
[docs] def logout(self): + """ + Causes the client to log out of the server. Triggered by ctrl-c signal. + + """ + self._logging_out = True + cmd = self._logout(self)[0] + self.report(f"-> logout/disconnect ({self.istep} actions)", self.key) + self.sendLine(bytes(cmd, "utf-8"))
+ +
[docs] def step(self): + """ + Perform a step. This is called repeatedly by the runner and + causes the client to issue commands to the server. This holds + all "intelligence" of the dummy client. + + """ + global NLOGGING_IN, NLOGIN_SCREEN + + rand = random.random() + + if not self._cmdlist: + # no commands ready. Load some. + + if not self._loggedin: + if rand < CHANCE_OF_LOGIN or NLOGGING_IN < 10: + # lower rate of logins, but not below 1 / s + # get the login commands + self._cmdlist = list(makeiter(self._login(self))) + NLOGGING_IN += 1 # this is for book-keeping + NLOGIN_SCREEN -= 1 + self.report("-> create/login", self.key) + self._loggedin = True + else: + # no login yet, so cmdlist not yet set + return + else: + # we always pick a cumulatively random function + crand = random.random() + cfunc = [func for (cprob, func) in self._actions if cprob >= crand][0] + self._cmdlist = list(makeiter(cfunc(self))) + + # at this point we always have a list of commands + if rand < CHANCE_OF_ACTION: + # send to the game + cmd = str(self._cmdlist.pop(0)) + + if cmd.startswith("dummyrunner_echo_response"): + # we need to set the timer element as close to + # the send as possible + cmd = cmd.format(timestamp=time.time()) + + self.sendLine(bytes(cmd, "utf-8")) + self.action_started = time.time() + self.istep += 1 + + if NCLIENTS == 1: + print(f"dummy-client sent: {cmd}")
+ + +
[docs]class DummyFactory(protocol.ReconnectingClientFactory): + protocol = DummyClient + initialDelay = 1 + maxDelay = 1 + noisy = False + +
[docs] def __init__(self, actions): + "Setup the factory base (shared by all clients)" + self.actions = actions
+ + +# ------------------------------------------------------------ +# Access method: +# Starts clients and connects them to a running server. +# ------------------------------------------------------------ + + +
[docs]def start_all_dummy_clients(nclients): + """ + Initialize all clients, connect them and start to step them + + Args: + nclients (int): Number of dummy clients to connect. + + """ + global NCLIENTS + NCLIENTS = int(nclients) + actions = DUMMYRUNNER_SETTINGS.ACTIONS + + if len(actions) < 2: + print(ERROR_FEW_ACTIONS) + return + + # make sure the probabilities add up to 1 + pratio = 1.0 / sum(tup[0] for tup in actions[2:]) + flogin, flogout, probs, cfuncs = ( + actions[0], + actions[1], + [tup[0] * pratio for tup in actions[2:]], + [tup[1] for tup in actions[2:]], + ) + # create cumulative probabilies for the random actions + cprobs = [sum(v for i, v in enumerate(probs) if i <= k) for k in range(len(probs))] + # rebuild a new, optimized action structure + actions = (flogin, flogout) + tuple(zip(cprobs, cfuncs)) + + # setting up all clients (they are automatically started) + factory = DummyFactory(actions) + for i in range(NCLIENTS): + reactor.connectTCP("127.0.0.1", TELNET_PORT, factory) + # start reactor + reactor.run()
+ + +# ------------------------------------------------------------ +# Command line interface +# ------------------------------------------------------------ + + +if __name__ == "__main__": + try: + settings.DUMMYRUNNER_MIXIN + except AttributeError: + print(ERROR_NO_MIXIN) + sys.exit() + + # parsing command line with default vals + parser = ArgumentParser(description=HELPTEXT) + parser.add_argument( + "-N", nargs=1, default=1, dest="nclients", help="Number of clients to start" + ) + + args = parser.parse_args() + nclients = int(args.nclients[0]) + + print( + INFO_STARTING.format( + nclients=nclients, + port=TELNET_PORT, + idmapper_cache_size=IDMAPPER_CACHE_MAXSIZE, + timestep=TIMESTEP, + rate=1 / TIMESTEP, + chance_of_login=CHANCE_OF_LOGIN * 100, + chance_of_action=CHANCE_OF_ACTION * 100, + avg_rate=(1 / TIMESTEP) * CHANCE_OF_ACTION, + avg_rate_total=(1 / TIMESTEP) * CHANCE_OF_ACTION * nclients, + ) + ) + + # run the dummyrunner + TIME_START = t0 = time.time() + start_all_dummy_clients(nclients=nclients) + ttot = time.time() - t0 + + # output runtime + print("... dummy client runner stopped after %s." % time_format(ttot, style=3)) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/profiling/dummyrunner_settings.html b/docs/latest/_modules/evennia/server/profiling/dummyrunner_settings.html new file mode 100644 index 0000000000..00731bee16 --- /dev/null +++ b/docs/latest/_modules/evennia/server/profiling/dummyrunner_settings.html @@ -0,0 +1,435 @@ + + + + + + + + evennia.server.profiling.dummyrunner_settings — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.profiling.dummyrunner_settings

+"""
+Settings and actions for the dummyrunner
+
+This module defines dummyrunner settings and sets up
+the actions available to dummy accounts.
+
+The settings are global variables:
+
+- TIMESTEP - time in seconds between each 'tick'. 1 is a good start.
+- CHANCE_OF_ACTION - chance 0-1 of action happening. Default is 0.5.
+- CHANCE_OF_LOGIN - chance 0-1 of login happening. 0.01 is a good number.
+- TELNET_PORT - port to use, defaults to settings.TELNET_PORT
+- ACTIONS - see below
+
+ACTIONS is a tuple
+
+```python
+(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
+responsible for logging in the account. The second entry is always
+called when the dummyrunner disconnects from the server and should
+thus issue a logout command. The other entries are tuples (chance,
+func). They are picked randomly, their commonality based on the
+cumulative chance given (the chance is normalized between all options
+so if will still work also if the given chances don't add up to 1).
+
+The PROFILE variable define pre-made ACTION tuples for convenience.
+
+Each function should return an iterable of one or more command-call
+strings (like "look here"), so each can group multiple command operations.
+
+An action-function is called with a "client" argument which is a
+reference to the dummy client currently performing the action.
+
+The client object has the following relevant properties and methods:
+
+- 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
+  to make it unique also between dummyrunner instances.
+
+The return should either be a single command string or a tuple of
+command strings. This list of commands will always be executed every
+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).
+
+----
+
+"""
+import random
+import string
+
+# Dummy runner settings
+
+# Time between each dummyrunner "tick", in seconds. Each dummy
+# will be called with this frequency.
+TIMESTEP = 1
+# TIMESTEP = 0.025  # 40/s
+
+# Chance of a dummy actually performing an action on a given tick.
+# This spreads out usage randomly, like it would be in reality.
+CHANCE_OF_ACTION = 0.5
+
+# Chance of a currently unlogged-in dummy performing its login
+# action every tick. This emulates not all accounts logging in
+# at exactly the same time.
+CHANCE_OF_LOGIN = 0.01
+
+# Which telnet port to connect to. If set to None, uses the first
+# default telnet port of the running server.
+TELNET_PORT = None
+
+
+# Setup actions tuple
+
+# some convenient templates
+
+DUMMY_NAME = "Dummy_{gid}"
+DUMMY_PWD = (
+    "".join(random.choice(string.ascii_letters + string.digits) for _ in range(20)) + "-{gid}"
+)
+START_ROOM = "testing_room_start_{gid}"
+ROOM_TEMPLATE = "testing_room_%s"
+EXIT_TEMPLATE = "exit_%s"
+OBJ_TEMPLATE = "testing_obj_%s"
+TOBJ_TEMPLATE = "testing_button_%s"
+TOBJ_TYPECLASS = "contrib.tutorial_examples.red_button.RedButton"
+
+
+# action function definitions (pick and choose from
+# these to build a client "usage profile"
+
+# login/logout
+
+
+
[docs]def c_login(client): + "logins to the game" + # we always use a new client name + cname = DUMMY_NAME.format(gid=client.gid) + cpwd = DUMMY_PWD.format(gid=client.gid) + room_name = START_ROOM.format(gid=client.gid) + + # we assign the dummyrunner cmdsert to ourselves so # we can use special commands + add_cmdset = ( + "py from evennia.server.profiling.dummyrunner import DummyRunnerCmdSet;" + "self.cmdset.add(DummyRunnerCmdSet, persistent=False)" + ) + + # create character, log in, then immediately dig a new location and + # teleport it (to keep the login room clean) + cmds = ( + f"create {cname} {cpwd}", + f"yes", # to confirm creation + f"connect {cname} {cpwd}", + f"dig {room_name}", + f"teleport {room_name}", + add_cmdset, + ) + return cmds
+ + +
[docs]def c_login_nodig(client): + "logins, don't dig its own room" + cname = DUMMY_NAME.format(gid=client.gid) + cpwd = DUMMY_PWD.format(gid=client.gid) + cmds = (f"create {cname} {cpwd}", f"connect {cname} {cpwd}") + return cmds
+ + +
[docs]def c_logout(client): + "logouts of the game" + return ("quit",)
+ + +# random commands + + +
[docs]def c_looks(client): + "looks at various objects" + cmds = ["look %s" % obj for obj in client.objs] + if not cmds: + cmds = ["look %s" % exi for exi in client.exits] + if not cmds: + cmds = ("look",) + return cmds
+ + +
[docs]def c_examines(client): + "examines various objects" + cmds = ["examine %s" % obj for obj in client.objs] + if not cmds: + cmds = ["examine %s" % exi for exi in client.exits] + if not cmds: + cmds = ("examine me",) + return cmds
+ + +
[docs]def c_idles(client): + "idles" + cmds = ("idle", "idle") + return cmds
+ + +
[docs]def c_help(client): + "reads help files" + cmds = ( + "help", + "dummyrunner_echo_response", + ) + return cmds
+ + +
[docs]def c_digs(client): + "digs a new room, storing exit names on client" + roomname = ROOM_TEMPLATE % client.counter() + exitname1 = EXIT_TEMPLATE % client.counter() + exitname2 = EXIT_TEMPLATE % client.counter() + client.exits.extend([exitname1, exitname2]) + return ("dig/tel %s = %s, %s" % (roomname, exitname1, exitname2),)
+ + +
[docs]def c_creates_obj(client): + "creates normal objects, storing their name on client" + objname = OBJ_TEMPLATE % client.counter() + client.objs.append(objname) + cmds = ( + "create %s" % objname, + 'desc %s = "this is a test object' % objname, + "set %s/testattr = this is a test attribute value." % objname, + "set %s/testattr2 = this is a second test attribute." % objname, + ) + return cmds
+ + +
[docs]def c_creates_button(client): + "creates example button, storing name on client" + objname = TOBJ_TEMPLATE % client.counter() + client.objs.append(objname) + cmds = ("create %s:%s" % (objname, TOBJ_TYPECLASS), "desc %s = test red button!" % objname) + return cmds
+ + +
[docs]def c_socialize(client): + "socializechats on channel" + cmds = ( + "pub Hello!", + "say Yo!", + "emote stands looking around.", + ) + return cmds
+ + +
[docs]def c_moves(client): + "moves to a previously created room, using the stored exits" + cmds = client.exits # try all exits - finally one will work + return ("look",) if not cmds else cmds
+ + +
[docs]def c_moves_n(client): + "move through north exit if available" + return ("north",)
+ + +
[docs]def c_moves_s(client): + "move through south exit if available" + return ("south",)
+ + +
[docs]def c_measure_lag(client): + """ + Special dummyrunner command, injected in c_login. It measures + response time. Including this in the ACTION tuple will give more + dummyrunner output about just how fast commands are being processed. + + The dummyrunner will treat this special and inject the + {timestamp} just before sending. + + """ + return ("dummyrunner_echo_response {timestamp}",)
+ + +# Action profile (required) + +# Some pre-made profiles to test. To make your own, just assign a tuple to ACTIONS. +# +# idler - does nothing after logging in +# looker - just looks around +# normal_player - moves around, reads help, looks around (digs rarely) (spammy) +# normal_builder - digs now and then, examines, creates objects, moves +# heavy_builder - digs and creates a lot, moves and examines +# socializing_builder - builds a lot, creates help entries, moves, chat (spammy) +# only_digger - extreme builder that only digs room after room + +PROFILE = "looker" + + +if PROFILE == "idler": + ACTIONS = ( + c_login, + c_logout, + (0.9, c_idles), + (0.1, c_measure_lag), + ) +elif PROFILE == "looker": + ACTIONS = (c_login, c_logout, (0.8, c_looks), (0.2, c_measure_lag)) +elif PROFILE == "normal_player": + ACTIONS = ( + c_login, + c_logout, + (0.01, c_digs), + (0.29, c_looks), + (0.2, c_help), + (0.3, c_moves), + (0.05, c_socialize), + (0.1, c_measure_lag), + ) +elif PROFILE == "normal_builder": + ACTIONS = ( + c_login, + c_logout, + (0.5, c_looks), + (0.08, c_examines), + (0.1, c_help), + (0.01, c_digs), + (0.01, c_creates_obj), + (0.2, c_moves), + (0.1, c_measure_lag), + ) +elif PROFILE == "heavy_builder": + ACTIONS = ( + c_login, + c_logout, + (0.1, c_looks), + (0.1, c_examines), + (0.2, c_help), + (0.1, c_digs), + (0.1, c_creates_obj), + (0.2, c_moves), + (0.1, c_measure_lag), + ) +elif PROFILE == "socializing_builder": + ACTIONS = ( + c_login, + c_logout, + (0.1, c_socialize), + (0.1, c_looks), + (0.1, c_help), + (0.1, c_creates_obj), + (0.2, c_digs), + (0.3, c_moves), + (0.1, c_measure_lag), + ) +elif PROFILE == "only_digger": + ACTIONS = (c_login, c_logout, (0.9, c_digs), (0.1, c_measure_lag)) + +else: + print("No dummyrunner ACTION profile defined.") + import sys + + sys.exit() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/profiling/memplot.html b/docs/latest/_modules/evennia/server/profiling/memplot.html new file mode 100644 index 0000000000..06bf10b2fd --- /dev/null +++ b/docs/latest/_modules/evennia/server/profiling/memplot.html @@ -0,0 +1,220 @@ + + + + + + + + evennia.server.profiling.memplot — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.profiling.memplot

+"""
+Script that saves memory and idmapper data over time.
+
+Data will be saved to game/logs/memoryusage.log. Note that
+the script will append to this file if it already exists.
+
+Call this module directly to plot the log (requires matplotlib and numpy).
+"""
+
+import os
+import sys
+import time
+
+# TODO!
+# sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
+# os.environ['DJANGO_SETTINGS_MODULE'] = 'game.settings'
+import evennia
+from evennia.utils.idmapper import models as _idmapper
+
+LOGFILE = "logs/memoryusage.log"
+INTERVAL = 30  # log every 30 seconds
+
+
+
[docs]class Memplot(evennia.DefaultScript): + """ + Describes a memory plotting action. + + """ + +
[docs] def at_script_creation(self): + "Called at script creation" + self.key = "memplot" + self.desc = "Save server memory stats to file" + self.start_delay = False + self.persistent = True + self.interval = INTERVAL + self.db.starttime = time.time()
+ +
[docs] def at_repeat(self): + "Regularly save memory statistics." + pid = os.getpid() + rmem = ( + float(os.popen("ps -p %d -o %s | tail -1" % (pid, "rss")).read()) / 1000.0 + ) # resident memory + vmem = ( + float(os.popen("ps -p %d -o %s | tail -1" % (pid, "vsz")).read()) / 1000.0 + ) # virtual memory + total_num, cachedict = _idmapper.cache_size() + t0 = (time.time() - self.db.starttime) / 60.0 # save in minutes + + with open(LOGFILE, "a") as f: + f.write("%s, %s, %s, %s\n" % (t0, rmem, vmem, int(total_num)))
+ + +if __name__ == "__main__": + # plot output from the file + + import numpy + from matplotlib import pyplot as pp + + data = numpy.genfromtxt("../../../game/" + LOGFILE, delimiter=",") + secs = data[:, 0] + rmem = data[:, 1] + vmem = data[:, 2] + nobj = data[:, 3] + + # calculate derivative of obj creation + # oderiv = (0.5*(nobj[2:] - nobj[:-2]) / (secs[2:] - secs[:-2])).copy() + # oderiv = (0.5*(rmem[2:] - rmem[:-2]) / (secs[2:] - secs[:-2])).copy() + + fig = pp.figure() + ax1 = fig.add_subplot(111) + ax1.set_title("1000 bots (normal accounts with light building)") + ax1.set_xlabel("Time (mins)") + ax1.set_ylabel("Memory usage (MB)") + ax1.plot(secs, rmem, "r", label="RMEM", lw=2) + ax1.plot(secs, vmem, "b", label="VMEM", lw=2) + ax1.legend(loc="upper left") + + ax2 = ax1.twinx() + ax2.plot(secs, nobj, "g--", label="objs in cache", lw=2) + # ax2.plot(secs[:-2], oderiv/60.0, "g--", label="Objs/second", lw=2) + # ax2.plot(secs[:-2], oderiv, "g--", label="Objs/second", lw=2) + ax2.set_ylabel("Number of objects") + ax2.legend(loc="lower right") + ax2.annotate("First 500 bots\nconnecting", xy=(10, 4000)) + ax2.annotate("Next 500 bots\nconnecting", xy=(350, 10000)) + # ax2.annotate("@reload", xy=(185,600)) + + # # plot mem vs cachesize + # nobj, rmem, vmem = nobj[:262].copy(), rmem[:262].copy(), vmem[:262].copy() + # + # fig = pp.figure() + # ax1 = fig.add_subplot(111) + # ax1.set_title("Memory usage per cache size") + # ax1.set_xlabel("Cache size (number of objects)") + # ax1.set_ylabel("Memory usage (MB)") + # ax1.plot(nobj, rmem, "r", label="RMEM", lw=2) + # ax1.plot(nobj, vmem, "b", label="VMEM", lw=2) + # + + # empirical estimate of memory usage: rmem = 35.0 + 0.0157 * Ncache + # Ncache = int((rmem - 35.0) / 0.0157) (rmem in MB) + # + # rderiv_aver = 0.0157 + # fig = pp.figure() + # ax1 = fig.add_subplot(111) + # ax1.set_title("Relation between memory and cache size") + # ax1.set_xlabel("Memory usage (MB)") + # ax1.set_ylabel("Idmapper Cache Size (number of objects)") + # rmem = numpy.linspace(35, 2000, 2000) + # nobjs = numpy.array([int((mem - 35.0) / 0.0157) for mem in rmem]) + # ax1.plot(rmem, nobjs, "r", lw=2) + + pp.show() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/profiling/test_queries.html b/docs/latest/_modules/evennia/server/profiling/test_queries.html new file mode 100644 index 0000000000..f8757fc0ce --- /dev/null +++ b/docs/latest/_modules/evennia/server/profiling/test_queries.html @@ -0,0 +1,147 @@ + + + + + + + + evennia.server.profiling.test_queries — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.profiling.test_queries

+"""
+This is a little routine for viewing the sql queries that are executed by a given
+query as well as count them for optimization testing.
+
+"""
+
+import os
+import sys
+
+# sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
+# os.environ["DJANGO_SETTINGS_MODULE"] = "game.settings"
+from django.db import connection
+
+
+
[docs]def count_queries(exec_string, setup_string): + """ + Display queries done by exec_string. Use setup_string + to setup the environment to test. + """ + + exec(setup_string) + + num_queries_old = len(connection.queries) + exec(exec_string) + nqueries = len(connection.queries) - num_queries_old + + for query in connection.queries[-nqueries if nqueries else 1 :]: + print(query["time"], query["sql"]) + print("Number of queries: %s" % nqueries)
+ + +if __name__ == "__main__": + # setup tests here + + setup_string = """ +from evennia.objects.models import ObjectDB +g = ObjectDB.objects.get(db_key="Griatch") +""" + exec_string = """ +g.tags.all() +""" + count_queries(exec_string, setup_string) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/profiling/tests.html b/docs/latest/_modules/evennia/server/profiling/tests.html new file mode 100644 index 0000000000..ab8ef12ba6 --- /dev/null +++ b/docs/latest/_modules/evennia/server/profiling/tests.html @@ -0,0 +1,265 @@ + + + + + + + + evennia.server.profiling.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.profiling.tests

+from anything import Something
+from django.test import TestCase
+from mock import Mock, mock_open, patch
+
+from .dummyrunner_settings import (
+    c_creates_button,
+    c_creates_obj,
+    c_digs,
+    c_examines,
+    c_help,
+    c_idles,
+    c_login,
+    c_login_nodig,
+    c_logout,
+    c_looks,
+    c_moves,
+    c_moves_n,
+    c_moves_s,
+    c_socialize,
+)
+
+try:
+    import memplot
+except ImportError:
+    memplot = Mock()
+
+
+
[docs]class TestDummyrunnerSettings(TestCase): +
[docs] def setUp(self): + self.client = Mock() + self.client.cid = 1 + self.client.counter = Mock(return_value=1) + self.client.gid = "20171025161153-1" + self.client.name = "Dummy_%s" % self.client.gid + self.client.password = (Something,) + self.client.start_room = "testing_room_start_%s" % self.client.gid + self.client.objs = [] + self.client.exits = []
+ +
[docs] def clear_client_lists(self): + self.client.objs = [] + self.client.exits = []
+ +
[docs] def test_c_login(self): + self.assertEqual( + c_login(self.client), + ( + Something, # create + "yes", # confirm creation + Something, # connect + "dig %s" % self.client.start_room, + "teleport %s" % self.client.start_room, + "py from evennia.server.profiling.dummyrunner import DummyRunnerCmdSet;" + "self.cmdset.add(DummyRunnerCmdSet, persistent=False)", + ), + )
+ +
[docs] def test_c_login_no_dig(self): + cmd1, cmd2 = c_login_nodig(self.client) + self.assertTrue(cmd1.startswith("create " + self.client.name + " ")) + self.assertTrue(cmd2.startswith("connect " + self.client.name + " "))
+ +
[docs] def test_c_logout(self): + self.assertEqual(c_logout(self.client), ("quit",))
+ +
[docs] def perception_method_tests(self, func, verb, alone_suffix=""): + self.assertEqual(func(self.client), ("%s%s" % (verb, alone_suffix),)) + self.client.exits = ["exit1", "exit2"] + self.assertEqual(func(self.client), ["%s exit1" % verb, "%s exit2" % verb]) + self.client.objs = ["foo", "bar"] + self.assertEqual(func(self.client), ["%s foo" % verb, "%s bar" % verb]) + self.clear_client_lists()
+ +
[docs] def test_c_looks(self): + self.perception_method_tests(c_looks, "look")
+ +
[docs] def test_c_examines(self): + self.perception_method_tests(c_examines, "examine", " me")
+ +
[docs] def test_idles(self): + self.assertEqual(c_idles(self.client), ("idle", "idle"))
+ +
[docs] def test_c_help(self): + self.assertEqual( + c_help(self.client), + ("help", "dummyrunner_echo_response"), + )
+ +
[docs] def test_c_digs(self): + self.assertEqual(c_digs(self.client), ("dig/tel testing_room_1 = exit_1, exit_1",)) + self.assertEqual(self.client.exits, ["exit_1", "exit_1"]) + self.clear_client_lists()
+ +
[docs] def test_c_creates_obj(self): + objname = "testing_obj_1" + self.assertEqual( + c_creates_obj(self.client), + ( + "create %s" % objname, + 'desc %s = "this is a test object' % objname, + "set %s/testattr = this is a test attribute value." % objname, + "set %s/testattr2 = this is a second test attribute." % objname, + ), + ) + self.assertEqual(self.client.objs, [objname]) + self.clear_client_lists()
+ +
[docs] def test_c_creates_button(self): + objname = "testing_button_1" + typeclass_name = "contrib.tutorial_examples.red_button.RedButton" + self.assertEqual( + c_creates_button(self.client), + ("create %s:%s" % (objname, typeclass_name), "desc %s = test red button!" % objname), + ) + self.assertEqual(self.client.objs, [objname]) + self.clear_client_lists()
+ +
[docs] def test_c_socialize(self): + self.assertEqual( + c_socialize(self.client), + ( + "pub Hello!", + "say Yo!", + "emote stands looking around.", + ), + )
+ +
[docs] def test_c_moves(self): + self.assertEqual(c_moves(self.client), ("look",)) + self.client.exits = ["south", "north"] + self.assertEqual(c_moves(self.client), ["south", "north"]) + self.clear_client_lists()
+ +
[docs] def test_c_move_n(self): + self.assertEqual(c_moves_n(self.client), ("north",))
+ +
[docs] def test_c_move_s(self): + self.assertEqual(c_moves_s(self.client), ("south",))
+ + +
[docs]class TestMemPlot(TestCase): +
[docs] @patch.object(memplot, "_idmapper") + @patch.object(memplot, "os") + @patch.object(memplot, "open", new_callable=mock_open, create=True) + @patch.object(memplot, "time") + @patch("evennia.utils.idmapper.models.SharedMemoryModel.flush_from_cache", new=Mock()) + def test_memplot(self, mock_time, mocked_open, mocked_os, mocked_idmapper): + if isinstance(memplot, Mock): + return + from evennia.utils.create import create_script + + mocked_idmapper.cache_size.return_value = (9, 5000) + mock_time.time = Mock(return_value=6000.0) + script = create_script(memplot.Memplot) + script.db.starttime = 0.0 + mocked_os.popen.read.return_value = 5000.0 + script.at_repeat() + handle = mocked_open() + handle.write.assert_called_with("100.0, 0.001, 0.001, 9\n") + script.stop()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/profiling/timetrace.html b/docs/latest/_modules/evennia/server/profiling/timetrace.html new file mode 100644 index 0000000000..31462d881b --- /dev/null +++ b/docs/latest/_modules/evennia/server/profiling/timetrace.html @@ -0,0 +1,145 @@ + + + + + + + + evennia.server.profiling.timetrace — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.profiling.timetrace

+"""
+Trace a message through the messaging system
+"""
+
+import time
+
+
+
[docs]def timetrace(message, idstring, tracemessage="TEST_MESSAGE", final=False): + """ + Trace a message with time stamps. + + Args: + message (str): The actual message coming through + idstring (str): An identifier string specifying where this trace is happening. + tracemessage (str): The start of the message to tag. + This message will get attached time stamp. + final (bool): This is the final leg in the path - include total time in message + + """ + if message.startswith(tracemessage): + # the message is on the form TEST_MESSAGE tlast t0 + # where t0 is the initial starting time and last is the time + # saved at the last stop. + try: + prefix, tlast, t0 = message.split(None, 2) + tlast, t0 = float(tlast), float(t0) + except (IndexError, ValueError): + t0 = time.time() + tlast = t0 + t1 = t0 + else: + t1 = time.time() + # print to log (important!) + print("** timetrace (%s): dT=%fs, total=%fs." % (idstring, t1 - tlast, t1 - t0)) + + if final: + message = " **** %s (total %f) **** " % (tracemessage, t1 - t0) + else: + message = "%s %f %f" % (tracemessage, t1, t0) + return message
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/server.html b/docs/latest/_modules/evennia/server/server.html new file mode 100644 index 0000000000..2efe2cb84a --- /dev/null +++ b/docs/latest/_modules/evennia/server/server.html @@ -0,0 +1,907 @@ + + + + + + + + evennia.server.server — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.server

+"""
+This module implements the main Evennia server process, the core of the game
+engine.
+
+This module should be started with the 'twistd' executable since it sets up all
+the networking features.  (this is done automatically by
+evennia/server/server_runner.py).
+
+"""
+import os
+import sys
+import time
+import traceback
+
+import django
+from twisted.application import internet, service
+from twisted.internet import defer, reactor
+from twisted.internet.task import LoopingCall
+from twisted.logger import globalLogPublisher
+from twisted.web import static
+
+django.setup()
+
+import importlib
+
+import evennia
+
+evennia._init()
+
+from evennia.server.sessionhandler import SESSIONS
+
+from django.conf import settings
+from django.db import connection
+from django.db.utils import OperationalError
+from django.utils.translation import gettext as _
+
+from evennia.accounts.models import AccountDB
+from evennia.scripts.models import ScriptDB
+from evennia.server.models import ServerConfig
+
+from evennia.utils import logger
+from evennia.utils.utils import get_evennia_version, make_iter, mod_import
+
+
+
+_SA = object.__setattr__
+
+# a file with a flag telling the server to restart after shutdown or not.
+SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", "server.restart")
+
+# modules containing hook methods called during start_stop
+SERVER_STARTSTOP_MODULES = [
+    mod_import(mod)
+    for mod in make_iter(settings.AT_SERVER_STARTSTOP_MODULE)
+    if isinstance(mod, str)
+]
+
+# modules containing plugin services
+SERVER_SERVICES_PLUGIN_MODULES = make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)
+
+
+# ------------------------------------------------------------
+# Evennia Server settings
+# ------------------------------------------------------------
+
+SERVERNAME = settings.SERVERNAME
+VERSION = get_evennia_version()
+
+AMP_ENABLED = True
+AMP_HOST = settings.AMP_HOST
+AMP_PORT = settings.AMP_PORT
+AMP_INTERFACE = settings.AMP_INTERFACE
+
+WEBSERVER_PORTS = settings.WEBSERVER_PORTS
+WEBSERVER_INTERFACES = settings.WEBSERVER_INTERFACES
+
+GUEST_ENABLED = settings.GUEST_ENABLED
+
+# server-channel mappings
+WEBSERVER_ENABLED = settings.WEBSERVER_ENABLED and WEBSERVER_PORTS and WEBSERVER_INTERFACES
+IRC_ENABLED = settings.IRC_ENABLED
+RSS_ENABLED = settings.RSS_ENABLED
+GRAPEVINE_ENABLED = settings.GRAPEVINE_ENABLED
+WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED
+GAME_INDEX_ENABLED = settings.GAME_INDEX_ENABLED
+
+INFO_DICT = {
+    "servername": SERVERNAME,
+    "version": VERSION,
+    "amp": "",
+    "errors": "",
+    "info": "",
+    "webserver": "",
+    "irc_rss": "",
+}
+
+try:
+    WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
+except ImportError:
+    WEB_PLUGINS_MODULE = None
+    INFO_DICT["errors"] = (
+        "WARNING: settings.WEB_PLUGINS_MODULE not found - "
+        "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf."
+    )
+
+# Maintenance function - this is called repeatedly by the server
+
+_IDMAPPER_CACHE_MAXSIZE = settings.IDMAPPER_CACHE_MAXSIZE
+_IDLE_TIMEOUT = settings.IDLE_TIMEOUT
+_LAST_SERVER_TIME_SNAPSHOT = 0
+
+_MAINTENANCE_COUNT = 0
+_FLUSH_CACHE = None
+_GAMETIME_MODULE = None
+_OBJECTDB = None
+
+
+def _server_maintenance():
+    """
+    This maintenance function handles repeated checks and updates that
+    the server needs to do. It is called every minute.
+    """
+    global EVENNIA, _MAINTENANCE_COUNT, _FLUSH_CACHE, _GAMETIME_MODULE
+    global _LAST_SERVER_TIME_SNAPSHOT
+    global _OBJECTDB
+
+    if not _OBJECTDB:
+        from evennia.objects.models import ObjectDB as _OBJECTDB
+    if not _GAMETIME_MODULE:
+        from evennia.utils import gametime as _GAMETIME_MODULE
+    if not _FLUSH_CACHE:
+        from evennia.utils.idmapper.models import conditional_flush as _FLUSH_CACHE
+
+    _MAINTENANCE_COUNT += 1
+
+    now = time.time()
+    if _MAINTENANCE_COUNT == 1:
+        # first call after a reload
+        _GAMETIME_MODULE.SERVER_START_TIME = now
+        _GAMETIME_MODULE.SERVER_RUNTIME = ServerConfig.objects.conf("runtime", default=0.0)
+        _LAST_SERVER_TIME_SNAPSHOT = now
+    else:
+        # adjust the runtime not with 60s but with the actual elapsed time
+        # in case this may varies slightly from 60s.
+        _GAMETIME_MODULE.SERVER_RUNTIME += now - _LAST_SERVER_TIME_SNAPSHOT
+    _LAST_SERVER_TIME_SNAPSHOT = now
+
+    # update game time and save it across reloads
+    _GAMETIME_MODULE.SERVER_RUNTIME_LAST_UPDATED = now
+    ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.SERVER_RUNTIME)
+
+    if _MAINTENANCE_COUNT % 5 == 0:
+        # check cache size every 5 minutes
+        _FLUSH_CACHE(_IDMAPPER_CACHE_MAXSIZE)
+    if _MAINTENANCE_COUNT % (60 * 7) == 0:
+        # drop database connection every 7 hrs to avoid default timeouts on MySQL
+        # (see https://github.com/evennia/evennia/issues/1376)
+        connection.close()
+
+    # handle idle timeouts
+    if _IDLE_TIMEOUT > 0:
+        reason = _("idle timeout exceeded")
+        to_disconnect = []
+        for session in (
+            sess for sess in SESSIONS.values() if (now - sess.cmd_last) > _IDLE_TIMEOUT
+        ):
+            if not session.account or not session.account.access(
+                session.account, "noidletimeout", default=False
+            ):
+                to_disconnect.append(session)
+
+        for session in to_disconnect:
+            SESSIONS.disconnect(session, reason=reason)
+
+    # run unpuppet hooks for objects that are marked as being puppeted,
+    # but which lacks an account (indicates a broken unpuppet operation
+    # such as a server crash)
+    if _MAINTENANCE_COUNT > 1:
+        unpuppet_count = 0
+        for obj in _OBJECTDB.objects.get_by_tag(key="puppeted", category="account"):
+            if not obj.has_account:
+                obj.at_pre_unpuppet()
+                obj.at_post_unpuppet(None, reason=_(" (connection lost)"))
+                obj.tags.remove("puppeted", category="account")
+                unpuppet_count += 1
+        if unpuppet_count:
+            logger.log_msg(f"Ran unpuppet-hooks for {unpuppet_count} link-dead puppets.")
+
+
+# ------------------------------------------------------------
+# Evennia Main Server object
+# ------------------------------------------------------------
+
+
+
[docs]class Evennia: + + """ + The main Evennia server handler. This object sets up the database and + tracks and interlinks all the twisted network services that make up + evennia. + + """ + +
[docs] def __init__(self, application): + """ + Setup the server. + + application - an instantiated Twisted application + + """ + sys.path.insert(1, ".") + + # create a store of services + self.services = service.MultiService() + self.services.setServiceParent(application) + self.amp_protocol = None # set by amp factory + self.sessions = SESSIONS + self.sessions.server = self + self.process_id = os.getpid() + + # Database-specific startup optimizations. + self.sqlite3_prep() + + self.start_time = time.time() + + # wrap the SIGINT handler to make sure we empty the threadpool + # even when we reload and we have long-running requests in queue. + # this is necessary over using Twisted's signal handler. + # (see https://github.com/evennia/evennia/issues/1128) + def _wrap_sigint_handler(*args): + from twisted.internet.defer import Deferred + + if hasattr(self, "web_root"): + d = self.web_root.empty_threadpool() + d.addCallback(lambda _: self.shutdown("reload", _reactor_stopping=True)) + else: + d = Deferred(lambda _: self.shutdown("reload", _reactor_stopping=True)) + d.addCallback(lambda _: reactor.stop()) + reactor.callLater(1, d.callback, None) + + reactor.sigInt = _wrap_sigint_handler
+ + # Server startup methods + +
[docs] def sqlite3_prep(self): + """ + Optimize some SQLite stuff at startup since we + can't save it to the database. + """ + if ( + ".".join(str(i) for i in django.VERSION) < "1.2" + and settings.DATABASES.get("default", {}).get("ENGINE") == "sqlite3" + ) or ( + hasattr(settings, "DATABASES") + and settings.DATABASES.get("default", {}).get("ENGINE", None) + == "django.db.backends.sqlite3" + ): + cursor = connection.cursor() + cursor.execute("PRAGMA cache_size=10000") + cursor.execute("PRAGMA synchronous=OFF") + cursor.execute("PRAGMA count_changes=OFF") + cursor.execute("PRAGMA temp_store=2")
+ +
[docs] def update_defaults(self): + """ + We make sure to store the most important object defaults here, so + we can catch if they change and update them on-objects automatically. + This allows for changing default cmdset locations and default + typeclasses in the settings file and have them auto-update all + already existing objects. + + """ + global INFO_DICT + + # setting names + settings_names = ( + "CMDSET_CHARACTER", + "CMDSET_ACCOUNT", + "BASE_ACCOUNT_TYPECLASS", + "BASE_OBJECT_TYPECLASS", + "BASE_CHARACTER_TYPECLASS", + "BASE_ROOM_TYPECLASS", + "BASE_EXIT_TYPECLASS", + "BASE_SCRIPT_TYPECLASS", + "BASE_CHANNEL_TYPECLASS", + ) + # get previous and current settings so they can be compared + settings_compare = list( + zip( + [ServerConfig.objects.conf(name) for name in settings_names], + [settings.__getattr__(name) for name in settings_names], + ) + ) + mismatches = [ + i for i, tup in enumerate(settings_compare) if tup[0] and tup[1] and tup[0] != tup[1] + ] + if len( + mismatches + ): # can't use any() since mismatches may be [0] which reads as False for any() + # we have a changed default. Import relevant objects and + # run the update + from evennia.comms.models import ChannelDB + from evennia.objects.models import ObjectDB + + # from evennia.accounts.models import AccountDB + for i, prev, curr in ( + (i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches + ): + # update the database + INFO_DICT[ + "info" + ] = " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % ( + settings_names[i], + prev, + curr, + ) + if i == 0: + ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update( + db_cmdset_storage=curr + ) + if i == 1: + AccountDB.objects.filter(db_cmdset_storage__exact=prev).update( + db_cmdset_storage=curr + ) + if i == 2: + AccountDB.objects.filter(db_typeclass_path__exact=prev).update( + db_typeclass_path=curr + ) + if i in (3, 4, 5, 6): + ObjectDB.objects.filter(db_typeclass_path__exact=prev).update( + db_typeclass_path=curr + ) + if i == 7: + ScriptDB.objects.filter(db_typeclass_path__exact=prev).update( + db_typeclass_path=curr + ) + if i == 8: + ChannelDB.objects.filter(db_typeclass_path__exact=prev).update( + db_typeclass_path=curr + ) + # store the new default and clean caches + ServerConfig.objects.conf(settings_names[i], curr) + ObjectDB.flush_instance_cache() + AccountDB.flush_instance_cache() + ScriptDB.flush_instance_cache() + ChannelDB.flush_instance_cache() + # if this is the first start we might not have a "previous" + # setup saved. Store it now. + [ + ServerConfig.objects.conf(settings_names[i], tup[1]) + for i, tup in enumerate(settings_compare) + if not tup[0] + ]
+ +
[docs] def run_initial_setup(self): + """ + This is triggered by the amp protocol when the connection + to the portal has been established. + This attempts to run the initial_setup script of the server. + It returns if this is not the first time the server starts. + Once finished the last_initial_setup_step is set to 'done' + + """ + global INFO_DICT + initial_setup = importlib.import_module(settings.INITIAL_SETUP_MODULE) + last_initial_setup_step = ServerConfig.objects.conf("last_initial_setup_step") + try: + if not last_initial_setup_step: + # None is only returned if the config does not exist, + # i.e. this is an empty DB that needs populating. + INFO_DICT["info"] = " Server started for the first time. Setting defaults." + initial_setup.handle_setup() + elif last_initial_setup_step not in ("done", -1): + # last step crashed, so we weill resume from this step. + # modules and setup will resume from this step, retrying + # the last failed module. When all are finished, the step + # is set to 'done' to show it does not need to be run again. + INFO_DICT["info"] = " Resuming initial setup from step '{last}'.".format( + last=last_initial_setup_step + ) + initial_setup.handle_setup(last_initial_setup_step) + except Exception: + # stop server if this happens. + print(traceback.format_exc()) + print("Error in initial setup. Stopping Server + Portal.") + self.sessions.portal_shutdown()
+ +
[docs] def create_default_channels(self): + """ + check so default channels exist on every restart, create if not. + + """ + + from evennia.accounts.models import AccountDB + from evennia.comms.models import ChannelDB + from evennia.utils.create import create_channel + + superuser = AccountDB.objects.get(id=1) + + # mudinfo + mudinfo_chan = settings.CHANNEL_MUDINFO + if mudinfo_chan and not ChannelDB.objects.filter(db_key__iexact=mudinfo_chan["key"]): + channel = create_channel(**mudinfo_chan) + channel.connect(superuser) + # connectinfo + connectinfo_chan = settings.CHANNEL_CONNECTINFO + if connectinfo_chan and not ChannelDB.objects.filter( + db_key__iexact=connectinfo_chan["key"] + ): + channel = create_channel(**connectinfo_chan) + # default channels + for chan_info in settings.DEFAULT_CHANNELS: + if not ChannelDB.objects.filter(db_key__iexact=chan_info["key"]): + channel = create_channel(**chan_info) + channel.connect(superuser)
+ +
[docs] def run_init_hooks(self, mode): + """ + Called by the amp client once receiving sync back from Portal + + Args: + mode (str): One of shutdown, reload or reset + + """ + from evennia.typeclasses.models import TypedObject + + # start server time and maintenance task + self.maintenance_task = LoopingCall(_server_maintenance) + self.maintenance_task.start(60, now=True) # call every minute + + # update eventual changed defaults + self.update_defaults() + + # run at_init() on all cached entities on reconnect + [ + [entity.at_init() for entity in typeclass_db.get_all_cached_instances()] + for typeclass_db in TypedObject.__subclasses__() + ] + + self.at_server_init() + + # call correct server hook based on start file value + if mode == "reload": + logger.log_msg("Server successfully reloaded.") + self.at_server_reload_start() + elif mode == "reset": + # only run hook, don't purge sessions + self.at_server_cold_start() + logger.log_msg("Evennia Server successfully restarted in 'reset' mode.") + elif mode == "shutdown": + from evennia.objects.models import ObjectDB + + self.at_server_cold_start() + # clear eventual lingering session storages + ObjectDB.objects.clear_all_sessids() + logger.log_msg("Evennia Server successfully started.") + + # always call this regardless of start type + self.at_server_start()
+ +
[docs] @defer.inlineCallbacks + def shutdown(self, mode="reload", _reactor_stopping=False): + """ + Shuts down the server from inside it. + + mode - sets the server restart mode. + - 'reload' - server restarts, no "persistent" scripts + are stopped, at_reload hooks called. + - 'reset' - server restarts, non-persistent scripts stopped, + at_shutdown hooks called but sessions will not + be disconnected. + - 'shutdown' - like reset, but server will not auto-restart. + _reactor_stopping - this is set if server is stopped by a kill + command OR this method was already called + once - in both cases the reactor is + dead/stopping already. + """ + if _reactor_stopping and hasattr(self, "shutdown_complete"): + # this means we have already passed through this method + # once; we don't need to run the shutdown procedure again. + defer.returnValue(None) + + from evennia.objects.models import ObjectDB + from evennia.server.models import ServerConfig + from evennia.utils import gametime as _GAMETIME_MODULE + + if mode == "reload": + # call restart hooks + ServerConfig.objects.conf("server_restart_mode", "reload") + yield [o.at_server_reload() for o in ObjectDB.get_all_cached_instances()] + yield [p.at_server_reload() for p in AccountDB.get_all_cached_instances()] + yield [ + (s._pause_task(auto_pause=True), s.at_server_reload()) + for s in ScriptDB.get_all_cached_instances() + if s.id and s.is_active + ] + yield self.sessions.all_sessions_portal_sync() + self.at_server_reload_stop() + # only save monitor state on reload, not on shutdown/reset + from evennia.scripts.monitorhandler import MONITOR_HANDLER + + MONITOR_HANDLER.save() + else: + if mode == "reset": + # like shutdown but don't unset the is_connected flag and don't disconnect sessions + yield [o.at_server_shutdown() for o in ObjectDB.get_all_cached_instances()] + yield [p.at_server_shutdown() for p in AccountDB.get_all_cached_instances()] + if self.amp_protocol: + yield self.sessions.all_sessions_portal_sync() + else: # shutdown + yield [_SA(p, "is_connected", False) for p in AccountDB.get_all_cached_instances()] + yield [o.at_server_shutdown() for o in ObjectDB.get_all_cached_instances()] + yield [ + (p.unpuppet_all(), p.at_server_shutdown()) + for p in AccountDB.get_all_cached_instances() + ] + yield ObjectDB.objects.clear_all_sessids() + yield [ + (s._pause_task(auto_pause=True), s.at_server_shutdown()) + for s in ScriptDB.get_all_cached_instances() + if s.id and s.is_active + ] + ServerConfig.objects.conf("server_restart_mode", "reset") + self.at_server_cold_stop() + + # tickerhandler state should always be saved. + from evennia.scripts.tickerhandler import TICKER_HANDLER + + TICKER_HANDLER.save() + + # always called, also for a reload + self.at_server_stop() + + if hasattr(self, "web_root"): # not set very first start + yield self.web_root.empty_threadpool() + + if not _reactor_stopping: + # kill the server + self.shutdown_complete = True + reactor.callLater(1, reactor.stop) + + # we make sure the proper gametime is saved as late as possible + ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime())
+ +
[docs] def get_info_dict(self): + """ + Return the server info, for display. + + """ + return INFO_DICT
+ + # server start/stop hooks + +
[docs] def at_server_init(self): + """ + This is called first when the server is starting, before any other hooks, regardless of how it's starting. + """ + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_init"): + mod.at_server_init()
+ +
[docs] def at_server_start(self): + """ + This is called every time the server starts up, regardless of + how it was shut down. + + """ + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_start"): + mod.at_server_start()
+ +
[docs] def at_server_stop(self): + """ + This is called just before a server is shut down, regardless + of it is fore a reload, reset or shutdown. + + """ + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_stop"): + mod.at_server_stop()
+ +
[docs] def at_server_reload_start(self): + """ + This is called only when server starts back up after a reload. + + """ + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_reload_start"): + mod.at_server_reload_start()
+ +
[docs] def at_post_portal_sync(self, mode): + """ + This is called just after the portal has finished syncing back data to the server + after reconnecting. + + Args: + mode (str): One of 'reload', 'reset' or 'shutdown'. + + """ + + from evennia.scripts.monitorhandler import MONITOR_HANDLER + + MONITOR_HANDLER.restore(mode == "reload") + + from evennia.scripts.tickerhandler import TICKER_HANDLER + + TICKER_HANDLER.restore(mode == "reload") + + # Un-pause all scripts, stop non-persistent timers + ScriptDB.objects.update_scripts_after_server_start() + + # start the task handler + from evennia.scripts.taskhandler import TASK_HANDLER + + TASK_HANDLER.load() + TASK_HANDLER.create_delays() + + # create/update channels + self.create_default_channels() + + # delete the temporary setting + ServerConfig.objects.conf("server_restart_mode", delete=True)
+ +
[docs] def at_server_reload_stop(self): + """ + This is called only time the server stops before a reload. + + """ + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_reload_stop"): + mod.at_server_reload_stop()
+ +
[docs] def at_server_cold_start(self): + """ + This is called only when the server starts "cold", i.e. after a + shutdown or a reset. + + """ + # We need to do this just in case the server was killed in a way where + # the normal cleanup operations did not have time to run. + from evennia.objects.models import ObjectDB + + ObjectDB.objects.clear_all_sessids() + + # Remove non-persistent scripts + from evennia.scripts.models import ScriptDB + + for script in ScriptDB.objects.filter(db_persistent=False): + script._stop_task() + + if GUEST_ENABLED: + for guest in AccountDB.objects.all().filter( + db_typeclass_path=settings.BASE_GUEST_TYPECLASS + ): + for character in guest.db._playable_characters: + if character: + character.delete() + guest.delete() + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_cold_start"): + mod.at_server_cold_start()
+ +
[docs] def at_server_cold_stop(self): + """ + This is called only when the server goes down due to a shutdown or reset. + + """ + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_cold_stop"): + mod.at_server_cold_stop()
+ + +# ------------------------------------------------------------ +# +# Start the Evennia game server and add all active services +# +# ------------------------------------------------------------ + + +# Tell the system the server is starting up; some things are not available yet +try: + ServerConfig.objects.conf("server_starting_mode", True) +except OperationalError: + print("Server server_starting_mode couldn't be set - database not set up.") + + +# twistd requires us to define the variable 'application' so it knows +# what to execute from. +application = service.Application("Evennia") + + +if "--nodaemon" not in sys.argv and "test" not in sys.argv: + # activate logging for interactive/testing mode + logfile = logger.WeeklyLogFile( + os.path.basename(settings.SERVER_LOG_FILE), + os.path.dirname(settings.SERVER_LOG_FILE), + day_rotation=settings.SERVER_LOG_DAY_ROTATION, + max_size=settings.SERVER_LOG_MAX_SIZE, + ) + globalLogPublisher.addObserver(logger.GetServerLogObserver()(logfile)) + + +# The main evennia server program. This sets up the database +# and is where we store all the other services. +EVENNIA = Evennia(application) + +if AMP_ENABLED: + + # The AMP protocol handles the communication between + # the portal and the mud server. Only reason to ever deactivate + # it would be during testing and debugging. + + ifacestr = "" + if AMP_INTERFACE != "127.0.0.1": + ifacestr = "-%s" % AMP_INTERFACE + + INFO_DICT["amp"] = "amp %s: %s" % (ifacestr, AMP_PORT) + + from evennia.server import amp_client + + factory = amp_client.AMPClientFactory(EVENNIA) + amp_service = internet.TCPClient(AMP_HOST, AMP_PORT, factory) + amp_service.setName("ServerAMPClient") + EVENNIA.services.addService(amp_service) + +if WEBSERVER_ENABLED: + + # Start a django-compatible webserver. + + from evennia.server.webserver import ( + DjangoWebRoot, + LockableThreadPool, + PrivateStaticRoot, + Website, + WSGIWebServer, + ) + + # start a thread pool and define the root url (/) as a wsgi resource + # recognized by Django + threads = LockableThreadPool( + minthreads=max(1, settings.WEBSERVER_THREADPOOL_LIMITS[0]), + maxthreads=max(1, settings.WEBSERVER_THREADPOOL_LIMITS[1]), + ) + + web_root = DjangoWebRoot(threads) + # point our media resources to url /media + web_root.putChild(b"media", PrivateStaticRoot(settings.MEDIA_ROOT)) + # point our static resources to url /static + web_root.putChild(b"static", PrivateStaticRoot(settings.STATIC_ROOT)) + EVENNIA.web_root = web_root + + if WEB_PLUGINS_MODULE: + # custom overloads + web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root) + + web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE) + web_site.is_portal = False + + INFO_DICT["webserver"] = "" + for proxyport, serverport in WEBSERVER_PORTS: + # create the webserver (we only need the port for this) + webserver = WSGIWebServer(threads, serverport, web_site, interface="127.0.0.1") + webserver.setName("EvenniaWebServer%s" % serverport) + EVENNIA.services.addService(webserver) + + INFO_DICT["webserver"] += "webserver: %s" % serverport + +ENABLED = [] +if IRC_ENABLED: + # IRC channel connections + ENABLED.append("irc") + +if RSS_ENABLED: + # RSS feed channel connections + ENABLED.append("rss") + +if GRAPEVINE_ENABLED: + # Grapevine channel connections + ENABLED.append("grapevine") + +if GAME_INDEX_ENABLED: + from evennia.server.game_index_client.service import EvenniaGameIndexService + + egi_service = EvenniaGameIndexService() + EVENNIA.services.addService(egi_service) + +if ENABLED: + INFO_DICT["irc_rss"] = ", ".join(ENABLED) + " enabled." + +for plugin_module in SERVER_SERVICES_PLUGIN_MODULES: + # external plugin protocols - load here + plugin_module = mod_import(plugin_module) + if plugin_module: + plugin_module.start_plugin_services(EVENNIA) + else: + print(f"Could not load plugin module {plugin_module}") + +# clear server startup mode +try: + ServerConfig.objects.conf("server_starting_mode", delete=True) +except OperationalError: + print("Server server_starting_mode couldn't unset - db not set up.") +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/serversession.html b/docs/latest/_modules/evennia/server/serversession.html new file mode 100644 index 0000000000..62010eb4af --- /dev/null +++ b/docs/latest/_modules/evennia/server/serversession.html @@ -0,0 +1,599 @@ + + + + + + + + evennia.server.serversession — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.serversession

+"""
+This defines a the Server's generic session object. This object represents
+a connection to the outside world but don't know any details about how the
+connection actually happens (so it's the same for telnet, web, ssh etc).
+
+It is stored on the Server side (as opposed to protocol-specific sessions which
+are stored on the Portal side)
+"""
+import time
+
+from django.conf import settings
+from django.utils import timezone
+from evennia.commands.cmdsethandler import CmdSetHandler
+from evennia.comms.models import ChannelDB
+from evennia.scripts.monitorhandler import MONITOR_HANDLER
+from evennia.typeclasses.attributes import AttributeHandler, DbHolder, InMemoryAttributeBackend
+from evennia.utils import logger
+from evennia.utils.utils import class_from_module, lazy_property, make_iter
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+_ObjectDB = None
+_ANSI = None
+
+_BASE_SESSION_CLASS = class_from_module(settings.BASE_SESSION_CLASS)
+
+
+# -------------------------------------------------------------
+# Server Session
+# -------------------------------------------------------------
+
+
+
[docs]class ServerSession(_BASE_SESSION_CLASS): + """ + This class represents an account's session and is a template for + individual protocols to communicate with Evennia. + + Each account gets a session assigned to them whenever they connect + to the game server. All communication between game and account goes + through their session. + + """ + + # Determines which order command sets begin to be assembled from. + # Sessions are usually first. + cmdset_provider_order = 0 + cmdset_provider_error_order = 50 + cmdset_provider_type = "session" + +
[docs] def __init__(self): + """ + Initiate to avoid AttributeErrors down the line + + """ + self.puppet = None + self.account = None + self.cmdset_storage_string = "" + self.cmdset = CmdSetHandler(self, True)
+ + def __cmdset_storage_get(self): + return [path.strip() for path in self.cmdset_storage_string.split(",")] + + def __cmdset_storage_set(self, value): + self.cmdset_storage_string = ",".join(str(val).strip() for val in make_iter(value)) + + cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set) + +
[docs] def get_cmdset_providers(self) -> dict[str, "CmdSetProvider"]: + """ + Overrideable method which returns a dictionary of every kind of object which + has a cmdsethandler linked to this ServerSession, and should participate in cmdset + merging. + + In all normal cases, that's the Session itself, and possibly an account and puppeted + object. + + Returns: + dict[str, CmdSetProvider]: The CmdSetProviders linked to this Object. + """ + out = {"session": self} + if self.account: + out["account"] = self.account + if self.puppet: + out["object"] = self.puppet + return out
+ + @property + def id(self): + return self.sessid + +
[docs] def at_sync(self): + """ + This is called whenever a session has been resynced with the + portal. At this point all relevant attributes have already + been set and self.account been assigned (if applicable). + + Since this is often called after a server restart we need to + set up the session as it was. + + """ + global _ObjectDB + if not _ObjectDB: + from evennia.objects.models import ObjectDB as _ObjectDB + + super().at_sync() + if not self.logged_in: + # assign the unloggedin-command set. + self.cmdset_storage = settings.CMDSET_UNLOGGEDIN + + self.cmdset.update(init_mode=True) + + if self.puid: + # reconnect puppet (puid is only set if we are coming + # back from a server reload). This does all the steps + # done in the default @ic command but without any + # hooks, echoes or access checks. + obj = _ObjectDB.objects.get(id=self.puid) + obj.sessions.add(self) + obj.account = self.account + self.puid = obj.id + self.puppet = obj + # obj.scripts.validate() + obj.locks.cache_lock_bypass(obj)
+ +
[docs] def at_login(self, account): + """ + Hook called by sessionhandler when the session becomes authenticated. + + Args: + account (Account): The account associated with the session. + + """ + self.account = account + self.uid = self.account.id + self.uname = self.account.username + self.logged_in = True + self.conn_time = time.time() + self.puid = None + self.puppet = None + self.cmdset_storage = settings.CMDSET_SESSION + + # Update account's last login time. + self.account.last_login = timezone.now() + self.account.save() + + # add the session-level cmdset + self.cmdset = CmdSetHandler(self, True)
+ +
[docs] def at_disconnect(self, reason=None): + """ + Hook called by sessionhandler when disconnecting this session. + + """ + if self.logged_in: + account = self.account + if self.puppet: + account.unpuppet_object(self) + uaccount = account + uaccount.last_login = timezone.now() + uaccount.save() + # calling account hook + account.at_disconnect(reason) + self.logged_in = False + if not self.sessionhandler.sessions_from_account(account): + # no more sessions connected to this account + account.is_connected = False + # this may be used to e.g. delete account after disconnection etc + account.at_post_disconnect() + # remove any webclient settings monitors associated with this + # session + MONITOR_HANDLER.remove(account, "_saved_webclient_options", self.sessid)
+ +
[docs] def get_account(self): + """ + Get the account associated with this session + + Returns: + account (Account or None): The associated Account. + + """ + return self.account if self.logged_in else None
+ +
[docs] def get_puppet(self): + """ + Get the in-game character associated with this session. + + Returns: + puppet (Object or None): The puppeted object, if any. + + """ + return self.puppet if self.logged_in else None
+ + get_character = get_puppet + +
[docs] def get_puppet_or_account(self): + """ + Get puppet or account. + + Returns: + controller (Object or Account): The puppet if one exists, + otherwise return the account. + + """ + if self.logged_in: + return self.puppet if self.puppet else self.account + return None
+ +
[docs] def log(self, message, channel=True): + """ + Emits session info to the appropriate outputs and info channels. + + Args: + message (str): The message to log. + channel (bool, optional): Log to the CHANNEL_CONNECTINFO channel + in addition to the server log. + + """ + cchan = channel and settings.CHANNEL_CONNECTINFO + if cchan: + try: + cchan = ChannelDB.objects.get_channel(cchan["key"]) + cchan.msg("[%s]: %s" % (cchan.key, message)) + except Exception: + logger.log_trace() + logger.log_info(message)
+ +
[docs] def get_client_size(self): + """ + Return eventual eventual width and height reported by the + client. Note that this currently only deals with a single + client window (windowID==0) as in a traditional telnet session. + + """ + flags = self.protocol_flags + # print("session flags:", flags) + width = flags.get("SCREENWIDTH", {}).get(0, settings.CLIENT_DEFAULT_WIDTH) + height = flags.get("SCREENHEIGHT", {}).get(0, settings.CLIENT_DEFAULT_HEIGHT) + return width, height
+ +
[docs] def update_session_counters(self, idle=False): + """ + Hit this when the user enters a command in order to update + idle timers and command counters. + + """ + # Idle time used for timeout calcs. + self.cmd_last = time.time() + + # Store the timestamp of the user's last command. + if not idle: + # Increment the user's command counter. + self.cmd_total += 1 + # Account-visible idle time, not used in idle timeout calcs. + self.cmd_last_visible = self.cmd_last
+ +
[docs] def update_flags(self, **kwargs): + """ + Update the protocol_flags and sync them with Portal. + + Keyword Args: + protocol_flag (any): A key and value to set in the + protocol_flags dictionary. + + Notes: + Since protocols can vary, no checking is done + as to the existene of the flag or not. The input + data should have been validated before this call. + + """ + if kwargs: + self.protocol_flags.update(kwargs) + self.sessionhandler.session_portal_sync(self)
+ +
[docs] def data_out(self, **kwargs): + """ + Sending data from Evennia->Client + + Keyword Args: + text (str or tuple) + any (str or tuple): Send-commands identified + by their keys. Or "options", carrying options + for the protocol(s). + + """ + self.sessionhandler.data_out(self, **kwargs)
+ +
[docs] def data_in(self, **kwargs): + """ + Receiving data from the client, sending it off to + the respective inputfuncs. + + Keyword Args: + kwargs (any): Incoming data from protocol on + the form `{"commandname": ((args), {kwargs}),...}` + Notes: + This method is here in order to give the user + a single place to catch and possibly process all incoming data from + the client. It should usually always end by sending + this data off to `self.sessionhandler.call_inputfuncs(self, **kwargs)`. + """ + self.sessionhandler.call_inputfuncs(self, **kwargs)
+ +
[docs] def msg(self, text=None, **kwargs): + """ + Wrapper to mimic msg() functionality of Objects and Accounts. + + Args: + text (str): String input. + + Keyword Args: + any (str or tuple): Send-commands identified + by their keys. Or "options", carrying options + for the protocol(s). + + """ + # this can happen if this is triggered e.g. a command.msg + # that auto-adds the session, we'd get a kwarg collision. + kwargs.pop("session", None) + kwargs.pop("from_obj", None) + if text is not None: + self.data_out(text=text, **kwargs) + else: + self.data_out(**kwargs)
+ +
[docs] def execute_cmd(self, raw_string, session=None, **kwargs): + """ + Do something as this object. This method is normally never + called directly, instead incoming command instructions are + sent to the appropriate inputfunc already at the sessionhandler + level. This method allows Python code to inject commands into + this stream, and will lead to the text inputfunc be called. + + Args: + raw_string (string): Raw command input + session (Session): This is here to make API consistent with + Account/Object.execute_cmd. If given, data is passed to + that Session, otherwise use self. + Keyword Args: + Other keyword arguments will be added to the found command + object instace as variables before it executes. This is + unused by default Evennia but may be used to set flags and + change operating paramaters for commands at run-time. + + """ + # inject instruction into input stream + kwargs["text"] = ((raw_string,), {}) + self.sessionhandler.data_in(session or self, **kwargs)
+ + def __eq__(self, other): + """ + Handle session comparisons + + """ + try: + return self.address == other.address + except AttributeError: + return False + + def __hash__(self): + """ + Python 3 requires that any class which implements __eq__ must also + implement __hash__ and that the corresponding hashes for equivalent + instances are themselves equivalent. + + """ + return hash(self.address) + + def __ne__(self, other): + try: + return self.address != other.address + except AttributeError: + return True + + def __str__(self): + """ + String representation of the user session class. We use + this a lot in the server logs. + + """ + symbol = "" + if self.logged_in and hasattr(self, "account") and self.account: + symbol = "(#%s)" % self.account.id + try: + if hasattr(self.address, "__iter__"): + address = ":".join([str(part) for part in self.address]) + else: + address = self.address + except Exception: + address = self.address + return "%s%s@%s" % (self.uname, symbol, address) + + def __repr__(self): + return "%s" % str(self) + + # Dummy API hooks for use during non-loggedin operation + +
[docs] def at_cmdset_get(self, **kwargs): + """ + Called just before cmdsets on this object are requested by the + command handler. If changes need to be done on the fly to the + cmdset before passing them on to the cmdhandler, this is the + place to do it. This is called also if the object currently + have no cmdsets. + + Keyword Args: + caller (Object, Account or Session): The object requesting the cmdsets. + current (CmdSet): The current merged cmdset. + force_init (bool): If `True`, force a re-build of the cmdset. (seems unused) + **kwargs: Arbitrary input for overloads. + + """ + pass
+ +
[docs] def get_cmdsets(self, caller, current, **kwargs): + """ + Called by the CommandHandler to get a list of cmdsets to merge. + + Args: + caller (obj): The object requesting the cmdsets. + current (cmdset): The current merged cmdset. + **kwargs: Arbitrary input for overloads. + + Returns: + tuple: A tuple of (current, cmdsets), which is probably self.cmdset.current and self.cmdset.cmdset_stack + """ + return self.cmdset.current, list(self.cmdset.cmdset_stack)
+ + # Mock db/ndb properties for allowing easy storage on the session + # (note that no databse is involved at all here. session.db.attr = + # value just saves a normal property in memory, just like ndb). + +
[docs] @lazy_property + def nattributes(self): + return AttributeHandler(self, InMemoryAttributeBackend)
+ +
[docs] @lazy_property + def attributes(self): + return self.nattributes
+ + # @property +
[docs] def ndb_get(self): + """ + A non-persistent store (ndb: NonDataBase). Everything stored + to this is guaranteed to be cleared when a server is shutdown. + Syntax is same as for the _get_db_holder() method and + property, e.g. obj.ndb.attr = value etc. + + """ + try: + return self._ndb_holder + except AttributeError: + self._ndb_holder = DbHolder(self, "nattrhandler", manager_name="nattributes") + return self._ndb_holder
+ + # @ndb.setter +
[docs] def ndb_set(self, value): + """ + Stop accidentally replacing the db object + + Args: + value (any): A value to store in the ndb. + + """ + string = "Cannot assign directly to ndb object! " + string += "Use ndb.attr=value instead." + raise Exception(string)
+ + # @ndb.deleter +
[docs] def ndb_del(self): + """ + Stop accidental deletion. + + """ + raise Exception("Cannot delete the ndb object!")
+ + ndb = property(ndb_get, ndb_set, ndb_del) + db = property(ndb_get, ndb_set, ndb_del) + + # Mock access method for the session (there is no lock info + # at this stage, so we just present a uniform API) +
[docs] def access(self, *args, **kwargs): + """ + Dummy method to mimic the logged-in API. + + """ + return True
+ +
[docs] def get_display_name(self, *args, **kwargs): + if self.puppet: + return self.puppet.get_display_name(*args, **kwargs) + elif self.account: + return self.account.get_display_name(*args, **kwargs) + else: + return f"{self.protocol_key}({self.address})"
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/service.html b/docs/latest/_modules/evennia/server/service.html new file mode 100644 index 0000000000..47f2cf9235 --- /dev/null +++ b/docs/latest/_modules/evennia/server/service.html @@ -0,0 +1,795 @@ + + + + + + + + evennia.server.service — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.service

+"""
+This module contains the main EvenniaService class, which is the very core of the
+Evennia server. It is instantiated by the evennia/server/server.py module.
+"""
+import importlib
+import time
+import traceback
+
+import django
+import evennia
+from django.conf import settings
+from django.db import connection
+from django.db.utils import OperationalError
+from django.utils.translation import gettext as _
+from evennia.utils import logger
+from evennia.utils.utils import get_evennia_version, make_iter, mod_import
+from twisted.application import internet
+from twisted.application.service import MultiService
+from twisted.internet import defer, reactor
+from twisted.internet.defer import Deferred
+from twisted.internet.task import LoopingCall
+
+_SA = object.__setattr__
+
+
+
[docs]class EvenniaServerService(MultiService): + def _wrap_sigint_handler(self, *args): + if hasattr(self, "web_root"): + d = self.web_root.empty_threadpool() + d.addCallback(lambda _: self.shutdown("reload", _reactor_stopping=True)) + else: + d = Deferred(lambda _: self.shutdown("reload", _reactor_stopping=True)) + d.addCallback(lambda _: reactor.stop()) + reactor.callLater(1, d.callback, None) + +
[docs] def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.maintenance_count = 0 + self.amp_protocol = None # set by amp factory + self.amp_service = None + self.info_dict = { + "servername": settings.SERVERNAME, + "version": get_evennia_version(), + "amp": "", + "errors": "", + "info": "", + "webserver": "", + "irc_rss": "", + } + self._flush_cache = None + self._last_server_time_snapshot = 0 + self.maintenance_task = None + + # Database-specific startup optimizations. + self.sqlite3_prep() + + self.start_time = 0 + + # wrap the SIGINT handler to make sure we empty the threadpool + # even when we reload and we have long-running requests in queue. + # this is necessary over using Twisted's signal handler. + # (see https://github.com/evennia/evennia/issues/1128) + + reactor.sigInt = self._wrap_sigint_handler + + self.start_stop_modules = [ + mod_import(mod) + for mod in make_iter(settings.AT_SERVER_STARTSTOP_MODULE) + if isinstance(mod, str) + ]
+ +
[docs] def server_maintenance(self): + """ + This maintenance function handles repeated checks and updates that + the server needs to do. It is called every minute. + """ + if not self._flush_cache: + from evennia.utils.idmapper.models import conditional_flush as _FLUSH_CACHE + + self._flush_cache = _FLUSH_CACHE + + self.maintenance_count += 1 + + now = time.time() + if self.maintenance_count == 1: + # first call after a reload + evennia.gametime.SERVER_START_TIME = now + evennia.gametime.SERVER_RUNTIME = evennia.ServerConfig.objects.conf( + "runtime", default=0.0 + ) + _LAST_SERVER_TIME_SNAPSHOT = now + else: + # adjust the runtime not with 60s but with the actual elapsed time + # in case this may varies slightly from 60s. + evennia.gametime.SERVER_RUNTIME += now - self._last_server_time_snapshot + self._last_server_time_snapshot = now + + # update game time and save it across reloads + evennia.gametime.SERVER_RUNTIME_LAST_UPDATED = now + evennia.ServerConfig.objects.conf("runtime", evennia.gametime.SERVER_RUNTIME) + + if self.maintenance_count % 5 == 0: + # check cache size every 5 minutes + self._flush_cache(settings.IDMAPPER_CACHE_MAXSIZE) + if self.maintenance_count % (60 * 7) == 0: + # drop database connection every 7 hrs to avoid default timeouts on MySQL + # (see https://github.com/evennia/evennia/issues/1376) + connection.close() + + self.process_idle_timeouts() + + # run unpuppet hooks for objects that are marked as being puppeted, + # but which lacks an account (indicates a broken unpuppet operation + # such as a server crash) + if self.maintenance_count > 1: + unpuppet_count = 0 + for obj in evennia.ObjectDB.objects.get_by_tag(key="puppeted", category="account"): + if not obj.has_account: + obj.at_pre_unpuppet() + obj.at_post_unpuppet(None, reason=_(" (connection lost)")) + obj.tags.remove("puppeted", category="account") + unpuppet_count += 1 + if unpuppet_count: + logger.log_msg(f"Ran unpuppet-hooks for {unpuppet_count} link-dead puppets.")
+ +
[docs] def process_idle_timeouts(self): + # handle idle timeouts + if settings.IDLE_TIMEOUT > 0: + now = time.time() + reason = _("idle timeout exceeded") + to_disconnect = [] + for session in ( + sess + for sess in evennia.SESSION_HANDLER.values() + if (now - sess.cmd_last) > settings.IDLE_TIMEOUT + ): + if not session.account or not session.account.access( + session.account, "noidletimeout", default=False + ): + to_disconnect.append(session) + + for session in to_disconnect: + evennia.SESSION_HANDLER.disconnect(session, reason=reason)
+ + # Server startup methods +
[docs] def privilegedStartService(self): + self.start_time = time.time() + + # Tell the system the server is starting up; some things are not available yet + try: + evennia.ServerConfig.objects.conf("server_starting_mode", True) + except OperationalError: + print("Server server_starting_mode couldn't be set - database not set up.") + + if settings.AMP_ENABLED: + self.register_amp() + + if settings.WEBSERVER_ENABLED: + self.register_webserver() + + ENABLED = [] + if settings.IRC_ENABLED: + # IRC channel connections + ENABLED.append("irc") + + if settings.RSS_ENABLED: + # RSS feed channel connections + ENABLED.append("rss") + + if settings.GRAPEVINE_ENABLED: + # Grapevine channel connections + ENABLED.append("grapevine") + + if settings.GAME_INDEX_ENABLED: + from evennia.server.game_index_client.service import EvenniaGameIndexService + + egi_service = EvenniaGameIndexService() + egi_service.setServiceParent(self) + + if ENABLED: + self.info_dict["irc_rss"] = ", ".join(ENABLED) + " enabled." + + self.register_plugins() + + super().privilegedStartService() + + # clear server startup mode + try: + evennia.ServerConfig.objects.conf("server_starting_mode", delete=True) + except OperationalError: + print("Server server_starting_mode couldn't unset - db not set up.")
+ +
[docs] def register_plugins(self): + SERVER_SERVICES_PLUGIN_MODULES = make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES) + for plugin_module in SERVER_SERVICES_PLUGIN_MODULES: + # external plugin protocols - load here + plugin_module = mod_import(plugin_module) + if plugin_module: + plugin_module.start_plugin_services(self) + else: + print(f"Could not load plugin module {plugin_module}")
+ +
[docs] def register_amp(self): + # The AMP protocol handles the communication between + # the portal and the mud server. Only reason to ever deactivate + # it would be during testing and debugging. + + ifacestr = "" + if settings.AMP_INTERFACE != "127.0.0.1": + ifacestr = "-%s" % settings.AMP_INTERFACE + + self.info_dict["amp"] = "amp %s: %s" % (ifacestr, settings.AMP_PORT) + + from evennia.server import amp_client + + factory = amp_client.AMPClientFactory(self) + self.amp_service = internet.TCPClient(settings.AMP_HOST, settings.AMP_PORT, factory) + self.amp_service.setName("ServerAMPClient") + self.amp_service.setServiceParent(self)
+ +
[docs] def register_webserver(self): + # Start a django-compatible webserver. + + from evennia.server.webserver import ( + DjangoWebRoot, + LockableThreadPool, + PrivateStaticRoot, + Website, + WSGIWebServer, + ) + + # start a thread pool and define the root url (/) as a wsgi resource + # recognized by Django + threads = LockableThreadPool( + minthreads=max(1, settings.WEBSERVER_THREADPOOL_LIMITS[0]), + maxthreads=max(1, settings.WEBSERVER_THREADPOOL_LIMITS[1]), + ) + + web_root = DjangoWebRoot(threads) + # point our media resources to url /media + web_root.putChild(b"media", PrivateStaticRoot(settings.MEDIA_ROOT)) + # point our static resources to url /static + web_root.putChild(b"static", PrivateStaticRoot(settings.STATIC_ROOT)) + self.web_root = web_root + + try: + WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) + except ImportError: + WEB_PLUGINS_MODULE = None + self.info_dict["errors"] = ( + "WARNING: settings.WEB_PLUGINS_MODULE not found - " + "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf." + ) + + if WEB_PLUGINS_MODULE: + # custom overloads + web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root) + + web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE) + web_site.is_portal = False + + self.info_dict["webserver"] = "" + for proxyport, serverport in settings.WEBSERVER_PORTS: + # create the webserver (we only need the port for this) + webserver = WSGIWebServer(threads, serverport, web_site, interface="127.0.0.1") + webserver.setName("EvenniaWebServer%s" % serverport) + webserver.setServiceParent(self) + + self.info_dict["webserver"] += "webserver: %s" % serverport
+ +
[docs] def sqlite3_prep(self): + """ + Optimize some SQLite stuff at startup since we + can't save it to the database. + """ + if ( + ".".join(str(i) for i in django.VERSION) < "1.2" + and settings.DATABASES.get("default", {}).get("ENGINE") == "sqlite3" + ) or ( + hasattr(settings, "DATABASES") + and settings.DATABASES.get("default", {}).get("ENGINE", None) + == "django.db.backends.sqlite3" + ): + cursor = connection.cursor() + cursor.execute("PRAGMA cache_size=10000") + cursor.execute("PRAGMA synchronous=OFF") + cursor.execute("PRAGMA count_changes=OFF") + cursor.execute("PRAGMA temp_store=2")
+ +
[docs] def update_defaults(self): + """ + We make sure to store the most important object defaults here, so + we can catch if they change and update them on-objects automatically. + This allows for changing default cmdset locations and default + typeclasses in the settings file and have them auto-update all + already existing objects. + + """ + + # setting names + settings_names = ( + "CMDSET_CHARACTER", + "CMDSET_ACCOUNT", + "BASE_ACCOUNT_TYPECLASS", + "BASE_OBJECT_TYPECLASS", + "BASE_CHARACTER_TYPECLASS", + "BASE_ROOM_TYPECLASS", + "BASE_EXIT_TYPECLASS", + "BASE_SCRIPT_TYPECLASS", + "BASE_CHANNEL_TYPECLASS", + ) + # get previous and current settings so they can be compared + settings_compare = list( + zip( + [evennia.ServerConfig.objects.conf(name) for name in settings_names], + [settings.__getattr__(name) for name in settings_names], + ) + ) + mismatches = [ + i for i, tup in enumerate(settings_compare) if tup[0] and tup[1] and tup[0] != tup[1] + ] + if len( + mismatches + ): # can't use any() since mismatches may be [0] which reads as False for any() + # we have a changed default. Import relevant objects and + # run the update + + # from evennia.accounts.models import AccountDB + for i, prev, curr in ( + (i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches + ): + # update the database + self.info_dict[ + "info" + ] = " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % ( + settings_names[i], + prev, + curr, + ) + if i == 0: + evennia.ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update( + db_cmdset_storage=curr + ) + if i == 1: + evennia.AccountDB.objects.filter(db_cmdset_storage__exact=prev).update( + db_cmdset_storage=curr + ) + if i == 2: + evennia.AccountDB.objects.filter(db_typeclass_path__exact=prev).update( + db_typeclass_path=curr + ) + if i in (3, 4, 5, 6): + evennia.ObjectDB.objects.filter(db_typeclass_path__exact=prev).update( + db_typeclass_path=curr + ) + if i == 7: + evennia.ScriptDB.objects.filter(db_typeclass_path__exact=prev).update( + db_typeclass_path=curr + ) + if i == 8: + evennia.ChannelDB.objects.filter(db_typeclass_path__exact=prev).update( + db_typeclass_path=curr + ) + # store the new default and clean caches + evennia.ServerConfig.objects.conf(settings_names[i], curr) + evennia.ObjectDB.flush_instance_cache() + evennia.AccountDB.flush_instance_cache() + evennia.ScriptDB.flush_instance_cache() + evennia.ChannelDB.flush_instance_cache() + # if this is the first start we might not have a "previous" + # setup saved. Store it now. + [ + evennia.ServerConfig.objects.conf(settings_names[i], tup[1]) + for i, tup in enumerate(settings_compare) + if not tup[0] + ]
+ +
[docs] def run_initial_setup(self): + """ + This is triggered by the amp protocol when the connection + to the portal has been established. + This attempts to run the initial_setup script of the server. + It returns if this is not the first time the server starts. + Once finished the last_initial_setup_step is set to 'done' + + """ + + initial_setup = importlib.import_module(settings.INITIAL_SETUP_MODULE) + last_initial_setup_step = evennia.ServerConfig.objects.conf("last_initial_setup_step") + try: + if not last_initial_setup_step: + # None is only returned if the config does not exist, + # i.e. this is an empty DB that needs populating. + self.info_dict["info"] = " Server started for the first time. Setting defaults." + initial_setup.handle_setup() + elif last_initial_setup_step not in ("done", -1): + # last step crashed, so we weill resume from this step. + # modules and setup will resume from this step, retrying + # the last failed module. When all are finished, the step + # is set to 'done' to show it does not need to be run again. + self.info_dict["info"] = " Resuming initial setup from step '{last}'.".format( + last=last_initial_setup_step + ) + initial_setup.handle_setup(last_initial_setup_step) + except Exception: + # stop server if this happens. + print(traceback.format_exc()) + if not settings.TEST_ENVIRONMENT or not evennia.SESSION_HANDLER: + print("Error in initial setup. Stopping Server + Portal.") + evennia.SESSION_HANDLER.portal_shutdown()
+ +
[docs] def create_default_channels(self): + """ + check so default channels exist on every restart, create if not. + + """ + + from evennia import AccountDB, ChannelDB + from evennia.utils.create import create_channel + + superuser = AccountDB.objects.get(id=1) + + # mudinfo + mudinfo_chan = settings.CHANNEL_MUDINFO + if mudinfo_chan and not ChannelDB.objects.filter(db_key__iexact=mudinfo_chan["key"]): + channel = create_channel(**mudinfo_chan) + channel.connect(superuser) + # connectinfo + connectinfo_chan = settings.CHANNEL_CONNECTINFO + if connectinfo_chan and not ChannelDB.objects.filter( + db_key__iexact=connectinfo_chan["key"] + ): + channel = create_channel(**connectinfo_chan) + # default channels + for chan_info in settings.DEFAULT_CHANNELS: + if not ChannelDB.objects.filter(db_key__iexact=chan_info["key"]): + channel = create_channel(**chan_info) + channel.connect(superuser)
+ +
[docs] def run_init_hooks(self, mode): + """ + Called by the amp client once receiving sync back from Portal + + Args: + mode (str): One of shutdown, reload or reset + + """ + from evennia.typeclasses.models import TypedObject + + # start server time and maintenance task + self.maintenance_task = LoopingCall(self.server_maintenance) + self.maintenance_task.start(60, now=True) # call every minute + + # update eventual changed defaults + self.update_defaults() + + # run at_init() on all cached entities on reconnect + [ + [entity.at_init() for entity in typeclass_db.get_all_cached_instances()] + for typeclass_db in TypedObject.__subclasses__() + ] + + self.at_server_init() + + # call correct server hook based on start file value + if mode == "reload": + logger.log_msg("Server successfully reloaded.") + self.at_server_reload_start() + elif mode == "reset": + # only run hook, don't purge sessions + self.at_server_cold_start() + logger.log_msg("Evennia Server successfully restarted in 'reset' mode.") + elif mode == "shutdown": + from evennia.objects.models import ObjectDB + + self.at_server_cold_start() + # clear eventual lingering session storages + ObjectDB.objects.clear_all_sessids() + logger.log_msg("Evennia Server successfully started.") + + # always call this regardless of start type + self.at_server_start()
+ +
[docs] @defer.inlineCallbacks + def shutdown(self, mode="reload", _reactor_stopping=False): + """ + Shuts down the server from inside it. + + mode - sets the server restart mode. + - 'reload' - server restarts, no "persistent" scripts + are stopped, at_reload hooks called. + - 'reset' - server restarts, non-persistent scripts stopped, + at_shutdown hooks called but sessions will not + be disconnected. + - 'shutdown' - like reset, but server will not auto-restart. + _reactor_stopping - this is set if server is stopped by a kill + command OR this method was already called + once - in both cases the reactor is + dead/stopping already. + """ + if _reactor_stopping and hasattr(self, "shutdown_complete"): + # this means we have already passed through this method + # once; we don't need to run the shutdown procedure again. + defer.returnValue(None) + + if mode == "reload": + # call restart hooks + evennia.ServerConfig.objects.conf("server_restart_mode", "reload") + yield [o.at_server_reload() for o in evennia.ObjectDB.get_all_cached_instances()] + yield [p.at_server_reload() for p in evennia.AccountDB.get_all_cached_instances()] + yield [ + (s._pause_task(auto_pause=True) if s.is_active else None, s.at_server_reload()) + for s in evennia.ScriptDB.get_all_cached_instances() + if s.id + ] + yield evennia.SESSION_HANDLER.all_sessions_portal_sync() + self.at_server_reload_stop() + # only save monitor state on reload, not on shutdown/reset + from evennia.scripts.monitorhandler import MONITOR_HANDLER + + MONITOR_HANDLER.save() + else: + if mode == "reset": + # like shutdown but don't unset the is_connected flag and don't disconnect sessions + yield [o.at_server_shutdown() for o in evennia.ObjectDB.get_all_cached_instances()] + yield [p.at_server_shutdown() for p in evennia.AccountDB.get_all_cached_instances()] + if self.amp_protocol: + yield evennia.SESSION_HANDLER.all_sessions_portal_sync() + else: # shutdown + yield [ + _SA(p, "is_connected", False) + for p in evennia.AccountDB.get_all_cached_instances() + ] + yield [o.at_server_shutdown() for o in evennia.ObjectDB.get_all_cached_instances()] + yield [ + (p.unpuppet_all(), p.at_server_shutdown()) + for p in evennia.AccountDB.get_all_cached_instances() + ] + yield evennia.ObjectDB.objects.clear_all_sessids() + yield [ + (s._pause_task(auto_pause=True), s.at_server_shutdown()) + for s in evennia.ScriptDB.get_all_cached_instances() + if s.id and s.is_active + ] + evennia.ServerConfig.objects.conf("server_restart_mode", "reset") + self.at_server_cold_stop() + + # tickerhandler state should always be saved. + from evennia.scripts.tickerhandler import TICKER_HANDLER + + TICKER_HANDLER.save() + + # always called, also for a reload + self.at_server_stop() + + if hasattr(self, "web_root"): # not set very first start + yield self.web_root.empty_threadpool() + + if not _reactor_stopping: + # kill the server + self.shutdown_complete = True + reactor.callLater(1, reactor.stop) + + # we make sure the proper gametime is saved as late as possible + evennia.ServerConfig.objects.conf("runtime", evennia.gametime.runtime())
+ +
[docs] def get_info_dict(self): + """ + Return the server info, for display. + + """ + return self.info_dict
+ + # server start/stop hooks + + def _call_start_stop(self, hookname): + """ + Helper method for calling hooks on all modules. + + Args: + hookname (str): Name of hook to call. + + """ + for mod in self.start_stop_modules: + if hook := getattr(mod, hookname, None): + hook() + +
[docs] def at_server_init(self): + """ + This is called first when the server is starting, before any other hooks, regardless of how it's starting. + """ + self._call_start_stop("at_server_init")
+ +
[docs] def at_server_start(self): + """ + This is called every time the server starts up, regardless of + how it was shut down. + + """ + self._call_start_stop("at_server_start")
+ +
[docs] def at_server_stop(self): + """ + This is called just before a server is shut down, regardless + of it is fore a reload, reset or shutdown. + + """ + self._call_start_stop("at_server_stop")
+ +
[docs] def at_server_reload_start(self): + """ + This is called only when server starts back up after a reload. + + """ + self._call_start_stop("at_server_reload_start")
+ +
[docs] def at_post_portal_sync(self, mode): + """ + This is called just after the portal has finished syncing back data to the server + after reconnecting. + + Args: + mode (str): One of 'reload', 'reset' or 'shutdown'. + + """ + + from evennia.scripts.monitorhandler import MONITOR_HANDLER + + MONITOR_HANDLER.restore(mode == "reload") + + from evennia.scripts.tickerhandler import TICKER_HANDLER + + TICKER_HANDLER.restore(mode == "reload") + + # Un-pause all scripts, stop non-persistent timers + evennia.ScriptDB.objects.update_scripts_after_server_start() + + # start the task handler + from evennia.scripts.taskhandler import TASK_HANDLER + + TASK_HANDLER.load() + TASK_HANDLER.create_delays() + + # create/update channels + self.create_default_channels() + + # delete the temporary setting + evennia.ServerConfig.objects.conf("server_restart_mode", delete=True)
+ +
[docs] def at_server_reload_stop(self): + """ + This is called only time the server stops before a reload. + + """ + self._call_start_stop("at_server_reload_stop")
+ +
[docs] def at_server_cold_start(self): + """ + This is called only when the server starts "cold", i.e. after a + shutdown or a reset. + + """ + # We need to do this just in case the server was killed in a way where + # the normal cleanup operations did not have time to run. + from evennia.objects.models import ObjectDB + + ObjectDB.objects.clear_all_sessids() + + # Remove non-persistent scripts + from evennia.scripts.models import ScriptDB + + for script in ScriptDB.objects.filter(db_persistent=False): + script._stop_task() + + if settings.GUEST_ENABLED: + for guest in evennia.AccountDB.objects.all().filter( + db_typeclass_path=settings.BASE_GUEST_TYPECLASS + ): + for character in guest.db._playable_characters: + if character: + character.delete() + guest.delete() + self._call_start_stop("at_server_cold_start")
+ +
[docs] def at_server_cold_stop(self): + """ + This is called only when the server goes down due to a shutdown or reset. + + """ + self._call_start_stop("at_server_cold_stop")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/session.html b/docs/latest/_modules/evennia/server/session.html new file mode 100644 index 0000000000..87267385c5 --- /dev/null +++ b/docs/latest/_modules/evennia/server/session.html @@ -0,0 +1,280 @@ + + + + + + + + evennia.server.session — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.session

+"""
+This module defines a generic session class. All connection instances
+(both on Portal and Server side) should inherit from this class.
+
+"""
+import time
+
+from django.conf import settings
+
+# ------------------------------------------------------------
+# Server Session
+# ------------------------------------------------------------
+
+
+
[docs]class Session: + """ + This class represents a player's session and is a template for + both portal- and server-side sessions. + + Each connection will see two session instances created: + + 1. A Portal session. This is customized for the respective connection + protocols that Evennia supports, like Telnet, SSH etc. The Portal + session must call init_session() as part of its initialization. The + respective hook methods should be connected to the methods unique + for the respective protocol so that there is a unified interface + to Evennia. + 2. A Server session. This is the same for all connected accounts, + regardless of how they connect. + + The Portal and Server have their own respective sessionhandlers. These + are synced whenever new connections happen or the Server restarts etc, + which means much of the same information must be stored in both places + e.g. the portal can re-sync with the server when the server reboots. + + """ + +
[docs] def init_session(self, protocol_key, address, sessionhandler): + """ + Initialize the Session. This should be called by the protocol when + a new session is established. + + Args: + protocol_key (str): By default, one of 'telnet', 'telnet/ssl', 'ssh', + 'webclient/websocket' or 'webclient/ajax'. + address (str): Client address. + sessionhandler (SessionHandler): Reference to the + main sessionhandler instance. + + """ + # This is currently 'telnet', 'ssh', 'ssl' or 'web' + self.protocol_key = protocol_key + # Protocol address tied to this session + self.address = address + + # suid is used by some protocols, it's a hex key. + self.suid = None + + # unique id for this session + self.sessid = 0 # no sessid yet + # client session id, if given by the client + self.csessid = None + # database id for the user connected to this session + self.uid = None + # user name, for easier tracking of sessions + self.uname = None + # if user has authenticated already or not + self.logged_in = False + + # database id of puppeted object (if any) + self.puid = None + + # session time statistics + self.conn_time = time.time() + self.cmd_last_visible = self.conn_time + self.cmd_last = self.conn_time + self.cmd_total = 0 + + self.protocol_flags = { + "ENCODING": "utf-8", + "SCREENREADER": False, + "INPUTDEBUG": False, + "RAW": False, + "NOCOLOR": False, + "LOCALECHO": False, + } + self.server_data = {} + + # map of input data to session methods + self.datamap = {} + + # a back-reference to the relevant sessionhandler this + # session is stored in. + self.sessionhandler = sessionhandler
+ +
[docs] def get_sync_data(self): + """ + Get all data relevant to sync the session. + + Args: + syncdata (dict): All syncdata values, based on + the keys given by self._attrs_to_sync. + + """ + return { + attr: getattr(self, attr) for attr in settings.SESSION_SYNC_ATTRS if hasattr(self, attr) + }
+ +
[docs] def load_sync_data(self, sessdata): + """ + Takes a session dictionary, as created by get_sync_data, and + loads it into the correct properties of the session. + + Args: + sessdata (dict): Session data dictionary. + + """ + for propname, value in sessdata.items(): + if ( + propname == "protocol_flags" + and isinstance(value, dict) + and hasattr(self, "protocol_flags") + and isinstance(self.protocol_flags, dict) + ): + # special handling to allow partial update of protocol flags + self.protocol_flags.update(value) + else: + setattr(self, propname, value)
+ +
[docs] def at_sync(self): + """ + Called after a session has been fully synced (including + secondary operations such as setting self.account based + on uid etc). + + """ + if self.account: + self.protocol_flags.update( + self.account.attributes.get("_saved_protocol_flags", None) or {} + )
+ + # access hooks + +
[docs] def disconnect(self, reason=None): + """ + generic hook called from the outside to disconnect this session + should be connected to the protocols actual disconnect mechanism. + + Args: + reason (str): Eventual text motivating the disconnect. + + """ + pass
+ +
[docs] def data_out(self, **kwargs): + """ + Generic hook for sending data out through the protocol. Server + protocols can use this right away. Portal sessions + should overload this to format/handle the outgoing data as needed. + + Keyword Args: + kwargs (any): Other data to the protocol. + + """ + pass
+ +
[docs] def data_in(self, **kwargs): + """ + Hook for protocols to send incoming data to the engine. + + Keyword Args: + kwargs (any): Other data from the protocol. + + """ + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/sessionhandler.html b/docs/latest/_modules/evennia/server/sessionhandler.html new file mode 100644 index 0000000000..0990b25d86 --- /dev/null +++ b/docs/latest/_modules/evennia/server/sessionhandler.html @@ -0,0 +1,983 @@ + + + + + + + + evennia.server.sessionhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.sessionhandler

+"""
+This module defines handlers for storing sessions when handles
+sessions of users connecting to the server.
+
+There are two similar but separate stores of sessions:
+
+- ServerSessionHandler - this stores generic game sessions
+     for the game. These sessions has no knowledge about
+     how they are connected to the world.
+- PortalSessionHandler - this stores sessions created by
+     twisted protocols. These are dumb connectors that
+     handle network communication but holds no game info.
+
+"""
+import time
+from codecs import decode as codecs_decode
+
+import evennia
+from django.conf import settings
+from django.utils.translation import gettext as _
+from evennia.commands.cmdhandler import CMD_LOGINSTART
+from evennia.server.portal import amp
+from evennia.server.signals import (
+    SIGNAL_ACCOUNT_POST_FIRST_LOGIN,
+    SIGNAL_ACCOUNT_POST_LAST_LOGOUT,
+    SIGNAL_ACCOUNT_POST_LOGIN,
+    SIGNAL_ACCOUNT_POST_LOGOUT,
+)
+from evennia.utils.logger import log_trace
+from evennia.utils.utils import callables_from_module, class_from_module, delay, is_iter, make_iter
+
+_FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED = settings.FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED
+_BROADCAST_SERVER_RESTART_MESSAGES = settings.BROADCAST_SERVER_RESTART_MESSAGES
+
+# delayed imports
+_AccountDB = None
+_ServerSession = None
+_ServerConfig = None
+_ScriptDB = None
+_OOB_HANDLER = None
+
+_ERR_BAD_UTF8 = _("Your client sent an incorrect UTF-8 sequence.")
+
+
+
[docs]class DummySession(object): + sessid = 0
+ + +DUMMYSESSION = DummySession() + +_SERVERNAME = settings.SERVERNAME +_MULTISESSION_MODE = settings.MULTISESSION_MODE +_IDLE_TIMEOUT = settings.IDLE_TIMEOUT +_DELAY_CMD_LOGINSTART = settings.DELAY_CMD_LOGINSTART +_MAX_SERVER_COMMANDS_PER_SECOND = 100.0 +_MAX_SESSION_COMMANDS_PER_SECOND = 5.0 +_MODEL_MAP = None +_FUNCPARSER = None + + +# input handlers + +_INPUT_FUNCS = {} +for modname in make_iter(settings.INPUT_FUNC_MODULES): + _INPUT_FUNCS.update(callables_from_module(modname)) + + +
[docs]def delayed_import(): + """ + Helper method for delayed import of all needed entities. + + """ + global _ServerSession, _AccountDB, _ServerConfig, _ScriptDB + if not _ServerSession: + # we allow optional arbitrary serversession class for overloading + _ServerSession = class_from_module(settings.SERVER_SESSION_CLASS) + if not _AccountDB: + from evennia.accounts.models import AccountDB as _AccountDB + if not _ServerConfig: + from evennia.server.models import ServerConfig as _ServerConfig + if not _ScriptDB: + from evennia.scripts.models import ScriptDB as _ScriptDB + # including once to avoid warnings in Python syntax checkers + assert _ServerSession, "ServerSession class could not load" + assert _AccountDB, "AccountDB class could not load" + assert _ServerConfig, "ServerConfig class could not load" + assert _ScriptDB, "ScriptDB class c ould not load"
+ + +# ----------------------------------------------------------- +# SessionHandler base class +# ------------------------------------------------------------ + + +
[docs]class SessionHandler(dict): + """ + This handler holds a stack of sessions. + + """ + + def __getitem__(self, key): + """ + Clean out None-sessions automatically. + + """ + if None in self: + del self[None] + return super().__getitem__(key) + +
[docs] def get(self, key, default=None): + """ + Clean out None-sessions automatically. + + """ + if None in self: + del self[None] + return super().get(key, default)
+ + def __setitem__(self, key, value): + """ + Don't assign None sessions" + + """ + if key is not None: + super().__setitem__(key, value) + + def __contains__(self, key): + """ + None-keys are not accepted. + + """ + return False if key is None else super().__contains__(key) + +
[docs] def get_sessions(self, include_unloggedin=False): + """ + Returns the connected session objects. + + Args: + include_unloggedin (bool, optional): Also list Sessions + that have not yet authenticated. + + Returns: + sessions (list): A list of `Session` objects. + + """ + if include_unloggedin: + return list(self.values()) + else: + return [session for session in self.values() if session.logged_in]
+ +
[docs] def get_all_sync_data(self): + """ + Create a dictionary of sessdata dicts representing all + sessions in store. + + Returns: + syncdata (dict): A dict of sync data. + + """ + return dict((sessid, sess.get_sync_data()) for sessid, sess in self.items())
+ +
[docs] def clean_senddata(self, session, kwargs): + """ + Clean up data for sending across the AMP wire. Also apply the + FuncParser using callables from `settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES`. + + Args: + session (Session): The relevant session instance. + kwargs (dict) Each keyword represents a send-instruction, with the keyword itself being + the name of the instruction (like "text"). Suitable values for each keyword are: + - arg -> [[arg], {}] + - [args] -> [[args], {}] + - {kwargs} -> [[], {kwargs}] + - [args, {kwargs}] -> [[arg], {kwargs}] + - [[args], {kwargs}] -> [[args], {kwargs}] + + Returns: + kwargs (dict): A cleaned dictionary of cmdname:[[args],{kwargs}] pairs, + where the keys, args and kwargs have all been converted to + send-safe entities (strings or numbers), and funcparser parsing has been + applied. + + """ + global _FUNCPARSER + if not _FUNCPARSER: + from evennia.utils.funcparser import FuncParser + + _FUNCPARSER = FuncParser( + settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES, raise_errors=True + ) + + options = kwargs.pop("options", None) or {} + raw = options.get("raw", False) + strip_inlinefunc = options.get("strip_inlinefunc", False) + + def _utf8(data): + if isinstance(data, bytes): + try: + data = codecs_decode(data, session.protocol_flags["ENCODING"]) + except LookupError: + # wrong encoding set on the session. Set it to a safe one + session.protocol_flags["ENCODING"] = "utf-8" + data = codecs_decode(data, "utf-8") + except UnicodeDecodeError: + # incorrect unicode sequence + session.sendLine(_ERR_BAD_UTF8) + data = "" + + return data + + def _validate(data): + """ + Helper function to convert data to AMP-safe (picketable) values" + + """ + if isinstance(data, dict): + newdict = {} + for key, part in data.items(): + newdict[key] = _validate(part) + return newdict + elif is_iter(data): + return [_validate(part) for part in data] + elif isinstance(data, (str, bytes)): + data = _utf8(data) + + if ( + _FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED + and not raw + and isinstance(self, ServerSessionHandler) + ): + # only apply funcparser on the outgoing path (sessionhandler->) + # data = parse_inlinefunc(data, strip=strip_inlinefunc, session=session) + data = _FUNCPARSER.parse(data, strip=strip_inlinefunc, session=session) + + return str(data) + elif ( + hasattr(data, "id") + and hasattr(data, "db_date_created") + and hasattr(data, "__dbclass__") + ): + # convert database-object to their string representation. + return _validate(str(data)) + else: + return data + + rkwargs = {} + for key, data in kwargs.items(): + key = _validate(key) + if not data: + if key == "text": + # we don't allow sending text = None, this must mean + # that the text command is not to be used. + continue + rkwargs[key] = [[], {}] + elif isinstance(data, dict): + rkwargs[key] = [[], _validate(data)] + elif is_iter(data): + data = tuple(data) + if isinstance(data[-1], dict): + if len(data) == 2: + if is_iter(data[0]): + rkwargs[key] = [_validate(data[0]), _validate(data[1])] + else: + rkwargs[key] = [[_validate(data[0])], _validate(data[1])] + else: + rkwargs[key] = [_validate(data[:-1]), _validate(data[-1])] + else: + rkwargs[key] = [_validate(data), {}] + else: + rkwargs[key] = [[_validate(data)], {}] + rkwargs[key][1]["options"] = dict(options) + # make sure that any "prompt" message will be processed last + # by moving it to the end + if "prompt" in rkwargs: + prompt = rkwargs.pop("prompt") + rkwargs["prompt"] = prompt + return rkwargs
+ + +# ------------------------------------------------------------ +# Server-SessionHandler class +# ------------------------------------------------------------ + + +
[docs]class ServerSessionHandler(SessionHandler): + """ + This object holds the stack of sessions active in the game at any time. + + A session register with the handler in two steps, first by registering itself with the connect() + method. This indicates an non-authenticated session. Whenever the session is authenticated the + session together with the related account is sent to the login() method. + + """ + + # AMP communication methods + +
[docs] def __init__(self, *args, **kwargs): + """ + Init the handler. + + """ + super().__init__(*args, **kwargs) + evennia.server_data = {"servername": _SERVERNAME} + # will be set on psync + self.portal_start_time = 0.0
+ + def _run_cmd_login(self, session): + """ + Launch the CMD_LOGINSTART command. This is wrapped + for delays. + + """ + if not session.logged_in: + self.data_in(session, text=[[CMD_LOGINSTART], {}]) + +
[docs] def portal_connect(self, portalsessiondata): + """ + Called by Portal when a new session has connected. + Creates a new, unlogged-in game session. + + Args: + portalsessiondata (dict): a dictionary of all property:value + keys defining the session and which is marked to be + synced. + + """ + delayed_import() + global _ServerSession, _AccountDB, _ScriptDB + + sess = _ServerSession() + sess.sessionhandler = self + sess.load_sync_data(portalsessiondata) + sess.at_sync() + # validate all scripts + # _ScriptDB.objects.validate() + self[sess.sessid] = sess + + if sess.logged_in and sess.uid: + # Session is already logged in. This can happen in the + # case of auto-authenticating protocols like SSH or + # webclient's session sharing + account = _AccountDB.objects.get_account_from_uid(sess.uid) + if account: + # this will set account.is_connected too + self.login(sess, account, force=True) + return + else: + sess.logged_in = False + sess.uid = None + + # show the first login command, may delay slightly to allow + # the handshakes to finish. + delay(_DELAY_CMD_LOGINSTART, self._run_cmd_login, sess)
+ +
[docs] def portal_session_sync(self, portalsessiondata): + """ + Called by Portal when it wants to update a single session (e.g. + because of all negotiation protocols have finally replied) + + Args: + portalsessiondata (dict): a dictionary of all property:value + keys defining the session and which is marked to be + synced. + + """ + sessid = portalsessiondata.get("sessid") + session = self.get(sessid) + if session: + # since some of the session properties may have had + # a chance to change already before the portal gets here + # the portal doesn't send all sessiondata but only + # ones which should only be changed from portal (like + # protocol_flags etc) + session.load_sync_data(portalsessiondata)
+ +
[docs] def portal_sessions_sync(self, portalsessionsdata): + """ + Syncing all session ids of the portal with the ones of the + server. This is instantiated by the portal when reconnecting. + + Args: + portalsessionsdata (dict): A dictionary + `{sessid: {property:value},...}` defining each session and + the properties in it which should be synced. + + """ + delayed_import() + global _ServerSession, _AccountDB, _ServerConfig, _ScriptDB + + for sess in list(self.values()): + # we delete the old session to make sure to catch eventual + # lingering references. + del sess + + for sessid, sessdict in portalsessionsdata.items(): + sess = _ServerSession() + sess.sessionhandler = self + sess.load_sync_data(sessdict) + if sess.uid: + sess.account = _AccountDB.objects.get_account_from_uid(sess.uid) + self[sessid] = sess + sess.at_sync() + + mode = "reload" + + # tell the server hook we synced + evennia.EVENNIA_SERVER_SERVICE.at_post_portal_sync(mode) + # announce the reconnection + if _BROADCAST_SERVER_RESTART_MESSAGES: + self.announce_all(_(" ... Server restarted."))
+ +
[docs] def portal_disconnect(self, session): + """ + Called from Portal when Portal session closed from the portal + side. There is no message to report in this case. + + Args: + session (Session): The Session to disconnect + + """ + # disconnect us without calling Portal since + # Portal already knows. + self.disconnect(session, reason="", sync_portal=False)
+ +
[docs] def portal_disconnect_all(self): + """ + Called from Portal when Portal is closing down. All + Sessions should die. The Portal should not be informed. + + """ + # set a watchdog to avoid self.disconnect from deleting + # the session while we are looping over them + self._disconnect_all = True + for session in self.values(): + session.disconnect() + del self._disconnect_all
+ + # server-side access methods + +
[docs] def start_bot_session(self, protocol_path, configdict): + """ + This method allows the server-side to force the Portal to + create a new bot session. + + Args: + protocol_path (str): The full python path to the bot's + class. + configdict (dict): This dict will be used to configure + the bot (this depends on the bot protocol). + + Examples: + start_bot_session("evennia.server.portal.irc.IRCClient", + {"uid":1, "botname":"evbot", "channel":"#evennia", + "network:"irc.freenode.net", "port": 6667}) + + Notes: + The new session will use the supplied account-bot uid to + initiate an already logged-in connection. The Portal will + treat this as a normal connection and henceforth so will + the Server. + + """ + evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + DUMMYSESSION, operation=amp.SCONN, protocol_path=protocol_path, config=configdict + )
+ +
[docs] def portal_restart_server(self): + """ + Called by server when reloading. We tell the portal to start a new server instance. + + """ + evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + DUMMYSESSION, operation=amp.SRELOAD + )
+ +
[docs] def portal_reset_server(self): + """ + Called by server when reloading. We tell the portal to start a new server instance. + + """ + evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + DUMMYSESSION, operation=amp.SRESET + )
+ +
[docs] def portal_shutdown(self): + """ + Called by server when it's time to shut down (the portal will shut us down and then shut + itself down) + + """ + evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + DUMMYSESSION, operation=amp.PSHUTD + )
+ +
[docs] def login(self, session, account, force=False, testmode=False): + """ + Log in the previously unloggedin session and the account we by now should know is connected + to it. After this point we assume the session to be logged in one way or another. + + Args: + session (Session): The Session to authenticate. + account (Account): The Account identified as associated with this Session. + force (bool): Login also if the session thinks it's already logged in + (this can happen for auto-authenticating protocols) + testmode (bool, optional): This is used by unittesting for + faking login without any AMP being actually active. + + """ + if session.logged_in and not force: + # don't log in a session that is already logged in. + return + + account.is_connected = True + + # sets up and assigns all properties on the session + session.at_login(account) + + # account init + account.at_init() + + # Check if this is the first time the *account* logs in + if account.db.FIRST_LOGIN: + account.at_first_login() + del account.db.FIRST_LOGIN + + account.at_pre_login() + + if _MULTISESSION_MODE == 0: + # disconnect all previous sessions. + self.disconnect_duplicate_sessions(session) + + nsess = len(self.sessions_from_account(account)) + string = "Logged in: {account} {address} ({nsessions} session(s) total)" + string = string.format(account=account, address=session.address, nsessions=nsess) + session.log(string) + session.logged_in = True + # sync the portal to the session + if not testmode: + evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + session, operation=amp.SLOGIN, sessiondata={"logged_in": True, "uid": session.uid} + ) + account.at_post_login(session=session) + if nsess < 2: + SIGNAL_ACCOUNT_POST_FIRST_LOGIN.send(sender=account, session=session) + SIGNAL_ACCOUNT_POST_LOGIN.send(sender=account, session=session)
+ +
[docs] def disconnect(self, session, reason="", sync_portal=True): + """ + Called from server side to remove session and inform portal + of this fact. + + Args: + session (Session): The Session to disconnect. + reason (str, optional): A motivation for the disconnect. + sync_portal (bool, optional): Sync the disconnect to + Portal side. This should be done unless this was + called by self.portal_disconnect(). + + """ + session = self.get(session.sessid) + if not session: + return + + if hasattr(session, "account") and session.account: + # only log accounts logging off + nsess = len(self.sessions_from_account(session.account)) - 1 + sreason = " ({})".format(reason) if reason else "" + string = "Logged out: {account} {address} ({nsessions} sessions(s) remaining){reason}" + string = string.format( + reason=sreason, account=session.account, address=session.address, nsessions=nsess + ) + session.log(string) + + if nsess == 0: + SIGNAL_ACCOUNT_POST_LAST_LOGOUT.send(sender=session.account, session=session) + + session.at_disconnect(reason) + SIGNAL_ACCOUNT_POST_LOGOUT.send(sender=session.account, session=session) + sessid = session.sessid + if sessid in self and not hasattr(self, "_disconnect_all"): + del self[sessid] + if sync_portal: + # inform portal that session should be closed. + evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + session, operation=amp.SDISCONN, reason=reason + )
+ +
[docs] def all_sessions_portal_sync(self): + """ + This is called by the server when it reboots. It syncs all session data + to the portal. Returns a deferred! + + """ + sessdata = self.get_all_sync_data() + return evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + DUMMYSESSION, operation=amp.SSYNC, sessiondata=sessdata + )
+ +
[docs] def session_portal_sync(self, session): + """ + This is called by the server when it wants to sync a single session + with the Portal for whatever reason. Returns a deferred! + + """ + sessdata = {session.sessid: session.get_sync_data()} + return evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + DUMMYSESSION, operation=amp.SSYNC, sessiondata=sessdata, clean=False + )
+ +
[docs] def session_portal_partial_sync(self, session_data): + """ + Call to make a partial update of the session, such as only a particular property. + + Args: + session_data (dict): Store `{sessid: {property:value}, ...}` defining one or + more sessions in detail. + + """ + return evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + DUMMYSESSION, operation=amp.SSYNC, sessiondata=session_data, clean=False + )
+ +
[docs] def disconnect_all_sessions(self, reason="You have been disconnected."): + """ + Cleanly disconnect all of the connected sessions. + + Args: + reason (str, optional): The reason for the disconnection. + + """ + + for session in self: + del session + # tell portal to disconnect all sessions + evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_AdminServer2Portal( + DUMMYSESSION, operation=amp.SDISCONNALL, reason=reason + )
+ +
[docs] def disconnect_duplicate_sessions( + self, curr_session, reason=_("Logged in from elsewhere. Disconnecting.") + ): + """ + Disconnects any existing sessions with the same user. + + args: + curr_session (Session): Disconnect all Sessions matching this one. + reason (str, optional): A motivation for disconnecting. + + """ + uid = curr_session.uid + # we can't compare sessions directly since this will compare addresses and + # mean connecting from the same host would not catch duplicates + sid = id(curr_session) + doublet_sessions = [ + sess for sess in self.values() if sess.logged_in and sess.uid == uid and id(sess) != sid + ] + + for session in doublet_sessions: + self.disconnect(session, reason)
+ +
[docs] def validate_sessions(self): + """ + Check all currently connected sessions (logged in and not) and + see if any are dead or idle. + + """ + tcurr = time.time() + reason = _("Idle timeout exceeded, disconnecting.") + for session in ( + session + for session in self.values() + if session.logged_in + and _IDLE_TIMEOUT > 0 + and (tcurr - session.cmd_last) > _IDLE_TIMEOUT + ): + self.disconnect(session, reason=reason)
+ +
[docs] def account_count(self): + """ + Get the number of connected accounts (not sessions since a + account may have more than one session depending on settings). + Only logged-in accounts are counted here. + + Returns: + naccount (int): Number of connected accounts + + """ + return len(set(session.uid for session in self.values() if session.logged_in))
+ +
[docs] def all_connected_accounts(self): + """ + Get a unique list of connected and logged-in Accounts. + + Returns: + accounts (list): All connected Accounts (which may be fewer than the + amount of Sessions due to multi-playing). + + """ + return list( + set( + session.account + for session in self.values() + if session.logged_in and session.account + ) + )
+ +
[docs] def session_from_sessid(self, sessid): + """ + Get session based on sessid, or None if not found + + Args: + sessid (int or list): Session id(s). + + Return: + sessions (Session or list): Session(s) found. This + is a list if input was a list. + + """ + if is_iter(sessid): + return [self.get(sid) for sid in sessid if sid in self] + return self.get(sessid)
+ +
[docs] def session_from_account(self, account, sessid): + """ + Given an account and a session id, return the actual session + object. + + Args: + account (Account): The Account to get the Session from. + sessid (int or list): Session id(s). + + Returns: + sessions (Session or list): Session(s) found. + + """ + sessions = [ + self[sid] + for sid in make_iter(sessid) + if sid in self and self[sid].logged_in and account.uid == self[sid].uid + ] + return sessions[0] if len(sessions) == 1 else sessions
+ +
[docs] def sessions_from_account(self, account): + """ + Given an account, return all matching sessions. + + Args: + account (Account): Account to get sessions from. + + Returns: + sessions (list): All Sessions associated with this account. + + """ + uid = account.uid + return [session for session in self.values() if session.logged_in and session.uid == uid]
+ +
[docs] def sessions_from_puppet(self, puppet): + """ + Given a puppeted object, return all controlling sessions. + + Args: + puppet (Object): Object puppeted + + Returns. + sessions (Session or list): Can be more than one of Object is controlled by more than + one Session (MULTISESSION_MODE > 1). + + """ + sessions = puppet.sessid.get() + return sessions[0] if len(sessions) == 1 else sessions
+ + sessions_from_character = sessions_from_puppet + +
[docs] def sessions_from_csessid(self, csessid): + """ + Given a client identification hash (for session types that offer them) + return all sessions with a matching hash. + + Args + csessid (str): The session hash. + + Returns: + sessions (list): The sessions with matching .csessid, if any. + + """ + if not csessid: + return [] + return [ + session for session in self.values() if session.csessid and session.csessid == csessid + ]
+ +
[docs] def announce_all(self, message): + """ + Send message to all connected sessions + + Args: + message (str): Message to send. + + """ + for session in self.values(): + self.data_out(session, text=message)
+ +
[docs] def data_out(self, session, **kwargs): + """ + Sending data Server -> Portal + + Args: + session (Session): Session to relay to. + text (str, optional): text data to return + + Notes: + The outdata will be scrubbed for sending across + the wire here. + """ + # clean output for sending + kwargs = self.clean_senddata(session, kwargs) + + # send across AMP + evennia.EVENNIA_SERVER_SERVICE.amp_protocol.send_MsgServer2Portal(session, **kwargs)
+ +
[docs] def get_inputfuncs(self): + """ + Get all registered inputfuncs (access function) + + Returns: + inputfuncs (dict): A dict of {key:inputfunc,...} + """ + return _INPUT_FUNCS
+ +
[docs] def data_in(self, session, **kwargs): + """ + We let the data take a "detour" to session.data_in + so the user can override and see it all in one place. + That method is responsible to in turn always call + this class' `sessionhandler.call_inputfunc` with the + (possibly processed) data. + + """ + if session: + session.data_in(**kwargs)
+ +
[docs] def call_inputfuncs(self, session, **kwargs): + """ + Split incoming data into its inputfunc counterparts. This should be + called by the `serversession.data_in` as + `sessionhandler.call_inputfunc(self, **kwargs)`. + + We also intercept OOB communication here. + + Args: + sessions (Session): Session. + + Keyword Args: + any (tuple): Incoming data from protocol, each + on the form `commandname=((args), {kwargs})`. + + """ + + # distribute incoming data to the correct receiving methods. + if session: + input_debug = session.protocol_flags.get("INPUTDEBUG", False) + for cmdname, (cmdargs, cmdkwargs) in kwargs.items(): + cname = cmdname.strip().lower() + try: + cmdkwargs.pop("options", None) + if cname in _INPUT_FUNCS: + _INPUT_FUNCS[cname](session, *cmdargs, **cmdkwargs) + else: + _INPUT_FUNCS["default"](session, cname, *cmdargs, **cmdkwargs) + except Exception as err: + if input_debug: + session.msg(err) + log_trace()
+ + +# These are filled in during server boot. +SESSION_HANDLER = None +SESSIONS = None # legacy +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/throttle.html b/docs/latest/_modules/evennia/server/throttle.html new file mode 100644 index 0000000000..e0f9a9e85d --- /dev/null +++ b/docs/latest/_modules/evennia/server/throttle.html @@ -0,0 +1,330 @@ + + + + + + + + evennia.server.throttle — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.throttle

+import time
+from collections import deque
+
+from django.core.cache import caches
+from django.utils.translation import gettext as _
+
+from evennia.utils import logger
+
+
+
[docs]class Throttle: + """ + Keeps a running count of failed actions per IP address. + + Available methods indicate whether or not the number of failures exceeds a + particular threshold. + + This version of the throttle is usable by both the terminal server as well + as the web server, imposes limits on memory consumption by using deques + with length limits instead of open-ended lists, and uses native Django + caches for automatic key eviction and persistence configurability. + """ + + error_msg = _("Too many failed attempts; you must wait a few minutes before trying again.") + +
[docs] def __init__(self, **kwargs): + """ + Allows setting of throttle parameters. + + Keyword Args: + name (str): Name of this throttle. + limit (int): Max number of failures before imposing limiter. If `None`, + the throttle is disabled. + timeout (int): number of timeout seconds after + max number of tries has been reached. + cache_size (int): Max number of attempts to record per IP within a + rolling window; this is NOT the same as the limit after which + the throttle is imposed! + """ + try: + self.storage = caches["throttle"] + except Exception: + logger.log_trace("Throttle: Errors encountered; using default cache.") + self.storage = caches["default"] + + self.name = kwargs.get("name", "undefined-throttle") + self.limit = kwargs.get("limit", 5) + self.cache_size = kwargs.get("cache_size", self.limit) + self.timeout = kwargs.get("timeout", 5 * 60)
+ +
[docs] def get_cache_key(self, *args, **kwargs): + """ + Creates a 'prefixed' key containing arbitrary terms to prevent key + collisions in the same namespace. + + """ + return "-".join((self.name, *args))
+ +
[docs] def touch(self, key, *args, **kwargs): + """ + Refreshes the timeout on a given key and ensures it is recorded in the + key register. + + Args: + key(str): Key of entry to renew. + + """ + cache_key = self.get_cache_key(key) + if self.storage.touch(cache_key, self.timeout): + self.record_key(key)
+ +
[docs] def get(self, ip=None): + """ + Convenience function that returns the storage table, or part of. + + Args: + ip (str, optional): IP address of requestor + + Returns: + storage (dict): When no IP is provided, returns a dict of all + current IPs being tracked and the timestamps of their recent + failures. + timestamps (deque): When an IP is provided, returns a deque of + timestamps of recent failures only for that IP. + + """ + if ip: + cache_key = self.get_cache_key(str(ip)) + return self.storage.get(cache_key, deque(maxlen=self.cache_size)) + else: + keys_key = self.get_cache_key("keys") + keys = self.storage.get_or_set(keys_key, set(), self.timeout) + data = self.storage.get_many((self.get_cache_key(x) for x in keys)) + + found_keys = set(data.keys()) + if len(keys) != len(found_keys): + self.storage.set(keys_key, found_keys, self.timeout) + + return data
+ +
[docs] def update(self, ip, failmsg="Exceeded threshold."): + """ + Store the time of the latest failure. + + Args: + ip (str): IP address of requestor + failmsg (str, optional): Message to display in logs upon activation + of throttle. + + Returns: + None + + """ + cache_key = self.get_cache_key(ip) + + # Get current status + previously_throttled = self.check(ip) + + # Get previous failures, if any + entries = self.storage.get(cache_key, []) + entries.append(time.time()) + + # Store updated record + self.storage.set(cache_key, deque(entries, maxlen=self.cache_size), self.timeout) + + # See if this update caused a change in status + currently_throttled = self.check(ip) + + # If this makes it engage, log a single activation event + if not previously_throttled and currently_throttled: + logger.log_sec( + f"Throttle Activated: {failmsg} (IP: {ip}, " + f"{self.limit} hits in {self.timeout} seconds.)" + ) + + self.record_ip(ip)
+ +
[docs] def remove(self, ip, *args, **kwargs): + """ + Clears data stored for an IP from the throttle. + + Args: + ip(str): IP to clear. + + """ + exists = self.get(ip) + if not exists: + return False + + cache_key = self.get_cache_key(ip) + self.storage.delete(cache_key) + self.unrecord_ip(ip) + + # Return True if NOT exists + return not bool(self.get(ip))
+ +
[docs] def record_ip(self, ip, *args, **kwargs): + """ + Tracks keys as they are added to the cache (since there is no way to + get a list of keys after-the-fact). + + Args: + ip(str): IP being added to cache. This should be the original + IP, not the cache-prefixed key. + + """ + keys_key = self.get_cache_key("keys") + keys = self.storage.get(keys_key, set()) + keys.add(ip) + self.storage.set(keys_key, keys, self.timeout) + return True
+ +
[docs] def unrecord_ip(self, ip, *args, **kwargs): + """ + Forces removal of a key from the key registry. + + Args: + ip(str): IP to remove from list of keys. + + """ + keys_key = self.get_cache_key("keys") + keys = self.storage.get(keys_key, set()) + try: + keys.remove(ip) + self.storage.set(keys_key, keys, self.timeout) + return True + except KeyError: + return False
+ +
[docs] def check(self, ip): + """ + This will check the session's address against the + storage dictionary to check they haven't spammed too many + fails recently. + + Args: + ip (str): IP address of requestor + + Returns: + throttled (bool): True if throttling is active, + False otherwise. + + """ + if self.limit is None: + # throttle is disabled + return False + + now = time.time() + ip = str(ip) + + cache_key = self.get_cache_key(ip) + + # checking mode + latest_fails = self.storage.get(cache_key) + if latest_fails and len(latest_fails) >= self.limit: + # too many fails recently + if now - latest_fails[-1] < self.timeout: + # too soon - timeout in play + self.touch(cache_key) + return True + else: + # timeout has passed. clear faillist + self.remove(ip) + return False + else: + return False
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/validators.html b/docs/latest/_modules/evennia/server/validators.html new file mode 100644 index 0000000000..1feb2f49fd --- /dev/null +++ b/docs/latest/_modules/evennia/server/validators.html @@ -0,0 +1,195 @@ + + + + + + + + evennia.server.validators — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.validators

+import re
+
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext as _
+
+from evennia.accounts.models import AccountDB
+
+
+
[docs]class EvenniaUsernameAvailabilityValidator: + """ + Checks to make sure a given username is not taken or otherwise reserved. + """ + + def __call__(self, username): + """ + Validates a username to make sure it is not in use or reserved. + + Args: + username (str): Username to validate + + Returns: + None (None): None if password successfully validated, + raises ValidationError otherwise. + + """ + # Check guest list + if settings.GUEST_LIST and username.lower() in ( + guest.lower() for guest in settings.GUEST_LIST + ): + raise ValidationError( + _("Sorry, that username is reserved."), code="evennia_username_reserved" + ) + + # Check database + exists = AccountDB.objects.filter(username__iexact=username).exists() + if exists: + raise ValidationError( + _("Sorry, that username is already taken."), code="evennia_username_taken" + )
+ + +
[docs]class EvenniaPasswordValidator: +
[docs] def __init__( + self, + regex=r"^[\w. @+\-',]+$", + policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only.", + ): + """ + Constructs a standard Django password validator. + + Args: + regex (str): Regex pattern of valid characters to allow. + policy (str): Brief explanation of what the defined regex permits. + + """ + self.regex = regex + self.policy = policy
+ +
[docs] def validate(self, password, user=None): + """ + Validates a password string to make sure it meets predefined Evennia + acceptable character policy. + + Args: + password (str): Password to validate + user (None): Unused argument but required by Django + + Returns: + None (None): None if password successfully validated, + raises ValidationError otherwise. + + """ + # Check complexity + if not re.findall(self.regex, password): + raise ValidationError(_(self.policy), code="evennia_password_policy")
+ +
[docs] def get_help_text(self): + """ + Returns a user-facing explanation of the password policy defined + by this validator. + + Returns: + text (str): Explanation of password policy. + + """ + return _( + "{policy} From a terminal client, you can also use a phrase of multiple words if " + "you enclose the password in double quotes.".format(policy=self.policy) + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/server/webserver.html b/docs/latest/_modules/evennia/server/webserver.html new file mode 100644 index 0000000000..b119a6228b --- /dev/null +++ b/docs/latest/_modules/evennia/server/webserver.html @@ -0,0 +1,412 @@ + + + + + + + + evennia.server.webserver — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.server.webserver

+"""
+This implements resources for Twisted webservers using the WSGI
+interface of Django. This alleviates the need of running e.g. an
+Apache server to serve Evennia's web presence (although you could do
+that too if desired).
+
+The actual servers are started inside server.py as part of the Evennia
+application.
+
+(Lots of thanks to http://github.com/clemesha/twisted-wsgi-django for
+a great example/aid on how to do this.)
+
+
+"""
+import urllib.parse
+from urllib.parse import quote as urlquote
+
+from django.conf import settings
+from django.core.wsgi import get_wsgi_application
+from twisted.application import internet
+from twisted.internet import defer, reactor
+from twisted.python import threadpool
+from twisted.web import http, resource, server, static
+from twisted.web.proxy import ReverseProxyResource
+from twisted.web.server import NOT_DONE_YET
+from twisted.web.wsgi import WSGIResource
+
+from evennia.utils import logger
+
+_UPSTREAM_IPS = settings.UPSTREAM_IPS
+_DEBUG = settings.DEBUG
+
+
+
[docs]class LockableThreadPool(threadpool.ThreadPool): + """ + Threadpool that can be locked from accepting new requests. + """ + +
[docs] def __init__(self, *args, **kwargs): + self._accept_new = True + threadpool.ThreadPool.__init__(self, *args, **kwargs)
+ +
[docs] def lock(self): + self._accept_new = False
+ +
[docs] def callInThread(self, func, *args, **kwargs): + """ + called in the main reactor thread. Makes sure the pool + is not locked before continuing. + """ + if self._accept_new: + threadpool.ThreadPool.callInThread(self, func, *args, **kwargs)
+ + +# +# X-Forwarded-For Handler +# + + +
[docs]class HTTPChannelWithXForwardedFor(http.HTTPChannel): + """ + HTTP xforward class + + """ + +
[docs] def allHeadersReceived(self): + """ + Check to see if this is a reverse proxied connection. + + """ + if self.requests: + CLIENT = 0 + http.HTTPChannel.allHeadersReceived(self) + req = self.requests[-1] + client_ip, port = self.transport.client + proxy_chain = req.getHeader("X-FORWARDED-FOR") + if proxy_chain and client_ip in _UPSTREAM_IPS: + forwarded = proxy_chain.split(", ", 1)[CLIENT] + self.transport.client = (forwarded, port)
+ + +# Monkey-patch Twisted to handle X-Forwarded-For. + +http.HTTPFactory.protocol = HTTPChannelWithXForwardedFor + + +
[docs]class EvenniaReverseProxyResource(ReverseProxyResource): +
[docs] def getChild(self, path, request): + """ + Create and return a proxy resource with the same proxy configuration + as this one, except that its path also contains the segment given by + path at the end. + + Args: + path (str): Url path. + request (Request object): Incoming request. + + Return: + resource (EvenniaReverseProxyResource): A proxy resource. + + """ + request.notifyFinish().addErrback( + lambda f: 0 + # lambda f: logger.log_trace("%s\nCaught errback in webserver.py" % f) + ) + return EvenniaReverseProxyResource( + self.host, self.port, self.path + "/" + urlquote(path, safe=""), self.reactor + )
+ +
[docs] def render(self, request): + """ + Render a request by forwarding it to the proxied server. + + Args: + request (Request): Incoming request. + + Returns: + not_done (char): Indicator to note request not yet finished. + + """ + # RFC 2616 tells us that we can omit the port if it's the default port, + # but we have to provide it otherwise + request.content.seek(0, 0) + qs = urllib.parse.urlparse(request.uri)[4] + if qs: + rest = self.path + "?" + qs.decode() + else: + rest = self.path + rest = rest.encode() + clientFactory = self.proxyClientFactoryClass( + request.method, + rest, + request.clientproto, + request.getAllHeaders(), + request.content.read(), + request, + ) + clientFactory.noisy = False + self.reactor.connectTCP(self.host, self.port, clientFactory) + # don't trigger traceback if connection is lost before request finish. + request.notifyFinish().addErrback(lambda f: 0) + # request.notifyFinish().addErrback( + # lambda f:logger.log_trace("Caught errback in webserver.py: %s" % f) + return NOT_DONE_YET
+ + +# +# Website server resource +# + + +
[docs]class DjangoWebRoot(resource.Resource): + """ + This creates a web root (/) that Django + understands by tweaking the way + child instances are recognized. + """ + +
[docs] def __init__(self, pool): + """ + Setup the django+twisted resource. + + Args: + pool (ThreadPool): The twisted threadpool. + + """ + self.pool = pool + self._echo_log = True + self._pending_requests = {} + super().__init__() + self.wsgi_resource = WSGIResource(reactor, pool, get_wsgi_application())
+ +
[docs] def empty_threadpool(self): + """ + Converts our _pending_requests list of deferreds into a DeferredList + + Returns: + deflist (DeferredList): Contains all deferreds of pending requests. + + """ + self.pool.lock() + if self._pending_requests and self._echo_log: + self._echo_log = False # just to avoid multiple echoes + msg = "Webserver waiting for %i requests ... " + logger.log_info(msg % len(self._pending_requests)) + return defer.DeferredList(self._pending_requests, consumeErrors=True)
+ + def _decrement_requests(self, *args, **kwargs): + self._pending_requests.pop(kwargs.get("deferred", None), None) + +
[docs] def getChild(self, path, request): + """ + To make things work we nudge the url tree to make this the + root. + + Args: + path (str): Url path. + request (Request object): Incoming request. + + Notes: + We make sure to save the request queue so + that we can safely kill the threadpool + on a server reload. + + """ + path0 = request.prepath.pop(0) + request.postpath.insert(0, path0) + + request.notifyFinish().addErrback( + lambda f: 0 + # lambda f: logger.log_trace("%s\nCaught errback in webserver.py:" % f) + ) + + deferred = request.notifyFinish() + self._pending_requests[deferred] = deferred + deferred.addBoth(self._decrement_requests, deferred=deferred) + + return self.wsgi_resource
+ + +# +# Site with deactivateable logging +# + + +
[docs]class Website(server.Site): + """ + This class will only log http requests if settings.DEBUG is True. + """ + + noisy = False + +
[docs] def logPrefix(self): + "How to be named in logs" + if hasattr(self, "is_portal") and self.is_portal: + return "Webserver-proxy" + return "Webserver"
+ +
[docs] def log(self, request): + """Conditional logging""" + if _DEBUG: + server.Site.log(self, request)
+ + +# +# Threaded Webserver +# + + +
[docs]class WSGIWebServer(internet.TCPServer): + """ + This is a WSGI webserver. It makes sure to start + the threadpool after the service itself started, + so as to register correctly with the twisted daemon. + + call with WSGIWebServer(threadpool, port, wsgi_resource) + + """ + +
[docs] def __init__(self, pool, *args, **kwargs): + """ + This just stores the threadpool. + + Args: + pool (ThreadPool): The twisted threadpool. + args, kwargs (any): Passed on to the TCPServer. + + """ + self.pool = pool + super().__init__(*args, **kwargs)
+ +
[docs] def startService(self): + """ + Start the pool after the service starts. + + """ + try: + super().startService() + self.pool.start() + except Exception: + logger.log_trace("Webserver did not start correctly. Disabling.") + self.stopService()
+ +
[docs] def stopService(self): + """ + Safely stop the pool after the service stops. + + """ + try: + super().stopService() + except Exception: + logger.log_trace("Webserver stopService error.") + finally: + if self.pool.started: + self.pool.stop()
+ + +
[docs]class PrivateStaticRoot(static.File): + """ + This overrides the default static file resource so as to not make the + directory listings public (that is, if you go to /media or /static you + won't see an index of all static/media files on the server). + + """ + +
[docs] def directoryListing(self): + return resource.ForbiddenResource()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/typeclasses/attributes.html b/docs/latest/_modules/evennia/typeclasses/attributes.html new file mode 100644 index 0000000000..651cdb447d --- /dev/null +++ b/docs/latest/_modules/evennia/typeclasses/attributes.html @@ -0,0 +1,1875 @@ + + + + + + + + evennia.typeclasses.attributes — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.typeclasses.attributes

+"""
+Attributes are arbitrary data stored on objects. Attributes supports
+both pure-string values and pickled arbitrary data.
+
+Attributes are also used to implement Nicks. This module also contains
+the Attribute- and NickHandlers as well as the `NAttributeHandler`,
+which is a non-db version of Attributes.
+
+
+"""
+import fnmatch
+import re
+from collections import defaultdict
+
+from django.conf import settings
+from django.db import models
+from django.utils.encoding import smart_str
+
+from evennia.locks.lockhandler import LockHandler
+from evennia.utils.dbserialize import from_pickle, to_pickle
+from evennia.utils.idmapper.models import SharedMemoryModel
+from evennia.utils.picklefield import PickledObjectField
+from evennia.utils.utils import is_iter, lazy_property, make_iter, to_str
+
+_TYPECLASS_AGGRESSIVE_CACHE = settings.TYPECLASS_AGGRESSIVE_CACHE
+
+# -------------------------------------------------------------
+#
+#   Attributes
+#
+# -------------------------------------------------------------
+
+
+
[docs]class IAttribute: + """ + Attributes are things that are specific to different types of objects. For + example, a drink container needs to store its fill level, whereas an exit + needs to store its open/closed/locked/unlocked state. These are done via + attributes, rather than making different classes for each object type and + storing them directly. The added benefit is that we can add/remove + attributes on the fly as we like. + + The Attribute class defines the following properties: + - key (str): Primary identifier. + - lock_storage (str): Perm strings. + - model (str): A string defining the model this is connected to. This + is a natural_key, like "objects.objectdb" + - date_created (datetime): When the attribute was created. + - value (any): The data stored in the attribute, in pickled form + using wrappers to be able to store/retrieve models. + - strvalue (str): String-only data. This data is not pickled and + is thus faster to search for in the database. + - category (str): Optional character string for grouping the + Attribute. + + This class is an API/Interface/Abstract base class; do not instantiate it directly. + """ + +
[docs] @lazy_property + def locks(self): + return LockHandler(self)
+ + key = property(lambda self: self.db_key) + strvalue = property(lambda self: self.db_strvalue) + category = property(lambda self: self.db_category) + model = property(lambda self: self.db_model) + attrtype = property(lambda self: self.db_attrtype) + date_created = property(lambda self: self.db_date_created) + + def __lock_storage_get(self): + return self.db_lock_storage + + def __lock_storage_set(self, value): + self.db_lock_storage = value + + def __lock_storage_del(self): + self.db_lock_storage = "" + + lock_storage = property(__lock_storage_get, __lock_storage_set, __lock_storage_del) + +
[docs] def access(self, accessing_obj, access_type="read", default=False, **kwargs): + """ + Determines if another object has permission to access. + + Args: + accessing_obj (object): Entity trying to access this one. + access_type (str, optional): Type of access sought, see + the lock documentation. + default (bool, optional): What result to return if no lock + of access_type was found. The default, `False`, means a lockdown + policy, only allowing explicit access. + kwargs (any, optional): Not used; here to make the API consistent with + other access calls. + + Returns: + result (bool): If the lock was passed or not. + + """ + result = self.locks.check(accessing_obj, access_type=access_type, default=default) + return result
+ + # + # + # Attribute methods + # + # + + def __str__(self): + return smart_str("%s(%s)" % (self.db_key, self.id)) + + def __repr__(self): + return "%s(%s)" % (self.db_key, self.id)
+ + +
[docs]class InMemoryAttribute(IAttribute): + """ + This Attribute is used purely for NAttributes/NAttributeHandler. It has no database backend. + + """ + + # Primary Key has no meaning for an InMemoryAttribute. This merely serves to satisfy other code. + +
[docs] def __init__(self, pk, **kwargs): + """ + Create an Attribute that exists only in Memory. + + Args: + pk (int): This is a fake 'primary key' / id-field. It doesn't actually have to be + unique, but is fed an incrementing number from the InMemoryBackend by default. This + is needed only so Attributes can be sorted. Some parts of the API also see the lack + of a .pk field as a sign that the Attribute was deleted. + **kwargs: Other keyword arguments are used to construct the actual Attribute. + + """ + self.id = pk + self.pk = pk + + # Copy all kwargs to local properties. We use db_ for compatability here. + for key, value in kwargs.items(): + # Value and locks are special. We must call the wrappers. + if key == "value": + self.value = value + elif key == "lock_storage": + self.lock_storage = value + else: + setattr(self, f"db_{key}", value)
+ + # value property (wraps db_value) + def __value_get(self): + return self.db_value + + def __value_set(self, new_value): + self.db_value = new_value + + def __value_del(self): + pass + + value = property(__value_get, __value_set, __value_del)
+ + +
[docs]class AttributeProperty: + """ + AttributeProperty. + + """ + + attrhandler_name = "attributes" + +
[docs] def __init__(self, default=None, category=None, strattr=False, lockstring="", autocreate=True): + """ + Allows for specifying Attributes as Django-like 'fields' on the class level. Note that while + one can set a lock on the Attribute, there is no way to *check* said lock when accessing via + the property - use the full `AttributeHandler` if you need to do access checks. Note however + that if you use the full `AttributeHandler` to access this Attribute, the `at_get/at_set` + methods on this class will _not_ fire (because you are bypassing the `AttributeProperty` + entirely in that case). + + Initialize an Attribute as a property descriptor. + + Keyword Args: + default (any): A default value if the attr is not set. If a callable, this will be + run without any arguments and is expected to return the default value. + category (str): The attribute's category. If unset, use class default. + strattr (bool): If set, this Attribute *must* be a simple string, and will be + stored more efficiently. + lockstring (str): This is not itself useful with the property, but only if + using the full AttributeHandler.get(accessing_obj=...) to access the + Attribute. + autocreate (bool): True by default; this means Evennia makes sure to create a new + copy of the Attribute (with the default value) whenever a new object with this + property is created. If `False`, no Attribute will be created until the property + is explicitly assigned a value. This makes it more efficient while it retains + its default (there's no db access), but without an actual Attribute generated, + one cannot access it via .db, the AttributeHandler or see it with `examine`. + Example: + :: + + class Character(DefaultCharacter): + foo = AttributeProperty(default="Bar") + + """ + self._default = default + self._category = category + self._strattr = strattr + self._lockstring = lockstring + self._autocreate = autocreate + self._key = ""
+ + @property + def _default(self): + """ + Tries returning a new instance of default if callable. + + """ + if callable(self.__default): + return self.__default() + + return self.__default + + @_default.setter + def _default(self, value): + self.__default = value + + def __set_name__(self, cls, name): + """ + Called when descriptor is first assigned to the class. It is called with + the name of the field. + + """ + self._key = name + + def __get__(self, instance, owner): + """ + Called when the attrkey is retrieved from the instance. + + """ + value = self._default + try: + value = self.at_get( + getattr(instance, self.attrhandler_name).get( + key=self._key, + default=self._default, + category=self._category, + strattr=self._strattr, + raise_exception=self._autocreate, + ), + instance, + ) + except AttributeError: + if self._autocreate: + # attribute didn't exist and autocreate is set + self.__set__(instance, self._default) + else: + raise + return value + + def __set__(self, instance, value): + """ + Called when assigning to the property (and when auto-creating an Attribute). + + """ + ( + getattr(instance, self.attrhandler_name).add( + self._key, + self.at_set(value, instance), + category=self._category, + lockstring=self._lockstring, + strattr=self._strattr, + ) + ) + + def __delete__(self, instance): + """ + Called when running `del` on the property. Will remove/clear the Attribute. Note that + the Attribute will be recreated next retrieval unless the AttributeProperty is also + removed in code! + + """ + getattr(instance, self.attrhandler_name).remove(key=self._key, category=self._category) + +
[docs] def at_set(self, value, obj): + """ + The value to set is passed through the method. It can be used to customize/validate + the input in a custom child class. + + Args: + value (any): The value about to the stored in this Attribute. + obj (object): Object the attribute is attached to + + Returns: + any: The value to store. + + Raises: + AttributeError: If the value is invalid to store. + + Notes: + This is will only fire if you actually set the Attribute via this `AttributeProperty`. + That is, if you instead set it via the `AttributeHandler` (or via `.db`), you are + bypassing this `AttributeProperty` entirely and this method is never reached. + + """ + return value
+ +
[docs] def at_get(self, value, obj): + """ + The value returned from the Attribute is passed through this method. It can be used + to react to the retrieval or modify the result in some way. + + Args: + value (any): Value returned from the Attribute. + obj (object): Object the attribute is attached to + + Returns: + any: The value to return to the caller. + + Notes: + This is will only fire if you actually get the Attribute via this `AttributeProperty`. + That is, if you instead get it via the `AttributeHandler` (or via `.db`), you are + bypassing this `AttributeProperty` entirely and this method is never reached. + + """ + return value
+ + +
[docs]class NAttributeProperty(AttributeProperty): + """ + NAttribute property descriptor. Allows for specifying NAttributes as Django-like 'fields' + on the class level. + + Example: + :: + + class Character(DefaultCharacter): + foo = NAttributeProperty(default="Bar") + + """ + + attrhandler_name = "nattributes"
+ + +
[docs]class Attribute(IAttribute, SharedMemoryModel): + """ + This attribute is stored via Django. Most Attributes will be using this class. + + """ + + # + # Attribute Database Model setup + # + # These database fields are all set using their corresponding properties, + # named same as the field, but without the db_* prefix. + db_key = models.CharField("key", max_length=255, db_index=True) + db_value = PickledObjectField( + "value", + null=True, + help_text=( + "The data returned when the attribute is accessed. Must be " + "written as a Python literal if editing through the admin " + "interface. Attribute values which are not Python literals " + "cannot be edited through the admin interface." + ), + ) + db_strvalue = models.TextField( + "strvalue", null=True, blank=True, help_text="String-specific storage for quick look-up" + ) + db_category = models.CharField( + "category", + max_length=128, + db_index=True, + blank=True, + null=True, + help_text="Optional categorization of attribute.", + ) + # Lock storage + db_lock_storage = models.TextField( + "locks", blank=True, help_text="Lockstrings for this object are stored here." + ) + db_model = models.CharField( + "model", + max_length=32, + db_index=True, + blank=True, + null=True, + help_text=( + "Which model of object this attribute is attached to (A " + "natural key like 'objects.objectdb'). You should not change " + "this value unless you know what you are doing." + ), + ) + # subclass of Attribute (None or nick) + db_attrtype = models.CharField( + "attrtype", + max_length=16, + db_index=True, + blank=True, + null=True, + help_text="Subclass of Attribute (None or nick)", + ) + # time stamp + db_date_created = models.DateTimeField("date_created", editable=False, auto_now_add=True) + + # Database manager + # objects = managers.AttributeManager() + + class Meta: + "Define Django meta options" + verbose_name = "Attribute" + + # Wrapper properties to easily set database fields. These are + # @property decorators that allows to access these fields using + # normal python operations (without having to remember to save() + # etc). So e.g. a property 'attr' has a get/set/del decorator + # defined that allows the user to do self.attr = value, + # value = self.attr and del self.attr respectively (where self + # is the object in question). + + # lock_storage wrapper. Overloaded for saving to database. + def __lock_storage_get(self): + return self.db_lock_storage + + def __lock_storage_set(self, value): + self.db_lock_storage = value + self.save(update_fields=["db_lock_storage"]) + + def __lock_storage_del(self): + self.db_lock_storage = "" + self.save(update_fields=["db_lock_storage"]) + + lock_storage = property(__lock_storage_get, __lock_storage_set, __lock_storage_del) + + # value property (wraps db_value) + @property + def value(self): + """ + Getter. Allows for `value = self.value`. + We cannot cache here since it makes certain cases (such + as storing a dbobj which is then deleted elsewhere) out-of-sync. + The overhead of unpickling seems hard to avoid. + """ + return from_pickle(self.db_value, db_obj=self) + + @value.setter + def value(self, new_value): + """ + Setter. Allows for self.value = value. We cannot cache here, + see self.__value_get. + """ + self.db_value = to_pickle(new_value) + self.save(update_fields=["db_value"]) + + @value.deleter + def value(self): + """Deleter. Allows for del attr.value. This removes the entire attribute.""" + self.delete()
+ + +# +# Handlers making use of the Attribute model +# + + +
[docs]class IAttributeBackend: + """ + Abstract interface for the backends used by the Attribute Handler. + + All Backends must implement this base class. + """ + + _attrcreate = "attrcreate" + _attredit = "attredit" + _attrread = "attrread" + _attrclass = None + +
[docs] def __init__(self, handler, attrtype): + self.handler = handler + self.obj = handler.obj + self._attrtype = attrtype + self._objid = handler.obj.id + self._cache = {} + # store category names fully cached + self._catcache = {} + # full cache was run on all attributes + self._cache_complete = False
+ +
[docs] def query_all(self): + """ + Fetch all Attributes from this object. + + Returns: + attrlist (list): A list of Attribute objects. + """ + raise NotImplementedError()
+ +
[docs] def query_key(self, key, category): + """ + + Args: + key (str): The key of the Attribute being searched for. + category (str or None): The category of the desired Attribute. + + Returns: + attribute (IAttribute): A single Attribute. + """ + raise NotImplementedError()
+ +
[docs] def query_category(self, category): + """ + Returns every matching Attribute as a list, given a category. + + This method calls up whatever storage the backend uses. + + Args: + category (str or None): The category to query. + + Returns: + attrs (list): The discovered Attributes. + """ + raise NotImplementedError()
+ + def _full_cache(self): + """Cache all attributes of this object""" + if not _TYPECLASS_AGGRESSIVE_CACHE: + return + attrs = self.query_all() + self._cache = { + f"{to_str(attr.key).lower()}-{attr.category.lower() if attr.category else None}": attr + for attr in attrs + } + self._cache_complete = True + + def _get_cache_key(self, key, category): + """ + Fetch cache key. + + Args: + key (str): The key of the Attribute being searched for. + category (str or None): The category of the desired Attribute. + + Returns: + attribute (IAttribute): A single Attribute. + """ + cachekey = "%s-%s" % (key, category) + cachefound = False + try: + attr = _TYPECLASS_AGGRESSIVE_CACHE and self._cache[cachekey] + cachefound = True + except KeyError: + attr = None + + if attr and (not hasattr(attr, "pk") and attr.pk is None): + # clear out Attributes deleted from elsewhere. We must search this anew. + attr = None + cachefound = False + del self._cache[cachekey] + if cachefound and _TYPECLASS_AGGRESSIVE_CACHE: + if attr: + return [attr] # return cached entity + else: + return [] # no such attribute: return an empty list + else: + conn = self.query_key(key, category) + if conn: + attr = conn[0].attribute + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = attr + return [attr] if attr.pk else [] + else: + # There is no such attribute. We will explicitly save that + # in our cache to avoid firing another query if we try to + # retrieve that (non-existent) attribute again. + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = None + return [] + + def _get_cache_category(self, category): + """ + Retrieves Attribute list (by category) from cache. + + Args: + category (str or None): The category to query. + + Returns: + attrs (list): The discovered Attributes. + """ + catkey = "-%s" % category + if _TYPECLASS_AGGRESSIVE_CACHE and catkey in self._catcache: + return [attr for key, attr in self._cache.items() if key.endswith(catkey) and attr] + else: + # we have to query to make this category up-date in the cache + attrs = self.query_category(category) + if _TYPECLASS_AGGRESSIVE_CACHE: + for attr in attrs: + if attr.pk: + cachekey = "%s-%s" % (attr.key, category) + self._cache[cachekey] = attr + # mark category cache as up-to-date + self._catcache[catkey] = True + return attrs + + def _get_cache(self, key=None, category=None): + """ + Retrieve from cache or database (always caches) + + Args: + key (str, optional): Attribute key to query for + category (str, optional): Attribiute category + + Returns: + args (list): Returns a list of zero or more matches + found from cache or database. + Notes: + When given a category only, a search for all objects + of that cateogory is done and the category *name* is + stored. This tells the system on subsequent calls that the + list of cached attributes of this category is up-to-date + and that the cache can be queried for category matches + without missing any. + The TYPECLASS_AGGRESSIVE_CACHE=False setting will turn off + caching, causing each attribute access to trigger a + database lookup. + + """ + key = key.strip().lower() if key else None + category = category.strip().lower() if category is not None else None + if key: + return self._get_cache_key(key, category) + return self._get_cache_category(category) + +
[docs] def get(self, key=None, category=None): + """ + Frontend for .get_cache. Retrieves Attribute(s). + + Args: + key (str, optional): Attribute key to query for + category (str, optional): Attribiute category + + Returns: + args (list): Returns a list of zero or more matches + found from cache or database. + """ + return self._get_cache(key, category)
+ + def _set_cache(self, key, category, attr_obj): + """ + Update cache. + + Args: + key (str): A cleaned key string + category (str or None): A cleaned category name + attr_obj (IAttribute): The newly saved attribute + + """ + if not _TYPECLASS_AGGRESSIVE_CACHE: + return + if not key: # don't allow an empty key in cache + return + cachekey = "%s-%s" % (key, category) + catkey = "-%s" % category + self._cache[cachekey] = attr_obj + # mark that the category cache is no longer up-to-date + self._catcache.pop(catkey, None) + self._cache_complete = False + + def _delete_cache(self, key, category): + """ + Remove attribute from cache + + Args: + key (str): A cleaned key string + category (str or None): A cleaned category name + + """ + catkey = "-%s" % category + if key: + cachekey = "%s-%s" % (key, category) + self._cache.pop(cachekey, None) + else: + self._cache = { + key: attrobj + for key, attrobj in list(self._cache.items()) + if not key.endswith(catkey) + } + # mark that the category cache is no longer up-to-date + self._catcache.pop(catkey, None) + self._cache_complete = False + +
[docs] def reset_cache(self): + """ + Reset cache from the outside. + """ + self._cache_complete = False + self._cache = {} + self._catcache = {}
+ +
[docs] def do_create_attribute(self, key, category, lockstring, value, strvalue): + """ + Does the hard work of actually creating Attributes, whatever is needed. + + Args: + key (str): The Attribute's key. + category (str or None): The Attribute's category, or None + lockstring (str): Any locks for the Attribute. + value (obj): The Value of the Attribute. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + + Returns: + attr (IAttribute): The new Attribute. + """ + raise NotImplementedError()
+ +
[docs] def create_attribute(self, key, category, lockstring, value, strvalue=False, cache=True): + """ + Creates Attribute (using the class specified for the backend), (optionally) caches it, and + returns it. + + This MUST actively save the Attribute to whatever database backend is used, AND + call self.set_cache(key, category, new_attrobj) + + Args: + key (str): The Attribute's key. + category (str or None): The Attribute's category, or None + lockstring (str): Any locks for the Attribute. + value (obj): The Value of the Attribute. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + cache (bool): Whether to cache the new Attribute + + Returns: + attr (IAttribute): The new Attribute. + """ + attr = self.do_create_attribute(key, category, lockstring, value, strvalue) + if cache: + self._set_cache(key, category, attr) + return attr
+ +
[docs] def do_update_attribute(self, attr, value, strvalue): + """ + Simply sets a new Value to an Attribute. + + Args: + attr (IAttribute): The Attribute being changed. + value (obj): The Value for the Attribute. + strvalue (bool): If True, `value` is expected to be a string. + + """ + raise NotImplementedError()
+ +
[docs] def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + """ + Called opnly by batch add. For the database backend, this is a method + of updating that can alter category and lock-storage. + + Args: + attr_obj (IAttribute): The Attribute being altered. + category (str or None): The attribute's (new) category. + lock_storage (str): The attribute's new locks. + new_value (obj): The Attribute's new value. + strvalue (bool): Signifies if this is a strvalue Attribute. Value MUST be a string or + this will lead to Trouble. Ignored for InMemory attributes. + """ + raise NotImplementedError()
+ +
[docs] def do_batch_finish(self, attr_objs): + """ + Called after batch_add completed. Used for handling database operations + and/or caching complications. + + Args: + attr_objs (list of IAttribute): The Attributes created/updated thus far. + + """ + raise NotImplementedError()
+ +
[docs] def batch_add(self, *args, **kwargs): + """ + Batch-version of `.add()`. This is more efficient than repeat-calling + `.add` when having many Attributes to add. + + Args: + *args (tuple): Tuples of varying length representing the + Attribute to add to this object. Supported tuples are + + - (key, value) + - (key, value, category) + - (key, value, category, lockstring) + - (key, value, category, lockstring, default_access) + + Raises: + RuntimeError: If trying to pass a non-iterable as argument. + + Notes: + The indata tuple order matters, so if you want a lockstring but no + category, set the category to `None`. This method does not have the + ability to check editing permissions and is mainly used internally. + It does not use the normal `self.add` but applies the Attributes + directly to the database. + + """ + new_attrobjs = [] + strattr = kwargs.get("strattr", False) + for tup in args: + if not is_iter(tup) or len(tup) < 2: + raise RuntimeError("batch_add requires iterables as arguments (got %r)." % tup) + ntup = len(tup) + keystr = str(tup[0]).strip().lower() + new_value = tup[1] + category = str(tup[2]).strip().lower() if ntup > 2 and tup[2] is not None else None + lockstring = tup[3] if ntup > 3 else "" + + attr_objs = self._get_cache(keystr, category) + + if attr_objs: + attr_obj = attr_objs[0] + # update an existing attribute object + self.do_batch_update_attribute(attr_obj, category, lockstring, new_value, strattr) + else: + new_attr = self.do_create_attribute( + keystr, category, lockstring, new_value, strvalue=strattr + ) + new_attrobjs.append(new_attr) + if new_attrobjs: + self.do_batch_finish(new_attrobjs)
+ +
[docs] def do_delete_attribute(self, attr): + """ + Does the hard work of actually deleting things. + + Args: + attr (IAttribute): The attribute to delete. + """ + raise NotImplementedError()
+ +
[docs] def delete_attribute(self, attr): + """ + Given an Attribute, deletes it. Also remove it from cache. + + Args: + attr (IAttribute): The attribute to delete. + """ + if not attr: + return + self._delete_cache(attr.key, attr.category) + self.do_delete_attribute(attr)
+ +
[docs] def update_attribute(self, attr, value, strattr=False): + """ + Simply updates an Attribute. + + Args: + attr (IAttribute): The attribute to delete. + value (obj): The new value. + strattr (bool): If set, the `value` is a raw string. + """ + self.do_update_attribute(attr, value, strattr)
+ +
[docs] def do_batch_delete(self, attribute_list): + """ + Given a list of attributes, deletes them all. + The default implementation is fine, but this is overridable since some databases may allow + for a better method. + + Args: + attribute_list (list of IAttribute): + """ + for attribute in attribute_list: + self.delete_attribute(attribute)
+ +
[docs] def clear_attributes(self, category, accessing_obj, default_access): + """ + Remove all Attributes on this object. + + Args: + category (str, optional): If given, clear only Attributes + of this category. + accessing_obj (object, optional): If given, check the + `attredit` lock on each Attribute before continuing. + default_access (bool, optional): Use this permission as + fallback if `access_obj` is given but there is no lock of + type `attredit` on the Attribute in question. + + """ + category = category.strip().lower() if category is not None else None + + if not self._cache_complete: + self._full_cache() + + if category is not None: + attrs = [attr for attr in self._cache.values() if attr.category == category] + else: + attrs = self._cache.values() + + if accessing_obj: + self.do_batch_delete( + [ + attr + for attr in attrs + if attr.access(accessing_obj, self._attredit, default=default_access) + ] + ) + else: + # have to cast the results to a list or we'll get a RuntimeError for removing from the + # dict we're iterating + self.do_batch_delete(list(attrs)) + self.reset_cache()
+ +
[docs] def get_all_attributes(self): + """ + Simply returns all Attributes of this object, sorted by their IDs. + + Returns: + attributes (list of IAttribute) + """ + if _TYPECLASS_AGGRESSIVE_CACHE: + if not self._cache_complete: + self._full_cache() + return sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id) + else: + return sorted([attr for attr in self.query_all() if attr], key=lambda o: o.id)
+ + +
[docs]class InMemoryAttributeBackend(IAttributeBackend): + """ + This Backend for Attributes stores NOTHING in the database. Everything is kept in memory, and + normally lost on a crash, reload, shared memory flush, etc. It generates IDs for the Attributes + it manages, but these are of little importance beyond sorting and satisfying the caching logic + to know an Attribute hasn't been deleted out from under the cache's nose. + + """ + + _attrclass = InMemoryAttribute + +
[docs] def __init__(self, handler, attrtype): + super().__init__(handler, attrtype) + self._storage = dict() + self._category_storage = defaultdict(list) + self._id_counter = 0
+ + def _next_id(self): + """ + Increments the internal ID counter and returns the new value. + + Returns: + next_id (int): A simple integer. + """ + self._id_counter += 1 + return self._id_counter + +
[docs] def query_all(self): + return self._storage.values()
+ +
[docs] def query_key(self, key, category): + found = self._storage.get((key, category), None) + if found: + return [found] + return []
+ +
[docs] def query_category(self, category): + if category is None: + return self._storage.values() + return self._category_storage.get(category, [])
+ +
[docs] def do_create_attribute(self, key, category, lockstring, value, strvalue): + """ + See parent class. + + strvalue has no meaning for InMemory attributes. + + """ + new_attr = self._attrclass( + pk=self._next_id(), key=key, category=category, lock_storage=lockstring, value=value + ) + self._storage[(key, category)] = new_attr + self._category_storage[category].append(new_attr) + return new_attr
+ +
[docs] def do_update_attribute(self, attr, value, strvalue): + attr.value = value
+ +
[docs] def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + """ + No need to bother saving anything. Just set some values. + """ + attr_obj.db_category = category + attr_obj.db_lock_storage = lock_storage if lock_storage else "" + attr_obj.value = new_value
+ +
[docs] def do_batch_finish(self, attr_objs): + """ + Nothing to do here for In-Memory. + + Args: + attr_objs (list of IAttribute): The Attributes created/updated thus far. + """ + pass
+ +
[docs] def do_delete_attribute(self, attr): + """ + Removes the Attribute from local storage. Once it's out of the cache, garbage collection + will handle the rest. + + Args: + attr (IAttribute): The attribute to delete. + """ + del self._storage[(attr.key, attr.category)] + self._category_storage[attr.category].remove(attr)
+ + +
[docs]class ModelAttributeBackend(IAttributeBackend): + """ + Uses Django models for storing Attributes. + """ + + _attrclass = Attribute + _m2m_fieldname = "db_attributes" + +
[docs] def __init__(self, handler, attrtype): + super().__init__(handler, attrtype) + self._model = to_str(handler.obj.__dbclass__.__name__.lower())
+ +
[docs] def query_all(self): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + } + return [ + conn.attribute + for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) + ]
+ +
[docs] def query_key(self, key, category): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + "attribute__db_key__iexact": key.lower(), + "attribute__db_category__iexact": category.lower() if category else None, + } + if not self.obj.pk: + return [] + return getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
+ +
[docs] def query_category(self, category): + query = { + "%s__id" % self._model: self._objid, + "attribute__db_model__iexact": self._model, + "attribute__db_attrtype": self._attrtype, + "attribute__db_category__iexact": category.lower() if category else None, + } + return [ + conn.attribute + for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) + ]
+ +
[docs] def do_create_attribute(self, key, category, lockstring, value, strvalue): + kwargs = { + "db_key": key, + "db_category": category, + "db_model": self._model, + "db_lock_storage": lockstring if lockstring else "", + "db_attrtype": self._attrtype, + } + if strvalue: + kwargs["db_value"] = None + kwargs["db_strvalue"] = value + else: + kwargs["db_value"] = to_pickle(value) + kwargs["db_strvalue"] = None + new_attr = self._attrclass(**kwargs) + new_attr.save() + getattr(self.obj, self._m2m_fieldname).add(new_attr) + self._set_cache(key, category, new_attr) + return new_attr
+ +
[docs] def do_update_attribute(self, attr, value, strvalue): + if strvalue: + attr.value = None + attr.db_strvalue = value + else: + attr.value = value + attr.db_strvalue = None + attr.save(update_fields=["db_strvalue", "db_value"])
+ +
[docs] def do_batch_update_attribute(self, attr_obj, category, lock_storage, new_value, strvalue): + attr_obj.db_category = category + attr_obj.db_lock_storage = lock_storage if lock_storage else "" + if strvalue: + # store as a simple string (will not notify OOB handlers) + attr_obj.db_strvalue = new_value + attr_obj.value = None + else: + # store normally (this will also notify OOB handlers) + attr_obj.value = new_value + attr_obj.db_strvalue = None + attr_obj.save(update_fields=["db_strvalue", "db_value", "db_category", "db_lock_storage"])
+ +
[docs] def do_batch_finish(self, attr_objs): + # Add new objects to m2m field all at once + getattr(self.obj, self._m2m_fieldname).add(*attr_objs)
+ +
[docs] def do_delete_attribute(self, attr): + try: + attr.delete() + except AssertionError: + # This could happen if the Attribute has already been deleted. + pass
+ + +
[docs]class AttributeHandler: + """ + Handler for adding Attributes to the object. + """ + + _attrcreate = "attrcreate" + _attredit = "attredit" + _attrread = "attrread" + _attrtype = None + +
[docs] def __init__(self, obj, backend_class): + """ + Setup the AttributeHandler. + + Args: + obj (TypedObject): An Account, Object, Channel, ServerSession (not technically a typed + object), etc. backend_class (IAttributeBackend class): The class of the backend to + use. + """ + self.obj = obj + self.backend = backend_class(self, self._attrtype)
+ +
[docs] def has(self, key=None, category=None): + """ + Checks if the given Attribute (or list of Attributes) exists on + the object. + + Args: + key (str or iterable): The Attribute key or keys to check for. + If `None`, search by category. + category (str or None): Limit the check to Attributes with this + category (note, that `None` is the default category). + + Returns: + has_attribute (bool or list): If the Attribute exists on + this object or not. If `key` was given as an iterable then + the return is a list of booleans. + + """ + ret = [] + category = category.strip().lower() if category is not None else None + for keystr in make_iter(key): + keystr = key.strip().lower() + ret.extend(bool(attr) for attr in self.backend.get(keystr, category)) + return ret[0] if len(ret) == 1 else ret
+ +
[docs] def get( + self, + key=None, + default=None, + category=None, + return_obj=False, + strattr=False, + raise_exception=False, + accessing_obj=None, + default_access=True, + return_list=False, + ): + """ + Get the Attribute. + + Args: + key (str or list, optional): the attribute identifier or + multiple attributes to get. if a list of keys, the + method will return a list. + default (any, optional): The value to return if an + Attribute was not defined. If set, it will be returned in + a one-item list. + category (str, optional): the category within which to + retrieve attribute(s). + return_obj (bool, optional): If set, the return is not the value of the + Attribute but the Attribute object itself. + strattr (bool, optional): Return the `strvalue` field of + the Attribute rather than the usual `value`, this is a + string-only value for quick database searches. + raise_exception (bool, optional): When an Attribute is not + found, the return from this is usually `default`. If this + is set, an exception is raised instead. + accessing_obj (object, optional): If set, an `attrread` + permission lock will be checked before returning each + looked-after Attribute. + default_access (bool, optional): If no `attrread` lock is set on + object, this determines if the lock should then be passed or not. + return_list (bool, optional): Always return a list, also if there is only + one or zero matches found. + + Returns: + result (any or list): One or more matches for keys and/or + categories. Each match will be the value of the found Attribute(s) + unless `return_obj` is True, at which point it will be the + attribute object itself or None. If `return_list` is True, this + will always be a list, regardless of the number of elements. + + Raises: + AttributeError: If `raise_exception` is set and no matching Attribute + was found matching `key`. + + """ + + ret = [] + for keystr in make_iter(key): + # it's okay to send a None key + attr_objs = self.backend.get(keystr, category) + if attr_objs: + ret.extend(attr_objs) + elif raise_exception: + raise AttributeError + elif return_obj: + ret.append(None) + + if accessing_obj: + # check 'attrread' locks + ret = [ + attr + for attr in ret + if attr.access(accessing_obj, self._attrread, default=default_access) + ] + if strattr: + ret = ret if return_obj else [attr.strvalue for attr in ret if attr] + else: + ret = ret if return_obj else [attr.value for attr in ret if attr] + + if return_list: + return ret if ret else [default] if default is not None else [] + return ret[0] if ret and len(ret) == 1 else ret or default
+ +
[docs] def add( + self, + key, + value, + category=None, + lockstring="", + strattr=False, + accessing_obj=None, + default_access=True, + ): + """ + Add attribute to object, with optional `lockstring`. + + Args: + key (str): An Attribute name to add. + value (any or str): The value of the Attribute. If + `strattr` keyword is set, this *must* be a string. + category (str, optional): The category for the Attribute. + The default `None` is the normal category used. + lockstring (str, optional): A lock string limiting access + to the attribute. + strattr (bool, optional): Make this a string-only Attribute. + This is only ever useful for optimization purposes. + accessing_obj (object, optional): An entity to check for + the `attrcreate` access-type. If not passing, this method + will be exited. + default_access (bool, optional): What access to grant if + `accessing_obj` is given but no lock of the type + `attrcreate` is defined on the Attribute in question. + + """ + if accessing_obj and not self.obj.access( + accessing_obj, self._attrcreate, default=default_access + ): + # check create access + return + + if not key: + return + + category = category.strip().lower() if category is not None else None + keystr = key.strip().lower() + attr_obj = self.backend.get(key, category) + + if attr_obj: + # update an existing attribute object + attr_obj = attr_obj[0] + self.backend.update_attribute(attr_obj, value, strattr) + else: + # create a new Attribute (no OOB handlers can be notified) + self.backend.create_attribute(keystr, category, lockstring, value, strattr)
+ +
[docs] def batch_add(self, *args, **kwargs): + """ + Batch-version of `add()`. This is more efficient than + repeat-calling add when having many Attributes to add. + + Args: + *args (tuple): Each argument should be a tuples (can be of varying + length) representing the Attribute to add to this object. + Supported tuples are + + - (key, value) + - (key, value, category) + - (key, value, category, lockstring) + - (key, value, category, lockstring, default_access) + + Keyword Args: + strattr (bool): If `True`, value must be a string. This + will save the value without pickling which is less + flexible but faster to search (not often used except + internally). + + Raises: + RuntimeError: If trying to pass a non-iterable as argument. + + Notes: + The indata tuple order matters, so if you want a lockstring + but no category, set the category to `None`. This method + does not have the ability to check editing permissions like + normal .add does, and is mainly used internally. It does not + use the normal self.add but apply the Attributes directly + to the database. + + """ + self.backend.batch_add(*args, **kwargs)
+ +
[docs] def remove( + self, + key=None, + category=None, + raise_exception=False, + accessing_obj=None, + default_access=True, + ): + """ + Remove attribute or a list of attributes from object. + + Args: + key (str or list, optional): An Attribute key to remove or a list of keys. If + multiple keys, they must all be of the same `category`. If None and + category is not given, remove all Attributes. + category (str, optional): The category within which to + remove the Attribute. + raise_exception (bool, optional): If set, not finding the + Attribute to delete will raise an exception instead of + just quietly failing. + accessing_obj (object, optional): An object to check + against the `attredit` lock. If not given, the check will + be skipped. + default_access (bool, optional): The fallback access to + grant if `accessing_obj` is given but there is no + `attredit` lock set on the Attribute in question. + + Raises: + AttributeError: If `raise_exception` is set and no matching Attribute + was found matching `key`. + + Notes: + If neither key nor category is given, this acts as clear(). + + """ + + if key is None: + self.clear( + category=category, accessing_obj=accessing_obj, default_access=default_access + ) + return + + category = category.strip().lower() if category is not None else None + + for keystr in make_iter(key): + keystr = keystr.lower() + + attr_objs = self.backend.get(keystr, category) + for attr_obj in attr_objs: + if not ( + accessing_obj + and not attr_obj.access(accessing_obj, self._attredit, default=default_access) + ): + self.backend.delete_attribute(attr_obj) + if not attr_objs and raise_exception: + raise AttributeError
+ +
[docs] def clear(self, category=None, accessing_obj=None, default_access=True): + """ + Remove all Attributes on this object. + + Args: + category (str, optional): If given, clear only Attributes + of this category. + accessing_obj (object, optional): If given, check the + `attredit` lock on each Attribute before continuing. + default_access (bool, optional): Use this permission as + fallback if `access_obj` is given but there is no lock of + type `attredit` on the Attribute in question. + + """ + self.backend.clear_attributes(category, accessing_obj, default_access)
+ +
[docs] def all(self, category=None, accessing_obj=None, default_access=True): + """ + Return all Attribute objects on this object, regardless of category. + + Args: + category (str, optional): A given category to limit results to. + accessing_obj (object, optional): Check the `attrread` + lock on each attribute before returning them. If not + given, this check is skipped. + default_access (bool, optional): Use this permission as a + fallback if `accessing_obj` is given but one or more + Attributes has no lock of type `attrread` defined on them. + + Returns: + Attributes (list): All the Attribute objects (note: Not + their values!) in the handler. + + """ + attrs = self.backend.get_all_attributes() + if category: + attrs = [attr for attr in attrs if attr.category == category] + + if accessing_obj: + return [ + attr + for attr in attrs + if attr.access(accessing_obj, self._attrread, default=default_access) + ] + else: + return attrs
+ +
[docs] def reset_cache(self): + self.backend.reset_cache()
+ + +# DbHolders for .db and .ndb properties on Typeclasses. + +_GA = object.__getattribute__ +_SA = object.__setattr__ + + +
[docs]class DbHolder: + "Holder for allowing property access of attributes" + +
[docs] def __init__(self, obj, name, manager_name="attributes"): + _SA(self, name, _GA(obj, manager_name)) + _SA(self, "name", name)
+ + def __getattribute__(self, attrname): + if attrname == "all": + # we allow to overload our default .all + attr = _GA(self, _GA(self, "name")).get("all") + return attr if attr else _GA(self, "all") + return _GA(self, _GA(self, "name")).get(attrname) + + def __setattr__(self, attrname, value): + _GA(self, _GA(self, "name")).add(attrname, value) + + def __delattr__(self, attrname): + _GA(self, _GA(self, "name")).remove(attrname) + +
[docs] def get_all(self): + return _GA(self, _GA(self, "name")).backend.get_all_attributes()
+ + all = property(get_all)
+ + +# +# Nick templating +# + +""" +This supports the use of replacement templates in nicks: + +This happens in two steps: + +1) The user supplies a template that is converted to a regex according + to the unix-like templating language. +2) This regex is tested against nicks depending on which nick replacement + strategy is considered (most commonly inputline). +3) If there is a template match and there are templating markers, + these are replaced with the arguments actually given. + +@desc $1 $2 $3 + +This will be converted to the following regex: + + \@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+) + +Supported template markers (through fnmatch) + * matches anything (non-greedy) -> .*? + ? matches any single character -> + [seq] matches any entry in sequence + [!seq] matches entries not in sequence +Custom arg markers + $N argument position (1-99) + +""" +_RE_OR = re.compile(r"(?<!\\)\|") +_RE_NICK_RE_ARG = re.compile(r"arg([1-9][0-9]?)") +_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") +_RE_NICK_RAW_ARG = re.compile(r"(\$)([1-9][0-9]?)") +_RE_NICK_SPACE = re.compile(r"\\ ") + + +
[docs]class NickTemplateInvalid(ValueError): + pass
+ + +
[docs]def initialize_nick_templates(pattern, replacement, pattern_is_regex=False): + """ + Initialize the nick templates for matching and remapping a string. + + Args: + pattern (str): The pattern to be used for nick recognition. This will + be parsed for shell patterns into a regex, unless `pattern_is_regex` + is `True`, in which case it must be an already valid regex string. In + this case, instead of `$N`, numbered arguments must instead be given + as matching groups named as `argN`, such as `(?P<arg1>.+?)`. + replacement (str): The template to be used to replace the string + matched by the pattern. This can contain `$N` markers and is never + parsed into a regex. + pattern_is_regex (bool): If set, `pattern` is a full regex string + instead of containing shell patterns. + + Returns: + regex, template (str): Regex to match against strings and template + with markers ``{arg1}, {arg2}``, etc for replacement using the standard + `.format` method. + + Raises: + evennia.typecalasses.attributes.NickTemplateInvalid: If the in/out + template does not have a matching number of `$args`. + + Examples: + - `pattern` (shell syntax): `"grin $1"` + - `pattern` (regex): `"grin (?P<arg1.+?>)"` + - `replacement`: `"emote gives a wicked grin to $1"` + + """ + + # create the regex from the pattern + if pattern_is_regex: + # Note that for a regex we can't validate in the way we do for the shell + # pattern, since you may have complex OR statements or optional arguments. + + # Explicit regex given from the onset - this already contains argN + # groups. we need to split out any | - separated parts so we can + # attach the line-break/ending extras all regexes require. + pattern_regex_string = r"|".join( + or_part + r"(?:[\n\r]*?)\Z" for or_part in _RE_OR.split(pattern) + ) + + else: + # Shell pattern syntax - convert $N to argN groups + # for the shell pattern we make sure we have matching $N on both sides + pattern_args = [match.group(1) for match in _RE_NICK_RAW_ARG.finditer(pattern)] + replacement_args = [match.group(1) for match in _RE_NICK_RAW_ARG.finditer(replacement)] + if set(pattern_args) != set(replacement_args): + # We don't have the same amount of argN/$N tags in input/output. + raise NickTemplateInvalid("Nicks: Both in/out-templates must contain the same $N tags.") + + # generate regex from shell pattern + pattern_regex_string = fnmatch.translate(pattern) + pattern_regex_string = _RE_NICK_SPACE.sub(r"\\s+", pattern_regex_string) + pattern_regex_string = _RE_NICK_ARG.sub( + lambda m: "(?P<arg%s>.+?)" % m.group(2), pattern_regex_string + ) + # we must account for a possible line break coming over the wire + pattern_regex_string = pattern_regex_string[:-2] + r"(?:[\n\r]*?)\Z" + + # map the replacement to match the arg1 group-names, to make replacement easy + replacement_string = _RE_NICK_RAW_ARG.sub(lambda m: "{arg%s}" % m.group(2), replacement) + + return pattern_regex_string, replacement_string
+ + +
[docs]def parse_nick_template(string, template_regex, outtemplate): + """ + Parse a text using a template and map it to another template + + Args: + string (str): The input string to process + template_regex (regex): A template regex created with + initialize_nick_template. + outtemplate (str): The template to which to map the matches + produced by the template_regex. This should have $1, $2, + etc to match the template-regex. Un-found $N-markers (possible if + the regex has optional matching groups) are replaced with empty + strings. + + """ + match = template_regex.match(string) + if match: + matchdict = { + key: value if value is not None else "" for key, value in match.groupdict().items() + } + return True, outtemplate.format_map(matchdict) + return False, string
+ + +
[docs]class NickHandler(AttributeHandler): + """ + Handles the addition and removal of Nicks. Nicks are special + versions of Attributes with an `_attrtype` hardcoded to `nick`. + They also always use the `strvalue` fields for their data. + + """ + + _attrtype = "nick" + +
[docs] def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._regex_cache = {}
+ +
[docs] def has(self, key, category="inputline"): + """ + Args: + key (str or iterable): The Nick key or keys to check for. + category (str): Limit the check to Nicks with this + category (note, that `None` is the default category). + + Returns: + has_nick (bool or list): If the Nick exists on this object + or not. If `key` was given as an iterable then the return + is a list of booleans. + + """ + return super().has(key, category=category)
+ +
[docs] def get(self, key=None, category="inputline", return_tuple=False, **kwargs): + """ + Get the replacement value matching the given key and category + + Args: + key (str or list, optional): the attribute identifier or + multiple attributes to get. if a list of keys, the + method will return a list. + category (str, optional): the category within which to + retrieve the nick. The "inputline" means replacing data + sent by the user. + return_tuple (bool, optional): return the full nick tuple rather + than just the replacement. For non-template nicks this is just + a string. + kwargs (any, optional): These are passed on to `AttributeHandler.get`. + + Returns: + str or tuple: The nick replacement string or nick tuple. + + """ + if return_tuple or "return_obj" in kwargs: + return super().get(key=key, category=category, **kwargs) + else: + retval = super().get(key=key, category=category, **kwargs) + if retval: + return ( + retval[3] + if isinstance(retval, tuple) + else [tup[3] for tup in make_iter(retval)] + ) + return None
+ +
[docs] def add(self, pattern, replacement, category="inputline", pattern_is_regex=False, **kwargs): + """ + Add a new nick, a mapping pattern -> replacement. + + Args: + pattern (str): A pattern to match for. This will be parsed for + shell patterns using the `fnmatch` library and can contain + `$N`-markers to indicate the locations of arguments to catch. If + `pattern_is_regex=True`, this must instead be a valid regular + expression and the `$N`-markers must be named `argN` that matches + numbered regex groups (see examples). + replacement (str): The string (or template) to replace `key` with + (the "nickname"). This may contain `$N` markers to indicate where to + place the argument-matches + category (str, optional): the category within which to + retrieve the nick. The "inputline" means replacing data + sent by the user. + pattern_is_regex (bool): If `True`, the `pattern` will be parsed as a + raw regex string. Instead of using `$N` markers in this string, one + then must mark numbered arguments as a named regex-groupd named `argN`. + For example, `(?P<arg1>.+?)` will match the behavior of using `$1` + in the shell pattern. + **kwargs (any, optional): These are passed on to `AttributeHandler.get`. + + Notes: + For most cases, the shell-pattern is much shorter and easier. The + regex pattern form can be useful for more complex matchings though, + for example in order to add optional arguments, such as with + `(?P<argN>.*?)`. + + Example: + - pattern (default shell syntax): `"gr $1 at $2"` + - pattern (with pattern_is_regex=True): `r"gr (?P<arg1>.+?) at (?P<arg2>.+?)"` + - replacement: `"emote With a flourish, $1 grins at $2."` + + """ + nick_regex, nick_template = initialize_nick_templates( + pattern, replacement, pattern_is_regex=pattern_is_regex + ) + super().add( + pattern, (nick_regex, nick_template, pattern, replacement), category=category, **kwargs + )
+ +
[docs] def remove(self, key, category="inputline", **kwargs): + """ + Remove Nick with matching category. + + Args: + key (str): A key for the nick to match for. + category (str, optional): the category within which to + removethe nick. The "inputline" means replacing data + sent by the user. + kwargs (any, optional): These are passed on to `AttributeHandler.get`. + + """ + super().remove(key, category=category, **kwargs)
+ +
[docs] def nickreplace(self, raw_string, categories=("inputline", "channel"), include_account=True): + """ + Apply nick replacement of entries in raw_string with nick replacement. + + Args: + raw_string (str): The string in which to perform nick + replacement. + categories (tuple, optional): Replacement categories in + which to perform the replacement, such as "inputline", + "channel" etc. + include_account (bool, optional): Also include replacement + with nicks stored on the Account level. + kwargs (any, optional): Not used. + + Returns: + string (str): A string with matching keys replaced with + their nick equivalents. + + """ + nicks = {} + for category in make_iter(categories): + nicks.update( + { + nick.key: nick + for nick in make_iter(self.get(category=category, return_obj=True)) + if nick and nick.key + } + ) + if include_account and self.obj.has_account: + for category in make_iter(categories): + nicks.update( + { + nick.key: nick + for nick in make_iter( + self.obj.account.nicks.get(category=category, return_obj=True) + ) + if nick and nick.key + } + ) + for key, nick in nicks.items(): + nick_regex, template, _, _ = nick.value + regex = self._regex_cache.get(nick_regex) + if not regex: + try: + regex = re.compile(nick_regex, re.I + re.DOTALL + re.U) + except re.error: + from evennia.utils import logger + + logger.log_trace("Probably nick being created with unvalidated regex mapping.") + continue + self._regex_cache[nick_regex] = regex + + is_match, raw_string = parse_nick_template(raw_string, regex, template) + if is_match: + break + return raw_string
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/typeclasses/managers.html b/docs/latest/_modules/evennia/typeclasses/managers.html new file mode 100644 index 0000000000..9cb2128b65 --- /dev/null +++ b/docs/latest/_modules/evennia/typeclasses/managers.html @@ -0,0 +1,951 @@ + + + + + + + + evennia.typeclasses.managers — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.typeclasses.managers

+"""
+This implements the common managers that are used by the
+abstract models in dbobjects.py (and which are thus shared by
+all Attributes and TypedObjects).
+
+"""
+import shlex
+
+from django.db.models import Count, ExpressionWrapper, F, FloatField, Q
+from django.db.models.functions import Cast
+
+from evennia.typeclasses.attributes import Attribute
+from evennia.typeclasses.tags import Tag
+from evennia.utils import idmapper
+from evennia.utils.utils import class_from_module, make_iter, variable_from_module
+
+__all__ = ("TypedObjectManager",)
+_GA = object.__getattribute__
+_Tag = None
+
+
+# Managers
+
+
+
[docs]class TypedObjectManager(idmapper.manager.SharedMemoryManager): + """ + Common ObjectManager for all dbobjects. + + """ + + # common methods for all typed managers. These are used + # in other methods. Returns querysets. + + # Attribute manager methods +
[docs] def get_attribute( + self, key=None, category=None, value=None, strvalue=None, obj=None, attrtype=None, **kwargs + ): + """ + Return Attribute objects by key, by category, by value, by strvalue, by + object (it is stored on) or with a combination of those criteria. + + Args: + key (str, optional): The attribute's key to search for + category (str, optional): The category of the attribute(s) to search for. + value (str, optional): The attribute value to search for. + Note that this is not a very efficient operation since it + will query for a pickled entity. Mutually exclusive to + `strvalue`. + strvalue (str, optional): The str-value to search for. + Most Attributes will not have strvalue set. This is + mutually exclusive to the `value` keyword and will take + precedence if given. + obj (Object, optional): On which object the Attribute to + search for is. + attrype (str, optional): An attribute-type to search for. + By default this is either `None` (normal Attributes) or + `"nick"`. + **kwargs (any): Currently unused. Reserved for future use. + + Returns: + list: The matching Attributes. + + """ + dbmodel = self.model.__dbclass__.__name__.lower() + query = [("attribute__db_attrtype", attrtype), ("attribute__db_model", dbmodel)] + if obj: + query.append(("%s__id" % self.model.__dbclass__.__name__.lower(), obj.id)) + if key: + query.append(("attribute__db_key", key)) + if category: + query.append(("attribute__db_category", category)) + if strvalue: + query.append(("attribute__db_strvalue", strvalue)) + if value: + # no reason to make strvalue/value mutually exclusive at this level + query.append(("attribute__db_value", value)) + return Attribute.objects.filter( + pk__in=self.model.db_attributes.through.objects.filter(**dict(query)).values_list( + "attribute_id", flat=True + ) + )
+ +
[docs] def get_nick(self, key=None, category=None, value=None, strvalue=None, obj=None): + """ + Get a nick, in parallel to `get_attribute`. + + Args: + key (str, optional): The nicks's key to search for + category (str, optional): The category of the nicks(s) to search for. + value (str, optional): The attribute value to search for. Note that this + is not a very efficient operation since it will query for a pickled + entity. Mutually exclusive to `strvalue`. + strvalue (str, optional): The str-value to search for. Most Attributes + will not have strvalue set. This is mutually exclusive to the `value` + keyword and will take precedence if given. + obj (Object, optional): On which object the Attribute to search for is. + + Returns: + nicks (list): The matching Nicks. + + """ + return self.get_attribute( + key=key, category=category, value=value, strvalue=strvalue, obj=obj + )
+ +
[docs] def get_by_attribute( + self, key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs + ): + """ + Return objects having attributes with the given key, category, + value, strvalue or combination of those criteria. + + Args: + key (str, optional): The attribute's key to search for + category (str, optional): The category of the attribute + to search for. + value (str, optional): The attribute value to search for. + Note that this is not a very efficient operation since it + will query for a pickled entity. Mutually exclusive to + `strvalue`. + strvalue (str, optional): The str-value to search for. + Most Attributes will not have strvalue set. This is + mutually exclusive to the `value` keyword and will take + precedence if given. + attrype (str, optional): An attribute-type to search for. + By default this is either `None` (normal Attributes) or + `"nick"`. + kwargs (any): Currently unused. Reserved for future use. + + Returns: + obj (list): Objects having the matching Attributes. + + """ + dbmodel = self.model.__dbclass__.__name__.lower() + query = [ + ("db_attributes__db_attrtype", attrtype), + ("db_attributes__db_model", dbmodel), + ] + if key: + query.append(("db_attributes__db_key", key)) + if category: + query.append(("db_attributes__db_category", category)) + if strvalue: + query.append(("db_attributes__db_strvalue", strvalue)) + elif value: + # strvalue and value are mutually exclusive + query.append(("db_attributes__db_value", value)) + return self.filter(**dict(query))
+ +
[docs] def get_by_nick(self, key=None, nick=None, category="inputline"): + """ + Get object based on its key or nick. + + Args: + key (str, optional): The attribute's key to search for + nick (str, optional): The nickname to search for + category (str, optional): The category of the nick + to search for. + + Returns: + obj (list): Objects having the matching Nicks. + + """ + return self.get_by_attribute(key=key, category=category, strvalue=nick, attrtype="nick")
+ + # Tag manager methods + +
[docs] def get_tag(self, key=None, category=None, obj=None, tagtype=None, global_search=False): + """ + Return Tag objects by key, by category, by object (it is + stored on) or with a combination of those criteria. + + Args: + key (str, optional): The Tag's key to search for + category (str, optional): The Tag of the attribute(s) + to search for. + obj (Object, optional): On which object the Tag to + search for is. + tagtype (str, optional): One of `None` (normal tags), + "alias" or "permission" + global_search (bool, optional): Include all possible tags, + not just tags on this object + + Returns: + tag (list): The matching Tags. + + """ + global _Tag + if not _Tag: + from evennia.typeclasses.models import Tag as _Tag + dbmodel = self.model.__dbclass__.__name__.lower() + if global_search: + # search all tags using the Tag model + query = [("db_tagtype", tagtype), ("db_model", dbmodel)] + if obj: + query.append(("id", obj.id)) + if key: + query.append(("db_key", key)) + if category: + query.append(("db_category", category)) + else: + query.append(("db_category", None)) + return _Tag.objects.filter(**dict(query)) + else: + # search only among tags stored on on this model + query = [("tag__db_tagtype", tagtype), ("tag__db_model", dbmodel)] + if obj: + query.append(("%s__id" % self.model.__name__.lower(), obj.id)) + if key: + query.append(("tag__db_key", key)) + if category: + query.append(("tag__db_category", category)) + return Tag.objects.filter( + pk__in=self.model.db_tags.through.objects.filter(**dict(query)).values_list( + "tag_id", flat=True + ) + )
+ +
[docs] def get_permission(self, key=None, category=None, obj=None): + """ + Get a permission from the database. + + Args: + key (str, optional): The permission's identifier. + category (str, optional): The permission's category. + obj (object, optional): The object on which this Tag is set. + + Returns: + permission (list): Permission objects. + + """ + return self.get_tag(key=key, category=category, obj=obj, tagtype="permission")
+ +
[docs] def get_alias(self, key=None, category=None, obj=None): + """ + Get an alias from the database. + + Args: + key (str, optional): The permission's identifier. + category (str, optional): The permission's category. + obj (object, optional): The object on which this Tag is set. + + Returns: + alias (list): Alias objects. + + """ + return self.get_tag(key=key, category=category, obj=obj, tagtype="alias")
+ +
[docs] def get_by_tag(self, key=None, category=None, tagtype=None, **kwargs): + """ + Return objects having tags with a given key or category or combination of the two. + Also accepts multiple tags/category/tagtype + + Args: + key (str or list, optional): Tag key or list of keys. Not case sensitive. + category (str or list, optional): Tag category. Not case sensitive. + If `key` is a list, a single category can either apply to all + keys in that list or this must be a list matching the `key` + list element by element. If no `key` is given, all objects with + tags of this category are returned. + tagtype (str, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. This always apply to all queried tags. + + Keyword Args: + match (str): "all" (default) or "any"; determines whether the + target object must be tagged with ALL of the provided + tags/categories or ANY single one. ANY will perform a weighted + sort, so objects with more tag matches will outrank those with + fewer tag matches. + + Returns: + objects (list): Objects with matching tag. + + Raises: + IndexError: If `key` and `category` are both lists and `category` is shorter + than `key`. + + """ + if not (key or category): + return [] + + global _Tag + if not _Tag: + from evennia.typeclasses.models import Tag as _Tag + + anymatch = "any" == kwargs.get("match", "all").lower().strip() + + keys = make_iter(key) if key else [] + categories = make_iter(category) if category else [] + n_keys = len(keys) + n_categories = len(categories) + unique_categories = set(categories) + n_unique_categories = len(unique_categories) + + dbmodel = self.model.__dbclass__.__name__.lower() + query = ( + self.filter(db_tags__db_tagtype__iexact=tagtype, db_tags__db_model__iexact=dbmodel) + .distinct() + .order_by("id") + ) + + if n_keys > 0: + # keys and/or categories given + if n_categories == 0: + categories = [None for _ in range(n_keys)] + elif n_categories == 1 and n_keys > 1: + cat = categories[0] + categories = [cat for _ in range(n_keys)] + elif 1 < n_categories < n_keys: + raise IndexError( + "get_by_tag needs a single category or a list of categories " + "the same length as the list of tags." + ) + clauses = Q() + for ikey, key in enumerate(keys): + # ANY mode; must match any one of the given tags/categories + clauses |= Q(db_key__iexact=key, db_category__iexact=categories[ikey]) + else: + # only one or more categories given + clauses = Q() + # ANY mode; must match any one of them + for category in unique_categories: + clauses |= Q(db_category__iexact=category) + + tags = _Tag.objects.filter(clauses) + query = query.filter(db_tags__in=tags).annotate( + matches=Count("db_tags__pk", filter=Q(db_tags__in=tags), distinct=True) + ) + + if anymatch: + # ANY: Match any single tag, ordered by weight + query = query.order_by("-matches") + else: + # Default ALL: Match all of the tags and optionally more + n_req_tags = n_keys if n_keys > 0 else n_unique_categories + query = query.filter(matches__gte=n_req_tags) + + return query
+ +
[docs] def get_by_permission(self, key=None, category=None): + """ + Return objects having permissions with a given key or category or + combination of the two. + + Args: + key (str, optional): Permissions key. Not case sensitive. + category (str, optional): Permission category. Not case sensitive. + Returns: + objects (list): Objects with matching permission. + """ + return self.get_by_tag(key=key, category=category, tagtype="permission")
+ +
[docs] def get_by_alias(self, key=None, category=None): + """ + Return objects having aliases with a given key or category or + combination of the two. + + Args: + key (str, optional): Alias key. Not case sensitive. + category (str, optional): Alias category. Not case sensitive. + Returns: + objects (list): Objects with matching alias. + """ + return self.get_by_tag(key=key, category=category, tagtype="alias")
+ +
[docs] def create_tag(self, key=None, category=None, data=None, tagtype=None): + """ + Create a new Tag of the base type associated with this + object. This makes sure to create case-insensitive tags. + If the exact same tag configuration (key+category+tagtype+dbmodel) + exists on the model, a new tag will not be created, but an old + one returned. + + + Args: + key (str, optional): Tag key. Not case sensitive. + category (str, optional): Tag category. Not case sensitive. + data (str, optional): Extra information about the tag. + tagtype (str or None, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. + Notes: + The `data` field is not part of the uniqueness of the tag: + Setting `data` on an existing tag will overwrite the old + data field. It is intended only as a way to carry + information about the tag (like a help text), not to carry + any information about the tagged objects themselves. + + """ + data = str(data) if data is not None else None + # try to get old tag + + dbmodel = self.model.__dbclass__.__name__.lower() + tag = self.get_tag(key=key, category=category, tagtype=tagtype, global_search=True) + if tag and data is not None: + # get tag from list returned by get_tag + tag = tag[0] + # overload data on tag + tag.db_data = data + tag.save() + elif not tag: + # create a new tag + global _Tag + if not _Tag: + from evennia.typeclasses.models import Tag as _Tag + tag = _Tag.objects.create( + db_key=key.strip().lower() if key is not None else None, + db_category=category.strip().lower() if category and key is not None else None, + db_data=data, + db_model=dbmodel, + db_tagtype=tagtype.strip().lower() if tagtype is not None else None, + ) + tag.save() + return make_iter(tag)[0]
+ +
[docs] def dbref(self, dbref, reqhash=True): + """ + Determing if input is a valid dbref. + + Args: + dbref (str or int): A possible dbref. + reqhash (bool, optional): If the "#" is required for this + to be considered a valid hash. + + Returns: + dbref (int or None): The integer part of the dbref. + + Notes: + Valid forms of dbref (database reference number) are + either a string '#N' or an integer N. + + """ + if reqhash and not (isinstance(dbref, str) and dbref.startswith("#")): + return None + if isinstance(dbref, str): + dbref = dbref.lstrip("#") + try: + if int(dbref) < 0: + return None + except Exception: + return None + return dbref
+ +
[docs] def get_id(self, dbref): + """ + Find object with given dbref. + + Args: + dbref (str or int): The id to search for. + + Returns: + object (TypedObject): The matched object. + + """ + dbref = self.dbref(dbref, reqhash=False) + try: + return self.get(id=dbref) + except self.model.DoesNotExist: + pass + return None
+ + + + search_dbref = dbref_search # alias + +
[docs] def get_dbref_range(self, min_dbref=None, max_dbref=None): + """ + Get objects within a certain range of dbrefs. + + Args: + min_dbref (int): Start of dbref range. + max_dbref (int): End of dbref range (inclusive) + + Returns: + objects (list): TypedObjects with dbrefs within + the given dbref ranges. + + """ + retval = super().all() + if min_dbref is not None: + retval = retval.filter(id__gte=self.dbref(min_dbref, reqhash=False)) + if max_dbref is not None: + retval = retval.filter(id__lte=self.dbref(max_dbref, reqhash=False)) + return retval
+ +
[docs] def get_typeclass_totals(self, *args, **kwargs) -> object: + """ + Returns a queryset of typeclass composition statistics. + + Returns: + qs (Queryset): A queryset of dicts containing the typeclass (name), + the count of objects with that typeclass and a float representing + the percentage of objects associated with the typeclass. + + """ + return ( + self.values("db_typeclass_path") + .distinct() + .annotate( + # Get count of how many objects for each typeclass exist + count=Count("db_typeclass_path") + ) + .annotate( + # Rename db_typeclass_path field to something more human + typeclass=F("db_typeclass_path"), + # Calculate this class' percentage of total composition + percent=ExpressionWrapper( + ((F("count") / float(self.count())) * 100.0), + output_field=FloatField(), + ), + ) + .values("typeclass", "count", "percent") + )
+ +
[docs] def object_totals(self): + """ + Get info about database statistics. + + Returns: + census (dict): A dictionary `{typeclass_path: number, ...}` with + all the typeclasses active in-game as well as the number + of such objects defined (i.e. the number of database + object having that typeclass set on themselves). + + """ + stats = self.get_typeclass_totals().order_by("typeclass") + return {x.get("typeclass"): x.get("count") for x in stats}
+ +
+ + +class TypeclassManager(TypedObjectManager): + """ + Manager for the typeclasses. The main purpose of this manager is + to limit database queries to the given typeclass despite all + typeclasses technically being defined in the same core database + model. + + """ + + # object-manager methods + def smart_search(self, query): + """ + Search by supplying a string with optional extra search criteria to aid the query. + + Args: + query (str): A search criteria that accepts extra search criteria on the following + forms: + + [key|alias|#dbref...] + [tag==<tagstr>[:category]...] + [attr==<key>:<value>:category...] + + All three can be combined in the same query, separated by spaces. + + Returns: + matches (queryset): A queryset result matching all queries exactly. If wanting to use + spaces or ==, != in tags or attributes, enclose them in quotes. + + Example: + house = smart_search("key=foo alias=bar tag=house:building tag=magic attr=color:red") + + Note: + The flexibility of this method is limited by the input line format. Tag/attribute + matching only works for matching primitives. For even more complex queries, such as + 'in' operations or object field matching, use the full django query language. + + """ + # shlex splits by spaces unless escaped by quotes + querysplit = shlex.split(query) + queries, plustags, plusattrs, negtags, negattrs = [], [], [], [], [] + for ipart, part in enumerate(querysplit): + key, rest = part, "" + if ":" in part: + key, rest = part.split(":", 1) + # tags are on the form tag or tag:category + if key.startswith("tag=="): + plustags.append((key[5:], rest)) + continue + elif key.startswith("tag!="): + negtags.append((key[5:], rest)) + continue + # attrs are on the form attr:value or attr:value:category + elif rest: + value, category = rest, "" + if ":" in rest: + value, category = rest.split(":", 1) + if key.startswith("attr=="): + plusattrs.append((key[7:], value, category)) + continue + elif key.startswith("attr!="): + negattrs.append((key[7:], value, category)) + continue + # if we get here, we are entering a key search criterion which + # we assume is one word. + queries.append(part) + # build query from components + query = " ".join(queries) + # TODO + + def get(self, *args, **kwargs): + """ + Overload the standard get. This will limit itself to only + return the current typeclass. + + Args: + args (any): These are passed on as arguments to the default + django get method. + Keyword Args: + kwargs (any): These are passed on as normal arguments + to the default django get method + Returns: + object (object): The object found. + + Raises: + ObjectNotFound: The exact name of this exception depends + on the model base used. + + """ + kwargs.update({"db_typeclass_path": self.model.path}) + return super().get(**kwargs) + + def filter(self, *args, **kwargs): + """ + Overload of the standard filter function. This filter will + limit itself to only the current typeclass. + + Args: + args (any): These are passed on as arguments to the default + django filter method. + Keyword Args: + kwargs (any): These are passed on as normal arguments + to the default django filter method. + Returns: + objects (queryset): The objects found. + + """ + kwargs.update({"db_typeclass_path": self.model.path}) + return super().filter(*args, **kwargs) + + def all(self): + """ + Overload method to return all matches, filtering for typeclass. + + Returns: + objects (queryset): The objects found. + + """ + return super().all().filter(db_typeclass_path=self.model.path) + + def first(self): + """ + Overload method to return first match, filtering for typeclass. + + Returns: + object (object): The object found. + + Raises: + ObjectNotFound: The exact name of this exception depends + on the model base used. + + """ + return super().filter(db_typeclass_path=self.model.path).first() + + def last(self): + """ + Overload method to return last match, filtering for typeclass. + + Returns: + object (object): The object found. + + Raises: + ObjectNotFound: The exact name of this exception depends + on the model base used. + + """ + return super().filter(db_typeclass_path=self.model.path).last() + + def count(self): + """ + Overload method to return number of matches, filtering for typeclass. + + Returns: + integer : Number of objects found. + + """ + return super().filter(db_typeclass_path=self.model.path).count() + + def annotate(self, *args, **kwargs): + """ + Overload annotate method to filter on typeclass before annotating. + Args: + *args (any): Positional arguments passed along to queryset annotate method. + **kwargs (any): Keyword arguments passed along to queryset annotate method. + + Returns: + Annotated queryset. + """ + return super().filter(db_typeclass_path=self.model.path).annotate(*args, **kwargs) + + def values(self, *args, **kwargs): + """ + Overload values method to filter on typeclass first. + Args: + *args (any): Positional arguments passed along to values method. + **kwargs (any): Keyword arguments passed along to values method. + + Returns: + Queryset of values dictionaries, just filtered by typeclass first. + """ + return super().filter(db_typeclass_path=self.model.path).values(*args, **kwargs) + + def values_list(self, *args, **kwargs): + """ + Overload values method to filter on typeclass first. + Args: + *args (any): Positional arguments passed along to values_list method. + **kwargs (any): Keyword arguments passed along to values_list method. + + Returns: + Queryset of value_list tuples, just filtered by typeclass first. + """ + return super().filter(db_typeclass_path=self.model.path).values_list(*args, **kwargs) + + def _get_subclasses(self, cls): + """ + Recursively get all subclasses to a class. + + Args: + cls (classoject): A class to get subclasses from. + """ + all_subclasses = cls.__subclasses__() + for subclass in all_subclasses: + all_subclasses.extend(self._get_subclasses(subclass)) + return all_subclasses + + def get_family(self, *args, **kwargs): + """ + Variation of get that not only returns the current typeclass + but also all subclasses of that typeclass. + + Keyword Args: + kwargs (any): These are passed on as normal arguments + to the default django get method. + Returns: + objects (list): The objects found. + + Raises: + ObjectNotFound: The exact name of this exception depends + on the model base used. + + """ + paths = [self.model.path] + [ + "%s.%s" % (cls.__module__, cls.__name__) for cls in self._get_subclasses(self.model) + ] + kwargs.update({"db_typeclass_path__in": paths}) + return super().get(*args, **kwargs) + + def filter_family(self, *args, **kwargs): + """ + Variation of filter that allows results both from typeclass + and from subclasses of typeclass + + Args: + args (any): These are passed on as arguments to the default + django filter method. + Keyword Args: + kwargs (any): These are passed on as normal arguments + to the default django filter method. + Returns: + objects (list): The objects found. + + """ + # query, including all subclasses + paths = [self.model.path] + [ + "%s.%s" % (cls.__module__, cls.__name__) for cls in self._get_subclasses(self.model) + ] + kwargs.update({"db_typeclass_path__in": paths}) + return super().filter(*args, **kwargs) + + def all_family(self): + """ + Return all matches, allowing matches from all subclasses of + the typeclass. + + Returns: + objects (list): The objects found. + + """ + paths = [self.model.path] + [ + "%s.%s" % (cls.__module__, cls.__name__) for cls in self._get_subclasses(self.model) + ] + return super().all().filter(db_typeclass_path__in=paths) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/typeclasses/models.html b/docs/latest/_modules/evennia/typeclasses/models.html new file mode 100644 index 0000000000..a462c086e6 --- /dev/null +++ b/docs/latest/_modules/evennia/typeclasses/models.html @@ -0,0 +1,1204 @@ + + + + + + + + evennia.typeclasses.models — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.typeclasses.models

+"""
+This is the *abstract* django models for many of the database objects
+in Evennia. A django abstract (obs, not the same as a Python metaclass!) is
+a model which is not actually created in the database, but which only exists
+for other models to inherit from, to avoid code duplication. Any model can
+import and inherit from these classes.
+
+Attributes are database objects stored on other objects. The implementing
+class needs to supply a ForeignKey field attr_object pointing to the kind
+of object being mapped. Attributes storing iterables actually store special
+types of iterables named PackedList/PackedDict respectively. These make
+sure to save changes to them to database - this is criticial in order to
+allow for obj.db.mylist[2] = data. Also, all dbobjects are saved as
+dbrefs but are also aggressively cached.
+
+TypedObjects are objects 'decorated' with a typeclass - that is, the typeclass
+(which is a normal Python class implementing some special tricks with its
+get/set attribute methods, allows for the creation of all sorts of different
+objects all with the same database object underneath. Usually attributes are
+used to permanently store things not hard-coded as field on the database object.
+The admin should usually not have to deal directly  with the database object
+layer.
+
+This module also contains the Managers for the respective models; inherit from
+these to create custom managers.
+
+"""
+from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import models
+from django.db.models import signals
+from django.db.models.base import ModelBase
+from django.urls import reverse
+from django.utils.encoding import smart_str
+from django.utils.text import slugify
+
+import evennia
+from evennia.locks.lockhandler import LockHandler
+from evennia.server.signals import SIGNAL_TYPED_OBJECT_POST_RENAME
+from evennia.typeclasses import managers
+from evennia.typeclasses.attributes import (
+    Attribute,
+    AttributeHandler,
+    AttributeProperty,
+    DbHolder,
+    InMemoryAttributeBackend,
+    ModelAttributeBackend,
+)
+from evennia.typeclasses.tags import (
+    AliasHandler,
+    PermissionHandler,
+    Tag,
+    TagCategoryProperty,
+    TagHandler,
+    TagProperty,
+)
+from evennia.utils.idmapper.models import SharedMemoryModel, SharedMemoryModelBase
+from evennia.utils.logger import log_trace
+from evennia.utils.utils import class_from_module, inherits_from, is_iter, lazy_property
+
+__all__ = ("TypedObject",)
+
+TICKER_HANDLER = None
+
+_PERMISSION_HIERARCHY = [p.lower() for p in settings.PERMISSION_HIERARCHY]
+_TYPECLASS_AGGRESSIVE_CACHE = settings.TYPECLASS_AGGRESSIVE_CACHE
+_GA = object.__getattribute__
+_SA = object.__setattr__
+
+
+# signal receivers. Connected in __new__
+
+
+def call_at_first_save(sender, instance, created, **kwargs):
+    """
+    Receives a signal just after the object is saved.
+
+    """
+    if created:
+        instance.at_first_save()
+
+
+def remove_attributes_on_delete(sender, instance, **kwargs):
+    """
+    Wipe object's Attributes when it's deleted
+
+    """
+    instance.db_attributes.all().delete()
+
+
+# ------------------------------------------------------------
+#
+# Typed Objects
+#
+# ------------------------------------------------------------
+
+
+#
+# Meta class for typeclasses
+#
+
+
+class TypeclassBase(SharedMemoryModelBase):
+    """
+    Metaclass which should be set for the root of model proxies
+    that don't define any new fields, like Object, Script etc. This
+    is the basis for the typeclassing system.
+
+    """
+
+    def __new__(cls, name, bases, attrs):
+        """
+        We must define our Typeclasses as proxies. We also store the
+        path directly on the class, this is required by managers.
+        """
+
+        # storage of stats
+        attrs["typename"] = name
+        attrs["path"] = "%s.%s" % (attrs["__module__"], name)
+
+        def _get_dbmodel(bases):
+            """Recursively get the dbmodel"""
+            if not hasattr(bases, "__iter__"):
+                bases = [bases]
+            for base in bases:
+                try:
+                    if base._meta.proxy or base._meta.abstract:
+                        for kls in base._meta.parents:
+                            return _get_dbmodel(kls)
+                except AttributeError:
+                    # this happens if trying to parse a non-typeclass mixin parent,
+                    # without a _meta
+                    continue
+                else:
+                    return base
+                return None
+
+        dbmodel = _get_dbmodel(bases)
+
+        if not dbmodel:
+            raise TypeError(f"{name} does not appear to inherit from a database model.")
+
+        # typeclass proxy setup
+        # first check explicit __applabel__ on the typeclass, then figure
+        # it out from the dbmodel
+        if "__applabel__" not in attrs:
+            # find the app-label in one of the bases, usually the dbmodel
+            attrs["__applabel__"] = dbmodel._meta.app_label
+
+        if "Meta" not in attrs:
+
+            class Meta:
+                proxy = True
+                app_label = attrs.get("__applabel__", "typeclasses")
+
+            attrs["Meta"] = Meta
+        attrs["Meta"].proxy = True
+
+        new_class = ModelBase.__new__(cls, name, bases, attrs)
+
+        # django doesn't support inheriting proxy models so we hack support for
+        # it here by injecting `proxy_for_model` to the actual dbmodel.
+        # Unfortunately we cannot also set the correct model_name, because this
+        # would block multiple-inheritance of typeclasses (Django doesn't allow
+        # multiple bases of the same model).
+        if dbmodel:
+            new_class._meta.proxy_for_model = dbmodel
+            # Maybe Django will eventually handle this in the future:
+            # new_class._meta.model_name = dbmodel._meta.model_name
+
+        # attach signals
+        signals.post_save.connect(call_at_first_save, sender=new_class)
+        signals.pre_delete.connect(remove_attributes_on_delete, sender=new_class)
+        return new_class
+
+
+#
+# Main TypedObject abstraction
+#
+
+
+
[docs]class TypedObject(SharedMemoryModel): + """ + Abstract Django model. + + This is the basis for a typed object. It also contains all the + mechanics for managing connected attributes. + + The TypedObject has the following properties: + + - key - main name + - name - alias for key + - typeclass_path - the path to the decorating typeclass + - typeclass - auto-linked typeclass + - date_created - time stamp of object creation + - permissions - perm strings + - dbref - #id of object + - db - persistent attribute storage + - ndb - non-persistent attribute storage + + """ + + # + # TypedObject Database Model setup + # + # + # These databse fields are all accessed and set using their corresponding + # properties, named same as the field, but without the db_* prefix + # (no separate save() call is needed) + + # Main identifier of the object, for searching. Is accessed with self.key + # or self.name + db_key = models.CharField("key", max_length=255, db_index=True) + # This is the python path to the type class this object is tied to. The + # typeclass is what defines what kind of Object this is) + db_typeclass_path = models.CharField( + "typeclass", + max_length=255, + null=True, + help_text=( + "this defines what 'type' of entity this is. This variable holds " + "a Python path to a module with a valid Evennia Typeclass." + ), + db_index=True, + ) + # Creation date. This is not changed once the object is created. + db_date_created = models.DateTimeField("creation date", editable=False, auto_now_add=True) + # Lock storage + db_lock_storage = models.TextField( + "locks", + blank=True, + help_text=( + "locks limit access to an entity. A lock is defined as a 'lock string' " + "on the form 'type:lockfunctions', defining what functionality is locked and " + "how to determine access. Not defining a lock means no access is granted." + ), + ) + # many2many relationships + db_attributes = models.ManyToManyField( + Attribute, + help_text=( + "attributes on this object. An attribute can hold any pickle-able " + "python object (see docs for special cases)." + ), + ) + db_tags = models.ManyToManyField( + Tag, + help_text=( + "tags on this object. Tags are simple string markers to identify, " + "group and alias objects." + ), + ) + + # Database manager + objects = managers.TypedObjectManager() + + # quick on-object typeclass cache for speed + _cached_typeclass = None + + # typeclass mechanism + +
[docs] def set_class_from_typeclass(self, typeclass_path=None): + if typeclass_path: + try: + self.__class__ = class_from_module( + typeclass_path, defaultpaths=settings.TYPECLASS_PATHS + ) + except Exception: + log_trace() + try: + self.__class__ = class_from_module(self.__settingsclasspath__) + except Exception: + log_trace() + try: + self.__class__ = class_from_module(self.__defaultclasspath__) + except Exception: + log_trace() + self.__class__ = self._meta.concrete_model or self.__class__ + finally: + self.db_typeclass_path = typeclass_path + elif self.db_typeclass_path: + try: + self.__class__ = class_from_module(self.db_typeclass_path) + except Exception: + log_trace() + try: + self.__class__ = class_from_module(self.__defaultclasspath__) + except Exception: + log_trace() + self.__dbclass__ = self._meta.concrete_model or self.__class__ + else: + self.db_typeclass_path = "%s.%s" % (self.__module__, self.__class__.__name__) + # important to put this at the end since _meta is based on the set __class__ + try: + self.__dbclass__ = self._meta.concrete_model or self.__class__ + except AttributeError: + err_class = repr(self.__class__) + self.__class__ = class_from_module("evennia.objects.objects.DefaultObject") + self.__dbclass__ = class_from_module("evennia.objects.models.ObjectDB") + self.db_typeclass_path = "evennia.objects.objects.DefaultObject" + log_trace( + "Critical: Class %s of %s is not a valid typeclass!\nTemporarily falling back" + " to %s." % (err_class, self, self.__class__) + )
+ +
[docs] def __init__(self, *args, **kwargs): + """ + The `__init__` method of typeclasses is the core operational + code of the typeclass system, where it dynamically re-applies + a class based on the db_typeclass_path database field rather + than use the one in the model. + + Args: + Passed through to parent. + + Keyword Args: + Passed through to parent. + + Notes: + The loading mechanism will attempt the following steps: + + 1. Attempt to load typeclass given on command line + 2. Attempt to load typeclass stored in db_typeclass_path + 3. Attempt to load `__settingsclasspath__`, which is by the + default classes defined to be the respective user-set + base typeclass settings, like `BASE_OBJECT_TYPECLASS`. + 4. Attempt to load `__defaultclasspath__`, which is the + base classes in the library, like DefaultObject etc. + 5. If everything else fails, use the database model. + + Normal operation is to load successfully at either step 1 + or 2 depending on how the class was called. Tracebacks + will be logged for every step the loader must take beyond + 2. + + """ + typeclass_path = kwargs.pop("typeclass", None) + super().__init__(*args, **kwargs) + self.set_class_from_typeclass(typeclass_path=typeclass_path)
+ +
[docs] def init_evennia_properties(self): + """ + Called by creation methods; makes sure to initialize Attribute/TagProperties + by fetching them once. + """ + for propkey, prop in self.__class__.__dict__.items(): + if isinstance(prop, (AttributeProperty, TagProperty, TagCategoryProperty)): + try: + getattr(self, propkey) + except Exception: + log_trace()
+ + # initialize all handlers in a lazy fashion +
[docs] @lazy_property + def attributes(self): + return AttributeHandler(self, ModelAttributeBackend)
+ +
[docs] @lazy_property + def locks(self): + return LockHandler(self)
+ +
[docs] @lazy_property + def tags(self): + return TagHandler(self)
+ +
[docs] @lazy_property + def aliases(self): + return AliasHandler(self)
+ +
[docs] @lazy_property + def permissions(self): + return PermissionHandler(self)
+ +
[docs] @lazy_property + def nattributes(self): + return AttributeHandler(self, InMemoryAttributeBackend)
+ +
[docs] class Meta: + """ + Django setup info. + """ + + abstract = True + verbose_name = "Evennia Database Object" + ordering = ["-db_date_created", "id", "db_typeclass_path", "db_key"]
+ + # wrapper + # Wrapper properties to easily set database fields. These are + # @property decorators that allows to access these fields using + # normal python operations (without having to remember to save() + # etc). So e.g. a property 'attr' has a get/set/del decorator + # defined that allows the user to do self.attr = value, + # value = self.attr and del self.attr respectively (where self + # is the object in question). + + # name property (alias to self.key) + def __name_get(self): + return self.key + + def __name_set(self, value): + self.key = value + + def __name_del(self): + raise Exception("Cannot delete name") + + name = property(__name_get, __name_set, __name_del) + + # key property (overrides's the idmapper's db_key for the at_rename hook) + @property + def key(self): + return self.db_key + + @key.setter + def key(self, value): + oldname = str(self.db_key) + self.db_key = value + self.save(update_fields=["db_key"]) + self.at_rename(oldname, value) + SIGNAL_TYPED_OBJECT_POST_RENAME.send(sender=self, old_key=oldname, new_key=value) + + # + # + # TypedObject main class methods and properties + # + # + + def __eq__(self, other): + try: + return self.__dbclass__ == other.__dbclass__ and self.dbid == other.dbid + except AttributeError: + return False + + def __hash__(self): + # this is required to maintain hashing + return super().__hash__() + + def __str__(self): + return smart_str("%s" % self.db_key) + + def __repr__(self): + return "%s" % self.db_key + + # @property + def __dbid_get(self): + """ + Caches and returns the unique id of the object. + Use this instead of self.id, which is not cached. + """ + return self.id + + def __dbid_set(self, value): + raise Exception("dbid cannot be set!") + + def __dbid_del(self): + raise Exception("dbid cannot be deleted!") + + dbid = property(__dbid_get, __dbid_set, __dbid_del) + + # @property + def __dbref_get(self): + """ + Returns the object's dbref on the form #NN. + """ + return "#%s" % self.id + + def __dbref_set(self): + raise Exception("dbref cannot be set!") + + def __dbref_del(self): + raise Exception("dbref cannot be deleted!") + + dbref = property(__dbref_get, __dbref_set, __dbref_del) + +
[docs] def at_idmapper_flush(self): + """ + This is called when the idmapper cache is flushed and + allows customized actions when this happens. + + Returns: + do_flush (bool): If True, flush this object as normal. If + False, don't flush and expect this object to handle + the flushing on its own. + + Notes: + The default implementation relies on being able to clear + Django's Foreignkey cache on objects not affected by the + flush (notably objects with an NAttribute stored). We rely + on this cache being stored on the format "_<fieldname>_cache". + If Django were to change this name internally, we need to + update here (unlikely, but marking just in case). + + """ + if self.nattributes.all(): + # we can't flush this object if we have non-persistent + # attributes stored - those would get lost! Nevertheless + # we try to flush as many references as we can. + self.attributes.reset_cache() + self.tags.reset_cache() + # flush caches for all related fields + for field in self._meta.fields: + name = "_%s_cache" % field.name + if field.is_relation and name in self.__dict__: + # a foreignkey - remove its cache + del self.__dict__[name] + return False + # a normal flush + return True
+ + # + # Object manipulation methods + # + +
[docs] def at_init(self): + """ + Called when this object is loaded into cache. This is more reliable + than to override `__init__`. + + """ + pass
+ +
[docs] @classmethod + def search(cls, query, **kwargs): + """ + Overridden by class children. This implements a common API. + + Args: + query (str): A search query. + **kwargs: Other search parameters. + + Returns: + list: A list of 0, 1 or more matches, only of this typeclass. + + """ + if cls.objects.dbref(query): + return [cls.objects.get_id(query)] + return list(cls.objects.filter(db_key__lower=query))
+ +
[docs] def is_typeclass(self, typeclass, exact=False): + """ + Returns true if this object has this type OR has a typeclass + which is an subclass of the given typeclass. This operates on + the actually loaded typeclass (this is important since a + failing typeclass may instead have its default currently + loaded) typeclass - can be a class object or the python path + to such an object to match against. + + Args: + typeclass (str or class): A class or the full python path + to the class to check. + exact (bool, optional): Returns true only if the object's + type is exactly this typeclass, ignoring parents. + + Returns: + is_typeclass (bool): If this typeclass matches the given + typeclass. + + """ + if isinstance(typeclass, str): + typeclass = [typeclass] + [ + "%s.%s" % (prefix, typeclass) for prefix in settings.TYPECLASS_PATHS + ] + else: + typeclass = [typeclass.path] + + selfpath = self.path + if exact: + # check only exact match + return selfpath in typeclass + else: + # check parent chain + return any( + hasattr(cls, "path") and cls.path in typeclass for cls in self.__class__.mro() + )
+ +
[docs] def swap_typeclass( + self, + new_typeclass, + clean_attributes=False, + run_start_hooks="all", + no_default=True, + clean_cmdsets=False, + ): + """ + This performs an in-situ swap of the typeclass. This means + that in-game, this object will suddenly be something else. + Account will not be affected. To 'move' an account to a different + object entirely (while retaining this object's type), use + self.account.swap_object(). + + Note that this might be an error prone operation if the + old/new typeclass was heavily customized - your code + might expect one and not the other, so be careful to + bug test your code if using this feature! Often its easiest + to create a new object and just swap the account over to + that one instead. + + Args: + new_typeclass (str or classobj): Type to switch to. + clean_attributes (bool or list, optional): Will delete all + attributes stored on this object (but not any of the + database fields such as name or location). You can't get + attributes back, but this is often the safest bet to make + sure nothing in the new typeclass clashes with the old + one. If you supply a list, only those named attributes + will be cleared. + run_start_hooks (str or None, optional): This is either None, + to not run any hooks, "all" to run all hooks defined by + at_first_start, or a string with space-separated hook-names to run + (for example 'at_object_creation'). This will + always be called without arguments. + no_default (bool, optiona): If set, the swapper will not + allow for swapping to a default typeclass in case the + given one fails for some reason. Instead the old one will + be preserved. + clean_cmdsets (bool, optional): Delete all cmdsets on the object. + + """ + + if not callable(new_typeclass): + # this is an actual class object - build the path + new_typeclass = class_from_module(new_typeclass, defaultpaths=settings.TYPECLASS_PATHS) + + # if we get to this point, the class is ok. + + if inherits_from(self, "evennia.scripts.models.ScriptDB"): + if self.interval > 0: + raise RuntimeError( + "Cannot use swap_typeclass on time-dependent " + "Script '%s'.\nStop and start a new Script of the " + "right type instead." % self.key + ) + + self.typeclass_path = new_typeclass.path + self.__class__ = new_typeclass + + if clean_attributes: + # Clean out old attributes + if is_iter(clean_attributes): + for attr in clean_attributes: + self.attributes.remove(attr) + for nattr in clean_attributes: + if hasattr(self.ndb, nattr): + self.nattributes.remove(nattr) + else: + self.attributes.clear() + self.nattributes.clear() + if clean_cmdsets: + # purge all cmdsets + self.cmdset.clear() + self.cmdset.remove_default() + + if run_start_hooks == "all": + # fake this call to mimic the first save + self.at_first_save() + elif run_start_hooks: + # a custom hook-name to call. + for start_hook in str(run_start_hooks).split(): + getattr(self, run_start_hooks)()
+ + # + # Lock / permission methods + # + +
[docs] def access( + self, accessing_obj, access_type="read", default=False, no_superuser_bypass=False, **kwargs + ): + """ + Determines if another object has permission to access this one. + + Args: + accessing_obj (str): Object trying to access this one. + access_type (str, optional): Type of access sought. + default (bool, optional): What to return if no lock of + access_type was found + no_superuser_bypass (bool, optional): Turn off the + superuser lock bypass (be careful with this one). + + Keyword Args: + kwar (any): Ignored, but is there to make the api + consistent with the object-typeclass method access, which + use it to feed to its hook methods. + + """ + return self.locks.check( + accessing_obj, + access_type=access_type, + default=default, + no_superuser_bypass=no_superuser_bypass, + )
+ +
[docs] def check_permstring(self, permstring): + """ + This explicitly checks if we hold particular permission + without involving any locks. + + Args: + permstring (str): The permission string to check against. + + Returns: + result (bool): If the permstring is passed or not. + + """ + if inherits_from(self, evennia.DefaultObject): + if ( + self.account + and self.account.is_superuser + and not self.account.attributes.get("_quell") + ): + return True + else: + if self.is_superuser and not self.attributes.get("_quell"): + return True + + if not permstring: + return False + perm = permstring.lower() + perms = [p.lower() for p in self.permissions.all()] + if perm in perms: + # simplest case - we have a direct match + return True + if perm in _PERMISSION_HIERARCHY: + # check if we have a higher hierarchy position + ppos = _PERMISSION_HIERARCHY.index(perm) + return any( + True + for hpos, hperm in enumerate(_PERMISSION_HIERARCHY) + if hperm in perms and hpos > ppos + ) + # we ignore pluralization (english only) + if perm.endswith("s"): + return self.check_permstring(perm[:-1]) + + return False
+ + # + # Deletion methods + # + + def _deleted(self, *args, **kwargs): + """ + Scrambling method for already deleted objects + """ + raise ObjectDoesNotExist("This object was already deleted!") + +
[docs] def delete(self): + """ + Cleaning up handlers on the typeclass level + + """ + global TICKER_HANDLER + self.permissions.clear() + self.attributes.clear() + self.aliases.clear() + if hasattr(self, "nicks"): + self.nicks.clear() + # scrambling properties + self.delete = self._deleted + super().delete()
+ + # + # Attribute storage + # + + @property + def db(self): + """ + Attribute handler wrapper. Allows for the syntax + + ```python + obj.db.attrname = value + # and + value = obj.db.attrname + # and + del obj.db.attrname + # and + all_attr = obj.db.all() + # (unless there is an attribute + # named 'all', in which case that will be returned instead). + ``` + + """ + try: + return self._db_holder + except AttributeError: + self._db_holder = DbHolder(self, "attributes") + return self._db_holder + + @db.setter + def db(self, value): + "Stop accidentally replacing the db object" + string = "Cannot assign directly to db object! " + string += "Use db.attr=value instead." + raise Exception(string) + + @db.deleter + def db(self): + "Stop accidental deletion." + raise Exception("Cannot delete the db object!") + + # + # Non-persistent (ndb) storage + # + + @property + def ndb(self): + """ + A non-attr_obj store (ndb: NonDataBase). Everything stored + to this is guaranteed to be cleared when a server is shutdown. + Syntax is same as for the _get_db_holder() method and + property, e.g. obj.ndb.attr = value etc. + """ + try: + return self._ndb_holder + except AttributeError: + self._ndb_holder = DbHolder(self, "nattrhandler", manager_name="nattributes") + return self._ndb_holder + + @ndb.setter + def ndb(self, value): + "Stop accidentally replacing the ndb object" + string = "Cannot assign directly to ndb object! " + string += "Use ndb.attr=value instead." + raise Exception(string) + + @ndb.deleter + def ndb(self): + "Stop accidental deletion." + raise Exception("Cannot delete the ndb object!") + +
[docs] def get_display_name(self, looker, **kwargs): + """ + Displays the name of the object in a viewer-aware manner. + + Args: + looker (TypedObject, optional): The object or account that is looking + at/getting inforamtion for this object. If not given, some + 'safe' minimum level should be returned. + + Returns: + name (str): A string containing the name of the object, + including the DBREF if this user is privileged to control + said object. + + Notes: + This function could be extended to change how object names + appear to users in character, but be wary. This function + does not change an object's keys or aliases when + searching, and is expected to produce something useful for + builders. + + """ + if self.access(looker, access_type="controls"): + return "{}(#{})".format(self.name, self.id) + return self.name
+ +
[docs] def get_extra_info(self, looker, **kwargs): + """ + Used when an object is in a list of ambiguous objects as an + additional information tag. + + For instance, if you had potions which could have varying + levels of liquid left in them, you might want to display how + many drinks are left in each when selecting which to drop, but + not in your normal inventory listing. + + Args: + looker (TypedObject): The object or account that is looking + at/getting information for this object. + + Returns: + info (str): A string with disambiguating information, + conventionally with a leading space. + + """ + + if self.location == looker: + return " (carried)" + return ""
+ +
[docs] def at_rename(self, oldname, newname): + """ + This Hook is called by @name on a successful rename. + + Args: + oldname (str): The instance's original name. + newname (str): The new name for the instance. + + """ + pass
+ + # + # Web/Django methods + # + +
[docs] def web_get_admin_url(self): + """ + Returns the URI path for the Django Admin page for this object. + + ex. Account#1 = '/admin/accounts/accountdb/1/change/' + + Returns: + path (str): URI path to Django Admin page for object. + + """ + content_type = ContentType.objects.get_for_model(self.__class__) + return reverse( + "admin:%s_%s_change" % (content_type.app_label, content_type.model), args=(self.id,) + )
+ +
[docs] @classmethod + def web_get_create_url(cls): + """ + Returns the URI path for a View that allows users to create new + instances of this object. + + ex. Chargen = '/characters/create/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-create' would be referenced by this method. + + ex. + url(r'characters/create/', ChargenView.as_view(), name='character-create') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can create new objects is the + developer's responsibility. + + Returns: + path (str): URI path to object creation page, if defined. + + """ + try: + return reverse("%s-create" % slugify(cls._meta.verbose_name)) + except Exception: + return "#"
+ +
[docs] def web_get_detail_url(self): + """ + Returns the URI path for a View that allows users to view details for + this object. + + Returns: + path (str): URI path to object detail page, if defined. + + Examples: + + ```python + Oscar (Character) = '/characters/oscar/1/' + ``` + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-detail' would be referenced by this method. + + + ```python + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', + CharDetailView.as_view(), name='character-detail') + ``` + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can view this object is the + developer's responsibility. + + """ + try: + return reverse( + "%s-detail" % slugify(self._meta.verbose_name), + kwargs={"pk": self.pk, "slug": slugify(self.name)}, + ) + except Exception: + return "#"
+ +
[docs] def web_get_puppet_url(self): + """ + Returns the URI path for a View that allows users to puppet a specific + object. + + Returns: + str: URI path to object puppet page, if defined. + + Examples: + :: + + Oscar (Character) = '/characters/oscar/1/puppet/' + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-puppet' would be referenced by this method. + :: + + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/puppet/$', + CharPuppetView.as_view(), name='character-puppet') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can view this object is the developer's + responsibility. + + + """ + try: + return reverse( + "%s-puppet" % slugify(self._meta.verbose_name), + kwargs={"pk": self.pk, "slug": slugify(self.name)}, + ) + except Exception: + return "#"
+ +
[docs] def web_get_update_url(self): + """ + Returns the URI path for a View that allows users to update this + object. + + Returns: + str: URI path to object update page, if defined. + + Examples: + + ```python + Oscar (Character) = '/characters/oscar/1/change/' + ``` + + For this to work, the developer must have defined a named view somewhere + in urls.py that follows the format 'modelname-action', so in this case + a named view of 'character-update' would be referenced by this method. + :: + + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$', + CharUpdateView.as_view(), name='character-update') + + If no View has been created and defined in urls.py, returns an + HTML anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can modify objects is the developer's + responsibility. + + + """ + try: + return reverse( + "%s-update" % slugify(self._meta.verbose_name), + kwargs={"pk": self.pk, "slug": slugify(self.name)}, + ) + except Exception: + return "#"
+ +
[docs] def web_get_delete_url(self): + """ + Returns the URI path for a View that allows users to delete this object. + + Returns: + path (str): URI path to object deletion page, if defined. + + Examples: + + ```python + Oscar (Character) = '/characters/oscar/1/delete/' + ``` + + For this to work, the developer must have defined a named view + somewhere in urls.py that follows the format 'modelname-action', so + in this case a named view of 'character-detail' would be referenced + by this method. + :: + + url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$', + CharDeleteView.as_view(), name='character-delete') + + If no View has been created and defined in urls.py, returns an HTML + anchor. + + This method is naive and simply returns a path. Securing access to + the actual view and limiting who can delete this object is the + developer's responsibility. + + + """ + try: + return reverse( + "%s-delete" % slugify(self._meta.verbose_name), + kwargs={"pk": self.pk, "slug": slugify(self.name)}, + ) + except Exception: + return "#"
+ + # Used by Django Sites/Admin + get_absolute_url = web_get_detail_url
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/typeclasses/tags.html b/docs/latest/_modules/evennia/typeclasses/tags.html new file mode 100644 index 0000000000..dea8f72d34 --- /dev/null +++ b/docs/latest/_modules/evennia/typeclasses/tags.html @@ -0,0 +1,951 @@ + + + + + + + + evennia.typeclasses.tags — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.typeclasses.tags

+"""
+Tags are entities that are attached to objects in the same way as
+Attributes. But contrary to Attributes, which are unique to an
+individual object, a single Tag can be attached to any number of
+objects at the same time.
+
+Tags are used for tagging, obviously, but the data structure is also
+used for storing Aliases and Permissions. This module contains the
+respective handlers.
+
+"""
+from collections import defaultdict
+
+from django.conf import settings
+from django.db import models
+
+from evennia.locks.lockfuncs import perm as perm_lockfunc
+from evennia.utils.utils import make_iter, to_str
+
+_TYPECLASS_AGGRESSIVE_CACHE = settings.TYPECLASS_AGGRESSIVE_CACHE
+
+# ------------------------------------------------------------
+#
+# Tags
+#
+# ------------------------------------------------------------
+
+
+
[docs]class Tag(models.Model): + """ + Tags are quick markers for objects in-game. An typeobject can have + any number of tags, stored via its db_tags property. Tagging + similar objects will make it easier to quickly locate the group + later (such as when implementing zones). The main advantage of + tagging as opposed to using tags is speed; a tag is very + limited in what data it can hold, and the tag key+category is + indexed for efficient lookup in the database. Tags are shared + between objects - a new tag is only created if the key+category + combination did not previously exist, making them unsuitable for + storing object-related data (for this a regular Attribute should be + used). + + The 'db_data' field is intended as a documentation field for the + tag itself, such as to document what this tag+category stands for + and display that in a web interface or similar. + + The main default use for Tags is to implement Aliases for objects. + this uses the 'aliases' tag category, which is also checked by the + default search functions of Evennia to allow quick searches by alias. + + """ + + db_key = models.CharField( + "key", max_length=255, null=True, help_text="tag identifier", db_index=True + ) + db_category = models.CharField( + "category", max_length=64, null=True, blank=True, help_text="tag category", db_index=True + ) + db_data = models.TextField( + "data", + null=True, + blank=True, + help_text="optional data field with extra information. This is not searched for.", + ) + # this is "objectdb" etc. Required behind the scenes + db_model = models.CharField( + "model", max_length=32, null=True, help_text="database model to Tag", db_index=True + ) + # this is None, alias or permission + db_tagtype = models.CharField( + "tagtype", + max_length=16, + null=True, + blank=True, + help_text="overall type of Tag", + db_index=True, + ) + + class Meta: + "Define Django meta options" + verbose_name = "Tag" + unique_together = (("db_key", "db_category", "db_tagtype", "db_model"),) + index_together = (("db_key", "db_category", "db_tagtype", "db_model"),) + + def __lt__(self, other): + return str(self) < str(other) + + def __str__(self): + return str( + "<Tag: %s%s>" + % (self.db_key, "(category:%s)" % self.db_category if self.db_category else "") + )
+ + +# +# Handlers making use of the Tags model +# + + +
[docs]class TagProperty: + """ + Tag Property. + """ + + taghandler_name = "tags" + +
[docs] def __init__(self, category=None, data=None): + """ + Tag property descriptor. Allows for setting tags on an object as Django-like 'fields' + on the class level. Since Tags are almost always used for querying, Tags are always + created/assigned along with the object. Make sure the property/tagname does not collide + with an existing method/property on the class. If it does, you must use tags.add() + instead. + + Note that while you _can_ check e.g. `obj.tagname,this will give an AttributeError + if the Tag is not set. Most often you want to use `obj.tags.get("tagname")` to check + if a tag is set on an object. + + Example: + :: + + class Character(DefaultCharacter): + mytag = TagProperty() # category=None + mytag2 = TagProperty(category="tagcategory") + """ + + self._category = category + self._data = data + self._key = ""
+ + def __set_name__(self, cls, name): + """ + Called when descriptor is first assigned to the class (not the instance!). + It is called with the name of the field. + + """ + self._key = name + + def __get__(self, instance, owner): + """ + Called when accessing the tag as a property on the instance. + + """ + try: + return getattr(instance, self.taghandler_name).get( + key=self._key, category=self._category, return_list=False, raise_exception=True + ) + except AttributeError: + self.__set__(instance, self._category) + + def __set__(self, instance, category): + """ + Assign a new category to the tag. It's not possible to set 'data' this way. + + """ + self._category = category + ( + getattr(instance, self.taghandler_name).add( + key=self._key, category=self._category, data=self._data + ) + ) + + def __delete__(self, instance): + """ + Called when running `del` on the property. Will disconnect the object from + the Tag. Note that the tag will be readded on next fetch unless the + TagProperty is also removed in code! + + """ + getattr(instance, self.taghandler_name).remove(key=self._key, category=self._category)
+ + +
[docs]class TagCategoryProperty: + """ + Tag Category Property. + + """ + + taghandler_name = "tags" + +
[docs] def __init__(self, *default_tags): + """ + Assign a property for a Tag Category, with any number of Tag keys. + This is often more useful than the `TagProperty` since it's common to want to check which + tags of a particular category the object is a member of. + + Args: + *args (str or callable): Tag keys to assign to this property, using the category given + by the name of the property. Note that, if these tags are always set on the object, + if they are removed by some other means, they will be re-added when this property + is accessed. Furthermore, changing this list after the object was created, will + not remove any old tags (there is no way for the property to know if the + new list is new or not). If a callable, it will be called without arguments to + return the tag key. It is not possible to set tag `data` this way (use the + Taghandler directly for that). Tag keys are not case sensitive. + + Raises: + ValueError: If the input is not a valid tag key or tuple. + + Notes: + It is not possible to set Tags with a `None` category using a `TagCategoryProperty` - + use `obj.tags.add()` instead. + + Example: + :: + + class RogueCharacter(DefaultCharacter): + guild = TagProperty("thieves_guild", "merchant_guild") + + """ + self._category = "" + self._default_tags = self._parse_tag_input(*default_tags)
+ + def _parse_tag_input(self, *args): + """ + Parse input to the property. + + Args: + *args (str or callable): Tags, either as strings or `callable`, which should return + the tag key when called without arguments. Keys are not case sensitive. + + Returns: + list: A list of tag keys. + + """ + tags = [] + for tagkey in args: + if callable(tagkey): + tagkey = tagkey() + tags.append((str(tagkey).lower())) + return tags + + def __set_name__(self, cls, name): + """ + Called when descriptor is first assigned to the class (not the instance!). + It is called with the name of the field. + + """ + self._category = name + + def __get__(self, instance, owner): + """ + Called when accessing the tag as a property on the instance. Returns a list + of tags under the given category. + """ + taghandler = getattr(instance, self.taghandler_name) + + default_tags = self._default_tags + tags = taghandler.get(category=self._category, return_list=True) + + missing_default_tags = set(default_tags) - set(tags) + + if missing_default_tags: + getattr(instance, self.taghandler_name).batch_add( + *[(tag, self._category) for tag in missing_default_tags] + ) + + tags += missing_default_tags # to avoid a second db call + + return tags + + def __set__(self, instance, *args): + """ + Assign a new set of tags to the category. Note that we can't know if previous + tags were assigned from this property or from TagHandler, so we don't + remove old tags. To refresh to only have the tags in this constructor, first + use `del` on this property and re-access the property with the changed default list. + + """ + getattr(instance, self.taghandler_name).batch_add(*[(tag, self._category) for tag in args]) + + def __delete__(self, instance): + """ + Called when running `del` on the property. Will remove all tags of this + category from the object. Note that next time this desriptor is accessed, the + default ones will be re-added! + + Note: + This will remove _all_ tags of this category from the object. This is necessary + in order to be able to be able to combine this with `__set__` to get a tag + list where property and handler are in sync. + + """ + getattr(instance, self.taghandler_name).remove(category=self._category)
+ + +
[docs]class TagHandler(object): + """ + Generic tag-handler. Accessed via TypedObject.tags. + + """ + + _m2m_fieldname = "db_tags" + _tagtype = None + +
[docs] def __init__(self, obj): + """ + Tags are stored internally in the TypedObject.db_tags m2m + field with an tag.db_model based on the obj the taghandler is + stored on and with a tagtype given by self.handlertype + + Args: + obj (object): The object on which the handler is set. + + """ + self.obj = obj + self._objid = obj.id + self._model = obj.__dbclass__.__name__.lower() + self._cache = {} + # store category names fully cached + self._catcache = {} + # full cache was run on all tags + self._cache_complete = False
+ + def _query_all(self): + """ + Get all tags for this object. + + """ + query = { + "%s__id" % self._model: self._objid, + "tag__db_model": self._model, + "tag__db_tagtype": self._tagtype, + } + return [ + conn.tag + for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) + ] + + def _fullcache(self): + """ + Cache all tags of this object. + + """ + if not _TYPECLASS_AGGRESSIVE_CACHE: + return + tags = self._query_all() + self._cache = dict( + ( + "%s-%s" + % ( + to_str(tag.db_key).lower(), + tag.db_category.lower() if tag.db_category else None, + ), + tag, + ) + for tag in tags + ) + self._cache_complete = True + + def _getcache(self, key=None, category=None): + """ + Retrieve from cache or database (always caches) + + Args: + key (str, optional): Tag key to query for + category (str, optional): Tag category + + Returns: + args (list): Returns a list of zero or more matches + found from cache or database. + Notes: + When given a category only, a search for all objects + of that category is done and a the category *name* is is + stored. This tells the system on subsequent calls that the + list of cached tags of this category is up-to-date + and that the cache can be queried for category matches + without missing any. + The TYPECLASS_AGGRESSIVE_CACHE=False setting will turn off + caching, causing each tag access to trigger a + database lookup. + + """ + key = str(key).strip().lower() if key else None + category = category.strip().lower() if category else None + if key: + cachekey = "%s-%s" % (key, category) + tag = _TYPECLASS_AGGRESSIVE_CACHE and self._cache.get(cachekey, None) + if tag and (not hasattr(tag, "pk") and tag.pk is None): + # clear out Tags deleted from elsewhere. We must search this anew. + tag = None + del self._cache[cachekey] + if tag: + return [tag] # return cached entity + else: + query = { + "%s__id" % self._model: self._objid, + "tag__db_model": self._model, + "tag__db_tagtype": self._tagtype, + "tag__db_key__iexact": key.lower(), + "tag__db_category__iexact": category.lower() if category else None, + } + conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) + if conn: + tag = conn[0].tag + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = tag + return [tag] + else: + # only category given (even if it's None) - we can't + # assume the cache to be complete unless we have queried + # for this category before + catkey = "-%s" % category + if _TYPECLASS_AGGRESSIVE_CACHE and catkey in self._catcache: + return [tag for key, tag in self._cache.items() if key.endswith(catkey)] + else: + # we have to query to make this category up-date in the cache + query = { + "%s__id" % self._model: self._objid, + "tag__db_model": self._model, + "tag__db_tagtype": self._tagtype, + "tag__db_category__iexact": category.lower() if category else None, + } + tags = [ + conn.tag + for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter( + **query + ) + ] + if _TYPECLASS_AGGRESSIVE_CACHE: + for tag in tags: + cachekey = "%s-%s" % (tag.db_key, category) + self._cache[cachekey] = tag + # mark category cache as up-to-date + self._catcache[catkey] = True + return tags + return [] + + def _setcache(self, key, category, tag_obj): + """ + Update cache. + + Args: + key (str): A cleaned key string + category (str or None): A cleaned category name + tag_obj (tag): The newly saved tag + + """ + if not _TYPECLASS_AGGRESSIVE_CACHE: + return + if not key: # don't allow an empty key in cache + return + key, category = ( + str(key).strip().lower(), + category.strip().lower() if category else category, + ) + cachekey = "%s-%s" % (key, category) + catkey = "-%s" % category + self._cache[cachekey] = tag_obj + # mark that the category cache is no longer up-to-date + self._catcache.pop(catkey, None) + self._cache_complete = False + + def _delcache(self, key, category): + """ + Remove tag from cache + + Args: + key (str): A cleaned key string + category (str or None): A cleaned category name + + """ + key, category = ( + str(key).strip().lower(), + category.strip().lower() if category else category, + ) + catkey = "-%s" % category + if key: + cachekey = "%s-%s" % (key, category) + self._cache.pop(cachekey, None) + else: + [self._cache.pop(key, None) for key in self._cache if key.endswith(catkey)] + # mark that the category cache is no longer up-to-date + self._catcache.pop(catkey, None) + self._cache_complete = False + +
[docs] def reset_cache(self): + """ + Reset the cache from the outside. + + """ + self._cache_complete = False + self._cache = {} + self._catcache = {}
+ +
[docs] def add(self, key=None, category=None, data=None): + """ + Add a new tag to the handler. + + Args: + key (str or list): The name of the tag to add. If a list, + add several Tags. + category (str, optional): Category of Tag. `None` is the default category. + data (str, optional): Info text about the tag(s) added. + This can not be used to store object-unique info but only + eventual info about the tag itself. + + Notes: + If the tag + category combination matches an already + existing Tag object, this will be re-used and no new Tag + will be created. + + """ + if not key: + return + if not self._cache_complete: + self._fullcache() + for tagstr in make_iter(key): + if not tagstr: + continue + tagstr = str(tagstr).strip().lower() + category = str(category).strip().lower() if category else category + data = str(data) if data is not None else None + # this will only create tag if no matches existed beforehand (it + # will overload data on an existing tag since that is not + # considered part of making the tag unique) + tagobj = self.obj.__class__.objects.create_tag( + key=tagstr, category=category, data=data, tagtype=self._tagtype + ) + getattr(self.obj, self._m2m_fieldname).add(tagobj) + self._setcache(tagstr, category, tagobj)
+ +
[docs] def has(self, key=None, category=None, return_list=False): + """ + Checks if the given Tag (or list of Tags) exists on the object. + + Args: + key (str or iterable): The Tag key or tags to check for. + If `None`, search by category. + category (str, optional): Limit the check to Tags with this + category (note, that `None` is the default category). + + Returns: + has_tag (bool or list): If the Tag exists on this object or not. + If `tag` was given as an iterable then the return is a list of booleans. + + Raises: + ValueError: If neither `tag` nor `category` is given. + + """ + ret = [] + category = category.strip().lower() if category is not None else None + if key: + for tag_str in make_iter(key): + tag_str = str(tag_str).strip().lower() + ret.append(bool(self._getcache(tag_str, category))) + elif category: + ret.extend(bool(tag) for tag in self._getcache(category=category)) + else: + raise ValueError("Either tag or category must be provided.") + + if return_list: + return ret + + return ret[0] if len(ret) == 1 else ret
+ +
[docs] def get( + self, + key=None, + default=None, + category=None, + return_tagobj=False, + return_list=False, + raise_exception=False, + ): + """ + Get the tag for the given key, category or combination of the two. + + Args: + key (str or list, optional): The tag or tags to retrieve. + default (any, optional): The value to return in case of no match. + category (str, optional): The Tag category to limit the + request to. Note that `None` is the valid, default + category. If no `key` is given, all tags of this category will be + returned. + return_tagobj (bool, optional): Return the Tag object itself + instead of a string representation of the Tag. + return_list (bool, optional): Always return a list, regardless + of number of matches. + raise_exception (bool, optional): Raise AttributeError if no matches + are found. + + Returns: + tags (list): The matches, either string + representations of the tags or the Tag objects themselves + depending on `return_tagobj`. If 'default' is set, this + will be a list with the default value as its only element. + + Raises: + AttributeError: If finding no matches and `raise_exception` is True. + + """ + ret = [] + for keystr in make_iter(key): + # note - the _getcache call removes case sensitivity for us + ret.extend( + [ + tag if return_tagobj else to_str(tag.db_key) + for tag in self._getcache(keystr, category) + ] + ) + if not ret: + if raise_exception: + raise AttributeError(f"No tags found matching input {key}, {category}.") + elif return_list: + return [default] if default is not None else [] + else: + return default + return ret if return_list else (ret[0] if len(ret) == 1 else ret)
+ +
[docs] def remove(self, key=None, category=None): + """ + Remove a tag from the handler based ond key and/or category. + + Args: + key (str or list, optional): The tag or tags to retrieve. + category (str, optional): The Tag category to limit the + request to. Note that `None` is the valid, default + category + Notes: + If neither key nor category is specified, this acts + as .clear(). + + """ + if not key: + # only category + self.clear(category=category) + return + + for key in make_iter(key): + if not (key or key.strip()): # we don't allow empty tags + continue + tagstr = str(key).strip().lower() + category = category.strip().lower() if category else category + + # This does not delete the tag object itself. Maybe it should do + # that when no objects reference the tag anymore (but how to check)? + # For now, tags are never deleted, only their connection to objects. + tagobj = getattr(self.obj, self._m2m_fieldname).filter( + db_key=tagstr, db_category=category, db_model=self._model, db_tagtype=self._tagtype + ) + if tagobj: + getattr(self.obj, self._m2m_fieldname).remove(tagobj[0]) + self._delcache(key, category)
+ +
[docs] def clear(self, category=None): + """ + Remove all tags from the handler. + + Args: + category (str, optional): The Tag category to limit the + request to. Note that `None` is the valid, default + category. + + """ + if not self._cache_complete: + self._fullcache() + query = { + "%s__id" % self._model: self._objid, + "tag__db_model": self._model, + "tag__db_tagtype": self._tagtype, + } + if category: + query["tag__db_category"] = category.strip().lower() + getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query).delete() + self._cache = {} + self._catcache = {} + self._cache_complete = False
+ +
[docs] def all(self, return_key_and_category=False, return_objs=False): + """ + Get all tags in this handler, regardless of category. + + Args: + return_key_and_category (bool, optional): Return a list of + tuples `[(key, category), ...]`. + return_objs (bool, optional): Return tag objects. + + Returns: + tags (list): A list of tag keys `[tagkey, tagkey, ...]` or + a list of tuples `[(key, category), ...]` if + `return_key_and_category` is set. + + """ + if _TYPECLASS_AGGRESSIVE_CACHE: + if not self._cache_complete: + self._fullcache() + tags = sorted(self._cache.values()) + else: + tags = sorted(self._query_all()) + + if return_key_and_category: + # return tuple (key, category) + return [(to_str(tag.db_key), tag.db_category) for tag in tags] + elif return_objs: + return tags + else: + return [to_str(tag.db_key) for tag in tags]
+ +
[docs] def batch_add(self, *args): + """ + Batch-add tags from a list of tuples. + + Args: + *args (tuple or str): Each argument should be a `tagstr` keys or tuple + `(keystr, category)` or `(keystr, category, data)`. It's possible to mix input + types. + + Notes: + This will generate a mimimal number of self.add calls, + based on the number of categories involved (including + `None`) (data is not unique and may be overwritten by the content + of a latter tuple with the same category). + + """ + keys = defaultdict(list) + data = {} + for tup in args: + tup = make_iter(tup) + nlen = len(tup) + if nlen == 1: # just a key, no category + keys[None].append(tup[0]) + elif nlen == 2: + keys[tup[1]].append(tup[0]) + else: + keys[tup[1]].append(tup[0]) + data[tup[1]] = tup[2] # overwrite previous + for category, key in keys.items(): + self.add(key=key, category=category, data=data.get(category, None))
+ +
[docs] def batch_remove(self, *args): + """ + Batch-remove tags from a list of tuples. + + Args: + *args (tuple or str): Each argument should be a `tagstr` keys or tuple + `(keystr, category)` or `(keystr, category, data)` (the `data` field is ignored, + only `keystr`/`category` matters). It's possible to mix input types. + + """ + keys = defaultdict(list) + for tup in args: + tup = make_iter(tup) + nlen = len(tup) + if nlen == 1: # just a key, no category + keys[None].append(tup[0]) + elif nlen > 1: + keys[tup[1]].append(tup[0]) + for category, key in keys.items(): + self.remove(key=key, category=category, data=data.get(category, None))
+ + def __str__(self): + return ",".join(self.all())
+ + +
[docs]class AliasProperty(TagProperty): + """ + Allows for setting aliases like Django fields: + :: + + class Character(DefaultCharacter): + # note that every character will get the alias bob. Make sure + # the alias property does not collide with an existing method + # or property on the class. + bob = AliasProperty() + + """ + + taghandler_name = "aliases"
+ + +
[docs]class AliasHandler(TagHandler): + """ + A handler for the Alias Tag type. + + """ + + _tagtype = "alias"
+ + +
[docs]class PermissionProperty(TagProperty): + """ + Allows for setting permissions like Django fields: + :: + + class Character(DefaultCharacter): + # note that every character will get this permission! Make + # sure it doesn't collide with an existing method or property. + myperm = PermissionProperty() + + """ + + taghandler_name = "permissions"
+ + +
[docs]class PermissionHandler(TagHandler): + """ + A handler for the Permission Tag type. + + """ + + _tagtype = "permission" + +
[docs] def check(self, *permissions, require_all=False): + """ + Straight-up check the provided permission against this handler. The check will pass if + + - any/all given permission exists on the handler (depending on if `require_all` is set). + - If handler sits on puppeted object and this is a hierarachical perm, the puppeting + Account's permission will also be included in the check, prioritizing the Account's perm + (this avoids escalation exploits by puppeting a too-high prio character) + - a permission is also considered to exist on the handler, if it is *lower* than + a permission on the handler and this is a 'hierarchical' permission given + in `settings.PERMISSION_HIERARCHY`. Example: If the 'Developer' hierarchical + perm perm is set on the handler, and we check for the 'Builder' perm, the + check will pass. + + Args: + *permissions (str): Any number of permissions to check. By default, + the permission is passed if any of these (or higher, if a + hierarchical permission defined in settings.PERMISSION_HIERARCHY) + exists in the handler. Permissions are not case-sensitive. + require_all (bool): If set, *all* provided permissions much pass + the check for the entire check to pass. By default only one + needs to pass. + + Returns: + bool: If the provided permission(s) pass the check on this handler. + + Example: + :: + can_enter = obj.permissions.check("Blacksmith", "Builder") + + Notes: + This works the same way as the `perms` lockfunc and could be + replicated with a lock check against the lockstring + + "locktype: perm(perm1) OR perm(perm2) OR ..." + + (using AND for the `require_all` condition). + + """ + if require_all: + return all(perm_lockfunc(self.obj, None, perm) for perm in permissions) + else: + return any(perm_lockfunc(self.obj, None, perm) for perm in permissions)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/ansi.html b/docs/latest/_modules/evennia/utils/ansi.html new file mode 100644 index 0000000000..47d9eb95e8 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/ansi.html @@ -0,0 +1,1602 @@ + + + + + + + + evennia.utils.ansi — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.ansi

+"""
+ANSI - Gives colour to text.
+
+Use the codes defined in the *ANSIParser* class to apply colour to text. The
+`parse_ansi` function in this module parses text for markup and `strip_ansi`
+removes it.
+
+You should usually not need to call `parse_ansi` explicitly; it is run by
+Evennia just before returning data to/from the user. Alternative markup is
+possible by overriding the parser class (see also contrib/ for deprecated
+markup schemes).
+
+
+Supported standards:
+
+- ANSI 8 bright and 8 dark fg (foreground) colors
+- ANSI 8 dark bg (background) colors
+- 'ANSI' 8 bright bg colors 'faked' with xterm256 (bright bg not included in ANSI standard)
+- Xterm256 - 255 fg/bg colors + 26 greyscale fg/bg colors
+
+## Markup
+
+ANSI colors: `r` ed, `g` reen, `y` ellow, `b` lue, `m` agenta, `c` yan, `n` ormal (no color).
+Capital letters indicate the 'dark' variant.
+
+- `|r` fg bright red
+- `|R` fg dark red
+- `|[r` bg bright red
+- `|[R` bg dark red
+- `|[R|g` bg dark red, fg bright green
+
+```python
+"This is |rRed text|n and this is normal again."
+
+```
+
+Xterm256 colors are given as RGB (Red-Green-Blue), with values 0-5:
+
+- `|500` fg bright red
+- `|050` fg bright green
+- `|005` fg bright blue
+- `|110` fg dark brown
+- `|425` fg pink
+- `|[431` bg orange
+
+Xterm256 greyscale:
+
+- `|=a` fg black
+- `|=g` fg dark grey
+- `|=o` fg middle grey
+- `|=v` fg bright grey
+- `|=z` fg white
+- `|[=r` bg middle grey
+
+```python
+"This is |500Red text|n and this is normal again."
+"This is |[=jText on dark grey background"
+
+```
+
+----
+
+"""
+import functools
+import re
+from collections import OrderedDict
+
+from django.conf import settings
+
+from evennia.utils import logger, utils
+from evennia.utils.utils import to_str
+
+MXP_ENABLED = settings.MXP_ENABLED
+
+# ANSI definitions
+
+ANSI_BEEP = "\07"
+ANSI_ESCAPE = "\033"
+ANSI_NORMAL = "\033[0m"
+
+ANSI_UNDERLINE = "\033[4m"
+ANSI_HILITE = "\033[1m"
+ANSI_UNHILITE = "\033[22m"
+ANSI_BLINK = "\033[5m"
+ANSI_INVERSE = "\033[7m"
+ANSI_INV_HILITE = "\033[1;7m"
+ANSI_INV_BLINK = "\033[7;5m"
+ANSI_BLINK_HILITE = "\033[1;5m"
+ANSI_INV_BLINK_HILITE = "\033[1;5;7m"
+
+# Foreground colors
+ANSI_BLACK = "\033[30m"
+ANSI_RED = "\033[31m"
+ANSI_GREEN = "\033[32m"
+ANSI_YELLOW = "\033[33m"
+ANSI_BLUE = "\033[34m"
+ANSI_MAGENTA = "\033[35m"
+ANSI_CYAN = "\033[36m"
+ANSI_WHITE = "\033[37m"
+
+# Background colors
+ANSI_BACK_BLACK = "\033[40m"
+ANSI_BACK_RED = "\033[41m"
+ANSI_BACK_GREEN = "\033[42m"
+ANSI_BACK_YELLOW = "\033[43m"
+ANSI_BACK_BLUE = "\033[44m"
+ANSI_BACK_MAGENTA = "\033[45m"
+ANSI_BACK_CYAN = "\033[46m"
+ANSI_BACK_WHITE = "\033[47m"
+
+# Formatting Characters
+ANSI_RETURN = "\r\n"
+ANSI_TAB = "\t"
+ANSI_SPACE = " "
+
+# Escapes
+ANSI_ESCAPES = ("{{", "\\\\", "\|\|")
+
+_PARSE_CACHE = OrderedDict()
+_PARSE_CACHE_SIZE = 10000
+
+_COLOR_NO_DEFAULT = settings.COLOR_NO_DEFAULT
+
+
+
[docs]class ANSIParser(object): + """ + A class that parses ANSI markup + to ANSI command sequences + + We also allow to escape colour codes + by prepending with an extra `|`. + + """ + + # Mapping using {r {n etc + + ansi_map = [ + # alternative |-format + (r"|n", ANSI_NORMAL), # reset + (r"|/", ANSI_RETURN), # line break + (r"|-", ANSI_TAB), # tab + (r"|>", ANSI_SPACE * 4), # indent (4 spaces) + (r"|_", ANSI_SPACE), # space + (r"|*", ANSI_INVERSE), # invert + (r"|^", ANSI_BLINK), # blinking text (very annoying and not supported by all clients) + (r"|u", ANSI_UNDERLINE), # underline + (r"|r", ANSI_HILITE + ANSI_RED), + (r"|g", ANSI_HILITE + ANSI_GREEN), + (r"|y", ANSI_HILITE + ANSI_YELLOW), + (r"|b", ANSI_HILITE + ANSI_BLUE), + (r"|m", ANSI_HILITE + ANSI_MAGENTA), + (r"|c", ANSI_HILITE + ANSI_CYAN), + (r"|w", ANSI_HILITE + ANSI_WHITE), # pure white + (r"|x", ANSI_HILITE + ANSI_BLACK), # dark grey + (r"|R", ANSI_UNHILITE + ANSI_RED), + (r"|G", ANSI_UNHILITE + ANSI_GREEN), + (r"|Y", ANSI_UNHILITE + ANSI_YELLOW), + (r"|B", ANSI_UNHILITE + ANSI_BLUE), + (r"|M", ANSI_UNHILITE + ANSI_MAGENTA), + (r"|C", ANSI_UNHILITE + ANSI_CYAN), + (r"|W", ANSI_UNHILITE + ANSI_WHITE), # light grey + (r"|X", ANSI_UNHILITE + ANSI_BLACK), # pure black + # hilight-able colors + (r"|h", ANSI_HILITE), + (r"|H", ANSI_UNHILITE), + (r"|!R", ANSI_RED), + (r"|!G", ANSI_GREEN), + (r"|!Y", ANSI_YELLOW), + (r"|!B", ANSI_BLUE), + (r"|!M", ANSI_MAGENTA), + (r"|!C", ANSI_CYAN), + (r"|!W", ANSI_WHITE), # light grey + (r"|!X", ANSI_BLACK), # pure black + # normal ANSI backgrounds + (r"|[R", ANSI_BACK_RED), + (r"|[G", ANSI_BACK_GREEN), + (r"|[Y", ANSI_BACK_YELLOW), + (r"|[B", ANSI_BACK_BLUE), + (r"|[M", ANSI_BACK_MAGENTA), + (r"|[C", ANSI_BACK_CYAN), + (r"|[W", ANSI_BACK_WHITE), # light grey background + (r"|[X", ANSI_BACK_BLACK), # pure black background + ] + + ansi_xterm256_bright_bg_map = [ + # "bright" ANSI backgrounds using xterm256 since ANSI + # standard does not support it (will + # fallback to dark ANSI background colors if xterm256 + # is not supported by client) + # |-style variations + (r"|[r", r"|[500"), + (r"|[g", r"|[050"), + (r"|[y", r"|[550"), + (r"|[b", r"|[005"), + (r"|[m", r"|[505"), + (r"|[c", r"|[055"), + (r"|[w", r"|[555"), # white background + (r"|[x", r"|[222"), + ] # dark grey background + + # xterm256. These are replaced directly by + # the sub_xterm256 method + + if settings.COLOR_NO_DEFAULT: + ansi_map = settings.COLOR_ANSI_EXTRA_MAP + xterm256_fg = settings.COLOR_XTERM256_EXTRA_FG + xterm256_bg = settings.COLOR_XTERM256_EXTRA_BG + xterm256_gfg = settings.COLOR_XTERM256_EXTRA_GFG + xterm256_gbg = settings.COLOR_XTERM256_EXTRA_GBG + ansi_xterm256_bright_bg_map = settings.COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP + else: + xterm256_fg = [r"\|([0-5])([0-5])([0-5])"] # |123 - foreground colour + xterm256_bg = [r"\|\[([0-5])([0-5])([0-5])"] # |[123 - background colour + xterm256_gfg = [r"\|=([a-z])"] # |=a - greyscale foreground + xterm256_gbg = [r"\|\[=([a-z])"] # |[=a - greyscale background + ansi_map += settings.COLOR_ANSI_EXTRA_MAP + xterm256_fg += settings.COLOR_XTERM256_EXTRA_FG + xterm256_bg += settings.COLOR_XTERM256_EXTRA_BG + xterm256_gfg += settings.COLOR_XTERM256_EXTRA_GFG + xterm256_gbg += settings.COLOR_XTERM256_EXTRA_GBG + ansi_xterm256_bright_bg_map += settings.COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP + + mxp_re = r"\|lc(.*?)\|lt(.*?)\|le" + mxp_url_re = r"\|lu(.*?)\|lt(.*?)\|le" + + # prepare regex matching + brightbg_sub = re.compile( + r"|".join([r"(?<!\|)%s" % re.escape(tup[0]) for tup in ansi_xterm256_bright_bg_map]), + re.DOTALL, + ) + xterm256_fg_sub = re.compile(r"|".join(xterm256_fg), re.DOTALL) + xterm256_bg_sub = re.compile(r"|".join(xterm256_bg), re.DOTALL) + xterm256_gfg_sub = re.compile(r"|".join(xterm256_gfg), re.DOTALL) + xterm256_gbg_sub = re.compile(r"|".join(xterm256_gbg), re.DOTALL) + + # xterm256_sub = re.compile(r"|".join([tup[0] for tup in xterm256_map]), re.DOTALL) + ansi_sub = re.compile(r"|".join([re.escape(tup[0]) for tup in ansi_map]), re.DOTALL) + mxp_sub = re.compile(mxp_re, re.DOTALL) + mxp_url_sub = re.compile(mxp_url_re, re.DOTALL) + + # used by regex replacer to correctly map ansi sequences + ansi_map_dict = dict(ansi_map) + ansi_xterm256_bright_bg_map_dict = dict(ansi_xterm256_bright_bg_map) + + # prepare matching ansi codes overall + ansi_re = r"\033\[[0-9;]+m" + ansi_regex = re.compile(ansi_re) + + # escapes - these double-chars will be replaced with a single + # instance of each + ansi_escapes = re.compile(r"(%s)" % "|".join(ANSI_ESCAPES), re.DOTALL) + + # tabs/linebreaks |/ and |- should be able to be cleaned + unsafe_tokens = re.compile(r"\|\/|\|-", re.DOTALL) + +
[docs] def sub_ansi(self, ansimatch): + """ + Replacer used by `re.sub` to replace ANSI + markers with correct ANSI sequences + + Args: + ansimatch (re.matchobject): The match. + + Returns: + processed (str): The processed match string. + + """ + return self.ansi_map_dict.get(ansimatch.group(), "")
+ +
[docs] def sub_brightbg(self, ansimatch): + """ + Replacer used by `re.sub` to replace ANSI + bright background markers with Xterm256 replacement + + Args: + ansimatch (re.matchobject): The match. + + Returns: + processed (str): The processed match string. + + """ + return self.ansi_xterm256_bright_bg_map_dict.get(ansimatch.group(), "")
+ +
[docs] def sub_xterm256(self, rgbmatch, use_xterm256=False, color_type="fg"): + """ + This is a replacer method called by `re.sub` with the matched + tag. It must return the correct ansi sequence. + + It checks `self.do_xterm256` to determine if conversion + to standard ANSI should be done or not. + + Args: + rgbmatch (re.matchobject): The match. + use_xterm256 (bool, optional): Don't convert 256-colors to 16. + color_type (str): One of 'fg', 'bg', 'gfg', 'gbg'. + + Returns: + processed (str): The processed match string. + + """ + if not rgbmatch: + return "" + + # get tag, stripping the initial marker + # rgbtag = rgbmatch.group()[1:] + + background = color_type in ("bg", "gbg") + grayscale = color_type in ("gfg", "gbg") + + if not grayscale: + # 6x6x6 color-cube (xterm indexes 16-231) + try: + red, green, blue = [int(val) for val in rgbmatch.groups() if val is not None] + except (IndexError, ValueError): + logger.log_trace() + return rgbmatch.group(0) + else: + # grayscale values (xterm indexes 0, 232-255, 15) for full spectrum + try: + letter = [val for val in rgbmatch.groups() if val is not None][0] + except IndexError: + logger.log_trace() + return rgbmatch.group(0) + + if letter == "a": + colval = 16 # pure black @ index 16 (first color cube entry) + elif letter == "z": + colval = 231 # pure white @ index 231 (last color cube entry) + else: + # letter in range [b..y] (exactly 24 values!) + colval = 134 + ord(letter) + + # ansi fallback logic expects r,g,b values in [0..5] range + gray = round((ord(letter) - 97) / 5.0) + red, green, blue = gray, gray, gray + + if use_xterm256: + if not grayscale: + colval = 16 + (red * 36) + (green * 6) + blue + + return "\033[%s8;5;%sm" % (3 + int(background), colval) + # replaced since some clients (like Potato) does not accept codes with leading zeroes, + # see issue #1024. + # return "\033[%s8;5;%s%s%sm" % (3 + int(background), colval // 100, (colval % 100) // 10, colval%10) # noqa + + else: + # xterm256 not supported, convert the rgb value to ansi instead + rgb = (red, green, blue) + + def _convert_for_ansi(val): + return int((val + 1) // 2) + + # greys + if (max(rgb) - min(rgb)) <= 1: + match rgb: + case (0, 0, 0): + return ANSI_BACK_BLACK if background else ANSI_NORMAL + ANSI_BLACK + case ((1 | 2), (1 | 2), (1 | 2)): + return ANSI_BACK_BLACK if background else ANSI_HILITE + ANSI_BLACK + case ((2 | 3), (2 | 3), (2 | 3)): + return ANSI_BACK_WHITE if background else ANSI_NORMAL + ANSI_WHITE + case ((3 | 4), (3 | 4), (3 | 4)): + return ANSI_BACK_WHITE if background else ANSI_NORMAL + ANSI_WHITE + case ((4 | 5), (4 | 5), (4 | 5)): + return ANSI_BACK_WHITE if background else ANSI_HILITE + ANSI_WHITE + + match tuple(_convert_for_ansi(c) for c in rgb): + # red + case ((2 | 3), (0 | 1), (0 | 1)): + return ANSI_BACK_RED if background else ANSI_HILITE + ANSI_RED + case ((1 | 2), 0, 0): + return ANSI_BACK_RED if background else ANSI_NORMAL + ANSI_RED + # green + case ((0 | 1), (2 | 3), (0 | 1)): + return ANSI_BACK_GREEN if background else ANSI_HILITE + ANSI_GREEN + case ((0 | 1), 1, 0) if green > red: + return ANSI_BACK_GREEN if background else ANSI_NORMAL + ANSI_GREEN + # blue + case ((0 | 1), (0 | 1), (2 | 3)): + return ANSI_BACK_BLUE if background else ANSI_HILITE + ANSI_BLUE + case (0, 0, 1): + return ANSI_BACK_BLUE if background else ANSI_NORMAL + ANSI_BLUE + # cyan + case ((0 | 1 | 2), (2 | 3), (2 | 3)) if red == min(rgb): + return ANSI_BACK_CYAN if background else ANSI_HILITE + ANSI_CYAN + case (0, (1 | 2), (1 | 2)): + return ANSI_BACK_CYAN if background else ANSI_NORMAL + ANSI_CYAN + # yellow + case ((2 | 3), (2 | 3), (0 | 1 | 2)) if blue == min(rgb): + return ANSI_BACK_YELLOW if background else ANSI_HILITE + ANSI_YELLOW + case ((2 | 1), (2 | 1), (0 | 1)): + return ANSI_BACK_YELLOW if background else ANSI_NORMAL + ANSI_YELLOW + # magenta + case ((2 | 3), (0 | 1 | 2), (2 | 3)) if green == min(rgb): + return ANSI_BACK_MAGENTA if background else ANSI_HILITE + ANSI_MAGENTA + case ((1 | 2), 0, (1 | 2)): + return ANSI_BACK_MAGENTA if background else ANSI_NORMAL + ANSI_MAGENTA
+ +
[docs] def strip_raw_codes(self, string): + """ + Strips raw ANSI codes from a string. + + Args: + string (str): The string to strip. + + Returns: + string (str): The processed string. + + """ + return self.ansi_regex.sub("", string)
+ +
[docs] def strip_mxp(self, string): + """ + Strips all MXP codes from a string. + + Args: + string (str): The string to strip. + + Returns: + string (str): The processed string. + + """ + string = self.mxp_sub.sub(r"\2", string) + string = self.mxp_url_sub.sub(r"\1", string) # replace with url verbatim + return string
+ +
[docs] def strip_unsafe_tokens(self, string): + """ + Strip explicitly ansi line breaks and tabs. + + """ + return self.unsafe_tokens.sub("", string)
+ +
[docs] def parse_ansi(self, string, strip_ansi=False, xterm256=False, mxp=False): + """ + Parses a string, subbing color codes according to the stored + mapping. + + Args: + string (str): The string to parse. + strip_ansi (boolean, optional): Strip all found ansi markup. + xterm256 (boolean, optional): If actually using xterm256 or if + these values should be converted to 16-color ANSI. + mxp (boolean, optional): Parse MXP commands in string. + + Returns: + string (str): The parsed string. + + """ + if hasattr(string, "_raw_string"): + if strip_ansi: + return string.clean() + else: + return string.raw() + + if not string: + return "" + + # check cached parsings + global _PARSE_CACHE + cachekey = "%s-%s-%s-%s" % (string, strip_ansi, xterm256, mxp) + if cachekey in _PARSE_CACHE: + return _PARSE_CACHE[cachekey] + + # pre-convert bright colors to xterm256 color tags + string = self.brightbg_sub.sub(self.sub_brightbg, string) + + def do_xterm256_fg(part): + return self.sub_xterm256(part, xterm256, "fg") + + def do_xterm256_bg(part): + return self.sub_xterm256(part, xterm256, "bg") + + def do_xterm256_gfg(part): + return self.sub_xterm256(part, xterm256, "gfg") + + def do_xterm256_gbg(part): + return self.sub_xterm256(part, xterm256, "gbg") + + in_string = utils.to_str(string) + + # do string replacement + parsed_string = [] + parts = self.ansi_escapes.split(in_string) + [" "] + for part, sep in zip(parts[::2], parts[1::2]): + pstring = self.xterm256_fg_sub.sub(do_xterm256_fg, part) + pstring = self.xterm256_bg_sub.sub(do_xterm256_bg, pstring) + pstring = self.xterm256_gfg_sub.sub(do_xterm256_gfg, pstring) + pstring = self.xterm256_gbg_sub.sub(do_xterm256_gbg, pstring) + pstring = self.ansi_sub.sub(self.sub_ansi, pstring) + parsed_string.append("%s%s" % (pstring, sep[0].strip())) + parsed_string = "".join(parsed_string) + + if not mxp: + parsed_string = self.strip_mxp(parsed_string) + + if strip_ansi: + # remove all ansi codes (including those manually + # inserted in string) + return self.strip_raw_codes(parsed_string) + + # cache and crop old cache + _PARSE_CACHE[cachekey] = parsed_string + if len(_PARSE_CACHE) > _PARSE_CACHE_SIZE: + _PARSE_CACHE.popitem(last=False) + + return parsed_string
+ + +ANSI_PARSER = ANSIParser() + + +# +# Access function +# + + +
[docs]def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=False): + """ + Parses a string, subbing color codes as needed. + + Args: + string (str): The string to parse. + strip_ansi (bool, optional): Strip all ANSI sequences. + parser (ansi.AnsiParser, optional): A parser instance to use. + xterm256 (bool, optional): Support xterm256 or not. + mxp (bool, optional): Support MXP markup or not. + + Returns: + string (str): The parsed string. + + """ + string = string or "" + return parser.parse_ansi(string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp)
+ + +
[docs]def strip_ansi(string, parser=ANSI_PARSER): + """ + Strip all ansi from the string. This handles the Evennia-specific + markup. + + Args: + string (str): The string to strip. + parser (ansi.AnsiParser, optional): The parser to use. + + Returns: + string (str): The stripped string. + + """ + string = string or "" + return parser.parse_ansi(string, strip_ansi=True)
+ + +
[docs]def strip_raw_ansi(string, parser=ANSI_PARSER): + """ + Remove raw ansi codes from string. This assumes pure + ANSI-bytecodes in the string. + + Args: + string (str): The string to parse. + parser (bool, optional): The parser to use. + + Returns: + string (str): the stripped string. + + """ + string = string or "" + return parser.strip_raw_codes(string)
+ + +
[docs]def strip_unsafe_tokens(string, parser=ANSI_PARSER): + """ + Strip markup that can be used to create visual exploits + (notably linebreaks and tags) + + """ + return parser.strip_unsafe_tokens(string)
+ + +
[docs]def strip_mxp(string, parser=ANSI_PARSER): + """ + Strip MXP markup. + + """ + string = string or "" + return parser.strip_mxp(string)
+ + +
[docs]def raw(string): + """ + Escapes a string into a form which won't be colorized by the ansi + parser. + + Returns: + string (str): The raw, escaped string. + + """ + string = string or "" + return string.replace("{", "{{").replace("|", "||")
+ + +# ------------------------------------------------------------ +# +# ANSIString - ANSI-aware string class +# +# ------------------------------------------------------------ + + +def _spacing_preflight(func): + """ + This wrapper function is used to do some preflight checks on + functions used for padding ANSIStrings. + + """ + + @functools.wraps(func) + def wrapped(self, width=78, fillchar=None): + if fillchar is None: + fillchar = " " + if (len(fillchar) != 1) or (not isinstance(fillchar, str)): + raise TypeError("must be char, not %s" % type(fillchar)) + if not isinstance(width, int): + raise TypeError("integer argument expected, got %s" % type(width)) + _difference = width - len(self) + if _difference <= 0: + return self + return func(self, width, fillchar, _difference) + + return wrapped + + +def _query_super(func_name): + """ + Have the string class handle this with the cleaned string instead + of ANSIString. + + """ + + def wrapped(self, *args, **kwargs): + return getattr(self.clean(), func_name)(*args, **kwargs) + + return wrapped + + +def _on_raw(func_name): + """ + Like query_super, but makes the operation run on the raw string. + + """ + + def wrapped(self, *args, **kwargs): + args = list(args) + try: + string = args.pop(0) + if hasattr(string, "_raw_string"): + args.insert(0, string.raw()) + else: + args.insert(0, string) + except IndexError: + # just skip out if there are no more strings + pass + result = getattr(self._raw_string, func_name)(*args, **kwargs) + if isinstance(result, str): + return ANSIString(result, decoded=True) + return result + + return wrapped + + +def _transform(func_name): + """ + Some string functions, like those manipulating capital letters, + return a string the same length as the original. This function + allows us to do the same, replacing all the non-coded characters + with the resulting string. + + """ + + def wrapped(self, *args, **kwargs): + replacement_string = _query_super(func_name)(self, *args, **kwargs) + to_string = [] + char_counter = 0 + for index in range(0, len(self._raw_string)): + if index in self._code_indexes: + to_string.append(self._raw_string[index]) + elif index in self._char_indexes: + to_string.append(replacement_string[char_counter]) + char_counter += 1 + return ANSIString( + "".join(to_string), + decoded=True, + code_indexes=self._code_indexes, + char_indexes=self._char_indexes, + clean_string=replacement_string, + ) + + return wrapped + + +
[docs]class ANSIMeta(type): + """ + Many functions on ANSIString are just light wrappers around the string + base class. We apply them here, as part of the classes construction. + + """ + +
[docs] def __init__(cls, *args, **kwargs): + for func_name in [ + "count", + "startswith", + "endswith", + "find", + "index", + "isalnum", + "isalpha", + "isdigit", + "islower", + "isspace", + "istitle", + "isupper", + "rfind", + "rindex", + "__len__", + ]: + setattr(cls, func_name, _query_super(func_name)) + for func_name in ["__mod__", "expandtabs", "decode", "replace", "format", "encode"]: + setattr(cls, func_name, _on_raw(func_name)) + for func_name in ["capitalize", "translate", "lower", "upper", "swapcase"]: + setattr(cls, func_name, _transform(func_name)) + super().__init__(*args, **kwargs)
+ + +
[docs]class ANSIString(str, metaclass=ANSIMeta): + """ + Unicode-like object that is aware of ANSI codes. + + This class can be used nearly identically to strings, in that it will + report string length, handle slices, etc, much like a string object + would. The methods should be used identically as string methods are. + + There is at least one exception to this (and there may be more, though + they have not come up yet). When using ''.join() or u''.join() on an + ANSIString, color information will get lost. You must use + ANSIString('').join() to preserve color information. + + This implementation isn't perfectly clean, as it doesn't really have an + understanding of what the codes mean in order to eliminate + redundant characters-- though cleaning up the strings might end up being + inefficient and slow without some C code when dealing with larger values. + Such enhancements could be made as an enhancement to ANSI_PARSER + if needed, however. + + If one is going to use ANSIString, one should generally avoid converting + away from it until one is about to send information on the wire. This is + because escape sequences in the string may otherwise already be decoded, + and taken literally the second time around. + + """ + + # A compiled Regex for the format mini-language: + # https://docs.python.org/3/library/string.html#formatspec + re_format = re.compile( + r"(?i)(?P<just>(?P<fill>.)?(?P<align>\<|\>|\=|\^))?(?P<sign>\+|\-| )?(?P<alt>\#)?" + r"(?P<zero>0)?(?P<width>\d+)?(?P<grouping>\_|\,)?(?:\.(?P<precision>\d+))?" + r"(?P<type>b|c|d|e|E|f|F|g|G|n|o|s|x|X|%)?" + ) + + def __new__(cls, *args, **kwargs): + """ + When creating a new ANSIString, you may use a custom parser that has + the same attributes as the standard one, and you may declare the + string to be handled as already decoded. It is important not to double + decode strings, as escapes can only be respected once. + + Internally, ANSIString can also passes itself precached code/character + indexes and clean strings to avoid doing extra work when combining + ANSIStrings. + + """ + string = args[0] + if not isinstance(string, str): + string = to_str(string) + parser = kwargs.get("parser", ANSI_PARSER) + decoded = kwargs.get("decoded", False) or hasattr(string, "_raw_string") + code_indexes = kwargs.pop("code_indexes", None) + char_indexes = kwargs.pop("char_indexes", None) + clean_string = kwargs.pop("clean_string", None) + # All True, or All False, not just one. + checks = [x is None for x in [code_indexes, char_indexes, clean_string]] + if not len(set(checks)) == 1: + raise ValueError( + "You must specify code_indexes, char_indexes, " + "and clean_string together, or not at all." + ) + if not all(checks): + decoded = True + if not decoded: + # Completely new ANSI String + clean_string = parser.parse_ansi(string, strip_ansi=True, mxp=MXP_ENABLED) + string = parser.parse_ansi(string, xterm256=True, mxp=MXP_ENABLED) + elif clean_string is not None: + # We have an explicit clean string. + pass + elif hasattr(string, "_clean_string"): + # It's already an ANSIString + clean_string = string._clean_string + code_indexes = string._code_indexes + char_indexes = string._char_indexes + string = string._raw_string + else: + # It's a string that has been pre-ansi decoded. + clean_string = parser.strip_raw_codes(string) + + if not isinstance(string, str): + string = string.decode("utf-8") + + ansi_string = super().__new__(ANSIString, to_str(clean_string)) + ansi_string._raw_string = string + ansi_string._clean_string = clean_string + ansi_string._code_indexes = code_indexes + ansi_string._char_indexes = char_indexes + return ansi_string + + def __str__(self): + return self._raw_string + + def __format__(self, format_spec): + """ + This magic method covers ANSIString's behavior within a str.format() or f-string. + + Current features supported: fill, align, width. + + Args: + format_spec (str): The format specification passed by f-string or str.format(). This is + a string such as "0<30" which would mean "left justify to 30, filling with zeros". + The full specification can be found at + https://docs.python.org/3/library/string.html#formatspec + + Returns: + ansi_str (str): The formatted ANSIString's .raw() form, for display. + + """ + # This calls the compiled regex stored on ANSIString's class to analyze the format spec. + # It returns a dictionary. + format_data = self.re_format.match(format_spec).groupdict() + clean = self.clean() + base_output = ANSIString(self.raw()) + align = format_data.get("align", "<") + fill = format_data.get("fill", " ") + + # Need to coerce width into an integer. We can be certain that it's numeric thanks to regex. + width = format_data.get("width", None) + if width is None: + width = len(clean) + else: + width = int(width) + + if align == "<": + base_output = self.ljust(width, fill) + elif align == ">": + base_output = self.rjust(width, fill) + elif align == "^": + base_output = self.center(width, fill) + elif align == "=": + pass + + # Return the raw string with ANSI markup, ready to be displayed. + return base_output.raw() + + def __repr__(self): + """ + Let's make the repr the command that would actually be used to + construct this object, for convenience and reference. + + """ + return "ANSIString(%s, decoded=True)" % repr(self._raw_string) + +
[docs] def __init__(self, *_, **kwargs): + """ + When the ANSIString is first initialized, a few internal variables + have to be set. + + The first is the parser. It is possible to replace Evennia's standard + ANSI parser with one of your own syntax if you wish, so long as it + implements the same interface. + + The second is the _raw_string. This is the original "dumb" string + with ansi escapes that ANSIString represents. + + The third thing to set is the _clean_string. This is a string that is + devoid of all ANSI Escapes. + + Finally, _code_indexes and _char_indexes are defined. These are lookup + tables for which characters in the raw string are related to ANSI + escapes, and which are for the readable text. + + """ + self.parser = kwargs.pop("parser", ANSI_PARSER) + super().__init__() + if self._code_indexes is None: + self._code_indexes, self._char_indexes = self._get_indexes()
+ + @staticmethod + def _shifter(iterable, offset): + """ + Takes a list of integers, and produces a new one incrementing all + by a number. + + """ + if not offset: + return iterable + return [i + offset for i in iterable] + + @classmethod + def _adder(cls, first, second): + """ + Joins two ANSIStrings, preserving calculated info. + + """ + + raw_string = first._raw_string + second._raw_string + clean_string = first._clean_string + second._clean_string + code_indexes = first._code_indexes[:] + char_indexes = first._char_indexes[:] + code_indexes.extend(cls._shifter(second._code_indexes, len(first._raw_string))) + char_indexes.extend(cls._shifter(second._char_indexes, len(first._raw_string))) + return ANSIString( + raw_string, + code_indexes=code_indexes, + char_indexes=char_indexes, + clean_string=clean_string, + ) + + def __add__(self, other): + """ + We have to be careful when adding two strings not to reprocess things + that don't need to be reprocessed, lest we end up with escapes being + interpreted literally. + + """ + if not isinstance(other, str): + return NotImplemented + if not isinstance(other, ANSIString): + other = ANSIString(other) + return self._adder(self, other) + + def __radd__(self, other): + """ + Likewise, if we're on the other end. + + """ + if not isinstance(other, str): + return NotImplemented + if not isinstance(other, ANSIString): + other = ANSIString(other) + return self._adder(other, self) + + def __getslice__(self, i, j): + """ + This function is deprecated, so we just make it call the proper + function. + + """ + return self.__getitem__(slice(i, j)) + + def _slice(self, slc): + """ + This function takes a slice() object. + + Slices have to be handled specially. Not only are they able to specify + a start and end with [x:y], but many forget that they can also specify + an interval with [x:y:z]. As a result, not only do we have to track + the ANSI Escapes that have played before the start of the slice, we + must also replay any in these intervals, should they exist. + + Thankfully, slicing the _char_indexes table gives us the actual + indexes that need slicing in the raw string. We can check between + those indexes to figure out what escape characters need to be + replayed. + + """ + char_indexes = self._char_indexes + slice_indexes = char_indexes[slc] + # If it's the end of the string, we need to append final color codes. + if not slice_indexes: + # if we find no characters it may be because we are just outside + # of the interval, using an open-ended slice. We must replay all + # of the escape characters until/after this point. + if char_indexes: + if slc.start is None and slc.stop is None: + # a [:] slice of only escape characters + return ANSIString(self._raw_string[slc]) + if slc.start is None: + # this is a [:x] slice + return ANSIString(self._raw_string[: char_indexes[0]]) + if slc.stop is None: + # a [x:] slice + return ANSIString(self._raw_string[char_indexes[-1] + 1 :]) + return ANSIString("") + try: + string = self[slc.start or 0]._raw_string + except IndexError: + return ANSIString("") + last_mark = slice_indexes[0] + # Check between the slice intervals for escape sequences. + i = None + for i in slice_indexes[1:]: + for index in range(last_mark, i): + if index in self._code_indexes: + string += self._raw_string[index] + last_mark = i + try: + string += self._raw_string[i] + except IndexError: + # raw_string not long enough + pass + if i is not None: + append_tail = self._get_interleving(char_indexes.index(i) + 1) + else: + append_tail = "" + return ANSIString(string + append_tail, decoded=True) + + def __getitem__(self, item): + """ + Gateway for slices and getting specific indexes in the ANSIString. If + this is a regexable ANSIString, it will get the data from the raw + string instead, bypassing ANSIString's intelligent escape skipping, + for reasons explained in the __new__ method's docstring. + + """ + if isinstance(item, slice): + # Slices must be handled specially. + return self._slice(item) + try: + self._char_indexes[item] + except IndexError: + raise IndexError("ANSIString Index out of range") + # Get character codes after the index as well. + if self._char_indexes[-1] == self._char_indexes[item]: + append_tail = self._get_interleving(item + 1) + else: + append_tail = "" + item = self._char_indexes[item] + + clean = self._raw_string[item] + result = "" + # Get the character they're after, and replay all escape sequences + # previous to it. + for index in range(0, item + 1): + if index in self._code_indexes: + result += self._raw_string[index] + return ANSIString(result + clean + append_tail, decoded=True) + +
[docs] def clean(self): + """ + Return a string object *without* the ANSI escapes. + + Returns: + clean_string (str): A unicode object with no ANSI escapes. + + """ + return self._clean_string
+ +
[docs] def raw(self): + """ + Return a string object with the ANSI escapes. + + Returns: + raw (str): A unicode object *with* the raw ANSI escape sequences. + + """ + return self._raw_string
+ +
[docs] def partition(self, sep, reverse=False): + """ + Splits once into three sections (with the separator being the middle section) + + We use the same techniques we used in split() to make sure each are + colored. + + Args: + sep (str): The separator to split the string on. + reverse (boolean): Whether to split the string on the last + occurrence of the separator rather than the first. + + Returns: + ANSIString: The part of the string before the separator + ANSIString: The separator itself + ANSIString: The part of the string after the separator. + + """ + if hasattr(sep, "_clean_string"): + sep = sep.clean() + if reverse: + parent_result = self._clean_string.rpartition(sep) + else: + parent_result = self._clean_string.partition(sep) + current_index = 0 + result = tuple() + for section in parent_result: + result += (self[current_index : current_index + len(section)],) + current_index += len(section) + return result
+ + def _get_indexes(self): + """ + Two tables need to be made, one which contains the indexes of all + readable characters, and one which contains the indexes of all ANSI + escapes. It's important to remember that ANSI escapes require more + that one character at a time, though no readable character needs more + than one character, since the string base class abstracts that away + from us. However, several readable characters can be placed in a row. + + We must use regexes here to figure out where all the escape sequences + are hiding in the string. Then we use the ranges of their starts and + ends to create a final, comprehensive list of all indexes which are + dedicated to code, and all dedicated to text. + + It's possible that only one of these tables is actually needed, the + other assumed to be what isn't in the first. + + """ + + code_indexes = [] + for match in self.parser.ansi_regex.finditer(self._raw_string): + code_indexes.extend(list(range(match.start(), match.end()))) + if not code_indexes: + # Plain string, no ANSI codes. + return code_indexes, list(range(0, len(self._raw_string))) + # all indexes not occupied by ansi codes are normal characters + char_indexes = [i for i in range(len(self._raw_string)) if i not in code_indexes] + return code_indexes, char_indexes + + def _get_interleving(self, index): + """ + Get the code characters from the given slice end to the next + character. + + """ + try: + index = self._char_indexes[index - 1] + except IndexError: + return "" + s = "" + while True: + index += 1 + if index in self._char_indexes: + break + elif index in self._code_indexes: + s += self._raw_string[index] + else: + break + return s + + def __mul__(self, other): + """ + Multiplication method. Implemented for performance reasons. + + """ + if not isinstance(other, int): + return NotImplemented + raw_string = self._raw_string * other + clean_string = self._clean_string * other + code_indexes = self._code_indexes[:] + char_indexes = self._char_indexes[:] + for i in range(other): + code_indexes.extend(self._shifter(self._code_indexes, i * len(self._raw_string))) + char_indexes.extend(self._shifter(self._char_indexes, i * len(self._raw_string))) + return ANSIString( + raw_string, + code_indexes=code_indexes, + char_indexes=char_indexes, + clean_string=clean_string, + ) + + def __rmul__(self, other): + return self.__mul__(other) + +
[docs] def split(self, by=None, maxsplit=-1): + """ + Splits a string based on a separator. + + Stolen from PyPy's pure Python string implementation, tweaked for + ANSIString. + + PyPy is distributed under the MIT licence. + http://opensource.org/licenses/MIT + + Args: + by (str): A string to search for which will be used to split + the string. For instance, ',' for 'Hello,world' would + result in ['Hello', 'world'] + maxsplit (int): The maximum number of times to split the string. + For example, a maxsplit of 2 with a by of ',' on the string + 'Hello,world,test,string' would result in + ['Hello', 'world', 'test,string'] + Returns: + result (list of ANSIStrings): A list of ANSIStrings derived from + this string. + + """ + drop_spaces = by is None + if drop_spaces: + by = " " + + bylen = len(by) + if bylen == 0: + raise ValueError("empty separator") + + res = [] + start = 0 + while maxsplit != 0: + next = self._clean_string.find(by, start) + if next < 0: + break + # Get character codes after the index as well. + res.append(self[start:next]) + start = next + bylen + maxsplit -= 1 # NB. if it's already < 0, it stays < 0 + + res.append(self[start : len(self)]) + if drop_spaces: + return [part for part in res if part != ""] + return res
+ +
[docs] def rsplit(self, by=None, maxsplit=-1): + """ + Like split, but starts from the end of the string rather than the + beginning. + + Stolen from PyPy's pure Python string implementation, tweaked for + ANSIString. + + PyPy is distributed under the MIT licence. + http://opensource.org/licenses/MIT + + Args: + by (str): A string to search for which will be used to split + the string. For instance, ',' for 'Hello,world' would + result in ['Hello', 'world'] + maxsplit (int): The maximum number of times to split the string. + For example, a maxsplit of 2 with a by of ',' on the string + 'Hello,world,test,string' would result in + ['Hello,world', 'test', 'string'] + Returns: + result (list of ANSIStrings): A list of ANSIStrings derived from + this string. + + """ + res = [] + end = len(self) + drop_spaces = by is None + if drop_spaces: + by = " " + bylen = len(by) + if bylen == 0: + raise ValueError("empty separator") + + while maxsplit != 0: + next = self._clean_string.rfind(by, 0, end) + if next < 0: + break + # Get character codes after the index as well. + res.append(self[next + bylen : end]) + end = next + maxsplit -= 1 # NB. if it's already < 0, it stays < 0 + + res.append(self[:end]) + res.reverse() + if drop_spaces: + return [part for part in res if part != ""] + return res
+ +
[docs] def strip(self, chars=None): + """ + Strip from both ends, taking ANSI markers into account. + + Args: + chars (str, optional): A string containing individual characters + to strip off of both ends of the string. By default, any blank + spaces are trimmed. + Returns: + result (ANSIString): A new ANSIString with the ends trimmed of the + relevant characters. + + """ + clean = self._clean_string + raw = self._raw_string + + # count continuous sequence of chars from left and right + nlen = len(clean) + nlstripped = nlen - len(clean.lstrip(chars)) + nrstripped = nlen - len(clean.rstrip(chars)) + + # within the stripped regions, only retain parts of the raw + # string *not* matching the clean string (these are ansi/mxp tags) + lstripped = "" + ic, ir1 = 0, 0 + while nlstripped: + if ic >= nlstripped: + break + elif raw[ir1] != clean[ic]: + lstripped += raw[ir1] + else: + ic += 1 + ir1 += 1 + rstripped = "" + ic, ir2 = nlen - 1, len(raw) - 1 + while nrstripped: + if nlen - ic > nrstripped: + break + elif raw[ir2] != clean[ic]: + rstripped += raw[ir2] + else: + ic -= 1 + ir2 -= 1 + rstripped = rstripped[::-1] + return ANSIString(lstripped + raw[ir1 : ir2 + 1] + rstripped)
+ +
[docs] def lstrip(self, chars=None): + """ + Strip from the left, taking ANSI markers into account. + + Args: + chars (str, optional): A string containing individual characters + to strip off of the left end of the string. By default, any + blank spaces are trimmed. + Returns: + result (ANSIString): A new ANSIString with the left end trimmed of + the relevant characters. + + """ + clean = self._clean_string + raw = self._raw_string + + # count continuous sequence of chars from left and right + nlen = len(clean) + nlstripped = nlen - len(clean.lstrip(chars)) + # within the stripped regions, only retain parts of the raw + # string *not* matching the clean string (these are ansi/mxp tags) + lstripped = "" + ic, ir1 = 0, 0 + while nlstripped: + if ic >= nlstripped: + break + elif raw[ir1] != clean[ic]: + lstripped += raw[ir1] + else: + ic += 1 + ir1 += 1 + return ANSIString(lstripped + raw[ir1:])
+ +
[docs] def rstrip(self, chars=None): + """ + Strip from the right, taking ANSI markers into account. + + Args: + chars (str, optional): A string containing individual characters + to strip off of the right end of the string. By default, any + blank spaces are trimmed. + Returns: + result (ANSIString): A new ANSIString with the right end trimmed of + the relevant characters. + + """ + clean = self._clean_string + raw = self._raw_string + nlen = len(clean) + nrstripped = nlen - len(clean.rstrip(chars)) + rstripped = "" + ic, ir2 = nlen - 1, len(raw) - 1 + while nrstripped: + if nlen - ic > nrstripped: + break + elif raw[ir2] != clean[ic]: + rstripped += raw[ir2] + else: + ic -= 1 + ir2 -= 1 + rstripped = rstripped[::-1] + return ANSIString(raw[: ir2 + 1] + rstripped)
+ +
[docs] def join(self, iterable): + """ + Joins together strings in an iterable, using this string between each + one. + + NOTE: This should always be used for joining strings when ANSIStrings + are involved. Otherwise color information will be discarded by python, + due to details in the C implementation of strings. + + Args: + iterable (list of strings): A list of strings to join together + + Returns: + ANSIString: A single string with all of the iterable's + contents concatenated, with this string between each. + + Examples: + :: + + >>> ANSIString(', ').join(['up', 'right', 'left', 'down']) + ANSIString('up, right, left, down') + + """ + result = ANSIString("") + last_item = None + for item in iterable: + if last_item is not None: + result += self._raw_string + if not isinstance(item, ANSIString): + item = ANSIString(item) + result += item + last_item = item + return result
+ + def _filler(self, char, amount): + """ + Generate a line of characters in a more efficient way than just adding + ANSIStrings. + + """ + if not isinstance(char, ANSIString): + line = char * amount + return ANSIString( + char * amount, + code_indexes=[], + char_indexes=list(range(0, len(line))), + clean_string=char, + ) + try: + start = char._code_indexes[0] + except IndexError: + start = None + end = char._char_indexes[0] + prefix = char._raw_string[start:end] + postfix = char._raw_string[end + 1 :] + line = char._clean_string * amount + code_indexes = [i for i in range(0, len(prefix))] + length = len(prefix) + len(line) + code_indexes.extend([i for i in range(length, length + len(postfix))]) + char_indexes = self._shifter(list(range(0, len(line))), len(prefix)) + raw_string = prefix + line + postfix + return ANSIString( + raw_string, clean_string=line, char_indexes=char_indexes, code_indexes=code_indexes + ) + + # The following methods should not be called with the '_difference' argument explicitly. This is + # data provided by the wrapper _spacing_preflight. +
[docs] @_spacing_preflight + def center(self, width, fillchar, _difference): + """ + Center some text with some spaces padding both sides. + + Args: + width (int): The target width of the output string. + fillchar (str): A single character string to pad the output string + with. + Returns: + result (ANSIString): A string padded on both ends with fillchar. + + """ + remainder = _difference % 2 + _difference //= 2 + spacing = self._filler(fillchar, _difference) + result = spacing + self + spacing + self._filler(fillchar, remainder) + return result
+ +
[docs] @_spacing_preflight + def ljust(self, width, fillchar, _difference): + """ + Left justify some text. + + Args: + width (int): The target width of the output string. + fillchar (str): A single character string to pad the output string + with. + Returns: + result (ANSIString): A string padded on the right with fillchar. + + """ + return self + self._filler(fillchar, _difference)
+ +
[docs] @_spacing_preflight + def rjust(self, width, fillchar, _difference): + """ + Right justify some text. + + Args: + width (int): The target width of the output string. + fillchar (str): A single character string to pad the output string + with. + Returns: + result (ANSIString): A string padded on the left with fillchar. + + """ + return self._filler(fillchar, _difference) + self
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/batchprocessors.html b/docs/latest/_modules/evennia/utils/batchprocessors.html new file mode 100644 index 0000000000..53e22c76a1 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/batchprocessors.html @@ -0,0 +1,546 @@ + + + + + + + + evennia.utils.batchprocessors — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.batchprocessors

+"""
+This module contains the core methods for the Batch-command- and
+Batch-code-processors respectively. In short, these are two different ways to
+build a game world using a normal text-editor without having to do so 'on the
+fly' in-game. They also serve as an automatic backup so you can quickly
+recreate a world also after a server reset. The functions in this module is
+meant to form the backbone of a system called and accessed through game
+commands.
+
+The Batch-command processor is the simplest. It simply runs a list of in-game
+commands in sequence by reading them from a text file. The advantage of this is
+that the builder only need to remember the normal in-game commands. They are
+also executing with full permission checks etc, making it relatively safe for
+builders to use. The drawback is that in-game there is really a
+builder-character walking around building things, and it can be important to
+create rooms and objects in the right order, so the character can move between
+them. Also objects that affects players (such as mobs, dark rooms etc) will
+affect the building character too, requiring extra care to turn off/on.
+
+The Batch-code processor is a more advanced system that accepts full
+Python code, executing in chunks. The advantage of this is much more
+power; practically anything imaginable can be coded and handled using
+the batch-code processor. There is no in-game character that moves and
+that can be affected by what is being built - the database is
+populated on the fly. The drawback is safety and entry threshold - the
+code is executed as would any server code, without mud-specific
+permission-checks, and you have full access to modifying objects
+etc. You also need to know Python and Evennia's API. Hence it's
+recommended that the batch-code processor is limited only to
+superusers or highly trusted staff.
+
+# Batch-command processor file syntax
+
+The batch-command processor accepts 'batchcommand files' e.g
+`batch.ev`, containing a sequence of valid Evennia commands in a
+simple format. The engine runs each command in sequence, as if they
+had been run at the game prompt.
+
+Each Evennia command must be delimited by a line comment to mark its
+end.
+
+::
+
+    look
+    # delimiting comment
+    create/drop box
+    # another required comment
+
+One can also inject another batchcmdfile:
+
+::
+
+    #INSERT path.batchcmdfile
+
+This way entire game worlds can be created and planned offline; it is
+especially useful in order to create long room descriptions where a
+real offline text editor is often much better than any online text
+editor or prompt.
+
+## Example of batch.ev file:
+
+::
+
+    # batch file
+    # all lines starting with # are comments; they also indicate
+    # that a command definition is over.
+
+    create box
+
+    # this comment ends the @create command.
+
+    set box/desc = A large box.
+
+    Inside are some scattered piles of clothing.
+
+
+    It seems the bottom of the box is a bit loose.
+
+    # Again, this comment indicates the @set command is over. Note how
+    # the description could be freely added. Excess whitespace on a line
+    # is ignored.  An empty line in the command definition is parsed as a \n
+    # (so two empty lines becomes a new paragraph).
+
+    teleport #221
+
+    # (Assuming #221 is a warehouse or something.)
+    # (remember, this comment ends the @teleport command! Don'f forget it)
+
+    # Example of importing another file at this point.
+    #IMPORT examples.batch
+
+    drop box
+
+    # Done, the box is in the warehouse! (this last comment is not necessary to
+    # close the drop command since it's the end of the file)
+
+An example batch file is `contrib/examples/batch_example.ev`.
+
+# Batch-code processor file syntax
+
+The Batch-code processor accepts full python modules (e.g. `batch.py`)
+that looks identical to normal Python files. The difference from
+importing and running any Python module is that the batch-code module
+is loaded as a file and executed directly, so changes to the file will
+apply immediately without a server @reload.
+
+Optionally, one can add some special commented tokens to split the
+execution of the code for the benefit of the batchprocessor's
+interactive- and debug-modes. This allows to conveniently step through
+the code and re-run sections of it easily during development.
+
+Code blocks are marked by commented tokens alone on a line:
+
+- `#HEADER` - This denotes code that should be pasted at the top of all
+  other code. Multiple HEADER statements - regardless of where
+  it exists in the file - is the same as one big block.
+  Observe that changes to variables made in one block is not
+  preserved between blocks!
+- `#CODE` - This designates a code block that will be executed like a
+  stand-alone piece of code together with any HEADER(s)
+  defined. It is mainly used as a way to mark stop points for
+  the interactive mode of the batchprocessor. If no CODE block
+  is defined in the module, the entire module (including HEADERS)
+  is assumed to be a CODE block.
+- `#INSERT path.filename` - This imports another batch_code.py file and
+  runs it in the given position. The inserted file will retain
+  its own HEADERs which will not be mixed with the headers of
+  this file.
+
+Importing works as normal. The following variables are automatically
+made available in the script namespace.
+
+- `caller` - The object executing the batchscript
+- `DEBUG` - This is a boolean marking if the batchprocessor is running
+            in debug mode. It can be checked to e.g. delete created objects
+            when running a CODE block multiple times during testing.
+            (avoids creating a slew of same-named db objects)
+
+## Example batch.py file
+
+::
+
+    #HEADER
+
+    from django.conf import settings
+    from evennia.utils import create
+    from types import basetypes
+
+    GOLD = 10
+
+    #CODE
+
+    obj = create.create_object(basetypes.Object)
+    obj2 = create.create_object(basetypes.Object)
+    obj.location = caller.location
+    obj.db.gold = GOLD
+    caller.msg("The object was created!")
+
+    if DEBUG:
+        obj.delete()
+        obj2.delete()
+
+    #INSERT another_batch_file
+
+    #CODE
+
+    script = create.create_script()
+
+"""
+import codecs
+import re
+import sys
+import traceback
+
+from django.conf import settings
+
+from evennia.utils import utils
+
+_ENCODINGS = settings.ENCODINGS
+_RE_INSERT = re.compile(r"^\#\s*?INSERT (.*)$", re.MULTILINE)
+_RE_CLEANBLOCK = re.compile(r"^\#.*?$|^\s*$", re.MULTILINE)
+_RE_CMD_SPLIT = re.compile(r"^\#.*?$", re.MULTILINE)
+_RE_CODE_OR_HEADER = re.compile(
+    r"((?:\A|^)#\s*?CODE|(?:/A|^)#\s*?HEADER|\A)(.*?)$(.*?)(?=^#\s*?CODE.*?$|^#\s*?HEADER.*?$|\Z)",
+    re.MULTILINE + re.DOTALL,
+)
+
+
+# -------------------------------------------------------------
+# Helper function
+# -------------------------------------------------------------
+
+
+
[docs]def read_batchfile(pythonpath, file_ending=".py"): + """ + This reads the contents of a batch-file. Filename is considered + to be a python path to a batch file relative the directory + specified in `settings.py`. + + file_ending specify which batchfile ending should be assumed (.ev + or .py). The ending should not be included in the python path. + + Args: + pythonpath (str): A dot-python path to a file. + file_ending (str): The file ending of this file (.ev or .py) + + Returns: + text (str): The text content of the batch file. + + Raises: + IOError: If problems reading file. + + """ + + # find all possible absolute paths + abspaths = utils.pypath_to_realpath(pythonpath, file_ending, settings.BASE_BATCHPROCESS_PATHS) + if not abspaths: + raise IOError("Absolute batchcmd paths could not be found.") + text = None + decoderr = [] + for abspath in abspaths: + # try different paths, until we get a match + # we read the file directly into string. + for file_encoding in _ENCODINGS: + # try different encodings, in order + try: + with codecs.open(abspath, "r", encoding=file_encoding) as fobj: + text = fobj.read() + except (ValueError, UnicodeDecodeError) as e: + # this means an encoding error; try another encoding + decoderr.append(str(e)) + continue + break + if not text and decoderr: + raise UnicodeDecodeError("\n".join(decoderr), bytearray(), 0, 0, "") + + return text
+ + +# ------------------------------------------------------------- +# +# Batch-command processor +# +# ------------------------------------------------------------- + + +
[docs]class BatchCommandProcessor(object): + """ + This class implements a batch-command processor. + + """ + +
[docs] def parse_file(self, pythonpath): + """ + This parses the lines of a batch-command-file. + + Args: + pythonpath (str): The dot-python path to the file. + + Returns: + list: A list of all parsed commands with arguments, as strings. + + Notes: + Parsing follows the following rules: + + 1. A `#` at the beginning of a line marks the end of the command before + it. It is also a comment and any number of # can exist on + subsequent lines (but not inside comments). + 2. #INSERT at the beginning of a line imports another + batch-cmd file file and pastes it into the batch file as if + it was written there. + 3. Commands are placed alone at the beginning of a line and their + arguments are considered to be everything following (on any + number of lines) until the next comment line beginning with #. + 4. Newlines are ignored in command definitions + 5. A completely empty line in a command line definition is condered + a newline (so two empty lines is a paragraph). + 6. Excess spaces and indents inside arguments are stripped. + + """ + + text = "".join(read_batchfile(pythonpath, file_ending=".ev")) + + def replace_insert(match): + """Map replace entries""" + try: + path = match.group(1) + return "\n#\n".join(self.parse_file(path)) + except IOError: + raise IOError("#INSERT {} failed.".format(path)) + + text = _RE_INSERT.sub(replace_insert, text) + commands = _RE_CMD_SPLIT.split(text) + commands = [c.strip("\r\n") for c in commands] + commands = [c for c in commands if c] + + return commands
+ + +# ------------------------------------------------------------- +# +# Batch-code processor +# +# ------------------------------------------------------------- + + +
[docs]def tb_filename(tb): + """Helper to get filename from traceback""" + return tb.tb_frame.f_code.co_filename
+ + +
[docs]def tb_iter(tb): + """Traceback iterator.""" + while tb is not None: + yield tb + tb = tb.tb_next
+ + +
[docs]class BatchCodeProcessor(object): + """ + This implements a batch-code processor + + """ + +
[docs] def parse_file(self, pythonpath): + """ + This parses the lines of a batch-code file + + Args: + pythonpath (str): The dot-python path to the file. + + Returns: + list: A list of all `#CODE` blocks, each with + prepended `#HEADER` block data. If no `#CODE` + blocks were found, this will be a list of one element + containing all code in the file (so a normal Python file). + + Notes: + Parsing is done according to the following rules: + + 1. Code before a #CODE/HEADER block are considered part of + the first code/header block or is the ONLY block if no + `#CODE/HEADER` blocks are defined. + 2. Lines starting with #HEADER starts a header block (ends other blocks) + 3. Lines starting with #CODE begins a code block (ends other blocks) + 4. Lines starting with #INSERT are on form #INSERT filename. Code from + this file are processed with their headers *separately* before + being inserted at the point of the #INSERT. + 5. Code after the last block is considered part of the last header/code + block + + + """ + + text = "".join(read_batchfile(pythonpath, file_ending=".py")) + + def replace_insert(match): + """Run parse_file on the import before sub:ing it into this file""" + path = match.group(1) + try: + return "# batchcode insert (%s):" % path + "\n".join(self.parse_file(path)) + except IOError as err: + raise IOError("#INSERT {} failed.".format(path)) + + # process and then insert code from all #INSERTS + text = _RE_INSERT.sub(replace_insert, text) + + headers = [] + codes = [] + for imatch, match in enumerate(list(_RE_CODE_OR_HEADER.finditer(text))): + mtype = match.group(1).strip().lstrip("#").strip() + # we need to handle things differently at the start of the file + if mtype: + istart, iend = match.span(3) + else: + istart, iend = match.start(2), match.end(3) + code = text[istart:iend] + if mtype == "HEADER": + headers.append(code) + else: # either CODE or matching from start of file + codes.append(code) + + # join all headers together to one + header = "# batchcode header:\n%s\n\n" % "\n\n".join(headers) if headers else "" + # add header to each code block + codes = ["%s# batchcode code:\n%s" % (header, code) for code in codes] + return codes
+ +
[docs] def code_exec(self, code, extra_environ=None, debug=False): + """ + Execute a single code block, including imports and appending + global vars. + + Args: + code (str): Code to run. + extra_environ (dict): Environment variables to run with code. + debug (bool, optional): Set the DEBUG variable in the execution + namespace. + + Returns: + err (str or None): An error code or None (ok). + + """ + # define the execution environment + environdict = {"settings_module": settings, "DEBUG": debug} + for key, value in extra_environ.items(): + environdict[key] = value + + # initializing the django settings at the top of code + code = ( + "# batchcode evennia initialization: \n" + "try: settings_module.configure()\n" + "except RuntimeError: pass\n" + "finally: del settings_module\n\n%s" % code + ) + + # execute the block + try: + exec(code, environdict) + except Exception: + etype, value, tb = sys.exc_info() + + fname = tb_filename(tb) + for tb in tb_iter(tb): + if fname != tb_filename(tb): + break + lineno = tb.tb_lineno - 1 + err = "" + for iline, line in enumerate(code.split("\n")): + if iline == lineno: + err += "\n|w%02i|n: %s" % (iline + 1, line) + elif lineno - 5 < iline < lineno + 5: + err += "\n%02i: %s" % (iline + 1, line) + + err += "\n".join(traceback.format_exception(etype, value, tb)) + return err + return None
+ + +BATCHCMD = BatchCommandProcessor() +BATCHCODE = BatchCodeProcessor() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/containers.html b/docs/latest/_modules/evennia/utils/containers.html new file mode 100644 index 0000000000..e50c581a8b --- /dev/null +++ b/docs/latest/_modules/evennia/utils/containers.html @@ -0,0 +1,359 @@ + + + + + + + + evennia.utils.containers — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.containers

+"""
+Containers
+
+Containers are storage classes usually initialized from a setting. They
+represent Singletons and acts as a convenient place to find resources (
+available as properties on the singleton)
+
+evennia.GLOBAL_SCRIPTS
+evennia.OPTION_CLASSES
+
+"""
+
+
+from pickle import dumps
+
+from django.conf import settings
+from django.db.utils import OperationalError, ProgrammingError
+
+from evennia.utils import logger
+from evennia.utils.utils import callables_from_module, class_from_module
+
+SCRIPTDB = None
+
+
+
[docs]class Container: + """ + Base container class. A container is simply a storage object whose + properties can be acquired as a property on it. This is generally + considered a read-only affair. + + The container is initialized by a list of modules containing callables. + + """ + + storage_modules = [] + +
[docs] def __init__(self): + """ + Read data from module. + + """ + self.loaded_data = None
+ +
[docs] def load_data(self): + """ + Delayed import to avoid eventual circular imports from inside + the storage modules. + + """ + if self.loaded_data is None: + self.loaded_data = {} + for module in self.storage_modules: + self.loaded_data.update(callables_from_module(module))
+ + def __getattr__(self, key): + return self.get(key) + +
[docs] def get(self, key, default=None): + """ + Retrive data by key (in case of not knowing it beforehand). + + Args: + key (str): The name of the script. + default (any, optional): Value to return if key is not found. + + Returns: + any (any): The data loaded on this container. + + """ + self.load_data() + return self.loaded_data.get(key, default)
+ +
[docs] def all(self): + """ + Get all stored data + + Returns: + scripts (list): All global script objects stored on the container. + + """ + self.load_data() + return list(self.loaded_data.values())
+ + +
[docs]class OptionContainer(Container): + """ + Loads and stores the final list of OPTION CLASSES. + + Can access these as properties or dictionary-contents. + """ + + storage_modules = settings.OPTION_CLASS_MODULES
+ + +
[docs]class GlobalScriptContainer(Container): + """ + Simple Handler object loaded by the Evennia API to contain and manage a + game's Global Scripts. This will list global Scripts created on their own + but will also auto-(re)create scripts defined in `settings.GLOBAL_SCRIPTS`. + + Example: + import evennia + evennia.GLOBAL_SCRIPTS.scriptname + + Note: + This does not use much of the BaseContainer since it's not loading + callables from settings but a custom dict of tuples. + + """ + +
[docs] def __init__(self): + """ + Note: We must delay loading of typeclasses since this module may get + initialized before Scripts are actually initialized. + + """ + self.typeclass_storage = dict() + self.loaded_data = { + key: {} if data is None else data for key, data in settings.GLOBAL_SCRIPTS.items() + } + self.loaded = False
+ + def _load_script(self, key): + typeclass = self.typeclass_storage[key] + script = typeclass.objects.filter( + db_key=key, db_account__isnull=True, db_obj__isnull=True + ).first() + + kwargs = {**self.loaded_data[key]} + kwargs["key"] = key + kwargs["persistent"] = kwargs.get("persistent", True) + + compare_hash = str(dumps(kwargs, protocol=4)) + + if script: + script_hash = script.attributes.get("global_script_settings", category="settings_hash") + if script_hash is None: + # legacy - store the hash anew and assume no change + script.attributes.add( + "global_script_settings", compare_hash, category="settings_hash" + ) + elif script_hash != compare_hash: + # wipe the old version and create anew + logger.log_info(f"GLOBAL_SCRIPTS: Settings changed for {key} ({typeclass}).") + script.stop() + script.delete() + script = None + + if not script: + logger.log_info(f"GLOBAL_SCRIPTS: (Re)creating {key} ({typeclass}).") + + script, errors = typeclass.create(**kwargs) + if errors: + logger.log_err("\n".join(errors)) + return None + + # store a hash representation of the setup + script.attributes.add("global_script_settings", compare_hash, category="settings_hash") + + return script + +
[docs] def start(self): + """ + Called last in evennia.__init__ to initialize the container late + (after script typeclasses have finished loading). + + We include all global scripts in the handler and + make sure to auto-load time-based scripts. + + """ + # populate self.typeclass_storage + if not self.loaded: + self.load_data() + + # make sure settings-defined scripts are loaded + scripts_to_run = [] + for key in self.loaded_data: + script = self._load_script(key) + if script: + scripts_to_run.append(script) + # start all global scripts + try: + for script in scripts_to_run: + script.start() + except (OperationalError, ProgrammingError): + # this can happen if db is not loaded yet (such as when building docs) + pass
+ +
[docs] def load_data(self): + """ + This delayed import avoids trying to load Scripts before they are + initialized. + + """ + if self.loaded: + return + if not self.typeclass_storage: + for key, data in list(self.loaded_data.items()): + typeclass = data.get("typeclass", settings.BASE_SCRIPT_TYPECLASS) + self.typeclass_storage[key] = class_from_module( + typeclass, fallback=settings.BASE_SCRIPT_TYPECLASS + ) + self.loaded = True
+ +
[docs] def get(self, key, default=None): + """ + Retrive data by key (in case of not knowing it beforehand). Any + scripts that are in settings.GLOBAL_SCRIPTS that are not found + will be recreated on-demand. + + Args: + key (str): The name of the script. + default (any, optional): Value to return if key is not found + at all on this container (i.e it cannot be loaded at all). + + Returns: + any (any): The data loaded on this container. + """ + if not self.loaded: + self.load_data() + out_value = default + if key in self.loaded_data: + if key not in self.typeclass_storage: + # this means we are trying to load in a loop + raise RuntimeError( + f"Trying to access `GLOBAL_SCRIPTS.{key}` before scripts have finished " + "initializing. This can happen if accessing GLOBAL_SCRIPTS from the same " + "module the script is defined in." + ) + # recreate if we have the info + script_found = self._load_script(key) + if script_found: + out_value = script_found + + return out_value
+ +
[docs] def all(self): + """ + Get all global scripts. Note that this will not auto-start + scripts defined in settings. + + Returns: + scripts (list): All global script objects stored on the container. + + """ + if not self.loaded: + self.load_data() + return self.scripts.values()
+ + +# Create all singletons + +GLOBAL_SCRIPTS = GlobalScriptContainer() +OPTION_CLASSES = OptionContainer() +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/dbserialize.html b/docs/latest/_modules/evennia/utils/dbserialize.html new file mode 100644 index 0000000000..de704dd54d --- /dev/null +++ b/docs/latest/_modules/evennia/utils/dbserialize.html @@ -0,0 +1,1091 @@ + + + + + + + + evennia.utils.dbserialize — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.dbserialize

+"""
+This module handles serialization of arbitrary python structural data,
+intended primarily to be stored in the database. It also supports
+storing Django model instances (which plain pickle cannot do).
+
+This serialization is used internally by the server, notably for
+storing data in Attributes and for piping data to process pools.
+
+The purpose of dbserialize is to handle all forms of data. For
+well-structured non-arbitrary exchange, such as communicating with a
+rich web client, a simpler JSON serialization makes more sense.
+
+This module also implements the `SaverList`, `SaverDict` and `SaverSet`
+classes. These are iterables that track their position in a nested
+structure and makes sure to send updates up to their root. This is
+used by Attributes - without it, one would not be able to update mutables
+in-situ, e.g `obj.db.mynestedlist[3][5] = 3` would never be saved and
+be out of sync with the database.
+
+"""
+from collections import OrderedDict, defaultdict, deque
+from collections.abc import MutableMapping, MutableSequence, MutableSet
+from functools import update_wrapper
+
+try:
+    from pickle import UnpicklingError, dumps, loads
+except ImportError:
+    from pickle import dumps, loads
+
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
+from django.utils.safestring import SafeString
+
+import evennia
+from evennia.utils import logger
+from evennia.utils.utils import is_iter, to_bytes, uses_database
+
+__all__ = ("to_pickle", "from_pickle", "do_pickle", "do_unpickle", "dbserialize", "dbunserialize")
+
+PICKLE_PROTOCOL = 2
+
+
+# message to send if editing an already deleted Attribute in a savermutable
+_ERROR_DELETED_ATTR = (
+    "{cls_name} {obj} has had its root Attribute deleted. "
+    "It must be cast to a {non_saver_name} before it can be modified further."
+)
+
+
+def _get_mysql_db_version():
+    """
+    This is a helper method for specifically getting the version
+    string of a MySQL database.
+
+    Returns:
+        mysql_version (str): The currently used mysql database
+            version.
+
+    """
+    from django.db import connection
+
+    conn = connection.cursor()
+    conn.execute("SELECT VERSION()")
+    version = conn.fetchone()
+    return version and str(version[0]) or ""
+
+
+# initialization and helpers
+
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+_FROM_MODEL_MAP = None
+_TO_MODEL_MAP = None
+_IGNORE_DATETIME_MODELS = None
+
+
+def _IS_PACKED_DBOBJ(o):
+    return isinstance(o, tuple) and len(o) == 4 and o[0] == "__packed_dbobj__"
+
+
+def _IS_PACKED_SESSION(o):
+    return isinstance(o, tuple) and len(o) == 3 and o[0] == "__packed_session__"
+
+
+if uses_database("mysql") and _get_mysql_db_version() < "5.6.4":
+    # mysql <5.6.4 don't support millisecond precision
+    _DATESTRING = "%Y:%m:%d-%H:%M:%S:000000"
+else:
+    _DATESTRING = "%Y:%m:%d-%H:%M:%S:%f"
+
+
+def _TO_DATESTRING(obj):
+    """
+    Creates datestring hash.
+
+    Args:
+        obj (Object): Database object.
+
+    Returns:
+        datestring (str): A datestring hash.
+
+    """
+    try:
+        return _GA(obj, "db_date_created").strftime(_DATESTRING)
+    except AttributeError:
+        # this can happen if object is not yet saved - no datestring is then set
+        try:
+            obj.save()
+        except AttributeError:
+            # we have received a None object, for example due to an erroneous save.
+            return None
+        return _GA(obj, "db_date_created").strftime(_DATESTRING)
+
+
+def _init_globals():
+    """Lazy importing to avoid circular import issues"""
+    global _FROM_MODEL_MAP, _TO_MODEL_MAP, _IGNORE_DATETIME_MODELS
+    if not _FROM_MODEL_MAP:
+        _FROM_MODEL_MAP = defaultdict(str)
+        _FROM_MODEL_MAP.update(dict((c.model, c.natural_key()) for c in ContentType.objects.all()))
+    if not _TO_MODEL_MAP:
+        from django.conf import settings
+
+        _TO_MODEL_MAP = defaultdict(str)
+        _TO_MODEL_MAP.update(
+            dict((c.natural_key(), c.model_class()) for c in ContentType.objects.all())
+        )
+        _IGNORE_DATETIME_MODELS = []
+        for src_key, dst_key in settings.ATTRIBUTE_STORED_MODEL_RENAME:
+            _TO_MODEL_MAP[src_key] = _TO_MODEL_MAP.get(dst_key, None)
+            _IGNORE_DATETIME_MODELS.append(src_key)
+
+
+#
+# SaverList, SaverDict, SaverSet - Attribute-specific helper classes and functions
+#
+
+
+def _save(method):
+    """method decorator that saves data to Attribute"""
+
+    def save_wrapper(self, *args, **kwargs):
+        self.__doc__ = method.__doc__
+        ret = method(self, *args, **kwargs)
+        self._save_tree()
+        return ret
+
+    return update_wrapper(save_wrapper, method)
+
+
+class _SaverMutable:
+    """
+    Parent class for properly handling  of nested mutables in
+    an Attribute. If not used something like
+     obj.db.mylist[1][2] = "test" (allocation to a nested list)
+    will not save the updated value to the database.
+    """
+
+    def __init__(self, *args, **kwargs):
+        """store all properties for tracking the tree"""
+        self._parent = kwargs.pop("_parent", None)
+        self._db_obj = kwargs.pop("_db_obj", None)
+        self._data = None
+
+    def __bool__(self):
+        """Make sure to evaluate as False if empty"""
+        return bool(self._data)
+
+    def _save_tree(self):
+        """recursively traverse back up the tree, save when we reach the root"""
+        if self._parent:
+            self._parent._save_tree()
+        elif self._db_obj:
+            if not self._db_obj.pk:
+                cls_name = self.__class__.__name__
+                try:
+                    non_saver_name = cls_name.split("_Saver", 1)[1].lower()
+                except IndexError:
+                    non_saver_name = cls_name
+                raise ValueError(
+                    _ERROR_DELETED_ATTR.format(
+                        cls_name=cls_name, obj=self, non_saver_name=non_saver_name
+                    )
+                )
+            self._db_obj.value = self
+        else:
+            logger.log_err("_SaverMutable %s has no root Attribute to save to." % self)
+
+    def _convert_mutables(self, data):
+        """converts mutables to Saver* variants and assigns ._parent property"""
+
+        def process_tree(item, parent):
+            """recursively populate the tree, storing parents"""
+            dtype = type(item)
+            if dtype in (str, int, float, bool, tuple):
+                return item
+            elif dtype == list:
+                dat = _SaverList(_parent=parent)
+                dat._data.extend(process_tree(val, dat) for val in item)
+                return dat
+            elif dtype == dict:
+                dat = _SaverDict(_parent=parent)
+                dat._data.update((key, process_tree(val, dat)) for key, val in item.items())
+                return dat
+            elif dtype == defaultdict:
+                dat = _SaverDefaultDict(item.default_factory, _parent=parent)
+                dat._data.update((key, process_tree(val, dat)) for key, val in item.items())
+                return dat
+            elif dtype == deque:
+                dat = _SaverDeque(_parent=parent, maxlen=item.maxlen)
+            elif dtype == set:
+                dat = _SaverSet(_parent=parent)
+                dat._data.update(process_tree(val, dat) for val in item)
+                return dat
+            return item
+
+        return process_tree(data, self)
+
+    def __repr__(self):
+        return self._data.__repr__()
+
+    def __len__(self):
+        return self._data.__len__()
+
+    def __iter__(self):
+        return self._data.__iter__()
+
+    def __getitem__(self, key):
+        return self._data.__getitem__(key)
+
+    def __eq__(self, other):
+        return self._data == other
+
+    def __ne__(self, other):
+        return self._data != other
+
+    def __lt__(self, other):
+        return self._data < other
+
+    def __gt__(self, other):
+        return self._data > other
+
+    def __or__(self, other):
+        return self._data | other
+
+    def __ror__(self, other):
+        return self._data | other
+
+    @_save
+    def __setitem__(self, key, value):
+        self._data.__setitem__(key, self._convert_mutables(value))
+
+    @_save
+    def __delitem__(self, key):
+        self._data.__delitem__(key)
+
+    def deserialize(self):
+        """Deserializes this mutable into its corresponding non-Saver type."""
+        return deserialize(self)
+
+
+class _SaverList(_SaverMutable, MutableSequence):
+    """
+    A list that saves itself to an Attribute when updated.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._data = kwargs.pop("_class", list)()
+
+    @_save
+    def __iadd__(self, otherlist):
+        self._data = self._data.__add__(otherlist)
+        return self._data
+
+    def __add__(self, otherlist):
+        return list(self._data) + otherlist
+
+    @_save
+    def insert(self, index, value):
+        self._data.insert(index, self._convert_mutables(value))
+
+    def __eq__(self, other):
+        try:
+            return list(self._data) == list(other)
+        except TypeError:
+            return False
+
+    def __ne__(self, other):
+        try:
+            return list(self._data) != list(other)
+        except TypeError:
+            return True
+
+    def index(self, value, *args):
+        return self._data.index(value, *args)
+
+    @_save
+    def sort(self, *, key=None, reverse=False):
+        self._data.sort(key=key, reverse=reverse)
+
+    def copy(self):
+        return self._data.copy()
+
+
+class _SaverDict(_SaverMutable, MutableMapping):
+    """
+    A dict that stores changes to an Attribute when updated
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._data = kwargs.pop("_class", dict)()
+
+    def has_key(self, key):
+        return key in self._data
+
+    @_save
+    def update(self, *args, **kwargs):
+        self._data.update(*args, **kwargs)
+
+
+class _SaverDefaultDict(_SaverDict):
+    """
+    A defaultdict that stores changes to an attribute when updated
+    """
+
+    def __init__(self, factory, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._data = defaultdict(factory)
+        self.default_factory = factory
+
+    def __getitem__(self, key):
+        if key not in self._data.keys():
+            # detect the case of db.foo['a'] with no immediate assignment
+            # (important: using `key in self._data` would be always True!)
+            default_value = self._data[key]
+            self.__setitem__(key, default_value)
+        return self._data[key]
+
+
+class _SaverSet(_SaverMutable, MutableSet):
+    """
+    A set that saves to an Attribute when updated
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._data = set()
+
+    def __contains__(self, value):
+        return self._data.__contains__(value)
+
+    @_save
+    def add(self, value):
+        self._data.add(self._convert_mutables(value))
+
+    @_save
+    def discard(self, value):
+        self._data.discard(value)
+
+    @_save
+    def update(self, *others):
+        self._data.update(*others)
+
+    @_save
+    def remove(self, value):
+        self._data.remove(value)
+
+    @_save
+    def pop(self):
+        self._data.pop()
+
+    @_save
+    def clear(self):
+        self._data.clear()
+
+    @_save
+    def intersection_update(self, *others):
+        self._data.intersection_update(*others)
+
+    @_save
+    def difference_update(self, *others):
+        self._data.difference_update(*others)
+
+    @_save
+    def symmetric_difference_update(self, *others):
+        self._data.symmetric_difference(*others)
+
+    def isdisjoint(self, other):
+        return self._data.isdisjoint(other)
+
+    def issubset(self, other):
+        return self._data.issubset(other)
+
+    def issuperset(self, other):
+        return self._data.issuperset(other)
+
+    def union(self, *others):
+        return self._data.union(*others)
+
+    def intersection(self, *others):
+        return self._data.intersection(*others)
+
+    def difference(self, *others):
+        return self._data.difference(*others)
+
+    def symmetric_difference(self, other):
+        return self._data.symmetric_difference(other)
+
+    def copy(self):
+        return self._data.copy()
+
+
+class _SaverOrderedDict(_SaverMutable, MutableMapping):
+    """
+    An ordereddict that can be saved and operated on.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._data = OrderedDict()
+
+    def has_key(self, key):
+        return key in self._data
+
+
+class _SaverDeque(_SaverMutable):
+    """
+    A deque that can be saved and operated on.
+    """
+
+    def __init__(self, *args, maxlen=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._data = deque((), maxlen=maxlen)
+
+    @_save
+    def append(self, *args, **kwargs):
+        self._data.append(*args, **kwargs)
+
+    @_save
+    def appendleft(self, *args, **kwargs):
+        self._data.appendleft(*args, **kwargs)
+
+    @_save
+    def clear(self):
+        self._data.clear()
+
+    @_save
+    def extendleft(self, *args, **kwargs):
+        self._data.extendleft(*args, **kwargs)
+
+    # maxlen property
+    def _getmaxlen(self):
+        return self._data.maxlen
+
+    def _setmaxlen(self, value):
+        self._data.maxlen = value
+
+    def _delmaxlen(self):
+        del self._data.maxlen
+
+    maxlen = property(_getmaxlen, _setmaxlen, _delmaxlen)
+
+    @_save
+    def pop(self, *args, **kwargs):
+        return self._data.pop(*args, **kwargs)
+
+    @_save
+    def popleft(self, *args, **kwargs):
+        return self._data.popleft(*args, **kwargs)
+
+    @_save
+    def reverse(self):
+        self._data.reverse()
+
+    @_save
+    def rotate(self, *args):
+        self._data.rotate(*args)
+
+    @_save
+    def remove(self, *args):
+        self._data.remove(*args)
+
+
+_DESERIALIZE_MAPPING = {
+    _SaverList.__name__: list,
+    _SaverDict.__name__: dict,
+    _SaverSet.__name__: set,
+    _SaverOrderedDict.__name__: OrderedDict,
+    _SaverDeque.__name__: deque,
+    _SaverDefaultDict.__name__: defaultdict,
+}
+
+
+def deserialize(obj):
+    """
+    Make sure to *fully* decouple a structure from the database, by turning all _Saver*-mutables
+    inside it back into their normal Python forms.
+
+    """
+
+    def _iter(obj):
+        # breakpoint()
+        typ = type(obj)
+        tname = typ.__name__
+        if tname in ("_SaverDict", "dict"):
+            return {_iter(key): _iter(val) for key, val in obj.items()}
+        elif tname in ("_SaverOrderedDict", "OrderedDict"):
+            return OrderedDict([(_iter(key), _iter(val)) for key, val in obj.items()])
+        elif tname in ("_SaverDefaultDict", "defaultdict"):
+            return defaultdict(
+                obj.default_factory, {_iter(key): _iter(val) for key, val in obj.items()}
+            )
+        elif tname in ("_SaverDeque", deque):
+            return deque((_iter(val) for val in obj), maxlen=obj.maxlen)
+        elif tname in _DESERIALIZE_MAPPING:
+            return _DESERIALIZE_MAPPING[tname](_iter(val) for val in obj)
+        elif is_iter(obj):
+            return typ(_iter(val) for val in obj)
+        return obj
+
+    return _iter(obj)
+
+
+#
+# serialization helpers
+
+
+def pack_dbobj(item):
+    """
+    Check and convert django database objects to an internal representation.
+
+    Args:
+        item (any): A database entity to pack
+
+    Returns:
+        packed (any or tuple): Either returns the original input item
+            or the packing tuple `("__packed_dbobj__", key, creation_time, id)`.
+
+    """
+    _init_globals()
+    obj = item
+    natural_key = _FROM_MODEL_MAP[
+        hasattr(obj, "id")
+        and hasattr(obj, "db_date_created")
+        and hasattr(obj, "__dbclass__")
+        and obj.__dbclass__.__name__.lower()
+    ]
+    # build the internal representation as a tuple
+    #  ("__packed_dbobj__", key, creation_time, id)
+    return (
+        natural_key
+        and ("__packed_dbobj__", natural_key, _TO_DATESTRING(obj), _GA(obj, "id"))
+        or item
+    )
+
+
+def unpack_dbobj(item):
+    """
+    Check and convert internal representations back to Django database
+    models.
+
+    Args:
+        item (packed_dbobj): The fact that item is a packed dbobj
+            should be checked before this call.
+
+    Returns:
+        unpacked (any): Either the original input or converts the
+            internal store back to a database representation (its
+            typeclass is returned if applicable).
+
+    """
+    _init_globals()
+    try:
+        obj = item[3] and _TO_MODEL_MAP[item[1]].objects.get(id=item[3])
+    except ObjectDoesNotExist:
+        return None
+    except TypeError:
+        if hasattr(item, "pk"):
+            # this happens if item is already an obj
+            return item
+        return None
+    if item[1] in _IGNORE_DATETIME_MODELS:
+        # if we are replacing models we ignore the datatime
+        return obj
+    else:
+        # even if we got back a match, check the sanity of the date (some
+        # databases may 're-use' the id)
+        return _TO_DATESTRING(obj) == item[2] and obj or None
+
+
+def pack_session(item):
+    """
+    Handle the safe serializion of Sessions objects (these contain
+    hidden references to database objects (accounts, puppets) so they
+    can't be safely serialized).
+
+    Args:
+        item (Session)): This item must have all properties of a session
+            before entering this call.
+
+    Returns:
+        packed (tuple or None): A session-packed tuple on the form
+            `(__packed_session__, sessid, conn_time)`. If this sessid
+            does not match a session in the Session handler, None is returned.
+
+    """
+    _init_globals()
+    session = evennia.SESSION_HANDLER.get(item.sessid)
+    if session and session.conn_time == item.conn_time:
+        # we require connection times to be identical for the Session
+        # to be accepted as actually being a session (sessids gets
+        # reused all the time).
+        return (
+            item.conn_time
+            and item.sessid
+            and ("__packed_session__", _GA(item, "sessid"), _GA(item, "conn_time"))
+        )
+    return None
+
+
+def unpack_session(item):
+    """
+    Check and convert internal representations back to Sessions.
+
+    Args:
+        item (packed_session): The fact that item is a packed session
+            should be checked before this call.
+
+    Returns:
+        unpacked (any): Either the original input or converts the
+            internal store back to a Session. If Session no longer
+            exists, None will be returned.
+    """
+    _init_globals()
+    session = evennia.SESSION_HANDLER.get(item[1])
+    if session and session.conn_time == item[2]:
+        # we require connection times to be identical for the Session
+        # to be accepted as the same as the one stored (sessids gets
+        # reused all the time).
+        return session
+    return None
+
+
+#
+# Access methods
+
+
+
[docs]def to_pickle(data): + """ + This prepares data on arbitrary form to be pickled. It handles any + nested structure and returns data on a form that is safe to pickle + (including having converted any database models to their internal + representation). We also convert any Saver*-type objects back to + their normal representations, they are not pickle-safe. + + Args: + data (any): Data to pickle. + + Returns: + data (any): Pickled data. + + """ + + def process_item(item): + """Recursive processor and identification of data""" + + dtype = type(item) + + if dtype in (str, int, float, bool, bytes, SafeString): + return item + elif dtype == tuple: + return tuple(process_item(val) for val in item) + elif dtype in (list, _SaverList): + return [process_item(val) for val in item] + elif dtype in (dict, _SaverDict): + return dict((process_item(key), process_item(val)) for key, val in item.items()) + elif dtype in (defaultdict, _SaverDefaultDict): + return defaultdict( + item.default_factory, + ((process_item(key), process_item(val)) for key, val in item.items()), + ) + elif dtype in (deque, _SaverDeque): + return deque((process_item(val) for val in item), maxlen=item.maxlen) + elif dtype in (set, _SaverSet): + return set(process_item(val) for val in item) + elif dtype in (OrderedDict, _SaverOrderedDict): + return OrderedDict((process_item(key), process_item(val)) for key, val in item.items()) + elif dtype in (deque, _SaverDeque): + return deque(process_item(val) for val in item) + + # not one of the base types + if hasattr(item, "__serialize_dbobjs__"): + # Allows custom serialization of any dbobjects embedded in + # the item that Evennia will otherwise not find (these would + # otherwise lead to an error). Use the dbserialize helper from + # this method. + try: + item.__serialize_dbobjs__() + except TypeError as err: + # we catch typerrors so we can handle both classes (requiring + # classmethods) and instances + pass + + if hasattr(item, "__iter__"): + try: + # we try to conserve the iterable class, if not convert to dict + try: + return item.__class__( + (process_item(key), process_item(val)) for key, val in item.items() + ) + except (AttributeError, TypeError): + return {process_item(key): process_item(val) for key, val in item.items()} + except Exception: + # we try to conserve the iterable class, if not convert to list + try: + return item.__class__([process_item(val) for val in item]) + except (AttributeError, TypeError): + return [process_item(val) for val in item] + elif hasattr(item, "sessid") and hasattr(item, "conn_time"): + return pack_session(item) + try: + return pack_dbobj(item) + except TypeError: + return item + except Exception: + logger.log_err(f"The object {item} of type {type(item)} could not be stored.") + raise + + return process_item(data)
+ + +# @transaction.autocommit +
[docs]def from_pickle(data, db_obj=None): + """ + This should be fed a just de-pickled data object. It will be converted back + to a form that may contain database objects again. Note that if a database + object was removed (or changed in-place) in the database, None will be + returned. + + Args: + data (any): Pickled data to unpickle. + db_obj (Atribute, any): This is the model instance (normally + an Attribute) that _Saver*-type iterables (_SaverList etc) + will save to when they update. It must have a 'value' property + that saves assigned data to the database. Skip if not + serializing onto a given object. If db_obj is given, this + function will convert lists, dicts and sets to their + _SaverList, _SaverDict and _SaverSet counterparts. + + Returns: + data (any): Unpickled data. + + """ + + def process_item(item): + """Recursive processor and identification of data""" + # breakpoint() + dtype = type(item) + if dtype in (str, int, float, bool, bytes, SafeString): + return item + elif _IS_PACKED_DBOBJ(item): + # this must be checked before tuple + return unpack_dbobj(item) + elif _IS_PACKED_SESSION(item): + return unpack_session(item) + elif dtype == tuple: + return tuple(process_item(val) for val in item) + elif dtype == dict: + return dict((process_item(key), process_item(val)) for key, val in item.items()) + elif dtype == defaultdict: + return defaultdict( + item.default_factory, + ((process_item(key), process_item(val)) for key, val in item.items()), + ) + elif dtype == set: + return set(process_item(val) for val in item) + elif dtype == OrderedDict: + return OrderedDict((process_item(key), process_item(val)) for key, val in item.items()) + elif dtype == deque: + return deque((process_item(val) for val in item), maxlen=item.maxlen) + elif hasattr(item, "__iter__"): + try: + # we try to conserve the iterable class, if not convert to dict + try: + return item.__class__( + (process_item(key), process_item(val)) for key, val in item.items() + ) + except (AttributeError, TypeError): + return {process_item(key): process_item(val) for key, val in item.items()} + except Exception: + try: + # we try to conserve the iterable class if + # it accepts an iterator + return item.__class__(process_item(val) for val in item) + except (AttributeError, TypeError): + return [process_item(val) for val in item] + + if hasattr(item, "__deserialize_dbobjs__"): + # this allows the object to custom-deserialize any embedded dbobjs + # that we previously serialized with __serialize_dbobjs__. + # use the dbunserialize helper in this module. + try: + item.__deserialize_dbobjs__() + except (TypeError, UnpicklingError): + # handle recoveries both of classes (requiring classmethods + # or instances. Unpickling errors can happen when re-loading the + # data from cache (because the hidden entity was already + # deserialized and stored back on the object, unpickling it + # again fails). TODO: Maybe one could avoid this retry in a + # more graceful way? + pass + + return item + + def process_tree(item, parent): + """Recursive processor, building a parent-tree from iterable data""" + # breakpoint() + dtype = type(item) + if dtype in (str, int, float, bool, bytes, SafeString): + return item + elif _IS_PACKED_DBOBJ(item): + # this must be checked before tuple + return unpack_dbobj(item) + elif dtype == tuple: + return tuple(process_tree(val, item) for val in item) + elif dtype == list: + dat = _SaverList(_parent=parent) + dat._data.extend(process_tree(val, dat) for val in item) + return dat + elif dtype == dict: + dat = _SaverDict(_parent=parent) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in item.items() + ) + return dat + elif dtype == defaultdict: + dat = _SaverDefaultDict(item.default_factory, _parent=parent) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in item.items() + ) + return dat + elif dtype == set: + dat = _SaverSet(_parent=parent) + dat._data.update(set(process_tree(val, dat) for val in item)) + return dat + elif dtype == OrderedDict: + dat = _SaverOrderedDict(_parent=parent) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in item.items() + ) + return dat + elif dtype == deque: + dat = _SaverDeque(_parent=parent, maxlen=item.maxlen) + dat._data.extend(process_item(val) for val in item) + return dat + elif hasattr(item, "__iter__"): + try: + # we try to conserve the iterable class, if not convert to dict + try: + dat = _SaverDict(_parent=parent, _class=item.__class__) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in item.items() + ) + return dat + except (AttributeError, TypeError): + dat = _SaverDict(_parent=parent) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in item.items() + ) + return dat + except Exception: + try: + # we try to conserve the iterable class if it + # accepts an iterator + dat = _SaverList(_parent=parent, _class=item.__class__) + dat._data.extend(process_tree(val, dat) for val in item) + return dat + except (AttributeError, TypeError): + dat = _SaverList(_parent=parent) + dat._data.extend(process_tree(val, dat) for val in item) + return dat + + if hasattr(item, "__deserialize_dbobjs__"): + try: + item.__deserialize_dbobjs__() + except (TypeError, UnpicklingError): + pass + + return item + + if db_obj: + # convert lists, dicts and sets to their Saved* counterparts. It + # is only relevant if the "root" is an iterable of the right type. + dtype = type(data) + if dtype in (str, int, float, bool, bytes, SafeString, tuple): + return process_item(data) + elif dtype == list: + dat = _SaverList(_db_obj=db_obj) + dat._data.extend(process_tree(val, dat) for val in data) + return dat + elif dtype == dict: + dat = _SaverDict(_db_obj=db_obj) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in data.items() + ) + return dat + elif dtype == defaultdict: + dat = _SaverDefaultDict(data.default_factory, _db_obj=db_obj) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in data.items() + ) + return dat + elif dtype == set: + dat = _SaverSet(_db_obj=db_obj) + dat._data.update(process_tree(val, dat) for val in data) + return dat + elif dtype == OrderedDict: + dat = _SaverOrderedDict(_db_obj=db_obj) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in data.items() + ) + return dat + elif dtype == deque: + dat = _SaverDeque(_db_obj=db_obj, maxlen=data.maxlen) + dat._data.extend(process_item(val) for val in data) + return dat + elif hasattr(data, "__iter__"): + try: + # we try to conserve the iterable class, if not convert to dict + try: + dat = _SaverDict(_db_obj=db_obj, _class=data.__class__) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in data.items() + ) + return dat + except (AttributeError, TypeError): + dat = _SaverDict(_db_obj=db_obj) + dat._data.update( + (process_item(key), process_tree(val, dat)) for key, val in data.items() + ) + return dat + except Exception: + try: + # we try to conserve the iterable class if it + # accepts an iterator + dat = _SaverList(_db_obj=db_obj, _class=data.__class__) + dat._data.extend(process_tree(val, dat) for val in data) + return dat + + except (AttributeError, TypeError): + dat = _SaverList(_db_obj=db_obj) + dat._data.extend(process_tree(val, dat) for val in data) + return dat + + return process_item(data)
+ + +
[docs]def do_pickle(data): + """Perform pickle to string""" + try: + return dumps(data, protocol=PICKLE_PROTOCOL) + except Exception: + logger.log_err(f"Could not pickle data for storage: {data}") + raise
+ + +
[docs]def do_unpickle(data): + """Retrieve pickle from pickled string""" + try: + return loads(to_bytes(data)) + except Exception: + logger.log_err(f"Could not unpickle data from storage: {data}") + raise
+ + +
[docs]def dbserialize(data): + """Serialize to pickled form in one step""" + return do_pickle(to_pickle(data))
+ + +
[docs]def dbunserialize(data, db_obj=None): + """Un-serialize in one step. See from_pickle for help db_obj.""" + return from_pickle(do_unpickle(data), db_obj=db_obj)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/eveditor.html b/docs/latest/_modules/evennia/utils/eveditor.html new file mode 100644 index 0000000000..9b8502bf75 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/eveditor.html @@ -0,0 +1,1290 @@ + + + + + + + + evennia.utils.eveditor — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.eveditor

+"""
+EvEditor (Evennia Line Editor)
+
+This implements an advanced line editor for editing longer texts in-game. The
+editor mimics the command mechanisms of the "VI" editor (a famous line-by-line
+editor) as far as reasonable.
+
+Features of the editor:
+
+- undo/redo.
+- edit/replace on any line of the buffer.
+- search&replace text anywhere in buffer.
+- formatting of buffer, or selection, to certain width + indentations.
+- allow to echo the input or not, depending on your client.
+- in-built help
+
+To use the editor, just import EvEditor from this module and initialize it:
+
+```python
+from evennia.utils.eveditor import EvEditor
+
+# set up an editor to edit the caller's 'desc' Attribute
+def _loadfunc(caller):
+    return caller.db.desc
+
+def _savefunc(caller, buffer):
+    caller.db.desc = buffer.strip()
+    return True
+
+def _quitfunc(caller):
+    caller.msg("Custom quit message")
+
+# start the editor
+EvEditor(caller, loadfunc=None, savefunc=None, quitfunc=None, key="",
+         persistent=True, code=False)
+```
+
+The editor can also be used to format Python code and be made to
+survive a reload. See the `EvEditor` class for more details.
+
+"""
+import re
+
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+from evennia import CmdSet
+from evennia.commands import cmdhandler
+from evennia.utils import dedent, fill, is_iter, justify, logger, to_str, utils
+from evennia.utils.ansi import raw
+
+# we use cmdhandler instead of evennia.syscmdkeys to
+# avoid some cases of loading before evennia init'd
+_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
+
+_RE_GROUP = re.compile(r"\".*?\"|\'.*?\'|\S*")
+_COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
+# use NAWS in the future?
+_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+
+# -------------------------------------------------------------
+#
+# texts
+#
+# -------------------------------------------------------------
+
+_HELP_TEXT = _(
+    """
+ <txt>  - any non-command is appended to the end of the buffer.
+ :  <l> - view buffer or only line(s) <l>
+ :: <l> - raw-view buffer or only line(s) <l>
+ :::    - escape - enter ':' as the only character on the line.
+ :h     - this help.
+
+ :w     - save the buffer (don't quit)
+ :wq    - save buffer and quit
+ :q     - quit (will be asked to save if buffer was changed)
+ :q!    - quit without saving, no questions asked
+
+ :u     - (undo) step backwards in undo history
+ :uu    - (redo) step forward in undo history
+ :UU    - reset all changes back to initial state
+
+ :dd <l>     - delete last line or line(s) <l>
+ :dw <l> <w> - delete word or regex <w> in entire buffer or on line <l>
+ :DD         - clear entire buffer
+
+ :y  <l>        - yank (copy) line(s) <l> to the copy buffer
+ :x  <l>        - cut line(s) <l> and store it in the copy buffer
+ :p  <l>        - put (paste) previously copied line(s) directly after <l>
+ :i  <l> <txt>  - insert new text <txt> at line <l>. Old line will move down
+ :r  <l> <txt>  - replace line <l> with text <txt>
+ :I  <l> <txt>  - insert text at the beginning of line <l>
+ :A  <l> <txt>  - append text after the end of line <l>
+
+ :s <l> <w> <txt> - search/replace word or regex <w> in buffer or on line <l>
+
+ :j <l> <w> - justify buffer or line <l>. <w> is f, c, l or r. Default f (full)
+ :f <l>     - flood-fill entire buffer or line <l>: Equivalent to :j left
+ :fi <l>    - indent entire buffer or line <l>
+ :fd <l>    - de-indent entire buffer or line <l>
+
+ :echo - turn echoing of the input on/off (helpful for some clients)
+"""
+)
+
+_HELP_LEGEND = _(
+    """
+    Legend:
+    <l>   - line number, like '5' or range, like '3:7'.
+    <w>   - a single word, or multiple words with quotes around them.
+    <txt> - longer string, usually not needing quotes.
+"""
+)
+
+_HELP_CODE = _(
+    """
+ :!    - Execute code buffer without saving
+ :<    - Decrease the level of automatic indentation for the next lines
+ :>    - Increase the level of automatic indentation for the next lines
+ :=    - Switch automatic indentation on/off
+""".lstrip(
+        "\n"
+    )
+)
+
+_ERROR_LOADFUNC = _(
+    """
+{error}
+
+|rBuffer load function error. Could not load initial data.|n
+"""
+)
+
+_ERROR_SAVEFUNC = _(
+    """
+{error}
+
+|rSave function returned an error. Buffer not saved.|n
+"""
+)
+
+_ERROR_NO_SAVEFUNC = _("|rNo save function defined. Buffer cannot be saved.|n")
+
+_MSG_SAVE_NO_CHANGE = _("No changes need saving")
+_DEFAULT_NO_QUITFUNC = _("Exited editor.")
+
+_ERROR_QUITFUNC = _(
+    """
+{error}
+
+|rQuit function gave an error. Skipping.|n
+"""
+)
+
+_ERROR_PERSISTENT_SAVING = _(
+    """
+{error}
+
+|rThe editor state could not be saved for persistent mode. Switching
+to non-persistent mode (which means the editor session won't survive
+an eventual server reload - so save often!)|n
+"""
+)
+
+_TRACE_PERSISTENT_SAVING = _(
+    "EvEditor persistent-mode error. Commonly, this is because one or "
+    "more of the EvEditor callbacks could not be pickled, for example "
+    "because it's a class method or is defined inside another function."
+)
+
+
+_MSG_NO_UNDO = _("Nothing to undo.")
+_MSG_NO_REDO = _("Nothing to redo.")
+_MSG_UNDO = _("Undid one step.")
+_MSG_REDO = _("Redid one step.")
+
+# -------------------------------------------------------------
+#
+# Handle yes/no quit question
+#
+# -------------------------------------------------------------
+
+
+
[docs]class CmdSaveYesNo(_COMMAND_DEFAULT_CLASS): + """ + Save the editor state on quit. This catches + nomatches (defaults to Yes), and avoid saves only if + command was given specifically as "no" or "n". + """ + + key = _CMD_NOMATCH + aliases = _CMD_NOINPUT + locks = "cmd:all()" + help_cateogory = "LineEditor" + +
[docs] def func(self): + """ + Implement the yes/no choice. + + """ + # this is only called from inside the lineeditor + # so caller.ndb._lineditor must be set. + + self.caller.cmdset.remove(SaveYesNoCmdSet) + if self.raw_string.strip().lower() in ("no", "n"): + # answered no + self.caller.msg(self.caller.ndb._eveditor.quit()) + else: + # answered yes (default) + self.caller.ndb._eveditor.save_buffer() + self.caller.ndb._eveditor.quit()
+ + +
[docs]class SaveYesNoCmdSet(CmdSet): + """ + Stores the yesno question + + """ + + key = "quitsave_yesno" + priority = 150 # override other cmdsets. + mergetype = "Replace" + +
[docs] def at_cmdset_creation(self): + """at cmdset creation""" + self.add(CmdSaveYesNo())
+ + +# ------------------------------------------------------------- +# +# Editor commands +# +# ------------------------------------------------------------- + + +
[docs]class CmdEditorBase(_COMMAND_DEFAULT_CLASS): + """ + Base parent for editor commands + """ + + locks = "cmd:all()" + help_entry = "LineEditor" + + editor = None + +
[docs] def parse(self): + """ + Handles pre-parsing. Editor commands are on the form + + :: + + :cmd [li] [w] [txt] + + Where all arguments are optional. + + - `li` - line number (int), starting from 1. This could also + be a range given as <l>:<l>. + - `w` - word(s) (string), could be encased in quotes. + - `txt` - extra text (string), could be encased in quotes. + + """ + + editor = self.caller.ndb._eveditor + if not editor: + # this will completely replace the editor + _load_editor(self.caller) + editor = self.caller.ndb._eveditor + self.editor = editor + + linebuffer = self.editor.get_buffer().split("\n") + + nlines = len(linebuffer) + + # The regular expression will split the line by whitespaces, + # stripping extra whitespaces, except if the text is + # surrounded by single- or double quotes, in which case they + # will be kept together and extra whitespace preserved. You + # can input quotes on the line by alternating single and + # double quotes. + arglist = [part for part in _RE_GROUP.findall(self.args) if part] + temp = [] + for arg in arglist: + # we want to clean the quotes, but only one type, + # in case we are nesting. + if arg.startswith('"'): + arg.strip('"') + elif arg.startswith("'"): + arg.strip("'") + temp.append(arg) + arglist = temp + + # A dumb split, without grouping quotes + words = self.args.split() + + # current line number + cline = nlines - 1 + + # the first argument could also be a range of line numbers, on the + # form <lstart>:<lend>. Either of the ends could be missing, to + # mean start/end of buffer respectively. + + lstart, lend = cline, cline + 1 + linerange = False + if arglist and arglist[0].count(":") == 1: + part1, part2 = arglist[0].split(":") + if part1 and part1.isdigit(): + lstart = min(max(0, int(part1)) - 1, nlines) + linerange = True + if part2 and part2.isdigit(): + lend = min(lstart + 1, int(part2)) + 1 + linerange = True + elif arglist and arglist[0].isdigit(): + lstart = min(max(0, int(arglist[0]) - 1), nlines) + lend = lstart + 1 + linerange = True + if linerange: + arglist = arglist[1:] + + # nicer output formatting of the line range. + lstr = ( + "line %i" % (lstart + 1) + if not linerange or lstart + 1 == lend + else "lines %i-%i" % (lstart + 1, lend) + ) + + # arg1 and arg2 is whatever arguments. Line numbers or -ranges are + # never included here. + args = " ".join(arglist) + arg1, arg2 = "", "" + if len(arglist) > 1: + arg1, arg2 = arglist[0], " ".join(arglist[1:]) + else: + arg1 = " ".join(arglist) + + # store for use in func() + + self.linebuffer = linebuffer + self.nlines = nlines + self.arglist = arglist + self.cline = cline + self.lstart = lstart + self.lend = lend + self.linerange = linerange + self.lstr = lstr + self.words = words + self.args = args + self.arg1 = arg1 + self.arg2 = arg2
+ + +def _load_editor(caller): + """ + Load persistent editor from storage. + + """ + saved_options = caller.attributes.get("_eveditor_saved") + saved_buffer, saved_undo = caller.attributes.get("_eveditor_buffer_temp", (None, None)) + unsaved = caller.attributes.get("_eveditor_unsaved", False) + indent = caller.attributes.get("_eveditor_indent", 0) + if saved_options: + eveditor = EvEditor(caller, **saved_options[0]) + if saved_buffer: + # we have to re-save the buffer data so we can handle subsequent restarts + caller.attributes.add("_eveditor_buffer_temp", (saved_buffer, saved_undo)) + setattr(eveditor, "_buffer", saved_buffer) + setattr(eveditor, "_undo_buffer", saved_undo) + setattr(eveditor, "_undo_pos", len(saved_undo) - 1) + setattr(eveditor, "_unsaved", unsaved) + setattr(eveditor, "_indent", indent) + for key, value in saved_options[1].items(): + setattr(eveditor, key, value) + else: + # something went wrong. Cleanup. + caller.cmdset.remove(EvEditorCmdSet) + + +
[docs]class CmdLineInput(CmdEditorBase): + """ + No command match - Inputs line of text into buffer. + + """ + + key = _CMD_NOMATCH + aliases = _CMD_NOINPUT + +
[docs] def func(self): + """ + Adds the line without any formatting changes. + + If the editor handles code, it might add automatic + indentation. + """ + caller = self.caller + editor = caller.ndb._eveditor + buf = editor.get_buffer() + + # add a line of text to buffer + line = self.raw_string.strip("\r\n") + if editor._codefunc and editor._indent >= 0: + # if automatic indentation is active, add spaces + line = editor.deduce_indent(line, buf) + buf = line if not buf else buf + "\n%s" % line + self.editor.update_buffer(buf) + if self.editor._echo_mode: + # need to do it here or we will be off one line + cline = len(self.editor.get_buffer().split("\n")) + if editor._codefunc: + # display the current level of identation + indent = editor._indent + if indent < 0: + indent = "off" + + self.caller.msg("|b%02i|||n (|g%s|n) %s" % (cline, indent, raw(line))) + else: + self.caller.msg("|b%02i|||n %s" % (cline, raw(self.args)))
+ + +
[docs]class CmdEditorGroup(CmdEditorBase): + """ + Commands for the editor + """ + + key = ":editor_command_group" + aliases = [ + ":", + "::", + ":::", + ":h", + ":w", + ":wq", + ":q", + ":q!", + ":u", + ":uu", + ":UU", + ":dd", + ":dw", + ":DD", + ":y", + ":x", + ":p", + ":i", + ":j", + ":r", + ":I", + ":A", + ":s", + ":S", + ":f", + ":fi", + ":fd", + ":echo", + ":!", + ":<", + ":>", + ":=", + ] + arg_regex = r"\s.*?|$" + +
[docs] def func(self): + """ + This command handles all the in-editor :-style commands. Since + each command is small and very limited, this makes for a more + efficient presentation. + + """ + caller = self.caller + editor = caller.ndb._eveditor + + linebuffer = self.linebuffer + lstart, lend = self.lstart, self.lend + cmd = self.cmdstring + echo_mode = self.editor._echo_mode + + if cmd == ":": + # Echo buffer + if self.linerange: + buf = linebuffer[lstart:lend] + editor.display_buffer(buf=buf, offset=lstart) + else: + editor.display_buffer() + elif cmd == "::": + # Echo buffer without the line numbers and syntax parsing + if self.linerange: + buf = linebuffer[lstart:lend] + editor.display_buffer(buf=buf, offset=lstart, linenums=False, options={"raw": True}) + else: + editor.display_buffer(linenums=False, options={"raw": True}) + elif cmd == ":::": + # Insert single colon alone on a line + editor.update_buffer([":"] if lstart == 0 else linebuffer + [":"]) + if echo_mode: + caller.msg(_("Single ':' added to buffer.")) + elif cmd == ":h": + # help entry + editor.display_help() + elif cmd == ":w": + # save without quitting + editor.save_buffer() + elif cmd == ":wq": + # save and quit + editor.save_buffer() + editor.quit() + elif cmd == ":q": + # quit. If not saved, will ask + if self.editor._unsaved: + caller.cmdset.add(SaveYesNoCmdSet) + caller.msg(_("Save before quitting?") + " |lcyes|lt[Y]|le/|lcno|ltN|le") + else: + editor.quit() + elif cmd == ":q!": + # force quit, not checking saving + editor.quit() + elif cmd == ":u": + # undo + editor.update_undo(-1) + elif cmd == ":uu": + # redo + editor.update_undo(1) + elif cmd == ":UU": + # reset buffer + editor.update_buffer(editor._pristine_buffer) + caller.msg(_("Reverted all changes to the buffer back to original state.")) + elif cmd == ":dd": + # :dd <l> - delete line <l> + buf = linebuffer[:lstart] + linebuffer[lend:] + editor.update_buffer(buf) + caller.msg(_("Deleted {string}.").format(string=self.lstr)) + elif cmd == ":dw": + # :dw <w> - delete word in entire buffer + # :dw <l> <w> delete word only on line(s) <l> + if not self.arg1: + caller.msg(_("You must give a search word to delete.")) + else: + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + caller.msg( + _("Removed {arg1} for lines {l1}-{l2}.").format( + arg1=self.arg1, l1=lstart + 1, l2=lend + 1 + ) + ) + else: + caller.msg( + _("Removed {arg1} for {line}.").format(arg1=self.arg1, line=self.lstr) + ) + sarea = "\n".join(linebuffer[lstart:lend]) + sarea = re.sub(r"%s" % self.arg1.strip("'").strip('"'), "", sarea, re.MULTILINE) + buf = linebuffer[:lstart] + sarea.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":DD": + # clear buffer + editor.update_buffer("") + + # Reset indentation level to 0 + if editor._codefunc: + if editor._indent >= 0: + editor._indent = 0 + if editor._persistent: + caller.attributes.add("_eveditor_indent", 0) + caller.msg(_("Cleared {nlines} lines from buffer.").format(nlines=self.nlines)) + elif cmd == ":y": + # :y <l> - yank line(s) to copy buffer + cbuf = linebuffer[lstart:lend] + editor._copy_buffer = cbuf + caller.msg(_("{line}, {cbuf} yanked.").format(line=self.lstr.capitalize(), cbuf=cbuf)) + elif cmd == ":x": + # :x <l> - cut line to copy buffer + cbuf = linebuffer[lstart:lend] + editor._copy_buffer = cbuf + buf = linebuffer[:lstart] + linebuffer[lend:] + editor.update_buffer(buf) + caller.msg(_("{line}, {cbuf} cut.").format(line=self.lstr.capitalize(), cbuf=cbuf)) + elif cmd == ":p": + # :p <l> paste line(s) from copy buffer + if not editor._copy_buffer: + caller.msg(_("Copy buffer is empty.")) + else: + buf = linebuffer[:lstart] + editor._copy_buffer + linebuffer[lstart:] + editor.update_buffer(buf) + caller.msg( + _("Pasted buffer {cbuf} to {line}.").format( + cbuf=editor._copy_buffer, line=self.lstr + ) + ) + elif cmd == ":i": + # :i <l> <txt> - insert new line + new_lines = self.args.split("\n") + if not new_lines: + caller.msg(_("You need to enter a new line and where to insert it.")) + else: + buf = linebuffer[:lstart] + new_lines + linebuffer[lstart:] + editor.update_buffer(buf) + caller.msg( + _("Inserted {num} new line(s) at {line}.").format( + num=len(new_lines), line=self.lstr + ) + ) + elif cmd == ":r": + # :r <l> <txt> - replace lines + new_lines = self.args.split("\n") + if not new_lines: + caller.msg(_("You need to enter a replacement string.")) + else: + buf = linebuffer[:lstart] + new_lines + linebuffer[lend:] + editor.update_buffer(buf) + caller.msg( + _("Replaced {num} line(s) at {line}.").format( + num=len(new_lines), line=self.lstr + ) + ) + elif cmd == ":I": + # :I <l> <txt> - insert text at beginning of line(s) <l> + if not self.raw_string and not editor._codefunc: + caller.msg(_("You need to enter text to insert.")) + else: + buf = ( + linebuffer[:lstart] + + ["%s%s" % (self.args, line) for line in linebuffer[lstart:lend]] + + linebuffer[lend:] + ) + editor.update_buffer(buf) + caller.msg(_("Inserted text at beginning of {line}.").format(line=self.lstr)) + elif cmd == ":A": + # :A <l> <txt> - append text after end of line(s) + if not self.args: + caller.msg(_("You need to enter text to append.")) + else: + buf = ( + linebuffer[:lstart] + + ["%s%s" % (line, self.args) for line in linebuffer[lstart:lend]] + + linebuffer[lend:] + ) + editor.update_buffer(buf) + caller.msg(_("Appended text to end of {line}.").format(line=self.lstr)) + elif cmd == ":s": + # :s <li> <w> <txt> - search and replace words + # in entire buffer or on certain lines + if not self.arg1 or not self.arg2: + caller.msg(_("You must give a search word and something to replace it with.")) + else: + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + caller.msg( + _("Search-replaced {arg1} -> {arg2} for lines {l1}-{l2}.").format( + arg1=self.arg1, arg2=self.arg2, l1=lstart + 1, l2=lend + ) + ) + else: + caller.msg( + _("Search-replaced {arg1} -> {arg2} for {line}.").format( + arg1=self.arg1, arg2=self.arg2, line=self.lstr + ) + ) + sarea = "\n".join(linebuffer[lstart:lend]) + + regex = r"%s|^%s(?=\s)|(?<=\s)%s(?=\s)|^%s$|(?<=\s)%s$" + regarg = self.arg1.strip("'").strip('"') + if " " in regarg: + regarg = regarg.replace(" ", " +") + sarea = re.sub( + regex % (regarg, regarg, regarg, regarg, regarg), + self.arg2.strip("'").strip('"'), + sarea, + re.MULTILINE, + ) + buf = linebuffer[:lstart] + sarea.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":f": + # :f <l> flood-fill buffer or <l> lines of buffer. + width = _DEFAULT_WIDTH + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + caller.msg(_("Flood filled lines {l1}-{l2}.").format(l1=lstart + 1, l2=lend)) + else: + caller.msg(_("Flood filled {line}.").format(line=self.lstr)) + fbuf = "\n".join(linebuffer[lstart:lend]) + fbuf = fill(fbuf, width=width) + buf = linebuffer[:lstart] + fbuf.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":j": + # :f <l> <w> justify buffer of <l> with <w> as align (one of + # f(ull), c(enter), r(ight) or l(left). Default is full. + align_map = { + "full": "f", + "f": "f", + "center": "c", + "c": "c", + "right": "r", + "r": "r", + "left": "l", + "l": "l", + } + align_name = {"f": "Full", "c": "Center", "l": "Left", "r": "Right"} + width = _DEFAULT_WIDTH + if self.arg1 and self.arg1.lower() not in align_map: + self.caller.msg( + _("Valid justifications are") + + " [f]ull (default), [c]enter, [r]right or [l]eft" + ) + return + align = align_map[self.arg1.lower()] if self.arg1 else "f" + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + self.caller.msg( + _("{align}-justified lines {l1}-{l2}.").format( + align=align_name[align], l1=lstart + 1, l2=lend + ) + ) + else: + self.caller.msg( + _("{align}-justified {line}.").format(align=align_name[align], line=self.lstr) + ) + jbuf = "\n".join(linebuffer[lstart:lend]) + jbuf = justify(jbuf, width=width, align=align) + buf = linebuffer[:lstart] + jbuf.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":fi": + # :fi <l> indent buffer or lines <l> of buffer. + indent = " " * 4 + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + caller.msg(_("Indented lines {l1}-{l2}.").format(l1=lstart + 1, l2=lend)) + else: + caller.msg(_("Indented {line}.").format(line=self.lstr)) + fbuf = [indent + line for line in linebuffer[lstart:lend]] + buf = linebuffer[:lstart] + fbuf + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":fd": + # :fi <l> indent buffer or lines <l> of buffer. + if not self.linerange: + lstart = 0 + lend = self.cline + 1 + caller.msg( + _("Removed left margin (dedented) lines {l1}-{l2}.").format( + l1=lstart + 1, l2=lend + ) + ) + else: + caller.msg(_("Removed left margin (dedented) {line}.").format(line=self.lstr)) + fbuf = "\n".join(linebuffer[lstart:lend]) + fbuf = dedent(fbuf) + buf = linebuffer[:lstart] + fbuf.split("\n") + linebuffer[lend:] + editor.update_buffer(buf) + elif cmd == ":echo": + # set echoing on/off + editor._echo_mode = not editor._echo_mode + caller.msg(_("Echo mode set to {mode}").format(mode=editor._echo_mode)) + elif cmd == ":!": + if editor._codefunc: + editor._codefunc(caller, editor._buffer) + else: + caller.msg(_("This command is only available in code editor mode.")) + elif cmd == ":<": + # :< + if editor._codefunc: + editor.decrease_indent() + indent = editor._indent + if indent >= 0: + caller.msg( + _("Decreased indentation: new indentation is {indent}.").format( + indent=indent + ) + ) + else: + caller.msg(_("|rManual indentation is OFF.|n Use := to turn it on.")) + else: + caller.msg(_("This command is only available in code editor mode.")) + elif cmd == ":>": + # :> + if editor._codefunc: + editor.increase_indent() + indent = editor._indent + if indent >= 0: + caller.msg( + _("Increased indentation: new indentation is {indent}.").format( + indent=indent + ) + ) + else: + caller.msg(_("|rManual indentation is OFF.|n Use := to turn it on.")) + else: + caller.msg(_("This command is only available in code editor mode.")) + elif cmd == ":=": + # := + if editor._codefunc: + editor.swap_autoindent() + indent = editor._indent + if indent >= 0: + caller.msg(_("Auto-indentation turned on.")) + else: + caller.msg(_("Auto-indentation turned off.")) + else: + caller.msg(_("This command is only available in code editor mode."))
+ + +
[docs]class EvEditorCmdSet(CmdSet): + """CmdSet for the editor commands""" + + key = "editorcmdset" + mergetype = "Replace" + +
[docs] def at_cmdset_creation(self): + self.add(CmdLineInput()) + self.add(CmdEditorGroup())
+ + +# ------------------------------------------------------------- +# +# Main Editor object +# +# ------------------------------------------------------------- + + +
[docs]class EvEditor: + """ + This defines a line editor object. It creates all relevant commands + and tracks the current state of the buffer. It also cleans up after + itself. + + """ + +
[docs] def __init__( + self, + caller, + loadfunc=None, + savefunc=None, + quitfunc=None, + key="", + persistent=False, + codefunc=False, + ): + """ + Launches a full in-game line editor, mimicking the functionality of VIM. + + Args: + caller (Object): Who is using the editor. + loadfunc (callable, optional): This will be called as + `loadfunc(caller)` when the editor is first started. Its + return will be used as the editor's starting buffer. + savefunc (callable, optional): This will be called as + `savefunc(caller, buffer)` when the save-command is given and + is used to actually determine where/how result is saved. + It should return `True` if save was successful and also + handle any feedback to the user. + quitfunc (callable, optional): This will optionally be + called as `quitfunc(caller)` when the editor is + exited. If defined, it should handle all wanted feedback + to the user. + quitfunc_args (tuple, optional): Optional tuple of arguments to + supply to `quitfunc`. + key (str, optional): An optional key for naming this + session and make it unique from other editing sessions. + persistent (bool, optional): Make the editor survive a reboot. Note + that if this is set, all callables must be possible to pickle + codefunc (bool, optional): If given, will run the editor in code mode. + This will be called as `codefunc(caller, buf)`. + + Notes: + In persistent mode, all the input callables (savefunc etc) + must be possible to be *pickled*, this excludes e.g. + callables that are class methods or functions defined + dynamically or as part of another function. In + non-persistent mode no such restrictions exist. + + + + """ + self._key = key + self._caller = caller + self._caller.ndb._eveditor = self + self._buffer = "" + self._unsaved = False + self._persistent = persistent + self._indent = 0 + + if loadfunc: + self._loadfunc = loadfunc + else: + self._loadfunc = lambda caller: self._buffer + self.load_buffer() + if savefunc: + self._savefunc = savefunc + else: + self._savefunc = lambda caller, buffer: caller.msg(_ERROR_NO_SAVEFUNC) + if quitfunc: + self._quitfunc = quitfunc + else: + self._quitfunc = lambda caller: caller.msg(_DEFAULT_NO_QUITFUNC) + self._codefunc = codefunc + + # store the original version + self._pristine_buffer = self._buffer + self._sep = "-" + + # undo operation buffer + self._undo_buffer = [self._buffer] + self._undo_pos = 0 + self._undo_max = 20 + + # copy buffer + self._copy_buffer = [] + + if persistent: + # save in tuple {kwargs, other options} + try: + caller.attributes.add( + "_eveditor_saved", + ( + dict( + loadfunc=loadfunc, + savefunc=savefunc, + quitfunc=quitfunc, + codefunc=codefunc, + key=key, + persistent=persistent, + ), + dict(_pristine_buffer=self._pristine_buffer, _sep=self._sep), + ), + ) + caller.attributes.add("_eveditor_buffer_temp", (self._buffer, self._undo_buffer)) + caller.attributes.add("_eveditor_unsaved", False) + caller.attributes.add("_eveditor_indent", 0) + except Exception as err: + caller.msg(_ERROR_PERSISTENT_SAVING.format(error=err)) + logger.log_trace(_TRACE_PERSISTENT_SAVING) + persistent = False + + # Create the commands we need + caller.cmdset.add(EvEditorCmdSet, persistent=persistent) + + # echo inserted text back to caller + self._echo_mode = True + + # show the buffer ui + self.display_buffer()
+ +
[docs] def load_buffer(self): + """ + Load the buffer using the load function hook. + + """ + try: + self._buffer = self._loadfunc(self._caller) + if not isinstance(self._buffer, str): + self._caller.msg( + f"|rBuffer is of type |w{type(self._buffer)})|r. " + "Continuing, it is converted to a string " + "(and will be saved as such)!|n" + ) + self._buffer = to_str(self._buffer) + except Exception as e: + from evennia.utils import logger + + logger.log_trace() + self._caller.msg(_ERROR_LOADFUNC.format(error=e))
+ +
[docs] def get_buffer(self): + """ + Return: + buffer (str): The current buffer. + + """ + return self._buffer
+ +
[docs] def update_buffer(self, buf): + """ + This should be called when the buffer has been changed + somehow. It will handle unsaved flag and undo updating. + + Args: + buf (str): The text to update the buffer with. + + """ + if is_iter(buf): + buf = "\n".join(buf) + + if buf != self._buffer: + self._buffer = buf + self.update_undo() + self._unsaved = True + if self._persistent: + self._caller.attributes.add( + "_eveditor_buffer_temp", (self._buffer, self._undo_buffer) + ) + self._caller.attributes.add("_eveditor_unsaved", True) + self._caller.attributes.add("_eveditor_indent", self._indent)
+ +
[docs] def quit(self): + """ + Cleanly exit the editor. + + """ + try: + self._quitfunc(self._caller) + except Exception as e: + self._caller.msg(_ERROR_QUITFUNC.format(error=e)) + self._caller.nattributes.remove("_eveditor") + self._caller.attributes.remove("_eveditor_buffer_temp") + self._caller.attributes.remove("_eveditor_saved") + self._caller.attributes.remove("_eveditor_unsaved") + self._caller.attributes.remove("_eveditor_indent") + self._caller.cmdset.remove(EvEditorCmdSet)
+ +
[docs] def save_buffer(self): + """ + Saves the content of the buffer. + + """ + if self._unsaved or self._codefunc: + # always save code - this allows us to tie execution to + # saving if we want. + try: + if self._savefunc(self._caller, self._buffer): + # Save codes should return a true value to indicate + # save worked. The saving function is responsible for + # any status messages. + self._unsaved = False + except Exception as e: + self._caller.msg(_ERROR_SAVEFUNC.format(error=e)) + else: + self._caller.msg(_MSG_SAVE_NO_CHANGE)
+ +
[docs] def update_undo(self, step=None): + """ + This updates the undo position. + + Args: + step (int, optional): The amount of steps + to progress the undo position to. This + may be a negative value for undo and + a positive value for redo. + + """ + if step and step < 0: + # undo + if self._undo_pos <= 0: + self._caller.msg(_MSG_NO_UNDO) + else: + self._undo_pos = max(0, self._undo_pos + step) + self._buffer = self._undo_buffer[self._undo_pos] + self._caller.msg(_MSG_UNDO) + elif step and step > 0: + # redo + if self._undo_pos >= len(self._undo_buffer) - 1 or self._undo_pos + 1 >= self._undo_max: + self._caller.msg(_MSG_NO_REDO) + else: + self._undo_pos = min( + self._undo_pos + step, min(len(self._undo_buffer), self._undo_max) - 1 + ) + self._buffer = self._undo_buffer[self._undo_pos] + self._caller.msg(_MSG_REDO) + if not self._undo_buffer or ( + self._undo_buffer and self._buffer != self._undo_buffer[self._undo_pos] + ): + # save undo state + self._undo_buffer = self._undo_buffer[: self._undo_pos + 1] + [self._buffer] + self._undo_pos = len(self._undo_buffer) - 1
+ +
[docs] def display_buffer(self, buf=None, offset=0, linenums=True, options={"raw": False}): + """ + This displays the line editor buffer, or selected parts of it. + + Args: + buf (str, optional): The buffer or part of buffer to display. + offset (int, optional): If `buf` is set and is not the full buffer, + `offset` should define the actual starting line number, to + get the linenum display right. + linenums (bool, optional): Show line numbers in buffer. + options: raw (bool, optional): Tell protocol to not parse + formatting information. + + """ + if buf is None: + buf = self._buffer + if is_iter(buf): + buf = "\n".join(buf) + + lines = buf.split("\n") + nlines = len(lines) + nwords = len(buf.split()) + nchars = len(buf) + + sep = self._sep + header = ( + "|n" + + sep * 10 + + _("Line Editor [{name}]").format(name=self._key) + + sep * (_DEFAULT_WIDTH - 24 - len(self._key)) + ) + footer = ( + "|n" + + sep * 10 + + "[l:%02i w:%03i c:%04i]" % (nlines, nwords, nchars) + + sep * 12 + + _("(:h for help)") + + sep * (_DEFAULT_WIDTH - 54) + ) + if linenums: + main = "\n".join( + "|b%02i|||n %s" % (iline + 1 + offset, raw(line)) + for iline, line in enumerate(lines) + ) + else: + main = "\n".join([raw(line) for line in lines]) + string = "%s\n%s\n%s" % (header, main, footer) + self._caller.msg(string, options=options)
+ +
[docs] def display_help(self): + """ + Shows the help entry for the editor. + + """ + string = self._sep * _DEFAULT_WIDTH + _HELP_TEXT + if self._codefunc: + string += _HELP_CODE + string += _HELP_LEGEND + self._sep * _DEFAULT_WIDTH + self._caller.msg(string)
+ +
[docs] def deduce_indent(self, line, buffer): + """ + Try to deduce the level of indentation of the given line. + + """ + keywords = { + "elif ": ["if "], + "else:": ["if ", "try"], + "except": ["try:"], + "finally:": ["try:"], + } + opening_tags = ("if ", "try:", "for ", "while ") + + # If the line begins by one of the given keywords + indent = self._indent + if any(line.startswith(kw) for kw in keywords.keys()): + # Get the keyword and matching begin tags + keyword = [kw for kw in keywords if line.startswith(kw)][0] + begin_tags = keywords[keyword] + for oline in reversed(buffer.splitlines()): + if any(oline.lstrip(" ").startswith(tag) for tag in begin_tags): + # This line begins with a begin tag, takes the identation + indent = (len(oline) - len(oline.lstrip(" "))) / 4 + break + + self._indent = indent + 1 + if self._persistent: + self._caller.attributes.add("_eveditor_indent", self._indent) + elif any(line.startswith(kw) for kw in opening_tags): + self._indent = indent + 1 + if self._persistent: + self._caller.attributes.add("_eveditor_indent", self._indent) + + line = " " * 4 * indent + line + return line
+ +
[docs] def decrease_indent(self): + """Decrease automatic indentation by 1 level.""" + if self._codefunc and self._indent > 0: + self._indent -= 1 + if self._persistent: + self._caller.attributes.add("_eveditor_indent", self._indent)
+ +
[docs] def increase_indent(self): + """Increase automatic indentation by 1 level.""" + if self._codefunc and self._indent >= 0: + self._indent += 1 + if self._persistent: + self._caller.attributes.add("_eveditor_indent", self._indent)
+ +
[docs] def swap_autoindent(self): + """Swap automatic indentation on or off.""" + if self._codefunc: + if self._indent >= 0: + self._indent = -1 + else: + self._indent = 0 + + if self._persistent: + self._caller.attributes.add("_eveditor_indent", self._indent)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/evform.html b/docs/latest/_modules/evennia/utils/evform.html new file mode 100644 index 0000000000..50235bb982 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/evform.html @@ -0,0 +1,662 @@ + + + + + + + + evennia.utils.evform — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.evform

+# coding=utf-8
+"""
+EvForm - a way to create advanced ASCII forms
+
+This is intended for creating advanced ASCII game forms, such as a large pretty character sheet or
+info document.
+
+The system works on the basis of a readin template that is given in a separate Python file imported
+into the handler. This file contains some optional settings and a string mapping out the form. The
+template has markers in it to denounce fields to fill. The markers map the absolute size of the
+field and will be filled with an `evtable.EvCell` object when displaying the form.
+
+Example of input file `testform.py`:
+
+```python
+FORMCHAR = "x"
+TABLECHAR = "c"
+
+FORM = '''
+.------------------------------------------------.
+|                                                |
+|  Name: xxxxx1xxxxx    Player: xxxxxxx2xxxxxxx  |
+|        xxxxxxxxxxx                             |
+|                                                |
+ >----------------------------------------------<
+|                                                |
+| Desc:  xxxxxxxxxxx    STR: x4x    DEX: x5x     |
+|        xxxxx3xxxxx    INT: x6x    STA: x7x     |
+|        xxxxxxxxxxx    LUC: x8x    MAG: x9x     |
+|                                                |
+ >----------------------------------------------<
+|          |                                     |
+| cccccccc | ccccccccccccccccccccccccccccccccccc |
+| cccccccc | ccccccccccccccccccccccccccccccccccc |
+| cccAcccc | ccccccccccccccccccccccccccccccccccc |
+| cccccccc | ccccccccccccccccccccccccccccccccccc |
+| cccccccc | cccccccccccccccccBccccccccccccccccc |
+|          |                                     |
+|                                           v&   |
+-------------------------------------------------
+'''
+```
+
+The first line of the `FORM` string is ignored if empty. The forms and table markers must mark out
+complete, unbroken rectangles, each containing one embedded single-character identifier (so the
+smallest element possible is a 3-character wide form). The identifier can be any character except
+for the `FORM_CHAR` and `TABLE_CHAR` and some of the common ASCII-art elements, like space, `_` `|`
+`*` etc (see `INVALID_FORMCHARS` in this module). Form Rectangles can have any size, but must be
+separated from each other by at least one other character's width.
+
+The form can also replace literal markers not abiding by these rules. For example, the `v&` in the
+bottom right corner could be such literal marker. If a literal-mapping for 'v&' is provided, all
+occurrences of this marker will be replaced. This will happen *before* any other parsing, so in
+principle this could be used to inject new fields/tables into the form dynamically.  This literal
+mapping does not consider width, but it will affect to total width of the form, so make sure what
+you inject does not break things.  Using literal markers is the only way to inject 1 or 2-character
+replacements.
+
+Usage
+
+```python
+from evennia import EvForm, EvTable
+
+# create a new form from the template - using the python path
+form = EvForm("path.to.testform")
+
+# alteratively, you can supply the template as a dict:
+
+form = EvForm({"FORM": "....", "TABLECHAR": "c", "FORMCHAR": "x"})
+
+# EvForm can also take a dictionary instead of a filepath, as long
+# as the dict contains the keys FORMCHAR, TABLECHAR and FORM
+# form = EvForm(form=form_dict)
+
+# add data to each tagged form cell
+form.map(cells={1: "Tom the Bouncer",
+                2: "Griatch",
+                3: "A sturdy fellow",
+                4: 12,
+                5: 10,
+                6:  5,
+                7: 18,
+                8: 10,
+                9:  3})
+# create the EvTables
+tableA = EvTable("HP","MV","MP",
+                           table=[["**"], ["*****"], ["***"]],
+                           border="incols")
+tableB = EvTable("Skill", "Value", "Exp",
+                           table=[["Shooting", "Herbalism", "Smithing"],
+                                  [12,14,9],["550/1200", "990/1400", "205/900"]],
+                           border="incols")
+# map 'literal' replacents (here, a version string)
+custom_mapping = {"v&", "v2"}
+
+# add the tables to the proper ids in the form
+form.map(tables={"A": tableA,
+                 "B": tableB})
+
+print(form)
+```
+
+This produces the following result:
+
+::
+
+    .------------------------------------------------.
+    |                                                |
+    |  Name: Tom the        Player: Griatch          |
+    |        Bouncer                                 |
+    |                                                |
+     >----------------------------------------------<
+    |                                                |
+    | Desc:  A sturdy       STR: 12     DEX: 10      |
+    |        fellow         INT: 5      STA: 18      |
+    |                       LUC: 10     MAG: 3       |
+    |                                                |
+     >----------------------------------------------<
+    |          |                                     |
+    | HP|MV|MP | Skill      |Value      |Exp         |
+    | ~~+~~+~~ | ~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~~~~ |
+    | **|**|** | Shooting   |12         |550/1200    |
+    |   |**|*  | Herbalism  |14         |990/1400    |
+    |   |* |   | Smithing   |9          |205/900     |
+    |          |                                     |
+    |                                           v2   |
+     ------------------------------------------------
+
+The marked forms have been replaced with EvCells of text and with EvTables. The literal marker `v&`
+was replaced with `v2`.
+
+If you change the form layout on disk, you can use `form.reload()` to re-read it from disk without
+creating a new form.
+
+If you want to update the data of an existing form, you can use `form.map()` with the changes - the
+mappings will be updated, keeping the things you want. You can also update the template itself this
+way, by supplying it as a dict.
+
+Each component (except literal mappings) is restrained to the width and height specified by the
+template, so it will resize to fit (or crop text if the area is too small for it). If you try to fit
+a table into an area it cannot fit into (when including its borders and at least one line of text),
+the form will raise an error.
+
+----
+
+"""
+
+import re
+from copy import copy
+
+from evennia.utils.ansi import ANSIString
+from evennia.utils.ansi import raw as ansi_raw
+from evennia.utils.evtable import EvCell, EvTable
+from evennia.utils.utils import all_from_module, is_iter, to_str
+
+# non-valid form-identifying characters (which can thus be
+# used as separators between forms without being detected
+# as an identifier). These should be listed in regex form.
+INVALID_FORMCHARS = r"\s\/\|\\\*\_\-\#\<\>\~\^\:\;\.\,"
+# if there is an ansi-escape (||) we have to replace this with ||| to make sure
+# to properly escape down the line
+_ANSI_ESCAPE = re.compile(r"\|\|")
+
+
+
[docs]class EvForm: + """ + This object is instantiated with a text file and parses + it for rectangular form fields. It can then be fed a + mapping so as to populate the fields with fixed-width + EvCell or Tables. + + """ + + # cell option defaults + cell_options = { + "pad_left": 0, + "pad_right": 0, + "pad_top": 0, + "pad_bottom": 0, + "align": "l", + "valign": "t", + "enforce_size": True, + } + + # table option defaults + table_options = { + "pad_left": 0, + "pad_right": 0, + "pad_top": 0, + "pad_bottom": 0, + "align": "l", + "valign": "t", + "enforce_size": True, + } + +
[docs] def __init__(self, data=None, cells=None, tables=None, literals=None, **kwargs): + """ + Initiate the form + + Keyword Args: + data (str or dict): Path to template file or a dict with + "formchar", "tablechar" and "form" keys (not case sensitive, so FORM etc + also works, to stay compatible with the in-file names). While "form/FORM" + is required, if FORMCHAR/TABLECHAR are not given, they will default to + 'x' and 'c' respectively. + cells (dict): A dictionary mapping `{id: str}` + tables (dict): A dictionary mapping `{id: EvTable}`. + literals (dict): A dictionary mapping `{id: str}`. Will be replaced + after width of form is calculated, but before cells/tables are mapped. + All occurrences of the identifier on the form will be replaced. Note + that there is no length-restriction on the remap, you are responsible + for not breaking the form. + + Notes: + Other kwargs are fed as options to the EvCells and EvTables + (see `evtable.EvCell` and `evtable.EvTable` for more info). + + """ + self.indata = data # storing here so we can reload later in case of a filename + self.options = self._parse_inkwargs(**kwargs) + + self.cells_mapping = ( + dict((to_str(key), value) for key, value in cells.items()) if cells else {} + ) + self.tables_mapping = ( + dict((to_str(key), value) for key, value in tables.items()) if tables else {} + ) + self.literals_mapping = ( + dict((to_str(key), to_str(value)) for key, value in literals.items()) + if literals + else {} + ) + + # work arrays + self.literal_form = "" + self.mapping = {} + self.matrix = [] + self.form = [] + + # will parse and build the form + self.reload()
+ + def _parse_indata(self): + """ + Parse and validate the `self.indata` property. We do this in order to be able to + re-load the evform module if indata is a filename and catch any on-file changes. + + Returns: + dict: The data dict parsed/generated from the in-data. + + """ + data = self.indata + + default_formchar = "x" + default_tablechar = "c" + + if isinstance(data, str): + # a module path - read all variables from it + data = all_from_module(data) + + if isinstance(data, dict): + data = { + "form": str(data.get("form", data.get("FORM", None))), + "formchar": str(data.get("formchar", data.get("FORMCHAR", default_formchar))), + "tablechar": str(data.get("tablechar", data.get("TABLECHAR", default_tablechar))), + } + else: + raise RuntimeError(f"EvForm invalid input: {data}.") + + if not data or data["form"] is None: + raise RuntimeError("Evform data must specify a valid 'form' or 'FORM'.") + + # handle empty or multi-character form/tablechars (not supported) + data["formchar"] = data["formchar"][0] if data["formchar"] else default_formchar + data["tablechar"] = data["tablechar"][0] if data["tablechar"] else default_tablechar + if re.match(rf"[{INVALID_FORMCHARS}]", data["formchar"]): + raise RuntimeError(f"Invalid formchar: {data['formchar']}") + if re.match(rf"[{INVALID_FORMCHARS}]", data["tablechar"]): + raise RuntimeError(f"Invalid tablechar: {data['tablechar']}") + + return data + + def _parse_inkwargs(self, **kwargs): + """ + Validate incoming kwargs that will be passed on to become cell/table options. + + Keyword Args: + any: Kwargs to process. + + Returns: + dict: A validated/cleaned kwarg to use for options. + + """ + if "filename" in kwargs: + raise DeprecationWarning( + "EvForm's 'filename' kwarg was renamed to 'data' and can now accept both " + "a python path and a dict with 'FORMCHAR', 'TABLECHAR' and 'FORM' keys." + ) + if "form" in kwargs: + raise DeprecationWarning( + "EvForms's 'form' kwarg was renamed to 'data' and can now accept both " + "a python path and a dict detailing the form." + ) + + # clean cell kwarg options (these cannot be overridden on the cell but must be controlled + # by the evform itself) + kwargs.pop("enforce_size", None) + kwargs.pop("width", None) + kwargs.pop("height", None) + + return kwargs + + def _do_literal_mapping(self): + """ + Do literal replacement in the EvForm. + + """ + literal_form = copy(self.data["form"]) + + for key, repl in self.literals_mapping.items(): + literal_form = literal_form.replace(key, repl) + return literal_form + + def _parse_to_matrix(self): + """ + Forces all lines to be as long as the longest line, filling with whitespace. + + Args: + lines (list): list of `ANSIString`s + + Returns: + (list): list of `ANSIString`s of + same length as the longest input line + + """ + matrix = EvForm._to_ansi(self.literal_form.split("\n")) + + maxl = max(len(line) for line in matrix) + matrix = [line + " " * (maxl - len(line)) for line in matrix] + if matrix and not matrix[0].strip(): + # the first line is normally empty, we strip it. + matrix = matrix[1:] + return matrix + + @staticmethod + def _to_ansi(obj, regexable=False): + "convert anything to ANSIString" + + if isinstance(obj, ANSIString): + return obj + elif isinstance(obj, str): + # since ansi will be parsed twice (here and in the normal ansi send), we have to + # escape ansi twice. + obj = ansi_raw(obj) + + if isinstance(obj, dict): + return dict( + (key, EvForm._to_ansi(value, regexable=regexable)) for key, value in obj.items() + ) + # regular _to_ansi (from EvTable) + elif is_iter(obj): + return [EvForm._to_ansi(o) for o in obj] + else: + return ANSIString(obj, regexable=regexable) + + def _rectangles_to_mapping(self): + """ + Parse a form for rectangular formfields identified by formchar/tablechar enclosing an + identifier. + + """ + formchar = self.data["formchar"] + tablechar = self.data["tablechar"] + matrix = self.matrix + + cell_options = copy(self.cell_options) + cell_options.update(self.options) + + table_options = copy(self.table_options) + table_options.update(self.options) + + nmatrix = len(matrix) + + mapping = {} + + def _get_rectangles(char): + """Find all identified rectangles marked with given char""" + rects = [] + coords = {} + regex = re.compile(rf"{char}+([^{INVALID_FORMCHARS}{char}]+){char}+") + + # find the start/width of rectangles for each line + for iy, line in enumerate(EvForm._to_ansi(matrix, regexable=True)): + ix0 = 0 + while True: + match = regex.search(line, ix0) + if match: + # get the width of the rectangle directly from the match + coords[match.group(1)] = [iy, match.start(), match.end()] + ix0 = match.end() + else: + break + + for key, (iy, leftix, rightix) in coords.items(): + # scan up to find top of rectangle + dy_up = 0 + if iy > 0: + for i in range(1, iy): + if all(matrix[iy - i][ix] == char for ix in range(leftix, rightix)): + dy_up += 1 + else: + break + # find bottom edge of rectangle + dy_down = 0 + if iy < nmatrix - 1: + for i in range(1, nmatrix - iy - 1): + if all(matrix[iy + i][ix] == char for ix in range(leftix, rightix)): + dy_down += 1 + else: + break + + # we have our rectangle. Calculate size + iyup = iy - dy_up + iydown = iy + dy_down + width = rightix - leftix + height = abs(iyup - iydown) + 1 + + # store (key, y, x, width, height) of triangle + rects.append((key, iyup, leftix, width, height)) + + return rects + + # Map EvCells into form rectangles + for key, y, x, width, height in _get_rectangles(formchar): + # get data to populate cell + data = self.cells_mapping.get(key, "") + if isinstance(data, EvCell): + # mapping already provides the cell. We need to override some + # of the cell's options to make it work in the evform rectangle. + # We retain the align/valign since this may be interesting to + # play with within the rectangle. + cell = data + custom_align = cell.align + custom_valign = cell.valign + cell.reformat( + width=width, + height=height, + **{**cell_options, **{"align": custom_align, "valign": custom_valign}}, + ) + else: + # generating cell on the fly + cell = EvCell(data, width=width, height=height, **cell_options) + + mapping[key] = (y, x, width, height, cell) + + # Map EvTables into form rectangles + for key, y, x, width, height in _get_rectangles(tablechar): + # get EvTable from mapping + table = self.tables_mapping.get(key, None) + + if table: + table.reformat(width=width, height=height, **table_options) + else: + table = EvTable(width=width, height=height, **table_options) + + mapping[key] = (y, x, width, height, table) + + return mapping + + def _build_form(self): + """ + Insert cell/table contents into form at given locations to create + the final result. + + """ + form = copy(self.matrix) + mapping = self.mapping + + for key, (y, x, width, height, cell_or_table) in mapping.items(): + # rect is a list of <height> lines, each <width> wide + rect = cell_or_table.get() + for il, rectline in enumerate(rect): + formline = form[y + il] + # insert new content, replacing old + form[y + il] = formline[:x] + rectline + formline[x + width :] + + return form + +
[docs] def reload(self): + """ + Creates the form from a filename or data structure. + + Args: + data (str or dict): Can be used to update an existing form using + the same cells/tables provided on initialization or using `.map()`. + + Notes: + Kwargs are passed through to Cel creation. + + """ + self.data = self._parse_indata() + + # Map any literals into the string + self.literal_form = self._do_literal_mapping() + # Create raw form matrix, indexable with (y, x) coords + self.matrix = self._parse_to_matrix() + # parse and identify all rectangles in the form + self.mapping = self._rectangles_to_mapping() + # combine mapping with form template into a final result + self.form = self._build_form()
+ +
[docs] def map(self, cells=None, tables=None, data=None, literals=None, **kwargs): + """ + Add mapping for form. This allows for updating an existing + evform. + + Args: + cells (dict): A dictionary of {identifier:celltext}. These + will be appended to the existing mappings. + tables (dict): A dictionary of {identifier:table}. Will + be appended to the existing mapping. + data (str or dict): A path to a evform module or a dict with + the needed "FORM", "TABLE/FORMCHAR" keys. Will replace + the originally initialized form. + literals + + Keyword Args: + These will be appended to the existing cell/table options. + + Notes: + kwargs will be forwarded to tables/cells. See + `evtable.EvCell` and `evtable.EvTable` for info. + + """ + if data: + # storing so ._parse_indata will find it during reload + self.indata = data + + new_cells = dict((to_str(key), value) for key, value in cells.items()) if cells else {} + self.cells_mapping.update(new_cells) + new_tables = dict((to_str(key), value) for key, value in tables.items()) if tables else {} + self.tables_mapping.update(new_tables) + new_literals = ( + dict((to_str(key), to_str(value)) for key, value in literals.items()) + if literals + else {} + ) + self.literals_mapping.update(new_literals) + + self.options.update(self._parse_inkwargs(**kwargs)) + + # parse and build the form + self.reload()
+ + def __str__(self): + "Prints the form" + return str(ANSIString("\n").join([line for line in self.form]))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/evmenu.html b/docs/latest/_modules/evennia/utils/evmenu.html new file mode 100644 index 0000000000..67bd8e83f7 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/evmenu.html @@ -0,0 +1,2210 @@ + + + + + + + + evennia.utils.evmenu — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.evmenu

+"""
+EvMenu
+
+This implements a full menu system for Evennia.
+
+To start the menu, just import the EvMenu class from this module.
+Example usage:
+
+```python
+
+    from evennia.utils.evmenu import EvMenu
+
+    EvMenu(caller, menu_module_path,
+         startnode="node1",
+         cmdset_mergetype="Replace", cmdset_priority=1,
+         auto_quit=True, cmd_on_exit="look", persistent=True)
+```
+
+Where `caller` is the Object to use the menu on - it will get a new
+cmdset while using the Menu. The menu_module_path is the python path
+to a python module containing function definitions.  By adjusting the
+keyword options of the Menu() initialization call you can start the
+menu at different places in the menu definition file, adjust if the
+menu command should overload the normal commands or not, etc.
+
+The `persistent` keyword will make the menu survive a server reboot.
+It is `False` by default. Note that if using persistent mode, every
+node and callback in the menu must be possible to be *pickled*, this
+excludes e.g. callables that are class methods or functions defined
+dynamically or as part of another function. In non-persistent mode
+no such restrictions exist.
+
+The menu is defined in a module (this can be the same module as the
+command definition too) with function definitions:
+
+```python
+
+    def node1(caller):
+        # (this is the start node if called like above)
+        # code
+        return text, options
+
+    def node_with_other_name(caller, input_string):
+        # code
+        return text, options
+
+    def another_node(caller, input_string, **kwargs):
+        # code
+        return text, options
+```
+
+Where caller is the object using the menu and input_string is the
+command entered by the user on the *previous* node (the command
+entered to get to this node). The node function code will only be
+executed once per node-visit and the system will accept nodes with
+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._evmenu`. This makes it a convenient place to store
+temporary state variables between nodes, since this NAttribute is
+deleted when the menu is exited.
+
+The return values must be given in the above order, but each can be
+returned as None as well. If the options are returned as None, the
+menu is immediately exited and the default "look" command is called.
+
+- `text` (str, tuple or None): Text shown at this node. If a tuple, the
+   second element in the tuple holds either a string or a dict. If a string,
+   this is the help text to show when `auto_help` is active for the menu and
+   the user presses `h`. If a dict, this is a mapping of `'help topic': 'help text'` to
+   show in that menu. This can be used to show information without having to
+   switch to another node.
+- `options` (tuple, dict or None): If `None`, this exits the menu.
+  If a single dict, this is a single-option node. If a tuple,
+  it should be a tuple of option dictionaries. Option dicts have the following keys:
+
+  - `key` (str or tuple, optional): What to enter to choose this option.
+    If a tuple, it must be a tuple of strings, where the first string is the
+    key which will be shown to the user and the others are aliases.
+    If unset, the options' number will be used. The special key `_default`
+    marks this option as the default fallback when no other option matches
+    the user input. There can only be one `_default` option per node. It
+    will not be displayed in the list.
+  - `desc` (str, optional): This describes what choosing the option will do.
+  - `goto` (str, tuple or callable): If string, should be the name of node to go to
+    when this option is selected. If a callable, it has the signature
+    `callable(caller[,raw_input][,**kwargs])`. If a tuple, the first element
+    is the callable and the second is a dict with the `**kwargs` to pass to
+    the callable. Those kwargs will also be passed into the next node if possible.
+    Such a callable should return either a str or a (str, dict), where the
+    string is the name of the next node to go to and the dict is the new,
+    (possibly modified) kwarg to pass into the next node. If the callable returns
+    None or the empty string, the current node will be revisited.
+
+If `key` is not given, the option will automatically be identified by
+its number 1..N.
+
+Example:
+
+```python
+
+    # in menu_module.py
+
+    def node1(caller):
+        text = ("This is a node text",
+                "This is help text for this node")
+        options = ({"key": "testing",
+                    "desc": "Select this to go to node 2",
+                    "goto": ("node2", {"foo": "bar"}),
+                   {"desc": "Go to node 3.",
+                    "goto": "node3"})
+        return text, options
+
+    def callback1(caller):
+        # this is called when choosing the "testing" option in node1
+        # (before going to node2). If it returned a string, say 'node3',
+        # then the next node would be node3 instead of node2 as specified
+        # by the normal 'goto' option key above.
+        caller.msg("Callback called!")
+
+    def node2(caller, **kwargs):
+        text = '''
+            This is node 2. It only allows you to go back
+            to the original node1. This extra indent will
+            be stripped. We don't include a help text but
+            here are the variables passed to us: {}
+            '''.format(kwargs)
+        options = {"goto": "node1"}
+        return text, options
+
+    def node3(caller):
+        text = "This ends the menu since there are no options."
+        return text, None
+
+```
+
+When starting this menu with  `Menu(caller, "path.to.menu_module")`,
+the first node will look something like this:
+
+::
+
+    This is a node text
+    ______________________________________
+
+    testing: Select this to go to node 2
+    2: Go to node 3
+
+Where you can both enter "testing" and "1" to select the first option.
+If the client supports MXP, they may also mouse-click on "testing" to
+do the same. When making this selection, a function "callback1" in the
+same Using `help` will show the help text, otherwise a list of
+available commands while in menu mode.
+
+The menu tree is exited either by using the in-menu quit command or by
+reaching a node without any options.
+
+
+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:
+
+```python
+
+    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
+
+        # Beginning 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 inspect
+import re
+from ast import literal_eval
+from fnmatch import fnmatch
+from inspect import getfullargspec, isfunction
+from math import ceil
+
+from django.conf import settings
+
+# i18n
+from django.utils.translation import gettext as _
+
+import evennia
+from evennia import CmdSet, Command
+from evennia.commands import cmdhandler
+from evennia.utils import logger
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.evtable import EvColumn, EvTable
+from evennia.utils.utils import (
+    crop,
+    dedent,
+    is_iter,
+    m_len,
+    make_iter,
+    mod_import,
+    pad,
+    to_str,
+    inherits_from,
+)
+
+# read from protocol NAWS later?
+_MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+
+# we use cmdhandler instead of evennia.syscmdkeys to
+# avoid some cases of loading before evennia init'd
+_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
+
+# Return messages
+
+
+_ERR_NOT_IMPLEMENTED = _(
+    "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.")
+_HELP_FULL = _("Commands: <menu option>, help, quit")
+_HELP_NO_QUIT = _("Commands: <menu option>, help")
+_HELP_NO_OPTIONS = _("Commands: help, quit")
+_HELP_NO_OPTIONS_NO_QUIT = _("Commands: help")
+_HELP_NO_OPTION_MATCH = _("Choose an option or try 'help'.")
+
+_ERROR_PERSISTENT_SAVING = """
+{error}
+
+|rThe menu state could not be saved for persistent mode. Switching
+to non-persistent mode (which means the menu session won't survive
+an eventual server reload).|n
+"""
+
+_TRACE_PERSISTENT_SAVING = (
+    "EvMenu persistent-mode error. Commonly, this is because one or "
+    "more of the EvEditor callbacks could not be pickled, for example "
+    "because it's a class method or is defined inside another function."
+)
+
+
+
[docs]class EvMenuError(RuntimeError): + """ + Error raised by menu when facing internal errors. + + """ + + pass
+ + +
[docs]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 +# +# ------------------------------------------------------------- + + +
[docs]class CmdEvMenuNode(Command): + """ + Command to handle all user input targeted at the menu while the menu is active. + + """ + + key = _CMD_NOINPUT + aliases = [_CMD_NOMATCH] + locks = "cmd:all()" + help_category = "Menu" + auto_help_display_key = "<menu commands>" + +
[docs] def get_help(self): + return "Menu commands are explained within the menu."
+ + def _update_aliases(self, menu): + """Add aliases to make sure to override defaults if we defined we want it.""" + + new_aliases = [_CMD_NOMATCH] + if menu.auto_quit and "quit" not in self.aliases: + new_aliases.extend(["q", "quit"]) + if menu.auto_look and "look" not in self.aliases: + new_aliases.extend(["l", "look"]) + if menu.auto_help and "help" not in self.aliases: + new_aliases.extend(["h", "help"]) + if len(new_aliases) > 1: + self.set_aliases(new_aliases) + + self.msg(f"aliases: {self.aliases}") + +
[docs] def func(self): + """ + Implement all menu commands. + """ + + def _restore(caller): + # check if there is a saved menu available. + # this will re-start a completely new evmenu call. + saved_options = caller.attributes.get("_menutree_saved") + if saved_options: + startnode_tuple = caller.attributes.get("_menutree_saved_startnode") + try: + startnode, startnode_input = startnode_tuple + except ValueError: # old form of startnode store + startnode, startnode_input = startnode_tuple, "" + if startnode: + saved_options[2]["startnode"] = startnode + saved_options[2]["startnode_input"] = startnode_input + MenuClass = saved_options[0] + # this will create a completely new menu call + menu = MenuClass(caller, *saved_options[1], **saved_options[2]) + # self._update_aliases(menu) + return True + return None + + caller = self.caller + # we store Session on the menu since this can be hard to + # get in multisession environments if caller is an Account. + menu = caller.ndb._evmenu + if not menu: + if _restore(caller): + return + orig_caller = caller + caller = caller.account if inherits_from(caller, evennia.DefaultObject) 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._evmenu + if not menu: + # can't restore from a session + 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) + + # self._update_aliases(menu) + + # 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._evmenu._session = self.session + # we have a menu, use it. + menu.parse_input(self.raw_string)
+ + +
[docs]class EvMenuCmdSet(CmdSet): + """ + The Menu cmdset replaces the current cmdset. + + """ + + key = "menu_cmdset" + priority = 1 + mergetype = "Replace" + no_objs = True + no_exits = True + no_channels = False + +
[docs] def at_cmdset_creation(self): + """ + Called when creating the set. + """ + self.add(CmdEvMenuNode())
+ + +# ------------------------------------------------------------ +# +# Menu main class +# +# ------------------------------------------------------------- + + +
[docs]class EvMenu: + """ + This object represents an operational menu. It is initialized from + a menufile.py instruction. + + """ + + # convenient helpers for easy overloading + node_border_char = "_" + +
[docs] def __init__( + self, + caller, + menudata, + startnode="start", + cmdset_mergetype="Replace", + cmdset_priority=1, + auto_quit=True, + auto_look=True, + auto_help=True, + cmd_on_exit="look", + persistent=False, + startnode_input="", + session=None, + debug=False, + **kwargs, + ): + """ + Initialize the menu tree and start the caller onto the first node. + + Args: + caller (Object, Account or Session): The user of the menu. + menudata (str, module or dict): The full or relative path to the module + holding the menu tree data. All global functions in this module + whose name doesn't start with '_ ' will be parsed as menu nodes. + Also the module itself is accepted as input. Finally, a dictionary + menu tree can be given directly. This must then be a mapping + `{"nodekey":callable,...}` where `callable` must be called as + and return the data expected of a menu node. This allows for + dynamic menu creation. + startnode (str, optional): The starting node name in the menufile. + cmdset_mergetype (str, optional): 'Replace' (default) means the menu + commands will be exclusive - no other normal commands will + be usable while the user is in the menu. 'Union' means the + menu commands will be integrated with the existing commands + (it will merge with `merge_priority`), if so, make sure that + the menu's command names don't collide with existing commands + in an unexpected way. Also the CMD_NOMATCH and CMD_NOINPUT will + be overloaded by the menu cmdset. Other cmdser mergetypes + has little purpose for the menu. + cmdset_priority (int, optional): The merge priority for the + menu command set. The default (1) is usually enough for most + types of menus. + auto_quit (bool, optional): Allow user to use "q", "quit" or + "exit" to leave the menu at any point. Recommended during + development! + auto_look (bool, optional): Automatically make "looK" or "l" to + re-show the last node. Turning this off means you have to handle + re-showing nodes yourself, but may be useful if you need to + use "l" for some other purpose. + auto_help (bool, optional): Automatically make "help" or "h" show + the current help entry for the node. If turned off, eventual + help must be handled manually, but it may be useful if you + need 'h' for some other purpose, for example. + cmd_on_exit (callable, str or None, optional): When exiting the menu + (either by reaching a node with no options or by using the + in-built quit command (activated with `allow_quit`), this + callback function or command string will be executed. + The callback function takes two parameters, the caller then the + EvMenu object. This is called after cleanup is complete. + Set to None to not call any command. + persistent (bool, optional): Make the Menu persistent (i.e. it will + survive a reload. This will make the Menu cmdset persistent. Use + with caution - if your menu is buggy you may end up in a state + you can't get out of! Also note that persistent mode requires + that all formatters, menu nodes and callables are possible to + *pickle*. When the server is reloaded, the latest node shown will be completely + re-run with the same input arguments - so be careful if you are counting + up some persistent counter or similar - the counter may be run twice if + reload happens on the node that does that. Note that if `debug` is True, + this setting is ignored and assumed to be False. + startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if + a user input text from a fictional previous node. If including the dict, this will + be passed as **kwargs to that node. When the server reloads, + the latest visited node will be re-run as `node(caller, raw_string, **kwargs)`. + session (Session, optional): This is useful when calling EvMenu from an account + in multisession mode > 2. Note that this session only really relevant + for the very first display of the first node - after that, EvMenu itself + will keep the session updated from the command input. So a persistent + menu will *not* be using this same session anymore after a reload. + debug (bool, optional): If set, the 'menudebug' command will be made available + by default in all nodes of the menu. This will print out the current state of + the menu. Deactivate for production use! When the debug flag is active, the + `persistent` flag is deactivated. + **kwargs: 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._evmenu`. Also + the current Session (from the Command, so this is still valid in multisession + 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. + + In persistent mode, all nodes, formatters and callbacks in the menu must be + possible to be *pickled*, this excludes e.g. callables that are class methods + or functions defined dynamically or as part of another function. In + non-persistent mode no such restrictions exist. + + """ + self._startnode = startnode + self._menutree = self._parse_menudata(menudata) + self._persistent = persistent if not debug else False + self._quitting = False + + if startnode not in self._menutree: + raise EvMenuError("Start node '%s' not in menu tree!" % startnode) + + # public variables made available to the command + + self.caller = caller + + # track EvMenu kwargs + self.auto_quit = auto_quit + self.auto_look = auto_look + self.auto_help = auto_help + self.debug_mode = debug + self._session = session + if isinstance(cmd_on_exit, str): + # At this point menu._session will have been replaced by the + # menu command to the actual session calling. + self.cmd_on_exit = lambda caller, menu: caller.execute_cmd( + cmd_on_exit, session=menu._session + ) + elif callable(cmd_on_exit): + self.cmd_on_exit = cmd_on_exit + else: + self.cmd_on_exit = None + # current menu state + self.default = None + self.nodetext = None + self.helptext = None + self.options = None + self.nodename = None + self.node_kwargs = {} + + # used for testing + self.test_options = {} + self.test_nodetext = "" + + # assign kwargs as initialization vars on ourselves. + reserved_clash = set( + ( + "_startnode", + "_menutree", + "_session", + "_persistent", + "cmd_on_exit", + "default", + "nodetext", + "helptext", + "options", + "cmdset_mergetype", + "auto_quit", + ) + ).intersection(set(kwargs.keys())) + if reserved_clash: + raise RuntimeError( + 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) + + 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._evmenu.close_menu() + except Exception: + pass + + # store ourself on the object + self.caller.ndb._evmenu = self + + # TODO DEPRECATED - for backwards-compatibility. Use `.ndb._evmenu` instead + self.caller.ndb._menutree = self + + if persistent: + # save the menu to the database + calldict = { + "startnode": startnode, + "cmdset_mergetype": cmdset_mergetype, + "cmdset_priority": cmdset_priority, + "auto_quit": auto_quit, + "auto_look": auto_look, + "auto_help": auto_help, + "cmd_on_exit": cmd_on_exit, + "persistent": persistent, + } + calldict.update(kwargs) + try: + caller.attributes.add("_menutree_saved", (self.__class__, (menudata,), calldict)) + caller.attributes.add("_menutree_saved_startnode", (startnode, startnode_input)) + except Exception as err: + self.msg(_ERROR_PERSISTENT_SAVING.format(error=err)) + logger.log_trace(_TRACE_PERSISTENT_SAVING) + persistent = False + + # set up the menu command on the caller + menu_cmdset = EvMenuCmdSet() + menu_cmdset.mergetype = str(cmdset_mergetype).lower().capitalize() or "Replace" + menu_cmdset.priority = int(cmdset_priority) + self.caller.cmdset.add(menu_cmdset, persistent=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)
+ + def _parse_menudata(self, menudata): + """ + Parse a menufile for node functions and store in dictionary + map. Alternatively, accept a pre-made mapping dictionary of + node functions. + + Args: + menudata (str, module or dict): The python.path to the menufile, + or the python module itself. If a dict, this should be a + mapping nodename:callable, where the callable must match + the criteria for a menu node. + + Returns: + menutree (dict): A {nodekey: func} + + """ + if isinstance(menudata, dict): + # This is assumed to be a pre-loaded menu tree. + return menudata + else: + # a python path of a module + module = mod_import(menudata) + return dict( + (key, func) + for key, func in module.__dict__.items() + if isfunction(func) and not key.startswith("_") + ) + + def _format_node(self, nodetext, optionlist): + """ + Format the node text + option section + + Args: + nodetext (str): The node text + optionlist (list): List of (key, desc) pairs. + + Returns: + string (str): The options section, including + all needed spaces. + + Notes: + This will adjust the columns of the options, first to use + a maxiumum of 4 rows (expanding in columns), then gradually + growing to make use of the screen space. + + """ + + # handle the node text + nodetext = self.nodetext_formatter(nodetext) + + # handle the options + optionstext = self.options_formatter(optionlist) + + # format the entire node + return self.node_formatter(nodetext, optionstext) + + def _safe_call(self, callback, raw_string, **kwargs): + """ + Call a node-like callable, with a variable number of raw_string, *args, **kwargs, all of + which should work also if not present (only `caller` is always required). Return its result. + + """ + try: + try: + nargs = len(getfullargspec(callback).args) + except TypeError: + raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback)) + supports_kwargs = bool(getfullargspec(callback).varkw) + if nargs <= 0: + raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback)) + + if supports_kwargs: + if nargs > 1: + ret = callback(self.caller, raw_string, **kwargs) + # callback accepting raw_string, **kwargs + else: + # callback accepting **kwargs + ret = callback(self.caller, **kwargs) + elif nargs > 1: + # callback accepting raw_string + ret = callback(self.caller, raw_string) + else: + # normal callback, only the caller as arg + ret = callback(self.caller) + except EvMenuError: + errmsg = _ERR_GENERAL.format(nodename=callback) + self.msg(errmsg) + logger.log_trace() + raise + + return ret + + def _execute_node(self, nodename, raw_string, **kwargs): + """ + Execute a node (-function) and get its returns. + + Args: + nodename (str): Name of node. + raw_string (str): The raw default string entered on the + previous node (only used if the node accepts it as an + argument) + kwargs (any, optional): Optional kwargs for the node. + + Returns: + nodetext, options (tuple): The node text (a string or a + tuple and the options tuple, if any. + + """ + try: + node = self._menutree[nodename] + except KeyError: + self.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename)) + 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] + else: + nodetext, options = ret, None + except KeyError: + self.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename)) + logger.log_trace() + raise EvMenuError + except Exception: + self.msg(_ERR_GENERAL.format(nodename=nodename)) + logger.log_trace() + raise + + # store options to make them easier to test + self.test_nodetext = nodetext + self.test_options = options + + return nodetext, options + + def _extract_goto(self, nodename, option_dict): + """ + Helper: Get callables and their eventual kwargs. + + Args: + nodename (str): The current node name (used for error reporting). + option_dict (dict): The seleted option's dict. + + Returns: + goto (str, callable or None): The goto directive in the option. + goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty. + + """ + goto_kwargs = {} + goto = option_dict.get("goto", None) + if goto and isinstance(goto, (tuple, list)): + if len(goto) > 1: + goto, goto_kwargs = goto[:2] # ignore any extra arguments + if not hasattr(goto_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError( + "EvMenu node {}: goto kwargs is not a dict: {}".format( + nodename, goto_kwargs + ) + ) + else: + goto = goto[0] + return goto, goto_kwargs + +
[docs] def goto(self, nodename_or_callable, raw_string, **kwargs): + """ + Run a node by name, optionally dynamically generating that name first. + + Args: + nodename_or_callable (str or callable): Name of node or a callable + to be called as `function(caller, raw_string, **kwargs)` or + `function(caller, **kwargs)`. This callable must return the node-name (str) + pointing to the next node. + raw_string (str): The raw default string entered on the + previous node (only used if the node accepts it as an + argument) + **kwargs: Extra arguments to goto callables. + + """ + + inp_nodename = nodename_or_callable + if callable(nodename_or_callable): + # run the "goto" callable to get the next node to go to + nodename = self._safe_call(nodename_or_callable, raw_string, **kwargs) + if isinstance(nodename, (tuple, list)): + if not len(nodename) > 1 or not isinstance(nodename[1], dict): + raise EvMenuError( + "{}: goto callable must return str or (str, dict)".format(inp_nodename) + ) + nodename, kwargs = nodename[:2] + if not nodename: + # no nodename return. Re-run current node + nodename = self.nodename + elif nodename_or_callable is None: + # repeat current node + nodename = self.nodename + else: + # the nodename given directly + nodename = nodename_or_callable + + # one way or another, we have the nodename as a string now + + try: + # execute the found nodename, make use of the returns. + nodetext, options = self._execute_node(nodename, raw_string, **kwargs) + except EvMenuError: + return + + if self._persistent: + self.caller.attributes.add( + "_menutree_saved_startnode", (nodename, (raw_string, kwargs)) + ) + + # validation of the node return values + + # if the nodetext is a list/tuple, the second set is the help text. + # helptext can also be a dict, which allows for tooltip command-text (key-value) or + # ((key,aliases)-value) pairs. + + # make sure helptext is defined + helptext = "" + if is_iter(nodetext): + nodetext, *helptext = nodetext + helptext = helptext[0] if helptext else "" + + if isinstance(helptext, dict): + # handle both (key-value) and (key, aliases)-value pairs + _help_text = {} + for topic_keys, help_entry in helptext.items(): + for topic_key in make_iter(topic_keys): + _help_text[topic_key.strip().lower()] = help_entry + helptext = _help_text + + nodetext = "" if nodetext is None else str(nodetext) + + # handle the helptext + if helptext: + self.helptext = self.helptext_formatter(helptext) + elif options: + self.helptext = _HELP_FULL if self.auto_quit else _HELP_NO_QUIT + else: + self.helptext = _HELP_NO_OPTIONS if self.auto_quit else _HELP_NO_OPTIONS_NO_QUIT + + # store the current node's data in the menu state + + self.nodename = nodename + self.node_kwargs = kwargs + self.options = {} + self.default = None + display_options = [] # options will be displayed in this order + + if options: + options = [options] if isinstance(options, dict) else options + for inum, dic in enumerate(options): + # homogenize the options dict + keys = make_iter(dic.get("key")) + desc = dic.get("desc", dic.get("text", None)) + + if "_default" in keys: + keys = [key for key in keys if key != "_default"] + goto, goto_kwargs = self._extract_goto(nodename, dic) + self.default = (goto, goto_kwargs) + else: + # use the key (only) if set, otherwise use the running number + keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) + goto, goto_kwargs = self._extract_goto(nodename, dic) + + if keys: + display_options.append((keys[0], desc)) + for key in keys: + self.options[strip_ansi(key).strip().lower()] = (goto, goto_kwargs) + + # format the text + self.nodetext = self._format_node(nodetext, display_options) + + # display self.nodetext to the user + self.display_nodetext() + + # close menu if we have no more options to process + if not options: + self.close_menu()
+ +
[docs] def close_menu(self): + """ + Shutdown menu; occurs when reaching the end node or using the quit command. + """ + if not self._quitting: + # avoid multiple calls from different sources + self._quitting = True + self.caller.cmdset.remove(EvMenuCmdSet) + del self.caller.ndb._evmenu + del self.caller.ndb._menutree # TODO Deprecated + if self._persistent: + self.caller.attributes.remove("_menutree_saved") + self.caller.attributes.remove("_menutree_saved_startnode") + if self.cmd_on_exit is not None: + self.cmd_on_exit(self.caller, self) + # special for template-generated menues + del self.caller.db._evmenu_template_contents
+ +
[docs] def print_debug_info(self, arg): + """ + Messages the caller with the current menu state, for debug purposes. + + Args: + arg (str): Arg to debug instruction, either nothing, 'full' or the name + of a property to inspect. + + """ + all_props = inspect.getmembers(self) + all_methods = [name for name, _ in inspect.getmembers(self, predicate=inspect.ismethod)] + all_builtins = [name for name, _ in inspect.getmembers(self, predicate=inspect.isbuiltin)] + props = { + prop: value + for prop, value in all_props + if prop not in all_methods and prop not in all_builtins and not prop.endswith("__") + } + + local = { + key: var + for key, var in locals().items() + if key not in all_props and not key.endswith("__") + } + + if arg: + if arg in props: + debugtxt = " |y* {}:|n\n{}".format(arg, props[arg]) + elif arg in local: + debugtxt = " |y* {}:|n\n{}".format(arg, local[arg]) + elif arg == "full": + debugtxt = ( + "|yMENU DEBUG full ... |n\n" + + "\n".join( + "|y *|n {}: {}".format(key, val) for key, val in sorted(props.items()) + ) + + "\n |yLOCAL VARS:|n\n" + + "\n".join( + "|y *|n {}: {}".format(key, val) for key, val in sorted(local.items()) + ) + + "\n |y... END MENU DEBUG|n" + ) + else: + debugtxt = "|yUsage: menudebug full|<name of property>|n" + else: + debugtxt = ( + "|yMENU DEBUG properties ... |n\n" + + "\n".join( + "|y *|n {}: {}".format(key, crop(to_str(val, force_string=True), width=50)) + for key, val in sorted(props.items()) + ) + + "\n |yLOCAL VARS:|n\n" + + "\n".join( + "|y *|n {}: {}".format(key, crop(to_str(val, force_string=True), width=50)) + for key, val in sorted(local.items()) + ) + + "\n |y... END MENU DEBUG|n" + ) + self.msg(debugtxt)
+ +
[docs] 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)
+ +
[docs] def parse_input(self, raw_string): + """ + Parses the incoming string from the menu user. This is the entry-point for all input + into the menu. + + Args: + raw_string (str): The incoming, unmodified string + from the user. + Notes: + This method is expected to parse input and use the result + to relay execution to the relevant methods of the menu. It + should also report errors directly to the user. + + """ + # this is the input cmd given to the menu + cmd = strip_ansi(raw_string.strip().lower()) + + try: + if self.options and cmd in self.options: + # we chose one of the available options; this + # will take precedence over the default commands + goto_node, goto_kwargs = self.options[cmd] + self.goto(goto_node, raw_string, **(goto_kwargs or {})) + elif self.auto_look and cmd in ("look", "l"): + self.display_nodetext() + elif self.auto_help and isinstance(self.helptext, dict) and cmd in self.helptext: + self.display_tooltip(cmd) + 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_node, goto_kwargs = self.default + self.goto(goto_node, raw_string, **(goto_kwargs or {})) + else: + 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.msg(str(err))
+ +
[docs] def display_nodetext(self): + self.msg(self.nodetext)
+ +
[docs] def display_helptext(self): + self.msg(self.helptext)
+ +
[docs] def display_tooltip(self, cmd): + self.msg(self.helptext.get(cmd))
+ + # formatters - override in a child class + +
[docs] def nodetext_formatter(self, nodetext): + """ + Format the node text itself. + + Args: + nodetext (str): The full node text (the text describing the node). + + Returns: + nodetext (str): The formatted node text. + + """ + return dedent(nodetext.strip("\n"), baseline_index=0).rstrip()
+ +
[docs] def helptext_formatter(self, helptext): + """ + Format the node's help text + + Args: + helptext (str): The unformatted help text for the node. + + Returns: + helptext (str): The formatted help text. + + """ + return dedent(helptext.strip("\n"), baseline_index=0).rstrip()
+ +
[docs] def options_formatter(self, optionlist): + """ + Formats the option block. + + Args: + optionlist (list): List of (key, description) tuples for every + option related to this node. + + Returns: + options (str): The formatted option display. + + """ + if not optionlist: + return "" + + # column separation distance + colsep = 4 + + nlist = len(optionlist) + + # get the widest option line in the table. + table_width_max = -1 + table = [] + for key, desc in optionlist: + if key or desc: + desc_string = f": {desc}" if desc else "" + table_width_max = max( + table_width_max, + max(m_len(p) for p in key.split("\n")) + + max(m_len(p) for p in desc_string.split("\n")) + + colsep, + ) + raw_key = strip_ansi(key) + if raw_key != key: + # already decorations in key definition + table.append(f" |lc{raw_key}|lt{key}|le{desc_string}") + else: + # add a default white color to key + table.append(f" |lc{raw_key}|lt|w{key}|n|le{desc_string}") + ncols = _MAX_TEXT_WIDTH // table_width_max # number of columns + + if ncols < 0: + # no visible options at all + return "" + + ncols = 1 if ncols == 0 else ncols + + # minimum number of rows in a column + min_rows = 4 + + # split the items into columns + split = max(min_rows, ceil(len(table) / ncols)) + max_end = len(table) + cols_list = [] + for icol in range(ncols): + start = icol * split + end = min(start + split, max_end) + cols_list.append(EvColumn(*table[start:end])) + + return str(EvTable(table=cols_list, border="none"))
+ +
[docs] def node_formatter(self, nodetext, optionstext): + """ + Formats the entirety of the node. + + Args: + nodetext (str): The node text as returned by `self.nodetext_formatter`. + optionstext (str): The options display as returned by `self.options_formatter`. + caller (Object, Account or None, optional): The caller of the node. + + Returns: + node (str): The formatted node to display. + + """ + sep = self.node_border_char + + if self._session: + screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: _MAX_TEXT_WIDTH})[0] + else: + screen_width = _MAX_TEXT_WIDTH + + nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) + options_width_max = max(m_len(line) for line in optionstext.split("\n")) + total_width = min(screen_width, max(options_width_max, nodetext_width_max)) + separator1 = sep * total_width + "\n\n" if nodetext_width_max else "" + separator2 = "\n" + sep * total_width + "\n\n" if total_width else "" + return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext
+ + +# ----------------------------------------------------------- +# +# List node (decorator turning a node into a list with +# look/edit/add functionality for the elements) +# +# ----------------------------------------------------------- + + +
[docs]def list_node(option_generator, select=None, pagesize=10): + """ + Decorator for making an EvMenu node into a multi-page list node. Will add new options, + prepending those options added in the node. + + Args: + option_generator (callable or list): A list of strings indicating the options, or a callable + that is called as option_generator(caller) to produce such a list. + select (callable or str, optional): Node to redirect a selection to. Its `**kwargs` will + contain the `available_choices` list and `selection` will hold one of the elements in + that list. If a callable, it will be called as + `select(caller, menuchoice, **kwargs)` where menuchoice is the chosen option as a + string and `available_choices` is a kwarg mapping the option keys to the choices + offered by the option_generator. The callable whould return the name of the target node + to goto after this selection (or None to repeat the list-node). Note that if this is not + given, the decorated node must itself provide a way to continue from the node! + pagesize (int): How many options to show per page. + + Example: + + ```python + def select(caller, selection, available_choices=None, **kwargs): + ''' + This will be called by all auto-generated options except any 'extra_options' + you return from the node (those you need to handle normally). + + Args: + caller (Object or Account): User of the menu. + selection (str): What caller chose in the menu + available_choices (list): The keys of elements available on the *current listing + page*. + **kwargs: Kwargs passed on from the node. + Returns: + tuple, str or None: A tuple (nextnodename, **kwargs) or just nextnodename. Return + `None` to go back to the listnode. + ''' + + # (do something with `selection` here) + + return "nextnode", **kwargs + + @list_node(['foo', 'bar'], select) + def node_index(caller): + text = "describing the list" + + # optional extra options in addition to the list-options + extra_options = [] + + return text, extra_options + + ``` + + Notes: + All normal `goto` callables returned from the decorated nodes + will, if they accept `**kwargs`, get a new kwarg 'available_choices' + injected. These are the ordered list of named options (descs) visible + on the current node page. + + """ + + def decorator(func): + def _select_parser(caller, raw_string, **kwargs): + """ + Parse the select action + + """ + available_choices = kwargs.pop("available_choices", []) + + try: + index = int(raw_string.strip()) - 1 + selection = available_choices[index] + except Exception: + caller.msg(_("|rInvalid choice.|n")) + else: + if callable(select): + try: + if bool(getfullargspec(select).varkw): + return select( + caller, selection, available_choices=available_choices, **kwargs + ) + else: + return select(caller, selection, **kwargs) + except Exception: + logger.log_trace( + "Error in EvMenu.list_node decorator:\n " + f"select-callable: {select}\n with args: ({caller}" + f"{selection}, {available_choices}, {kwargs}) raised " + "exception." + ) + elif select: + # we assume a string was given, we inject the result into the kwargs + # to pass on to the next node + kwargs["selection"] = selection + return str(select), kwargs + # this means the previous node will be re-run with these same kwargs + return None, kwargs + + def _list_node(caller, raw_string, **kwargs): + option_list = ( + option_generator(caller) if callable(option_generator) else option_generator + ) + + npages = 0 + page_index = 0 + page = [] + options = [] + + if option_list: + nall_options = len(option_list) + pages = [ + option_list[ind : ind + pagesize] for ind in range(0, nall_options, pagesize) + ] + npages = len(pages) + + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + page = pages[page_index] + + text = "" + extra_text = None + + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options.extend( + [ + {"desc": opt, "goto": (_select_parser, {"available_choices": page, **kwargs})} + for opt in page + ] + ) + + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + options.append( + { + "key": (_("|Wcurrent|n"), "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), + "goto": (lambda caller: None, {"optionpage_index": page_index}), + } + ) + if page_index > 0: + options.append( + { + "key": (_("|wp|Wrevious page|n"), "p"), + "goto": (lambda caller: None, {"optionpage_index": page_index - 1}), + } + ) + if page_index < npages - 1: + options.append( + { + "key": (_("|wn|Wext page|n"), "n"), + "goto": (lambda caller: None, {"optionpage_index": page_index + 1}), + } + ) + + # add data from the decorated node + + decorated_options = [] + supports_kwargs = bool(getfullargspec(func).varkw) + try: + if supports_kwargs: + text, decorated_options = func(caller, raw_string, **kwargs) + else: + text, decorated_options = func(caller, raw_string) + except TypeError: + try: + if supports_kwargs: + text, decorated_options = func(caller, **kwargs) + else: + text, decorated_options = func(caller) + except Exception: + raise + except Exception: + logger.log_trace() + else: + if isinstance(decorated_options, dict): + decorated_options = [decorated_options] + else: + decorated_options = make_iter(decorated_options) + + extra_options = [] + if isinstance(decorated_options, dict): + decorated_options = [decorated_options] + for eopt in decorated_options: + cback = ("goto" in eopt and "goto") or None + if cback: + signature = eopt[cback] + if callable(signature): + # callable with no kwargs defined + eopt[cback] = (signature, {"available_choices": page}) + elif is_iter(signature): + if len(signature) > 1 and isinstance(signature[1], dict): + signature[1]["available_choices"] = page + eopt[cback] = signature + elif signature: + # a callable alone in a tuple (i.e. no previous kwargs) + eopt[cback] = (signature[0], {"available_choices": page}) + else: + # malformed input. + logger.log_err( + "EvMenu @list_node decorator found " + "malformed option to decorate: {}".format(eopt) + ) + extra_options.append(eopt) + + options.extend(extra_options) + text = text + "\n\n" + extra_text if extra_text else text + + return text, options + + return _list_node + + return decorator
+ + +# ------------------------------------------------------------------------------------------------- +# +# Simple input shortcuts +# +# ------------------------------------------------------------------------------------------------- + + +
[docs]class CmdGetInput(Command): + """ + Enter your data and press return. + """ + + key = _CMD_NOMATCH + aliases = _CMD_NOINPUT + +
[docs] def func(self): + """This is called when user enters anything.""" + caller = self.caller + try: + getinput = caller.ndb._getinput + if not getinput and inherits_from(caller, evennia.DefaultObject): + getinput = caller.account.ndb._getinput + if getinput: + caller = caller.account + callback = getinput._callback + + caller.ndb._getinput._session = self.session + prompt = caller.ndb._getinput._prompt + args = caller.ndb._getinput._args + kwargs = caller.ndb._getinput._kwargs + result = self.raw_string.rstrip() # we strip the ending line break caused by sending + + ok = not callback(caller, prompt, result, *args, **kwargs) + if ok: + # only clear the state if the callback does not return + # anything + del caller.ndb._getinput + caller.cmdset.remove(InputCmdSet) + except Exception: + # make sure to clean up cmdset if something goes wrong + caller.msg("|rError in get_input. Choice not confirmed (report to admin)|n") + logger.log_trace("Error in get_input") + caller.cmdset.remove(InputCmdSet)
+ + +
[docs]class InputCmdSet(CmdSet): + """ + This stores the input command + """ + + key = "input_cmdset" + priority = 1 + mergetype = "Replace" + no_objs = True + no_exits = True + no_channels = False + +
[docs] def at_cmdset_creation(self): + """called once at creation""" + self.add(CmdGetInput())
+ + +class _Prompt: + """Dummy holder""" + + pass + + +
[docs]def get_input(caller, prompt, callback, session=None, *args, **kwargs): + """ + This is a helper function for easily request input from the caller. + + Args: + caller (Account or Object): The entity being asked the question. This + should usually be an object controlled by a user. + prompt (str): This text will be shown to the user, in order to let them + know their input is needed. + callback (callable): A function that will be called + when the user enters a reply. It must take three arguments: the + `caller`, the `prompt` text and the `result` of the input given by + the user. If the callback doesn't return anything or return False, + the input prompt will be cleaned up and exited. If returning True, + the prompt will remain and continue to accept input. + session (Session, optional): This allows to specify the + session to send the prompt to. It's usually only needed if `caller` + is an Account in multisession modes greater than 2. The session is + then updated by the command and is available (for example in + callbacks) through `caller.ndb.getinput._session`. + *args (any): Extra arguments to pass to `callback`. To utilise `*args` + (and `**kwargs`), a value for the `session` argument must also be + provided. + **kwargs (any): Extra kwargs to pass to `callback`. + + Raises: + RuntimeError: If the given callback is not callable. + + Notes: + The result value sent to the callback is raw and not processed in any + way. This means that you will get the ending line return character from + most types of client inputs. So make sure to strip that before doing a + comparison. + + When the prompt is running, a temporary object `caller.ndb._getinput` + is stored; this will be removed when the prompt finishes. + + If you need the specific Session of the caller (which may not be easy + to get if caller is an account in higher multisession modes), then it + is available in the callback through `caller.ndb._getinput._session`. + This is why the `session` is required as input. + + It's not recommended to 'chain' `get_input` into a sequence of + questions. This will result in the caller stacking ever more instances + of InputCmdSets. While they will all be cleared on concluding the + get_input chain, EvMenu should be considered for anything beyond a + single question. + + """ + if not callable(callback): + raise RuntimeError("get_input: input callback is not callable.") + caller.ndb._getinput = _Prompt() + caller.ndb._getinput._callback = callback + caller.ndb._getinput._prompt = prompt + caller.ndb._getinput._session = session + caller.ndb._getinput._args = args + caller.ndb._getinput._kwargs = kwargs + caller.cmdset.add(InputCmdSet, persistent=False) + caller.msg(prompt, session=session)
+ + +
[docs]class CmdYesNoQuestion(Command): + """ + Handle a prompt for yes or no. Press [return] for the default choice. + + """ + + key = _CMD_NOINPUT + aliases = [_CMD_NOMATCH, "yes", "no", "y", "n", "a", "abort"] + arg_regex = r"^$" + + def _clean(self, caller): + del caller.ndb._yes_no_question + if not caller.cmdset.has(YesNoQuestionCmdSet) and inherits_from( + caller, evennia.DefaultObject + ): + caller.account.cmdset.remove(YesNoQuestionCmdSet) + else: + caller.cmdset.remove(YesNoQuestionCmdSet) + +
[docs] def func(self): + """This is called when user enters anything.""" + caller = self.caller + try: + yes_no_question = caller.ndb._yes_no_question + if not yes_no_question and inherits_from(caller, evennia.DefaultObject): + yes_no_question = caller.account.ndb._yes_no_question + caller = caller.account + + if not yes_no_question: + self._clean(caller) + return + + inp = self.cmdname + + if inp == _CMD_NOINPUT: + raw = self.raw_cmdname.strip() + if not raw: + # use default + inp = yes_no_question.default + else: + inp = raw + + if inp in ("a", "abort") and yes_no_question.allow_abort: + caller.msg(_("Aborted.")) + self._clean(caller) + return + + caller.ndb._yes_no_question.session = self.session + + args = yes_no_question.args + kwargs = yes_no_question.kwargs + kwargs["caller_session"] = self.session + + if inp in ("yes", "y"): + yes_no_question.yes_callable(caller, *args, **kwargs) + elif inp in ("no", "n"): + yes_no_question.no_callable(caller, *args, **kwargs) + else: + # invalid input. Resend prompt without cleaning + caller.msg(yes_no_question.prompt, session=self.session) + return + + # cleanup + self._clean(caller) + except Exception: + # make sure to clean up cmdset if something goes wrong + caller.msg(_("|rError in ask_yes_no. Choice not confirmed (report to admin)|n")) + logger.log_trace("Error in ask_yes_no") + self._clean(caller) + raise
+ + +
[docs]class YesNoQuestionCmdSet(CmdSet): + """ + This stores the input command + """ + + key = "yes_no_question_cmdset" + priority = 1 + mergetype = "Replace" + no_objs = True + no_exits = True + no_channels = False + +
[docs] def at_cmdset_creation(self): + """called once at creation""" + self.add(CmdYesNoQuestion())
+ + +
[docs]def ask_yes_no( + caller, + prompt="Yes or No {options}?", + yes_action="Yes", + no_action="No", + default=None, + allow_abort=False, + session=None, + *args, + **kwargs, +): + """ + A helper function for asking a simple yes/no question. This will cause + the system to pause and wait for input from the player. + + Args: + caller (Object): The entity being asked. + prompt (str): The yes/no question to ask. This takes an optional formatting + marker `{options}` which will be filled with 'Y/N', '[Y]/N' or + 'Y/[N]' depending on the setting of `default`. If `allow_abort` is set, + then the 'A(bort)' option will also be available. + yes_action (callable or str): If a callable, this will be called + with `(caller, *args, **kwargs)` when the Yes-choice is made. + If a string, this string will be echoed back to the caller. + no_action (callable or str): If a callable, this will be called + with `(caller, *args, **kwargs)` when the No-choice is made. + If a string, this string will be echoed back to the caller. + default (str optional): This is what the user will get if they just press the + return key without giving any input. One of 'N', 'Y', 'A' or `None` + for no default (an explicit choice must be given). If 'A' (abort) + is given, `allow_abort` kwarg is ignored and assumed set. + allow_abort (bool, optional): If set, the 'A(bort)' option is available + (a third option meaning neither yes or no but just exits the prompt). + session (Session, optional): This allows to specify the + session to send the prompt to. It's usually only needed if `caller` + is an Account in multisession modes greater than 2. The session is + then updated by the command and is available (for example in + callbacks) through `caller.ndb._yes_no_question.session`. + *args: Additional arguments passed on into callables. + **kwargs: Additional keyword args passed on into callables. + + Raises: + RuntimeError, FooError: If default and `allow_abort` clashes. + + Example: + :: + + # just returning strings + ask_yes_no(caller, "Are you happy {options}?", + "you answered yes", "you answered no") + # trigger callables + ask_yes_no(caller, "Are you sad {options}?", + _callable_yes, _callable_no, allow_abort=True) + + """ + + def _callable_yes_txt(caller, *args, **kwargs): + yes_txt = kwargs["yes_txt"] + session = kwargs["caller_session"] + caller.msg(yes_txt, session=session) + + def _callable_no_txt(caller, *args, **kwargs): + no_txt = kwargs["no_txt"] + session = kwargs["caller_session"] + caller.msg(no_txt, session=session) + + if not callable(yes_action): + kwargs["yes_txt"] = str(yes_action) + yes_action = _callable_yes_txt + + if not callable(no_action): + kwargs["no_txt"] = str(no_action) + no_action = _callable_no_txt + + # prepare the prompt with options + options = "Y/N" + abort_txt = "/Abort" if allow_abort else "" + if default: + default = default.lower() + if default == "y": + options = "[Y]/N" + elif default == "n": + options = "Y/[N]" + elif default == "a": + allow_abort = True + abort_txt = "/[A]bort" + options += abort_txt + prompt = prompt.format(options=options) + + caller.ndb._yes_no_question = _Prompt() + caller.ndb._yes_no_question.prompt = prompt + caller.ndb._yes_no_question.session = session + caller.ndb._yes_no_question.prompt = prompt + caller.ndb._yes_no_question.default = default + caller.ndb._yes_no_question.allow_abort = allow_abort + caller.ndb._yes_no_question.yes_callable = yes_action + caller.ndb._yes_no_question.no_callable = no_action + caller.ndb._yes_no_question.args = args + caller.ndb._yes_no_question.kwargs = kwargs + + caller.cmdset.add(YesNoQuestionCmdSet) + caller.msg(prompt, session=session)
+ + +# ------------------------------------------------------------- +# +# Menu generation from menu template string +# +# ------------------------------------------------------------- + +_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]+?)\)|\(\))", 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 _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") + 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 ({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} + return goto, {"generated_nodename": goto} + + +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"] + return _process_callable(caller, goto, goto_callables, raw_string, current_nodename, 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(): + if fnmatch(raw_string.lower(), pattern): + 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): + 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 + + +
[docs]def parse_menu_template(caller, menu_template, goto_callables=None): + """ + Parse menu-template string. The main function of the EvMenu templating system. + + 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. + + Returns: + 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): + """ + 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)] + + # 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: + # 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, + { + "evmenu_goto": goto, + "evmenu_current_nodename": nodename, + "evmenu_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, + { + "evmenu_gotomap": inputparsemap, + "evmenu_current_nodename": nodename, + "evmenu_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._evmenu_template_contents = content_map + + return nodetree + + return _parse(caller, menu_template, goto_callables)
+ + +
[docs]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. 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. + 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. + + Returns: + EvMenu: The generated EvMenu. + + """ + goto_callables = goto_callables or {} + menu_tree = parse_menu_template(caller, menu_template, goto_callables) + return EvMenu( + caller, + menu_tree, + persistent=persistent, + **kwargs, + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/evmore.html b/docs/latest/_modules/evennia/utils/evmore.html new file mode 100644 index 0000000000..424d7c12f0 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/evmore.html @@ -0,0 +1,670 @@ + + + + + + + + evennia.utils.evmore — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.evmore

+# -*- coding: utf-8 -*-
+"""
+EvMore - pager mechanism
+
+This is a pager for displaying long texts and allows stepping up and down in
+the text (the name comes from the traditional 'more' unix command).
+
+To use, simply pass the text through the EvMore object:
+
+
+```python
+
+    from evennia.utils.evmore import EvMore
+
+    text = some_long_text_output()
+    EvMore(caller, text, always_page=False, session=None, justify_kwargs=None, **kwargs)
+```
+
+One can also use the convenience function `msg` from this module to avoid
+having to set up the `EvMenu` object manually:
+
+```python
+
+    from evennia.utils import evmore
+
+    text = some_long_text_output()
+    evmore.msg(caller, text, always_page=False, session=None, justify_kwargs=None, **kwargs)
+```
+
+The `always_page` argument  decides if the pager is used also if the text is not long
+enough to need to scroll, `session` is used to determine which session to relay
+to and `justify_kwargs` are kwargs to pass to utils.utils.justify in order to
+change the formatting of the text. The remaining `**kwargs` will be passed on to
+the `caller.msg()` construct every time the page is updated.
+
+----
+
+"""
+import evennia
+from django.conf import settings
+from django.core.paginator import Paginator
+from django.db.models.query import QuerySet
+from django.utils.translation import gettext as _
+from evennia.commands import cmdhandler
+from evennia.commands.cmdset import CmdSet
+from evennia.commands.command import Command
+from evennia.utils.ansi import ANSIString
+from evennia.utils.utils import dedent, inherits_from, justify, make_iter
+
+_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
+
+# we need to use NAWS for this
+_SCREEN_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+_SCREEN_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT
+
+_EVTABLE = None
+
+_LBR = ANSIString("\n")
+
+# text
+
+_DISPLAY = """{text}
+|n(|wPage|n [{pageno}/{pagemax}] |wn|next|n || |wp|nrevious || |wt|nop || |we|nnd || |wq|nuit)"""
+
+
+
[docs]class CmdMore(Command): + """ + Manipulate the text paging. Catch no-input with aliases. + """ + + key = _CMD_NOINPUT + aliases = ["quit", "q", "abort", "a", "next", "n", "previous", "p", "top", "t", "end", "e"] + auto_help = False + +
[docs] def func(self): + """ + Implement the command + """ + more = self.caller.ndb._more + if not more and inherits_from(self.caller, evennia.DefaultObject): + more = self.caller.account.ndb._more + if not more: + self.caller.msg("Error in loading the pager. Contact an admin.") + return + + cmd = self.cmdstring + + if cmd in ("abort", "a", "q"): + more.page_quit() + elif cmd in ("previous", "p"): + more.page_back() + elif cmd in ("top", "t", "look", "l"): + more.page_top() + elif cmd in ("end", "e"): + more.page_end() + else: + # return or n, next + more.page_next()
+ + +
[docs]class CmdMoreExit(Command): + """ + Any non-more command will exit the pager. + + """ + + key = _CMD_NOMATCH + +
[docs] def func(self): + """ + Exit pager and re-fire the failed command. + + """ + more = self.caller.ndb._more + more.page_quit() + + # re-fire the command (in new cmdset) + self.caller.execute_cmd(self.raw_string)
+ + +
[docs]class CmdSetMore(CmdSet): + """ + Stores the more command + """ + + key = "more_commands" + priority = 110 + mergetype = "Replace" + +
[docs] def at_cmdset_creation(self): + self.add(CmdMore()) + self.add(CmdMoreExit())
+ + +# resources for handling queryset inputs +
[docs]def queryset_maxsize(qs): + return qs.count()
+ + +
[docs]class EvMore(object): + """ + The main pager object + """ + +
[docs] def __init__( + self, + caller, + inp, + always_page=False, + session=None, + justify=False, + justify_kwargs=None, + exit_on_lastpage=False, + exit_cmd=None, + page_formatter=str, + **kwargs, + ): + """ + Initialization of the EvMore pager. + + Args: + caller (Object or Account): Entity reading the text. + inp (str, EvTable, Paginator or iterator): The text or data to put under paging. + + - If a string, paginage normally. If this text contains + one or more `\\\\f` format symbol, automatic pagination and justification + are force-disabled and page-breaks will only happen after each `\\\\f`. + - If `EvTable`, the EvTable will be paginated with the same + setting on each page if it is too long. The table + decorations will be considered in the size of the page. + - Otherwise `inp` is converted to an iterator, where each step is + expected to be a line in the final display. Each line + will be run through `iter_callable`. + + always_page (bool, optional): If `False`, the + pager will only kick in if `inp` is too big + to fit the screen. + session (Session, optional): If given, this session will be used + to determine the screen width and will receive all output. + justify (bool, optional): If set, auto-justify long lines. This must be turned + off for fixed-width or formatted output, like tables. It's force-disabled + if `inp` is an EvTable. + justify_kwargs (dict, optional): Keywords for the justify function. Used only + if `justify` is True. If this is not set, default arguments will be used. + exit_on_lastpage (bool, optional): If reaching the last page without the + page being completely filled, exit pager immediately. If unset, + another move forward is required to exit. If set, the pager + exit message will not be shown. + exit_cmd (str, optional): If given, this command-string will be executed on + the caller when the more page exits. Note that this will be using whatever + cmdset the user had *before* the evmore pager was activated (so none of + the evmore commands will be available when this is run). + kwargs (any, optional): These will be passed on to the `caller.msg` method. Notably, + one can pass additional outputfuncs this way. There is one special kwarg: + - `text_kwargs` - extra kwargs to pass with the text outputfunc, e.g. + `text_kwargs={"type": "help"}` would result to each page being sent + to `msg` as `text=(pagetxt, {"type": "help"})`. + + Examples: + + ```python + super_long_text = " ... " + EvMore(caller, super_long_text) + ``` + Paginator + ```python + from django.core.paginator import Paginator + query = ObjectDB.objects.all() + pages = Paginator(query, 10) # 10 objs per page + EvMore(caller, pages) + ``` + Every page an EvTable + ```python + from evennia import EvTable + def _to_evtable(page): + table = ... # convert page to a table + return EvTable(*headers, table=table, ...) + EvMore(caller, pages, page_formatter=_to_evtable) + ``` + + """ + self._caller = caller + self._always_page = always_page + + if not session: + # if not supplied, use the first session to + # determine screen size + sessions = caller.sessions.get() + if not sessions: + return + session = sessions[0] + self._session = session + + self._justify = justify + self._justify_kwargs = justify_kwargs + self.exit_on_lastpage = exit_on_lastpage + self.exit_cmd = exit_cmd + self._exit_msg = _("|xExited pager.|n") + + self._text_kwargs = kwargs.pop("text_kwargs", {}) + + self._kwargs = kwargs + + self._data = None + + self._pages = [] + self._npos = 0 + + self._npages = 1 + self._paginator = self.paginator_index + self._page_formatter = str + + # set up individual pages for different sessions + height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4) + self.width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0] + # always limit number of chars to 10 000 per page + self.height = min(10000 // max(1, self.width), height) + + # does initial parsing of input + self.init_pages(inp) + + # kick things into gear + self.start()
+ + # EvMore functional methods + +
[docs] def display(self, show_footer=True): + """ + Pretty-print the page. + """ + pos = 0 + text = "[no content]" + if self._npages > 0: + pos = self._npos + text = self.page_formatter(self.paginator(pos)) + if show_footer: + page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages) + else: + page = text + # check to make sure our session is still valid + sessions = self._caller.sessions.get() + if not sessions: + self.page_quit() + return + # this must be an 'is' check, not an == check + if not any(ses for ses in sessions if self._session is ses): + self._session = sessions[0] + text_outputfunc = (page, (), self._text_kwargs) + self._caller.msg(text=text_outputfunc, session=self._session, **self._kwargs)
+ +
[docs] def page_top(self): + """ + Display the top page + """ + self._npos = 0 + self.display()
+ +
[docs] def page_end(self): + """ + Display the bottom page. + """ + self._npos = self._npages - 1 + self.display()
+ +
[docs] def page_next(self): + """ + Scroll the text to the next page. Quit if already at the end + of the page. + """ + if self._npos >= self._npages - 1: + # exit if we are already at the end + self.page_quit() + else: + self._npos += 1 + if self.exit_on_lastpage and self._npos >= (self._npages - 1): + self.display(show_footer=False) + self.page_quit(quiet=True) + else: + self.display()
+ +
[docs] def page_back(self): + """ + Scroll the text back up, at the most to the top. + """ + self._npos = max(0, self._npos - 1) + self.display()
+ +
[docs] def page_quit(self, quiet=False): + """ + Quit the pager + """ + del self._caller.ndb._more + if not quiet: + self._caller.msg(text=self._exit_msg, **self._kwargs) + self._caller.cmdset.remove(CmdSetMore) + if self.exit_cmd: + self._caller.execute_cmd(self.exit_cmd, session=self._session)
+ +
[docs] def start(self): + """ + Starts the pagination + """ + if self._npages <= 1 and not self._always_page: + # no need for paging; just pass-through. + self.display(show_footer=False) + else: + # go into paging mode + # first pass on the msg kwargs + self._caller.ndb._more = self + self._caller.cmdset.add(CmdSetMore) + + # goto top of the text + self.page_top()
+ + # default paginators - responsible for extracting a specific page number + +
[docs] def paginator_index(self, pageno): + """Paginate to specific, known index""" + return self._data[pageno]
+ +
[docs] def paginator_slice(self, pageno): + """ + Paginate by slice. This is done with an eye on memory efficiency (usually for + querysets); to avoid fetching all objects at the same time. + + """ + return self._data[pageno * self.height : pageno * self.height + self.height]
+ +
[docs] def paginator_django(self, pageno): + """ + Paginate using the django queryset Paginator API. Note that his is indexed from 1. + """ + return self._data.page(pageno + 1)
+ + # default helpers to set up particular input types + +
[docs] def init_evtable(self, table): + """The input is an EvTable.""" + if table.height: + # enforced height of each paged table, plus space for evmore extras + self.height = table.height - 4 + + # convert table to string + text = str(table) + self._justify = False + self._justify_kwargs = None # enforce + self.init_str(text)
+ +
[docs] def init_queryset(self, qs): + """The input is a queryset""" + nsize = qs.count() # we assume each will be a line + self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) + self._data = qs
+ +
[docs] def init_django_paginator(self, pages): + """ + The input is a django Paginator object. + """ + self._npages = pages.num_pages + self._data = pages
+ +
[docs] def init_iterable(self, inp): + """The input is something other than a string - convert to iterable of strings""" + inp = make_iter(inp) + nsize = len(inp) + self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) + self._data = inp
+ +
[docs] def init_f_str(self, text): + """ + The input contains `\\f` markers. We use `\\f` to indicate the user wants to + enforce their line breaks on their own. If so, we do no automatic + line-breaking/justification at all. + + Args: + text (str): The string to format with f-markers. + + """ + self._data = text.split("\f") + self._npages = len(self._data)
+ +
[docs] def init_str(self, text): + """The input is a string""" + + if self._justify: + # we must break very long lines into multiple ones. Note that this + # will also remove spurious whitespace. + justify_kwargs = self._justify_kwargs or {} + width = self._justify_kwargs.get("width", self.width) + justify_kwargs["width"] = width + justify_kwargs["align"] = self._justify_kwargs.get("align", "l") + justify_kwargs["indent"] = self._justify_kwargs.get("indent", 0) + + lines = [] + for line in text.split("\n"): + if len(line) > width: + lines.extend(justify(line, **justify_kwargs).split("\n")) + else: + lines.append(line) + else: + # no justification. Simple division by line + lines = text.split("\n") + + # note: If joining on ANSIString here, we will parse out || escapes into | too early, + # meaning the protocol will later parse into color; better to leave things and only parse + # once. + self._data = [ + "\n".join(lines[i : i + self.height]) for i in range(0, len(lines), self.height) + ] + self._npages = len(self._data)
+ + # Hooks for customizing input handling and formatting (override in a child class) + +
[docs] def init_pages(self, inp): + """ + Initialize the pagination. By default, will analyze input type to determine + how pagination automatically. + + Args: + inp (any): Incoming data to be paginated. By default, handles pagination of + strings, querysets, django.Paginator, EvTables and any iterables with strings. + + Notes: + If overridden, this method must perform the following actions: + + - read and re-store `self._data` (the incoming data set) if needed for pagination to + work. + - set `self._npages` to the total number of pages. Default is 1. + - set `self._paginator` to a callable that will take a page number 1...N and return + the data to display on that page (not any decorations or next/prev buttons). If only + wanting to change the paginator, override `self.paginator` instead. + - set `self._page_formatter` to a callable that will receive the page from + `self._paginator` and format it with one element per line. Default is `str`. Or + override `self.page_formatter` directly instead. + + By default, helper methods are called that perform these actions + depending on supported inputs. + + """ + if inherits_from(inp, "evennia.utils.evtable.EvTable"): + # an EvTable + self.init_evtable(inp) + self._paginator = self.paginator_index + elif isinstance(inp, QuerySet): + # a queryset + self.init_queryset(inp) + self._paginator = self.paginator_slice + elif isinstance(inp, Paginator): + self.init_django_paginator(inp) + self._paginator = self.paginator_django + elif not isinstance(inp, str): + # anything else not a str + self.init_iterable(inp) + self._paginator = self.paginator_slice + elif "\f" in inp: + # string with \f line-break markers in it + self.init_f_str(inp) + self._paginator = self.paginator_index + else: + # a string + self.init_str(inp) + self._paginator = self.paginator_index
+ +
[docs] def paginator(self, pageno): + """ + Paginator. The data operated upon is in `self._data`. + + Args: + pageno (int): The page number to view, from 0...N-1 + Returns: + str: The page to display (without any decorations, those are added + by EvMore). + + """ + return self._paginator(pageno)
+ +
[docs] def page_formatter(self, page): + """ + Page formatter. Every page passes through this method. Override + it to customize behavior per-page. A common use is to generate a new + EvTable for every page (this is more efficient than to generate one huge + EvTable across many pages and feed it into EvMore all at once). + + Args: + page (any): A piece of data representing one page to display. This must + + Returns: + str: A ready-formatted page to display. Extra footer with help about + switching to the next/prev page will be added automatically + + """ + return self._page_formatter(page)
+ + +# helper function + + +
[docs]def msg( + caller, + text="", + always_page=False, + session=None, + justify=False, + justify_kwargs=None, + exit_on_lastpage=True, + **kwargs, +): + """ + EvMore-supported version of msg, mimicking the normal msg method. + + """ + EvMore( + caller, + text, + always_page=always_page, + session=session, + justify=justify, + justify_kwargs=justify_kwargs, + exit_on_lastpage=exit_on_lastpage, + **kwargs, + )
+ + +msg.__doc__ += dedent(EvMore.__init__.__doc__) +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/evtable.html b/docs/latest/_modules/evennia/utils/evtable.html new file mode 100644 index 0000000000..40b297fde0 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/evtable.html @@ -0,0 +1,1776 @@ + + + + + + + + evennia.utils.evtable — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.evtable

+"""
+This is an advanced ASCII table creator. It was inspired by Prettytable
+(https://code.google.com/p/prettytable/) but shares no code and is considerably
+more advanced, supporting auto-balancing of incomplete tables and ANSI colors among
+other things.
+
+Example usage:
+
+```python
+  from evennia.utils import evtable
+
+  table = evtable.EvTable("Heading1", "Heading2",
+                  table=[[1,2,3],[4,5,6],[7,8,9]], border="cells")
+  table.add_column("This is long data", "This is even longer data")
+  table.add_row("This is a single row")
+  print table
+```
+
+Result:
+
+::
+
+    +----------------------+----------+---+--------------------------+
+    |       Heading1       | Heading2 |   |                          |
+    +~~~~~~~~~~~~~~~~~~~~~~+~~~~~~~~~~+~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~+
+    |           1          |     4    | 7 |     This is long data    |
+    +----------------------+----------+---+--------------------------+
+    |           2          |     5    | 8 | This is even longer data |
+    +----------------------+----------+---+--------------------------+
+    |           3          |     6    | 9 |                          |
+    +----------------------+----------+---+--------------------------+
+    | This is a single row |          |   |                          |
+    +----------------------+----------+---+--------------------------+
+
+As seen, the table will automatically expand with empty cells to make
+the table symmetric. Tables can be restricted to a given width:
+
+```python
+  table.reformat(width=50, align="l")
+```
+
+(We could just have added these keywords to the table creation call)
+
+This yields the following result:
+
+::
+
+    +-----------+------------+-----------+-----------+
+    | Heading1  | Heading2   |           |           |
+    +~~~~~~~~~~~+~~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~~~~+
+    | 1         | 4          | 7         | This is   |
+    |           |            |           | long data |
+    +-----------+------------+-----------+-----------+
+    |           |            |           | This is   |
+    | 2         | 5          | 8         | even      |
+    |           |            |           | longer    |
+    |           |            |           | data      |
+    +-----------+------------+-----------+-----------+
+    | 3         | 6          | 9         |           |
+    +-----------+------------+-----------+-----------+
+    | This is a |            |           |           |
+    |  single   |            |           |           |
+    | row       |            |           |           |
+    +-----------+------------+-----------+-----------+
+
+
+Table-columns can be individually formatted. Note that if an
+individual column is set with a specific width, table auto-balancing
+will not affect this column (this may lead to the full table being too
+wide, so be careful mixing fixed-width columns with auto- balancing).
+Here we change the width and alignment of the column at index 3
+(Python starts from 0):
+
+```python
+
+table.reformat_column(3, width=30, align="r")
+print table
+```
+
+::
+
+    +-----------+-------+-----+-----------------------------+---------+
+    | Heading1  | Headi |     |                             |         |
+    |           | ng2   |     |                             |         |
+    +~~~~~~~~~~~+~~~~~~~+~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+~~~~~~~~~+
+    | 1         | 4     | 7   |           This is long data | Test1   |
+    +-----------+-------+-----+-----------------------------+---------+
+    | 2         | 5     | 8   |    This is even longer data | Test3   |
+    +-----------+-------+-----+-----------------------------+---------+
+    | 3         | 6     | 9   |                             | Test4   |
+    +-----------+-------+-----+-----------------------------+---------+
+    | This is a |       |     |                             |         |
+    |  single   |       |     |                             |         |
+    | row       |       |     |                             |         |
+    +-----------+-------+-----+-----------------------------+---------+
+
+When adding new rows/columns their data can have its own alignments
+(left/center/right, top/center/bottom).
+
+If the height is restricted, cells will be restricted from expanding
+vertically. This will lead to text contents being cropped. Each cell
+can only shrink to a minimum width and height of 1.
+
+`EvTable` is intended to be used with `ANSIString` for supporting ANSI-coloured
+string types.
+
+When a cell is auto-wrapped across multiple lines, ANSI-reset sequences will be
+put at the end of each wrapped line. This means that the colour of a wrapped
+cell will not "bleed", but it also means that eventual colour outside the table
+will not transfer "across" a table, you need to re-set the color to have it
+appear on both sides of the table string.
+
+----
+
+"""
+
+from copy import copy, deepcopy
+from textwrap import TextWrapper
+
+from django.conf import settings
+
+from evennia.utils.ansi import ANSIString
+from evennia.utils.utils import display_len as d_len
+from evennia.utils.utils import is_iter, justify
+
+_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+
+
+def _to_ansi(obj):
+    """
+    convert to ANSIString.
+
+    Args:
+        obj (str): Convert incoming text to
+            be ANSI aware ANSIStrings.
+    """
+    if is_iter(obj):
+        return [_to_ansi(o) for o in obj]
+    else:
+        return ANSIString(obj)
+
+
+_whitespace = "\t\n\x0b\x0c\r "
+
+
+
[docs]class ANSITextWrapper(TextWrapper): + """ + This is a wrapper work class for handling strings with ANSI tags + in it. It overloads the standard library `TextWrapper` class and + is used internally in `EvTable` and has no public methods. + + """ + + def _munge_whitespace(self, text): + """_munge_whitespace(text : string) -> string + + Munge whitespace in text: expand tabs and convert all other + whitespace characters to spaces. Eg. " foo\tbar\n\nbaz" + becomes " foo bar baz". + """ + return text + + # TODO: Ignore expand_tabs/replace_whitespace until ANSIString handles them. + # - don't remove this code. /Griatch + # if self.expand_tabs: + # text = text.expandtabs() + # if self.replace_whitespace: + # if isinstance(text, str): + # text = text.translate(self.whitespace_trans) + # return text + + def _split(self, text): + """_split(text : string) -> [string] + + Split the text to wrap into indivisible chunks. Chunks are + not quite the same as words; see _wrap_chunks() for full + details. As an example, the text + Look, goof-ball -- use the -b option! + breaks into the following chunks: + 'Look,', ' ', 'goof-', 'ball', ' ', '--', ' ', + 'use', ' ', 'the', ' ', '-b', ' ', 'option!' + if break_on_hyphens is True, or in: + 'Look,', ' ', 'goof-ball', ' ', '--', ' ', + 'use', ' ', 'the', ' ', '-b', ' ', option!' + otherwise. + """ + # NOTE-PYTHON3: The following code only roughly approximates what this + # function used to do. Regex splitting on ANSIStrings is + # dropping ANSI codes, so we're using ANSIString.split + # for the time being. + # + # A less hackier solution would be appreciated. + chunks = _to_ansi(text).split() + + chunks = [chunk + " " for chunk in chunks if chunk] # remove empty chunks + + if len(chunks) > 1: + chunks[-1] = chunks[-1][0:-1] + + return chunks + + def _wrap_chunks(self, chunks): + """_wrap_chunks(chunks : [string]) -> [string] + + Wrap a sequence of text chunks and return a list of lines of + length 'self.width' or less. (If 'break_long_words' is false, + some lines may be longer than this.) Chunks correspond roughly + to words and the whitespace between them: each chunk is + indivisible (modulo 'break_long_words'), but a line break can + come between any two chunks. Chunks should not have internal + whitespace; ie. a chunk is either all whitespace or a "word". + Whitespace chunks will be removed from the beginning and end of + lines, but apart from that whitespace is preserved. + """ + lines = [] + if self.width <= 0: + raise ValueError("invalid width %r (must be > 0)" % self.width) + + # Arrange in reverse order so items can be efficiently popped + # from a stack of chucks. + chunks.reverse() + + while chunks: + # Start the list of chunks that will make up the current line. + # cur_len is just the length of all the chunks in cur_line. + cur_line = [] + cur_len = 0 + + # Figure out which static string will prefix this line. + if lines: + indent = self.subsequent_indent + else: + indent = self.initial_indent + + # Maximum width for this line. + width = self.width - d_len(indent) + + # First chunk on line is whitespace -- drop it, unless this + # is the very beginning of the text (ie. no lines started yet). + if self.drop_whitespace and chunks[-1].strip() == "" and lines: + del chunks[-1] + + while chunks: + ln = d_len(chunks[-1]) + + # Can at least squeeze this chunk onto the current line. + if cur_len + ln <= width: + cur_line.append(chunks.pop()) + cur_len += ln + + # Nope, this line is full. + else: + break + + # The current line is full, and the next chunk is too big to + # fit on *any* line (not just this one). + if chunks and d_len(chunks[-1]) > width: + self._handle_long_word(chunks, cur_line, cur_len, width) + + # If the last chunk on this line is all whitespace, drop it. + if self.drop_whitespace and cur_line and cur_line[-1].strip() == "": + del cur_line[-1] + + # Convert current line back to a string and store it in list + # of all lines (return value). + if cur_line: + ln = "" + for w in cur_line: # ANSI fix + ln += w # + lines.append(indent + ln) + return lines
+ + +# -- Convenience interface --------------------------------------------- + + +
[docs]def wrap(text, width=_DEFAULT_WIDTH, **kwargs): + """ + Wrap a single paragraph of text, returning a list of wrapped lines. + + Reformat the single paragraph in 'text' so it fits in lines of no + more than 'width' columns, and return a list of wrapped lines. By + default, tabs in 'text' are expanded with string.expandtabs(), and + all other whitespace characters (including newline) are converted to + + Args: + text (str): Text to wrap. + width (int, optional): Width to wrap `text` to. + + Keyword Args: + See TextWrapper class for available keyword args to customize + wrapping behaviour. + + """ + w = ANSITextWrapper(width=width, **kwargs) + return w.wrap(text)
+ + +
[docs]def fill(text, width=_DEFAULT_WIDTH, **kwargs): + """Fill a single paragraph of text, returning a new string. + + Reformat the single paragraph in 'text' to fit in lines of no more + than 'width' columns, and return a new string containing the entire + wrapped paragraph. As with wrap(), tabs are expanded and other + whitespace characters converted to space. + + Args: + text (str): Text to fill. + width (int, optional): Width of fill area. + + Keyword Args: + See TextWrapper class for available keyword args to customize + filling behaviour. + + """ + w = ANSITextWrapper(width=width, **kwargs) + return w.fill(text)
+ + +# EvCell class (see further down for the EvTable itself) + + +
[docs]class EvCell: + """ + Holds a single data cell for the table. A cell has a certain width + and height and contains one or more lines of data. It can shrink + and resize as needed. + + """ + +
[docs] def __init__(self, data, **kwargs): + """ + Args: + data (str): The un-padded data of the entry. + + Keyword Args: + width (int): Desired width of cell. It will pad + to this size. + height (int): Desired height of cell. it will pad + to this size. + pad_width (int): General padding width. This can be overruled + by individual settings below. + pad_left (int): Number of extra pad characters on the left. + pad_right (int): Number of extra pad characters on the right. + pad_top (int): Number of extra pad lines top (will pad with `vpad_char`). + pad_bottom (int): Number of extra pad lines bottom (will pad with `vpad_char`). + pad_char (str)- pad character to use for padding. This is overruled + by individual settings below (default `" "`). + hpad_char (str): Pad character to use both for extra horizontal + padding (default `" "`). + vpad_char (str): Pad character to use for extra vertical padding + and for vertical fill (default `" "`). + fill_char (str): Character used to filling (expanding cells to + desired size). This can be overruled by individual settings below. + hfill_char (str): Character used for horizontal fill (default `" "`). + vfill_char (str): Character used for vertical fill (default `" "`). + align (str): Should be one of "l", "r", "c", "f" or "a" for left-, right-, center-, + full-justified (with space between words) or absolute (keep as much original + whitespace as possible). Default is left-aligned. + valign (str): Should be one of "t", "b" or "c" for top-, bottom and center + vertical alignment respectively. Default is centered. + border_width (int): General border width. This is overruled + by individual settings below. + border_left (int): Left border width. + border_right (int): Right border width. + border_top (int): Top border width. + border_bottom (int): Bottom border width. + border_char (str): This will use a single border char for all borders. + overruled by individual settings below. + border_left_char (str): Char used for left border. + border_right_char (str): Char used for right border. + border_top_char (str): Char used for top border. + border_bottom_char (str): Char user for bottom border. + corner_char (str): Character used when two borders cross. (default is ""). + This is overruled by individual settings below. + corner_top_left_char (str): Char used for "nw" corner. + corner_top_right_char (str): Char used for "ne" corner. + corner_bottom_left_char (str): Char used for "sw" corner. + corner_bottom_right_char (str): Char used for "se" corner. + crop_string (str): String to use when cropping sideways, default is `'[...]'`. + crop (bool): Crop contentof cell rather than expand vertically, default=`False`. + enforce_size (bool): If true, the width/height of the cell is + strictly enforced and extra text will be cropped rather than the + cell growing vertically. + + Raises: + Exception: for impossible cell size requirements where the + border width or height cannot fit, or the content is too + small. + + """ + self.formatted = None + padwidth = kwargs.get("pad_width", None) + padwidth = int(padwidth) if padwidth is not None else None + self.pad_left = int(kwargs.get("pad_left", padwidth if padwidth is not None else 1)) + self.pad_right = int(kwargs.get("pad_right", padwidth if padwidth is not None else 1)) + self.pad_top = int(kwargs.get("pad_top", padwidth if padwidth is not None else 0)) + self.pad_bottom = int(kwargs.get("pad_bottom", padwidth if padwidth is not None else 0)) + + self.enforce_size = kwargs.get("enforce_size", False) + + # avoid multi-char pad_chars messing up counting + pad_char = kwargs.get("pad_char", " ") + pad_char = pad_char[0] if pad_char else " " + hpad_char = kwargs.get("hpad_char", pad_char) + self.hpad_char = hpad_char[0] if hpad_char else pad_char + vpad_char = kwargs.get("vpad_char", pad_char) + self.vpad_char = vpad_char[0] if vpad_char else pad_char + + fill_char = kwargs.get("fill_char", " ") + fill_char = fill_char[0] if fill_char else " " + hfill_char = kwargs.get("hfill_char", fill_char) + self.hfill_char = hfill_char[0] if hfill_char else " " + vfill_char = kwargs.get("vfill_char", fill_char) + self.vfill_char = vfill_char[0] if vfill_char else " " + + self.crop_string = kwargs.get("crop_string", "[...]") + + # borders and corners + borderwidth = kwargs.get("border_width", 0) + self.border_left = kwargs.get("border_left", borderwidth) + self.border_right = kwargs.get("border_right", borderwidth) + self.border_top = kwargs.get("border_top", borderwidth) + self.border_bottom = kwargs.get("border_bottom", borderwidth) + + borderchar = kwargs.get("border_char", None) + self.border_left_char = kwargs.get("border_left_char", borderchar if borderchar else "|") + self.border_right_char = kwargs.get( + "border_right_char", borderchar if borderchar else self.border_left_char + ) + self.border_top_char = kwargs.get("border_top_char", borderchar if borderchar else "-") + self.border_bottom_char = kwargs.get( + "border_bottom_char", borderchar if borderchar else self.border_top_char + ) + + corner_char = kwargs.get("corner_char", "+") + self.corner_top_left_char = kwargs.get("corner_top_left_char", corner_char) + self.corner_top_right_char = kwargs.get("corner_top_right_char", corner_char) + self.corner_bottom_left_char = kwargs.get("corner_bottom_left_char", corner_char) + self.corner_bottom_right_char = kwargs.get("corner_bottom_right_char", corner_char) + + # alignments + self.align = kwargs.get("align", "l") + self.valign = kwargs.get("valign", "c") + + self.data = self._split_lines(_to_ansi(data)) + self.raw_width = max(d_len(line) for line in self.data) + self.raw_height = len(self.data) + + # this is extra trimming required for cels in the middle of a table only + self.trim_horizontal = 0 + self.trim_vertical = 0 + + # width/height is given without left/right or top/bottom padding + if "width" in kwargs: + width = kwargs.pop("width") + self.width = ( + width - self.pad_left - self.pad_right - self.border_left - self.border_right + ) + if self.width <= 0 < self.raw_width: + raise Exception("Cell width too small - no space for data.") + else: + self.width = self.raw_width + if "height" in kwargs: + height = kwargs.pop("height") + self.height = ( + height - self.pad_top - self.pad_bottom - self.border_top - self.border_bottom + ) + if self.height <= 0 < self.raw_height: + raise Exception("Cell height too small - no space for data.") + else: + self.height = self.raw_height
+ + def _reformat(self): + """ + Apply all EvCells' formatting operations. + + """ + data = self._border(self._pad(self._valign(self._align(self._fit_width(self.data))))) + return data + + def _split_lines(self, text): + """ + Simply split by linebreaks + + Args: + text (str): text to split. + + Returns: + split (list): split text. + """ + return text.split("\n") + + def _fit_width(self, data): + """ + Split too-long lines to fit the desired width of the Cell. + + Args: + data (str): Text to adjust to the cell's width. + + Returns: + adjusted data (str): The adjusted text. + + Notes: + This also updates `raw_width`. + + + """ + width = self.width + adjusted_data = [] + for line in data: + if 0 < width < d_len(line): + # replace_whitespace=False, expand_tabs=False is a + # fix for ANSIString not supporting expand_tabs/translate + adjusted_data.extend( + [ + ANSIString(part + ANSIString("|n")) + for part in wrap(line, width=width, drop_whitespace=False) + ] + ) + else: + adjusted_data.append(line) + if self.enforce_size: + # don't allow too high cells + excess = len(adjusted_data) - self.height + if excess > 0: + # too many lines. Crop and mark last line with crop_string + crop_string = self.crop_string + adjusted_data = adjusted_data[:-excess] + adjusted_data_length = len(adjusted_data[-1]) + crop_string_length = len(crop_string) + if adjusted_data_length >= crop_string_length: + # replace with data[...] + # (note that if adjusted data is shorter than the crop-string, + # we skip the crop-string and just pass the cropped data.) + adjusted_data[-1] = adjusted_data[-1][:-crop_string_length] + crop_string + + elif excess < 0: + # too few lines. Fill to height. + adjusted_data.extend(["" for _ in range(excess)]) + + return adjusted_data + + def _align(self, data): + """ + Align list of rows of cell. Whitespace characters will be stripped + if there is only one whitespace character - otherwise, it's assumed + the caller may be trying some manual formatting in the text. + + Args: + data (str): Text to align. + + Returns: + text (str): Aligned result. + + """ + align = self.align + hfill_char = self.hfill_char + width = self.width + return [justify(line, width, align=align, fillchar=hfill_char) for line in data] + + def _valign(self, data): + """ + Align cell vertically + + Args: + data (str): Text to align. + + Returns: + text (str): Vertically aligned text. + + """ + valign = self.valign + height = self.height + cheight = len(data) + excess = height - cheight + padline = self.vfill_char * self.width + + if excess <= 0: + return data + # only care if we need to add new lines + if valign == "t": + return data + [padline for _ in range(excess)] + elif valign == "b": + return [padline for _ in range(excess)] + data + else: # center + narrowside = [padline for _ in range(excess // 2)] + widerside = narrowside + [padline] + if excess % 2: + # uneven padding + if height % 2: + return widerside + data + narrowside + else: + return narrowside + data + widerside + else: + # even padding, same on both sides + return narrowside + data + narrowside + + def _pad(self, data): + """ + Pad data with extra characters on all sides. + + Args: + data (str): Text to pad. + + Returns: + text (str): Padded text. + + """ + left = self.hpad_char * self.pad_left + right = self.hpad_char * self.pad_right + vfill = (self.width + self.pad_left + self.pad_right) * self.vpad_char + top = [vfill for _ in range(self.pad_top)] + bottom = [vfill for _ in range(self.pad_bottom)] + return top + [left + line + right for line in data] + bottom + + def _border(self, data): + """ + Add borders to the cell. + + Args: + data (str): Text to surround with borders. + + Return: + text (str): Text with borders. + + """ + + left = self.border_left_char * self.border_left + ANSIString("|n") + right = ANSIString("|n") + self.border_right_char * self.border_right + + cwidth = ( + self.width + + self.pad_left + + self.pad_right + + max(0, self.border_left - 1) + + max(0, self.border_right - 1) + ) + + vfill = self.corner_top_left_char if left else "" + vfill += cwidth * self.border_top_char + vfill += self.corner_top_right_char if right else "" + top = [vfill for _ in range(self.border_top)] + + vfill = self.corner_bottom_left_char if left else "" + vfill += cwidth * self.border_bottom_char + vfill += self.corner_bottom_right_char if right else "" + bottom = [vfill for _ in range(self.border_bottom)] + + return top + [left + line + right for line in data] + bottom + +
[docs] def get_min_height(self): + """ + Get the minimum possible height of cell, including at least + one line for data. + + Returns: + min_height (int): The mininum height of cell. + + """ + return self.pad_top + self.pad_bottom + self.border_bottom + self.border_top + 1
+ +
[docs] def get_min_width(self): + """ + Get the minimum possible width of cell, including at least one + character-width for data. + + Returns: + min_width (int): The minimum width of cell. + + """ + return self.pad_left + self.pad_right + self.border_left + self.border_right + 1
+ +
[docs] def get_height(self): + """ + Get natural height of cell, including padding. + + Returns: + natural_height (int): Height of cell. + + """ + return len(self.formatted) # if self.formatted else 0
+ +
[docs] def get_width(self): + """ + Get natural width of cell, including padding. + + Returns: + natural_width (int): Width of cell. + + """ + return d_len(self.formatted[0]) # if self.formatted else 0
+ +
[docs] def replace_data(self, data, **kwargs): + """ + Replace cell data. This causes a full reformat of the cell. + + Args: + data (str): Cell data. + + Notes: + The available keyword arguments are the same as for + `EvCell.__init__`. + + """ + self.data = self._split_lines(_to_ansi(data)) + self.raw_width = max(d_len(line) for line in self.data) + self.raw_height = len(self.data) + self.reformat(**kwargs)
+ +
[docs] def reformat(self, **kwargs): + """ + Reformat the EvCell with new options + + Keyword Args: + The available keyword arguments are the same as for `EvCell.__init__`. + + Raises: + Exception: If the cells cannot shrink enough to accomodate + the options or the data given. + + """ + # keywords that require manipulation + padwidth = kwargs.get("pad_width", None) + padwidth = int(padwidth) if padwidth is not None else None + self.pad_left = int( + kwargs.pop("pad_left", padwidth if padwidth is not None else self.pad_left) + ) + self.pad_right = int( + kwargs.pop("pad_right", padwidth if padwidth is not None else self.pad_right) + ) + self.pad_top = int( + kwargs.pop("pad_top", padwidth if padwidth is not None else self.pad_top) + ) + self.pad_bottom = int( + kwargs.pop("pad_bottom", padwidth if padwidth is not None else self.pad_bottom) + ) + + self.enforce_size = kwargs.get("enforce_size", False) + + pad_char = kwargs.pop("pad_char", None) + hpad_char = kwargs.pop("hpad_char", pad_char) + self.hpad_char = hpad_char[0] if hpad_char else self.hpad_char + vpad_char = kwargs.pop("vpad_char", pad_char) + self.vpad_char = vpad_char[0] if vpad_char else self.vpad_char + + fillchar = kwargs.pop("fill_char", None) + hfill_char = kwargs.pop("hfill_char", fillchar) + self.hfill_char = hfill_char[0] if hfill_char else self.hfill_char + vfill_char = kwargs.pop("vfill_char", fillchar) + self.vfill_char = vfill_char[0] if vfill_char else self.vfill_char + + borderwidth = kwargs.get("border_width", None) + self.border_left = kwargs.pop( + "border_left", borderwidth if borderwidth is not None else self.border_left + ) + self.border_right = kwargs.pop( + "border_right", borderwidth if borderwidth is not None else self.border_right + ) + self.border_top = kwargs.pop( + "border_top", borderwidth if borderwidth is not None else self.border_top + ) + self.border_bottom = kwargs.pop( + "border_bottom", borderwidth if borderwidth is not None else self.border_bottom + ) + + borderchar = kwargs.get("border_char", None) + self.border_left_char = kwargs.pop( + "border_left_char", borderchar if borderchar else self.border_left_char + ) + self.border_right_char = kwargs.pop( + "border_right_char", borderchar if borderchar else self.border_right_char + ) + self.border_top_char = kwargs.pop( + "border_topchar", borderchar if borderchar else self.border_top_char + ) + self.border_bottom_char = kwargs.pop( + "border_bottom_char", borderchar if borderchar else self.border_bottom_char + ) + + corner_char = kwargs.get("corner_char", None) + self.corner_top_left_char = kwargs.pop( + "corner_top_left", corner_char if corner_char is not None else self.corner_top_left_char + ) + self.corner_top_right_char = kwargs.pop( + "corner_top_right", + corner_char if corner_char is not None else self.corner_top_right_char, + ) + self.corner_bottom_left_char = kwargs.pop( + "corner_bottom_left", + corner_char if corner_char is not None else self.corner_bottom_left_char, + ) + self.corner_bottom_right_char = kwargs.pop( + "corner_bottom_right", + corner_char if corner_char is not None else self.corner_bottom_right_char, + ) + + # this is used by the table to adjust size of cells with borders in the middle + # of the table + self.trim_horizontal = kwargs.pop("trim_horizontal", self.trim_horizontal) + self.trim_vertical = kwargs.pop("trim_vertical", self.trim_vertical) + + # fill all other properties + for key, value in kwargs.items(): + setattr(self, key, value) + + # Handle sizes + if "width" in kwargs: + width = kwargs.pop("width") + self.width = ( + width + - self.pad_left + - self.pad_right + - self.border_left + - self.border_right + + self.trim_horizontal + ) + # if self.width <= 0 and self.raw_width > 0: + if self.width <= 0 < self.raw_width: + raise Exception("Cell width too small, no room for data.") + if "height" in kwargs: + height = kwargs.pop("height") + self.height = ( + height + - self.pad_top + - self.pad_bottom + - self.border_top + - self.border_bottom + + self.trim_vertical + ) + if self.height <= 0 < self.raw_height: + raise Exception("Cell height too small, no room for data.") + + # reformat (to new sizes, padding, header and borders) + self.formatted = self._reformat()
+ +
[docs] def get(self): + """ + Get data, padded and aligned in the form of a list of lines. + + """ + if not self.formatted: + self.formatted = self._reformat() + return self.formatted
+ + def __repr__(self): + if not self.formatted: + self.formatted = self._reformat() + return str(ANSIString("<EvCel %s>" % self.formatted)) + + def __str__(self): + "returns cell contents on string form" + if not self.formatted: + self.formatted = self._reformat() + return str(ANSIString("\n").join(self.formatted))
+ + +# EvColumn class + + +
[docs]class EvColumn: + """ + This class holds a list of Cells to represent a column of a table. + It holds operations and settings that affect *all* cells in the + column. + + Columns are not intended to be used stand-alone; they should be + incorporated into an EvTable (like EvCells) + + """ + +
[docs] def __init__(self, *args, **kwargs): + """ + Args: + Text for each row in the column + + Keyword Args: + All `EvCell.__init_` keywords are available, these + settings will be persistently applied to every Cell in the + column. + + """ + self.options = kwargs # column-specific options + self.column = [EvCell(data, **kwargs) for data in args]
+ + def _balance(self, **kwargs): + """ + Make sure to adjust the width of all cells so we form a + coherent and lined-up column. Will enforce column-specific + options to cells. + + Keyword Args: + Extra keywords to modify the column setting. Same keywords + as in `EvCell.__init__`. + + """ + col = self.column + # fixed options for the column will override those requested in the call! + # this is particularly relevant to things like width/height, to avoid + # fixed-widths columns from being auto-balanced + kwargs.update(self.options) + # use fixed width or adjust to the largest cell + if "width" not in kwargs: + [ + cell.reformat() for cell in col + ] # this is necessary to get initial widths of all cells + kwargs["width"] = max(cell.get_width() for cell in col) if col else 0 + [cell.reformat(**kwargs) for cell in col] + +
[docs] def add_rows(self, *args, **kwargs): + """ + Add new cells to column. They will be inserted as + a series of rows. It will inherit the options + of the rest of the column's cells (use update to change + options). + + Args: + Texts for the new cells + ypos (int, optional): Index position in table before which to insert the + new column. Uses Python indexing, so to insert at the top, + use `ypos=0`. If not given, data will be inserted at the end + of the column. + + Keyword Args: + Available keywods as per `EvCell.__init__`. + + """ + # column-level options override those in kwargs + options = {**kwargs, **self.options} + + ypos = kwargs.get("ypos", None) + if ypos is None or ypos > len(self.column): + # add to the end + self.column.extend([EvCell(data, **options) for data in args]) + else: + # insert cells before given index + ypos = min(len(self.column) - 1, max(0, int(ypos))) + new_cells = [EvCell(data, **options) for data in args] + self.column = self.column[:ypos] + new_cells + self.column[ypos:]
+ # self._balance(**kwargs) + +
[docs] def reformat(self, **kwargs): + """ + Change the options for the column. + + Keyword Args: + Keywords as per `EvCell.__init__`. + + """ + self._balance(**kwargs)
+ +
[docs] def reformat_cell(self, index, **kwargs): + """ + reformat cell at given index, keeping column options if + necessary. + + Args: + index (int): Index location of the cell in the column, + starting from 0 for the first row to Nrows-1. + + Keyword Args: + Keywords as per `EvCell.__init__`. + + """ + # column-level options take precedence here + kwargs.update(self.options) + self.column[index].reformat(**kwargs)
+ + def __repr__(self): + return "<EvColumn\n %s>" % "\n ".join([repr(cell) for cell in self.column]) + + def __len__(self): + return len(self.column) + + def __iter__(self): + return iter(self.column) + + def __getitem__(self, index): + return self.column[index] + + def __setitem__(self, index, value): + self.column[index] = value + + def __delitem__(self, index): + del self.column[index]
+ + +# Main Evtable class + + +
[docs]class EvTable: + """ + The table class holds a list of EvColumns, each consisting of EvCells so + that the result is a 2D matrix. + """ + +
[docs] def __init__(self, *args, **kwargs): + """ + Args: + Header texts for the table. + + Keyword Args: + table (list of lists or list of `EvColumns`, optional): + This is used to build the table in a quick way. If not + given, the table will start out empty and `add_` methods + need to be used to add rows/columns. + header (bool, optional): `True`/`False` - turn off the + header texts (`*args`) being treated as a header (such as + not adding extra underlining) + pad_width (int, optional): How much empty space to pad your cells with + (default is 1) + border (str, optional)): The border style to use. This is one of + - `None` - No border drawing at all. + - "table" - only a border around the whole table. + - "tablecols" - table and column borders. (default) + - "header" - only border under header. + - "cols" - only vertical borders. + - "incols" - vertical borders, no outer edges. + - "rows" - only borders between rows. + - "cells" - border around all cells. + border_width (int, optional): Width of table borders, if border is active. + Note that widths wider than 1 may give artifacts in the corners. Default is 1. + corner_char (str, optional): Character to use in corners when border is active. + Default is `+`. + corner_top_left_char (str, optional): Character used for "nw" corner of table. + Defaults to `corner_char`. + corner_top_right_char (str, optional): Character used for "ne" corner of table. + Defaults to `corner_char`. + corner_bottom_left_char (str, optional): Character used for "sw" corner of table. + Defaults to `corner_char`. + corner_bottom_right_char (str, optional): Character used for "se" corner of table. + Defaults to `corner_char`. + pretty_corners (bool, optional): Use custom characters to + make the table corners look "rounded". Uses UTF-8 + characters. Defaults to `False` for maximum compatibility with various displays + that may occationally have issues with UTF-8 characters. + header_line_char (str, optional): Character to use for underlining + the header row (default is '~'). Requires `border` to not be `None`. + width (int, optional): Fixed width of table. If not set, + width is set by the total width of each column. This will + resize individual columns in the vertical direction to fit. + height (int, optional): Fixed height of table. Defaults to being unset. Width is + still given precedence. If given, table cells will crop text rather + than expand vertically. + evenwidth (bool, optional): Used with the `width` keyword. Adjusts columns to have as + even width as possible. This often looks best also for mixed-length tables. Default + is `False`. + maxwidth (int, optional): This will set a maximum width + of the table while allowing it to be smaller. Only if it grows wider than this + size will it be resized by expanding horizontally (or crop `height` is given). + This keyword has no meaning if `width` is set. + + Raises: + Exception: If given erroneous input or width settings for the data. + + Notes: + Beyond those table-specific keywords, the non-overlapping keywords + of `EvCell.__init__` are also available. These will be passed down + to every cell in the table. + + """ + # at this point table is a 2D grid - a list of columns + # x is the column position, y the row + table = kwargs.pop("table", []) + + # header is a list of texts. We merge it to the table's top + header = [_to_ansi(head) for head in args] + self.header = header != [] + if self.header: + if table: + excess = len(header) - len(table) + if excess > 0: + # header bigger than table + table.extend([] for _ in range(excess)) + elif excess < 0: + # too short header + header.extend(_to_ansi(["" for _ in range(abs(excess))])) + for ix, heading in enumerate(header): + table[ix].insert(0, heading) + else: + table = [[heading] for heading in header] + # even though we inserted the header, we can still turn off + # header border underling etc. We only allow this if a header + # was actually set + self.header = kwargs.pop("header", self.header) if self.header else False + hchar = kwargs.pop("header_line_char", "~") + self.header_line_char = hchar[0] if hchar else "~" + + border = kwargs.pop("border", "tablecols") + if border is None: + border = "none" + if border not in ( + "none", + "table", + "tablecols", + "header", + "incols", + "cols", + "rows", + "cells", + ): + raise Exception("Unsupported border type: '%s'" % border) + self.border = border + + # border settings are passed into Cell as well (so kwargs.get and not pop) + self.border_width = kwargs.get("border_width", 1) + self.corner_char = kwargs.get("corner_char", "+") + pcorners = kwargs.pop("pretty_corners", False) + self.corner_top_left_char = _to_ansi( + kwargs.pop("corner_top_left_char", "." if pcorners else self.corner_char) + ) + self.corner_top_right_char = _to_ansi( + kwargs.pop("corner_top_right_char", "." if pcorners else self.corner_char) + ) + self.corner_bottom_left_char = _to_ansi( + kwargs.pop("corner_bottom_left_char", " " if pcorners else self.corner_char) + ) + self.corner_bottom_right_char = _to_ansi( + kwargs.pop("corner_bottom_right_char", " " if pcorners else self.corner_char) + ) + + self.width = kwargs.pop("width", None) + self.height = kwargs.pop("height", None) + self.evenwidth = kwargs.pop("evenwidth", False) + self.maxwidth = kwargs.pop("maxwidth", None) + if self.maxwidth and self.width and self.maxwidth < self.width: + raise Exception("table maxwidth < table width!") + # size in cell cols/rows + self.ncols = len(table) + self.nrows = max(len(col) for col in table) if table else 0 + # size in characters (gets set when _balance is called) + self.nwidth = 0 + self.nheight = 0 + # save options + self.options = kwargs + + # use the temporary table to generate the table on the fly, as a list of EvColumns + self.table = [] + for col in table: + if isinstance(col, EvColumn): + self.add_column(col, **kwargs) + elif isinstance(col, (list, tuple)): + self.table.append(EvColumn(*col, **kwargs)) + else: + raise RuntimeError( + "EvTable 'table' kwarg must be a list of EvColumns or a list-of-lists of" + f" strings. Found {type(col)}." + ) + + # self.table = [EvColumn(*col, **kwargs) for col in table] + + # this is the actual working table + self.worktable = None
+ + # balance the table + # self._balance() + + def _cellborders(self, ix, iy, nx, ny, **kwargs): + """ + Adds borders to the table by adjusting the input kwarg to + instruct cells to build a border in the right positions. + + Args: + ix (int): x index positions in table. + iy (int): y index positions in table. + nx (int): x size of table. + ny (int): y size of table. + + Keyword Args: + Keywords as per `EvTable.__init__`. + + Returns: + table (str): string with the correct borders. + + Notes: + A copy of the kwarg is returned to the cell. This is method + is called by self._borders. + + """ + + ret = kwargs.copy() + + # handle the various border modes + border = self.border + header = self.header + + bwidth = self.border_width + headchar = self.header_line_char + + def corners(ret): + """Handle corners of table""" + if ix == 0 and iy == 0: + ret["corner_top_left_char"] = self.corner_top_left_char + if ix == nx and iy == 0: + ret["corner_top_right_char"] = self.corner_top_right_char + if ix == 0 and iy == ny: + ret["corner_bottom_left_char"] = self.corner_bottom_left_char + if ix == nx and iy == ny: + ret["corner_bottom_right_char"] = self.corner_bottom_right_char + return ret + + def left_edge(ret): + """add vertical border along left table edge""" + if ix == 0: + ret["border_left"] = bwidth + # ret["trim_horizontal"] = bwidth + return ret + + def top_edge(ret): + """add border along top table edge""" + if iy == 0: + ret["border_top"] = bwidth + # ret["trim_vertical"] = bwidth + return ret + + def right_edge(ret): + """add vertical border along right table edge""" + if ix == nx: # and 0 < iy < ny: + ret["border_right"] = bwidth + # ret["trim_horizontal"] = 0 + return ret + + def bottom_edge(ret): + """add border along bottom table edge""" + if iy == ny: + ret["border_bottom"] = bwidth + # ret["trim_vertical"] = bwidth + return ret + + def cols(ret): + """Adding vertical borders inside the table""" + if 0 <= ix < nx: + ret["border_right"] = bwidth + return ret + + def rows(ret): + """Adding horizontal borders inside the table""" + if 0 <= iy < ny: + ret["border_bottom"] = bwidth + return ret + + def head(ret): + """Add header underline""" + if iy == 0: + # put different bottom line for header + ret["border_bottom"] = bwidth + ret["border_bottom_char"] = headchar + return ret + + # use the helper functions to define various + # table "styles" + + if border in ("table", "tablecols", "cells"): + ret = bottom_edge(right_edge(top_edge(left_edge(corners(ret))))) + if border in ("cols", "tablecols", "cells"): + ret = cols(right_edge(left_edge(ret))) + if border in "incols": + ret = cols(ret) + if border in ("rows", "cells"): + ret = rows(bottom_edge(top_edge(ret))) + if header and border not in ("none", None): + ret = head(ret) + + return ret + + def _borders(self): + """ + Add borders to table. This is called from self._balance. + """ + nx, ny = self.ncols - 1, self.nrows - 1 + options = self.options + for ix, col in enumerate(self.worktable): + for iy, cell in enumerate(col): + col.reformat_cell(iy, **self._cellborders(ix, iy, nx, ny, **options)) + + def _balance(self): + """ + Balance the table. This means to make sure + all cells on the same row have the same height, + that all columns have the same number of rows + and that the table fits within the given width. + """ + + # we make all modifications on a working copy of the + # actual table. This allows us to add columns/rows + # and re-balance over and over without issue. + self.worktable = deepcopy(self.table) + # self._borders() + # return + options = copy(self.options) + + # balance number of rows to make a rectangular table + # column by column + ncols = len(self.worktable) + nrows = [len(col) for col in self.worktable] + nrowmax = max(nrows) if nrows else 0 + for icol, nrow in enumerate(nrows): + self.worktable[icol].reformat(**options) + if nrow < nrowmax: + # add more rows to too-short columns + empty_rows = ["" for _ in range(nrowmax - nrow)] + self.worktable[icol].add_rows(*empty_rows) + self.ncols = ncols + self.nrows = nrowmax + + # add borders - these add to the width/height, so we must do this before calculating + # width/height + self._borders() + + # equalize widths within each column + cwidths = [max(cell.get_width() for cell in col) for col in self.worktable] + + if self.width or self.maxwidth and self.maxwidth < sum(cwidths): + # we set a table width. Horizontal cells will be evenly distributed and + # expand vertically as needed (unless self.height is set, see below) + + # use fixed width, or set to maxwidth + width = self.width if self.width else self.maxwidth + + if ncols: + # get minimum possible cell widths for each row + cwidths_min = [max(cell.get_min_width() for cell in col) for col in self.worktable] + cwmin = sum(cwidths_min) + + # get which cols have separately set widths - these should be locked + # note that we need to remove cwidths_min for each lock to avoid counting + # it twice (in cwmin and in locked_cols) + locked_cols = { + icol: col.options["width"] - cwidths_min[icol] + for icol, col in enumerate(self.worktable) + if "width" in col.options + } + locked_width = sum(locked_cols.values()) + + excess = width - cwmin - locked_width + + if len(locked_cols) >= ncols and excess: + # we can't adjust the width at all - all columns are locked + raise Exception( + "Cannot balance table to width %s - " + "all columns have a set, fixed width summing to %s!" + % (self.width, sum(cwidths)) + ) + + if excess < 0: + # the locked cols makes it impossible + raise Exception( + "Cannot shrink table width to %s. " + "Minimum size (and/or fixed-width columns) " + "sets minimum at %s." % (self.width, cwmin + locked_width) + ) + + if self.evenwidth: + # make each column of equal width + # use cwidths as a work-array to track weights + cwidths = copy(cwidths_min) + correction = 0 + while correction < excess: + # flood-fill the minimum table starting with the smallest columns + ci = cwidths.index(min(cwidths)) + if ci in locked_cols: + # locked column, make sure it's not picked again + cwidths[ci] += 9999 + cwidths_min[ci] = locked_cols[ci] + else: + cwidths_min[ci] += 1 + correction += 1 + cwidths = cwidths_min + else: + # make each column expand more proportional to their data size + # we use cwidth as a work-array to track weights + correction = 0 + while correction < excess: + # fill wider columns first + ci = cwidths.index(max(cwidths)) + if ci in locked_cols: + # locked column, make sure it's not picked again + cwidths[ci] -= 9999 + cwidths_min[ci] = locked_cols[ci] + else: + cwidths_min[ci] += 1 + correction += 1 + # give a just changed col less prio next run + cwidths[ci] -= 3 + cwidths = cwidths_min + + # reformat worktable (for width align) + for ix, col in enumerate(self.worktable): + try: + col.reformat(width=cwidths[ix], **options) + except Exception: + raise + + # equalize heights for each row (we must do this here, since it may have changed to fit new + # widths) + cheights = [ + max(cell.get_height() for cell in (col[iy] for col in self.worktable)) + for iy in range(nrowmax) + ] + + if self.height: + # if we are fixing the table height, it means cells must crop text instead of resizing. + if nrowmax: + # get minimum possible cell heights for each column + cheights_min = [ + max(cell.get_min_height() for cell in (col[iy] for col in self.worktable)) + for iy in range(nrowmax) + ] + chmin = sum(cheights_min) + + # get which cols have separately set heights - these should be locked + # note that we need to remove cheights_min for each lock to avoid counting + # it twice (in chmin and in locked_cols) + locked_cols = { + icol: col.options["height"] - cheights_min[icol] + for icol, col in enumerate(self.worktable) + if "height" in col.options + } + locked_height = sum(locked_cols.values()) + + excess = self.height - chmin - locked_height + + if chmin > self.height: + # we cannot shrink any more + raise Exception( + "Cannot shrink table height to %s. Minimum " + "size (and/or fixed-height rows) sets minimum at %s." + % (self.height, chmin + locked_height) + ) + + # Add all the excess at the end of the table + # Note: Older solutions tried to balance individual + # rows' vsize. This could lead to empty rows that + # looked like a bug. This solution instead + # adds empty rows at the end which is less sophisticated + # but much more visually consistent. + cheights_min[-1] += excess + cheights = cheights_min + + # we must tell cells to crop instead of expanding + options["enforce_size"] = True + + # reformat table (for vertical align) + for ix, col in enumerate(self.worktable): + for iy, cell in enumerate(col): + try: + col.reformat_cell(iy, height=cheights[iy], **options) + except Exception as e: + msg = "ix=%s, iy=%s, height=%s: %s" % (ix, iy, cheights[iy], e.message) + raise Exception("Error in vertical align:\n %s" % msg) + + # calculate actual table width/height in characters + self.cwidth = sum(cwidths) + self.cheight = sum(cheights) + + def _generate_lines(self): + """ + Generates lines across all columns + (each cell may contain multiple lines) + This will also balance the table. + """ + self._balance() + for iy in range(self.nrows): + cell_row = [col[iy] for col in self.worktable] + # this produces a list of lists, each of equal length + cell_data = [cell.get() for cell in cell_row] + cell_height = min(len(lines) for lines in cell_data) + for iline in range(cell_height): + yield ANSIString("").join(_to_ansi(celldata[iline] for celldata in cell_data)) + +
[docs] def add_header(self, *args, **kwargs): + """ + Add header to table. This is a number of texts to be put at + the top of the table. They will replace an existing header. + + Args: + args (str): These strings will be used as the header texts. + + Keyword Args: + Same keywords as per `EvTable.__init__`. Will be applied + to the new header's cells. + + """ + self.header = True + self.add_row(ypos=0, *args, **kwargs)
+ +
[docs] def add_column(self, *args, **kwargs): + """ + Add a column to table. If there are more rows in new column + than there are rows in the current table, the table will + expand with empty rows in the other columns. If too few, the + new column with get new empty rows. All filling rows are added + to the end. + + Args: + args (`EvColumn` or multiple strings): Either a single EvColumn instance or + a number of data string arguments to be used to create a new column. + header (str, optional): The header text for the column + xpos (int, optional): Index position in table *before* which + to input new column. If not given, column will be added to the end + of the table. Uses Python indexing (so first column is `xpos=0`) + + Keyword Args: + Other keywords as per `Cell.__init__`. + + """ + # this will replace default options with new ones without changing default + options = dict(list(self.options.items()) + list(kwargs.items())) + + xpos = kwargs.get("xpos", None) + + if args and isinstance(args[0], EvColumn): + column = args[0] + column.reformat(**kwargs) + else: + column = EvColumn(*args, **options) + wtable = self.ncols + htable = self.nrows + + header = kwargs.get("header", None) + if header: + column.add_rows(str(header), ypos=0, **options) + self.header = True + elif self.header: + # we have a header already. Offset + column.add_rows("", ypos=0, **options) + + # Calculate whether the new column needs to expand to the + # current table size, or if the table needs to expand to + # the column size. + # This needs to happen after the header rows have already been + # added to the column in order for the size calculations to match. + excess = len(column) - htable + if excess > 0: + # we need to add new rows to table + for col in self.table: + empty_rows = ["" for _ in range(excess)] + col.add_rows(*empty_rows, **options) + self.nrows += excess + elif excess < 0: + # we need to add new rows to new column + empty_rows = ["" for _ in range(abs(excess))] + column.add_rows(*empty_rows, **options) + + if xpos is None or xpos > wtable - 1: + # add to the end + self.table.append(column) + else: + # insert column + xpos = min(wtable - 1, max(0, int(xpos))) + self.table.insert(xpos, column) + self.ncols += 1
+ +
[docs] def add_row(self, *args, **kwargs): + """ + Add a row to table (not a header). If there are more cells in + the given row than there are cells in the current table the + table will be expanded with empty columns to match. These will + be added to the end of the table. In the same way, adding a + line with too few cells will lead to the last ones getting + padded. + + Args: + args (str): Any number of string argumnets to use as the + data in the row (one cell per argument). + ypos (int, optional): Index position in table before which to + input new row. If not given, will be added to the end of the table. + Uses Python indexing (so first row is `ypos=0`) + + Keyword Args: + Other keywords are as per `EvCell.__init__`. + + """ + # this will replace default options with new ones without changing default + row = list(args) + options = dict(list(self.options.items()) + list(kwargs.items())) + + ypos = kwargs.get("ypos", None) + wtable = self.ncols + htable = self.nrows + excess = len(row) - wtable + + if excess > 0: + # we need to add new empty columns to table + empty_rows = ["" for _ in range(htable)] + self.table.extend([EvColumn(*empty_rows, **options) for _ in range(excess)]) + elif excess < 0: + # we need to add more cells to row + row.extend(["" for _ in range(abs(excess))]) + self.ncols = len(self.table) + + if ypos is None or ypos > htable - 1: + # add new row to the end + for icol, col in enumerate(self.table): + col.add_rows(row[icol], **options) + else: + # insert row elsewhere + ypos = min(htable - 1, max(0, int(ypos))) + for icol, col in enumerate(self.table): + col.add_rows(row[icol], ypos=ypos, **options) + self.nrows += 1
+ # self._balance() + +
[docs] def reformat(self, **kwargs): + """ + Force a re-shape of the entire table. + + Keyword Args: + Table options as per `EvTable.__init__`. + + """ + self.width = kwargs.pop("width", self.width) + self.height = kwargs.pop("height", self.height) + for key, value in kwargs.items(): + setattr(self, key, value) + + hchar = kwargs.pop("header_line_char", self.header_line_char) + + # border settings are also passed on into EvCells (so kwargs.get, not kwargs.pop) + self.header_line_char = hchar[0] if hchar else self.header_line_char + self.border_width = kwargs.get("border_width", self.border_width) + self.corner_char = kwargs.get("corner_char", self.corner_char) + self.header_line_char = kwargs.get("header_line_char", self.header_line_char) + + self.corner_top_left_char = _to_ansi(kwargs.pop("corner_top_left_char", self.corner_char)) + self.corner_top_right_char = _to_ansi(kwargs.pop("corner_top_right_char", self.corner_char)) + self.corner_bottom_left_char = _to_ansi( + kwargs.pop("corner_bottom_left_char", self.corner_char) + ) + self.corner_bottom_right_char = _to_ansi( + kwargs.pop("corner_bottom_right_char", self.corner_char) + ) + + self.options.update(kwargs)
+ +
[docs] def reformat_column(self, index, **kwargs): + """ + Sends custom options to a specific column in the table. + + Args: + index (int): Which column to reformat. The column index is + given from 0 to Ncolumns-1. + + Keyword Args: + Column options as per `EvCell.__init__`. + + Raises: + Exception: if an invalid index is found. + + """ + if index > len(self.table): + raise Exception("Not a valid column index") + # we update the columns' options which means eventual width/height + # will be 'locked in' and withstand auto-balancing width/height from the table later + self.table[index].options.update(kwargs) + self.table[index].reformat(**kwargs)
+ +
[docs] def get(self): + """ + Return lines of table as a list. + + Returns: + table_lines (list): The lines of the table, in order. + + """ + return [line for line in self._generate_lines()]
+ + def __str__(self): + """print table (this also balances it)""" + # h = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" + return str(str(ANSIString("\n").join([line for line in self._generate_lines()])))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/funcparser.html b/docs/latest/_modules/evennia/utils/funcparser.html new file mode 100644 index 0000000000..b96ee12223 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/funcparser.html @@ -0,0 +1,1667 @@ + + + + + + + + evennia.utils.funcparser — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.funcparser

+"""
+Generic function parser for functions embedded in a string, on the form
+`$funcname(*args, **kwargs)`, for example:
+
+```
+"A string $foo() with $bar(a, b, c, $moo(), d=23) etc."
+```
+
+Each arg/kwarg can also be another nested function. These will be executed
+inside-out and their return will used as arguments for the enclosing function
+(so the same as for regular Python function execution).
+
+This is the base for all forms of embedded func-parsing, like inlinefuncs and
+protfuncs. Each function available to use must be registered as a 'safe'
+function for the parser to accept it. This is usually done in a module with
+regular Python functions on the form:
+
+```python
+# in a module whose path is passed to the parser
+
+def _helper(x):
+    # use underscore to NOT make the function available as a callable
+
+def funcname(*args, **kwargs):
+    # this can be accessed as $funcname(*args, **kwargs)
+    # it must always accept *args and **kwargs.
+    ...
+    return something
+```
+
+Usage:
+
+```python
+from evennia.utils.funcparser import FuncParser
+
+parser = FuncParser("path.to.module_with_callables")
+result = parser.parse("String with $funcname() in it")
+
+```
+
+The `FuncParser` also accepts a direct dict mapping of `{'name': callable, ...}`.
+
+---
+
+"""
+import dataclasses
+import inspect
+import random
+
+from django.conf import settings
+
+from evennia.utils import logger, search
+from evennia.utils.utils import (
+    callables_from_module,
+    crop,
+    int2str,
+    justify,
+    make_iter,
+    pad,
+    safe_convert_to_types,
+    variable_from_module,
+)
+from evennia.utils.verb_conjugation.conjugate import verb_actor_stance_components
+from evennia.utils.verb_conjugation.pronouns import pronoun_to_viewpoints
+
+# setup
+
+_CLIENT_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+_MAX_NESTING = settings.FUNCPARSER_MAX_NESTING
+_START_CHAR = settings.FUNCPARSER_START_CHAR
+_ESCAPE_CHAR = settings.FUNCPARSER_ESCAPE_CHAR
+
+
+@dataclasses.dataclass
+class _ParsedFunc:
+    """
+    Represents a function parsed from the string
+
+    """
+
+    prefix: str = _START_CHAR
+    funcname: str = ""
+    args: list = dataclasses.field(default_factory=list)
+    kwargs: dict = dataclasses.field(default_factory=dict)
+
+    # state storage
+    fullstr: str = ""
+    infuncstr: str = ""
+    rawstr: str = ""
+    double_quoted: int = -1
+    current_kwarg: str = ""
+    open_lparens: int = 0
+    open_lsquate: int = 0
+    open_lcurly: int = 0
+    exec_return = ""
+
+    def get(self):
+        return self.funcname, self.args, self.kwargs
+
+    def __str__(self):
+        return self.prefix + self.rawstr + self.infuncstr
+
+
+
[docs]class ParsingError(RuntimeError): + """ + Failed to parse for some reason. + """ + + pass
+ + +
[docs]class FuncParser: + """ + Sets up a parser for strings containing `$funcname(*args, **kwargs)` + substrings. + + """ + +
[docs] def __init__( + self, + callables, + start_char=_START_CHAR, + escape_char=_ESCAPE_CHAR, + max_nesting=_MAX_NESTING, + **default_kwargs, + ): + """ + Initialize the parser. + + Args: + callables (str, module, list or dict): Where to find + 'safe' functions to make available in the parser. If a `dict`, + it should be a direct mapping `{"funcname": callable, ...}`. If + one or mode modules or module-paths, the module(s) are first checked + for a dict `FUNCPARSER_CALLABLES = {"funcname", callable, ...}`. If + no such variable exists, all callables in the module (whose name does + not start with an underscore) will be made available to the parser. + start_char (str, optional): A character used to identify the beginning + of a parseable function. Default is `$`. + escape_char (str, optional): Prepend characters with this to have + them not count as a function. Default is the backtick, `\\\\`. + max_nesting (int, optional): How many levels of nested function calls + are allowed, to avoid exploitation. Default is 20. + **default_kwargs: These kwargs will be passed into all callables. These + kwargs can be overridden both by kwargs passed direcetly to `.parse` *and* + by kwargs given directly in the string `$funcname` call. They are + suitable for global defaults that is intended to be changed by the + user. To guarantee a call always gets a particular kwarg, pass it + into `.parse` as `**reserved_kwargs` instead. + + """ + if isinstance(callables, dict): + loaded_callables = {**callables} + else: + # load all modules/paths in sequence. Later-added will override + # earlier same-named callables (allows for overriding evennia defaults) + loaded_callables = {} + for module_or_path in make_iter(callables): + callables_mapping = variable_from_module( + module_or_path, variable="FUNCPARSER_CALLABLES" + ) + if callables_mapping: + try: + # mapping supplied in variable + loaded_callables.update(callables_mapping) + except ValueError: + raise ParsingError( + f"Failure to parse - {module_or_path}.FUNCPARSER_CALLABLES " + "(must be a dict {'funcname': callable, ...})" + ) + else: + # use all top-level variables + # (handles both paths and module instances + loaded_callables.update(callables_from_module(module_or_path)) + self.validate_callables(loaded_callables) + self.callables = loaded_callables + self.escape_char = escape_char + self.start_char = start_char + self.default_kwargs = default_kwargs
+ +
[docs] def validate_callables(self, callables): + """ + Validate the loaded callables. Each callable must support at least + `funcname(*args, **kwargs)`. + property. + + Args: + callables (dict): A mapping `{"funcname": callable, ...}` to validate + + Raise: + AssertionError: If invalid callable was found. + + Notes: + This is also a good method to override for individual parsers + needing to run any particular pre-checks. + + """ + for funcname, clble in callables.items(): + try: + mapping = inspect.getfullargspec(clble) + except TypeError: + logger.log_trace(f"Could not run getfullargspec on {funcname}: {clble}") + else: + assert mapping.varargs, f"Parse-func callable '{funcname}' does not support *args." + assert mapping.varkw, f"Parse-func callable '{funcname}' does not support **kwargs."
+ +
[docs] def execute(self, parsedfunc, raise_errors=False, **reserved_kwargs): + """ + Execute a parsed function + + Args: + parsedfunc (_ParsedFunc): This dataclass holds the parsed details + of the function. + raise_errors (bool, optional): Raise errors. Otherwise return the + string with the function unparsed. + **reserved_kwargs: These kwargs are _guaranteed_ to always be passed into + the callable on every call. It will override any default kwargs + _and_ also a same-named kwarg given manually in the $funcname + call. This is often used by Evennia to pass required data into + the callable, for example the current Session for inlinefuncs. + Returns: + any: The result of the execution. If this is a nested function, it + can be anything, otherwise it will be converted to a string later. + Always a string on un-raised error (the unparsed function string). + + Raises: + ParsingError, any: A `ParsingError` if the function could not be + found, otherwise error from function definition. Only raised if + `raise_errors` is `True` + + Notes: + The kwargs passed into the callable will be a mixture of the + `default_kwargs` passed into `FuncParser.__init__`, kwargs given + directly in the `$funcdef` string, and the `reserved_kwargs` this + function gets from `.parse()`. For colliding keys, funcdef-defined + kwargs will override default kwargs while reserved kwargs will always + override the other two. + + """ + funcname, args, kwargs = parsedfunc.get() + func = self.callables.get(funcname) + + if not func: + if raise_errors: + available = ", ".join(f"'{key}'" for key in self.callables) + raise ParsingError( + f"Unknown parsed function '{str(parsedfunc)}' (available: {available})" + ) + return str(parsedfunc) + + # build kwargs in the proper priority order + kwargs = { + **self.default_kwargs, + **kwargs, + **reserved_kwargs, + **{"funcparser": self, "raise_errors": raise_errors}, + } + + try: + ret = func(*args, **kwargs) + return ret + except ParsingError: + if raise_errors: + raise + return str(parsedfunc) + except Exception: + logger.log_trace() + if raise_errors: + raise + return str(parsedfunc)
+ +
[docs] def parse( + self, + string, + raise_errors=False, + escape=False, + strip=False, + return_str=True, + **reserved_kwargs, + ): + """ + Use parser to parse a string that may or may not have + `$funcname(*args, **kwargs)` - style tokens in it. Only the callables + used to initiate the parser will be eligible for parsing. + + Args: + string (str): The string to parse. + raise_errors (bool, optional): By default, a failing parse just + means not parsing the string but leaving it as-is. If this is + `True`, errors (like not closing brackets) will lead to an + ParsingError. + escape (bool, optional): If set, escape all found functions so they + are not executed by later parsing. + strip (bool, optional): If set, strip any inline funcs from string + as if they were not there. + return_str (bool, optional): If set (default), always convert the + parse result to a string, otherwise return the result of the + latest called inlinefunc (if called separately). + **reserved_kwargs: If given, these are guaranteed to _always_ pass + as part of each parsed callable's **kwargs. These override + same-named default options given in `__init__` as well as any + same-named kwarg given in the string function. This is because + it is often used by Evennia to pass necessary kwargs into each + callable (like the current Session object for inlinefuncs). + + Returns: + str or any: The parsed string, or the same string on error (if + `raise_errors` is `False`). This is always a string + + Raises: + ParsingError: If a problem is encountered and `raise_errors` is True. + + """ + start_char = self.start_char + escape_char = self.escape_char + + # replace e.g. $$ with \$ so we only need to handle one escape method + string = string.replace(start_char + start_char, escape_char + start_char) + + # parsing state + callstack = [] + + double_quoted = -1 + open_lparens = 0 # open ( + open_lsquare = 0 # open [ + open_lcurly = 0 # open { + escaped = False + current_kwarg = "" + exec_return = "" + + curr_func = None + fullstr = "" # final string + infuncstr = "" # string parts inside the current level of $funcdef (including $) + literal_infuncstr = False + + for char in string: + if escaped: + # always store escaped characters verbatim + if curr_func: + infuncstr += char + else: + fullstr += char + escaped = False + continue + + if char == escape_char: + # don't store the escape-char itself + escaped = True + continue + + if char == start_char: + # start a new function definition (not escaped as $$) + + if curr_func: + # we are starting a nested funcdef + if len(callstack) >= _MAX_NESTING - 1: + # stack full - ignore this function + if raise_errors: + raise ParsingError( + "Only allows for parsing nesting function defs " + f"to a max depth of {_MAX_NESTING}." + ) + infuncstr += char + continue + else: + # store state for the current func and stack it + curr_func.current_kwarg = current_kwarg + curr_func.infuncstr = infuncstr + curr_func.double_quoted = double_quoted + curr_func.open_lparens = open_lparens + curr_func.open_lsquare = open_lsquare + curr_func.open_lcurly = open_lcurly + # we must strip the remaining funcstr so it's not counted twice + curr_func.rawstr = curr_func.rawstr[: -len(infuncstr)] + current_kwarg = "" + infuncstr = "" + double_quoted = -1 + open_lparens = 0 + open_lsquare = 0 + open_lcurly = 0 + exec_return = "" + literal_infuncstr = False + callstack.append(curr_func) + + # start a new func + curr_func = _ParsedFunc(prefix=char, fullstr=char) + continue + + if not curr_func: + # a normal piece of string + fullstr += char + # this must always be a string + return_str = True + continue + + # in a function def (can be nested) + + curr_func.rawstr += char + + if exec_return != "" and char not in (",=)"): + # if exec_return is followed by any other character + # than one demarking an arg,kwarg or function-end + # it must immediately merge as a string + infuncstr += str(exec_return) + exec_return = "" + + if char == '"': # note that this is the same as '\"' + # a double quote = flip status + if double_quoted == 0: + infuncstr = infuncstr[1:] + double_quoted = -1 + elif double_quoted > 0: + prefix = infuncstr[0:double_quoted] + infuncstr = prefix + infuncstr[double_quoted + 1 :] + double_quoted = -1 + else: + infuncstr += char + infuncstr = infuncstr.strip() + double_quoted = len(infuncstr) - 1 + literal_infuncstr = True + + continue + + if double_quoted >= 0: + # inside a string definition - this escapes everything else + infuncstr += char + continue + + # special characters detected inside function def + if char == "(": + if not curr_func.funcname: + # end of a funcdef name + curr_func.funcname = infuncstr + curr_func.fullstr += infuncstr + char + infuncstr = "" + else: + # just a random left-parenthesis + infuncstr += char + # track the open left-parenthesis + open_lparens += 1 + continue + + if char in "[]": + # a square bracket - start/end of a list? + infuncstr += char + open_lsquare += -1 if char == "]" else 1 + continue + + if char in "{}": + # a curly bracket - start/end of dict/set? + infuncstr += char + open_lcurly += -1 if char == "}" else 1 + continue + + if char == "=": + # beginning of a keyword argument + if exec_return != "": + infuncstr = exec_return + current_kwarg = infuncstr.strip() + curr_func.kwargs[current_kwarg] = "" + curr_func.fullstr += infuncstr + char + infuncstr = "" + continue + + if char in (",)"): + # commas and right-parens may indicate arguments ending + + if open_lparens > 1: + # one open left-parens is ok (beginning of arglist), more + # indicate we are inside an unclosed, nested (, so + # we need to not count this as a new arg or end of funcdef. + infuncstr += char + open_lparens -= 1 if char == ")" else 0 + continue + + if open_lcurly > 0 or open_lsquare > 0: + # also escape inside an open [... or {... structure + infuncstr += char + continue + + if exec_return != "": + # store the execution return as-received + if current_kwarg: + curr_func.kwargs[current_kwarg] = exec_return + else: + curr_func.args.append(exec_return) + else: + if not literal_infuncstr: + infuncstr = infuncstr.strip() + + # store a string instead + if current_kwarg: + curr_func.kwargs[current_kwarg] = infuncstr + elif literal_infuncstr or infuncstr.strip(): + # don't store the empty string + curr_func.args.append(infuncstr) + + # note that at this point either exec_return or infuncstr will + # be empty. We need to store the full string so we can print + # it 'raw' in case this funcdef turns out to e.g. lack an + # ending paranthesis + curr_func.fullstr += str(exec_return) + infuncstr + char + + current_kwarg = "" + exec_return = "" + infuncstr = "" + literal_infuncstr = False + + if char == ")": + # closing the function list - this means we have a + # ready function-def to run. + open_lparens = 0 + + if strip: + # remove function as if it returned empty + exec_return = "" + elif escape: + # get function and set it as escaped + exec_return = escape_char + curr_func.fullstr + else: + # execute the function - the result may be a string or + # something else + exec_return = self.execute( + curr_func, raise_errors=raise_errors, **reserved_kwargs + ) + + if callstack: + # unnest the higher-level funcdef from stack + # and continue where we were + curr_func = callstack.pop() + current_kwarg = curr_func.current_kwarg + if curr_func.infuncstr: + # if we have an ongoing string, we must merge the + # exec into this as a part of that string + infuncstr = curr_func.infuncstr + str(exec_return) + exec_return = "" + curr_func.infuncstr = "" + double_quoted = curr_func.double_quoted + open_lparens = curr_func.open_lparens + open_lsquare = curr_func.open_lsquare + open_lcurly = curr_func.open_lcurly + else: + # back to the top-level string - this means the + # exec_return should always be converted to a string. + curr_func = None + fullstr += str(exec_return) + if return_str: + exec_return = "" + infuncstr = "" + literal_infuncstr = False + continue + + infuncstr += char + + if curr_func: + # if there is a still open funcdef or defs remaining in callstack, + # these are malformed (no closing bracket) and we should get their + # strings as-is. + callstack.append(curr_func) + for inum, _ in enumerate(range(len(callstack))): + funcstr = str(callstack.pop()) + if inum == 0 and funcstr.endswith(infuncstr): + # avoid double-echo of nested function calls. This should + # produce a good result most of the time, but it's not 100% + # guaranteed to, since it can ignore genuine duplicates + infuncstr = funcstr + else: + infuncstr = funcstr + infuncstr + + if not return_str and exec_return != "": + # return explicit return + return exec_return + + # add the last bit to the finished string + fullstr += infuncstr + + return fullstr
+ +
[docs] def parse_to_any( + self, string, raise_errors=False, escape=False, strip=False, **reserved_kwargs + ): + """ + This parses a string and if the string only contains a "$func(...)", + the return will be the return value of that function, even if it's not + a string. If mixed in with other strings, the result will still always + be a string. + + Args: + string (str): The string to parse. + raise_errors (bool, optional): If unset, leave a failing (or + unrecognized) inline function as unparsed in the string. If set, + raise an ParsingError. + escape (bool, optional): If set, escape all found functions so they + are not executed by later parsing. + strip (bool, optional): If set, strip any inline funcs from string + as if they were not there. + **reserved_kwargs: If given, these are guaranteed to _always_ pass + as part of each parsed callable's **kwargs. These override + same-named default options given in `__init__` as well as any + same-named kwarg given in the string function. This is because + it is often used by Evennia to pass necessary kwargs into each + callable (like the current Session object for inlinefuncs). + + Returns: + any: The return from the callable. Or string if the callable is not + given alone in the string. + + Raises: + ParsingError: If a problem is encountered and `raise_errors` is True. + + Notes: + This is a convenience wrapper for `self.parse(..., return_str=False)` which + accomplishes the same thing. + + Examples: + :: + + from ast import literal_eval + from evennia.utils.funcparser import FuncParser + + + def ret1(*args, **kwargs): + return 1 + + parser = FuncParser({"lit": lit}) + + assert parser.parse_to_any("$ret1()" == 1 + assert parser.parse_to_any("$ret1() and text" == '1 and text' + + """ + return self.parse( + string, + raise_errors=raise_errors, + escape=escape, + strip=strip, + return_str=False, + **reserved_kwargs, + )
+ + +# +# Default funcparser callables. These are made available from this module's +# FUNCPARSER_CALLABLES. +# + + +
[docs]def funcparser_callable_eval(*args, **kwargs): + """ + Funcparser callable. This will combine safe evaluations to try to parse the + incoming string into a python object. If it fails, the return will be same + as the input. + + Args: + string (str): The string to parse. Only simple literals or operators are allowed. + + Returns: + any: The string parsed into its Python form, or the same as input. + + Examples: + - `$py(1) -> 1` + - `$py([1,2,3,4] -> [1, 2, 3]` + - `$py(3 + 4) -> 7` + + """ + args, kwargs = safe_convert_to_types(("py", {}), *args, **kwargs) + return args[0] if args else ""
+ + +
[docs]def funcparser_callable_toint(*args, **kwargs): + """Usage: $toint(43.0) -> 43""" + inp = funcparser_callable_eval(*args, **kwargs) + try: + return int(inp) + except TypeError: + return inp + except ValueError: + return inp
+ + +
[docs]def funcparser_callable_int2str(*args, **kwargs): + """ + Usage: $int2str(1) -> 'one' etc, up to 12->twelve. + + Args: + number (int): The number. If not an int, will be converted. + + Uses the int2str utility function. + """ + if not args: + return "" + try: + number = int(args[0]) + except ValueError: + return args[0] + return int2str(number)
+ + +
[docs]def funcparser_callable_an(*args, **kwargs): + """ + Usage: $an(thing) -> a thing + + Adds a/an depending on if the first letter of the given word is a consonant or not. + + """ + if not args: + return "" + item = str(args[0]) + if item and item[0] in "aeiouyAEIOUY": + return f"an {item}" + return f"a {item}"
+ + +def _apply_operation_two_elements(*args, operator="+", **kwargs): + """ + Helper operating on two arguments + + Args: + val1 (any): First value to operate on. + val2 (any): Second value to operate on. + + Return: + any: The result of val1 + val2. Values must be + valid simple Python structures possible to add, + such as numbers, lists etc. The $eval is usually + better for non-list arithmetic. + + """ + args, kwargs = safe_convert_to_types((("py", "py"), {}), *args, **kwargs) + if not len(args) > 1: + return "" + val1, val2 = args[0], args[1] + try: + if operator == "+": + return val1 + val2 + elif operator == "-": + return val1 - val2 + elif operator == "*": + return val1 * val2 + elif operator == "/": + return val1 / val2 + except Exception: + if kwargs.get("raise_errors"): + raise + return "" + + +
[docs]def funcparser_callable_add(*args, **kwargs): + """Usage: `$add(val1, val2) -> val1 + val2`""" + return _apply_operation_two_elements(*args, operator="+", **kwargs)
+ + +
[docs]def funcparser_callable_sub(*args, **kwargs): + """Usage: ``$sub(val1, val2) -> val1 - val2`""" + return _apply_operation_two_elements(*args, operator="-", **kwargs)
+ + +
[docs]def funcparser_callable_mult(*args, **kwargs): + """Usage: `$mult(val1, val2) -> val1 * val2`""" + return _apply_operation_two_elements(*args, operator="*", **kwargs)
+ + +
[docs]def funcparser_callable_div(*args, **kwargs): + """Usage: `$mult(val1, val2) -> val1 / val2`""" + return _apply_operation_two_elements(*args, operator="/", **kwargs)
+ + +
[docs]def funcparser_callable_round(*args, **kwargs): + """ + Funcparser callable. Rounds an incoming float to a + certain number of significant digits. + + Args: + inp (str or number): If a string, it will attempt + to be converted to a number first. + significant (int): The number of significant digits. Default is None - + this will turn the result into an int. + + Returns: + any: The rounded value or inp if inp was not a number. + + Examples: + - `$round(3.5434343, 3) -> 3.543` + - `$round($random(), 2)` - rounds random result, e.g `0.22` + + """ + if not args: + return "" + args, _ = safe_convert_to_types(((float, int), {}), *args, **kwargs) + + num, *significant = args + significant = significant[0] if significant else 0 + try: + return round(num, significant) + except Exception: + if kwargs.get("raise_errors"): + raise + return ""
+ + +
[docs]def funcparser_callable_random(*args, **kwargs): + """ + Funcparser callable. Returns a random number between 0 and 1, from 0 to a + maximum value, or within a given range (inclusive). + + Args: + minval (str, optional): Minimum value. If not given, assumed 0. + maxval (str, optional): Maximum value. + + Notes: + If either of the min/maxvalue has a '.' in it, a floating-point random + value will be returned. Otherwise it will be an + integer value in the given range. + + Examples: + - `$random()` - random value [0 .. 1) (float). + - `$random(5)` - random value [0..5] (int) + - `$random(5.0)` - random value [0..5] (float) + - `$random(5, 10)` - random value [5..10] (int) + - `$random(5, 10.0)` - random value [5..10] (float) + + """ + args, _ = safe_convert_to_types((("py", "py"), {}), *args, **kwargs) + + nargs = len(args) + if nargs == 1: + # only maxval given + minval, maxval = 0, args[0] + elif nargs > 1: + minval, maxval = args[:2] + else: + minval, maxval = 0, 1 + + try: + if isinstance(minval, float) or isinstance(maxval, float): + return minval + ((maxval - minval) * random.random()) + else: + return random.randint(minval, maxval) + except Exception: + if kwargs.get("raise_errors"): + raise + return ""
+ + +
[docs]def funcparser_callable_randint(*args, **kwargs): + """ + Usage: $randint(start, end): + + Legacy alias - always returns integers. + + """ + return int(funcparser_callable_random(*args, **kwargs))
+ + +
[docs]def funcparser_callable_choice(*args, **kwargs): + """ + FuncParser callable. Picks a random choice from a list. + + Args: + listing (list): A list of items to randomly choose between. + This will be converted from a string to a real list. + *args: If multiple args are given, will pick one randomly from them. + + Returns: + any: The randomly chosen element. + + Example: + - `$choice(key, flower, house)` + - `$choice([1, 2, 3, 4])` + + """ + if not args: + return "" + + nargs = len(args) + if nargs == 1: + # this needs to be a list/tuple for this to make sense + args, _ = safe_convert_to_types(("py", {}), args[0], **kwargs) + args = make_iter(args[0]) if args else None + else: + # separate arg per entry + converters = ["py" for _ in range(nargs)] + args, _ = safe_convert_to_types((converters, {}), *args, **kwargs) + + if not args: + return "" + try: + return random.choice(args) + except Exception: + if kwargs.get("raise_errors"): + raise + return ""
+ + +
[docs]def funcparser_callable_pad(*args, **kwargs): + """ + FuncParser callable. Pads text to given width, optionally with fill-characters + + Args: + text (str): Text to pad. + width (int): Width of padding. + align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'. + fillchar (str, optional): Character used for padding. Defaults to a space. + + Example: + - `$pad(text, 12, r, ' ') -> " text"` + - `$pad(text, width=12, align=c, fillchar=-) -> "----text----"` + + """ + if not args: + return "" + args, kwargs = safe_convert_to_types( + ((str, int, str, str), {"width": int, "align": str, "fillchar": str}), *args, **kwargs + ) + + text, *rest = args + nrest = len(rest) + try: + width = int(kwargs.get("width", rest[0] if nrest > 0 else _CLIENT_DEFAULT_WIDTH)) + except (TypeError, ValueError): + width = _CLIENT_DEFAULT_WIDTH + + align = kwargs.get("align", rest[1] if nrest > 1 else "c") + fillchar = kwargs.get("fillchar", rest[2] if nrest > 2 else " ") + if align not in ("c", "l", "r"): + align = "c" + return pad(str(text), width=width, align=align, fillchar=fillchar)
+ + +
[docs]def funcparser_callable_crop(*args, **kwargs): + """ + FuncParser callable. Crops ingoing text to given widths. + + Args: + text (str, optional): Text to crop. + width (str, optional): Will be converted to an integer. Width of + crop in characters. + suffix (str, optional): End string to mark the fact that a part + of the string was cropped. Defaults to `[...]`. + + Example: + - `$crop(A long text, 10, [...]) -> "A lon[...]"` + - `$crop(text, width=11, suffix='[...]) -> "A long[...]"` + + """ + if not args: + return "" + text, *rest = args + nrest = len(rest) + try: + width = int(kwargs.get("width", rest[0] if nrest > 0 else _CLIENT_DEFAULT_WIDTH)) + except (TypeError, ValueError): + width = _CLIENT_DEFAULT_WIDTH + suffix = kwargs.get("suffix", rest[1] if nrest > 1 else "[...]") + return crop(str(text), width=width, suffix=str(suffix))
+ + +
[docs]def funcparser_callable_space(*args, **kwarg): + """ + Usage: $space(43) + + Insert a length of space. + + """ + if not args: + return "" + try: + width = int(args[0]) + except ValueError: + width = 1 + return " " * width
+ + +
[docs]def funcparser_callable_justify(*args, **kwargs): + """ + Justify text across a width, default across screen width. + + Args: + text (str): Text to justify. + width (int, optional): Defaults to default screen width. + align (str, optional): One of 'l', 'c', 'r' or 'f' for 'full'. + indent (int, optional): Intendation of text block, if any. + + Returns: + str: The justified text. + + Examples: + - `$just(text, width=40)` + - `$just(text, align=r, indent=2)` + + """ + if not args: + return "" + text, *rest = args + lrest = len(rest) + try: + width = int(kwargs.get("width", rest[0] if lrest > 0 else _CLIENT_DEFAULT_WIDTH)) + except (TypeError, ValueError): + width = _CLIENT_DEFAULT_WIDTH + align = str(kwargs.get("align", rest[1] if lrest > 1 else "f")) + try: + indent = int(kwargs.get("indent", rest[2] if lrest > 2 else 0)) + except (TypeError, ValueError): + indent = 0 + return justify(str(text), width=width, align=align, indent=indent)
+ + +# legacy for backwards compatibility +
[docs]def funcparser_callable_left_justify(*args, **kwargs): + "Usage: $ljust(text)" + return funcparser_callable_justify(*args, align="l", **kwargs)
+ + +
[docs]def funcparser_callable_right_justify(*args, **kwargs): + "Usage: $rjust(text)" + return funcparser_callable_justify(*args, align="r", **kwargs)
+ + +
[docs]def funcparser_callable_center_justify(*args, **kwargs): + "Usage: $cjust(text)" + return funcparser_callable_justify(*args, align="c", **kwargs)
+ + +
[docs]def funcparser_callable_clr(*args, **kwargs): + """ + FuncParser callable. Colorizes nested text. + + Args: + startclr (str, optional): An ANSI color abbreviation without the + prefix `|`, such as `r` (red foreground) or `[r` (red background). + text (str, optional): Text + endclr (str, optional): The color to use at the end of the string. Defaults + to `|n` (reset-color). + Kwargs: + color (str, optional): If given, + + Example: + - `$clr(r, text, n) -> "|rtext|n"` + - `$clr(r, text) -> "|rtext|n` + - `$clr(text, start=r, end=n) -> "|rtext|n"` + + """ + if not args: + return "" + + startclr, text, endclr = "", "", "" + if len(args) > 1: + # $clr(pre, text, post)) + startclr, *rest = args + if rest: + text, *endclr = rest + if endclr: + endclr = endclr[0] + else: + # $clr(text, start=pre, end=post) + text = args[0] + startclr = kwargs.get("start", "") + endclr = kwargs.get("end", "") + + startclr = "|" + startclr if startclr else "" + endclr = "|" + endclr if endclr else ("|n" if startclr else "") + return f"{startclr}{text}{endclr}"
+ + +
[docs]def funcparser_callable_pluralize(*args, **kwargs): + """ + FuncParser callable. Handles pluralization of a word. + + Args: + singular_word (str): The base (singular) word to optionally pluralize + number (int): The number of elements; if 1 (or 0), use `singular_word` as-is, + otherwise use plural form. + plural_word (str, optional): If given, this will be used if `number` + is greater than one. If not given, we simply add 's' to the end of + `singular_word`. + + Example: + - `$pluralize(thing, 2)` -> "things" + - `$pluralize(goose, 18, geese)` -> "geese" + + """ + if not args: + return "" + nargs = len(args) + if nargs > 2: + singular_word, number, plural_word = args[:3] + elif nargs > 1: + singular_word, number = args[:2] + plural_word = f"{singular_word}s" + else: + singular_word, number = args[0], 1 + return singular_word if abs(int(number)) in (0, 1) else plural_word
+ + + + + +
[docs]def funcparser_callable_search_list(*args, caller=None, access="control", **kwargs): + """ + Usage: $objlist(#123) + + Legacy alias for search with a return_list=True kwarg preset. + + """ + return funcparser_callable_search( + *args, caller=caller, access=access, return_list=True, **kwargs + )
+ + +
[docs]def funcparser_callable_you( + *args, caller=None, receiver=None, mapping=None, capitalize=False, **kwargs +): + """ + Usage: $you() or $you(key) + + Replaces with you for the caller of the string, with the display_name + of the caller for others. + + Keyword Args: + caller (Object): The 'you' in the string. This is used unless another + you-key is passed to the callable in combination with `mapping`. + receiver (Object): The recipient of the string. + mapping (dict, optional): This is a mapping `{key:Object, ...}` and is + used to find which object `$you(key)` refers to. If not given, the + `caller` kwarg is used. + capitalize (bool): Passed by the You helper, to capitalize you. + + Returns: + str: The parsed string. + + Raises: + ParsingError: If `caller` and `receiver` were not supplied. + + Notes: + The kwargs should be passed the to parser directly. + + Examples: + This can be used by the say or emote hooks to pass actor stance + strings. This should usually be combined with the $conj() callable. + + - `With a grin, $you() $conj(jump) at $you(tommy).` + + The caller-object will see "With a grin, you jump at Tommy." + Tommy will see "With a grin, CharName jumps at you." + Others will see "With a grin, CharName jumps at Tommy." + + """ + if args and mapping: + # this would mean a $you(key) form + caller = mapping.get(args[0], None) + + if not (caller and receiver): + raise ParsingError("No caller or receiver supplied to $you callable.") + + capitalize = bool(capitalize) + if caller == receiver: + return "You" if capitalize else "you" + return ( + caller.get_display_name(looker=receiver) + if hasattr(caller, "get_display_name") + else str(caller) + )
+ + +
[docs]def funcparser_callable_you_capitalize( + *args, you=None, receiver=None, mapping=None, capitalize=True, **kwargs +): + """ + Usage: $You() - capitalizes the 'you' output. + + """ + return funcparser_callable_you( + *args, you=you, receiver=receiver, mapping=mapping, capitalize=capitalize, **kwargs + )
+ + +
[docs]def funcparser_callable_your( + *args, caller=None, receiver=None, mapping=None, capitalize=False, **kwargs +): + """ + Usage: $your() or $your(key) + + Replaces with your for the caller of the string, with the display_name +'s + of the caller for others. + + Keyword Args: + caller (Object): The 'your' in the string. This is used unless another + your-key is passed to the callable in combination with `mapping`. + receiver (Object): The recipient of the string. + mapping (dict, optional): This is a mapping `{key:Object, ...}` and is + used to find which object `$you(key)` refers to. If not given, the + `caller` kwarg is used. + capitalize (bool): Passed by the You helper, to capitalize you. + + Returns: + str: The parsed string. + + Raises: + ParsingError: If `caller` and `receiver` were not supplied. + + Notes: + The kwargs should be passed the to parser directly. + + Examples: + This can be used by the say or emote hooks to pass actor stance + strings. + + - `$your() pet jumps at $you(tommy).` + + The caller-object will see "Your pet jumps Tommy." + Tommy will see "CharName's pet jumps at you." + Others will see "CharName's pet jumps at Tommy." + + """ + if args and mapping: + # this would mean a $your(key) form + caller = mapping.get(args[0], None) + + if not (caller and receiver): + raise ParsingError("No caller or receiver supplied to $your callable.") + + capitalize = bool(capitalize) + if caller == receiver: + return "Your" if capitalize else "your" + + name = ( + caller.get_display_name(looker=receiver) + if hasattr(caller, "get_display_name") + else str(caller) + ) + + return name + "'s"
+ + +
[docs]def funcparser_callable_your_capitalize( + *args, you=None, receiver=None, mapping=None, capitalize=True, **kwargs +): + """ + Usage: $Your() - capitalizes the 'your' output. + + """ + return funcparser_callable_your( + *args, you=you, receiver=receiver, mapping=mapping, capitalize=capitalize, **kwargs + )
+ + +
[docs]def funcparser_callable_conjugate(*args, caller=None, receiver=None, **kwargs): + """ + Usage: $conj(word, [options]) + + Conjugate a verb according to if it should be 2nd or third person. + + Keyword Args: + caller (Object): The object who represents 'you' in the string. + receiver (Object): The recipient of the string. + + Returns: + str: The parsed string. + + Raises: + ParsingError: If `you` and `recipient` were not both supplied. + + Notes: + Note that the verb will not be capitalized. It also + assumes that the active party (You) is the one performing the verb. + This automatic conjugation will fail if the active part is another person + than 'you'. The caller/receiver must be passed to the parser directly. + + Examples: + This is often used in combination with the $you/You( callables. + + - `With a grin, $you() $conj(jump)` + + You will see "With a grin, you jump." + Others will see "With a grin, CharName jumps." + + """ + if not args: + return "" + if not (caller and receiver): + raise ParsingError("No caller/receiver supplied to $conj callable") + + second_person_str, third_person_str = verb_actor_stance_components(args[0]) + return second_person_str if caller == receiver else third_person_str
+ + +
[docs]def funcparser_callable_pronoun(*args, caller=None, receiver=None, capitalize=False, **kwargs): + """ + + Usage: $pron(word, [options]) + + Adjust pronouns to the expected form. Pronouns are words you use instead of a + proper name, such as 'him', 'herself', 'theirs' etc. These look different + depending on who sees the outgoing string. + + The parser maps between this table ... + + ==================== ======= ======= ========== ========== =========== + 1st/2nd person Subject Object Possessive Possessive Reflexive + Pronoun Pronoun Adjective Pronoun Pronoun + ==================== ======= ======= ========== ========== =========== + 1st person I me my mine myself + 1st person plural we us our ours ourselves + 2nd person you you your yours yourself + 2nd person plural you you your yours yourselves + ==================== ======= ======= ========== ========== =========== + + ... and this table (and vice versa). + + ==================== ======= ======= ========== ========== =========== + 3rd person Subject Object Possessive Possessive Reflexive + Pronoun Pronoun Adjective Pronoun Pronoun + ==================== ======= ======= ========== ========== =========== + 3rd person male he him his his himself + 3rd person female she her her hers herself + 3rd person neutral it it its itself + 3rd person plural they them their theirs themselves + ==================== ======= ======= ========== ========== =========== + + This system will examine `caller` for either a property or a callable `.gender` to + get a default gender fallback (if not specified in the call). If a callable, + `.gender` will be called without arguments and should return a string + `male`/`female`/`neutral`/`plural` (plural is considered a gender for this purpose). + If no `gender` property/callable is found, `neutral` is used as a fallback. + + The pronoun-type default (if not specified in call) is `subject pronoun`. + + Args: + pronoun (str): Input argument to parsed call. This can be any of the pronouns + in the table above. If given in 1st/second form, they will be mappped to + 3rd-person form for others viewing the message (but will need extra input + via the `gender`, see below). If given on 3rd person form, this will be + mapped to 2nd person form for `caller` unless `viewpoint` is specified + in options. + options (str, optional): A space- or comma-separated string detailing `pronoun_type`, + `gender`/`plural` and/or `viewpoint` to help the mapper differentiate between + non-unique cases (such as if `you` should become `him` or `they`). + Allowed values are: + + - `subject pronoun`/`subject`/`sp` (I, you, he, they) + - `object pronoun`/`object/`/`op` (me, you, him, them) + - `possessive adjective`/`adjective`/`pa` (my, your, his, their) + - `possessive pronoun`/`pronoun`/`pp` (mine, yours, his, theirs) + - `male`/`m` + - `female`/`f` + - `neutral`/`n` + - `plural`/`p` + - `1st person`/`1st`/`1` + - `2nd person`/`2nd`/`2` + - `3rd person`/`3rd`/`3` + + Keyword Args: + + caller (Object): The object creating the string. If this has a property 'gender', + it will be checked for a string 'male/female/neutral' to determine + the 3rd person gender (but if `pronoun_type` contains a gender + component, that takes precedence). Provided automatically to the + funcparser. + receiver (Object): The recipient of the string. This being the same as + `caller` or not helps determine 2nd vs 3rd-person forms. This is + provided automatically by the funcparser. + capitalize (bool): The input retains its capitalization. If this is set the output is + always capitalized. + + Examples: + + ====================== ============= =========== + Input caller sees others see + ====================== ============= =========== + $pron(I, m) I he + $pron(you,fo) you her + $pron(yourself) yourself itself + $pron(its) your its + $pron(you,op,p) you them + ====================== ============= =========== + + Notes: + There is no option to specify reflexive pronouns since they are all unique + and the mapping can always be auto-detected. + + """ + if not args: + return "" + + pronoun, *options = args + # options is either multiple args or a space-separated string + if len(options) == 1: + options = options[0] + + # default differentiators + default_pronoun_type = "subject pronoun" + default_gender = "neutral" + default_viewpoint = "2nd person" + + if hasattr(caller, "gender"): + if callable(caller.gender): + default_gender = caller.gender() + else: + default_gender = caller.gender + + if "viewpoint" in kwargs: + # passed into FuncParser initialization + default_viewpoint = kwargs["viewpoint"] + + pronoun_1st_or_2nd_person, pronoun_3rd_person = pronoun_to_viewpoints( + pronoun, + options, + pronoun_type=default_pronoun_type, + gender=default_gender, + viewpoint=default_viewpoint, + ) + + if capitalize: + pronoun_1st_or_2nd_person = pronoun_1st_or_2nd_person.capitalize() + pronoun_3rd_person = pronoun_3rd_person.capitalize() + + return pronoun_1st_or_2nd_person if caller == receiver else pronoun_3rd_person
+ + +
[docs]def funcparser_callable_pronoun_capitalize( + *args, caller=None, receiver=None, capitalize=True, **kwargs +): + """ + Usage: $Pron(word, [options]) - always maps to a capitalized word. + + """ + return funcparser_callable_pronoun( + *args, caller=caller, receiver=receiver, capitalize=capitalize, **kwargs + )
+ + +# these are made available as callables by adding 'evennia.utils.funcparser' as +# a callable-path when initializing the FuncParser. + +FUNCPARSER_CALLABLES = { + # 'standard' callables + # eval and arithmetic + "eval": funcparser_callable_eval, + "add": funcparser_callable_add, + "sub": funcparser_callable_sub, + "mult": funcparser_callable_mult, + "div": funcparser_callable_div, + "round": funcparser_callable_round, + "toint": funcparser_callable_toint, + # randomizers + "random": funcparser_callable_random, + "randint": funcparser_callable_randint, + "choice": funcparser_callable_choice, + # string manip + "pad": funcparser_callable_pad, + "crop": funcparser_callable_crop, + "just": funcparser_callable_justify, + "ljust": funcparser_callable_left_justify, + "rjust": funcparser_callable_right_justify, + "cjust": funcparser_callable_center_justify, + "justify": funcparser_callable_justify, # aliases for backwards compat + "justify_left": funcparser_callable_left_justify, + "justify_right": funcparser_callable_right_justify, + "justify_center": funcparser_callable_center_justify, + "space": funcparser_callable_space, + "clr": funcparser_callable_clr, + "pluralize": funcparser_callable_pluralize, + "int2str": funcparser_callable_int2str, + "an": funcparser_callable_an, +} + +SEARCHING_CALLABLES = { + # requires `caller` and optionally `access` to be passed into parser + "search": funcparser_callable_search, + "obj": funcparser_callable_search, # aliases for backwards compat + "objlist": funcparser_callable_search_list, + "dbref": funcparser_callable_search, +} + +ACTOR_STANCE_CALLABLES = { + # requires `you`, `receiver` and `mapping` to be passed into parser + "you": funcparser_callable_you, + "You": funcparser_callable_you_capitalize, + "your": funcparser_callable_your, + "Your": funcparser_callable_your_capitalize, + "obj": funcparser_callable_you, + "Obj": funcparser_callable_you_capitalize, + "conj": funcparser_callable_conjugate, + "pron": funcparser_callable_pronoun, + "Pron": funcparser_callable_pronoun_capitalize, + **FUNCPARSER_CALLABLES, +} +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/gametime.html b/docs/latest/_modules/evennia/utils/gametime.html new file mode 100644 index 0000000000..154ab72807 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/gametime.html @@ -0,0 +1,398 @@ + + + + + + + + evennia.utils.gametime — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.gametime

+"""
+The gametime module handles the global passage of time in the mud.
+
+It also supplies some useful methods to convert between
+in-mud time and real-world time as well allows to get the
+total runtime of the server and the current uptime.
+
+"""
+
+import time
+from datetime import datetime, timedelta
+
+from django.conf import settings
+from django.db.utils import OperationalError
+
+import evennia
+from evennia import DefaultScript
+from evennia.server.models import ServerConfig
+from evennia.utils.create import create_script
+
+# Speed-up factor of the in-game time compared
+# to real time.
+
+TIMEFACTOR = settings.TIME_FACTOR
+IGNORE_DOWNTIMES = settings.TIME_IGNORE_DOWNTIMES
+
+
+# Only set if gametime_reset was called at some point.
+try:
+    GAME_TIME_OFFSET = ServerConfig.objects.conf("gametime_offset", default=0)
+except OperationalError:
+    # the db is not initialized
+    print("Gametime offset could not load - db not set up.")
+    GAME_TIME_OFFSET = 0
+
+# Common real-life time measure, in seconds.
+# You should not change this.
+
+# these are kept updated by the server maintenance loop
+SERVER_START_TIME = 0.0
+SERVER_RUNTIME_LAST_UPDATED = 0.0
+SERVER_RUNTIME = 0.0
+
+# note that these should not be accessed directly since they may
+# need further processing. Access from server_epoch() and game_epoch().
+_SERVER_EPOCH = None
+_GAME_EPOCH = None
+
+# Helper Script dealing in gametime (created by `schedule` function
+# below).
+
+
+
[docs]class TimeScript(DefaultScript): + """Gametime-sensitive script.""" + +
[docs] def at_script_creation(self): + """The script is created.""" + self.key = "unknown scr" + self.interval = 100 + self.start_delay = True + self.persistent = True
+ +
[docs] def at_repeat(self): + """Call the callback and reset interval.""" + callback = self.db.callback + args = self.db.schedule_args or [] + kwargs = self.db.schedule_kwargs or {} + if callback: + callback(*args, **kwargs) + + seconds = real_seconds_until(**self.db.gametime) + self.start(interval=seconds, force_restart=True)
+ + +# Access functions + + +
[docs]def runtime(): + """ + Get the total runtime of the server since first start (minus + downtimes) + + Args: + format (bool, optional): Format into a time representation. + + Returns: + time (float or tuple): The runtime or the same time split up + into time units. + + """ + return SERVER_RUNTIME + time.time() - SERVER_RUNTIME_LAST_UPDATED
+ + +
[docs]def server_epoch(): + """ + Get the server epoch. We may need to calculate this on the fly. + + """ + global _SERVER_EPOCH + if not _SERVER_EPOCH: + _SERVER_EPOCH = ( + ServerConfig.objects.conf("server_epoch", default=None) or time.time() - runtime() + ) + return _SERVER_EPOCH
+ + +
[docs]def uptime(): + """ + Get the current uptime of the server since last reload + + Args: + format (bool, optional): Format into time representation. + + Returns: + time (float or tuple): The uptime or the same time split up + into time units. + + """ + return time.time() - SERVER_START_TIME
+ + +
[docs]def portal_uptime(): + """ + Get the current uptime of the portal. + + Returns: + time (float): The uptime of the portal. + """ + + return time.time() - evennia.SESSION_HANDLER.portal_start_time
+ + +
[docs]def game_epoch(): + """ + Get the game epoch. + + """ + game_epoch = settings.TIME_GAME_EPOCH + return game_epoch if game_epoch is not None else server_epoch()
+ + +
[docs]def gametime(absolute=False): + """ + Get the total gametime of the server since first start (minus downtimes) + + Args: + absolute (bool, optional): Get the absolute game time, including + the epoch. This could be converted to an absolute in-game + date. + + Returns: + time (float): The gametime as a virtual timestamp. + + Notes: + If one is using a standard calendar, one could convert the unformatted + return to a date using Python's standard `datetime` module like this: + `datetime.datetime.fromtimestamp(gametime(absolute=True))` + + """ + epoch = game_epoch() if absolute else 0 + if IGNORE_DOWNTIMES: + gtime = epoch + (time.time() - server_epoch()) * TIMEFACTOR + else: + gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR + return gtime
+ + +
[docs]def real_seconds_until(sec=None, min=None, hour=None, day=None, month=None, year=None): + """ + Return the real seconds until game time. + + Args: + sec (int or None): number of absolute seconds. + min (int or None): number of absolute minutes. + hour (int or None): number of absolute hours. + day (int or None): number of absolute days. + month (int or None): number of absolute months. + year (int or None): number of absolute years. + + Returns: + The number of real seconds before the given game time is up. + + Example: + real_seconds_until(hour=5, min=10, sec=0) + + If the game time is 5:00, TIME_FACTOR is set to 2 and you ask + the number of seconds until it's 5:10, then this function should + return 300 (5 minutes). + + + """ + current = datetime.fromtimestamp(gametime(absolute=True)) + s_sec = sec if sec is not None else current.second + s_min = min if min is not None else current.minute + s_hour = hour if hour is not None else current.hour + s_day = day if day is not None else current.day + s_month = month if month is not None else current.month + s_year = year if year is not None else current.year + projected = datetime(s_year, s_month, s_day, s_hour, s_min, s_sec) + + if projected <= current: + # We increase one unit of time depending on parameters + if month is not None: + projected = projected.replace(year=s_year + 1) + elif day is not None: + try: + projected = projected.replace(month=s_month + 1) + except ValueError: + projected = projected.replace(month=1) + elif hour is not None: + projected += timedelta(days=1) + elif min is not None: + projected += timedelta(seconds=3600) + else: + projected += timedelta(seconds=60) + + # Get the number of gametime seconds between these two dates + seconds = (projected - current).total_seconds() + return seconds / TIMEFACTOR
+ + +
[docs]def schedule( + callback, + repeat=False, + sec=None, + min=None, + hour=None, + day=None, + month=None, + year=None, + *args, + **kwargs, +): + """ + Call a callback at a given in-game time. + + Args: + callback (function): The callback function that will be called. Note + that the callback must be a module-level function, since the script will + be persistent. The callable should be on the form `callable(*args, **kwargs)` + where args/kwargs are passed into this schedule. + repeat (bool, optional): Defines if the callback should be called regularly + at the specified time. + sec (int or None): Number of absolute game seconds at which to run repeat. + min (int or None): Number of absolute minutes. + hour (int or None): Number of absolute hours. + day (int or None): Number of absolute days. + month (int or None): Number of absolute months. + year (int or None): Number of absolute years. + *args: Passed into the callable. Must be possible to store in Attribute. + **kwargs: Passed into the callable. Must be possible to store in Attribute. + + Returns: + Script: The created Script handling the scheduling. + + Examples: + :: + schedule(func, min=5, sec=0) # Will call 5 minutes past the next (in-game) hour. + schedule(func, hour=2, min=30, sec=0) # Will call the next (in-game) day at 02:30. + + """ + seconds = real_seconds_until(sec=sec, min=min, hour=hour, day=day, month=month, year=year) + script = create_script( + "evennia.utils.gametime.TimeScript", + key="TimeScript", + desc="A gametime-sensitive script", + interval=seconds, + start_delay=True, + repeats=-1 if repeat else 1, + ) + script.db.callback = callback + script.db.gametime = { + "sec": sec, + "min": min, + "hour": hour, + "day": day, + "month": month, + "year": year, + } + script.db.schedule_args = args + script.db.schedule_kwargs = kwargs + return script
+ + +
[docs]def reset_gametime(): + """ + Resets the game time to make it start from the current time. Note that + the epoch set by `settings.TIME_GAME_EPOCH` will still apply. + + """ + global GAME_TIME_OFFSET + GAME_TIME_OFFSET = runtime() + ServerConfig.objects.conf("gametime_offset", GAME_TIME_OFFSET)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/idmapper/manager.html b/docs/latest/_modules/evennia/utils/idmapper/manager.html new file mode 100644 index 0000000000..9f6d155a32 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/idmapper/manager.html @@ -0,0 +1,138 @@ + + + + + + + + evennia.utils.idmapper.manager — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.idmapper.manager

+"""
+IDmapper extension to the default manager.
+"""
+from django.db.models.manager import Manager
+
+
+
[docs]class SharedMemoryManager(Manager): + # TODO: improve on this implementation + # We need a way to handle reverse lookups so that this model can + # still use the singleton cache, but the active model isn't required + # to be a SharedMemoryModel. +
[docs] def get(self, *args, **kwargs): + """ + Data entity lookup. + """ + items = list(kwargs) + inst = None + if len(items) == 1: + # CL: support __exact + key = items[0] + if key.endswith("__exact"): + key = key[: -len("__exact")] + if key in ("pk", self.model._meta.pk.attname): + try: + inst = self.model.get_cached_instance(kwargs[items[0]]) + # we got the item from cache, but if this is a fk, check it's ours + if getattr(inst, str(self.field).split(".")[-1]) != self.instance: + inst = None + except Exception: + pass + if inst is None: + inst = super().get(*args, **kwargs) + return inst
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/idmapper/models.html b/docs/latest/_modules/evennia/utils/idmapper/models.html new file mode 100644 index 0000000000..20f060e5d6 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/idmapper/models.html @@ -0,0 +1,789 @@ + + + + + + + + evennia.utils.idmapper.models — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.idmapper.models

+"""
+Django ID mapper
+
+Modified for Evennia by making sure that no model references
+leave caching unexpectedly (no use of WeakRefs).
+
+Also adds `cache_size()` for monitoring the size of the cache.
+"""
+
+import gc
+import os
+import threading
+import time
+from weakref import WeakValueDictionary
+
+from django.core.exceptions import FieldError, ObjectDoesNotExist
+from django.db.models.base import Model, ModelBase
+from django.db.models.signals import post_migrate, post_save, pre_delete
+from django.db.transaction import atomic
+from django.db.utils import DatabaseError
+from twisted.internet.reactor import callFromThread
+
+from evennia.utils import logger
+from evennia.utils.utils import dbref, get_evennia_pids, to_str
+
+from .manager import SharedMemoryManager
+
+AUTO_FLUSH_MIN_INTERVAL = 60.0 * 5  # at least 5 mins between cache flushes
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+_DA = object.__delattr__
+_MONITOR_HANDLER = None
+
+# References to db-updated objects are stored here so the
+# main process can be informed to re-cache itself.
+PROC_MODIFIED_COUNT = 0
+PROC_MODIFIED_OBJS = WeakValueDictionary()
+
+# get info about the current process and thread; determine if our
+# current pid is different from the server PID (i.e.  # if we are in a
+# subprocess or not)
+_SELF_PID = os.getpid()
+_SERVER_PID, _PORTAL_PID = get_evennia_pids()
+_IS_SUBPROCESS = (_SERVER_PID and _PORTAL_PID) and _SELF_PID not in (_SERVER_PID, _PORTAL_PID)
+_IS_MAIN_THREAD = threading.current_thread().name == "MainThread"
+
+
+
[docs]class SharedMemoryModelBase(ModelBase): + # CL: upstream had a __new__ method that skipped ModelBase's __new__ if + # SharedMemoryModelBase was not in the model class's ancestors. It's not + # clear what was the intended purpose, but skipping ModelBase.__new__ + # broke things; in particular, default manager inheritance. + + def __call__(cls, *args, **kwargs): + """ + this method will either create an instance (by calling the default implementation) + or try to retrieve one from the class-wide cache by inferring the pk value from + `args` and `kwargs`. If instance caching is enabled for this class, the cache is + populated whenever possible (ie when it is possible to infer the pk value). + + """ + + def new_instance(): + return super(SharedMemoryModelBase, cls).__call__(*args, **kwargs) + + instance_key = cls._get_cache_key(args, kwargs) + # depending on the arguments, we might not be able to infer the PK, so in that case we + # create a new instance + if instance_key is None: + return new_instance() + cached_instance = cls.get_cached_instance(instance_key) + if cached_instance is None: + cached_instance = new_instance() + cls.cache_instance(cached_instance, new=True) + return cached_instance + + def _prepare(cls): + """ + Prepare the cache, making sure that proxies of the same db base + share the same cache. + + """ + # the dbmodel is either the proxy base or ourselves + dbmodel = cls._meta.concrete_model if cls._meta.proxy else cls + cls.__dbclass__ = dbmodel + if not hasattr(dbmodel, "__instance_cache__"): + # we store __instance_cache__ only on the dbmodel base + dbmodel.__instance_cache__ = {} + super()._prepare() + + def __new__(cls, name, bases, attrs): + """ + Field shortcut creation: + + Takes field names `db_*` and creates property wrappers named + without the `db_` prefix. So db_key -> key + + This wrapper happens on the class level, so there is no + overhead when creating objects. If a class already has a + wrapper of the given name, the automatic creation is skipped. + + Notes: + Remember to document this auto-wrapping in the class + header, this could seem very much like magic to the user + otherwise. + """ + + attrs["typename"] = cls.__name__ + attrs["path"] = "%s.%s" % (attrs["__module__"], name) + attrs["_is_deleted"] = False + + # set up the typeclass handling only if a variable _is_typeclass is set on the class + def create_wrapper(cls, fieldname, wrappername, editable=True, foreignkey=False): + "Helper method to create property wrappers with unique names (must be in separate call)" + + def _get(cls, fname): + "Wrapper for getting database field" + if _GA(cls, "_is_deleted"): + raise ObjectDoesNotExist( + "Cannot access %s: Hosting object was already deleted." % fname + ) + return _GA(cls, fieldname) + + def _get_foreign(cls, fname): + "Wrapper for returning foreignkey fields" + if _GA(cls, "_is_deleted"): + raise ObjectDoesNotExist( + "Cannot access %s: Hosting object was already deleted." % fname + ) + return _GA(cls, fieldname) + + def _set_nonedit(cls, fname, value): + "Wrapper for blocking editing of field" + raise FieldError("Field %s cannot be edited." % fname) + + def _set(cls, fname, value): + "Wrapper for setting database field" + if _GA(cls, "_is_deleted"): + raise ObjectDoesNotExist( + "Cannot set %s to %s: Hosting object was already deleted!" % (fname, value) + ) + _SA(cls, fname, value) + # only use explicit update_fields in save if we actually have a + # primary key assigned already (won't be set when first creating object) + update_fields = ( + [fname] if _GA(cls, "_get_pk_val")(_GA(cls, "_meta")) is not None else None + ) + _GA(cls, "save")(update_fields=update_fields) + + def _set_foreign(cls, fname, value): + "Setter only used on foreign key relations, allows setting with #dbref" + if _GA(cls, "_is_deleted"): + raise ObjectDoesNotExist( + "Cannot set %s to %s: Hosting object was already deleted!" % (fname, value) + ) + if isinstance(value, (str, int)): + value = to_str(value) + if value.isdigit() or value.startswith("#"): + # we also allow setting using dbrefs, if so we try to load the matching + # object. (we assume the object is of the same type as the class holding + # the field, if not a custom handler must be used for that field) + dbid = dbref(value, reqhash=False) + if dbid: + model = _GA(cls, "_meta").get_field(fname).model + try: + value = model._default_manager.get(id=dbid) + except ObjectDoesNotExist: + # maybe it is just a name that happens to look like a dbid + pass + _SA(cls, fname, value) + # only use explicit update_fields in save if we actually have a + # primary key assigned already (won't be set when first creating object) + update_fields = ( + [fname] if _GA(cls, "_get_pk_val")(_GA(cls, "_meta")) is not None else None + ) + _GA(cls, "save")(update_fields=update_fields) + + def _del_nonedit(cls, fname): + "wrapper for not allowing deletion" + raise FieldError("Field %s cannot be edited." % fname) + + def _del(cls, fname): + "Wrapper for clearing database field - sets it to None" + _SA(cls, fname, None) + update_fields = ( + [fname] if _GA(cls, "_get_pk_val")(_GA(cls, "_meta")) is not None else None + ) + _GA(cls, "save")(update_fields=update_fields) + + # wrapper factories + if not editable: + + def fget(cls): + return _get(cls, fieldname) + + def fset(cls, val): + return _set_nonedit(cls, fieldname, val) + + elif foreignkey: + + def fget(cls): + return _get_foreign(cls, fieldname) + + def fset(cls, val): + return _set_foreign(cls, fieldname, val) + + else: + + def fget(cls): + return _get(cls, fieldname) + + def fset(cls, val): + return _set(cls, fieldname, val) + + def fdel(cls): + return _del(cls, fieldname) if editable else _del_nonedit(cls, fieldname) + + # set docstrings for auto-doc + fget.__doc__ = "A wrapper for getting database field `%s`." % fieldname + fset.__doc__ = "A wrapper for setting (and saving) database field `%s`." % fieldname + fdel.__doc__ = "A wrapper for deleting database field `%s`." % fieldname + # assigning + attrs[wrappername] = property(fget, fset, fdel) + # type(cls).__setattr__(cls, wrappername, property(fget, fset, fdel))#, doc)) + + # exclude some models that should not auto-create wrapper fields + if cls.__name__ in ("ServerConfig", "TypeNick"): + return + # dynamically create the wrapper properties for all fields not already handled + # (manytomanyfields are always handlers) + for fieldname, field in ( + (fname, field) + for fname, field in list(attrs.items()) + if fname.startswith("db_") and type(field).__name__ != "ManyToManyField" + ): + foreignkey = type(field).__name__ == "ForeignKey" + wrappername = "dbid" if fieldname == "id" else fieldname.replace("db_", "", 1) + if wrappername not in attrs: + # makes sure not to overload manually created wrappers on the model + create_wrapper( + cls, fieldname, wrappername, editable=field.editable, foreignkey=foreignkey + ) + + return super().__new__(cls, name, bases, attrs)
+ + +
[docs]class SharedMemoryModel(Model, metaclass=SharedMemoryModelBase): + """ + Base class for idmapped objects. Inherit from `this`. + """ + + objects = SharedMemoryManager() + +
[docs] class Meta(object): + abstract = True
+ + @classmethod + def _get_cache_key(cls, args, kwargs): + """ + This method is used by the caching subsystem to infer the PK + value from the constructor arguments. It is used to decide if + an instance has to be built or is already in the cache. + + """ + result = None + # Quick hack for my composites work for now. + if hasattr(cls._meta, "pks"): + pk = cls._meta.pks[0] + else: + pk = cls._meta.pk + # get the index of the pk in the class fields. this should be calculated *once*, but isn't + # atm + pk_position = cls._meta.fields.index(pk) + if len(args) > pk_position: + # if it's in the args, we can get it easily by index + result = args[pk_position] + elif pk.attname in kwargs: + # retrieve the pk value. Note that we use attname instead of name, to handle the case + # where the pk is a a ForeignKey. + result = kwargs[pk.attname] + elif pk.name != pk.attname and pk.name in kwargs: + # ok we couldn't find the value, but maybe it's a FK and we can find the corresponding + # object instead + result = kwargs[pk.name] + + if result is not None and isinstance(result, Model): + # if the pk value happens to be a model instance (which can happen wich a FK), we'd + # rather use its own pk as the key + result = result._get_pk_val() + return result + +
[docs] @classmethod + def get_cached_instance(cls, id): + """ + Method to retrieve a cached instance by pk value. Returns None + when not found (which will always be the case when caching is + disabled for this class). Please note that the lookup will be + done even when instance caching is disabled. + + """ + return cls.__dbclass__.__instance_cache__.get(id)
+ +
[docs] @classmethod + def cache_instance(cls, instance, new=False): + """ + Method to store an instance in the cache. + + Args: + instance (Class instance): the instance to cache. + new (bool, optional): this is the first time this instance is + cached (i.e. this is not an update operation like after a + db save). + + """ + pk = instance._get_pk_val() + if pk is not None: + new = new or pk not in cls.__dbclass__.__instance_cache__ + cls.__dbclass__.__instance_cache__[pk] = instance + if new: + try: + # trigger the at_init hook only + # at first initialization + instance.at_init() + except AttributeError: + # The at_init hook is not assigned to all entities + pass
+ +
[docs] @classmethod + def get_all_cached_instances(cls): + """ + Return the objects so far cached by idmapper for this class. + + """ + return list(cls.__dbclass__.__instance_cache__.values())
+ + @classmethod + def _flush_cached_by_key(cls, key, force=True): + """ + Remove the cached reference. + + """ + try: + if force or cls.at_idmapper_flush(): + del cls.__dbclass__.__instance_cache__[key] + else: + cls._dbclass__.__instance_cache__[key].refresh_from_db() + except KeyError: + # No need to remove if cache doesn't contain it already + pass + +
[docs] @classmethod + def flush_cached_instance(cls, instance, force=True): + """ + Method to flush an instance from the cache. The instance will + always be flushed from the cache, since this is most likely + called from delete(), and we want to make sure we don't cache + dead objects. + + """ + cls._flush_cached_by_key(instance._get_pk_val(), force=force)
+ + # flush_cached_instance = classmethod(flush_cached_instance) + +
[docs] @classmethod + def flush_instance_cache(cls, force=False): + """ + This will clean safe objects from the cache. Use `force` + keyword to remove all objects, safe or not. + + """ + if force: + cls.__dbclass__.__instance_cache__ = {} + else: + cls.__dbclass__.__instance_cache__ = dict( + (key, obj) + for key, obj in cls.__dbclass__.__instance_cache__.items() + if not obj.at_idmapper_flush() + )
+ + # flush_instance_cache = classmethod(flush_instance_cache) + + # per-instance methods + + def __eq__(self, other): + return super().__eq__(other) + + def __hash__(self): + # this is required to maintain hashing + return super().__hash__() + +
[docs] def at_idmapper_flush(self): + """ + This is called when the idmapper cache is flushed and + allows customized actions when this happens. + + Returns: + do_flush (bool): If True, flush this object as normal. If + False, don't flush and expect this object to handle + the flushing on its own. + """ + return True
+ +
[docs] def flush_from_cache(self, force=False): + """ + Flush this instance from the instance cache. Use + `force` to override the result of at_idmapper_flush() for the object. + + """ + pk = self._get_pk_val() + if pk: + if force or self.at_idmapper_flush(): + self.__class__.__dbclass__.__instance_cache__.pop(pk, None)
+ +
[docs] def delete(self, *args, **kwargs): + """ + Delete the object, clearing cache. + + """ + self.flush_from_cache() + self._is_deleted = True + super().delete(*args, **kwargs)
+ +
[docs] def save(self, *args, **kwargs): + """ + Central database save operation. + + Notes: + Arguments as per Django documentation. + Calls `self.at_<fieldname>_postsave(new)` + (this is a wrapper set by oobhandler: + self._oob_at_<fieldname>_postsave()) + + """ + global _MONITOR_HANDLER + if not _MONITOR_HANDLER: + from evennia.scripts.monitorhandler import ( + MONITOR_HANDLER as _MONITOR_HANDLER, + ) + + if _IS_SUBPROCESS: + # we keep a store of objects modified in subprocesses so + # we know to update their caches in the central process + global PROC_MODIFIED_COUNT, PROC_MODIFIED_OBJS + PROC_MODIFIED_COUNT += 1 + PROC_MODIFIED_OBJS[PROC_MODIFIED_COUNT] = self + + if _IS_MAIN_THREAD: + # in main thread - normal operation + try: + with atomic(): + super().save(*args, **kwargs) + except DatabaseError: + # we handle the 'update_fields did not update any rows' error that + # may happen due to timing issues with attributes + ufields_removed = kwargs.pop("update_fields", None) + if ufields_removed: + with atomic(): + super().save(*args, **kwargs) + else: + raise + else: + # in another thread; make sure to save in reactor thread + def _save_callback(cls, *args, **kwargs): + super().save(*args, **kwargs) + + callFromThread(_save_callback, self, *args, **kwargs) + + if not self.pk: + # this can happen if some of the startup methods immediately + # delete the object (an example are Scripts that start and die immediately) + return + + # update field-update hooks and eventual OOB watchers + new = False + if "update_fields" in kwargs and kwargs["update_fields"]: + # get field objects from their names + update_fields = ( + self._meta.get_field(fieldname) for fieldname in kwargs.get("update_fields") + ) + else: + # meta.fields are already field objects; get them all + new = True + update_fields = self._meta.fields + for field in update_fields: + fieldname = field.name + # trigger eventual monitors + _MONITOR_HANDLER.at_update(self, fieldname) + # if a hook is defined it must be named exactly on this form + hookname = "at_%s_postsave" % fieldname + if hasattr(self, hookname) and callable(_GA(self, hookname)): + _GA(self, hookname)(new) + + # # if a trackerhandler is set on this object, update it with the + # # fieldname and the new value + # fieldtracker = "_oob_at_%s_postsave" % fieldname + # if hasattr(self, fieldtracker): + # _GA(self, fieldtracker)(fieldname) + pass
+ + +
[docs]class WeakSharedMemoryModelBase(SharedMemoryModelBase): + """ + Uses a WeakValue dictionary for caching instead of a regular one. + + """ + + def _prepare(cls): + super()._prepare() + cls.__dbclass__.__instance_cache__ = WeakValueDictionary()
+ + +
[docs]class WeakSharedMemoryModel(SharedMemoryModel, metaclass=WeakSharedMemoryModelBase): + """ + Uses a WeakValue dictionary for caching instead of a regular one + + """ + +
[docs] class Meta(object): + abstract = True
+ + +
[docs]def flush_cache(**kwargs): + """ + Flush idmapper cache. When doing so the cache will fire the + at_idmapper_flush hook to allow the object to optionally handle + its own flushing. + + Uses a signal so we make sure to catch cascades. + + """ + + def class_hierarchy(clslist): + """Recursively yield a class hierarchy""" + for cls in clslist: + subclass_list = cls.__subclasses__() + if subclass_list: + for subcls in class_hierarchy(subclass_list): + yield subcls + else: + yield cls + + for cls in class_hierarchy([SharedMemoryModel]): + cls.flush_instance_cache() + # run the python garbage collector + return gc.collect()
+ + +# request_finished.connect(flush_cache) +post_migrate.connect(flush_cache) + + +
[docs]def flush_cached_instance(sender, instance, **kwargs): + """ + Flush the idmapper cache only for a given instance. + + """ + # XXX: Is this the best way to make sure we can flush? + if not hasattr(instance, "flush_cached_instance"): + return + sender.flush_cached_instance(instance, force=True)
+ + +pre_delete.connect(flush_cached_instance) + + +
[docs]def update_cached_instance(sender, instance, **kwargs): + """ + Re-cache the given instance in the idmapper cache. + + """ + if not hasattr(instance, "cache_instance"): + return + sender.cache_instance(instance)
+ + +post_save.connect(update_cached_instance) + + +LAST_FLUSH = None + + +
[docs]def conditional_flush(max_rmem, force=False): + """ + Flush the cache if the estimated memory usage exceeds `max_rmem`. + + The flusher has a timeout to avoid flushing over and over + in particular situations (this means that for some setups + the memory usage will exceed the requirement and a server with + more memory is probably required for the given game). + + Args: + max_rmem (int): memory-usage estimation-treshold after which + cache is flushed. + force (bool, optional): forces a flush, regardless of timeout. + Defaults to `False`. + + """ + global LAST_FLUSH + + def mem2cachesize(desired_rmem): + """ + Estimate the size of the idmapper cache based on the memory + desired. This is used to optionally cap the cache size. + + desired_rmem - memory in MB (minimum 50MB) + + The formula is empirically estimated from usage tests (Linux) + and is + Ncache = RMEM - 35.0 / 0.0157 + where RMEM is given in MB and Ncache is the size of the cache + for this memory usage. VMEM tends to be about 100MB higher + than RMEM for large memory usage. + """ + vmem = max(desired_rmem, 50.0) + Ncache = int(abs(float(vmem) - 35.0) / 0.0157) + return Ncache + + if not max_rmem: + # auto-flush is disabled + return + + now = time.time() + if not LAST_FLUSH: + # server is just starting + LAST_FLUSH = now + return + + if ((now - LAST_FLUSH) < AUTO_FLUSH_MIN_INTERVAL) and not force: + # too soon after last flush. + logger.log_warn( + "Warning: Idmapper flush called more than once in %s min interval. Check memory usage." + % (AUTO_FLUSH_MIN_INTERVAL / 60.0) + ) + return + + if os.name == "nt": + # we can't look for mem info in Windows at the moment + return + + # check actual memory usage + Ncache_max = mem2cachesize(max_rmem) + Ncache, _ = cache_size() + actual_rmem = ( + float(os.popen("ps -p %d -o %s | tail -1" % (os.getpid(), "rss")).read()) / 1000.0 + ) # resident memory + + if Ncache >= Ncache_max and actual_rmem > max_rmem * 0.9: + # flush cache when number of objects in cache is big enough and our + # actual memory use is within 10% of our set max + flush_cache() + LAST_FLUSH = now
+ + +
[docs]def cache_size(mb=True): + """ + Calculate statistics about the cache. + + Note: we cannot get reliable memory statistics from the cache - + whereas we could do `getsizof` each object in cache, the result is + highly imprecise and for a large number of objects the result is + many times larger than the actual memory usage of the entire server; + Python is clearly reusing memory behind the scenes that we cannot + catch in an easy way here. Ideas are appreciated. /Griatch + + Returns: + total_num, {objclass:total_num, ...} + + """ + numtotal = [0] # use mutable to keep reference through recursion + classdict = {} + + def get_recurse(submodels): + for submodel in submodels: + subclasses = submodel.__subclasses__() + if not subclasses: + num = len(submodel.get_all_cached_instances()) + numtotal[0] += num + classdict[submodel.__dbclass__.__name__] = num + else: + get_recurse(subclasses) + + get_recurse(SharedMemoryModel.__subclasses__()) + return numtotal[0], classdict
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/idmapper/tests.html b/docs/latest/_modules/evennia/utils/idmapper/tests.html new file mode 100644 index 0000000000..3a8dd0312a --- /dev/null +++ b/docs/latest/_modules/evennia/utils/idmapper/tests.html @@ -0,0 +1,183 @@ + + + + + + + + evennia.utils.idmapper.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.idmapper.tests

+from django.db import models
+from django.test import TestCase
+
+from .models import SharedMemoryModel
+
+
+
[docs]class Category(SharedMemoryModel): + name = models.CharField(max_length=32)
+ + +
[docs]class RegularCategory(models.Model): + name = models.CharField(max_length=32)
+ + +
[docs]class Article(SharedMemoryModel): + name = models.CharField(max_length=32) + category = models.ForeignKey(Category, on_delete=models.CASCADE) + category2 = models.ForeignKey(RegularCategory, on_delete=models.CASCADE)
+ + +
[docs]class RegularArticle(models.Model): + name = models.CharField(max_length=32) + category = models.ForeignKey(Category, on_delete=models.CASCADE) + category2 = models.ForeignKey(RegularCategory, on_delete=models.CASCADE)
+ + +
[docs]class SharedMemorysTest(TestCase): + # TODO: test for cross model relation (singleton to regular) + +
[docs] def setUp(self): + super().setUp() + n = 0 + category = Category.objects.create(name="Category %d" % (n,)) + regcategory = RegularCategory.objects.create(name="Category %d" % (n,)) + + for n in range(0, 10): + Article.objects.create( + name="Article %d" % (n,), category=category, category2=regcategory + ) + RegularArticle.objects.create( + name="Article %d" % (n,), category=category, category2=regcategory + )
+ +
[docs] def testSharedMemoryReferences(self): + article_list = Article.objects.all().select_related("category") + last_article = article_list[0] + for article in article_list[1:]: + self.assertEqual(article.category is last_article.category, True) + last_article = article
+ +
[docs] def testRegularReferences(self): + article_list = RegularArticle.objects.all().select_related("category") + last_article = article_list[0] + for article in article_list[1:]: + self.assertEqual(article.category2 is last_article.category2, False) + last_article = article
+ +
[docs] def testMixedReferences(self): + article_list = RegularArticle.objects.all().select_related("category") + last_article = article_list[0] + for article in article_list[1:]: + self.assertEqual(article.category is last_article.category, True) + last_article = article
+ + # article_list = Article.objects.all().select_related('category') + # last_article = article_list[0] + # for article in article_list[1:]: + # self.assertEquals(article.category2 is last_article.category2, False) + # last_article = article + +
[docs] def testObjectDeletion(self): + # This must execute first so its guaranteed to be in memory. + list(Article.objects.all().select_related("category")) + + article = Article.objects.all()[0:1].get() + pk = article.pk + article.delete() + self.assertEqual(pk not in Article.__instance_cache__, True)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/logger.html b/docs/latest/_modules/evennia/utils/logger.html new file mode 100644 index 0000000000..2b6fc290ca --- /dev/null +++ b/docs/latest/_modules/evennia/utils/logger.html @@ -0,0 +1,740 @@ + + + + + + + + evennia.utils.logger — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.logger

+"""
+Logging facilities
+
+These are thin wrappers on top of Twisted's logging facilities; logs
+are all directed either to stdout (if Evennia is running in
+interactive mode) or to $GAME_DIR/server/logs.
+
+The log_file() function uses its own threading system to log to
+arbitrary files in $GAME_DIR/server/logs.
+
+Note: All logging functions have two aliases, log_type() and
+log_typemsg(). This is for historical, back-compatible reasons.
+
+"""
+
+
+import os
+import time
+from datetime import datetime
+from traceback import format_exc
+
+from twisted import logger as twisted_logger
+from twisted.internet.threads import deferToThread
+from twisted.python import logfile
+from twisted.python import util as twisted_util
+
+log = twisted_logger.Logger()
+
+_LOGDIR = None
+_LOG_ROTATE_SIZE = None
+_TIMEZONE = None
+_CHANNEL_LOG_NUM_TAIL_LINES = None
+
+_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
+
+
+def _log(msg, logfunc, prefix="", **kwargs):
+    try:
+        msg = str(msg)
+    except Exception as err:
+        msg = str(err)
+    if kwargs:
+        logfunc(msg, **kwargs)
+    else:
+        try:
+            for line in msg.splitlines():
+                logfunc("{line}", prefix=prefix, line=line)
+        except Exception as err:
+            log.error("Log failure: {err}", err=err)
+
+
+# log call functions (each has legacy aliases)
+
+
+
[docs]def log_info(msg, **kwargs): + """ + Logs any generic debugging/informative info that should appear in the log. + + Args: + msg: (string) The message to be logged. + **kwargs: If given, The `msg` is parsed as a format string with `{..}` + formatting markers that should match the keywords. + + """ + _log(msg, log.info, **kwargs)
+ + +info = log_info +log_infomsg = log_info +log_msg = log_info + + +
[docs]def log_warn(msg, **kwargs): + """ + Logs warnings that aren't critical but should be noted. + + Args: + msg (str): The message to be logged. + **kwargs: If given, The `msg` is parsed as a format string with `{..}` + formatting markers that should match the keywords. + + """ + _log(msg, log.warn, **kwargs)
+ + +warn = log_warn +warning = log_warn +log_warnmsg = log_warn + + +
[docs]def log_err(msg, **kwargs): + """ + Logs an error message to the server log. + + Args: + msg (str): The message to be logged. + **kwargs: If given, The `msg` is parsed as a format string with `{..}` + formatting markers that should match the keywords. + + """ + _log(msg, log.error, **kwargs)
+ + +error = log_err +err = log_err +log_errmsg = log_err + + +
[docs]def log_trace(msg=None, **kwargs): + """ + Log a traceback to the log. This should be called from within an + exception. + + Args: + msg (str, optional): Adds an extra line with added info + at the end of the traceback in the log. + **kwargs: If given, The `msg` is parsed as a format string with `{..}` + formatting markers that should match the keywords. + + """ + tracestring = format_exc() + if tracestring: + _log(tracestring, log.error, prefix="!!", **kwargs) + if msg: + _log(msg, log.error, prefix="!!", **kwargs)
+ + +log_tracemsg = log_trace +exception = log_trace +critical = log_trace +trace = log_trace + + +
[docs]def log_dep(msg, **kwargs): + """ + Prints a deprecation message. + + Args: + msg (str): The deprecation message to log. + **kwargs: If given, The `msg` is parsed as a format string with `{..}` + formatting markers that should match the keywords. + + """ + _log(msg, log.warn, prefix="DP", **kwargs)
+ + +dep = log_dep +deprecated = log_dep +log_depmsg = log_dep + + +
[docs]def log_sec(msg, **kwargs): + """ + Prints a security-related message. + + Args: + msg (str): The security message to log. + **kwargs: If given, The `msg` is parsed as a format string with `{..}` + formatting markers that should match the keywords. + + """ + _log(msg, log.info, prefix="SS", **kwargs)
+ + +sec = log_sec +security = log_sec +log_secmsg = log_sec + + +
[docs]def log_server(msg, **kwargs): + """ + This is for the Portal to log captured Server stdout messages (it's + usually only used during startup, before Server log is open) + + Args: + msg (str): The message to be logged. + **kwargs: If given, The `msg` is parsed as a format string with `{..}` + formatting markers that should match the keywords. + """ + _log(msg, log.info, prefix="Server", **kwargs)
+ + +
[docs]class GetLogObserver: + """ + Sets up how the system logs are formatted. + + """ + + component_prefix = "" + event_levels = { + twisted_logger.LogLevel.debug: "??", + twisted_logger.LogLevel.info: "..", + twisted_logger.LogLevel.warn: "WW", + twisted_logger.LogLevel.error: "EE", + twisted_logger.LogLevel.critical: "!!", + } + +
[docs] def format_log_event(self, event): + """ + By assigning log_system here, we skip the spammy display of namespace/level + in the default log output. + + [component_prefix] [date] [system/lvl] [msg] + + """ + # setting log_system fills the [..] block after the time stamp + prefix = event.get("prefix", "") + if prefix: + event["log_system"] = prefix + else: + lvl = event.get("log_level", twisted_logger.LogLevel.info) + event["log_system"] = self.event_levels.get(lvl, "-") + event["log_format"] = str(event.get("log_format", "")) + component_prefix = self.component_prefix or "" + log_msg = twisted_logger.formatEventAsClassicLogText( + event, formatTime=lambda e: twisted_logger.formatTime(e, _TIME_FORMAT) + ) + return f"{component_prefix}{log_msg}"
+ + def __call__(self, outfile): + return twisted_logger.FileLogObserver(outfile, self.format_log_event)
+ + +# Called by server/portal on startup + + +
[docs]class GetPortalLogObserver(GetLogObserver): + component_prefix = "|Portal| "
+ + +
[docs]class GetServerLogObserver(GetLogObserver): + component_prefix = ""
+ + +# logging overrides + + +
[docs]def timeformat(when=None): + """ + This helper function will format the current time in the same + way as the twisted logger does, including time zone info. Only + difference from official logger is that we only use two digits + for the year and don't show timezone for GMT times. + + Args: + when (int, optional): This is a time in POSIX seconds on the form + given by time.time(). If not given, this function will + use the current time. + + Returns: + timestring (str): A formatted string of the given time. + + """ + when = when if when else time.time() + + # time zone offset: UTC - the actual offset + tz_offset = datetime.utcfromtimestamp(when) - datetime.fromtimestamp(when) + tz_offset = tz_offset.days * 86400 + tz_offset.seconds + # correct given time to utc + when = datetime.utcfromtimestamp(when - tz_offset) + + if tz_offset == 0: + tz = "" + else: + tz_hour = abs(int(tz_offset // 3600)) + tz_mins = abs(int(tz_offset // 60 % 60)) + tz_sign = "-" if tz_offset >= 0 else "+" + tz = "%s%02d%s" % (tz_sign, tz_hour, (":%02d" % tz_mins if tz_mins else "")) + + return "%d-%02d-%02d %02d:%02d:%02d%s" % ( + when.year - 2000, + when.month, + when.day, + when.hour, + when.minute, + when.second, + tz, + )
+ + +
[docs]class WeeklyLogFile(logfile.DailyLogFile): + """ + Log file that rotates once per week by default. Overrides key methods to change format. + + """ + +
[docs] def __init__(self, name, directory, defaultMode=None, day_rotation=7, max_size=1000000): + """ + Args: + name (str): Name of log file. + directory (str): Directory holding the file. + defaultMode (str): Permissions used to create file. Defaults to + current permissions of this file if it exists. + day_rotation (int): How often to rotate the file. + max_size (int): Max size of log file before rotation (regardless of + time). Defaults to 1M. + + """ + self.day_rotation = day_rotation + self.max_size = max_size + self.size = 0 + logfile.DailyLogFile.__init__(self, name, directory, defaultMode=defaultMode)
+ + def _openFile(self): + logfile.DailyLogFile._openFile(self) + self.size = self._file.tell() + +
[docs] def shouldRotate(self): + """Rotate when the date has changed since last write""" + # all dates here are tuples (year, month, day) + now = self.toDate() + then = self.lastDate + return ( + now[0] > then[0] + or now[1] > then[1] + or now[2] > (then[2] + self.day_rotation) + or self.size >= self.max_size + )
+ +
[docs] def suffix(self, tupledate): + """Return the suffix given a (year, month, day) tuple or unixtime. + Format changed to have 03 for march instead of 3 etc (retaining unix + file order) + + If we get duplicate suffixes in location (due to hitting size limit), + we append __1, __2 etc. + + Examples: + server.log.2020_01_29 + server.log.2020_01_29__1 + server.log.2020_01_29__2 + + """ + suffix = "" + copy_suffix = 0 + while True: + try: + suffix = "_".join(["{:02d}".format(part) for part in tupledate]) + except Exception: + # try taking a float unixtime + suffix = "_".join(["{:02d}".format(part) for part in self.toDate(tupledate)]) + + suffix += f"__{copy_suffix}" if copy_suffix else "" + + if os.path.exists(f"{self.path}.{suffix}"): + # Append a higher copy_suffix to try to break the tie (starting from 2) + copy_suffix += 1 + else: + break + return suffix
+ +
[docs] def rotate(self): + try: + super().rotate() + except Exception: + log_trace(f"Could not rotate the log file {self.name}.")
+ +
[docs] def write(self, data): + """ + Write data to log file + + """ + logfile.BaseLogFile.write(self, data) + self.lastDate = max(self.lastDate, self.toDate()) + self.size += len(data)
+ + +# Arbitrary file logger + + +
[docs]class EvenniaLogFile(logfile.LogFile): + """ + A rotating logfile based off Twisted's LogFile. It overrides + the LogFile's rotate method in order to append some of the last + lines of the previous log to the start of the new log, in order + to preserve a continuous chat history for channel log files. + + """ + + # we delay import of settings to keep logger module as free + # from django as possible. + global _CHANNEL_LOG_NUM_TAIL_LINES + if _CHANNEL_LOG_NUM_TAIL_LINES is None: + from django.conf import settings + + _CHANNEL_LOG_NUM_TAIL_LINES = settings.CHANNEL_LOG_NUM_TAIL_LINES + num_lines_to_append = max(1, _CHANNEL_LOG_NUM_TAIL_LINES) + +
[docs] def rotate(self, num_lines_to_append=None): + """ + Rotates our log file and appends some number of lines from + the previous log to the start of the new one. + + """ + append_tail = ( + num_lines_to_append if num_lines_to_append is not None else self.num_lines_to_append + ) + if not append_tail: + logfile.LogFile.rotate(self) + return + lines = tail_log_file(self.path, 0, self.num_lines_to_append) + super().rotate() + for line in lines: + self.write(line)
+ +
[docs] def seek(self, *args, **kwargs): + """ + Convenience method for accessing our _file attribute's seek method, + which is used in tail_log_function. + + Args: + *args: Same args as file.seek + **kwargs: Same kwargs as file.seek + + """ + return self._file.seek(*args, **kwargs)
+ +
[docs] def readlines(self, *args, **kwargs): + """ + Convenience method for accessing our _file attribute's readlines method, + which is used in tail_log_function. + + Args: + *args: same args as file.readlines + **kwargs: same kwargs as file.readlines + + Returns: + lines (list): lines from our _file attribute. + + """ + lines = [] + for line in self._file.readlines(*args, **kwargs): + try: + lines.append(line.decode("utf-8")) + except UnicodeDecodeError: + try: + lines.append(str(line)) + except Exception: + lines.append("") + return lines
+ + +_LOG_FILE_HANDLES = {} # holds open log handles +_LOG_FILE_HANDLE_COUNTS = {} +_LOG_FILE_HANDLE_RESET = 500 + + +def _open_log_file(filename): + """ + Helper to open the log file (always in the log dir) and cache its + handle. Will create a new file in the log dir if one didn't + exist. + + To avoid keeping the filehandle open indefinitely we reset it every + _LOG_FILE_HANDLE_RESET accesses. This may help resolve issues for very + long uptimes and heavy log use. + + """ + # we delay import of settings to keep logger module as free + # from django as possible. + global _LOG_FILE_HANDLES, _LOG_FILE_HANDLE_COUNTS, _LOGDIR, _LOG_ROTATE_SIZE + if not _LOGDIR: + from django.conf import settings + + _LOGDIR = settings.LOG_DIR + _LOG_ROTATE_SIZE = max(1000, settings.CHANNEL_LOG_ROTATE_SIZE) + + filename = os.path.join(_LOGDIR, filename) + if filename in _LOG_FILE_HANDLES: + _LOG_FILE_HANDLE_COUNTS[filename] += 1 + if _LOG_FILE_HANDLE_COUNTS[filename] > _LOG_FILE_HANDLE_RESET: + # close/refresh handle + _LOG_FILE_HANDLES[filename].close() + del _LOG_FILE_HANDLES[filename] + else: + # return cached handle + return _LOG_FILE_HANDLES[filename] + try: + filehandle = EvenniaLogFile.fromFullPath(filename, rotateLength=_LOG_ROTATE_SIZE) + # filehandle = open(filename, "a+") # append mode + reading + _LOG_FILE_HANDLES[filename] = filehandle + _LOG_FILE_HANDLE_COUNTS[filename] = 0 + return filehandle + except IOError: + log_trace() + return None + + +
[docs]def log_file(msg, filename="game.log"): + """ + Arbitrary file logger using threads. + + Args: + msg (str): String to append to logfile. + filename (str, optional): Defaults to 'game.log'. All logs + will appear in the logs directory and log entries will start + on new lines following datetime info. + + """ + + def callback(filehandle, msg): + """Writing to file and flushing result""" + msg = "\n%s [-] %s" % (timeformat(), msg.strip()) + filehandle.write(msg) + # since we don't close the handle, we need to flush + # manually or log file won't be written to until the + # write buffer is full. + filehandle.flush() + + def errback(failure): + """Catching errors to normal log""" + log_trace() + + # save to server/logs/ directory + filehandle = _open_log_file(filename) + if filehandle: + deferToThread(callback, filehandle, msg).addErrback(errback)
+ + +
[docs]def log_file_exists(filename="game.log"): + """ + Determine if a log-file already exists. + + Args: + filename (str): The filename (within the log-dir). + + Returns: + bool: If the log file exists or not. + + """ + global _LOGDIR + if not _LOGDIR: + from django.conf import settings + + _LOGDIR = settings.LOG_DIR + + filename = os.path.join(_LOGDIR, filename) + return os.path.exists(filename)
+ + +
[docs]def rotate_log_file(filename="game.log", num_lines_to_append=None): + """ + Force-rotate a log-file, without + + Args: + filename (str): The log file, located in settings.LOG_DIR. + num_lines_to_append (int, optional): Include N number of + lines from previous file in new one. If `None`, use default. + Set to 0 to include no lines. + + """ + if log_file_exists(filename): + file_handle = _open_log_file(filename) + if file_handle: + file_handle.rotate(num_lines_to_append=num_lines_to_append)
+ + +
[docs]def delete_log_file(filename): + """ + Delete a log file + + Args: + filename(str): The name of the log file, located in settings.LOG_DIR + """ + if log_file_exists(filename): + global _LOGDIR + if not _LOGDIR: + from django.conf import settings + + _LOGDIR = settings.LOG_DIR + + filename = os.path.join(_LOGDIR, filename) + os.remove(filename)
+ + +
[docs]def tail_log_file(filename, offset, nlines, callback=None): + """ + Return the tail of the log file. + + Args: + filename (str): The name of the log file, presumed to be in + the Evennia log dir. + offset (int): The line offset *from the end of the file* to start + reading from. 0 means to start at the latest entry. + nlines (int): How many lines to return, counting backwards + from the offset. If file is shorter, will get all lines. + callback (callable, optional): A function to manage the result of the + asynchronous file access. This will get a list of lines. If unset, + the tail will happen synchronously. + + Returns: + lines (deferred or list): This will be a deferred if `callable` is given, + otherwise it will be a list with The nline entries from the end of the file, or + all if the file is shorter than nlines. + + """ + + def seek_file(filehandle, offset, nlines, callback): + """step backwards in chunks and stop only when we have enough lines""" + lines_found = [] + buffer_size = 4098 + block_count = -1 + while len(lines_found) < (offset + nlines): + try: + # scan backwards in file, starting from the end + filehandle.seek(block_count * buffer_size, os.SEEK_END) + except IOError: + # file too small for this seek, take what we've got + filehandle.seek(0) + lines_found = filehandle.readlines() + break + lines_found = filehandle.readlines() + block_count -= 1 + # return the right number of lines + lines_found = lines_found[-nlines - offset : -offset if offset else None] + if callback: + callback(lines_found) + return None + else: + return lines_found + + def errback(failure): + """Catching errors to normal log""" + log_trace() + + filehandle = _open_log_file(filename) + if filehandle: + if callback: + return deferToThread(seek_file, filehandle, offset, nlines, callback).addErrback( + errback + ) + else: + return seek_file(filehandle, offset, nlines, callback) + else: + return None
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/optionclasses.html b/docs/latest/_modules/evennia/utils/optionclasses.html new file mode 100644 index 0000000000..62c6f33454 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/optionclasses.html @@ -0,0 +1,429 @@ + + + + + + + + evennia.utils.optionclasses — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.optionclasses

+import datetime
+
+from evennia import logger
+from evennia.utils import validatorfuncs
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.utils import crop
+from evennia.utils.validatorfuncs import _TZ_DICT
+
+
+
[docs]class BaseOption: + """ + Abstract Class to deal with encapsulating individual Options. An Option has + a name/key, a description to display in relevant commands and menus, and a + default value. It saves to the owner's Attributes using its Handler's save + category. + + Designed to be extremely overloadable as some options can be cantankerous. + + Properties: + valid: Shortcut to the loaded VALID_HANDLER. + validator_key (str): The key of the Validator this uses. + + """ + + def __str__(self): + return "<Option {key}: {value}>".format(key=self.key, value=crop(str(self.value), width=10)) + + def __repr__(self): + return str(self) + +
[docs] def __init__(self, handler, key, description, default): + """ + + Args: + handler (OptionHandler): The OptionHandler that 'owns' this Option. + key (str): The name this will be used for storage in a dictionary. + Must be unique per OptionHandler. + description (str): What this Option's text will show in commands and menus. + default: A default value for this Option. + + """ + self.handler = handler + self.key = key + self.default_value = default + self.description = description + + # Value Storage contains None until the Option is loaded. + self.value_storage = None + + # And it's not loaded until it's called upon to spit out its contents. + self.loaded = False
+ + @property + def changed(self): + return self.value_storage != self.default_value + + @property + def default(self): + return self.default_value + + @property + def value(self): + if not self.loaded: + self.load() + if self.loaded: + return self.value_storage + else: + return self.default + + @value.setter + def value(self, value): + self.set(value) + +
[docs] def set(self, value, **kwargs): + """ + Takes user input and stores appropriately. This method allows for + passing extra instructions into the validator. + + Args: + value (str): The new value of this Option. + kwargs (any): Any kwargs will be passed into + `self.validate(value, **kwargs)` and `self.save(**kwargs)`. + + """ + final_value = self.validate(value, **kwargs) + self.value_storage = final_value + self.loaded = True + self.save(**kwargs)
+ +
[docs] def load(self): + """ + Takes the provided save data, validates it, and gets this Option ready to use. + + Returns: + Boolean: Whether loading was successful. + + """ + loadfunc = self.handler.loadfunc + load_kwargs = self.handler.load_kwargs + + try: + self.value_storage = self.deserialize( + loadfunc(self.key, default=self.default_value, **load_kwargs) + ) + except Exception: + logger.log_trace() + return False + self.loaded = True + return True
+ +
[docs] def save(self, **kwargs): + """ + Stores the current value using `.handler.save_handler(self.key, value, **kwargs)` + where `kwargs` are a combination of those passed into this function and + the ones specified by the `OptionHandler`. + + Keyword Args: + any (any): Not used by default. These are passed in from self.set + and allows the option to let the caller customize saving by + overriding or extend the default save kwargs + + """ + value = self.serialize() + save_kwargs = {**self.handler.save_kwargs, **kwargs} + savefunc = self.handler.savefunc + savefunc(self.key, value=value, **save_kwargs)
+ +
[docs] def deserialize(self, save_data): + """ + Perform sanity-checking on the save data as it is loaded from storage. + This isn't the same as what validator-functions provide (those work on + user input). For example, save data might be a timedelta or a list or + some other object. + + Args: + save_data: The data to check. + + Returns: + any (any): Whatever the Option needs to track, like a string or a + datetime. The display hook is responsible for what is actually + displayed to user. + """ + return save_data
+ +
[docs] def serialize(self): + """ + Serializes the save data for Attribute storage. + + Returns: + any (any): Whatever is best for storage. + + """ + return self.value_storage
+ +
[docs] def validate(self, value, **kwargs): + """ + Validate user input, which is presumed to be a string. + + Args: + value (str): User input. + account (AccountDB): The Account that is performing the validation. + This is necessary because of other settings which may affect the + check, such as an Account's timezone affecting how their datetime + entries are processed. + Returns: + any (any): The results of the validation. + Raises: + ValidationError: If input value failed validation. + + """ + return validatorfuncs.text(value, option_key=self.key, **kwargs)
+ +
[docs] def display(self, **kwargs) -> str: + """ + Renders the Option's value as something pretty to look at. + + Keyword Args: + any (any): These are options passed by the caller to potentially + customize display dynamically. + + Returns: + str: How the stored value should be projected to users (e.g. a raw + timedelta is pretty ugly). + + """ + return self.value if isinstance(self.value, str) else str(self.value)
+ + +# Option classes + + +
[docs]class Text(BaseOption): +
[docs] def deserialize(self, save_data): + got_data = str(save_data) + if not got_data: + raise ValueError(f"{self.key} expected Text data, got '{save_data}'") + return got_data
+ + +
[docs]class Email(BaseOption): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.email(value, option_key=self.key, **kwargs)
+ +
[docs] def deserialize(self, save_data): + got_data = str(save_data) + if not got_data: + raise ValueError(f"{self.key} expected String data, got '{save_data}'") + return got_data
+ + +
[docs]class Boolean(BaseOption): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.boolean(value, option_key=self.key, **kwargs)
+ +
[docs] def display(self, **kwargs): + if self.value: + return "1 - On/True" + return "0 - Off/False"
+ +
[docs] def serialize(self): + return self.value
+ +
[docs] def deserialize(self, save_data): + if not isinstance(save_data, bool): + raise ValueError(f"{self.key} expected Boolean, got '{save_data}'") + return save_data
+ + +
[docs]class Color(BaseOption): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.color(value, option_key=self.key, **kwargs)
+ +
[docs] def display(self, **kwargs): + return f"{self.value} - |{self.value}this|n"
+ +
[docs] def deserialize(self, save_data): + if not save_data or len(strip_ansi(f"|{save_data}|n")) > 0: + raise ValueError(f"{self.key} expected Color Code, got '{save_data}'") + return save_data
+ + +
[docs]class Timezone(BaseOption): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.timezone(value, option_key=self.key, **kwargs)
+ + @property + def default(self): + return _TZ_DICT[self.default_value] + +
[docs] def deserialize(self, save_data): + if save_data not in _TZ_DICT: + raise ValueError(f"{self.key} expected Timezone Data, got '{save_data}'") + return _TZ_DICT[save_data]
+ +
[docs] def serialize(self): + return str(self.value_storage)
+ + +
[docs]class UnsignedInteger(BaseOption): + validator_key = "unsigned_integer" + +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.unsigned_integer(value, option_key=self.key, **kwargs)
+ +
[docs] def deserialize(self, save_data): + if isinstance(save_data, int) and save_data >= 0: + return save_data + raise ValueError(f"{self.key} expected Whole Number 0+, got '{save_data}'")
+ + +
[docs]class SignedInteger(BaseOption): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.signed_integer(value, option_key=self.key, **kwargs)
+ +
[docs] def deserialize(self, save_data): + if isinstance(save_data, int): + return save_data + raise ValueError(f"{self.key} expected Whole Number, got '{save_data}'")
+ + +
[docs]class PositiveInteger(BaseOption): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.positive_integer(value, option_key=self.key, **kwargs)
+ +
[docs] def deserialize(self, save_data): + if isinstance(save_data, int) and save_data > 0: + return save_data + raise ValueError(f"{self.key} expected Whole Number 1+, got '{save_data}'")
+ + +
[docs]class Duration(BaseOption): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.duration(value, option_key=self.key, **kwargs)
+ +
[docs] def deserialize(self, save_data): + if isinstance(save_data, int): + return datetime.timedelta(0, save_data, 0, 0, 0, 0, 0) + raise ValueError(f"{self.key} expected Timedelta in seconds, got '{save_data}'")
+ +
[docs] def serialize(self): + return self.value_storage.seconds
+ + +
[docs]class Datetime(BaseOption): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.datetime(value, option_key=self.key, **kwargs)
+ +
[docs] def deserialize(self, save_data): + if isinstance(save_data, int): + return datetime.datetime.utcfromtimestamp(save_data) + raise ValueError(f"{self.key} expected UTC Datetime in EPOCH format, got '{save_data}'")
+ +
[docs] def serialize(self): + return int(self.value_storage.strftime("%s"))
+ + +
[docs]class Future(Datetime): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.future(value, option_key=self.key, **kwargs)
+ + +
[docs]class Lock(Text): +
[docs] def validate(self, value, **kwargs): + return validatorfuncs.lock(value, option_key=self.key, **kwargs)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/optionhandler.html b/docs/latest/_modules/evennia/utils/optionhandler.html new file mode 100644 index 0000000000..ecc9d67402 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/optionhandler.html @@ -0,0 +1,291 @@ + + + + + + + + evennia.utils.optionhandler — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.optionhandler

+from django.utils.translation import gettext as _
+
+from evennia.utils.containers import OPTION_CLASSES
+from evennia.utils.utils import string_partial_matching
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+
+
+
[docs]class InMemorySaveHandler: + """ + Fallback SaveHandler, implementing a minimum of the required save mechanism + and storing data in memory. + + """ + +
[docs] def __init__(self): + self.storage = {}
+ +
[docs] def add(self, key, value=None, **kwargs): + self.storage[key] = value
+ +
[docs] def get(self, key, default=None, **kwargs): + return self.storage.get(key, default)
+ + +
[docs]class OptionHandler: + """ + This is a generic Option handler. Retrieve options either as properties on + this handler or by using the .get method. + + This is used for Account.options but it could be used by Scripts or Objects + just as easily. All it needs to be provided is an options_dict. + + """ + +
[docs] def __init__( + self, + obj, + options_dict=None, + savefunc=None, + loadfunc=None, + save_kwargs=None, + load_kwargs=None, + ): + """ + Initialize an OptionHandler. + + Args: + obj (object): The object this handler sits on. This is usually a TypedObject. + options_dict (dict): A dictionary of option keys, where the values + are options. The format of those tuples is: ('key', "Description to + show", 'option_type', <default value>) + savefunc (callable): A callable for all options to call when saving itself. + It will be called as `savefunc(key, value, **save_kwargs)`. A common one + to pass would be AttributeHandler.add. + loadfunc (callable): A callable for all options to call when loading data into + itself. It will be called as `loadfunc(key, default=default, **load_kwargs)`. + A common one to pass would be AttributeHandler.get. + save_kwargs (any): Optional extra kwargs to pass into `savefunc` above. + load_kwargs (any): Optional extra kwargs to pass into `loadfunc` above. + + Notes: + Both loadfunc and savefunc must be specified. If only one is given, the other + will be ignored and in-memory storage will be used. + + """ + self.obj = obj + self.options_dict = {} if options_dict is None else options_dict + + if not savefunc and loadfunc: + self._in_memory_handler = InMemorySaveHandler() + savefunc = InMemorySaveHandler.add + loadfunc = InMemorySaveHandler.get + self.savefunc = savefunc + self.loadfunc = loadfunc + self.save_kwargs = {} if save_kwargs is None else save_kwargs + self.load_kwargs = {} if load_kwargs is None else load_kwargs + + # This dictionary stores the in-memory Options objects by their key for + # quick lookup. + self.options = {}
+ + def __getattr__(self, key): + """ + Allow for obj.options.key + + """ + return self.get(key) + + def __setattr__(self, key, value): + """ + Allow for obj.options.key = value + + But we must be careful to avoid infinite loops! + + """ + try: + if key in _GA(self, "options_dict"): + _GA(self, "set")(key, value) + except AttributeError: + pass + _SA(self, key, value) + + def _load_option(self, key): + """ + Loads option on-demand if it has not been loaded yet. + + Args: + key (str): The option being loaded. + + Returns: + + """ + desc, clsname, default_val = self.options_dict[key] + loaded_option = OPTION_CLASSES.get(clsname)(self, key, desc, default_val) + # store the value for future easy access + self.options[key] = loaded_option + return loaded_option + +
[docs] def get(self, key, default=None, return_obj=False, raise_error=False): + """ + Retrieves an Option stored in the handler. Will load it if it doesn't exist. + + Args: + key (str): The option key to retrieve. + default (any): What to return if the option is defined. + return_obj (bool, optional): If True, returns the actual option + object instead of its value. + raise_error (bool, optional): Raise Exception if key is not found in options. + Returns: + option_value (any or Option): An option value the Option itself. + Raises: + KeyError: If option is not defined. + + """ + if key not in self.options_dict: + if raise_error: + raise KeyError(_("Option not found!")) + return default + # get the options or load/recache it + op_found = self.options.get(key) or self._load_option(key) + return op_found if return_obj else op_found.value
+ +
[docs] def set(self, key, value, **kwargs) -> "BaseOption": + """ + Change an individual option. + + Args: + key (str): The key of an option that can be changed. Allows partial matching. + value (str): The value that should be checked, coerced, and stored.: + kwargs (any, optional): These are passed into the Option's validation function, + save function and display function and allows to customize either. + + Returns: + BaseOption: The matched object. Its new value can be accessed with + op.value or op.display(). + + """ + if not key: + raise ValueError(_("Option field blank!")) + match = string_partial_matching(list(self.options_dict.keys()), key, ret_index=False) + if not match: + raise ValueError(_("Option not found!")) + if len(match) > 1: + raise ValueError( + _("Multiple matches:") + f"{', '.join(match)}. " + _("Please be more specific.") + ) + match = match[0] + op = self.get(match, return_obj=True) + op.set(value, **kwargs) + return op
+ +
[docs] def all(self, return_objs=False): + """ + Get all options defined on this handler. + + Args: + return_objs (bool, optional): Return the actual Option objects rather + than their values. + Returns: + all_options (dict): All options on this handler, either `{key: value}` + or `{key: <Option>}` if `return_objs` is `True`. + + """ + return [self.get(key, return_obj=return_objs) for key in self.options_dict]
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/picklefield.html b/docs/latest/_modules/evennia/utils/picklefield.html new file mode 100644 index 0000000000..fc1e953779 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/picklefield.html @@ -0,0 +1,405 @@ + + + + + + + + evennia.utils.picklefield — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.picklefield

+#
+#  Copyright (c) 2009-2010 Gintautas Miliauskas
+#
+#   Permission is hereby granted, free of charge, to any person
+#   obtaining a copy of this software and associated documentation
+#   files (the "Software"), to deal in the Software without
+#   restriction, including without limitation the rights to use,
+#   copy, modify, merge, publish, distribute, sublicense, and/or sell
+#   copies of the Software, and to permit persons to whom the
+#   Software is furnished to do so, subject to the following
+#   conditions:
+#
+#   The above copyright notice and this permission notice shall be
+#   included in all copies or substantial portions of the Software.
+#
+#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+#   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+#   OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+#   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+#   HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+#   WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+#   OTHER DEALINGS IN THE SOFTWARE.
+
+"""
+Pickle field implementation for Django.
+
+Modified for Evennia by Griatch and the Evennia community.
+
+"""
+from ast import literal_eval
+from base64 import b64decode, b64encode
+from copy import Error as CopyError
+from copy import deepcopy
+from datetime import datetime
+from pickle import dumps, loads
+from zlib import compress, decompress
+
+# import six # this is actually a pypy component, not in default syslib
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.forms.fields import CharField
+from django.forms.widgets import Textarea
+from django.utils.encoding import force_str
+
+from evennia.utils.dbserialize import pack_dbobj
+
+DEFAULT_PROTOCOL = 4
+
+
+
[docs]class PickledObject(str): + """ + A subclass of string so it can be told whether a string is a pickled + object or not (if the object is an instance of this class then it must + [well, should] be a pickled one). + + Only really useful for passing pre-encoded values to ``default`` + with ``dbsafe_encode``, not that doing so is necessary. If you + remove PickledObject and its references, you won't be able to pass + in pre-encoded values anymore, but you can always just pass in the + python objects themselves. + """
+ + +class _ObjectWrapper(object): + """ + A class used to wrap object that have properties that may clash with the + ORM internals. + + For example, objects with the `prepare_database_save` property such as + `django.db.Model` subclasses won't work under certain conditions and the + same apply for trying to retrieve any `callable` object. + """ + + __slots__ = ("_obj",) + + def __init__(self, obj): + self._obj = obj + + +
[docs]def wrap_conflictual_object(obj): + if hasattr(obj, "prepare_database_save") or callable(obj): + obj = _ObjectWrapper(obj) + return obj
+ + +
[docs]def dbsafe_encode(value, compress_object=False, pickle_protocol=DEFAULT_PROTOCOL): + # We use deepcopy() here to avoid a problem with cPickle, where dumps + # can generate different character streams for same lookup value if + # they are referenced differently. + # The reason this is important is because we do all of our lookups as + # simple string matches, thus the character streams must be the same + # for the lookups to work properly. See tests.py for more information. + try: + value = deepcopy(value) + except CopyError: + # this can happen on a manager query where the search query string is a + # database model. + value = pack_dbobj(value) + + value = dumps(value, protocol=pickle_protocol) + + if compress_object: + value = compress(value) + value = b64encode(value).decode() # decode bytes to str + return PickledObject(value)
+ + +
[docs]def dbsafe_decode(value, compress_object=False): + value = value.encode() # encode str to bytes + value = b64decode(value) + if compress_object: + value = decompress(value) + return loads(value)
+ + +
[docs]class PickledWidget(Textarea): + """ + This is responsible for outputting HTML representing a given field. + """ + +
[docs] def render(self, name, value, attrs=None, renderer=None): + """Display of the PickledField in django admin""" + + repr_value = repr(value) + + # analyze represented value to see how big the field should be + if attrs is not None: + attrs["name"] = name + else: + attrs = {"name": name} + attrs["cols"] = 30 + # adapt number of rows to number of lines in string + rows = 1 + if isinstance(value, str) and "\n" in repr_value: + rows = max(1, len(value.split("\n"))) + attrs["rows"] = rows + attrs = self.build_attrs(attrs) + + try: + # necessary to convert it back after repr(), otherwise validation errors will mutate it + value = literal_eval(repr_value) + except (ValueError, SyntaxError): + # we could not eval it, just show its prepresentation + value = repr_value + return super().render(name, value, attrs=attrs, renderer=renderer)
+ +
[docs] def value_from_datadict(self, data, files, name): + dat = data.get(name) + # import evennia;evennia.set_trace() + return dat
+ + +
[docs]class PickledFormField(CharField): + """ + This represents one input field for the form. + + """ + + widget = PickledWidget + default_error_messages = dict(CharField.default_error_messages) + default_error_messages["invalid"] = ( + "This is not a Python Literal. You can store things like strings, " + "integers, or floats, but you must do it by typing them as you would " + "type them in the Python Interpreter. For instance, strings must be " + "surrounded by quote marks. We have converted it to a string for your " + "convenience. If it is acceptable, please hit save again." + ) + +
[docs] def __init__(self, *args, **kwargs): + # This needs to fall through to literal_eval. + kwargs["required"] = False + super().__init__(*args, **kwargs)
+ +
[docs] def clean(self, value): + value = super().clean(value) + + # handle empty input + try: + if not value.strip(): + # Field was left blank. Make this None. + value = "None" + except AttributeError: + pass + + # parse raw Python + try: + return literal_eval(value) + except (ValueError, SyntaxError): + pass + + # fall through to parsing the repr() of the data + try: + value = repr(value) + return literal_eval(value) + except (ValueError, SyntaxError): + raise ValidationError(self.error_messages["invalid"])
+ + +
[docs]class PickledObjectField(models.Field): + """ + A field that will accept *any* python object and store it in the + database. PickledObjectField will optionally compress its values if + declared with the keyword argument ``compress=True``. + + Does not actually encode and compress ``None`` objects (although you + can still do lookups using None). This way, it is still possible to + use the ``isnull`` lookup type correctly. + """ + +
[docs] def __init__(self, *args, **kwargs): + self.compress = kwargs.pop("compress", False) + self.protocol = kwargs.pop("protocol", DEFAULT_PROTOCOL) + super().__init__(*args, **kwargs)
+ +
[docs] def get_default(self): + """ + Returns the default value for this field. + + The default implementation on models.Field calls force_str + on the default, which means you can't set arbitrary Python + objects as the default. To fix this, we just return the value + without calling force_str on it. Note that if you set a + callable as a default, the field will still call it. It will + *not* try to pickle and encode it. + + """ + if self.has_default(): + if callable(self.default): + return self.default() + return self.default + # If the field doesn't have a default, then we punt to models.Field. + return super().get_default()
+ +
[docs] def from_db_value(self, value, *args): + """ + B64decode and unpickle the object, optionally decompressing it. + + If an error is raised in de-pickling and we're sure the value is + a definite pickle, the error is allowed to propagate. If we + aren't sure if the value is a pickle or not, then we catch the + error and return the original value instead. + + """ + if value is not None: + try: + value = dbsafe_decode(value, self.compress) + except Exception: + # If the value is a definite pickle; and an error is raised in + # de-pickling it should be allowed to propogate. + if isinstance(value, PickledObject): + raise + else: + if isinstance(value, _ObjectWrapper): + return value._obj + return value
+ +
[docs] def formfield(self, **kwargs): + return PickledFormField(**kwargs)
+ +
[docs] def pre_save(self, model_instance, add): + value = super().pre_save(model_instance, add) + return wrap_conflictual_object(value)
+ +
[docs] def get_db_prep_value(self, value, connection=None, prepared=False): + """ + Pickle and b64encode the object, optionally compressing it. + + The pickling protocol is specified explicitly (by default 2), + rather than as -1 or HIGHEST_PROTOCOL, because we don't want the + protocol to change over time. If it did, ``exact`` and ``in`` + lookups would likely fail, since pickle would now be generating + a different string. + + """ + if value is not None and not isinstance(value, PickledObject): + # We call force_str here explicitly, so that the encoded string + # isn't rejected by the postgresql backend. Alternatively, + # we could have just registered PickledObject with the psycopg + # marshaller (telling it to store it like it would a string), but + # since both of these methods result in the same value being stored, + # doing things this way is much easier. + value = force_str(dbsafe_encode(value, self.compress, self.protocol)) + return value
+ +
[docs] def value_to_string(self, obj): + value = self.value_from_object(obj) + return self.get_db_prep_value(value)
+ +
[docs] def get_internal_type(self): + return "TextField"
+ +
[docs] def get_db_prep_lookup(self, lookup_type, value, connection=None, prepared=False): + if lookup_type not in ["exact", "in", "isnull"]: + raise TypeError("Lookup type %s is not supported." % lookup_type) + # The Field model already calls get_db_prep_value before doing the + # actual lookup, so all we need to do is limit the lookup types. + return super().get_db_prep_lookup( + lookup_type, value, connection=connection, prepared=prepared + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/search.html b/docs/latest/_modules/evennia/utils/search.html new file mode 100644 index 0000000000..17d8607c3c --- /dev/null +++ b/docs/latest/_modules/evennia/utils/search.html @@ -0,0 +1,502 @@ + + + + + + + + evennia.utils.search — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.search

+"""
+This is a convenient container gathering all the main
+search methods for the various database tables.
+
+It is intended to be used e.g. as
+
+> from evennia.utils import search
+> match = search.objects(...)
+
+Note that this is not intended to be a complete listing of all search
+methods! You need to refer to the respective manager to get all
+possible search methods. To get to the managers from your code, import
+the database model and call its 'objects' property.
+
+Also remember that all commands in this file return lists (also if
+there is only one match) unless noted otherwise.
+
+Example: To reach the search method 'get_object_with_account'
+         in evennia/objects/managers.py:
+
+> from evennia.objects.models import ObjectDB
+> match = Object.objects.get_object_with_account(...)
+
+
+"""
+
+# Import the manager methods to be wrapped
+
+from django.contrib.contenttypes.models import ContentType
+from django.db.utils import OperationalError, ProgrammingError
+
+# limit symbol import from API
+__all__ = (
+    "search_object",
+    "search_account",
+    "search_script",
+    "search_message",
+    "search_channel",
+    "search_help_entry",
+    "search_tag",
+    "search_script_tag",
+    "search_account_tag",
+    "search_channel_tag",
+    "search_typeclass",
+)
+
+
+# import objects this way to avoid circular import problems
+try:
+    ObjectDB = ContentType.objects.get(app_label="objects", model="objectdb").model_class()
+    AccountDB = ContentType.objects.get(app_label="accounts", model="accountdb").model_class()
+    ScriptDB = ContentType.objects.get(app_label="scripts", model="scriptdb").model_class()
+    Msg = ContentType.objects.get(app_label="comms", model="msg").model_class()
+    ChannelDB = ContentType.objects.get(app_label="comms", model="channeldb").model_class()
+    HelpEntry = ContentType.objects.get(app_label="help", model="helpentry").model_class()
+    Tag = ContentType.objects.get(app_label="typeclasses", model="tag").model_class()
+except (OperationalError, ProgrammingError):
+    # this is a fallback used during tests/doc building
+    print("Database not available yet - using temporary fallback for search managers.")
+    from evennia.accounts.models import AccountDB
+    from evennia.comms.models import ChannelDB, Msg
+    from evennia.help.models import HelpEntry
+    from evennia.objects.models import ObjectDB
+    from evennia.scripts.models import ScriptDB
+    from evennia.typeclasses.tags import Tag  # noqa
+
+# -------------------------------------------------------------------
+# Search manager-wrappers
+# -------------------------------------------------------------------
+
+#
+# Search objects as a character
+#
+# NOTE: A more powerful wrapper of this method
+#  is reachable from within each command class
+#  by using self.caller.search()!
+#
+#    def object_search(self, ostring=None,
+#                      attribute_name=None,
+#                      typeclass=None,
+#                      candidates=None,
+#                      exact=True):
+#
+#        Search globally or in a list of candidates and return results.
+#        The result is always a list of Objects (or the empty list)
+#
+#        Arguments:
+#        ostring: (str) The string to compare names against. By default (if
+#                  not attribute_name is set), this will search object.key
+#                  and object.aliases in order. Can also be on the form #dbref,
+#                  which will, if exact=True be matched against primary key.
+#        attribute_name: (str): Use this named ObjectAttribute to match ostring
+#                        against, instead of the defaults.
+#        typeclass (str or TypeClass): restrict matches to objects having
+#                  this typeclass. This will help speed up global searches.
+#        candidates (list obj ObjectDBs): If supplied, search will only be
+#                  performed among the candidates in this list. A common list
+#                  of candidates is the contents of the current location.
+#        exact (bool): Match names/aliases exactly or partially. Partial
+#                  matching matches the beginning of words in the names/aliases,
+#                  using a matching routine to separate multiple matches in
+#                  names with multiple components (so "bi sw" will match
+#                  "Big sword"). Since this is more expensive than exact
+#                  matching, it is recommended to be used together with
+#                  the objlist keyword to limit the number of possibilities.
+#                  This keyword has no meaning if attribute_name is set.
+#
+#        Returns:
+#        A list of matching objects (or a list with one unique match)
+#    def object_search(self, ostring, caller=None,
+#                      candidates=None,
+#                      attribute_name=None):
+#
+search_object = ObjectDB.objects.search_object
+search_objects = search_object
+object_search = search_object
+objects = search_objects
+
+#
+# Search for accounts
+#
+# account_search(self, ostring)
+
+#     Searches for a particular account by name or
+#     database id.
+#
+#     ostring = a string or database id.
+#
+
+search_account = AccountDB.objects.search_account
+search_accounts = search_account
+account_search = search_account
+accounts = search_accounts
+
+#
+#   Searching for scripts
+#
+# script_search(self, ostring, obj=None, only_timed=False)
+#
+#     Search for a particular script.
+#
+#     ostring - search criterion - a script ID or key
+#     obj - limit search to scripts defined on this object
+#     only_timed - limit search only to scripts that run
+#                  on a timer.
+#
+
+search_script = ScriptDB.objects.search_script
+search_scripts = search_script
+script_search = search_script
+scripts = search_scripts
+#
+# Searching for communication messages
+#
+#
+# message_search(self, sender=None, receiver=None, channel=None, freetext=None)
+#
+#     Search the message database for particular messages. At least one
+#     of the arguments must be given to do a search.
+#
+#     sender - get messages sent by a particular account
+#     receiver - get messages received by a certain account
+#     channel - get messages sent to a particular channel
+#     freetext - Search for a text string in a message.
+#                NOTE: This can potentially be slow, so make sure to supply
+#                one of the other arguments to limit the search.
+#
+
+search_message = Msg.objects.search_message
+search_messages = search_message
+message_search = search_message
+messages = search_messages
+
+#
+# Search for Communication Channels
+#
+# channel_search(self, ostring)
+#
+#     Search the channel database for a particular channel.
+#
+#     ostring - the key or database id of the channel.
+#     exact -  requires an exact ostring match (not case sensitive)
+#
+
+search_channel = ChannelDB.objects.search_channel
+search_channels = search_channel
+channel_search = search_channel
+channels = search_channels
+
+#
+# Find help entry objects.
+#
+# search_help(self, ostring, help_category=None)
+#
+#     Retrieve a search entry object.
+#
+#     ostring - the help topic to look for
+#     category - limit the search to a particular help topic
+#
+
+search_help = HelpEntry.objects.search_help
+search_help_entry = search_help
+search_help_entries = search_help
+help_entry_search = search_help
+help_entries = search_help
+
+
+# Locate Attributes
+
+#    search_object_attribute(key, category, value, strvalue) (also search_attribute works)
+#    search_account_attribute(key, category, value, strvalue) (also search_attribute works)
+#    search_script_attribute(key, category, value, strvalue) (also search_attribute works)
+#    search_channel_attribute(key, category, value, strvalue) (also search_attribute works)
+
+# Note that these return the object attached to the Attribute,
+# not the attribute object itself (this is usually what you want)
+
+
+def search_object_attribute(
+    key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs
+):
+    return ObjectDB.objects.get_by_attribute(
+        key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs
+    )
+
+
+def search_account_attribute(
+    key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs
+):
+    return AccountDB.objects.get_by_attribute(
+        key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs
+    )
+
+
+def search_script_attribute(
+    key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs
+):
+    return ScriptDB.objects.get_by_attribute(
+        key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs
+    )
+
+
+def search_channel_attribute(
+    key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs
+):
+    return ChannelDB.objects.get_by_attribute(
+        key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs
+    )
+
+
+# search for attribute objects
+search_attribute_object = ObjectDB.objects.get_attribute
+
+# Locate Tags
+
+#    search_object_tag(key=None, category=None) (also search_tag works)
+#    search_account_tag(key=None, category=None)
+#    search_script_tag(key=None, category=None)
+#    search_channel_tag(key=None, category=None)
+
+# Note that this returns the object attached to the tag, not the tag
+# object itself (this is usually what you want)
+
+
+def search_object_by_tag(key=None, category=None, tagtype=None, **kwargs):
+    """
+    Find object based on tag or category.
+
+    Args:
+        key (str, optional): The tag key to search for.
+        category (str, optional): The category of tag
+            to search for. If not set, uncategorized
+            tags will be searched.
+        tagtype (str, optional): 'type' of Tag, by default
+            this is either `None` (a normal Tag), `alias` or
+            `permission`. This always apply to all queried tags.
+        kwargs (any): Other optional parameter that may be supported
+            by the manager method.
+
+    Returns:
+        matches (list): List of Objects with tags matching
+            the search criteria, or an empty list if no
+            matches were found.
+
+    """
+    return ObjectDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs)
+
+
+search_tag = search_object_by_tag  # this is the most common case
+
+
+
[docs]def search_account_tag(key=None, category=None, tagtype=None, **kwargs): + """ + Find account based on tag or category. + + Args: + key (str, optional): The tag key to search for. + category (str, optional): The category of tag + to search for. If not set, uncategorized + tags will be searched. + tagtype (str, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. This always apply to all queried tags. + kwargs (any): Other optional parameter that may be supported + by the manager method. + + Returns: + matches (list): List of Accounts with tags matching + the search criteria, or an empty list if no + matches were found. + + """ + return AccountDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs)
+ + +
[docs]def search_script_tag(key=None, category=None, tagtype=None, **kwargs): + """ + Find script based on tag or category. + + Args: + key (str, optional): The tag key to search for. + category (str, optional): The category of tag + to search for. If not set, uncategorized + tags will be searched. + tagtype (str, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. This always apply to all queried tags. + kwargs (any): Other optional parameter that may be supported + by the manager method. + + Returns: + matches (list): List of Scripts with tags matching + the search criteria, or an empty list if no + matches were found. + + """ + return ScriptDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs)
+ + +
[docs]def search_channel_tag(key=None, category=None, tagtype=None, **kwargs): + """ + Find channel based on tag or category. + + Args: + key (str, optional): The tag key to search for. + category (str, optional): The category of tag + to search for. If not set, uncategorized + tags will be searched. + tagtype (str, optional): 'type' of Tag, by default + this is either `None` (a normal Tag), `alias` or + `permission`. This always apply to all queried tags. + kwargs (any): Other optional parameter that may be supported + by the manager method. + + Returns: + matches (list): List of Channels with tags matching + the search criteria, or an empty list if no + matches were found. + + """ + return ChannelDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs)
+ + +# search for tag objects (not the objects they are attached to +search_tag_object = ObjectDB.objects.get_tag + + +# Locate Objects by Typeclass + +# search_objects_by_typeclass(typeclass="", include_children=False, include_parents=False) (also search_typeclass works) +# This returns the objects of the given typeclass + + +def search_objects_by_typeclass(typeclass, include_children=False, include_parents=False): + """ + Searches through all objects returning those of a certain typeclass. + + Args: + typeclass (str or class): A typeclass class or a python path to a typeclass. + include_children (bool, optional): Return objects with + given typeclass *and* all children inheriting from this + typeclass. Mutuall exclusive to `include_parents`. + include_parents (bool, optional): Return objects with + given typeclass *and* all parents to this typeclass. + Mutually exclusive to `include_children`. + + Returns: + objects (list): The objects found with the given typeclasses. + """ + return ObjectDB.objects.typeclass_search( + typeclass=typeclass, + include_children=include_children, + include_parents=include_parents, + ) + + +search_typeclass = search_objects_by_typeclass +
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/test_resources.html b/docs/latest/_modules/evennia/utils/test_resources.html new file mode 100644 index 0000000000..f713c3cbed --- /dev/null +++ b/docs/latest/_modules/evennia/utils/test_resources.html @@ -0,0 +1,728 @@ + + + + + + + + evennia.utils.test_resources — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.test_resources

+"""
+Various helper resources for writing unittests.
+
+Classes for testing Evennia core:
+
+- `BaseEvenniaTestCase` - no default objects, only enforced default settings
+- `BaseEvenniaTest` - all default objects, enforced default settings
+- `BaseEvenniaCommandTest` - for testing Commands, enforced default settings
+
+Classes for testing game folder content:
+
+- `EvenniaTestCase` - no default objects, using gamedir settings (identical to
+   standard Python TestCase)
+- `EvenniaTest` - all default objects, using gamedir settings
+- `EvenniaCommandTest` - for testing game folder commands, using gamedir settings
+
+Other:
+
+- `EvenniaTestMixin` - A class mixin for creating the test environment objects, for
+  making custom tests.
+- `EvenniaCommandMixin` - A class mixin that adds support for command testing with the .call()
+  helper. Used by the command-test classes, but can be used for making a customt test class.
+
+"""
+import re
+import sys
+import types
+
+import evennia
+from django.conf import settings
+from django.test import TestCase, override_settings
+from evennia import settings_default
+from evennia.accounts.accounts import DefaultAccount
+from evennia.commands.command import InterruptCommand
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.objects.objects import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
+from evennia.scripts.scripts import DefaultScript
+from evennia.server.serversession import ServerSession
+from evennia.utils import ansi, create
+from evennia.utils.idmapper.models import flush_cache
+from evennia.utils.utils import all_from_module, to_str
+from mock import MagicMock, Mock, patch
+from twisted.internet.defer import Deferred
+
+_RE_STRIP_EVMENU = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)
+
+
+# set up a 'pristine' setting, unaffected by any changes in mygame
+DEFAULT_SETTING_RESETS = dict(
+    CONNECTION_SCREEN_MODULE="evennia.game_template.server.conf.connection_screens",
+    AT_SERVER_STARTSTOP_MODULE="evennia.game_template.server.conf.at_server_startstop",
+    AT_SERVICES_PLUGINS_MODULES=["evennia.game_template.server.conf.server_services_plugins"],
+    PORTAL_SERVICES_PLUGIN_MODULES=["evennia.game_template.server.conf.portal_services_plugins"],
+    MSSP_META_MODULE="evennia.game_template.server.conf.mssp",
+    WEB_PLUGINS_MODULE="server.conf.web_plugins",
+    LOCK_FUNC_MODULES=("evennia.locks.lockfuncs", "evennia.game_template.server.conf.lockfuncs"),
+    INPUT_FUNC_MODULES=[
+        "evennia.server.inputfuncs",
+        "evennia.game_template.server.conf.inputfuncs",
+    ],
+    PROTOTYPE_MODULES=["evennia.game_template.world.prototypes"],
+    CMDSET_UNLOGGEDIN="evennia.game_template.commands.default_cmdsets.UnloggedinCmdSet",
+    CMDSET_SESSION="evennia.game_template.commands.default_cmdsets.SessionCmdSet",
+    CMDSET_CHARACTER="evennia.game_template.commands.default_cmdsets.CharacterCmdSet",
+    CMDSET_ACCOUNT="evennia.game_template.commands.default_cmdsets.AccountCmdSet",
+    CMDSET_PATHS=["evennia.game_template.commands", "evennia", "evennia.contrib"],
+    TYPECLASS_PATHS=[
+        "evennia",
+        "evennia.contrib",
+        "evennia.contrib.game_systems",
+        "evennia.contrib.base_systems",
+        "evennia.contrib.full_systems",
+        "evennia.contrib.tutorials",
+        "evennia.contrib.utils",
+    ],
+    BASE_ACCOUNT_TYPECLASS="evennia.accounts.accounts.DefaultAccount",
+    BASE_OBJECT_TYPECLASS="evennia.objects.objects.DefaultObject",
+    BASE_CHARACTER_TYPECLASS="evennia.objects.objects.DefaultCharacter",
+    BASE_ROOM_TYPECLASS="evennia.objects.objects.DefaultRoom",
+    BASE_EXIT_TYPECLASS="evennia.objects.objects.DefaultExit",
+    BASE_CHANNEL_TYPECLASS="evennia.comms.comms.DefaultChannel",
+    BASE_SCRIPT_TYPECLASS="evennia.scripts.scripts.DefaultScript",
+    BASE_BATCHPROCESS_PATHS=[
+        "evennia.game_template.world",
+        "evennia.contrib",
+        "evennia.contrib.tutorials",
+    ],
+    FILE_HELP_ENTRY_MODULES=["evennia.game_template.world.help_entries"],
+    FUNCPARSER_OUTGOING_MESSAGES_MODULES=[
+        "evennia.utils.funcparser",
+        "evennia.game_template.server.conf.inlinefuncs",
+    ],
+    FUNCPARSER_PROTOTYPE_PARSING_MODULES=[
+        "evennia.prototypes.protfuncs",
+        "evennia.game_template.server.conf.prototypefuncs",
+    ],
+    BASE_GUEST_TYPECLASS="evennia.accounts.accounts.DefaultGuest",
+    # a special setting boolean TEST_ENVIRONMENT is set by the test runner
+    # while the test suite is running.
+    DEFAULT_HOME="#1",
+    TEST_ENVIRONMENT=True,
+)
+
+DEFAULT_SETTINGS = {**all_from_module(settings_default), **DEFAULT_SETTING_RESETS}
+DEFAULT_SETTINGS.pop("DATABASES")  # we want different dbs tested in CI
+
+
+# mocking of evennia.utils.utils.delay
+
[docs]def mockdelay(timedelay, callback, *args, **kwargs): + callback(*args, **kwargs) + return Deferred()
+ + +# mocking of twisted's deferLater +
[docs]def mockdeferLater(reactor, timedelay, callback, *args, **kwargs): + callback(*args, **kwargs) + return Deferred()
+ + +
[docs]def unload_module(module): + """ + Reset import so one can mock global constants. + + Args: + module (module, object or str): The module will + be removed so it will have to be imported again. If given + an object, the module in which that object sits will be unloaded. A string + should directly give the module pathname to unload. + + Example: + + ```python + # (in a test method) + unload_module(foo) + with mock.patch("foo.GLOBALTHING", "mockval"): + import foo + ... # test code using foo.GLOBALTHING, now set to 'mockval' + ``` + + This allows for mocking constants global to the module, since + otherwise those would not be mocked (since a module is only + loaded once). + + """ + if isinstance(module, str): + modulename = module + elif hasattr(module, "__module__"): + modulename = module.__module__ + else: + modulename = module.__name__ + + if modulename in sys.modules: + del sys.modules[modulename]
+ + +def _mock_deferlater(reactor, timedelay, callback, *args, **kwargs): + callback(*args, **kwargs) + return Deferred() + + +
[docs]class EvenniaTestMixin: + """ + Evennia test environment mixin + """ + + account_typeclass = DefaultAccount + object_typeclass = DefaultObject + character_typeclass = DefaultCharacter + exit_typeclass = DefaultExit + room_typeclass = DefaultRoom + script_typeclass = DefaultScript + +
[docs] def create_accounts(self): + self.account = create.create_account( + "TestAccount", + email="test@test.com", + password="testpassword", + typeclass=self.account_typeclass, + ) + self.account2 = create.create_account( + "TestAccount2", + email="test@test.com", + password="testpassword", + typeclass=self.account_typeclass, + ) + self.account.permissions.add("Developer")
+ +
[docs] def teardown_accounts(self): + if hasattr(self, "account"): + self.account.delete() + if hasattr(self, "account2"): + self.account2.delete()
+ + # Set up fake prototype module for allowing tests to use named prototypes. +
[docs] @override_settings(PROTOTYPE_MODULES=["evennia.utils.tests.data.prototypes_example"]) + def create_rooms(self): + self.room1 = create.create_object(self.room_typeclass, key="Room", nohome=True) + self.room1.db.desc = "room_desc" + + self.room2 = create.create_object(self.room_typeclass, key="Room2") + self.exit = create.create_object( + self.exit_typeclass, key="out", location=self.room1, destination=self.room2 + )
+ +
[docs] def create_objs(self): + self.obj1 = create.create_object( + self.object_typeclass, key="Obj", location=self.room1, home=self.room1 + ) + self.obj2 = create.create_object( + self.object_typeclass, key="Obj2", location=self.room1, home=self.room1 + )
+ +
[docs] def create_chars(self): + self.char1 = create.create_object( + self.character_typeclass, key="Char", location=self.room1, home=self.room1 + ) + self.char1.permissions.add("Developer") + self.char2 = create.create_object( + self.character_typeclass, key="Char2", location=self.room1, home=self.room1 + ) + self.char1.account = self.account + self.account.db._last_puppet = self.char1 + self.char2.account = self.account2 + self.account2.db._last_puppet = self.char2
+ +
[docs] def create_script(self): + self.script = create.create_script(self.script_typeclass, key="Script")
+ +
[docs] def setup_session(self): + dummysession = ServerSession() + dummysession.init_session("telnet", ("localhost", "testmode"), evennia.SESSION_HANDLER) + dummysession.sessid = 1 + evennia.SESSION_HANDLER.portal_connect( + dummysession.get_sync_data() + ) # note that this creates a new Session! + session = evennia.SESSION_HANDLER.session_from_sessid(1) # the real session + evennia.SESSION_HANDLER.login(session, self.account, testmode=True) + self.session = session
+ +
[docs] def teardown_session(self): + if hasattr(self, "sessions"): + del evennia.SESSION_HANDLER[self.session.sessid]
+ +
[docs] @patch("evennia.scripts.taskhandler.deferLater", _mock_deferlater) + def setUp(self): + """ + Sets up testing environment + """ + self.backups = ( + evennia.SESSION_HANDLER.data_out, + evennia.SESSION_HANDLER.disconnect, + settings.DEFAULT_HOME, + settings.PROTOTYPE_MODULES, + ) + evennia.SESSION_HANDLER.data_out = Mock() + evennia.SESSION_HANDLER.disconnect = Mock() + + self.create_accounts() + self.create_rooms() + self.create_objs() + self.create_chars() + self.create_script() + self.setup_session()
+ +
[docs] @override_settings(PROTOTYPE_MODULES=["evennia.utils.tests.data.prototypes_example"]) + def tearDown(self): + flush_cache() + try: + evennia.SESSION_HANDLER.data_out = self.backups[0] + evennia.SESSION_HANDLER.disconnect = self.backups[1] + settings.DEFAULT_HOME = self.backups[2] + settings.PROTOTYPE_MODULES = self.backups[3] + except AttributeError as err: + raise AttributeError( + f"{err}: Teardown error. If you overrode the `setUp()` method " + "in your test, make sure you also added `super().setUp()`!" + ) + + del evennia.SESSION_HANDLER[self.session.sessid] + self.teardown_accounts() + super().tearDown()
+ + +
[docs]@patch("evennia.server.portal.portal.LoopingCall", new=MagicMock()) +class EvenniaCommandTestMixin: + """ + Mixin to add to a test in order to provide the `.call` helper for + testing the execution and returns of a command. + + Tests a Command by running it and comparing what messages it sends with + expected values. This tests without actually spinning up the cmdhandler + for every test, which is more controlled. + + Example: + :: + + from commands.echo import CmdEcho + + class MyCommandTest(EvenniaTest, CommandTestMixin): + + def test_echo(self): + ''' + Test that the echo command really returns + what you pass into it. + ''' + self.call(MyCommand(), "hello world!", + "You hear your echo: 'Hello world!'") + + """ + + # formatting for .call's error message + _ERROR_FORMAT = """ +=========================== Wanted message =================================== +{expected_msg} +=========================== Returned message ================================= +{returned_msg} +============================================================================== +""".rstrip() + +
[docs] def call( + self, + cmdobj, + input_args, + msg=None, + cmdset=None, + noansi=True, + caller=None, + receiver=None, + cmdstring=None, + obj=None, + inputs=None, + raw_string=None, + ): + """ + Test a command by assigning all the needed properties to a cmdobj and + running the sequence. The resulting `.msg` calls will be mocked and + the text= calls to them compared to a expected output. + + Args: + cmdobj (Command): The command object to use. + input_args (str): This should be the full input the Command should + see, such as 'look here'. This will become `.args` for the Command + instance to parse. + msg (str or dict, optional): This is the expected return value(s) + returned through `caller.msg(text=...)` calls in the command. If a string, the + receiver is controlled with the `receiver` kwarg (defaults to `caller`). + If this is a `dict`, it is a mapping + `{receiver1: "expected1", receiver2: "expected2",...}` and `receiver` is + ignored. The message(s) are compared with the actual messages returned + to the receiver(s) as the Command runs. Each check uses `.startswith`, + so you can choose to only include the first part of the + returned message if that's enough to verify a correct result. EvMenu + decorations (like borders) are stripped and should not be included. This + should also not include color tags unless `noansi=False`. + If the command returns texts in multiple separate `.msg`- + calls to a receiver, separate these with `|` if `noansi=True` + (default) and `||` if `noansi=False`. If no `msg` is given (`None`), + then no automatic comparison will be done. + cmdset (str, optional): If given, make `.cmdset` available on the Command + instance as it runs. While `.cmdset` is normally available on the + Command instance by default, this is usually only used by + commands that explicitly operates/displays cmdsets, like + `examine`. + noansi (str, optional): By default the color tags of the `msg` is + ignored, this makes them significant. If unset, `msg` must contain + the same color tags as the actual return message. + caller (Object or Account, optional): By default `self.char1` is used as the + command-caller (the `.caller` property on the Command). This allows to + execute with another caller, most commonly an Account. + receiver (Object or Account, optional): This is the object to receive the + return messages we want to test. By default this is the same as `caller` + (which in turn defaults to is `self.char1`). Note that if `msg` is + a `dict`, this is ignored since the receiver is already specified there. + cmdstring (str, optional): Normally this is the Command's `key`. + This allows for tweaking the `.cmdname` property of the + Command`. This isb used for commands with multiple aliases, + where the command explicitly checs which alias was used to + determine its functionality. + obj (str, optional): This sets the `.obj` property of the Command - the + object on which the Command 'sits'. By default this is the same as `caller`. + This can be used for testing on-object Command interactions. + inputs (list, optional): A list of strings to pass to functions that pause to + take input from the user (normally using `@interactive` and + `ret = yield(question)` or `evmenu.get_input`). Each element of the + list will be passed into the command as if the user answered each prompt + in that order. + raw_string (str, optional): Normally the `.raw_string` property is set as + a combination of your `key/cmdname` and `input_args`. This allows + direct control of what this is, for example for testing edge cases + or malformed inputs. + + Returns: + str or dict: The message sent to `receiver`, or a dict of + `{receiver: "msg", ...}` if multiple are given. This is usually + only used with `msg=None` to do the validation externally. + + Raises: + AssertionError: If the returns of `.msg` calls (tested with `.startswith`) does not + match `expected_input`. + + Notes: + As part of the tests, all methods of the Command will be called in + the proper order: + + - cmdobj.at_pre_cmd() + - cmdobj.parse() + - cmdobj.func() + - cmdobj.at_post_cmd() + + """ + # The `self.char1` is created in the `EvenniaTest` base along with + # other helper objects like self.room and self.obj + caller = caller if caller else self.char1 + cmdobj.caller = caller + cmdobj.cmdname = cmdstring if cmdstring else cmdobj.key + cmdobj.raw_cmdname = cmdobj.cmdname + cmdobj.cmdstring = cmdobj.cmdname # deprecated + cmdobj.args = input_args + cmdobj.cmdset = cmdset + cmdobj.session = evennia.SESSION_HANDLER.session_from_sessid(1) + cmdobj.account = self.account + cmdobj.raw_string = raw_string if raw_string is not None else cmdobj.key + " " + input_args + cmdobj.obj = obj or (caller if caller else self.char1) + inputs = inputs or [] + + # set up receivers + receiver_mapping = {} + if isinstance(msg, dict): + # a mapping {receiver: msg, ...} + receiver_mapping = { + recv: str(msg).strip() if msg else None for recv, msg in msg.items() + } + else: + # a single expected string and thus a single receiver (defaults to caller) + receiver = receiver if receiver else caller + receiver_mapping[receiver] = str(msg).strip() if msg is not None else None + + unmocked_msg_methods = {} + for receiver in receiver_mapping: + # save the old .msg method so we can get it back + # cleanly after the test + unmocked_msg_methods[receiver] = receiver.msg + # replace normal `.msg` with a mock + receiver.msg = Mock() + + # Run the methods of the Command. This mimics what happens in the + # cmdhandler. This will have the mocked .msg be called as part of the + # execution. Mocks remembers what was sent to them so we will be able + # to retrieve what was sent later. + try: + if cmdobj.at_pre_cmd(): + return + cmdobj.parse() + ret = cmdobj.func() + + # handle func's with yield in them (making them generators) + if isinstance(ret, types.GeneratorType): + while True: + try: + inp = inputs.pop() if inputs else None + if inp: + try: + # this mimics a user's reply to a prompt + ret.send(inp) + except TypeError: + next(ret) + ret = ret.send(inp) + else: + # non-input yield, like yield(10). We don't pause + # but fire it immediately. + next(ret) + except StopIteration: + break + + cmdobj.at_post_cmd() + except StopIteration: + pass + except InterruptCommand: + pass + + for inp in inputs: + # if there are any inputs left, we may have a non-generator + # input to handle (get_input/ask_yes_no that uses a separate + # cmdset rather than a yield + caller.execute_cmd(inp) + + # At this point the mocked .msg methods on each receiver will have + # stored all calls made to them (that's a basic function of the Mock + # class). We will not extract them and compare to what we expected to + # go to each receiver. + + returned_msgs = {} + for receiver, expected_msg in receiver_mapping.items(): + # get the stored messages from the Mock with Mock.mock_calls. + stored_msg = [ + args[0] if args and args[0] else kwargs.get("text", to_str(kwargs)) + for name, args, kwargs in receiver.msg.mock_calls + ] + # we can return this now, we are done using the mock + receiver.msg = unmocked_msg_methods[receiver] + + # Get the first element of a tuple if msg received a tuple instead of a string + stored_msg = [ + str(smsg[0]) if isinstance(smsg, tuple) else str(smsg) for smsg in stored_msg + ] + if expected_msg is None: + # no expected_msg; just build the returned_msgs dict + + returned_msg = "\n".join(str(msg) for msg in stored_msg) + returned_msgs[receiver] = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() + else: + # compare messages to expected + + # set our separator for returned messages based on parsing ansi or not + msg_sep = "|" if noansi else "||" + + # We remove Evmenu decorations since that just makes it harder + # to write the comparison string. We also strip ansi before this + # comparison since otherwise it would mess with the regex. + returned_msg = msg_sep.join( + _RE_STRIP_EVMENU.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) + for mess in stored_msg + ).strip() + + # this is the actual test + if expected_msg == "" and returned_msg or not returned_msg.startswith(expected_msg): + # failed the test + raise AssertionError( + self._ERROR_FORMAT.format( + expected_msg=expected_msg, returned_msg=returned_msg + ) + ) + # passed! + returned_msgs[receiver] = returned_msg + + if len(returned_msgs) == 1: + return list(returned_msgs.values())[0] + return returned_msgs
+ + +# Base testing classes + + +
[docs]@override_settings(**DEFAULT_SETTINGS) +class BaseEvenniaTestCase(TestCase): + """ + Base test (with no default objects) but with enforced default settings. + + """ + +
[docs] def tearDown(self) -> None: + super().tearDown() + flush_cache()
+ + +
[docs]class EvenniaTestCase(TestCase): + """ + For use with gamedir settings; Just like the normal test case, only for naming consistency. + + Notes: + + - Inheriting from this class will bypass EvenniaTestMixin, and therefore + not setup some default objects. This can result in faster tests. + + - If you do inherit from this class for your unit tests, and have + overridden the tearDown() method, please also call flush_cache(). Not + doing so will result in flakey and order-dependent tests due to the + Django ID cache not being flushed. + """ + +
[docs] def tearDown(self) -> None: + super().tearDown() + flush_cache()
+ + +
[docs]@override_settings(**DEFAULT_SETTINGS) +class BaseEvenniaTest(EvenniaTestMixin, TestCase): + """ + This class parent has all default objects and uses only default settings. + + """
+ + +
[docs]class EvenniaTest(EvenniaTestMixin, TestCase): + """ + This test class is intended for inheriting in mygame tests. + It helps ensure your tests are run with your own objects + and settings from your game folder. + + """ + + account_typeclass = settings.BASE_ACCOUNT_TYPECLASS + object_typeclass = settings.BASE_OBJECT_TYPECLASS + character_typeclass = settings.BASE_CHARACTER_TYPECLASS + exit_typeclass = settings.BASE_EXIT_TYPECLASS + room_typeclass = settings.BASE_ROOM_TYPECLASS + script_typeclass = settings.BASE_SCRIPT_TYPECLASS
+ + +
[docs]@patch("evennia.commands.account.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.admin.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.batchprocess.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.building.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.comms.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.general.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.help.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.syscommands.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.system.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.unloggedin.COMMAND_DEFAULT_CLASS", MuxCommand) +@override_settings(**DEFAULT_SETTINGS) +class BaseEvenniaCommandTest(BaseEvenniaTest, EvenniaCommandTestMixin): + """ + Commands only using the default settings. + + """
+ + +
[docs]class EvenniaCommandTest(EvenniaTest, EvenniaCommandTestMixin): + """ + Parent class to inherit from - makes tests use your own + classes and settings in mygame. + + """
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/text2html.html b/docs/latest/_modules/evennia/utils/text2html.html new file mode 100644 index 0000000000..ef87a51016 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/text2html.html @@ -0,0 +1,502 @@ + + + + + + + + evennia.utils.text2html — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.text2html

+"""
+ANSI -> html converter
+
+Credit for original idea and implementation
+goes to Muhammad Alkarouri and his
+snippet #577349 on http://code.activestate.com.
+
+(extensively modified by Griatch 2010)
+"""
+
+import re
+from html import escape as html_escape
+
+from .ansi import *
+
+# All xterm256 RGB equivalents
+
+XTERM256_FG = "\033[38;5;{}m"
+XTERM256_BG = "\033[48;5;{}m"
+
+
+
[docs]class TextToHTMLparser(object): + """ + This class describes a parser for converting from ANSI to html. + """ + + tabstop = 4 + + style_codes = [ + # non-color style markers + ANSI_NORMAL, + ANSI_UNDERLINE, + ANSI_HILITE, + ANSI_UNHILITE, + ANSI_INVERSE, + ANSI_BLINK, + ANSI_INV_HILITE, + ANSI_BLINK_HILITE, + ANSI_INV_BLINK, + ANSI_INV_BLINK_HILITE, + ] + + ansi_color_codes = [ + # Foreground colors + ANSI_BLACK, + ANSI_RED, + ANSI_GREEN, + ANSI_YELLOW, + ANSI_BLUE, + ANSI_MAGENTA, + ANSI_CYAN, + ANSI_WHITE, + ] + + xterm_fg_codes = [XTERM256_FG.format(i + 16) for i in range(240)] + + ansi_bg_codes = [ + # Background colors + ANSI_BACK_BLACK, + ANSI_BACK_RED, + ANSI_BACK_GREEN, + ANSI_BACK_YELLOW, + ANSI_BACK_BLUE, + ANSI_BACK_MAGENTA, + ANSI_BACK_CYAN, + ANSI_BACK_WHITE, + ] + + xterm_bg_codes = [XTERM256_BG.format(i + 16) for i in range(240)] + + re_style = re.compile( + r"({})".format( + "|".join( + style_codes + ansi_color_codes + xterm_fg_codes + ansi_bg_codes + xterm_bg_codes + ).replace("[", r"\[") + ) + ) + + colorlist = ( + [ANSI_UNHILITE + code for code in ansi_color_codes] + + [ANSI_HILITE + code for code in ansi_color_codes] + + xterm_fg_codes + ) + + bglist = ansi_bg_codes + [ANSI_HILITE + code for code in ansi_bg_codes] + xterm_bg_codes + + re_string = re.compile( + r"(?P<htmlchars>[<&>])|(?P<tab>[\t]+)|(?P<lineend>\r\n|\r|\n)", + re.S | re.M | re.I, + ) + re_url = re.compile( + r'(?<!=")(\b(?:ftp|www|https?)\W+(?:(?!\.(?:\s|$)|&\w+;)[^"\',;$*^\\(){}<>\[\]\s])+)(\.(?:\s|$)|&\w+;|)' + ) + re_protocol = re.compile(r"^(?:ftp|https?)://") + re_valid_no_protocol = re.compile( + r"^(?:www|ftp)\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_\+.~#?&//=]*" + ) + re_mxplink = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL) + re_mxpurl = re.compile(r"\|lu(.*?)\|lt(.*?)\|le", re.DOTALL) + +
[docs] def remove_bells(self, text): + """ + Remove ansi specials + + Args: + text (str): Text to process. + + Returns: + text (str): Processed text. + + """ + return text.replace(ANSI_BEEP, "")
+ +
[docs] def remove_backspaces(self, text): + """ + Removes special escape sequences + + Args: + text (str): Text to process. + + Returns: + text (str): Processed text. + + """ + backspace_or_eol = r"(.\010)|(\033\[K)" + n = 1 + while n > 0: + text, n = re.subn(backspace_or_eol, "", text, 1) + return text
+ +
[docs] def convert_linebreaks(self, text): + """ + Extra method for cleaning linebreaks + + Args: + text (str): Text to process. + + Returns: + text (str): Processed text. + + """ + return text.replace("\n", r"<br>")
+ +
[docs] def convert_urls(self, text): + """ + Replace urls (http://...) by valid HTML. + + Args: + text (str): Text to process. + + Returns: + text (str): Processed text. + + """ + m = self.re_url.search(text) + if m: + href = m.group(1) + label = href + # if there is no protocol (i.e. starts with www or ftp) + # prefix with http:// so the link isn't treated as relative + if not self.re_protocol.match(href): + if not self.re_valid_no_protocol.match(href): + return text + href = "http://" + href + rest = m.group(2) + # -> added target to output prevent the web browser from attempting to + # change pages (and losing our webclient session). + return ( + text[: m.start()] + + f'<a href="{href}" target="_blank">{label}</a>{rest}' + + text[m.end() :] + ) + else: + return text
+ + + +
[docs] def sub_mxp_urls(self, match): + """ + Helper method to be passed to re.sub, + replaces MXP links with HTML code. + Args: + match (re.Matchobject): Match for substitution. + Returns: + text (str): Processed text. + """ + url, text = [grp.replace('"', "\\&quot;") for grp in match.groups()] + val = r"""<a id="mxplink" href="{url}" target="_blank">{text}</a>""".format( + url=url, text=text + ) + return val
+ +
[docs] def sub_text(self, match): + """ + Helper method to be passed to re.sub, + for handling all substitutions. + + Args: + match (re.Matchobject): Match for substitution. + + Returns: + text (str): Processed text. + + """ + cdict = match.groupdict() + if cdict["htmlchars"]: + return html_escape(cdict["htmlchars"]) + elif cdict["lineend"]: + return "<br>" + elif cdict["tab"]: + text = cdict["tab"].replace("\t", " " * (self.tabstop)) + return text + return None
+ +
[docs] def format_styles(self, text): + """ + Takes a string with parsed ANSI codes and replaces them with + HTML spans and CSS classes. + + Args: + text (str): The string to process. + + Returns: + text (str): Processed text. + """ + + # split out the ANSI codes and clean out any empty items + str_list = [substr for substr in self.re_style.split(text) if substr] + # initialize all the flags and classes + classes = [] + clean = True + inverse = False + # default color is light grey - unhilite + white + hilight = ANSI_UNHILITE + fg = ANSI_WHITE + # default bg is black + bg = ANSI_BACK_BLACK + + for i, substr in enumerate(str_list): + # reset all current styling + if substr == ANSI_NORMAL: + # close any existing span if necessary + str_list[i] = "</span>" if not clean else "" + # reset to defaults + classes = [] + clean = True + inverse = False + hilight = ANSI_UNHILITE + fg = ANSI_WHITE + bg = ANSI_BACK_BLACK + + # change color + elif substr in self.ansi_color_codes + self.xterm_fg_codes: + # erase ANSI code from output + str_list[i] = "" + # set new color + fg = substr + + # change bg color + elif substr in self.ansi_bg_codes + self.xterm_bg_codes: + # erase ANSI code from output + str_list[i] = "" + # set new bg + bg = substr + + # non-color codes + elif substr in self.style_codes: + # erase ANSI code from output + str_list[i] = "" + + # hilight codes + if substr in (ANSI_HILITE, ANSI_UNHILITE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE): + # set new hilight status + hilight = ANSI_UNHILITE if substr == ANSI_UNHILITE else ANSI_HILITE + + # inversion codes + if substr in (ANSI_INVERSE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE): + inverse = True + + # blink codes + if ( + substr in (ANSI_BLINK, ANSI_BLINK_HILITE, ANSI_INV_BLINK_HILITE) + and "blink" not in classes + ): + classes.append("blink") + + # underline + if substr == ANSI_UNDERLINE and "underline" not in classes: + classes.append("underline") + + else: + # normal text, add text back to list + if not str_list[i - 1]: + # prior entry was cleared, which means style change + # get indices for the fg and bg codes + bg_index = self.bglist.index(bg) + try: + color_index = self.colorlist.index(hilight + fg) + except ValueError: + # xterm256 colors don't have the hilight codes + color_index = self.colorlist.index(fg) + + if inverse: + # inverse means swap fg and bg indices + bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) + color_class = "color-{}".format(str(bg_index).rjust(3, "0")) + else: + # use fg and bg indices for classes + bg_class = "bgcolor-{}".format(str(bg_index).rjust(3, "0")) + color_class = "color-{}".format(str(color_index).rjust(3, "0")) + + # black bg is the default, don't explicitly style + if bg_class != "bgcolor-000": + classes.append(bg_class) + # light grey text is the default, don't explicitly style + if color_class != "color-007": + classes.append(color_class) + # define the new style span + prefix = '<span class="{}">'.format(" ".join(classes)) + # close any prior span + if not clean: + prefix = "</span>" + prefix + # add span to output + str_list[i - 1] = prefix + + # clean out color classes to easily update next time + classes = [cls for cls in classes if "color" not in cls] + # flag as currently being styled + clean = False + + # close span if necessary + if not clean: + str_list.append("</span>") + # recombine back into string + return "".join(str_list)
+ +
[docs] def parse(self, text, strip_ansi=False): + """ + Main access function, converts a text containing ANSI codes + into html statements. + + Args: + text (str): Text to process. + strip_ansi (bool, optional): + + Returns: + text (str): Parsed text. + + """ + # parse everything to ansi first + text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True) + # convert all ansi to html + result = re.sub(self.re_string, self.sub_text, text) + result = re.sub(self.re_mxplink, self.sub_mxp_links, result) + result = re.sub(self.re_mxpurl, self.sub_mxp_urls, result) + result = self.remove_bells(result) + result = self.format_styles(result) + result = self.convert_linebreaks(result) + result = self.remove_backspaces(result) + result = self.convert_urls(result) + # clean out eventual ansi that was missed + ## result = parse_ansi(result, strip_ansi=True) + + return result
+ + +HTML_PARSER = TextToHTMLparser() + + +# +# Access function +# + + +
[docs]def parse_html(string, strip_ansi=False, parser=HTML_PARSER): + """ + Parses a string, replace ANSI markup with html + """ + return parser.parse(string, strip_ansi=strip_ansi)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/utils.html b/docs/latest/_modules/evennia/utils/utils.html new file mode 100644 index 0000000000..3a8f8a218d --- /dev/null +++ b/docs/latest/_modules/evennia/utils/utils.html @@ -0,0 +1,3125 @@ + + + + + + + + evennia.utils.utils — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.utils

+# -*- encoding: utf-8 -*-
+"""
+General helper functions that don't fit neatly under any given category.
+
+They provide some useful string and conversion methods that might
+be of use when designing your own game.
+
+"""
+import gc
+import importlib
+import importlib.machinery
+import importlib.util
+import inspect
+import ipaddress
+import math
+import os
+import random
+import re
+import sys
+import textwrap
+import threading
+import traceback
+import types
+from ast import literal_eval
+from collections import OrderedDict, defaultdict
+from inspect import getmembers, getmodule, getmro, ismodule, trace
+from os.path import join as osjoin
+from string import punctuation
+from unicodedata import east_asian_width
+
+from django.apps import apps
+from django.conf import settings
+from django.core.exceptions import ValidationError as DjangoValidationError
+from django.core.validators import validate_email as django_validate_email
+from django.utils import timezone
+from django.utils.html import strip_tags
+from django.utils.translation import gettext as _
+from simpleeval import simple_eval
+from twisted.internet import reactor, threads
+from twisted.internet.defer import returnValue  # noqa - used as import target
+from twisted.internet.task import deferLater
+
+import evennia
+from evennia.utils import logger
+
+_MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE
+_EVENNIA_DIR = settings.EVENNIA_DIR
+_GAME_DIR = settings.GAME_DIR
+_IS_MAIN_THREAD = threading.current_thread().name == "MainThread"
+
+ENCODINGS = settings.ENCODINGS
+
+_TASK_HANDLER = None
+_TICKER_HANDLER = None
+_STRIP_UNSAFE_TOKENS = None
+_ANSISTRING = None
+
+_GA = object.__getattribute__
+_SA = object.__setattr__
+_DA = object.__delattr__
+
+
+
[docs]def is_iter(obj): + """ + Checks if an object behaves iterably. + + Args: + obj (any): Entity to check for iterability. + + Returns: + is_iterable (bool): If `obj` is iterable or not. + + Notes: + Strings are *not* accepted as iterable (although they are + actually iterable), since string iterations are usually not + what we want to do with a string. + + """ + if isinstance(obj, (str, bytes)): + return False + + try: + return iter(obj) and True + except TypeError: + return False
+ + +
[docs]def make_iter(obj): + """ + Makes sure that the object is always iterable. + + Args: + obj (any): Object to make iterable. + + Returns: + iterable (list or iterable): The same object + passed-through or made iterable. + + """ + return not is_iter(obj) and [obj] or obj
+ + +
[docs]def wrap(text, width=None, indent=0): + """ + Safely wrap text to a certain number of characters. + + Args: + text (str): The text to wrap. + width (int, optional): The number of characters to wrap to. + indent (int): How much to indent each line (with whitespace). + + Returns: + text (str): Properly wrapped text. + + """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH + if not text: + return "" + indent = " " * indent + return to_str(textwrap.fill(text, width, initial_indent=indent, subsequent_indent=indent))
+ + +# alias - fill +fill = wrap + + +
[docs]def pad(text, width=None, align="c", fillchar=" "): + """ + Pads to a given width. + + Args: + text (str): Text to pad. + width (int, optional): The width to pad to, in characters. + align (str, optional): This is one of 'c', 'l' or 'r' (center, + left or right). + fillchar (str, optional): The character to fill with. + + Returns: + text (str): The padded text. + + """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH + align = align if align in ("c", "l", "r") else "c" + fillchar = fillchar[0] if fillchar else " " + if align == "l": + return text.ljust(width, fillchar) + elif align == "r": + return text.rjust(width, fillchar) + else: + return text.center(width, fillchar)
+ + +
[docs]def crop(text, width=None, suffix="[...]"): + """ + Crop text to a certain width, throwing away text from too-long + lines. + + Args: + text (str): Text to crop. + width (int, optional): Width of line to crop, in characters. + suffix (str, optional): This is appended to the end of cropped + lines to show that the line actually continues. Cropping + will be done so that the suffix will also fit within the + given width. If width is too small to fit both crop and + suffix, the suffix will be dropped. + + Returns: + text (str): The cropped text. + + """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH + ltext = len(text) + if ltext <= width: + return text + else: + lsuffix = len(suffix) + text = text[:width] if lsuffix >= width else "%s%s" % (text[: width - lsuffix], suffix) + return to_str(text)
+ + +
[docs]def dedent(text, baseline_index=None, indent=None): + """ + Safely clean all whitespace at the left of a paragraph. + + Args: + text (str): The text to dedent. + baseline_index (int, optional): Which row to use as a 'base' + for the indentation. Lines will be dedented to this level but + no further. If None, indent so as to completely deindent the + least indented text. + indent (int, optional): If given, force all lines to this indent. + This bypasses `baseline_index`. + + Returns: + text (str): Dedented string. + + Notes: + This is useful for preserving triple-quoted string indentation + while still shifting it all to be next to the left edge of the + display. + + """ + if not text: + return "" + if indent is not None: + lines = text.split("\n") + ind = " " * indent + indline = "\n" + ind + return ind + indline.join(line.strip() for line in lines) + elif baseline_index is None: + return textwrap.dedent(text) + else: + lines = text.split("\n") + baseline = lines[baseline_index] + spaceremove = len(baseline) - len(baseline.lstrip(" ")) + return "\n".join( + line[min(spaceremove, len(line) - len(line.lstrip(" "))) :] for line in lines + )
+ + +
[docs]def justify(text, width=None, align="l", indent=0, fillchar=" "): + """ + Fully justify a text so that it fits inside `width`. When using + full justification (default) this will be done by padding between + words with extra whitespace where necessary. Paragraphs will + be retained. + + Args: + text (str): Text to justify. + width (int, optional): The length of each line, in characters. + align (str, optional): The alignment, 'l', 'c', 'r', 'f' or 'a' + for left, center, right, full justification. The 'a' stands for + 'absolute' and means the text will be returned unmodified. + indent (int, optional): Number of characters indentation of + entire justified text block. + fillchar (str): The character to use to fill. Defaults to empty space. + + Returns: + justified (str): The justified and indented block of text. + + """ + # we need to retain ansitrings + global _ANSISTRING + if not _ANSISTRING: + from evennia.utils.ansi import ANSIString as _ANSISTRING + + is_ansi = isinstance(text, _ANSISTRING) + lb = _ANSISTRING("\n") if is_ansi else "\n" + + def _process_line(line): + """ + helper function that distributes extra spaces between words. The number + of gaps is nwords - 1 but must be at least 1 for single-word lines. We + distribute odd spaces to one of the gaps. + """ + line_rest = width - (wlen + ngaps) + + gap = _ANSISTRING(" ") if is_ansi else " " + + if line_rest > 0: + if align == "l": + if line[-1] == "\n\n": + line[-1] = sp * (line_rest - 1) + "\n" + sp * width + "\n" + sp * width + else: + line[-1] += sp * line_rest + elif align == "r": + line[0] = sp * line_rest + line[0] + elif align == "c": + pad = sp * (line_rest // 2) + line[0] = pad + line[0] + if line[-1] == "\n\n": + line[-1] += ( + pad + sp * (line_rest % 2 - 1) + "\n" + sp * width + "\n" + sp * width + ) + else: + line[-1] = line[-1] + pad + sp * (line_rest % 2) + else: # align 'f' + gap += sp * (line_rest // max(1, ngaps)) + rest_gap = line_rest % max(1, ngaps) + for i in range(rest_gap): + line[i] += sp + elif not any(line): + return [sp * width] + return gap.join(line) + + width = width if width is not None else settings.CLIENT_DEFAULT_WIDTH + sp = fillchar + + if align == "a": + # absolute mode - just crop or fill to width + abs_lines = [] + for line in text.split("\n"): + nlen = m_len(line) + if m_len(line) < width: + line += sp * (width - nlen) + else: + line = crop(line, width=width, suffix="") + abs_lines.append(line) + return lb.join(abs_lines) + + # all other aligns requires splitting into paragraphs and words + + # split into paragraphs and words + paragraphs = [text] # re.split("\n\s*?\n", text, re.MULTILINE) + words = [] + for ip, paragraph in enumerate(paragraphs): + if ip > 0: + words.append(("\n", 0)) + words.extend((word, m_len(word)) for word in paragraph.split()) + + if not words: + # Just whitespace! + return sp * width + + ngaps = 0 + wlen = 0 + line = [] + lines = [] + + while words: + if not line: + # start a new line + word = words.pop(0) + wlen = word[1] + line.append(word[0]) + elif (words[0][1] + wlen + ngaps) >= width: + # next word would exceed word length of line + smallest gaps + lines.append(_process_line(line)) + ngaps, wlen, line = 0, 0, [] + else: + # put a new word on the line + word = words.pop(0) + line.append(word[0]) + if word[1] == 0: + # a new paragraph, process immediately + lines.append(_process_line(line)) + ngaps, wlen, line = 0, 0, [] + else: + wlen += word[1] + ngaps += 1 + + if line: # catch any line left behind + lines.append(_process_line(line)) + indentstring = sp * indent + out = lb.join([indentstring + line for line in lines]) + return lb.join([indentstring + line for line in lines])
+ + +
[docs]def columnize(string, columns=2, spacing=4, align="l", width=None): + """ + Break a string into a number of columns, using as little + vertical space as possible. + + Args: + string (str): The string to columnize. + columns (int, optional): The number of columns to use. + spacing (int, optional): How much space to have between columns. + width (int, optional): The max width of the columns. + Defaults to client's default width. + + Returns: + columns (str): Text divided into columns. + + Raises: + RuntimeError: If given invalid values. + + """ + columns = max(1, columns) + spacing = max(1, spacing) + width = width if width else settings.CLIENT_DEFAULT_WIDTH + + w_spaces = (columns - 1) * spacing + w_txt = max(1, width - w_spaces) + + if w_spaces + columns > width: # require at least 1 char per column + raise RuntimeError("Width too small to fit columns") + + colwidth = int(w_txt / (1.0 * columns)) + + # first make a single column which we then split + onecol = justify(string, width=colwidth, align=align) + onecol = onecol.split("\n") + + nrows, dangling = divmod(len(onecol), columns) + nrows = [nrows + 1 if i < dangling else nrows for i in range(columns)] + + height = max(nrows) + cols = [] + istart = 0 + for irows in nrows: + cols.append(onecol[istart : istart + irows]) + istart = istart + irows + for col in cols: + if len(col) < height: + col.append(" " * colwidth) + + sep = " " * spacing + rows = [] + for irow in range(height): + rows.append(sep.join(col[irow] for col in cols)) + + return "\n".join(rows)
+ + +
[docs]def iter_to_str(iterable, sep=",", endsep=", and", addquote=False): + """ + This pretty-formats an iterable list as string output, adding an optional + alternative separator to the second to last entry. If `addquote` + is `True`, the outgoing strings will be surrounded by quotes. + + Args: + iterable (any): Usually an iterable to print. Each element must be possible to + present with a string. Note that if this is a generator, it will be + consumed by this operation. + sep (str, optional): The string to use as a separator for each item in the iterable. + endsep (str, optional): The last item separator will be replaced with this value. + addquote (bool, optional): This will surround all outgoing + values with double quotes. + + Returns: + str: The list represented as a string. + + Notes: + Default is to use 'Oxford comma', like 1, 2, 3, and 4. + + Examples: + + ```python + >>> iter_to_string([1,2,3], endsep=',') + '1, 2, 3' + >>> iter_to_string([1,2,3], endsep='') + '1, 2 3' + >>> iter_to_string([1,2,3], ensdep='and') + '1, 2 and 3' + >>> iter_to_string([1,2,3], sep=';', endsep=';') + '1; 2; 3' + >>> iter_to_string([1,2,3], addquote=True) + '"1", "2", and "3"' + ``` + + """ + iterable = list(make_iter(iterable)) + if not iterable: + return "" + len_iter = len(iterable) + + if addquote: + iterable = tuple(f'"{val}"' for val in iterable) + else: + iterable = tuple(str(val) for val in iterable) + + if endsep: + if endsep.startswith(sep) and endsep != sep: + # oxford comma alternative + endsep = endsep[1:] if len_iter < 3 else endsep + elif endsep[0] not in punctuation: + # add a leading space if endsep is a word + endsep = " " + str(endsep).strip() + + # also add a leading space if separator is a word + if sep not in punctuation: + sep = " " + sep + + if len_iter == 1: + return str(iterable[0]) + elif len_iter == 2: + return f"{endsep} ".join(str(v) for v in iterable) + else: + return f"{sep} ".join(str(v) for v in iterable[:-1]) + f"{endsep} {iterable[-1]}"
+ + +# legacy aliases +list_to_string = iter_to_str +iter_to_string = iter_to_str + + +
[docs]def wildcard_to_regexp(instring): + """ + Converts a player-supplied string that may have wildcards in it to + regular expressions. This is useful for name matching. + + Args: + instring (string): A string that may potentially contain + wildcards (`*` or `?`). + + Returns: + regex (str): A string where wildcards were replaced with + regular expressions. + + """ + regexp_string = "" + + # If the string starts with an asterisk, we can't impose the beginning of + # string (^) limiter. + if instring[0] != "*": + regexp_string += "^" + + # Replace any occurances of * or ? with the appropriate groups. + regexp_string += instring.replace("*", "(.*)").replace("?", "(.{1})") + + # If there's an asterisk at the end of the string, we can't impose the + # end of string ($) limiter. + if instring[-1] != "*": + regexp_string += "$" + + return regexp_string
+ + +
[docs]def time_format(seconds, style=0): + """ + Function to return a 'prettified' version of a value in seconds. + + Args: + seconds (int): Number if seconds to format. + style (int): One of the following styles: + 0. "1d 08:30" + 1. "1d" + 2. "1 day, 8 hours, 30 minutes" + 3. "1 day, 8 hours, 30 minutes, 10 seconds" + 4. highest unit (like "3 years" or "8 months" or "1 second") + Returns: + timeformatted (str): A pretty time string. + """ + if seconds < 0: + seconds = 0 + else: + # We'll just use integer math, no need for decimal precision. + seconds = int(seconds) + + days = seconds // 86400 + seconds -= days * 86400 + hours = seconds // 3600 + seconds -= hours * 3600 + minutes = seconds // 60 + seconds -= minutes * 60 + + retval = "" + if style == 0: + """ + Standard colon-style output. + """ + if days > 0: + retval = "%id %02i:%02i" % (days, hours, minutes) + else: + retval = "%02i:%02i" % (hours, minutes) + return retval + + elif style == 1: + """ + Simple, abbreviated form that only shows the highest time amount. + """ + if days > 0: + return "%id" % (days,) + elif hours > 0: + return "%ih" % (hours,) + elif minutes > 0: + return "%im" % (minutes,) + else: + return "%is" % (seconds,) + elif style == 2: + """ + Full-detailed, long-winded format. We ignore seconds. + """ + days_str = hours_str = "" + minutes_str = "0 minutes" + + if days > 0: + if days == 1: + days_str = "%i day, " % days + else: + days_str = "%i days, " % days + if days or hours > 0: + if hours == 1: + hours_str = "%i hour, " % hours + else: + hours_str = "%i hours, " % hours + if hours or minutes > 0: + if minutes == 1: + minutes_str = "%i minute " % minutes + else: + minutes_str = "%i minutes " % minutes + retval = "%s%s%s" % (days_str, hours_str, minutes_str) + elif style == 3: + """ + Full-detailed, long-winded format. Includes seconds. + """ + days_str = hours_str = minutes_str = seconds_str = "" + if days > 0: + if days == 1: + days_str = "%i day, " % days + else: + days_str = "%i days, " % days + if days or hours > 0: + if hours == 1: + hours_str = "%i hour, " % hours + else: + hours_str = "%i hours, " % hours + if hours or minutes > 0: + if minutes == 1: + minutes_str = "%i minute " % minutes + else: + minutes_str = "%i minutes " % minutes + if minutes or seconds > 0: + if seconds == 1: + seconds_str = "%i second " % seconds + else: + seconds_str = "%i seconds " % seconds + retval = "%s%s%s%s" % (days_str, hours_str, minutes_str, seconds_str) + elif style == 4: + """ + Only return the highest unit. + """ + if days >= 730: # Several years + return "{} years".format(days // 365) + elif days >= 365: # One year + return "a year" + elif days >= 62: # Several months + return "{} months".format(days // 31) + elif days >= 31: # One month + return "a month" + elif days >= 2: # Several days + return "{} days".format(days) + elif days > 0: + return "a day" + elif hours >= 2: # Several hours + return "{} hours".format(hours) + elif hours > 0: # One hour + return "an hour" + elif minutes >= 2: # Several minutes + return "{} minutes".format(minutes) + elif minutes > 0: # One minute + return "a minute" + elif seconds >= 2: # Several seconds + return "{} seconds".format(seconds) + elif seconds == 1: + return "a second" + else: + return "0 seconds" + else: + raise ValueError("Unknown style for time format: %s" % style) + + return retval.strip()
+ + +
[docs]def datetime_format(dtobj): + """ + Pretty-prints the time since a given time. + + Args: + dtobj (datetime): An datetime object, e.g. from Django's + `DateTimeField`. + + Returns: + deltatime (str): A string describing how long ago `dtobj` + took place. + + """ + + now = timezone.now() + + if dtobj.year < now.year: + # another year (Apr 5, 2019) + timestring = dtobj.strftime(f"%b {dtobj.day}, %Y") + elif dtobj.date() < now.date(): + # another date, same year (Apr 5) + timestring = dtobj.strftime(f"%b {dtobj.day}") + elif dtobj.hour < now.hour - 1: + # same day, more than 1 hour ago (10:45) + timestring = dtobj.strftime("%H:%M") + else: + # same day, less than 1 hour ago (10:45:33) + timestring = dtobj.strftime("%H:%M:%S") + return timestring
+ + +
[docs]def host_os_is(osname): + """ + Check to see if the host OS matches the query. + + Args: + osname (str): Common names are "posix" (linux/unix/mac) and + "nt" (windows). + + Args: + is_os (bool): If the os matches or not. + + """ + return os.name == osname
+ + +
[docs]def get_evennia_version(mode="long"): + """ + Helper method for getting the current evennia version. + + Args: + mode (str, optional): One of: + - long: 0.9.0 rev342453534 + - short: 0.9.0 + - pretty: Evennia 0.9.0 + + Returns: + version (str): The version string. + + """ + import evennia + + vers = evennia.__version__ + if mode == "short": + return vers.split()[0].strip() + elif mode == "pretty": + vers = vers.split()[0].strip() + return f"Evennia {vers}" + else: # mode "long": + return vers
+ + +
[docs]def pypath_to_realpath(python_path, file_ending=".py", pypath_prefixes=None): + """ + Converts a dotted Python path to an absolute path under the + Evennia library directory or under the current game directory. + + Args: + python_path (str): A dot-python path + file_ending (str): A file ending, including the period. + pypath_prefixes (list): A list of paths to test for existence. These + should be on python.path form. EVENNIA_DIR and GAME_DIR are automatically + checked, they need not be added to this list. + + Returns: + abspaths (list): All existing, absolute paths created by + converting `python_path` to an absolute paths and/or + prepending `python_path` by `settings.EVENNIA_DIR`, + `settings.GAME_DIR` and by`pypath_prefixes` respectively. + + Notes: + This will also try a few combinations of paths to allow cases + where pypath is given including the "evennia." or "mygame." + prefixes. + + """ + path = python_path.strip().split(".") + plong = osjoin(*path) + file_ending + pshort = ( + osjoin(*path[1:]) + file_ending if len(path) > 1 else plong + ) # in case we had evennia. or mygame. + prefixlong = ( + [osjoin(*ppath.strip().split(".")) for ppath in make_iter(pypath_prefixes)] + if pypath_prefixes + else [] + ) + prefixshort = ( + [ + osjoin(*ppath.strip().split(".")[1:]) + for ppath in make_iter(pypath_prefixes) + if len(ppath.strip().split(".")) > 1 + ] + if pypath_prefixes + else [] + ) + paths = ( + [plong] + + [osjoin(_EVENNIA_DIR, prefix, plong) for prefix in prefixlong] + + [osjoin(_GAME_DIR, prefix, plong) for prefix in prefixlong] + + [osjoin(_EVENNIA_DIR, prefix, plong) for prefix in prefixshort] + + [osjoin(_GAME_DIR, prefix, plong) for prefix in prefixshort] + + [osjoin(_EVENNIA_DIR, plong), osjoin(_GAME_DIR, plong)] + + [osjoin(_EVENNIA_DIR, prefix, pshort) for prefix in prefixshort] + + [osjoin(_GAME_DIR, prefix, pshort) for prefix in prefixshort] + + [osjoin(_EVENNIA_DIR, prefix, pshort) for prefix in prefixlong] + + [osjoin(_GAME_DIR, prefix, pshort) for prefix in prefixlong] + + [osjoin(_EVENNIA_DIR, pshort), osjoin(_GAME_DIR, pshort)] + ) + # filter out non-existing paths + return list(set(p for p in paths if os.path.isfile(p)))
+ + +
[docs]def dbref(inp, reqhash=True): + """ + Converts/checks if input is a valid dbref. + + Args: + inp (int, str): A database ref on the form N or #N. + reqhash (bool, optional): Require the #N form to accept + input as a valid dbref. + + Returns: + dbref (int or None): The integer part of the dbref or `None` + if input was not a valid dbref. + + """ + if reqhash: + num = ( + int(inp.lstrip("#")) + if (isinstance(inp, str) and inp.startswith("#") and inp.lstrip("#").isdigit()) + else None + ) + return num if isinstance(num, int) and num > 0 else None + elif isinstance(inp, str): + inp = inp.lstrip("#") + return int(inp) if inp.isdigit() and int(inp) > 0 else None + else: + return inp if isinstance(inp, int) else None
+ + +
[docs]def dbref_to_obj(inp, objclass, raise_errors=True): + """ + Convert a #dbref to a valid object. + + Args: + inp (str or int): A valid #dbref. + objclass (class): A valid django model to filter against. + raise_errors (bool, optional): Whether to raise errors + or return `None` on errors. + + Returns: + obj (Object or None): An entity loaded from the dbref. + + Raises: + Exception: If `raise_errors` is `True` and + `objclass.objects.get(id=dbref)` did not return a valid + object. + + """ + dbid = dbref(inp) + if not dbid: + # we only convert #dbrefs + return inp + try: + if dbid < 0: + return None + except ValueError: + return None + + # if we get to this point, inp is an integer dbref; get the matching object + try: + return objclass.objects.get(id=dbid) + except Exception: + if raise_errors: + raise + return inp
+ + +# legacy alias +dbid_to_obj = dbref_to_obj + + +# some direct translations for the latinify +_UNICODE_MAP = { + "EM DASH": "-", + "FIGURE DASH": "-", + "EN DASH": "-", + "HORIZONTAL BAR": "-", + "HORIZONTAL ELLIPSIS": "...", + "LEFT SINGLE QUOTATION MARK": "'", + "RIGHT SINGLE QUOTATION MARK": "'", + "LEFT DOUBLE QUOTATION MARK": '"', + "RIGHT DOUBLE QUOTATION MARK": '"', +} + + +
[docs]def latinify(string, default="?", pure_ascii=False): + """ + Convert a unicode string to "safe" ascii/latin-1 characters. + This is used as a last resort when normal encoding does not work. + + Arguments: + string (str): A string to convert to 'safe characters' convertible + to an latin-1 bytestring later. + default (str, optional): Characters resisting mapping will be replaced + with this character or string. The intent is to apply an encode operation + on the string soon after. + + Returns: + string (str): A 'latinified' string where each unicode character has been + replaced with a 'safe' equivalent available in the ascii/latin-1 charset. + Notes: + This is inspired by the gist by Ricardo Murri: + https://gist.github.com/riccardomurri/3c3ccec30f037be174d3 + + """ + + from unicodedata import name + + if isinstance(string, bytes): + string = string.decode("utf8") + + converted = [] + for unich in iter(string): + try: + ch = unich.encode("utf8").decode("ascii") + except UnicodeDecodeError: + # deduce a latin letter equivalent from the Unicode data + # point name; e.g., since `name(u'á') == 'LATIN SMALL + # LETTER A WITH ACUTE'` translate `á` to `a`. However, in + # some cases the unicode name is still "LATIN LETTER" + # although no direct equivalent in the Latin alphabet + # exists (e.g., Þ, "LATIN CAPITAL LETTER THORN") -- we can + # avoid these cases by checking that the letter name is + # composed of one letter only. + # We also supply some direct-translations for some particular + # common cases. + what = name(unich) + if what in _UNICODE_MAP: + ch = _UNICODE_MAP[what] + else: + what = what.split() + if what[0] == "LATIN" and what[2] == "LETTER" and len(what[3]) == 1: + ch = what[3].lower() if what[1] == "SMALL" else what[3].upper() + else: + ch = default + converted.append(chr(ord(ch))) + return "".join(converted)
+ + +
[docs]def to_bytes(text, session=None): + """ + Try to encode the given text to bytes, using encodings from settings or from Session. Will + always return a bytes, even if given something that is not str or bytes. + + Args: + text (any): The text to encode to bytes. If bytes, return unchanged. If not a str, convert + to str before converting. + session (Session, optional): A Session to get encoding info from. Will try this before + falling back to settings.ENCODINGS. + + Returns: + encoded_text (bytes): the encoded text following the session's protocol flag followed by the + encodings specified in settings.ENCODINGS. If all attempt fail, log the error and send + the text with "?" in place of problematic characters. If the specified encoding cannot + be found, the protocol flag is reset to utf-8. In any case, returns bytes. + + Notes: + If `text` is already bytes, return it as is. + + """ + if isinstance(text, bytes): + return text + if not isinstance(text, str): + # convert to a str representation before encoding + try: + text = str(text) + except Exception: + text = repr(text) + + default_encoding = session.protocol_flags.get("ENCODING", "utf-8") if session else "utf-8" + try: + return text.encode(default_encoding) + except (LookupError, UnicodeEncodeError): + for encoding in settings.ENCODINGS: + try: + return text.encode(encoding) + except (LookupError, UnicodeEncodeError): + pass + # no valid encoding found. Replace unconvertable parts with ? + return text.encode(default_encoding, errors="replace")
+ + +
[docs]def to_str(text, session=None): + """ + Try to decode a bytestream to a python str, using encoding schemas from settings + or from Session. Will always return a str(), also if not given a str/bytes. + + Args: + text (any): The text to encode to bytes. If a str, return it. If also not bytes, convert + to str using str() or repr() as a fallback. + session (Session, optional): A Session to get encoding info from. Will try this before + falling back to settings.ENCODINGS. + + Returns: + decoded_text (str): The decoded text. + + Notes: + If `text` is already str, return it as is. + """ + if isinstance(text, str): + return text + if not isinstance(text, bytes): + # not a byte, convert directly to str + try: + return str(text) + except Exception: + return repr(text) + + default_encoding = session.protocol_flags.get("ENCODING", "utf-8") if session else "utf-8" + try: + return text.decode(default_encoding) + except (LookupError, UnicodeDecodeError): + for encoding in settings.ENCODINGS: + try: + return text.decode(encoding) + except (LookupError, UnicodeDecodeError): + pass + # no valid encoding found. Replace unconvertable parts with ? + return text.decode(default_encoding, errors="replace")
+ + +
[docs]def validate_email_address(emailaddress): + """ + Checks if an email address is syntactically correct. Makes use + of the django email-validator for consistency. + + Args: + emailaddress (str): Email address to validate. + + Returns: + bool: If this is a valid email or not. + + """ + try: + django_validate_email(str(emailaddress)) + except DjangoValidationError: + return False + except Exception: + logger.log_trace() + return False + else: + return True
+ + +
[docs]def inherits_from(obj, parent): + """ + Takes an object and tries to determine if it inherits at *any* + distance from parent. + + Args: + obj (any): Object to analyze. This may be either an instance or + a class. + parent (any): Can be either an instance, a class or the python + path to the class. + + Returns: + inherits_from (bool): If `parent` is a parent to `obj` or not. + + Notes: + What differentiates this function from Python's `isinstance()` is the + flexibility in the types allowed for the object and parent being compared. + + """ + + if callable(obj): + # this is a class + obj_paths = ["%s.%s" % (mod.__module__, mod.__name__) for mod in obj.mro()] + else: + obj_paths = ["%s.%s" % (mod.__module__, mod.__name__) for mod in obj.__class__.mro()] + + if isinstance(parent, str): + # a given string path, for direct matching + parent_path = parent + elif callable(parent): + # this is a class + parent_path = "%s.%s" % (parent.__module__, parent.__name__) + else: + parent_path = "%s.%s" % (parent.__class__.__module__, parent.__class__.__name__) + return any(1 for obj_path in obj_paths if obj_path == parent_path)
+ + +
[docs]def server_services(): + """ + Lists all services active on the Server. Observe that since + services are launched in memory, this function will only return + any results if called from inside the game. + + Returns: + services (dict): A dict of available services. + + """ + + if hasattr(evennia.SESSION_HANDLER, "server") and hasattr( + evennia.SESSION_HANDLER.server, "services" + ): + server = evennia.SESSION_HANDLER.server.services.namedServices + else: + # This function must be called from inside the evennia process. + server = {} + del evennia.SESSION_HANDLER + return server
+ + +
[docs]def uses_database(name="sqlite3"): + """ + Checks if the game is currently using a given database. This is a + shortcut to having to use the full backend name. + + Args: + name (str): One of 'sqlite3', 'mysql', 'postgresql' or 'oracle'. + + Returns: + uses (bool): If the given database is used or not. + + """ + try: + engine = settings.DATABASES["default"]["ENGINE"] + except KeyError: + engine = settings.DATABASE_ENGINE + return engine == "django.db.backends.%s" % name
+ + +
[docs]def delay(timedelay, callback, *args, **kwargs): + """ + Delay the calling of a callback (function). + + Args: + timedelay (int or float): The delay in seconds. + callback (callable): Will be called as `callback(*args, **kwargs)` + after `timedelay` seconds. + *args: Will be used as arguments to callback + Keyword Args: + persistent (bool, optional): If True the delay remains after a server restart. + persistent is False by default. + any (any): Will be used as keyword arguments to callback. + + Returns: + task (TaskHandlerTask): An instance of a task. + Refer to, evennia.scripts.taskhandler.TaskHandlerTask + + Notes: + The task handler (`evennia.scripts.taskhandler.TASK_HANDLER`) will + be called for persistent or non-persistent tasks. + If persistent is set to True, the callback, its arguments + and other keyword arguments will be saved (serialized) in the database, + assuming they can be. The callback will be executed even after + a server restart/reload, taking into account the specified delay + (and server down time). + Keep in mind that persistent tasks arguments and callback should not + use memory references. + If persistent is set to True the delay function will return an int + which is the task's id intended for use with TASK_HANDLER's do_task + and remove methods. + All persistent tasks whose time delays have passed will be called on server startup. + + """ + global _TASK_HANDLER + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + + return _TASK_HANDLER.add(timedelay, callback, *args, **kwargs)
+ + +
[docs]def repeat( + interval, callback, persistent=True, idstring="", stop=False, store_key=None, *args, **kwargs +): + """ + Start a repeating task using the TickerHandler. + + Args: + interval (int): How often to call callback. + callback (callable): This will be called with `*args, **kwargs` every + `interval` seconds. This must be possible to pickle regardless + of if `persistent` is set or not! + persistent (bool, optional): If ticker survives a server reload. + idstring (str, optional): Separates multiple tickers. This is useful + mainly if wanting to set up multiple repeats for the same + interval/callback but with different args/kwargs. + stop (bool, optional): If set, use the given parameters to _stop_ a running + ticker instead of creating a new one. + store_key (tuple, optional): This is only used in combination with `stop` and + should be the return given from the original `repeat` call. If this + is given, all other args except `stop` are ignored. + *args: Used as arguments to `callback`. + **kwargs: Keyword-arguments to pass to `callback`. + + Returns: + tuple or None: The tuple is the `store_key` - the identifier for the + created ticker. Store this and pass into unrepat() in order to to stop + this ticker later. Returns `None` if `stop=True`. + + Raises: + KeyError: If trying to stop a ticker that was not found. + + """ + global _TICKER_HANDLER + if _TICKER_HANDLER is None: + from evennia.scripts.tickerhandler import TICKER_HANDLER as _TICKER_HANDLER + + if stop: + # we pass all args, but only store_key matters if given + _TICKER_HANDLER.remove( + interval=interval, + callback=callback, + idstring=idstring, + persistent=persistent, + store_key=store_key, + ) + else: + return _TICKER_HANDLER.add( + interval=interval, callback=callback, idstring=idstring, persistent=persistent + )
+ + +
[docs]def unrepeat(store_key): + """ + This is used to stop a ticker previously started with `repeat`. + + Args: + store_key (tuple): This is the return from `repeat`, used to uniquely + identify the ticker to stop. Without the store_key, the ticker + must be stopped by passing its parameters to `TICKER_HANDLER.remove` + directly. + + Returns: + bool: True if a ticker was stopped, False if not (for example because no + matching ticker was found or it was already stopped). + + """ + try: + repeat(None, None, stop=True, store_key=store_key) + return True + except KeyError: + return False
+ + +_PPOOL = None +_PCMD = None +_PROC_ERR = "A process has ended with a probable error condition: process ended by signal 9." + + +
[docs]def run_async(to_execute, *args, **kwargs): + """ + Runs a function or executes a code snippet asynchronously. + + Args: + to_execute (callable): If this is a callable, it will be + executed with `*args` and non-reserved `**kwargs` as arguments. + The callable will be executed using ProcPool, or in a thread + if ProcPool is not available. + Keyword Args: + at_return (callable): Should point to a callable with one + argument. It will be called with the return value from + to_execute. + at_return_kwargs (dict): This dictionary will be used as + keyword arguments to the at_return callback. + at_err (callable): This will be called with a Failure instance + if there is an error in to_execute. + at_err_kwargs (dict): This dictionary will be used as keyword + arguments to the at_err errback. + + Notes: + All other `*args` and `**kwargs` will be passed on to + `to_execute`. Run_async will relay executed code to a thread + or procpool. + + Use this function with restrain and only for features/commands + that you know has no influence on the cause-and-effect order of your + game (commands given after the async function might be executed before + it has finished). Accessing the same property from different threads + can lead to unpredicted behaviour if you are not careful (this is called a + "race condition"). + + Also note that some databases, notably sqlite3, don't support access from + multiple threads simultaneously, so if you do heavy database access from + your `to_execute` under sqlite3 you will probably run very slow or even get + tracebacks. + + """ + + # handle special reserved input kwargs + callback = kwargs.pop("at_return", None) + errback = kwargs.pop("at_err", None) + callback_kwargs = kwargs.pop("at_return_kwargs", {}) + errback_kwargs = kwargs.pop("at_err_kwargs", {}) + + if callable(to_execute): + # no process pool available, fall back to old deferToThread mechanism. + deferred = threads.deferToThread(to_execute, *args, **kwargs) + else: + # no appropriate input for this server setup + raise RuntimeError("'%s' could not be handled by run_async" % to_execute) + + # attach callbacks + if callback: + deferred.addCallback(callback, **callback_kwargs) + deferred.addErrback(errback, **errback_kwargs)
+ + +
[docs]def check_evennia_dependencies(): + """ + Checks the versions of Evennia's dependencies including making + some checks for runtime libraries. + + Returns: + result (bool): `False` if a show-stopping version mismatch is + found. + + """ + + # check main dependencies + from evennia.server.evennia_launcher import check_main_evennia_dependencies + + not_error = check_main_evennia_dependencies() + + errstring = "" + # South is no longer used ... + if "south" in settings.INSTALLED_APPS: + errstring += ( + "\n ERROR: 'south' found in settings.INSTALLED_APPS. " + "\n South is no longer used. If this was added manually, remove it." + ) + not_error = False + # IRC support + if settings.IRC_ENABLED: + try: + import twisted.words + + twisted.words # set to avoid debug info about not-used import + except ImportError: + errstring += ( + "\n ERROR: IRC is enabled, but twisted.words is not installed. Please install it." + "\n Linux Debian/Ubuntu users should install package 'python-twisted-words', " + "\n others can get it from http://twistedmatrix.com/trac/wiki/TwistedWords." + ) + not_error = False + errstring = errstring.strip() + if errstring: + mlen = max(len(line) for line in errstring.split("\n")) + logger.log_err("%s\n%s\n%s" % ("-" * mlen, errstring, "-" * mlen)) + return not_error
+ + +
[docs]def has_parent(basepath, obj): + """ + Checks if `basepath` is somewhere in obj's parent tree. + + Args: + basepath (str): Python dotpath to compare against obj path. + obj (any): Object whose path is to be checked. + + Returns: + has_parent (bool): If the check was successful or not. + + """ + try: + return any( + cls + for cls in obj.__class__.mro() + if basepath == "%s.%s" % (cls.__module__, cls.__name__) + ) + except (TypeError, AttributeError): + # this can occur if we tried to store a class object, not an + # instance. Not sure if one should defend against this. + return False
+ + +
[docs]def mod_import_from_path(path): + """ + Load a Python module at the specified path. + + Args: + path (str): An absolute path to a Python module to load. + + Returns: + (module or None): An imported module if the path was a valid + Python module. Returns `None` if the import failed. + + """ + if not os.path.isabs(path): + path = os.path.abspath(path) + dirpath, filename = path.rsplit(os.path.sep, 1) + modname = filename.rstrip(".py") + + try: + return importlib.machinery.SourceFileLoader(modname, path).load_module() + except OSError: + logger.log_trace(f"Could not find module '{modname}' ({modname}.py) at path '{dirpath}'") + return None
+ + +
[docs]def mod_import(module): + """ + A generic Python module loader. + + Args: + module (str, module): This can be either a Python path + (dot-notation like `evennia.objects.models`), an absolute path + (e.g. `/home/eve/evennia/evennia/objects/models.py`) or an + already imported module object (e.g. `models`) + Returns: + (module or None): An imported module. If the input argument was + already a module, this is returned as-is, otherwise the path is + parsed and imported. Returns `None` and logs error if import failed. + + """ + if not module: + return None + + if isinstance(module, types.ModuleType): + # if this is already a module, we are done + return module + + if module.endswith(".py") and os.path.exists(module): + return mod_import_from_path(module) + + try: + return importlib.import_module(module) + except ImportError: + return None
+ + +
[docs]def all_from_module(module): + """ + Return all global-level variables defined in a module. + + Args: + module (str, module): This can be either a Python path + (dot-notation like `evennia.objects.models`), an absolute path + (e.g. `/home/eve/evennia/evennia/objects.models.py`) or an + already imported module object (e.g. `models`) + + Returns: + dict: A dict of {variablename: variable} for all + variables in the given module. + + Notes: + Ignores modules and variable names starting with an underscore, as well + as variables imported into the module from other modules. + + """ + mod = mod_import(module) + if not mod: + return {} + # make sure to only return variables actually defined in this + # module if available (try to avoid imports) + members = getmembers(mod, predicate=lambda obj: getmodule(obj) in (mod, None)) + return dict((key, val) for key, val in members if not key.startswith("_"))
+ + +
[docs]def callables_from_module(module): + """ + Return all global-level callables defined in a module. + + Args: + module (str, module): A python-path to a module or an actual + module object. + + Returns: + callables (dict): A dict of {name: callable, ...} from the module. + + Notes: + Will ignore callables whose names start with underscore "_". + + """ + mod = mod_import(module) + if not mod: + return {} + # make sure to only return callables actually defined in this module (not imports) + members = getmembers(mod, predicate=lambda obj: callable(obj) and getmodule(obj) == mod) + return dict((key, val) for key, val in members if not key.startswith("_"))
+ + +
[docs]def variable_from_module(module, variable=None, default=None): + """ + Retrieve a variable or list of variables from a module. The + variable(s) must be defined globally in the module. If no variable + is given (or a list entry is `None`), all global variables are + extracted from the module. + + Args: + module (string or module): Python path, absolute path or a module. + variable (string or iterable, optional): Single variable name or iterable + of variable names to extract. If not given, all variables in + the module will be returned. + default (string, optional): Default value to use if a variable fails to + be extracted. Ignored if `variable` is not given. + + Returns: + variables (value or list): A single value or a list of values + depending on if `variable` is given or not. Errors in lists + are replaced by the `default` argument. + + """ + + if not module: + return default + + mod = mod_import(module) + + if not mod: + return default + + if variable: + result = [] + for var in make_iter(variable): + if var: + # try to pick a named variable + result.append(mod.__dict__.get(var, default)) + else: + # get all + result = [ + val for key, val in mod.__dict__.items() if not (key.startswith("_") or ismodule(val)) + ] + + if len(result) == 1: + return result[0] + return result
+ + +
[docs]def string_from_module(module, variable=None, default=None): + """ + This is a wrapper for `variable_from_module` that requires return + value to be a string to pass. It's primarily used by login screen. + + Args: + module (string or module): Python path, absolute path or a module. + variable (string or iterable, optional): Single variable name or iterable + of variable names to extract. If not given, all variables in + the module will be returned. + default (string, optional): Default value to use if a variable fails to + be extracted. Ignored if `variable` is not given. + + Returns: + variables (value or list): A single (string) value or a list of values + depending on if `variable` is given or not. Errors in lists (such + as the value not being a string) are replaced by the `default` argument. + + """ + val = variable_from_module(module, variable=variable, default=default) + if val: + if variable: + return val + else: + result = [v for v in make_iter(val) if isinstance(v, str)] + return result if result else default + return default
+ + +
[docs]def random_string_from_module(module): + """ + Returns a random global string from a module. + + Args: + module (string or module): Python path, absolute path or a module. + + Returns: + random (string): A random stribg variable from `module`. + """ + return random.choice(string_from_module(module))
+ + +
[docs]def fuzzy_import_from_module(path, variable, default=None, defaultpaths=None): + """ + Import a variable based on a fuzzy path. First the literal + `path` will be tried, then all given `defaultpaths` will be + prepended to see a match is found. + + Args: + path (str): Full or partial python path. + variable (str): Name of variable to import from module. + default (string, optional): Default value to use if a variable fails to + be extracted. Ignored if `variable` is not given. + defaultpaths (iterable, options): Python paths to attempt in order if + importing directly from `path` doesn't work. + + Returns: + value (any): The variable imported from the module, or `default`, if + not found. + + """ + paths = [path] + make_iter(defaultpaths) + for modpath in paths: + try: + mod = importlib.import_module(modpath) + except ImportError as ex: + if not str(ex).startswith("No module named %s" % modpath): + # this means the module was found but it + # triggers an ImportError on import. + raise ex + return getattr(mod, variable, default) + return default
+ + +
[docs]def class_from_module(path, defaultpaths=None, fallback=None): + """ + Return a class from a module, given the class' full python path. This is + primarily used to convert db_typeclass_path:s to classes. + + Args: + path (str): Full Python dot-path to module. + defaultpaths (iterable, optional): If a direct import from `path` fails, + try subsequent imports by prepending those paths to `path`. + fallback (str): If all other attempts fail, use this path as a fallback. + This is intended as a last-resort. In the example of Evennia + loading, this would be a path to a default parent class in the + evennia repo itself. + + Returns: + class (Class): An uninstantiated class recovered from path. + + Raises: + ImportError: If all loading failed. + + """ + cls = None + err = "" + if defaultpaths: + paths = ( + [path] + ["%s.%s" % (dpath, path) for dpath in make_iter(defaultpaths)] + if defaultpaths + else [] + ) + else: + paths = [path] + + for testpath in paths: + if "." in path: + testpath, clsname = testpath.rsplit(".", 1) + else: + raise ImportError("the path '%s' is not on the form modulepath.Classname." % path) + + try: + if not importlib.util.find_spec(testpath, package="evennia"): + continue + except ModuleNotFoundError: + continue + + try: + mod = importlib.import_module(testpath, package="evennia") + except ModuleNotFoundError: + err = traceback.format_exc(30) + break + + try: + cls = getattr(mod, clsname) + break + except AttributeError: + if len(trace()) > 2: + # AttributeError within the module, don't hide it + err = traceback.format_exc(30) + break + if not cls: + err = "\nCould not load typeclass '{}'{}".format( + path, " with the following traceback:\n" + err if err else "" + ) + if defaultpaths: + err += "\nPaths searched:\n %s" % "\n ".join(paths) + else: + err += "." + logger.log_err(err) + if fallback: + logger.log_warn(f"Falling back to {fallback}.") + return class_from_module(fallback) + else: + # even fallback fails + raise ImportError(err) + return cls
+ + +# alias +object_from_module = class_from_module + + +
[docs]def init_new_account(account): + """ + Deprecated. + """ + from evennia.utils import logger + + logger.log_dep("evennia.utils.utils.init_new_account is DEPRECATED and should not be used.")
+ + +
[docs]def string_similarity(string1, string2): + """ + This implements a "cosine-similarity" algorithm as described for example in + *Proceedings of the 22nd International Conference on Computation + Linguistics* (Coling 2008), pages 593-600, Manchester, August 2008. + The measure-vectors used is simply a "bag of words" type histogram + (but for letters). + + Args: + string1 (str): String to compare (may contain any number of words). + string2 (str): Second string to compare (any number of words). + + Returns: + similarity (float): A value 0...1 rating how similar the two + strings are. + + """ + vocabulary = set(list(string1 + string2)) + vec1 = [string1.count(v) for v in vocabulary] + vec2 = [string2.count(v) for v in vocabulary] + try: + return float(sum(vec1[i] * vec2[i] for i in range(len(vocabulary)))) / ( + math.sqrt(sum(v1**2 for v1 in vec1)) * math.sqrt(sum(v2**2 for v2 in vec2)) + ) + except ZeroDivisionError: + # can happen if empty-string cmdnames appear for some reason. + # This is a no-match. + return 0
+ + +
[docs]def string_suggestions(string, vocabulary, cutoff=0.6, maxnum=3): + """ + Given a `string` and a `vocabulary`, return a match or a list of + suggestions based on string similarity. + + Args: + string (str): A string to search for. + vocabulary (iterable): A list of available strings. + cutoff (int, 0-1): Limit the similarity matches (the higher + the value, the more exact a match is required). + maxnum (int): Maximum number of suggestions to return. + + Returns: + suggestions (list): Suggestions from `vocabulary` with a + similarity-rating that higher than or equal to `cutoff`. + Could be empty if there are no matches. + + """ + return [ + tup[1] + for tup in sorted( + [(string_similarity(string, sugg), sugg) for sugg in vocabulary], + key=lambda tup: tup[0], + reverse=True, + ) + if tup[0] >= cutoff + ][:maxnum]
+ + +
[docs]def string_partial_matching(alternatives, inp, ret_index=True): + """ + Partially matches a string based on a list of `alternatives`. + Matching is made from the start of each subword in each + alternative. Case is not important. So e.g. "bi sh sw" or just + "big" or "shiny" or "sw" will match "Big shiny sword". Scoring is + done to allow to separate by most common denominator. You will get + multiple matches returned if appropriate. + + Args: + alternatives (list of str): A list of possible strings to + match. + inp (str): Search criterion. + ret_index (bool, optional): Return list of indices (from alternatives + array) instead of strings. + Returns: + matches (list): String-matches or indices if `ret_index` is `True`. + + """ + if not alternatives or not inp: + return [] + + matches = defaultdict(list) + inp_words = inp.lower().split() + for altindex, alt in enumerate(alternatives): + alt_words = alt.lower().split() + last_index = 0 + score = 0 + for inp_word in inp_words: + # loop over parts, making sure only to visit each part once + # (this will invalidate input in the wrong word order) + submatch = [ + last_index + alt_num + for alt_num, alt_word in enumerate(alt_words[last_index:]) + if alt_word.startswith(inp_word) + ] + if submatch: + last_index = min(submatch) + 1 + score += 1 + else: + score = 0 + break + if score: + if ret_index: + matches[score].append(altindex) + else: + matches[score].append(alt) + if matches: + return matches[max(matches)] + return []
+ + +
[docs]def format_table(table, extra_space=1): + """ + Format a 2D array of strings into a multi-column table. + + Args: + table (list): A list of lists to represent columns in the + table: `[[val,val,val,...], [val,val,val,...], ...]`, where + each val will be placed on a separate row in the + column. All columns must have the same number of rows (some + positions may be empty though). + extra_space (int, optional): Sets how much *minimum* extra + padding (in characters) should be left between columns. + + Returns: + list: A list of lists representing the rows to print out one by one. + + Notes: + The function formats the columns to be as wide as the widest member + of each column. + + `evennia.utils.evtable` is more powerful than this, but this + function can be useful when the number of columns and rows are + unknown and must be calculated on the fly. + + Examples: :: + + ftable = format_table([[1,2,3], [4,5,6]]) + string = "" + for ir, row in enumerate(ftable): + if ir == 0: + # make first row white + string += "\\n|w" + "".join(row) + "|n" + else: + string += "\\n" + "".join(row) + print(string) + + """ + + if not table: + return [[]] + + max_widths = [max([len(str(val)) for val in col]) for col in table] + ftable = [] + for irow in range(len(table[0])): + ftable.append( + [ + str(col[irow]).ljust(max_widths[icol]) + " " * extra_space + for icol, col in enumerate(table) + ] + ) + return ftable
+ + +
[docs]def percent(value, minval, maxval, formatting="{:3.1f}%"): + """ + Get a value in an interval as a percentage of its position + in that interval. This also understands negative numbers. + + Args: + value (number): This should be a value minval<=value<=maxval. + minval (number or None): Smallest value in interval. This could be None + for an open interval (then return will always be 100%) + maxval (number or None): Biggest value in interval. This could be None + for an open interval (then return will always be 100%) + formatted (str, optional): This is a string that should + accept one formatting tag. This will receive the + current value as a percentage. If None, the + raw float will be returned instead. + Returns: + str or float: The formatted value or the raw percentage as a float. + Notes: + We try to handle a weird interval gracefully. + + - If either maxval or minval is None (open interval), we (aribtrarily) assume 100%. + - If minval > maxval, we return 0%. + - If minval == maxval == value we are looking at a single value match and return 100%. + - If minval == maxval != value we return 0%. + - If value not in [minval..maxval], we set value to the closest + boundary, so the result will be 0% or 100%, respectively. + + """ + result = None + if None in (minval, maxval): + # we have no boundaries, percent calculation makes no sense, + # we set this to 100% since it + result = 100.0 + elif minval > maxval: + # interval has no width so we cannot + # occupy any position within it. + result = 0.0 + elif minval == maxval == value: + # this is a single value that we match + result = 100.0 + elif minval == maxval != value: + # interval has no width so we cannot be in it. + result = 0.0 + + if result is None: + # constrain value to interval + value = min(max(minval, value), maxval) + + # these should both be >0 + dpart = value - minval + dfull = maxval - minval + result = (dpart / dfull) * 100.0 + + if isinstance(formatting, str): + return formatting.format(result) + return result
+ + +import functools # noqa + + +
[docs]def percentile(iterable, percent, key=lambda x: x): + """ + Find the percentile of a list of values. + + Args: + iterable (iterable): A list of values. Note N MUST BE already sorted. + percent (float): A value from 0.0 to 1.0. + key (callable, optional). Function to compute value from each element of N. + + Returns: + float: The percentile of the values + + """ + if not iterable: + return None + k = (len(iterable) - 1) * percent + f = math.floor(k) + c = math.ceil(k) + if f == c: + return key(iterable[int(k)]) + d0 = key(iterable[int(f)]) * (c - k) + d1 = key(iterable[int(c)]) * (k - f) + return d0 + d1
+ + +
[docs]def format_grid(elements, width=78, sep=" ", verbatim_elements=None, line_prefix=""): + """ + This helper function makes a 'grid' output, where it distributes the given + string-elements as evenly as possible to fill out the given width. + will not work well if the variation of length is very big! + + Args: + elements (iterable): A 1D list of string elements to put in the grid. + width (int, optional): The width of the grid area to fill. + sep (str, optional): The extra separator to put between words. If + set to the empty string, words may run into each other. + verbatim_elements (list, optional): This is a list of indices pointing to + specific items in the `elements` list. An element at this index will + not be included in the calculation of the slot sizes. It will still + be inserted into the grid at the correct position and may be surrounded + by padding unless filling the entire line. This is useful for embedding + decorations in the grid, such as horizontal bars. + ignore_ansi (bool, optional): Ignore ansi markups when calculating white spacing. + line_prefix (str, optional): A prefix to add at the beginning of each line. + This can e.g. be used to preserve line color across line breaks. + + Returns: + list: The grid as a list of ready-formatted rows. We return it + like this to make it easier to insert decorations between rows, such + as horizontal bars. + """ + + def _minimal_rows(elements): + """ + Minimalistic distribution with minimal spacing, good for single-line + grids but will look messy over many lines. + """ + rows = [""] + for element in elements: + rowlen = display_len((rows[-1])) + elen = display_len((element)) + if rowlen + elen <= width: + rows[-1] += element + else: + rows.append(element) + return rows + + def _weighted_rows(elements): + """ + Dynamic-space, good for making even columns in a multi-line grid but + will look strange for a single line. + """ + wls = [display_len((elem)) for elem in elements] + wls_percentile = [wl for iw, wl in enumerate(wls) if iw not in verbatim_elements] + + if wls_percentile: + # get the nth percentile as a good representation of average width + averlen = int(percentile(sorted(wls_percentile), 0.9)) + 2 # include extra space + aver_per_row = width // averlen + 1 + else: + # no adjustable rows, just keep all as-is + aver_per_row = 1 + + if aver_per_row == 1: + # one line per row, output directly since this is trivial + # we use rstrip here to remove extra spaces added by sep + return [ + crop(element.rstrip(), width) + + " " * max(0, width - display_len((element.rstrip()))) + for iel, element in enumerate(elements) + ] + + indices = [averlen * ind for ind in range(aver_per_row - 1)] + + rows = [] + ic = 0 + row = "" + for ie, element in enumerate(elements): + wl = wls[ie] + lrow = display_len((row)) + # debug = row.replace(" ", ".") + + if lrow + wl > width: + # this slot extends outside grid, move to next line + row += " " * (width - lrow) + rows.append(row) + if wl >= width: + # remove sep if this fills the entire line + element = element.rstrip() + row = crop(element, width) + ic = 0 + elif ic >= aver_per_row - 1: + # no more slots available on this line + row += " " * max(0, (width - lrow)) + rows.append(row) + row = crop(element, width) + ic = 0 + else: + try: + while lrow > max(0, indices[ic]): + # slot too wide, extend into adjacent slot + ic += 1 + row += " " * max(0, indices[ic] - lrow) + except IndexError: + # we extended past edge of grid, crop or move to next line + if ic == 0: + row = crop(element, width) + else: + row += " " * max(0, width - lrow) + rows.append(row) + row = "" + ic = 0 + else: + # add a new slot + row += element + " " * max(0, averlen - wl) + ic += 1 + + if ie >= nelements - 1: + # last element, make sure to store + row += " " * max(0, width - display_len((row))) + rows.append(row) + return rows + + if not elements: + return [] + if not verbatim_elements: + verbatim_elements = [] + + nelements = len(elements) + # add sep to all but the very last element + elements = [elements[ie] + sep for ie in range(nelements - 1)] + [elements[-1]] + + if sum(display_len((element)) for element in elements) <= width: + # grid fits in one line + rows = _minimal_rows(elements) + else: + # full multi-line grid + rows = _weighted_rows(elements) + + if line_prefix: + return [line_prefix + row for row in rows] + return rows
+ + +
[docs]def get_evennia_pids(): + """ + Get the currently valid PIDs (Process IDs) of the Portal and + Server by trying to access a PID file. + + Returns: + server, portal (tuple): The PIDs of the respective processes, + or two `None` values if not found. + + Examples: + This can be used to determine if we are in a subprocess by + + ```python + self_pid = os.getpid() + server_pid, portal_pid = get_evennia_pids() + is_subprocess = self_pid not in (server_pid, portal_pid) + ``` + + """ + server_pidfile = os.path.join(settings.GAME_DIR, "server.pid") + portal_pidfile = os.path.join(settings.GAME_DIR, "portal.pid") + server_pid, portal_pid = None, None + if os.path.exists(server_pidfile): + with open(server_pidfile, "r") as f: + server_pid = f.read() + if os.path.exists(portal_pidfile): + with open(portal_pidfile, "r") as f: + portal_pid = f.read() + if server_pid and portal_pid: + return int(server_pid), int(portal_pid) + return None, None
+ + +
[docs]def deepsize(obj, max_depth=4): + """ + Get not only size of the given object, but also the size of + objects referenced by the object, down to `max_depth` distance + from the object. + + Args: + obj (object): the object to be measured. + max_depth (int, optional): maximum referential distance + from `obj` that `deepsize()` should cover for + measuring objects referenced by `obj`. + + Returns: + size (int): deepsize of `obj` in Bytes. + + Notes: + This measure is necessarily approximate since some + memory is shared between objects. The `max_depth` of 4 is roughly + tested to give reasonable size information about database models + and their handlers. + + """ + + def _recurse(o, dct, depth): + if 0 <= max_depth < depth: + return + for ref in gc.get_referents(o): + idr = id(ref) + if idr not in dct: + dct[idr] = (ref, sys.getsizeof(ref, default=0)) + _recurse(ref, dct, depth + 1) + + sizedict = {} + _recurse(obj, sizedict, 0) + size = sys.getsizeof(obj) + sum([p[1] for p in sizedict.values()]) + return size
+ + +# lazy load handler +_missing = object() + + +
[docs]class lazy_property: + """ + Delays loading of property until first access. Credit goes to the + Implementation in the werkzeug suite: + http://werkzeug.pocoo.org/docs/utils/#werkzeug.utils.cached_property + + This should be used as a decorator in a class and in Evennia is + mainly used to lazy-load handlers: + + ```python + @lazy_property + def attributes(self): + return AttributeHandler(self) + ``` + + Once initialized, the `AttributeHandler` will be available as a + property "attributes" on the object. This is read-only since + this functionality is pretty much exclusively used by handlers. + + """ + +
[docs] def __init__(self, func, name=None, doc=None): + """Store all properties for now""" + self.__name__ = name or func.__name__ + self.__module__ = func.__module__ + self.__doc__ = doc or func.__doc__ + self.func = func
+ + def __get__(self, obj, type=None): + """Triggers initialization""" + if obj is None: + return self + value = obj.__dict__.get(self.__name__, _missing) + if value is _missing: + value = self.func(obj) + obj.__dict__[self.__name__] = value + return value + + def __set__(self, obj, value): + """Protect against setting""" + handlername = self.__name__ + raise AttributeError( + _( + "{obj}.{handlername} is a handler and can't be set directly. " + "To add values, use `{obj}.{handlername}.add()` instead." + ).format(obj=obj, handlername=handlername) + ) + + def __delete__(self, obj): + """Protect against deleting""" + handlername = self.__name__ + raise AttributeError( + _( + "{obj}.{handlername} is a handler and can't be deleted directly. " + "To remove values, use `{obj}.{handlername}.remove()` instead." + ).format(obj=obj, handlername=handlername) + )
+ + +_STRIP_ANSI = None +_RE_CONTROL_CHAR = re.compile( + "[%s]" % re.escape("".join([chr(c) for c in range(0, 32)])) +) # + range(127,160)]))) + + +
[docs]def strip_control_sequences(string): + """ + Remove non-print text sequences. + + Args: + string (str): Text to strip. + + Returns. + text (str): Stripped text. + + """ + global _STRIP_ANSI + if not _STRIP_ANSI: + from evennia.utils.ansi import strip_raw_ansi as _STRIP_ANSI + return _RE_CONTROL_CHAR.sub("", _STRIP_ANSI(string))
+ + +
[docs]def calledby(callerdepth=1): + """ + Only to be used for debug purposes. Insert this debug function in + another function; it will print which function called it. + + Args: + callerdepth (int or None): If None, show entire stack. If int, must be larger than 0. + When > 1, it will print the sequence to that depth. + + Returns: + calledby (str): A debug string detailing the code that called us. + + """ + import inspect + + def _stack_display(frame): + path = os.path.sep.join(frame[1].rsplit(os.path.sep, 2)[-2:]) + return ( + f"> called by '{frame[3]}': {path}:{frame[2]} >>>" + f" {frame[4][0].strip() if frame[4] else ''}" + ) + + stack = inspect.stack() + + out = [] + if callerdepth is None: + callerdepth = len(stack) - 1 + + # show range + for idepth in range(1, max(1, callerdepth + 1)): + # we must step one extra level back in stack since we don't want + # to include the call of this function itself. + out.append(_stack_display(stack[min(idepth + 1, len(stack) - 1)])) + return "\n".join(out[::-1])
+ + +
[docs]def m_len(target): + """ + Provides length checking for strings with MXP patterns, and falls + back to normal len for other objects. + + Args: + target (str): A string with potential MXP components + to search. + + Returns: + length (int): The length of `target`, ignoring MXP components. + + """ + # Would create circular import if in module root. + from evennia.utils.ansi import ANSI_PARSER + + if inherits_from(target, str) and "|lt" in target: + return len(ANSI_PARSER.strip_mxp(target)) + return len(target)
+ + +
[docs]def display_len(target): + """ + Calculate the 'visible width' of text. This is not necessarily the same as the + number of characters in the case of certain asian characters. This will also + strip MXP patterns. + + Args: + target (any): Something to measure the length of. If a string, it will be + measured keeping asian-character and MXP links in mind. + + Return: + int: The visible width of the target. + + """ + # Would create circular import if in module root. + from evennia.utils.ansi import ANSI_PARSER + + if inherits_from(target, str): + # str or ANSIString + target = ANSI_PARSER.strip_mxp(target) + target = ANSI_PARSER.parse_ansi(target, strip_ansi=True) + extra_wide = ("F", "W") + return sum(2 if east_asian_width(char) in extra_wide else 1 for char in target) + else: + return len(target)
+ + +# ------------------------------------------------------------------- +# Search handler function +# ------------------------------------------------------------------- +# +# Replace this hook function by changing settings.SEARCH_AT_RESULT. + + +
[docs]def at_search_result(matches, caller, query="", quiet=False, **kwargs): + """ + This is a generic hook for handling all processing of a search + result, including error reporting. This is also called by the cmdhandler + to manage errors in command lookup. + + Args: + matches (list): This is a list of 0, 1 or more typeclass + instances or Command instances, the matched result of the + search. If 0, a nomatch error should be echoed, and if >1, + multimatch errors should be given. Only if a single match + should the result pass through. + caller (Object): The object performing the search and/or which should + receive error messages. + query (str, optional): The search query used to produce `matches`. + quiet (bool, optional): If `True`, no messages will be echoed to caller + on errors. + Keyword Args: + nofound_string (str): Replacement string to echo on a notfound error. + multimatch_string (str): Replacement string to echo on a multimatch error. + + Returns: + processed_result (Object or None): This is always a single result + or `None`. If `None`, any error reporting/handling should + already have happened. The returned object is of the type we are + checking multimatches for (e.g. Objects or Commands) + + """ + + error = "" + if not matches: + # no results. + error = kwargs.get("nofound_string") or _("Could not find '{query}'.").format(query=query) + matches = None + elif len(matches) > 1: + multimatch_string = kwargs.get("multimatch_string") + if multimatch_string: + error = "%s\n" % multimatch_string + else: + error = _("More than one match for '{query}' (please narrow target):\n").format( + query=query + ) + + for num, result in enumerate(matches): + # we need to consider that result could be a Command, where .aliases + # is a list of strings + if hasattr(result.aliases, "all"): + # result is a typeclassed entity where `.aliases` is an AliasHandler. + aliases = result.aliases.all(return_objs=True) + # remove pluralization aliases + aliases = [ + alias + for alias in aliases + if hasattr(alias, "category") and alias.category not in ("plural_key",) + ] + else: + # result is likely a Command, where `.aliases` is a list of strings. + aliases = result.aliases + + error += _MULTIMATCH_TEMPLATE.format( + number=num + 1, + name=result.get_display_name(caller) + if hasattr(result, "get_display_name") + else query, + aliases=" [{alias}]".format(alias=";".join(aliases) if aliases else ""), + info=result.get_extra_info(caller), + ) + matches = None + else: + # exactly one match + matches = matches[0] + + if error and not quiet: + caller.msg(error.strip()) + return matches
+ + +
[docs]class LimitedSizeOrderedDict(OrderedDict): + """ + This dictionary subclass is both ordered and limited to a maximum + number of elements. Its main use is to hold a cache that can never + grow out of bounds. + + """ + +
[docs] def __init__(self, *args, **kwargs): + """ + Limited-size ordered dict. + + Keyword Args: + size_limit (int): Use this to limit the number of elements + alloweds to be in this list. By default the overshooting elements + will be removed in FIFO order. + fifo (bool, optional): Defaults to `True`. Remove overshooting elements + in FIFO order. If `False`, remove in FILO order. + + """ + super().__init__() + self.size_limit = kwargs.get("size_limit", None) + self.filo = not kwargs.get("fifo", True) # FIFO inverse of FILO + self._check_size()
+ + def __eq__(self, other): + ret = super().__eq__(other) + if ret: + return ( + ret + and hasattr(other, "size_limit") + and self.size_limit == other.size_limit + and hasattr(other, "fifo") + and self.fifo == other.fifo + ) + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def _check_size(self): + filo = self.filo + if self.size_limit is not None: + while self.size_limit < len(self): + self.popitem(last=filo) + + def __setitem__(self, key, value): + super().__setitem__(key, value) + self._check_size() + +
[docs] def update(self, *args, **kwargs): + super().update(*args, **kwargs) + self._check_size()
+ + +
[docs]def get_game_dir_path(): + """ + This is called by settings_default in order to determine the path + of the game directory. + + Returns: + path (str): Full OS path to the game dir + + """ + # current working directory, assumed to be somewhere inside gamedir. + for inum in range(10): + gpath = os.getcwd() + if "server" in os.listdir(gpath): + if os.path.isfile(os.path.join("server", "conf", "settings.py")): + return gpath + else: + os.chdir(os.pardir) + raise RuntimeError("server/conf/settings.py not found: Must start from inside game dir.")
+ + +
[docs]def get_all_typeclasses(parent=None): + """ + List available typeclasses from all available modules. + + Args: + parent (str, optional): If given, only return typeclasses inheriting + (at any distance) from this parent. + + Returns: + dict: On the form `{"typeclass.path": typeclass, ...}` + + Notes: + This will dynamically retrieve all abstract django models inheriting at + any distance from the TypedObject base (aka a Typeclass) so it will + work fine with any custom classes being added. + + """ + from evennia.typeclasses.models import TypedObject + + typeclasses = { + "{}.{}".format(model.__module__, model.__name__): model + for model in apps.get_models() + if TypedObject in getmro(model) + } + if parent: + typeclasses = { + name: typeclass + for name, typeclass in typeclasses.items() + if inherits_from(typeclass, parent) + } + return typeclasses
+ + +
[docs]def get_all_cmdsets(parent=None): + """ + List available cmdsets from all available modules. + + Args: + parent (str, optional): If given, only return cmdsets inheriting (at + any distance) from this parent. + + Returns: + dict: On the form {"cmdset.path": cmdset, ...} + + Notes: + This will dynamically retrieve all abstract django models inheriting at + any distance from the CmdSet base so it will work fine with any custom + classes being added. + + """ + from evennia.commands.cmdset import CmdSet + + base_cmdset = class_from_module(parent) if parent else CmdSet + + cmdsets = { + "{}.{}".format(subclass.__module__, subclass.__name__): subclass + for subclass in base_cmdset.__subclasses__() + } + return cmdsets
+ + +
[docs]def interactive(func): + """ + Decorator to make a method pausable with `yield(seconds)` + and able to ask for user-input with `response=yield(question)`. + For the question-asking to work, one of the args or kwargs to the + decorated function must be named 'caller'. + + Raises: + ValueError: If asking an interactive question but the decorated + function has no arg or kwarg named 'caller'. + ValueError: If passing non int/float to yield using for pausing. + + Examples: + + ```python + @interactive + def myfunc(caller): + caller.msg("This is a test") + # wait five seconds + yield(5) + # ask user (caller) a question + response = yield("Do you want to continue waiting?") + if response == "yes": + yield(5) + else: + # ... + ``` + + Notes: + This turns the decorated function or method into a generator. + + """ + from evennia.utils.evmenu import get_input + + def _process_input(caller, prompt, result, generator): + deferLater(reactor, 0, _iterate, generator, caller, response=result) + return False + + def _iterate(generator, caller=None, response=None): + try: + if response is None: + value = next(generator) + else: + value = generator.send(response) + except StopIteration: + pass + else: + if isinstance(value, (int, float)): + delay(value, _iterate, generator, caller=caller) + elif isinstance(value, str): + if not caller: + raise ValueError( + "To use `result yield('prompt')` in an @interactive method, that " + "method must have an argument named `caller`.)" + ) + get_input(caller, value, _process_input, generator=generator) + else: + raise ValueError( + "yield(val) in an @interactive method must have an int/float as arg." + ) + + def decorator(*args, **kwargs): + argnames = inspect.getfullargspec(func).args + caller = None + if "caller" in argnames: + # we assume this is an object + caller = args[argnames.index("caller")] + + ret = func(*args, **kwargs) + if isinstance(ret, types.GeneratorType): + _iterate(ret, caller) + else: + return ret + + return decorator
+ + +
[docs]def safe_convert_to_types(converters, *args, raise_errors=True, **kwargs): + """ + Helper function to safely convert inputs to expected data types. + + Args: + converters (tuple): A tuple `((converter, converter,...), {kwarg: converter, ...})` to + match a converter to each element in `*args` and `**kwargs`. + Each converter will will be called with the arg/kwarg-value as the only argument. + If there are too few converters given, the others will simply not be converter. If the + converter is given as the string 'py', it attempts to run + `safe_eval`/`literal_eval` on the input arg or kwarg value. It's possible to + skip the arg/kwarg part of the tuple, an empty tuple/dict will then be assumed. + *args: The arguments to convert with `argtypes`. + raise_errors (bool, optional): If set, raise any errors. This will + abort the conversion at that arg/kwarg. Otherwise, just skip the + conversion of the failing arg/kwarg. This will be set by the FuncParser if + this is used as a part of a FuncParser callable. + **kwargs: The kwargs to convert with `kwargtypes` + + Returns: + tuple: `(args, kwargs)` in converted form. + + Raises: + utils.funcparser.ParsingError: If parsing failed in the `'py'` + converter. This also makes this compatible with the FuncParser + interface. + any: Any other exception raised from other converters, if raise_errors is True. + + Notes: + This function is often used to validate/convert input from untrusted sources. For + security, the "py"-converter is deliberately limited and uses `safe_eval`/`literal_eval` + which only supports simple expressions or simple containers with literals. NEVER + use the python `eval` or `exec` methods as a converter for any untrusted input! Allowing + untrusted sources to execute arbitrary python on your server is a severe security risk, + + Example: + :: + + $funcname(1, 2, 3.0, c=[1,2,3]) + + def _funcname(*args, **kwargs): + args, kwargs = safe_convert_input(((int, int, float), {'c': 'py'}), *args, **kwargs) + # ... + + """ + container_end_char = {"(": ")", "[": "]", "{": "}"} # tuples, lists, sets + + def _manual_parse_containers(inp): + startchar = inp[0] + endchar = inp[-1] + if endchar != container_end_char.get(startchar): + return + return [str(part).strip() for part in inp[1:-1].split(",")] + + def _safe_eval(inp): + if not inp: + return "" + if not isinstance(inp, str): + # already converted + return inp + try: + try: + return literal_eval(inp) + except ValueError: + parts = _manual_parse_containers(inp) + if not parts: + raise + return parts + + except Exception as err: + literal_err = f"{err.__class__.__name__}: {err}" + try: + return simple_eval(inp) + except Exception as err: + simple_err = f"{str(err.__class__.__name__)}: {err}" + + if raise_errors: + from evennia.utils.funcparser import ParsingError + + err = ( + f"Errors converting '{inp}' to python:\n" + f"literal_eval raised {literal_err}\n" + f"simple_eval raised {simple_err}" + ) + raise ParsingError(err) + else: + # fallback - convert to str + return str(inp) + + # handle an incomplete/mixed set of input converters + if not converters: + return args, kwargs + arg_converters, *kwarg_converters = converters + arg_converters = make_iter(arg_converters) + kwarg_converters = kwarg_converters[0] if kwarg_converters else {} + + # apply the converters + if args and arg_converters: + args = list(args) + arg_converters = make_iter(arg_converters) + for iarg, arg in enumerate(args[: len(arg_converters)]): + converter = arg_converters[iarg] + converter = _safe_eval if converter in ("py", "python") else converter + try: + args[iarg] = converter(arg) + except Exception: + if raise_errors: + raise + args = tuple(args) + if kwarg_converters and isinstance(kwarg_converters, dict): + for key, converter in kwarg_converters.items(): + converter = _safe_eval if converter in ("py", "python") else converter + if key in {**kwargs}: + try: + kwargs[key] = converter(kwargs[key]) + except Exception: + if raise_errors: + raise + return args, kwargs
+ + +
[docs]def strip_unsafe_input(txt, session=None, bypass_perms=None): + """ + Remove 'unsafe' text codes from text; these are used to elimitate + exploits in user-provided data, such as html-tags, line breaks etc. + + Args: + txt (str): The text to clean. + session (Session, optional): A Session in order to determine if + the check should be bypassed by permission (will be checked + with the 'perm' lock, taking permission hierarchies into account). + bypass_perms (list, optional): Iterable of permission strings + to check for bypassing the strip. If not given, use + `settings.INPUT_CLEANUP_BYPASS_PERMISSIONS`. + + Returns: + str: The cleaned string. + + Notes: + The `INPUT_CLEANUP_BYPASS_PERMISSIONS` list defines what account + permissions are required to bypass this strip. + + """ + global _STRIP_UNSAFE_TOKENS + if not _STRIP_UNSAFE_TOKENS: + from evennia.utils.ansi import strip_unsafe_tokens as _STRIP_UNSAFE_TOKENS + + if session: + obj = session.puppet if session.puppet else session.account + bypass_perms = bypass_perms or settings.INPUT_CLEANUP_BYPASS_PERMISSIONS + if obj.permissions.check(*bypass_perms): + return txt + + # remove html codes + txt = strip_tags(txt) + txt = _STRIP_UNSAFE_TOKENS(txt) + return txt
+ + +
[docs]def copy_word_case(base_word, new_word): + """ + Converts a word to use the same capitalization as a first word. + + Args: + base_word (str): A word to get the capitalization from. + new_word (str): A new word to capitalize in the same way as `base_word`. + + Returns: + str: The `new_word` with capitalization matching the first word. + + Notes: + This is meant for words. Longer sentences may get unexpected results. + + If the two words have a mix of capital/lower letters _and_ `new_word` + is longer than `base_word`, the excess will retain its original case. + + """ + + # Word + if base_word.istitle(): + return new_word.title() + # word + elif base_word.islower(): + return new_word.lower() + # WORD + elif base_word.isupper(): + return new_word.upper() + else: + # WorD - a mix. Handle each character + maxlen = len(base_word) + shared, excess = new_word[:maxlen], new_word[maxlen - 1 :] + return ( + "".join( + char.upper() if base_word[ic].isupper() else char.lower() + for ic, char in enumerate(new_word) + ) + + excess + )
+ + +
[docs]def run_in_main_thread(function_or_method, *args, **kwargs): + """ + Force a callable to execute in the main Evennia thread. This is only relevant when + calling code from e.g. web views, which run in a separate threadpool. Use this + to avoid race conditions. + + Args: + function_or_method (callable): A function or method to fire. + *args: Will be passed into the callable. + **kwargs: Will be passed into the callable. + + """ + if _IS_MAIN_THREAD: + return function_or_method(*args, **kwargs) + else: + return threads.blockingCallFromThread(reactor, function_or_method, *args, **kwargs)
+ + +_INT2STR_MAP_NOUN = { + 0: "no", + 1: "one", + 2: "two", + 3: "three", + 4: "four", + 5: "five", + 6: "six", + 7: "seven", + 8: "eight", + 9: "nine", + 10: "ten", + 11: "eleven", + 12: "twelve", +} +_INT2STR_MAP_ADJ = {1: "1st", 2: "2nd", 3: "3rd"} # rest is Xth. + + +
[docs]def int2str(number, adjective=False): + """ + Convert a number to an English string for better display; so 1 -> one, 2 -> two etc + up until 12, after which it will be '13', '14' etc. + + Args: + number (int): The number to convert. Floats will be converted to ints. + adjective (int): If set, map 1->1st, 2->2nd etc. If unset, map 1->one, 2->two etc. + up to twelve. + Return: + str: The number expressed as a string. + + """ + number = int(number) + if adjective: + return _INT2STR_MAP_ADJ.get(number, f"{number}th") + return _INT2STR_MAP_NOUN.get(number, str(number))
+ + +_STR2INT_MAP = { + "one": 1, + "two": 2, + "three": 3, + "four": 4, + "five": 5, + "six": 6, + "seven": 7, + "eight": 8, + "nine": 9, + "ten": 10, + "eleven": 11, + "twelve": 12, + "thirteen": 13, + "fourteen": 14, + "fifteen": 15, + "sixteen": 16, + "seventeen": 17, + "eighteen": 18, + "nineteen": 19, + "twenty": 20, + "thirty": 30, + "forty": 40, + "fifty": 50, + "sixty": 60, + "seventy": 70, + "eighty": 80, + "ninety": 90, + "hundred": 100, + "thousand": 1000, +} +_STR2INT_ADJS = { + "first": 1, + "second": 2, + "third": 3, +} + + +
[docs]def str2int(number): + """ + Converts a string to an integer. + + Args: + number (str): The string to convert. It can be a digit such as "1", or a number word such as "one". + + Returns: + int: The string represented as an integer. + """ + number = str(number) + original_input = number + try: + # it's a digit already + return int(number) + except: + # if it's an ordinal number such as "1st", it'll convert to int with the last two characters chopped off + try: + return int(number[:-2]) + except: + pass + + # convert sound changes for generic ordinal numbers + if number[-2:] == "th": + # remove "th" + number = number[:-2] + if number[-1] == "f": + # e.g. twelfth, fifth + number = number[:-1] + "ve" + elif number[-2:] == "ie": + # e.g. twentieth, fortieth + number = number[:-2] + "y" + # custom case for ninth + elif number[-3:] == "nin": + number += "e" + + if i := _STR2INT_MAP.get(number): + # it's a single number, return it + return i + + # remove optional "and"s + number = number.replace(" and ", " ") + + # split number words by spaces, hyphens and commas, to accommodate multiple styles + numbers = [word.lower() for word in re.split(r"[-\s\,]", number) if word] + sums = [] + for word in numbers: + # check if it's a known number-word + if i := _STR2INT_MAP.get(word): + if not len(sums): + # initialize the list with the current value + sums = [i] + else: + # if the previous number was smaller, it's a multiplier + # e.g. the "two" in "two hundred" + if sums[-1] < i: + sums[-1] = sums[-1] * i + # otherwise, it's added on, like the "five" in "twenty five" + else: + sums.append(i) + elif i := _STR2INT_ADJS.get(word): + # it's a special adj word; ordinal case will never be a multiplier + sums.append(i) + else: + # invalid number-word, raise ValueError + raise ValueError(f"String {original_input} cannot be converted to int.") + return sum(sums)
+ + +
[docs]def match_ip(address, pattern) -> bool: + """ + Check if an IP address matches a given pattern. The pattern can be a single IP address + such as 8.8.8.8 or a CIDR-formatted subnet like 10.0.0.0/8 + + IPv6 is supported to, with CIDR-subnets looking like 2001:db8::/48 + + Args: + address (str): The source address being checked. + pattern (str): The single IP address or subnet to check against. + + Returns: + result (bool): Whether it was a match or not. + """ + try: + # Convert the given IP address to an IPv4Address or IPv6Address object + ip_obj = ipaddress.ip_address(address) + except ValueError: + # Invalid IP address format + return False + + try: + # Check if pattern is a single IP or a subnet + if "/" in pattern: + # It's (hopefully) a subnet in CIDR notation + network = ipaddress.ip_network(pattern, strict=False) + if ip_obj in network: + return True + else: + # It's a single IP address + if ip_obj == ipaddress.ip_address(pattern): + return True + except ValueError: + return False + return False
+ + +
[docs]def ip_from_request(request, exclude=None) -> str: + """ + Retrieves the IP address from a web Request, while respecting X-Forwarded-For and + settings.UPSTREAM_IPS. + + Args: + request (django Request or twisted.web.http.Request): The web request. + exclude: (list, optional): A list of IP addresses to exclude from the check. If left none, + then settings.UPSTREAM_IPS will be used. + + Returns: + ip (str): The IP address the request originated from. + """ + if exclude is None: + exclude = settings.UPSTREAM_IPS + + if hasattr(request, "getClientIP"): + # It's a twisted request. + remote_addr = request.getClientIP() + forwarded = request.getHeader("x-forwarded-for") + else: + # it's a Django request. + remote_addr = request.META.get("REMOTE_ADDR") + forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + + addresses = [remote_addr] + + if forwarded: + addresses.extend(x.strip() for x in forwarded.split(",")) + + for addr in reversed(addresses): + if all(not match_ip(addr, pattern) for pattern in exclude): + return addr + + logger.log_warn("ip_from_request: No valid IP address found in request. Using remote_addr.") + return remote_addr
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/validatorfuncs.html b/docs/latest/_modules/evennia/utils/validatorfuncs.html new file mode 100644 index 0000000000..3991f6f9d9 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/validatorfuncs.html @@ -0,0 +1,387 @@ + + + + + + + + evennia.utils.validatorfuncs — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.validatorfuncs

+"""
+Contains all the validation functions.
+
+All validation functions must have a checker (probably a session) and entry arg.
+
+They can employ more paramters at your leisure.
+
+
+"""
+
+import datetime as _dt
+import re as _re
+
+import pytz as _pytz
+from django.utils.translation import gettext as _
+
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.utils import string_partial_matching as _partial
+from evennia.utils.utils import validate_email_address
+
+_TZ_DICT = {str(tz): _pytz.timezone(tz) for tz in _pytz.common_timezones}
+
+
+
[docs]def text(entry, option_key="Text", **kwargs): + try: + return str(entry) + except Exception as err: + raise ValueError(_("Input could not be converted to text ({err})").format(err=err))
+ + +
[docs]def color(entry, option_key="Color", **kwargs): + """ + The color should be just a color character, so 'r' if red color is desired. + + """ + if not entry: + raise ValueError(_("Nothing entered for a {option_key}!").format(option_key=option_key)) + test_str = strip_ansi(f"|{entry}|n") + if test_str: + raise ValueError( + _("'{entry}' is not a valid {option_key}.").format(entry=entry, option_key=option_key) + ) + return entry
+ + +
[docs]def datetime(entry, option_key="Datetime", account=None, from_tz=None, **kwargs): + """ + Process a datetime string in standard forms while accounting for the + inputer's timezone. Always returns a result in UTC. + + Args: + entry (str): A date string from a user. + option_key (str): Name to display this datetime as. + account (AccountDB): The Account performing this lookup. Unless `from_tz` is provided, + the account's timezone option will be used. + from_tz (pytz.timezone): An instance of a pytz timezone object from the + user. If not provided, tries to use the timezone option of `account`. + If neither one is provided, defaults to UTC. + Returns: + datetime in UTC. + Raises: + ValueError: If encountering a malformed timezone, date string or other format error. + + """ + if not entry: + raise ValueError(_("No {option_key} entered!").format(option_key=option_key)) + if not from_tz: + from_tz = _pytz.UTC + if account: + acct_tz = account.options.get("timezone", "UTC") + try: + from_tz = _pytz.timezone(acct_tz) + except Exception as err: + raise ValueError( + _("Timezone string '{acct_tz}' is not a valid timezone ({err})").format( + acct_tz=acct_tz, err=err + ) + ) + else: + from_tz = _pytz.UTC + + utc = _pytz.UTC + now = _dt.datetime.utcnow().replace(tzinfo=utc) + cur_year = now.strftime("%Y") + split_time = entry.split(" ") + if len(split_time) == 3: + entry = f"{split_time[0]} {split_time[1]} {split_time[2]} {cur_year}" + elif len(split_time) == 4: + entry = f"{split_time[0]} {split_time[1]} {split_time[2]} {split_time[3]}" + else: + raise ValueError( + _("{option_key} must be entered in a 24-hour format such as: {timeformat}").format( + option_key=option_key, timeformat=now.strftime("%b %d %H:%M") + ) + ) + try: + local = _dt.datetime.strptime(entry, "%b %d %H:%M %Y") + except ValueError: + raise ValueError( + _("{option_key} must be entered in a 24-hour format such as: {timeformat}").format( + option_key=option_key, timeformat=now.strftime("%b %d %H:%M") + ) + ) + local_tz = from_tz.localize(local) + return local_tz.astimezone(utc)
+ + +
[docs]def duration(entry, option_key="Duration", **kwargs): + """ + Take a string and derive a datetime timedelta from it. + + Args: + entry (string): This is a string from user-input. The intended format is, for example: + "5d 2w 90s" for 'five days, two weeks, and ninety seconds.' Invalid sections are + ignored. + option_key (str): Name to display this query as. + + Returns: + timedelta + + """ + time_string = entry.lower().split(" ") + seconds = 0 + minutes = 0 + hours = 0 + days = 0 + weeks = 0 + + for interval in time_string: + if _re.match(r"^[\d]+s$", interval): + seconds += int(interval.rstrip("s")) + elif _re.match(r"^[\d]+m$", interval): + minutes += int(interval.rstrip("m")) + elif _re.match(r"^[\d]+h$", interval): + hours += int(interval.rstrip("h")) + elif _re.match(r"^[\d]+d$", interval): + days += int(interval.rstrip("d")) + elif _re.match(r"^[\d]+w$", interval): + weeks += int(interval.rstrip("w")) + elif _re.match(r"^[\d]+y$", interval): + days += int(interval.rstrip("y")) * 365 + else: + raise ValueError( + _("Could not convert section '{interval}' to a {option_key}.").format( + interval=interval, option_key=option_key + ) + ) + + return _dt.timedelta(days, seconds, 0, 0, minutes, hours, weeks)
+ + +
[docs]def future(entry, option_key="Future Datetime", from_tz=None, **kwargs): + time = datetime(entry, option_key, from_tz=from_tz) + if time < _dt.datetime.utcnow().replace(tzinfo=_dt.timezone.utc): + raise ValueError( + _("That {option_key} is in the past! Must give a Future datetime!").format( + option_key=option_key + ) + ) + return time
+ + +
[docs]def signed_integer(entry, option_key="Signed Integer", **kwargs): + if not entry: + raise ValueError( + _("Must enter a whole number for {option_key}!").format(option_key=option_key) + ) + try: + num = int(entry) + except ValueError: + raise ValueError( + _("Could not convert '{entry}' to a whole " "number for {option_key}!").format( + entry=entry, option_key=option_key + ) + ) + return num
+ + +
[docs]def positive_integer(entry, option_key="Positive Integer", **kwargs): + num = signed_integer(entry, option_key) + if not num >= 1: + raise ValueError( + _("Must enter a whole number greater than 0 for {option_key}!").format( + option_key=option_key + ) + ) + return num
+ + +
[docs]def unsigned_integer(entry, option_key="Unsigned Integer", **kwargs): + num = signed_integer(entry, option_key) + if not num >= 0: + raise ValueError( + _("{option_key} must be a whole number greater than " "or equal to 0!").format( + option_key=option_key + ) + ) + return num
+ + +
[docs]def boolean(entry, option_key="True/False", **kwargs): + """ + Simplest check in computer logic, right? This will take user input to flick the switch on or off + + Args: + entry (str): A value such as True, On, Enabled, Disabled, False, 0, or 1. + option_key (str): What kind of Boolean we are setting. What Option is this for? + + Returns: + Boolean + + """ + error = _("Must enter a true/false input for {option_key}. Accepts {alternatives}.").format( + option_key=option_key, alternatives="0/1, True/False, On/Off, Yes/No, Enabled/Disabled" + ) + if not isinstance(entry, str): + raise ValueError(error) + entry = entry.upper() + if entry in ("1", "TRUE", "ON", "ENABLED", "ENABLE", "YES"): + return True + if entry in ("0", "FALSE", "OFF", "DISABLED", "DISABLE", "NO"): + return False + raise ValueError(error)
+ + +
[docs]def timezone(entry, option_key="Timezone", **kwargs): + """ + Takes user input as string, and partial matches a Timezone. + + Args: + entry (str): The name of the Timezone. + option_key (str): What this Timezone is used for. + + Returns: + A PYTZ timezone. + + """ + if not entry: + raise ValueError(_("No {option_key} entered!").format(option_key=option_key)) + found = _partial(list(_TZ_DICT.keys()), entry, ret_index=False) + if len(found) > 1: + raise ValueError( + _("That matched: {matches}. Please be more specific!").format( + matches=", ".join(str(t) for t in found) + ) + ) + if found: + return _TZ_DICT[found[0]] + raise ValueError( + _("Could not find timezone '{entry}' for {option_key}!").format( + entry=entry, option_key=option_key + ) + )
+ + +
[docs]def email(entry, option_key="Email Address", **kwargs): + if not entry: + raise ValueError(_("Email address field empty!")) + valid = validate_email_address(entry) + if not valid: + raise ValueError(_("That isn't a valid {option_key}!").format(option_key=option_key)) + return entry
+ + +
[docs]def lock(entry, option_key="locks", access_options=None, **kwargs): + entry = entry.strip() + if not entry: + raise ValueError(_("No {option_key} entered to set!").format(option_key=option_key)) + for locksetting in entry.split(";"): + access_type, lockfunc = locksetting.split(":", 1) + if not access_type: + raise ValueError(_("Must enter an access type!")) + if access_options: + if access_type not in access_options: + raise ValueError( + _("Access type must be one of: {alternatives}").format( + alternatives=", ".join(access_options) + ) + ) + if not lockfunc: + raise ValueError(_("Lock func not entered.")) + return entry
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/verb_conjugation/conjugate.html b/docs/latest/_modules/evennia/utils/verb_conjugation/conjugate.html new file mode 100644 index 0000000000..985833302b --- /dev/null +++ b/docs/latest/_modules/evennia/utils/verb_conjugation/conjugate.html @@ -0,0 +1,494 @@ + + + + + + + + evennia.utils.verb_conjugation.conjugate — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.verb_conjugation.conjugate

+"""
+English verb conjugation
+
+Original Author: Tom De Smedt <tomdesmedt@organisms.be> of Nodebox
+Refactored by Griatch 2021, for Evennia.
+
+This is distributed under the GPL2 license. See ./LICENSE.txt for details.
+
+The verb.txt morphology was adopted from the XTAG morph_englis.flat:
+http://www.cis.upenn.edu/~xtag/
+
+
+"""
+
+import os
+
+_VERBS_FILE = "verbs.txt"
+
+# Each verb and its tenses is a list in verbs.txt,
+# indexed according to the following keys:
+# the negated forms (for supported verbs) are ind+11.
+
+verb_tenses_keys = {
+    "infinitive": 0,
+    "1st singular present": 1,
+    "2nd singular present": 2,
+    "3rd singular present": 3,
+    "present plural": 4,
+    "present participle": 5,
+    "1st singular past": 6,
+    "2nd singular past": 7,
+    "3rd singular past": 8,
+    "past plural": 9,
+    "past": 10,
+    "past participle": 11,
+}
+
+# allow to specify tenses with a shorter notation
+verb_tenses_aliases = {
+    "inf": "infinitive",
+    "1sgpres": "1st singular present",
+    "2sgpres": "2nd singular present",
+    "3sgpres": "3rd singular present",
+    "pl": "present plural",
+    "prog": "present participle",
+    "1sgpast": "1st singular past",
+    "2sgpast": "2nd singular past",
+    "3sgpast": "3rd singular past",
+    "pastpl": "past plural",
+    "ppart": "past participle",
+}
+
+# Each verb has morphs for infinitve,
+# 3rd singular present, present participle,
+# past and past participle.
+# Verbs like "be" have other morphs as well
+# (i.e. I am, you are, she is, they aren't)
+# Additionally, the following verbs can be negated:
+# be, can, do, will, must, have, may, need, dare, ought.
+
+# load the conjugation forms from ./verbs.txt
+verb_tenses = {}
+
+path = os.path.join(os.path.dirname(__file__), _VERBS_FILE)
+with open(path) as fil:
+    for line in fil.readlines():
+        wordlist = [part.strip() for part in line.split(",")]
+        verb_tenses[wordlist[0]] = wordlist
+
+# Each verb can be lemmatised:
+# inflected morphs of the verb point
+# to its infinitive in this dictionary.
+verb_lemmas = {}
+for infinitive in verb_tenses:
+    for tense in verb_tenses[infinitive]:
+        if tense:
+            verb_lemmas[tense] = infinitive
+
+
+
[docs]def verb_infinitive(verb): + """ + Returns the uninflected form of the verb, like 'are' -> 'be' + + Args: + verb (str): The verb to get the uninflected form of. + + Returns: + str: The uninflected verb form of `verb`. + + """ + + return verb_lemmas.get(verb, "")
+ + +
[docs]def verb_conjugate(verb, tense="infinitive", negate=False): + """ + Inflects the verb to the given tense. + + Args: + verb (str): The single verb to conjugate. + tense (str): The tense to convert to. This can be given either as a long or short form + - "infinitive" ("inf") - be + - "1st/2nd/3rd singular present" ("1/2/3sgpres") - am/are/is + - "present plural" ("pl") - are + - "present participle" ("prog") - being + - "1st/2nd/3rd singular past" ("1/2/3sgpast") - was/were/was + - "past plural" ("pastpl") - were + - "past" - were + - "past participle" ("ppart") - been + negate (bool): Negates the verb. This only supported + for a limited number of verbs: be, can, do, will, must, have, may, + need, dare, ought. + + Returns: + str: The conjugated verb. If conjugation fails, the original verb is returned. + + Examples: + The verb 'be': + - present: I am, you are, she is, + - present participle: being, + - past: I was, you were, he was, + - past participle: been, + - negated present: I am not, you aren't, it isn't. + + """ + tense = verb_tenses_aliases.get(tense, tense) + verb = verb_infinitive(verb) + ind = verb_tenses_keys[tense] + if negate: + ind += len(verb_tenses_keys) + try: + return verb_tenses[verb][ind] + except (IndexError, KeyError): + # TODO implement simple algorithm here with +s for certain tenses? + return verb
+ + +
[docs]def verb_present(verb, person="", negate=False): + """ + Inflects the verb in the present tense. + + Args: + person (str or int): This can be 1, 2, 3, "1st", "2nd", "3rd", "plural" or "*". + negate (bool): Some verbs like be, have, must, can be negated. + + Returns: + str: The present tense verb. + + Example: + had -> have + + """ + + person = str(person).replace("pl", "*").strip("stndrgural") + mapping = { + "1": "1st singular present", + "2": "2nd singular present", + "3": "3rd singular present", + "*": "present plural", + } + if person in mapping and verb_conjugate(verb, mapping[person], negate) != "": + return verb_conjugate(verb, mapping[person], negate) + + return verb_conjugate(verb, "infinitive", negate)
+ + +
[docs]def verb_present_participle(verb): + """ + Inflects the verb in the present participle. + + Args: + verb (str): The verb to inflect. + + Returns: + str: The inflected verb. + + Examples: + give -> giving, be -> being, swim -> swimming + + """ + return verb_conjugate(verb, "present participle")
+ + +
[docs]def verb_past(verb, person="", negate=False): + """ + + Inflects the verb in the past tense. + + Args: + verb (str): The verb to inflect. + person (str, optional): The person can be specified with 1, 2, 3, + "1st", "2nd", "3rd", "plural", "*". + negate (bool, optional): Some verbs like be, have, must, can be negated. + + Returns: + str: The inflected verb. + + Examples: + give -> gave, be -> was, swim -> swam + + """ + + person = str(person).replace("pl", "*").strip("stndrgural") + mapping = { + "1": "1st singular past", + "2": "2nd singular past", + "3": "3rd singular past", + "*": "past plural", + } + if person in mapping and verb_conjugate(verb, mapping[person], negate) != "": + return verb_conjugate(verb, mapping[person], negate) + + return verb_conjugate(verb, "past", negate=negate)
+ + +
[docs]def verb_past_participle(verb): + """ + Inflects the verb in the present participle. + + Args: + verb (str): The verb to inflect. + + Returns: + str: The inflected verb. + + Examples: + give -> given, be -> been, swim -> swum + + """ + return verb_conjugate(verb, "past participle")
+ + +
[docs]def verb_all_tenses(): + """ + Get all all possible verb tenses. + + Returns: + list: A list if string names. + + """ + + return list(verb_tenses_keys.keys())
+ + +
[docs]def verb_tense(verb): + """ + Returns a string from verb_tenses_keys representing the verb's tense. + + Args: + verb (str): The verb to check the tense of. + + Returns: + str: The tense. + + Example: + given -> "past participle" + + """ + infinitive = verb_infinitive(verb) + data = verb_tenses.get(infinitive) + if not data: + return infinitive + for tense in verb_tenses_keys: + if data[verb_tenses_keys[tense]] == verb: + return tense + if data[verb_tenses_keys[tense] + len(verb_tenses_keys)] == verb: + return tense
+ + +
[docs]def verb_is_tense(verb, tense): + """ + Checks whether the verb is in the given tense. + + Args: + verb (str): The verb to check. + tense (str): The tense to check. + + Return: + bool: If verb matches given tense. + + """ + tense = verb_tenses_aliases.get(tense, tense) + return verb_tense(verb) == tense
+ + +
[docs]def verb_is_present(verb, person="", negated=False): + """ + Checks whether the verb is in the present tense. + + Args: + verb (str): The verb to check. + person (str): Check which person. + negated (bool): Check if verb was negated. + + Returns: + bool: If verb was in present tense. + + """ + + person = str(person).replace("*", "plural") + tense = verb_tense(verb) + if tense is not None: + if "present" in tense and person in tense: + if not negated: + return True + elif "n't" in verb or " not" in verb: + return True + return False
+ + +
[docs]def verb_is_present_participle(verb): + """ + Checks whether the verb is in present participle. + + Args: + verb (str): The verb to check. + + Returns: + bool: Result of check. + + """ + + tense = verb_tense(verb) + return tense == "present participle"
+ + +
[docs]def verb_is_past(verb, person="", negated=False): + """ + Checks whether the verb is in the past tense. + + Args: + verb (str): The verb to check. + person (str): The person to check. + negated (bool): Check if verb is negated. + + Returns: + bool: Result of check. + + """ + + person = str(person).replace("*", "plural") + tense = verb_tense(verb) + if tense is not None: + if "past" in tense and person in tense: + if not negated: + return True + elif "n't" in verb or " not" in verb: + return True + + return False
+ + +
[docs]def verb_is_past_participle(verb): + """ + Checks whether the verb is in past participle. + + Args: + verb (str): The verb to check. + + Returns: + bool: The result of the check. + + """ + tense = verb_tense(verb) + return tense == "past participle"
+ + +
[docs]def verb_actor_stance_components(verb): + """ + Figure out actor stance components of a verb. + + Args: + verb (str): The verb to analyze + + Returns: + tuple: The 2nd person (you) and 3rd person forms of the verb, + in the same tense as the ingoing verb. + + """ + tense = verb_tense(verb) + if "participle" in tense or "plural" in tense: + return (verb, verb) + if tense == "infinitive" or "present" in tense: + you_str = verb_present(verb, person="2") or verb + them_str = verb_present(verb, person="3") or verb + "s" + else: + you_str = verb_past(verb, person="2") or verb + them_str = verb_past(verb, person="3") or verb + "s" + return (you_str, them_str)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/verb_conjugation/pronouns.html b/docs/latest/_modules/evennia/utils/verb_conjugation/pronouns.html new file mode 100644 index 0000000000..58ede3f498 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/verb_conjugation/pronouns.html @@ -0,0 +1,401 @@ + + + + + + + + evennia.utils.verb_conjugation.pronouns — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.verb_conjugation.pronouns

+"""
+English pronoun mapping between 1st/2nd person and 3rd person perspective (and vice-versa).
+
+This file is released under the Evennia regular BSD License.
+(Griatch 2021) - revised by InspectorCaracal 2022
+
+Pronouns are words you use instead of a proper name, such as 'him', 'herself', 'theirs' etc. These
+look different depending on who sees the outgoing string. This mapping maps between 1st/2nd case and
+the 3rd person case and back. In some cases, the mapping is not unique; it is assumed the system can
+differentiate between the options in some other way.
+
+
+====================  =======  ========  ==========  ==========  ===========
+viewpoint/pronouns    Subject  Object    Possessive  Possessive  Reflexive
+                      Pronoun  Pronoun   Adjective   Pronoun     Pronoun
+====================  =======  ========  ==========  ==========  ===========
+1st person              I        me        my         mine       myself
+1st person plural       we       us        our        ours       ourselves
+2nd person              you      you       your       yours      yourself
+2nd person plural       you      you       your       yours      yourselves
+
+3rd person male         he       him       his        his        himself
+3rd person female       she      her       her        hers       herself
+3rd person neutral      it       it        its        its        itself
+3rd person plural       they     them      their      theirs     themselves
+====================  =======  ========  ==========  ==========  ===========
+"""
+from evennia.utils.utils import copy_word_case, is_iter
+
+DEFAULT_PRONOUN_TYPE = "subject pronoun"
+DEFAULT_VIEWPOINT = "2nd person"
+DEFAULT_GENDER = "neutral"
+
+PRONOUN_TYPES = [
+    "subject pronoun",
+    "object pronoun",
+    "possessive adjective",
+    "possessive pronoun",
+    "reflexive pronoun",
+]
+VIEWPOINTS = ["1st person", "2nd person", "3rd person"]
+GENDERS = ["male", "female", "neutral", "plural"]
+
+PRONOUN_MAPPING = {
+    "1st person": {
+        "subject pronoun": {
+            "neutral": "I",
+            "plural": "we",
+        },
+        "object pronoun": {
+            "neutral": "me",
+            "plural": "us",
+        },
+        "possessive adjective": {
+            "neutral": "my",
+            "plural": "our",
+        },
+        "possessive pronoun": {
+            "neutral": "mine",
+            "plural": "ours",
+        },
+        "reflexive pronoun": {"neutral": "myself", "plural": "ourselves"},
+    },
+    "2nd person": {
+        "subject pronoun": {
+            "neutral": "you",
+        },
+        "object pronoun": {
+            "neutral": "you",
+        },
+        "possessive adjective": {
+            "neutral": "your",
+        },
+        "possessive pronoun": {
+            "neutral": "yours",
+        },
+        "reflexive pronoun": {
+            "neutral": "yourself",
+            "plural": "yourselves",
+        },
+    },
+    "3rd person": {
+        "subject pronoun": {"male": "he", "female": "she", "neutral": "it", "plural": "they"},
+        "object pronoun": {"male": "him", "female": "her", "neutral": "it", "plural": "them"},
+        "possessive adjective": {
+            "male": "his",
+            "female": "her",
+            "neutral": "its",
+            "plural": "their",
+        },
+        "possessive pronoun": {
+            "male": "his",
+            "female": "hers",
+            "neutral": "its",
+            "plural": "theirs",
+        },
+        "reflexive pronoun": {
+            "male": "himself",
+            "female": "herself",
+            "neutral": "itself",
+            "plural": "themselves",
+        },
+    },
+}
+
+PRONOUN_TABLE = {
+    "I": ("1st person", ("neutral", "male", "female", "plural"), "subject pronoun"),
+    "me": ("1st person", ("neutral", "male", "female", "plural"), "object pronoun"),
+    "my": ("1st person", ("neutral", "male", "female", "plural"), "possessive adjective"),
+    "mine": ("1st person", ("neutral", "male", "female", "plural"), "possessive pronoun"),
+    "myself": ("1st person", ("neutral", "male", "female", "plural"), "reflexive pronoun"),
+    "we": ("1st person", "plural", "subject pronoun"),
+    "us": ("1st person", "plural", "object pronoun"),
+    "our": ("1st person", "plural", "possessive adjective"),
+    "ours": ("1st person", "plural", "possessive pronoun"),
+    "ourselves": ("1st person", "plural", "reflexive pronoun"),
+    "you": (
+        "2nd person",
+        ("neutral", "male", "female", "plural"),
+        ("subject pronoun", "object pronoun"),
+    ),
+    "your": ("2nd person", ("neutral", "male", "female", "plural"), "possessive adjective"),
+    "yours": ("2nd person", ("neutral", "male", "female", "plural"), "possessive pronoun"),
+    "yourself": ("2nd person", ("neutral", "male", "female"), "reflexive pronoun"),
+    "yourselves": ("2nd person", "plural", "reflexive pronoun"),
+    "he": ("3rd person", "male", "subject pronoun"),
+    "him": ("3rd person", "male", "object pronoun"),
+    "his": (
+        "3rd person",
+        "male",
+        ("possessive pronoun", "possessive adjective"),
+    ),
+    "himself": ("3rd person", "male", "reflexive pronoun"),
+    "she": ("3rd person", "female", "subject pronoun"),
+    "her": (
+        "3rd person",
+        "female",
+        ("object pronoun", "possessive adjective"),
+    ),
+    "hers": ("3rd person", "female", "possessive pronoun"),
+    "herself": ("3rd person", "female", "reflexive pronoun"),
+    "it": (
+        "3rd person",
+        "neutral",
+        ("subject pronoun", "object pronoun"),
+    ),
+    "its": (
+        "3rd person",
+        "neutral",
+        ("possessive pronoun", "possessive adjective"),
+    ),
+    "itself": ("3rd person", "neutral", "reflexive pronoun"),
+    "they": ("3rd person", "plural", "subject pronoun"),
+    "them": ("3rd person", "plural", "object pronoun"),
+    "their": ("3rd person", "plural", "possessive adjective"),
+    "theirs": ("3rd person", "plural", "possessive pronoun"),
+    "themselves": ("3rd person", "plural", "reflexive pronoun"),
+}
+
+# define the default viewpoint conversions
+VIEWPOINT_CONVERSION = {
+    "1st person": "3rd person",
+    "2nd person": "3rd person",
+    "3rd person": ("2nd person", "1st person"),
+}
+
+ALIASES = {
+    "m": "male",
+    "f": "female",
+    "n": "neutral",
+    "p": "plural",
+    "1st": "1st person",
+    "2nd": "2nd person",
+    "3rd": "3rd person",
+    "1": "1st person",
+    "2": "2nd person",
+    "3": "3rd person",
+    "s": "subject pronoun",
+    "sp": "subject pronoun",
+    "subject": "subject pronoun",
+    "op": "object pronoun",
+    "object": "object pronoun",
+    "pa": "possessive adjective",
+    "pp": "possessive pronoun",
+}
+
+
+
[docs]def pronoun_to_viewpoints(pronoun, options=None, pronoun_type=None, gender=None, viewpoint=None): + """ + Access function for determining the forms of a pronoun from different viewpoints. + + Args: + pronoun (str): A valid English pronoun, such as 'you', 'his', 'themselves' etc. + options (str or list, optional): A list or space-separated string of options to help + the engine when there is no unique mapping to use. This could for example + be "2nd female" (alias 'f') or "possessive adjective" (alias 'pa' or 'a'). + pronoun_type (str, optional): An explicit object pronoun to separate cases where + there is no unique mapping. Pronoun types defined in `options` take precedence. + Values are + + - `subject pronoun`/`subject`/`sp` (I, you, he, they) + - `object pronoun`/`object/`/`op` (me, you, him, them) + - `possessive adjective`/`adjective`/`pa` (my, your, his, their) + - `possessive pronoun`/`pronoun`/`pp` (mine, yours, his, theirs) + + gender (str, optional): Specific gender to use (plural counts a gender for this purpose). + A gender specified in `options` takes precedence. Values and aliases are: + + - `male`/`m` + - `female`/`f` + - `neutral`/`n` + - `plural`/`p` + + viewpoint (str, optional): A specified viewpoint of the one talking, to use + when there is no unique mapping. A viewpoint given in `options` take + precedence. Values and aliases are: + + - `1st person`/`1st`/`1` + - `2nd person`/`2nd`/`2` + - `3rd person`/`3rd`/`3` + + Returns: + tuple: A tuple `(1st/2nd_person_pronoun, 3rd_person_pronoun)` to show to the one sending the + string and others respectively. If pronoun is invalid, the word is returned verbatim. + + Note: + The capitalization of the original word will be retained. + + """ + if not pronoun: + return pronoun + + pronoun_lower = "I" if pronoun == "I" else pronoun.lower() + + if pronoun_lower not in PRONOUN_TABLE: + return pronoun + + # get the default data for the input pronoun + source_viewpoint, source_gender, source_type = PRONOUN_TABLE[pronoun_lower] + + # use the source pronoun's attributes as defaults + if pronoun_type not in PRONOUN_TYPES: + pronoun_type = source_type[0] if is_iter(source_type) else source_type + if viewpoint not in VIEWPOINTS: + viewpoint = source_viewpoint + if gender not in GENDERS: + gender = source_gender[0] if is_iter(source_gender) else source_gender + + if options: + # option string/list will override the kwargs differentiators given + if isinstance(options, str): + options = options.split() + options = [str(part).strip().lower() for part in options] + options = [ALIASES.get(opt, opt) for opt in options] + + for opt in options: + if opt in PRONOUN_TYPES: + pronoun_type = opt + elif opt in VIEWPOINTS: + viewpoint = opt + elif opt in GENDERS: + gender = opt + + # check if pronoun maps to multiple options and differentiate + # but don't allow invalid differentiators + if is_iter(source_type): + pronoun_type = pronoun_type if pronoun_type in source_type else source_type[0] + else: + pronoun_type = source_type + target_viewpoint = VIEWPOINT_CONVERSION[source_viewpoint] + if is_iter(target_viewpoint): + viewpoint = viewpoint if viewpoint in target_viewpoint else target_viewpoint[0] + else: + viewpoint = target_viewpoint + + # by this point, gender will be a valid option from GENDERS and type/viewpoint will be validated + # step down into the mapping to get the converted pronoun + viewpoint_map = PRONOUN_MAPPING[viewpoint] + pronouns = viewpoint_map.get(pronoun_type, viewpoint_map[DEFAULT_PRONOUN_TYPE]) + mapped_pronoun = pronouns.get(gender, pronouns[DEFAULT_GENDER]) + + # keep the same capitalization as the original + if pronoun != "I": + # don't remap I, since this is always capitalized. + mapped_pronoun = copy_word_case(pronoun, mapped_pronoun) + if mapped_pronoun == "i": + mapped_pronoun = mapped_pronoun.upper() + + if viewpoint == "3rd person": + # the desired viewpoint is 3rd person, meaning the incoming viewpoint + # must have been 1st or 2nd person. + return pronoun, mapped_pronoun + else: + # the desired viewpoint is 1st or 2nd person, so incoming must have been + # in 3rd person form. + return mapped_pronoun, pronoun
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/utils/verb_conjugation/tests.html b/docs/latest/_modules/evennia/utils/verb_conjugation/tests.html new file mode 100644 index 0000000000..13ebacecf8 --- /dev/null +++ b/docs/latest/_modules/evennia/utils/verb_conjugation/tests.html @@ -0,0 +1,465 @@ + + + + + + + + evennia.utils.verb_conjugation.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.utils.verb_conjugation.tests

+"""
+Unit tests for verb conjugation.
+
+"""
+
+from django.test import TestCase
+from parameterized import parameterized
+
+from . import conjugate, pronouns
+
+
+
[docs]class TestVerbConjugate(TestCase): + """ + Test the conjugation. + + """ + + @parameterized.expand( + [ + ("have", "have"), + ("swim", "swim"), + ("give", "give"), + ("given", "give"), + ("am", "be"), + ("doing", "do"), + ("are", "be"), + ] + ) + def test_verb_infinitive(self, verb, expected): + """ + Test the infinite-getter. + """ + self.assertEqual(expected, conjugate.verb_infinitive(verb)) + + @parameterized.expand( + [ + ("inf", "have", "have"), + ("inf", "swim", "swim"), + ("inf", "give", "give"), + ("inf", "given", "give"), + ("inf", "am", "be"), + ("inf", "doing", "do"), + ("inf", "are", "be"), + ("2sgpres", "am", "are"), + ("3sgpres", "am", "is"), + ] + ) + def test_verb_conjugate(self, tense, verb, expected): + """ + Test conjugation for different tenses. + + """ + self.assertEqual(expected, conjugate.verb_conjugate(verb, tense=tense)) + + @parameterized.expand( + [ + ("1st", "have", "have"), + ("1st", "swim", "swim"), + ("1st", "give", "give"), + ("1st", "given", "give"), + ("1st", "am", "am"), + ("1st", "doing", "do"), + ("1st", "are", "am"), + ("2nd", "were", "are"), + ("3rd", "am", "is"), + ] + ) + def test_verb_present(self, person, verb, expected): + """ + Test the present. + + """ + self.assertEqual(expected, conjugate.verb_present(verb, person=person)) + + @parameterized.expand( + [ + ("have", "having"), + ("swim", "swimming"), + ("give", "giving"), + ("given", "giving"), + ("am", "being"), + ("doing", "doing"), + ("are", "being"), + ] + ) + def test_verb_present_participle(self, verb, expected): + """ + Test the present_participle + + """ + self.assertEqual(expected, conjugate.verb_present_participle(verb)) + + @parameterized.expand( + [ + ("1st", "have", "had"), + ("1st", "swim", "swam"), + ("1st", "give", "gave"), + ("1st", "given", "gave"), + ("1st", "am", "was"), + ("1st", "doing", "did"), + ("1st", "are", "was"), + ("2nd", "were", "were"), + ] + ) + def test_verb_past(self, person, verb, expected): + """ + Test the past getter. + + """ + self.assertEqual(expected, conjugate.verb_past(verb, person=person)) + + @parameterized.expand( + [ + ("have", "had"), + ("swim", "swum"), + ("give", "given"), + ("given", "given"), + ("am", "been"), + ("doing", "done"), + ("are", "been"), + ] + ) + def test_verb_past_participle(self, verb, expected): + """ + Test the past participle. + + """ + self.assertEqual(expected, conjugate.verb_past_participle(verb)) + +
[docs] def test_verb_get_all_tenses(self): + """ + Test getting all tenses. + + """ + self.assertEqual(list(conjugate.verb_tenses_keys.keys()), conjugate.verb_all_tenses())
+ + @parameterized.expand( + [ + ("have", "infinitive"), + ("swim", "infinitive"), + ("give", "infinitive"), + ("given", "past participle"), + ("am", "1st singular present"), + ("doing", "present participle"), + ("are", "2nd singular present"), + ] + ) + def test_verb_tense(self, verb, expected): + """ + Test the tense retriever. + + """ + self.assertEqual(expected, conjugate.verb_tense(verb)) + + @parameterized.expand( + [ + ("inf", "have", True), + ("inf", "swim", True), + ("inf", "give", True), + ("inf", "given", False), + ("inf", "am", False), + ("inf", "doing", False), + ("inf", "are", False), + ] + ) + def test_verb_is_tense(self, tense, verb, expected): + """ + Test the tense-checker + + """ + self.assertEqual(expected, conjugate.verb_is_tense(verb, tense)) + + @parameterized.expand( + [ + ("1st", "have", False), + ("1st", "swim", False), + ("1st", "give", False), + ("1st", "given", False), + ("1st", "am", True), + ("1st", "doing", False), + ("1st", "are", False), + ("1st", "had", False), + ] + ) + def test_verb_is_present(self, person, verb, expected): + """ + Test the tense-checker + + """ + self.assertEqual(expected, conjugate.verb_is_present(verb, person=person)) + + @parameterized.expand( + [ + ("have", False), + ("swim", False), + ("give", False), + ("given", False), + ("am", False), + ("doing", True), + ("are", False), + ] + ) + def test_verb_is_present_participle(self, verb, expected): + """ + Test the tense-checker + + """ + self.assertEqual(expected, conjugate.verb_is_present_participle(verb)) + + @parameterized.expand( + [ + ("1st", "have", False), + ("1st", "swim", False), + ("1st", "give", False), + ("1st", "given", False), + ("1st", "am", False), + ("1st", "doing", False), + ("1st", "are", False), + ("2nd", "were", True), + ] + ) + def test_verb_is_past(self, person, verb, expected): + """ + Test the tense-checker + + """ + self.assertEqual(expected, conjugate.verb_is_past(verb, person=person)) + + @parameterized.expand( + [ + ("have", False), + ("swimming", False), + ("give", False), + ("given", True), + ("am", False), + ("doing", False), + ("are", False), + ("had", False), + ] + ) + def test_verb_is_past_participle(self, verb, expected): + """ + Test the tense-checker + + """ + self.assertEqual(expected, conjugate.verb_is_past_participle(verb)) + + @parameterized.expand( + [ + ("have", ("have", "has")), + ("swimming", ("swimming", "swimming")), + ("give", ("give", "gives")), + ("given", ("given", "given")), + ("am", ("are", "is")), + ("doing", ("doing", "doing")), + ("are", ("are", "is")), + ("had", ("had", "had")), + ("grin", ("grin", "grins")), + ("smile", ("smile", "smiles")), + ("vex", ("vex", "vexes")), + ("thrust", ("thrust", "thrusts")), + ] + ) + def test_verb_actor_stance_components(self, verb, expected): + """ + Test the tense-checker + + """ + self.assertEqual(expected, conjugate.verb_actor_stance_components(verb))
+ + +
[docs]class TestPronounMapping(TestCase): + """ + Test pronoun viewpoint mapping + """ + + @parameterized.expand( + [ + ("you", "you", "it"), # default 3rd is "neutral" + ("I", "I", "it"), + ("Me", "Me", "It"), + ("ours", "ours", "theirs"), + ("yourself", "yourself", "itself"), + ("yourselves", "yourselves", "themselves"), + ("he", "you", "he"), # assume 2nd person + ("her", "you", "her"), + ("their", "your", "their"), + ("itself", "yourself", "itself"), + ("herself", "yourself", "herself"), + ("themselves", "yourselves", "themselves"), + ] + ) + def test_default_mapping(self, pronoun, expected_1st_or_2nd_person, expected_3rd_person): + """ + Test the pronoun mapper. + + """ + received_1st_or_2nd_person, received_3rd_person = pronouns.pronoun_to_viewpoints(pronoun) + + self.assertEqual(expected_1st_or_2nd_person, received_1st_or_2nd_person) + self.assertEqual(expected_3rd_person, received_3rd_person) + + @parameterized.expand( + [ + ("you", "m", "you", "he"), + ("you", "f op", "you", "her"), + ("you", "p op", "you", "them"), + ("I", "m", "I", "he"), + ("Me", "n", "Me", "It"), + ("your", "p", "your", "their"), + ("yourself", "m", "yourself", "himself"), + ("yourself", "f", "yourself", "herself"), + ("yourselves", "", "yourselves", "themselves"), + ("he", "1", "I", "he"), + ("he", "1 p", "we", "he"), # royal we + ("we", "m", "we", "he"), # royal we, other way + ("her", "p", "you", "her"), + ("her", "pa", "your", "her"), + ("their", "ma", "your", "their"), + ] + ) + def test_mapping_with_options( + self, pronoun, options, expected_1st_or_2nd_person, expected_3rd_person + ): + """ + Test the pronoun mapper. + + """ + received_1st_or_2nd_person, received_3rd_person = pronouns.pronoun_to_viewpoints( + pronoun, options + ) + self.assertEqual(expected_1st_or_2nd_person, received_1st_or_2nd_person) + self.assertEqual(expected_3rd_person, received_3rd_person) + + @parameterized.expand( + [ + ("you", "p", "you", "they"), + ("I", "p", "I", "they"), + ("Me", "p", "Me", "Them"), + ("your", "p", "your", "their"), + ("they", "1 p", "we", "they"), + ("they", "", "you", "they"), + ("yourself", "p", "yourself", "themselves"), + ("myself", "p", "myself", "themselves"), + ] + ) + def test_colloquial_plurals( + self, pronoun, options, expected_1st_or_2nd_person, expected_3rd_person + ): + """ + The use of this module by the funcparser expects a default person-pronoun + of the neutral "they", which is categorized here by the plural. + + """ + received_1st_or_2nd_person, received_3rd_person = pronouns.pronoun_to_viewpoints( + pronoun, options + ) + + self.assertEqual(expected_1st_or_2nd_person, received_1st_or_2nd_person) + self.assertEqual(expected_3rd_person, received_3rd_person)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/accounts.html b/docs/latest/_modules/evennia/web/admin/accounts.html new file mode 100644 index 0000000000..2931898233 --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/accounts.html @@ -0,0 +1,535 @@ + + + + + + + + evennia.web.admin.accounts — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.accounts

+#
+# This sets up how models are displayed
+# in the web admin interface.
+#
+from django import forms
+from django.conf import settings
+from django.contrib import admin, messages
+from django.contrib.admin.options import IS_POPUP_VAR
+from django.contrib.admin.utils import unquote
+from django.contrib.admin.widgets import FilteredSelectMultiple, ForeignKeyRawIdWidget
+from django.contrib.auth import update_session_auth_hash
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+from django.contrib.auth.forms import UserChangeForm, UserCreationForm
+from django.core.exceptions import PermissionDenied
+from django.http import Http404, HttpResponseRedirect
+from django.template.response import TemplateResponse
+from django.urls import path, reverse
+from django.utils.decorators import method_decorator
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
+from django.views.decorators.debug import sensitive_post_parameters
+
+from evennia.accounts.models import AccountDB
+from evennia.objects.models import ObjectDB
+from evennia.utils import create
+
+from . import utils as adminutils
+from .attributes import AttributeInline
+from .tags import TagInline
+
+sensitive_post_parameters_m = method_decorator(sensitive_post_parameters())
+
+
+# handle the custom User editor
+
[docs]class AccountChangeForm(UserChangeForm): + """ + Modify the accountdb class. + + """ + +
[docs] class Meta(object): + model = AccountDB + fields = "__all__"
+ + username = forms.RegexField( + label="Username", + max_length=30, + regex=r"^[\w. @+-]+$", + widget=forms.TextInput(attrs={"size": "30"}), + error_messages={ + "invalid": "This value may contain only letters, spaces, numbers " + "and @/./+/-/_ characters." + }, + help_text="30 characters or fewer. Letters, spaces, digits and " "@/./+/-/_ only.", + ) + + db_typeclass_path = forms.ChoiceField( + label="Typeclass", + help_text="This is the Python-path to the class implementing the actual account functionality. " + "You usually don't need to change this from the default.<BR>" + "If your custom class is not found here, it may not be imported into Evennia yet.", + choices=lambda: adminutils.get_and_load_typeclasses(parent=AccountDB), + ) + + db_lock_storage = forms.CharField( + label="Locks", + required=False, + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + help_text="Locks limit access to the entity. Written on form `type:lockdef;type:lockdef..." + "<BR>(Permissions (used with the perm() lockfunc) are Tags with the 'permission' type)", + ) + + db_cmdset_storage = forms.CharField( + label="CommandSet", + initial=settings.CMDSET_ACCOUNT, + widget=forms.TextInput(attrs={"size": "78"}), + required=False, + ) + + is_superuser = forms.BooleanField( + label="Superuser status", + required=False, + help_text="Superusers bypass all in-game locks and has all " + "permissions without explicitly assigning them. Usually " + "only one superuser (user #1) is needed and only a superuser " + "can create another superuser.<BR>" + "Only Superusers can change the user/group permissions below.", + ) + +
[docs] def clean_username(self): + """ + Clean the username and check its existence. + + """ + username = self.cleaned_data["username"] + if username.upper() == self.instance.username.upper(): + return username + elif AccountDB.objects.filter(username__iexact=username): + raise forms.ValidationError("An account with that name " "already exists.") + return self.cleaned_data["username"]
+ +
[docs] def __init__(self, *args, **kwargs): + """ + Tweak some fields dynamically. + + """ + super().__init__(*args, **kwargs) + + # better help text for cmdset_storage + account_cmdset = settings.CMDSET_ACCOUNT + self.fields["db_cmdset_storage"].help_text = ( + "Path to Command-set path. Most non-character objects don't need a cmdset" + " and can leave this field blank. Default cmdset-path<BR> for Accounts " + f"is <strong>{account_cmdset}</strong> ." + )
+ + +
[docs]class AccountCreationForm(UserCreationForm): + """ + Create a new AccountDB instance. + """ + +
[docs] class Meta(object): + model = AccountDB + fields = "__all__"
+ + username = forms.RegexField( + label="Username", + max_length=30, + regex=r"^[\w. @+-]+$", + widget=forms.TextInput(attrs={"size": "30"}), + error_messages={ + "invalid": "This value may contain only letters, spaces, numbers " + "and @/./+/-/_ characters." + }, + help_text="30 characters or fewer. Letters, spaces, digits and " "@/./+/-/_ only.", + ) + +
[docs] def clean_username(self): + """ + Cleanup username. + """ + username = self.cleaned_data["username"] + if AccountDB.objects.filter(username__iexact=username): + raise forms.ValidationError("An account with that name already " "exists.") + return username
+ + +
[docs]class AccountTagInline(TagInline): + """ + Inline Account Tags. + + """ + + model = AccountDB.db_tags.through + related_field = "accountdb"
+ + +
[docs]class AccountAttributeInline(AttributeInline): + """ + Inline Account Attributes. + + """ + + model = AccountDB.db_attributes.through + related_field = "accountdb"
+ + +
[docs]class ObjectPuppetInline(admin.StackedInline): + """ + Inline creation of puppet-Object in Account. + + """ + + from .objects import ObjectCreateForm + + verbose_name = "Puppeted Object" + model = ObjectDB + view_on_site = False + show_change_link = True + # template = "admin/accounts/stacked.html" + form = ObjectCreateForm + fieldsets = ( + ( + None, + { + "fields": ( + ("db_key", "db_typeclass_path"), + ("db_location", "db_home", "db_destination"), + "db_cmdset_storage", + "db_lock_storage", + ), + "description": "Object currently puppeted by the account (note that this " + "will go away if account logs out or unpuppets)", + }, + ), + ) + + extra = 0 + readonly_fields = ( + "db_key", + "db_typeclass_path", + "db_destination", + "db_location", + "db_home", + "db_account", + "db_cmdset_storage", + "db_lock_storage", + ) + + # disable adding/deleting this inline - read-only! +
[docs] def has_add_permission(self, request, obj=None): + return False
+ +
[docs] def has_delete_permission(self, request, obj=None): + return False
+ + +
[docs]@admin.register(AccountDB) +class AccountAdmin(BaseUserAdmin): + """ + This is the main creation screen for Users/accounts + + """ + + list_display = ( + "id", + "username", + "is_staff", + "is_superuser", + "db_typeclass_path", + "db_date_created", + ) + list_display_links = ("id", "username") + form = AccountChangeForm + add_form = AccountCreationForm + search_fields = ["=id", "^username", "db_typeclass_path"] + ordering = ["-db_date_created", "id"] + list_filter = ["is_superuser", "is_staff", "db_typeclass_path"] + inlines = [AccountTagInline, AccountAttributeInline] + readonly_fields = ["db_date_created", "serialized_string", "puppeted_objects"] + view_on_site = False + fieldsets = ( + ( + None, + { + "fields": ( + ("username", "db_typeclass_path"), + "password", + "email", + "db_date_created", + "db_lock_storage", + "db_cmdset_storage", + "puppeted_objects", + "serialized_string", + ) + }, + ), + ( + "Admin/Website properties", + { + "fields": ( + ("first_name", "last_name"), + "last_login", + "date_joined", + "is_active", + "is_staff", + "is_superuser", + "user_permissions", + "groups", + ), + "description": "<i>Used by the website/Django admin. " + "Except for `superuser status`, the permissions are not used in-game.</i>", + }, + ), + ) + + add_fieldsets = ( + ( + None, + { + "fields": ("username", "password1", "password2", "email"), + "description": "<i>These account details are shared by the admin " + "system and the game.</i>", + }, + ), + ) + +
[docs] def serialized_string(self, obj): + """ + Get the serialized version of the object. + + """ + from evennia.utils import dbserialize + + return str(dbserialize.pack_dbobj(obj))
+ + serialized_string.help_text = ( + "Copy & paste this string into an Attribute's `value` field to store this account there." + ) + +
[docs] def puppeted_objects(self, obj): + """ + Get any currently puppeted objects (read only list) + + """ + return mark_safe( + ", ".join( + '<a href="{url}">{name}</a>'.format( + url=reverse("admin:objects_objectdb_change", args=[obj.id]), name=obj.db_key + ) + for obj in ObjectDB.objects.filter(db_account=obj) + ) + )
+ + puppeted_objects.help_text = ( + "Objects currently puppeted by this Account. " + "Link new ones from the `Objects` admin page.<BR>" + "Note that these will disappear when a user unpuppets or goes offline - " + "this is normal." + ) + +
[docs] def get_form(self, request, obj=None, **kwargs): + """ + Overrides help texts. + + """ + help_texts = kwargs.get("help_texts", {}) + help_texts["serialized_string"] = self.serialized_string.help_text + help_texts["puppeted_objects"] = self.puppeted_objects.help_text + kwargs["help_texts"] = help_texts + + # security disabling for non-superusers + form = super().get_form(request, obj, **kwargs) + disabled_fields = set() + if not request.user.is_superuser: + disabled_fields |= {"is_superuser", "user_permissions", "user_groups"} + for field_name in disabled_fields: + if field_name in form.base_fields: + form.base_fields[field_name].disabled = True + return form
+ +
[docs] @sensitive_post_parameters_m + def user_change_password(self, request, id, form_url=""): + user = self.get_object(request, unquote(id)) + if not self.has_change_permission(request, user): + raise PermissionDenied + if user is None: + raise Http404("%(name)s object with primary key %(key)r does not exist.") % { + "name": self.model._meta.verbose_name, + "key": escape(id), + } + if request.method == "POST": + form = self.change_password_form(user, request.POST) + if form.is_valid(): + form.save() + change_message = self.construct_change_message(request, form, None) + self.log_change(request, user, change_message) + msg = "Password changed successfully." + messages.success(request, msg) + update_session_auth_hash(request, form.user) + return HttpResponseRedirect( + reverse( + "%s:%s_%s_change" + % ( + self.admin_site.name, + user._meta.app_label, + # the model_name is something we need to hardcode + # since our accountdb is a proxy: + "accountdb", + ), + args=(user.pk,), + ) + ) + else: + form = self.change_password_form(user) + + fieldsets = [(None, {"fields": list(form.base_fields)})] + adminForm = admin.helpers.AdminForm(form, fieldsets, {}) + + context = { + "title": "Change password: %s" % escape(user.get_username()), + "adminForm": adminForm, + "form_url": form_url, + "form": form, + "is_popup": (IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET), + "add": True, + "change": False, + "has_delete_permission": False, + "has_change_permission": True, + "has_absolute_url": False, + "opts": self.model._meta, + "original": user, + "save_as": False, + "show_save": True, + **self.admin_site.each_context(request), + } + + request.current_app = self.admin_site.name + + return TemplateResponse( + request, + self.change_user_password_template or "admin/auth/user/change_password.html", + context, + )
+ +
[docs] def save_model(self, request, obj, form, change): + """ + Custom save actions. + + Args: + request (Request): Incoming request. + obj (Object): Object to save. + form (Form): Related form instance. + change (bool): False if this is a new save and not an update. + + """ + obj.save() + if not change: + # calling hooks for new account + obj.set_class_from_typeclass(typeclass_path=settings.BASE_ACCOUNT_TYPECLASS) + obj.basetype_setup() + obj.at_account_creation()
+ +
[docs] def response_add(self, request, obj, post_url_continue=None): + from django.http import HttpResponseRedirect + from django.urls import reverse + + return HttpResponseRedirect(reverse("admin:accounts_accountdb_change", args=[obj.id]))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/attributes.html b/docs/latest/_modules/evennia/web/admin/attributes.html new file mode 100644 index 0000000000..af9b4dfc8c --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/attributes.html @@ -0,0 +1,315 @@ + + + + + + + + evennia.web.admin.attributes — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.attributes

+"""
+Attribute admin.
+
+Note that we don't present a separate admin for these, since they are only
+relevant together with a specific object.
+
+"""
+
+import traceback
+from datetime import datetime
+
+from django import forms
+from django.contrib import admin
+
+from evennia.typeclasses.attributes import Attribute
+from evennia.utils.dbserialize import _SaverSet, from_pickle
+from evennia.utils.picklefield import PickledFormField
+
+
+
[docs]class AttributeForm(forms.ModelForm): + """ + This form overrides the base behavior of the ModelForm that would be used for a Attribute-through-model. + Since the through-models only have access to the foreignkeys of the Attribute and the Object that they're + attached to, we need to spoof the behavior of it being a form that would correspond to its Attribute, + or the creation of an Attribute. Instead of being saved, we'll call to the Object's handler, which will handle + the creation, change, or deletion of an Attribute for us, as well as updating the handler's cache so that all + changes are instantly updated in-game. + """ + + attr_key = forms.CharField( + label="Attribute Name", + required=False, + help_text="The main identifier of the Attribute. For Nicks, this is the pattern-matching string.", + ) + attr_category = forms.CharField( + label="Category", + help_text="Categorization. Unset (default) gives a category of `None`, which is " + "is what is searched with e.g. `obj.db.attrname`. For 'nick'-type attributes, this is usually " + "'inputline' or 'channel'.", + required=False, + max_length=128, + ) + attr_value = PickledFormField( + label="Value", + help_text="Value to pickle/save. Db-objects are serialized as a list " + "containing `__packed_dbobj__` (they can't easily be added from here). Nicks " + "store their pattern-replacement here.", + required=False, + ) + attr_type = forms.ChoiceField( + label="Type", + choices=[(None, "-"), ("nick", "nick")], + help_text="Unset for regular Attributes, 'nick' for Nick-replacement usage.", + required=False, + ) + attr_lockstring = forms.CharField( + label="Locks", + required=False, + help_text="Lock string on the form locktype:lockdef;lockfunc:lockdef;...", + widget=forms.Textarea(attrs={"rows": 1, "cols": 8}), + ) + +
[docs] class Meta: + fields = ("attr_key", "attr_value", "attr_category", "attr_lockstring", "attr_type")
+ +
[docs] def __init__(self, *args, **kwargs): + """ + If we have an Attribute, then we'll prepopulate our instance with the fields we'd expect it + to have based on the Attribute. attr_key, attr_category, attr_value, attr_type, + and attr_lockstring all refer to the corresponding Attribute fields. The initial data of the form fields will + similarly be populated. + + """ + super().__init__(*args, **kwargs) + attr_key = None + attr_category = None + attr_value = None + attr_type = None + attr_lockstring = None + if hasattr(self.instance, "attribute"): + attr_key = self.instance.attribute.db_key + attr_category = self.instance.attribute.db_category + attr_value = self.instance.attribute.db_value + attr_type = self.instance.attribute.db_attrtype + attr_lockstring = self.instance.attribute.db_lock_storage + self.fields["attr_key"].initial = attr_key + self.fields["attr_category"].initial = attr_category + self.fields["attr_type"].initial = attr_type + self.fields["attr_value"].initial = attr_value + self.fields["attr_lockstring"].initial = attr_lockstring + self.instance.attr_key = attr_key + self.instance.attr_category = attr_category + self.instance.attr_value = attr_value + + # prevent from being transformed to str + if isinstance(attr_value, (set, _SaverSet)): + self.fields["attr_value"].disabled = True + + self.instance.deserialized_value = from_pickle(attr_value) + self.instance.attr_type = attr_type + self.instance.attr_lockstring = attr_lockstring
+ +
[docs] def save(self, commit=True): + """ + One thing we want to do here is the or None checks, because forms are saved with an empty + string rather than null from forms, usually, and the Handlers may handle empty strings + differently than None objects. So for consistency with how things are handled in game, + we'll try to make sure that empty form fields will be None, rather than ''. + """ + # we are spoofing an Attribute for the Handler that will be called + instance = self.instance + instance.attr_key = self.cleaned_data["attr_key"] or "no_name_entered_for_attribute" + instance.attr_category = self.cleaned_data["attr_category"] or None + instance.attr_value = self.cleaned_data["attr_value"] + # convert the serialized string value into an object, if necessary, for AttributeHandler + instance.attr_value = from_pickle(instance.attr_value) + instance.attr_type = self.cleaned_data["attr_type"] or None + instance.attr_lockstring = self.cleaned_data["attr_lockstring"] + return instance
+ +
[docs] def clean_attr_value(self): + """ + Prevent certain data-types from being cleaned due to literal_eval + failing on them. Otherwise they will be turned into str. + + """ + data = self.cleaned_data["attr_value"] + initial = self.instance.attr_value + if isinstance(initial, (set, _SaverSet, datetime)): + return initial + return data
+ + +
[docs]class AttributeFormSet(forms.BaseInlineFormSet): + """ + Attribute version of TagFormSet, as above. + """ + +
[docs] def save(self, commit=True): + def get_handler(finished_object): + related = getattr(finished_object, self.related_field) + try: + attrtype = finished_object.attr_type + except AttributeError: + attrtype = finished_object.attribute.db_attrtype + if attrtype == "nick": + handler_name = "nicks" + else: + handler_name = "attributes" + return getattr(related, handler_name) + + instances = super().save(commit=False) + for obj in self.deleted_objects: + # self.deleted_objects is a list created when super of save is called, we'll remove those + handler = get_handler(obj) + handler.remove(obj.attr_key, category=obj.attr_category) + + for instance in instances: + handler = get_handler(instance) + + value = instance.attr_value + + try: + handler.add( + instance.attr_key, + value, + category=instance.attr_category, + strattr=False, + lockstring=instance.attr_lockstring, + ) + except (TypeError, ValueError): + # catch errors in nick templates and continue + traceback.print_exc() + continue
+ + +
[docs]class AttributeInline(admin.TabularInline): + """ + A handler for inline Attributes. This class should be subclassed in the admin of your models, + and the 'model' and 'related_field' class attributes must be set. model should be the + through model (ObjectDB_db_tag', for example), while related field should be the name + of the field on that through model which points to the model being used: 'objectdb', + 'msg', 'accountdb', etc. + """ + + # Set this to the through model of your desired M2M when subclassing. + model = None + verbose_name = "Attribute" + verbose_name_plural = "Attributes" + form = AttributeForm + formset = AttributeFormSet + related_field = None # Must be 'objectdb', 'accountdb', 'msg', etc. Set when subclassing + # raw_id_fields = ('attribute',) + # readonly_fields = ('attribute',) + extra = 0 + +
[docs] def get_formset(self, request, obj=None, **kwargs): + """ + get_formset has to return a class, but we need to make the class that we return + know about the related_field that we'll use. Returning the class itself rather than + a proxy isn't threadsafe, since it'd be the base class and would change if multiple + people used the admin at the same time + """ + formset = super().get_formset(request, obj, **kwargs) + + class ProxyFormset(formset): + pass + + ProxyFormset.related_field = self.related_field + return ProxyFormset
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/comms.html b/docs/latest/_modules/evennia/web/admin/comms.html new file mode 100644 index 0000000000..6f0eb6ae1e --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/comms.html @@ -0,0 +1,425 @@ + + + + + + + + evennia.web.admin.comms — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.comms

+"""
+This defines how Comm models are displayed in the web admin interface.
+
+"""
+
+from django import forms
+from django.conf import settings
+from django.contrib import admin
+
+from evennia.comms.models import ChannelDB, Msg
+
+from .attributes import AttributeInline
+from .tags import TagInline
+
+
+
[docs]class MsgTagInline(TagInline): + """ + Inline display for Msg-tags. + + """ + + model = Msg.db_tags.through + related_field = "msg"
+ + +
[docs]class MsgForm(forms.ModelForm): + """ + Custom Msg form. + + """ + +
[docs] class Meta: + models = Msg + fields = "__all__"
+ + db_header = forms.CharField( + label="Header", + required=False, + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + help_text="Optional header for the message; it could be a title or " + "metadata depending on msg-use.", + ) + + db_lock_storage = forms.CharField( + label="Locks", + required=False, + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + help_text="In-game lock definition string. If not given, defaults will be used. " + "This string should be on the form " + "<i>type:lockfunction(args);type2:lockfunction2(args);...", + )
+ + +
[docs]@admin.register(Msg) +class MsgAdmin(admin.ModelAdmin): + """ + Defines display for Msg objects + + """ + + inlines = [MsgTagInline] + form = MsgForm + list_display = ( + "id", + "db_date_created", + "sender", + "receiver", + "start_of_message", + ) + list_display_links = ("id", "db_date_created", "start_of_message") + ordering = ["-db_date_created", "-id"] + search_fields = [ + "=id", + "^db_date_created", + "^db_message", + "^db_sender_accounts__db_key", + "^db_sender_objects__db_key", + "^db_sender_scripts__db_key", + "^db_sender_external", + "^db_receivers_accounts__db_key", + "^db_receivers_objects__db_key", + "^db_receivers_scripts__db_key", + "^db_receiver_external", + ] + readonly_fields = ["db_date_created", "serialized_string"] + save_as = True + save_on_top = True + list_select_related = True + view_on_site = False + + raw_id_fields = ( + "db_sender_accounts", + "db_sender_objects", + "db_sender_scripts", + "db_receivers_accounts", + "db_receivers_objects", + "db_receivers_scripts", + "db_hide_from_accounts", + "db_hide_from_objects", + ) + + fieldsets = ( + ( + None, + { + "fields": ( + ( + "db_sender_accounts", + "db_sender_objects", + "db_sender_scripts", + "db_sender_external", + ), + ( + "db_receivers_accounts", + "db_receivers_objects", + "db_receivers_scripts", + "db_receiver_external", + ), + ("db_hide_from_accounts", "db_hide_from_objects"), + "db_header", + "db_message", + "serialized_string", + ) + }, + ), + ) + +
[docs] def sender(self, obj): + senders = [o for o in obj.senders if o] + if senders: + return senders[0]
+ + sender.help_text = "If multiple, only the first is shown." + +
[docs] def receiver(self, obj): + receivers = [o for o in obj.receivers if o] + if receivers: + return receivers[0]
+ + receiver.help_text = "If multiple, only the first is shown." + +
[docs] def start_of_message(self, obj): + crop_length = 50 + if obj.db_message: + msg = obj.db_message + if len(msg) > (crop_length - 5): + msg = msg[:50] + "[...]" + return msg
+ +
[docs] def serialized_string(self, obj): + """ + Get the serialized version of the object. + + """ + from evennia.utils import dbserialize + + return str(dbserialize.pack_dbobj(obj))
+ + serialized_string.help_text = ( + "Copy & paste this string into an Attribute's `value` field to store " + "this message-object there." + ) + +
[docs] def get_form(self, request, obj=None, **kwargs): + """ + Overrides help texts. + + """ + help_texts = kwargs.get("help_texts", {}) + help_texts["serialized_string"] = self.serialized_string.help_text + kwargs["help_texts"] = help_texts + return super().get_form(request, obj, **kwargs)
+ + +
[docs]class ChannelAttributeInline(AttributeInline): + """ + Inline display of Channel Attribute - experimental + + """ + + model = ChannelDB.db_attributes.through + related_field = "channeldb"
+ + +
[docs]class ChannelTagInline(TagInline): + """ + Inline display of Channel Tags - experimental + + """ + + model = ChannelDB.db_tags.through + related_field = "channeldb"
+ + +
[docs]class ChannelForm(forms.ModelForm): + """ + Form for accessing channels. + + """ + +
[docs] class Meta: + model = ChannelDB + fields = "__all__"
+ + db_lock_storage = forms.CharField( + label="Locks", + required=False, + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + help_text="In-game lock definition string. If not given, defaults will be used. " + "This string should be on the form " + "<i>type:lockfunction(args);type2:lockfunction2(args);...", + )
+ + +
[docs]@admin.register(ChannelDB) +class ChannelAdmin(admin.ModelAdmin): + """ + Defines display for Channel objects + + """ + + inlines = [ChannelTagInline, ChannelAttributeInline] + form = ChannelForm + list_display = ( + "id", + "db_key", + "no_of_subscribers", + "db_lock_storage", + "db_typeclass_path", + "db_date_created", + ) + list_display_links = ("id", "db_key") + ordering = ["-db_date_created", "-id", "-db_key"] + search_fields = ["id", "db_key", "db_tags__db_key"] + readonly_fields = ["serialized_string"] + save_as = True + save_on_top = True + list_select_related = True + raw_id_fields = ("db_object_subscriptions", "db_account_subscriptions") + fieldsets = ( + ( + None, + { + "fields": ( + ("db_key",), + "db_lock_storage", + "db_account_subscriptions", + "db_object_subscriptions", + "serialized_string", + ) + }, + ), + ) + +
[docs] def subscriptions(self, obj): + """ + Helper method to get subs from a channel. + + Args: + obj (Channel): The channel to get subs from. + + """ + return ", ".join([str(sub) for sub in obj.subscriptions.all()])
+ +
[docs] def no_of_subscribers(self, obj): + """ + Get number of subs for a a channel . + + Args: + obj (Channel): The channel to get subs from. + + """ + return sum(1 for sub in obj.subscriptions.all())
+ +
[docs] def serialized_string(self, obj): + """ + Get the serialized version of the object. + + """ + from evennia.utils import dbserialize + + return str(dbserialize.pack_dbobj(obj))
+ + serialized_string.help_text = ( + "Copy & paste this string into an Attribute's `value` field to store this channel there." + ) + +
[docs] def get_form(self, request, obj=None, **kwargs): + """ + Overrides help texts. + + """ + help_texts = kwargs.get("help_texts", {}) + help_texts["serialized_string"] = self.serialized_string.help_text + kwargs["help_texts"] = help_texts + return super().get_form(request, obj, **kwargs)
+ +
[docs] def save_model(self, request, obj, form, change): + """ + Model-save hook. + + Args: + request (Request): Incoming request. + obj (Object): Database object. + form (Form): Form instance. + change (bool): If this is a change or a new object. + + """ + obj.save() + if not change: + # adding a new object + # have to call init with typeclass passed to it + obj.set_class_from_typeclass(typeclass_path=settings.BASE_CHANNEL_TYPECLASS) + obj.at_init()
+ +
[docs] def response_add(self, request, obj, post_url_continue=None): + from django.http import HttpResponseRedirect + from django.urls import reverse + + return HttpResponseRedirect(reverse("admin:comms_channeldb_change", args=[obj.id]))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/frontpage.html b/docs/latest/_modules/evennia/web/admin/frontpage.html new file mode 100644 index 0000000000..6137f4749a --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/frontpage.html @@ -0,0 +1,132 @@ + + + + + + + + evennia.web.admin.frontpage — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.frontpage

+"""
+Admin views.
+
+"""
+
+from django.contrib.admin.sites import site
+from django.contrib.admin.views.decorators import staff_member_required
+from django.shortcuts import render
+
+from evennia.accounts.models import AccountDB
+
+
+
[docs]@staff_member_required +def evennia_admin(request): + """ + Helpful Evennia-specific admin page. + + """ + return render(request, "admin/frontpage.html", {"accountdb": AccountDB})
+ + +
[docs]def admin_wrapper(request): + """ + Wrapper that allows us to properly use the base Django admin site, if needed. + + """ + return staff_member_required(site.index)(request)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/help.html b/docs/latest/_modules/evennia/web/admin/help.html new file mode 100644 index 0000000000..45652a8e3e --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/help.html @@ -0,0 +1,170 @@ + + + + + + + + evennia.web.admin.help — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.help

+"""
+This defines how to edit help entries in Admin.
+"""
+from django import forms
+from django.contrib import admin
+
+from evennia.help.models import HelpEntry
+
+from .tags import TagInline
+
+
+
[docs]class HelpTagInline(TagInline): + model = HelpEntry.db_tags.through + related_field = "helpentry"
+ + +
[docs]class HelpEntryForm(forms.ModelForm): + "Defines how to display the help entry" + +
[docs] class Meta: + model = HelpEntry + fields = "__all__"
+ + db_help_category = forms.CharField( + label="Help category", initial="General", help_text="organizes help entries in lists" + ) + db_lock_storage = forms.CharField( + label="Locks", + initial="view:all()", + required=False, + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + help_text="Set lock to view:all() unless you want it to only show to certain users." + "<BR>Use the `edit:` limit if wanting to limit who can edit from in-game. By default it's " + "only limited to who can use the `sethelp` command (Builders).", + )
+ + +
[docs]@admin.register(HelpEntry) +class HelpEntryAdmin(admin.ModelAdmin): + "Sets up the admin manaager for help entries" + inlines = [HelpTagInline] + list_display = ("id", "db_key", "db_help_category", "db_lock_storage", "db_date_created") + list_display_links = ("id", "db_key") + search_fields = ["^db_key", "db_entrytext"] + ordering = ["db_help_category", "db_key"] + list_filter = ["db_help_category"] + save_as = True + save_on_top = True + list_select_related = True + view_on_site = False + + form = HelpEntryForm + fieldsets = ( + ( + None, + { + "fields": ( + ("db_key", "db_help_category"), + "db_entrytext", + "db_lock_storage", + # "db_date_created", + ), + }, + ), + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/objects.html b/docs/latest/_modules/evennia/web/admin/objects.html new file mode 100644 index 0000000000..a8de3be4aa --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/objects.html @@ -0,0 +1,477 @@ + + + + + + + + evennia.web.admin.objects — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.objects

+#
+# This sets up how models are displayed
+# in the web admin interface.
+#
+from django import forms
+from django.conf import settings
+from django.contrib import admin, messages
+from django.contrib.admin.utils import flatten_fieldsets
+from django.contrib.admin.widgets import ForeignKeyRawIdWidget
+from django.http import HttpResponseRedirect
+from django.urls import path, reverse
+from django.utils.html import format_html
+from django.utils.translation import gettext as _
+
+from evennia.accounts.models import AccountDB
+from evennia.objects.models import ObjectDB
+
+from . import utils as adminutils
+from .attributes import AttributeInline
+from .tags import TagInline
+
+
+
[docs]class ObjectAttributeInline(AttributeInline): + """ + Defines inline descriptions of Attributes (experimental) + + """ + + model = ObjectDB.db_attributes.through + related_field = "objectdb"
+ + +
[docs]class ObjectTagInline(TagInline): + """ + Defines inline descriptions of Tags (experimental) + + """ + + model = ObjectDB.db_tags.through + related_field = "objectdb"
+ + +
[docs]class ObjectCreateForm(forms.ModelForm): + """ + This form details the look of the fields. + + """ + +
[docs] class Meta(object): + model = ObjectDB + fields = "__all__"
+ + db_key = forms.CharField( + label="Name/Key", + widget=forms.TextInput(attrs={"size": "78"}), + help_text="Main identifier, like 'apple', 'strong guy', 'Elizabeth' etc. " + "If creating a Character, check so the name is unique among characters!", + ) + db_typeclass_path = forms.ChoiceField( + label="Typeclass", + initial={settings.BASE_OBJECT_TYPECLASS: settings.BASE_OBJECT_TYPECLASS}, + help_text="This is the Python-path to the class implementing the actual functionality. " + f"<BR>If you are creating a Character you usually need <B>{settings.BASE_CHARACTER_TYPECLASS}</B> " + "or a subclass of that. <BR>If your custom class is not found in the list, it may not be imported " + "into Evennia yet.", + choices=lambda: adminutils.get_and_load_typeclasses(parent=ObjectDB), + ) + + db_lock_storage = forms.CharField( + label="Locks", + required=False, + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + help_text="In-game lock definition string. If not given, defaults will be used. " + "This string should be on the form " + "<i>type:lockfunction(args);type2:lockfunction2(args);...", + ) + db_cmdset_storage = forms.CharField( + label="CmdSet", + initial="", + required=False, + widget=forms.TextInput(attrs={"size": "78"}), + ) + + # This is not working well because it will not properly allow an empty choice, and will + # also not work well for comma-separated storage without more work. Notably, it's also + # a bit hard to visualize. + # db_cmdset_storage = forms.MultipleChoiceField( + # label="CmdSet", + # required=False, + # choices=adminutils.get_and_load_typeclasses(parent=ObjectDB)) + + db_location = forms.ModelChoiceField( + ObjectDB.objects.all(), + label="Location", + required=False, + widget=ForeignKeyRawIdWidget( + ObjectDB._meta.get_field("db_location").remote_field, admin.site + ), + help_text="The (current) in-game location.<BR>" + "Usually a Room but can be<BR>" + "empty for un-puppeted Characters.", + ) + db_home = forms.ModelChoiceField( + ObjectDB.objects.all(), + label="Home", + required=False, + widget=ForeignKeyRawIdWidget( + ObjectDB._meta.get_field("db_location").remote_field, admin.site + ), + help_text="Fallback in-game location.<BR>" + "All objects should usually have<BR>" + "a home location.", + ) + db_destination = forms.ModelChoiceField( + ObjectDB.objects.all(), + label="Destination", + required=False, + widget=ForeignKeyRawIdWidget( + ObjectDB._meta.get_field("db_destination").remote_field, admin.site + ), + help_text="Only used by Exits.", + ) + +
[docs] def __init__(self, *args, **kwargs): + """ + Tweak some fields dynamically. + + """ + super().__init__(*args, **kwargs) + + # set default home + home_id = str(settings.DEFAULT_HOME) + home_id = home_id[1:] if home_id.startswith("#") else home_id + default_home = ObjectDB.objects.filter(id=home_id) + if default_home: + default_home = default_home[0] + self.fields["db_home"].initial = default_home + self.fields["db_location"].initial = default_home + + # better help text for cmdset_storage + char_cmdset = settings.CMDSET_CHARACTER + account_cmdset = settings.CMDSET_ACCOUNT + self.fields["db_cmdset_storage"].help_text = ( + "Path to Command-set path. Most non-character objects don't need a cmdset" + " and can leave this field blank. Default cmdset-path<BR> for Characters " + f"is <strong>{char_cmdset}</strong> ." + )
+ + +
[docs]class ObjectEditForm(ObjectCreateForm): + """ + Form used for editing. Extends the create one with more fields + + """ + +
[docs] class Meta: + model = ObjectDB + fields = "__all__"
+ + db_account = forms.ModelChoiceField( + AccountDB.objects.all(), + label="Puppeting Account", + required=False, + widget=ForeignKeyRawIdWidget( + ObjectDB._meta.get_field("db_account").remote_field, admin.site + ), + help_text="An Account puppeting this Object (if any).<BR>Note that when a user logs " + "off/unpuppets, this<BR>field will be empty again. This is normal.", + )
+ + +
[docs]@admin.register(ObjectDB) +class ObjectAdmin(admin.ModelAdmin): + """ + Describes the admin page for Objects. + + """ + + inlines = [ObjectTagInline, ObjectAttributeInline] + list_display = ( + "id", + "db_key", + "db_typeclass_path", + "db_location", + "db_destination", + "db_account", + "db_date_created", + ) + list_display_links = ("id", "db_key") + ordering = ["-db_date_created", "-id"] + search_fields = [ + "=id", + "^db_key", + "db_typeclass_path", + "^db_account__db_key", + "^db_location__db_key", + ] + raw_id_fields = ("db_destination", "db_location", "db_home", "db_account") + readonly_fields = ("serialized_string", "link_button") + + save_as = True + save_on_top = True + list_select_related = True + view_on_site = False + list_filter = ("db_typeclass_path",) + + # editing fields setup + + form = ObjectEditForm + fieldsets = ( + ( + None, + { + "fields": ( + ("db_key", "db_typeclass_path"), + ("db_location", "db_home", "db_destination"), + ("db_account", "link_button"), + "db_cmdset_storage", + "db_lock_storage", + "serialized_string", + ) + }, + ), + ) + + add_form = ObjectCreateForm + add_fieldsets = ( + ( + None, + { + "fields": ( + ("db_key", "db_typeclass_path"), + ("db_location", "db_home", "db_destination"), + "db_cmdset_storage", + ) + }, + ), + ) + +
[docs] def serialized_string(self, obj): + """ + Get the serialized version of the object. + + """ + from evennia.utils import dbserialize + + return str(dbserialize.pack_dbobj(obj))
+ + serialized_string.help_text = ( + "Copy & paste this string into an Attribute's `value` field to store this object there." + ) + +
[docs] def get_fieldsets(self, request, obj=None): + """ + Return fieldsets. + + Args: + request (Request): Incoming request. + obj (Object, optional): Database object. + """ + if not obj: + return self.add_fieldsets + return super().get_fieldsets(request, obj)
+ +
[docs] def get_form(self, request, obj=None, **kwargs): + """ + Use special form during creation. + + Args: + request (Request): Incoming request. + obj (Object, optional): Database object. + + """ + help_texts = kwargs.get("help_texts", {}) + help_texts["serialized_string"] = self.serialized_string.help_text + kwargs["help_texts"] = help_texts + + defaults = {} + if obj is None: + defaults.update( + {"form": self.add_form, "fields": flatten_fieldsets(self.add_fieldsets)} + ) + defaults.update(kwargs) + return super().get_form(request, obj, **defaults)
+ +
[docs] def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "account-object-link/<int:pk>", + self.admin_site.admin_view(self.link_object_to_account), + name="object-account-link", + ) + ] + return custom_urls + urls
+ + + + link_button.short_description = "Create attrs/locks for puppeting" + link_button.allow_tags = True + + + +
[docs] def save_model(self, request, obj, form, change): + """ + Model-save hook. + + Args: + request (Request): Incoming request. + obj (Object): Database object. + form (Form): Form instance. + change (bool): If this is a change or a new object. + + """ + if not change: + # adding a new object + # have to call init with typeclass passed to it + obj.set_class_from_typeclass(typeclass_path=obj.db_typeclass_path) + obj.save() + obj.basetype_setup() + obj.basetype_posthook_setup() + obj.at_object_creation() + else: + obj.save() + obj.at_init()
+ +
[docs] def response_add(self, request, obj, post_url_continue=None): + from django.http import HttpResponseRedirect + from django.urls import reverse + + return HttpResponseRedirect(reverse("admin:objects_objectdb_change", args=[obj.id]))
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/scripts.html b/docs/latest/_modules/evennia/web/admin/scripts.html new file mode 100644 index 0000000000..5f2d0c09f9 --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/scripts.html @@ -0,0 +1,260 @@ + + + + + + + + evennia.web.admin.scripts — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.scripts

+#
+# This sets up how models are displayed
+# in the web admin interface.
+#
+from django import forms
+from django.conf import settings
+from django.contrib import admin
+
+from evennia.scripts.models import ScriptDB
+
+from . import utils as adminutils
+from .attributes import AttributeInline
+from .tags import TagInline
+
+
+
[docs]class ScriptForm(forms.ModelForm): + db_key = forms.CharField( + label="Name/Key", help_text="Script identifier, shown in listings etc." + ) + + db_typeclass_path = forms.ChoiceField( + label="Typeclass", + help_text="This is the Python-path to the class implementing the actual script functionality. " + "<BR>If your custom class is not found here, it may not be imported into Evennia yet.", + choices=lambda: adminutils.get_and_load_typeclasses( + parent=ScriptDB, excluded_parents=["evennia.prototypes.prototypes.DbPrototype"] + ), + ) + + db_lock_storage = forms.CharField( + label="Locks", + required=False, + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + help_text="In-game lock definition string. If not given, defaults will be used. " + "This string should be on the form " + "<i>type:lockfunction(args);type2:lockfunction2(args);...", + ) + + db_interval = forms.IntegerField( + label="Repeat Interval", + help_text="Optional timer component.<BR>How often to call the Script's<BR>`at_repeat` hook, in seconds." + "<BR>Set to 0 to disable.", + ) + db_repeats = forms.IntegerField( + help_text="Only repeat this many times." "<BR>Set to 0 to run indefinitely." + ) + db_start_delay = forms.BooleanField(help_text="Wait <B>Interval</B> seconds before first call.") + db_persistent = forms.BooleanField( + label="Survives reboot", help_text="If unset, a server reboot will remove the timer." + )
+ + +
[docs]class ScriptTagInline(TagInline): + """ + Inline script tags. + + """ + + model = ScriptDB.db_tags.through + related_field = "scriptdb"
+ + +
[docs]class ScriptAttributeInline(AttributeInline): + """ + Inline attribute tags. + + """ + + model = ScriptDB.db_attributes.through + related_field = "scriptdb"
+ + +
[docs]@admin.register(ScriptDB) +class ScriptAdmin(admin.ModelAdmin): + """ + Displaying the main Script page. + + """ + + list_display = ( + "id", + "db_key", + "db_typeclass_path", + "db_obj", + "db_interval", + "db_repeats", + "db_persistent", + "db_date_created", + ) + list_display_links = ("id", "db_key") + ordering = ["-db_date_created", "-id"] + search_fields = ["=id", "^db_key", "db_typeclass_path"] + readonly_fields = ["serialized_string"] + form = ScriptForm + save_as = True + save_on_top = True + list_select_related = True + view_on_site = False + raw_id_fields = ("db_obj",) + + fieldsets = ( + ( + None, + { + "fields": ( + ("db_key", "db_typeclass_path"), + ("db_interval", "db_repeats", "db_start_delay", "db_persistent"), + "db_obj", + "db_lock_storage", + "serialized_string", + ) + }, + ), + ) + inlines = [ScriptTagInline, ScriptAttributeInline] + +
[docs] def serialized_string(self, obj): + """ + Get the serialized version of the object. + + """ + from evennia.utils import dbserialize + + return str(dbserialize.pack_dbobj(obj))
+ + serialized_string.help_text = ( + "Copy & paste this string into an Attribute's `value` field to store this script there." + ) + +
[docs] def get_form(self, request, obj=None, **kwargs): + """ + Overrides help texts. + + """ + help_texts = kwargs.get("help_texts", {}) + help_texts["serialized_string"] = self.serialized_string.help_text + kwargs["help_texts"] = help_texts + return super().get_form(request, obj, **kwargs)
+ +
[docs] def save_model(self, request, obj, form, change): + """ + Model-save hook. + + Args: + request (Request): Incoming request. + obj (Object): Database object. + form (Form): Form instance. + change (bool): If this is a change or a new object. + + """ + obj.save() + if not change: + # adding a new object + # have to call init with typeclass passed to it + obj.set_class_from_typeclass(typeclass_path=obj.db_typeclass_path)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/server.html b/docs/latest/_modules/evennia/web/admin/server.html new file mode 100644 index 0000000000..95072fd137 --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/server.html @@ -0,0 +1,131 @@ + + + + + + + + evennia.web.admin.server — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.server

+"""
+
+This sets up how models are displayed
+in the web admin interface.
+
+"""
+
+from django.contrib import admin
+
+from evennia.server.models import ServerConfig
+
+
+
[docs]@admin.register(ServerConfig) +class ServerConfigAdmin(admin.ModelAdmin): + """ + Custom admin for server configs + + """ + + list_display = ("db_key", "db_value") + list_display_links = ("db_key",) + ordering = ["db_key", "db_value"] + search_fields = ["db_key"] + save_as = True + save_on_top = True + list_select_related = True
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/tags.html b/docs/latest/_modules/evennia/web/admin/tags.html new file mode 100644 index 0000000000..44ae48fa54 --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/tags.html @@ -0,0 +1,339 @@ + + + + + + + + evennia.web.admin.tags — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.tags

+"""
+Tag admin
+
+"""
+
+
+import traceback
+from datetime import datetime
+
+from django import forms
+from django.contrib import admin
+
+from evennia.typeclasses.tags import Tag
+from evennia.utils.dbserialize import _SaverSet, from_pickle
+from evennia.utils.picklefield import PickledFormField
+
+
+
[docs]class TagForm(forms.ModelForm): + """ + Form to display fields in the stand-alone Tag display. + + """ + + db_key = forms.CharField(label="Key/Name", required=True, help_text="The main key identifier") + db_category = forms.CharField( + label="Category", + help_text="Used for grouping tags. Unset (default) gives a category of None", + required=False, + ) + db_tagtype = forms.ChoiceField( + label="Type", + choices=[(None, "-"), ("alias", "alias"), ("permission", "permission")], + help_text="Tags are used for different things. Unset for regular tags.", + required=False, + ) + db_model = forms.ChoiceField( + label="Model", + required=False, + help_text="Each Tag can only 'attach' to one type of entity.", + choices=( + [ + ("objectdb", "objectdb"), + ("accountdb", "accountdb"), + ("scriptdb", "scriptdb"), + ("channeldb", "channeldb"), + ("helpentry", "helpentry"), + ("msg", "msg"), + ] + ), + ) + db_data = forms.CharField( + label="Data", + help_text="Usually unused. Intended for info about the tag itself", + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + required=False, + ) + +
[docs] class Meta: + fields = ("tag_key", "tag_category", "tag_data", "tag_type")
+ + +
[docs]class InlineTagForm(forms.ModelForm): + """ + Form for displaying tags inline together with other entities. + + This form overrides the base behavior of the ModelForm that would be used for a + Tag-through-model. Since the through-models only have access to the foreignkeys of the Tag and + the Object that they're attached to, we need to spoof the behavior of it being a form that would + correspond to its tag, or the creation of a tag. Instead of being saved, we'll call to the + Object's handler, which will handle the creation, change, or deletion of a tag for us, as well + as updating the handler's cache so that all changes are instantly updated in-game. + """ + + tag_key = forms.CharField( + label="Tag Name", required=True, help_text="This is the main key identifier" + ) + tag_category = forms.CharField( + label="Category", + help_text="Used for grouping tags. Unset (default) gives a category of None", + required=False, + ) + tag_type = forms.ChoiceField( + label="Type", + choices=[(None, "-"), ("alias", "alias"), ("permission", "permission")], + help_text="Tags are used for different things. Unset for regular tags.", + required=False, + ) + tag_data = forms.CharField( + label="Data", + widget=forms.Textarea(attrs={"cols": "100", "rows": "2"}), + help_text="Usually unused. Intended for eventual info about the tag itself", + required=False, + ) + +
[docs] class Meta: + fields = ("tag_key", "tag_category", "tag_data", "tag_type")
+ +
[docs] def __init__(self, *args, **kwargs): + """ + If we have a tag, then we'll prepopulate our instance with the fields we'd expect it + to have based on the tag. tag_key, tag_category, tag_type, and tag_data all refer to + the corresponding tag fields. The initial data of the form fields will similarly be + populated. + """ + super().__init__(*args, **kwargs) + tagkey = None + tagcategory = None + tagtype = None + tagdata = None + if hasattr(self.instance, "tag"): + tagkey = self.instance.tag.db_key + tagcategory = self.instance.tag.db_category + tagtype = self.instance.tag.db_tagtype + tagdata = self.instance.tag.db_data + self.fields["tag_key"].initial = tagkey + self.fields["tag_category"].initial = tagcategory + self.fields["tag_type"].initial = tagtype + self.fields["tag_data"].initial = tagdata + self.instance.tag_key = tagkey + self.instance.tag_category = tagcategory + self.instance.tag_type = tagtype + self.instance.tag_data = tagdata
+ +
[docs] def save(self, commit=True): + """ + One thing we want to do here is the or None checks, because forms are saved with an empty + string rather than null from forms, usually, and the Handlers may handle empty strings + differently than None objects. So for consistency with how things are handled in game, + we'll try to make sure that empty form fields will be None, rather than ''. + """ + # we are spoofing a tag for the Handler that will be called + # instance = super().save(commit=False) + instance = self.instance + instance.tag_key = self.cleaned_data["tag_key"] + instance.tag_category = self.cleaned_data["tag_category"] or None + instance.tag_type = self.cleaned_data["tag_type"] or None + instance.tag_data = self.cleaned_data["tag_data"] or None + return instance
+ + +
[docs]class TagFormSet(forms.BaseInlineFormSet): + """ + The Formset handles all the inline forms that are grouped together on the change page of the + corresponding object. All the tags will appear here, and we'll save them by overriding the + formset's save method. The forms will similarly spoof their save methods to return an instance + which hasn't been saved to the database, but have the relevant fields filled out based on the + contents of the cleaned form. We'll then use that to call to the handler of the corresponding + Object, where the handler is an AliasHandler, PermissionsHandler, or TagHandler, based on the + type of tag. + """ + + verbose_name = "Tag" + verbose_name_plural = "Tags" + +
[docs] def save(self, commit=True): + def get_handler(finished_object): + related = getattr(finished_object, self.related_field) + try: + tagtype = finished_object.tag_type + except AttributeError: + tagtype = finished_object.tag.db_tagtype + if tagtype == "alias": + handler_name = "aliases" + elif tagtype == "permission": + handler_name = "permissions" + else: + handler_name = "tags" + return getattr(related, handler_name) + + instances = super().save(commit=False) + # self.deleted_objects is a list created when super of save is called, we'll remove those + for obj in self.deleted_objects: + handler = get_handler(obj) + handler.remove(obj.tag_key, category=obj.tag_category) + for instance in instances: + handler = get_handler(instance) + handler.add(instance.tag_key, category=instance.tag_category, data=instance.tag_data)
+ + +
[docs]class TagInline(admin.TabularInline): + """ + A handler for inline Tags. This class should be subclassed in the admin of your models, + and the 'model' and 'related_field' class attributes must be set. model should be the + through model (ObjectDB_db_tag', for example), while related field should be the name + of the field on that through model which points to the model being used: 'objectdb', + 'msg', 'accountdb', etc. + """ + + # Set this to the through model of your desired M2M when subclassing. + model = None + verbose_name = "Tag" + verbose_name_plural = "Tags" + form = InlineTagForm + formset = TagFormSet + related_field = None # Must be 'objectdb', 'accountdb', 'msg', etc. Set when subclassing + # raw_id_fields = ('tag',) + # readonly_fields = ('tag',) + extra = 0 + +
[docs] def get_formset(self, request, obj=None, **kwargs): + """ + get_formset has to return a class, but we need to make the class that we return + know about the related_field that we'll use. Returning the class itself rather than + a proxy isn't threadsafe, since it'd be the base class and would change if multiple + people used the admin at the same time + """ + formset = super().get_formset(request, obj, **kwargs) + + class ProxyFormset(formset): + pass + + ProxyFormset.related_field = self.related_field + return ProxyFormset
+ + +
[docs]@admin.register(Tag) +class TagAdmin(admin.ModelAdmin): + """ + A django Admin wrapper for Tags. + + """ + + search_fields = ("db_key", "db_category", "db_tagtype") + list_display = ("db_key", "db_category", "db_tagtype", "db_model", "db_data") + list_filter = ("db_tagtype", "db_category", "db_model") + form = TagForm + view_on_site = False + + fieldsets = ( + ( + None, + {"fields": (("db_key", "db_category"), ("db_tagtype", "db_model"), "db_data")}, + ), + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/admin/utils.html b/docs/latest/_modules/evennia/web/admin/utils.html new file mode 100644 index 0000000000..0b27b31d94 --- /dev/null +++ b/docs/latest/_modules/evennia/web/admin/utils.html @@ -0,0 +1,196 @@ + + + + + + + + evennia.web.admin.utils — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.admin.utils

+"""
+Helper utils for admin views.
+
+"""
+
+import importlib
+
+from evennia.utils.utils import get_all_cmdsets, get_all_typeclasses, inherits_from
+
+
+
[docs]def get_and_load_typeclasses(parent=None, excluded_parents=None): + """ + Get all typeclasses. We we need to initialize things here + for them to be actually available in the admin process. + This is intended to be used with forms.ChoiceField. + + Args: + parent (str or class, optional): Limit selection to this class and its children + (at any distance). + exclude (list): Class-parents to exclude from the resulting list. All + children of these paretns will be skipped. + + Returns: + list: A list of (str, str), the way ChoiceField wants it. + + """ + # this is necessary in order to have typeclasses imported and accessible + # in the inheritance tree. + import evennia + + evennia._init() + + # this return a dict (path: class} + tmap = get_all_typeclasses(parent=parent) + + # filter out any excludes + excluded_parents = excluded_parents or [] + tpaths = [ + path + for path, tclass in tmap.items() + if not any(inherits_from(tclass, excl) for excl in excluded_parents) + ] + + # sort so we get custom paths (not in evennia repo) first + tpaths = sorted(tpaths, key=lambda k: (1 if k.startswith("evennia.") else 0, k)) + + # the base models are not typeclasses so we filter them out + tpaths = [ + path + for path in tpaths + if path + not in ( + "evennia.objects.models.ObjectDB", + "evennia.accounts.models.AccountDB", + "evennia.scripts.models.ScriptDB", + "evennia.comms.models.ChannelDB", + ) + ] + + # return on form excepted by ChoiceField + return [(path, path) for path in tpaths if path]
+ + +
[docs]def get_and_load_cmdsets(parent=None, excluded_parents=None): + """ + Get all cmdsets available or as children based on a parent cmdset. We need + to initialize things here to make sure as much as possible is loaded in the + admin process. This is intended to be used with forms.ChoiceField. + + Args: + parent (str, optional): Python-path to the parent cmdset, if any. + excluded_parents (list): A list of cmset-paths to exclude from the result. + + Returns: + list: A list of (str, str), the way ChoiceField wants it. + + """ + # we must do this to have cmdsets imported and accessible in the inheritance tree. + import evennia + + evennia._init() + + cmap = get_all_cmdsets(parent) + + excluded_parents = excluded_parents or [] + cpaths = [path for path in cmap if not any(path == excluded for excluded in excluded_parents)] + + cpaths = sorted(cpaths, key=lambda k: (1 if k.startswith("evennia.") else 0, k)) + + # return on form expected by ChoiceField + return [("", "-")] + [(path, path) for path in cpaths if path]
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/api/filters.html b/docs/latest/_modules/evennia/web/api/filters.html new file mode 100644 index 0000000000..8c3999f958 --- /dev/null +++ b/docs/latest/_modules/evennia/web/api/filters.html @@ -0,0 +1,254 @@ + + + + + + + + evennia.web.api.filters — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.api.filters

+"""
+FilterSets allow clients to specify querystrings that will determine the data
+that is retrieved in GET requests. By default, Django Rest Framework uses the
+'django-filter' package as its backend. Django-filter also has a section in its
+documentation specifically regarding DRF integration.
+
+https://django-filter.readthedocs.io/en/latest/guide/rest_framework.html
+
+"""
+from typing import Union
+
+from django.db.models import Q
+from django_filters.filters import EMPTY_VALUES, CharFilter
+from django_filters.rest_framework.filterset import FilterSet
+
+from evennia.accounts.models import AccountDB
+from evennia.objects.models import ObjectDB
+from evennia.scripts.models import ScriptDB
+
+
+
[docs]def get_tag_query(tag_type: Union[str, None], key: str) -> Q: + """ + Returns a Q object for searching by tag names for typeclasses + Args: + tag_type(str or None): The type of tag (None, 'alias', etc) + key (str): The name of the tag + + Returns: + A Q object that for searching by this tag type and name + + """ + return Q(db_tags__db_tagtype=tag_type) & Q(db_tags__db_key__iexact=key)
+ + +
[docs]class TagTypeFilter(CharFilter): + """ + This class lets you create different filters for tags of a specified db_tagtype. + + """ + + tag_type = None + +
[docs] def filter(self, qs, value): + # if no value is specified, we don't use the filter + if value in EMPTY_VALUES: + return qs + # if they enter a value, we filter objects by having a tag of this type with the given name + return qs.filter(get_tag_query(self.tag_type, value)).distinct()
+ + +
[docs]class AliasFilter(TagTypeFilter): + """A filter for objects by their aliases (tags with a tagtype of 'alias'""" + + tag_type = "alias"
+ + +
[docs]class PermissionFilter(TagTypeFilter): + """A filter for objects by their permissions (tags with a tagtype of 'permission'""" + + tag_type = "permission"
+ + +SHARED_FIELDS = ["db_key", "db_typeclass_path", "db_tags__db_key", "db_tags__db_category"] + + +
[docs]class BaseTypeclassFilterSet(FilterSet): + """ + A parent class with filters for aliases and permissions + + """ + + name = CharFilter(lookup_expr="iexact", method="filter_name", field_name="db_key") + alias = AliasFilter(lookup_expr="iexact") + permission = PermissionFilter(lookup_expr="iexact") + +
[docs] @staticmethod + def filter_name(queryset, name, value): + """ + Filters a queryset by aliases or the key of the typeclass + Args: + queryset: The queryset being filtered + name: The name of the field + value: The value passed in from GET params + + Returns: + The filtered queryset + """ + query = Q(**{f"{name}__iexact": value}) + query |= get_tag_query("alias", value) + return queryset.filter(query).distinct()
+ + +
[docs]class ObjectDBFilterSet(BaseTypeclassFilterSet): + """ + This adds filters for ObjectDB instances - characters, rooms, exits, etc + + """ + +
[docs] class Meta: + model = ObjectDB + fields = SHARED_FIELDS + [ + "db_location__db_key", + "db_home__db_key", + "db_location__id", + "db_home__id", + ]
+ + +
[docs]class AccountDBFilterSet(BaseTypeclassFilterSet): + """This adds filters for Account objects""" + + name = CharFilter(lookup_expr="iexact", method="filter_name", field_name="username") + +
[docs] class Meta: + model = AccountDB + fields = [ + fi + for fi in (SHARED_FIELDS + ["username", "db_is_connected", "db_is_bot"]) + if fi != "db_key" + ]
+ + +
[docs]class ScriptDBFilterSet(BaseTypeclassFilterSet): + """This adds filters for Script objects""" + +
[docs] class Meta: + model = ScriptDB + fields = SHARED_FIELDS + [ + "db_desc", + "db_obj__db_key", + "db_obj__id", + "db_account__id", + "db_account__username", + "db_is_active", + "db_persistent", + "db_interval", + ]
+ + +
[docs]class HelpFilterSet(FilterSet): + + """ + Filter for help entries + + """ + + name = CharFilter(lookup_expr="iexact", method="filter_name", field_name="db_key") + category = CharFilter(lookup_expr="iexact", method="filter_name", field_name="db_category") + alias = AliasFilter(lookup_expr="iexact")
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/api/permissions.html b/docs/latest/_modules/evennia/web/api/permissions.html new file mode 100644 index 0000000000..324345626c --- /dev/null +++ b/docs/latest/_modules/evennia/web/api/permissions.html @@ -0,0 +1,199 @@ + + + + + + + + evennia.web.api.permissions — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.api.permissions

+"""
+Sets up an api-access permission check using the in-game permission hierarchy.
+
+"""
+
+
+from django.conf import settings
+from rest_framework import permissions
+
+from evennia.locks.lockhandler import check_perm
+
+
+
[docs]class EvenniaPermission(permissions.BasePermission): + """ + A Django Rest Framework permission class that allows us to use Evennia's + permission structure. Based on the action in a given view, we'll check a + corresponding Evennia access/lock check. + + """ + + # subclass this to change these permissions + MINIMUM_LIST_PERMISSION = settings.REST_FRAMEWORK.get("DEFAULT_LIST_PERMISSION", "builder") + MINIMUM_CREATE_PERMISSION = settings.REST_FRAMEWORK.get("DEFAULT_CREATE_PERMISSION", "builder") + view_locks = settings.REST_FRAMEWORK.get("DEFAULT_VIEW_LOCKS", ["examine"]) + destroy_locks = settings.REST_FRAMEWORK.get("DEFAULT_DESTROY_LOCKS", ["delete"]) + update_locks = settings.REST_FRAMEWORK.get("DEFAULT_UPDATE_LOCKS", ["control", "edit"]) + +
[docs] def has_permission(self, request, view): + """Checks for permissions + + Args: + request (Request): The incoming request object. + view (View): The django view we are checking permission for. + + Returns: + bool: If permission is granted or not. If we return False here, a PermissionDenied + error will be raised from the view. + + Notes: + This method is a check that always happens first. If there's an object involved, + such as with retrieve, update, or delete, then the has_object_permission method + is called after this, assuming this returns `True`. + """ + # Only allow authenticated users to call the API + if not request.user.is_authenticated: + return False + if request.user.is_superuser: + return True + # these actions don't support object-level permissions, so use the above definitions + if view.action == "list": + return check_perm(request.user, self.MINIMUM_LIST_PERMISSION) + if view.action == "create": + return check_perm(request.user, self.MINIMUM_CREATE_PERMISSION) + return True # this means we'll check object-level permissions
+ +
[docs] @staticmethod + def check_locks(obj, user, locks): + """Checks access for user for object with given locks + Args: + obj: Object instance we're checking + user (Account): User who we're checking permissions + locks (list): list of lockstrings + + Returns: + bool: True if they have access, False if they don't + """ + return any([obj.access(user, lock) for lock in locks])
+ +
[docs] def has_object_permission(self, request, view, obj): + """Checks object-level permissions after has_permission + + Args: + request (Request): The incoming request object. + view (View): The django view we are checking permission for. + obj: Object we're checking object-level permissions for + + Returns: + bool: If permission is granted or not. If we return False here, a PermissionDenied + error will be raised from the view. + + Notes: + This method assumes that has_permission has already returned True. We check + equivalent Evennia permissions in the request.user to determine if they can + complete the action. + """ + if view.action in ("list", "retrieve"): + # access_type is based on the examine command + return self.check_locks(obj, request.user, self.view_locks) + if view.action == "destroy": + # access type based on the destroy command + return self.check_locks(obj, request.user, self.destroy_locks) + if view.action in ("update", "partial_update", "set_attribute"): + # access type based on set command + return self.check_locks(obj, request.user, self.update_locks)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/api/root.html b/docs/latest/_modules/evennia/web/api/root.html new file mode 100644 index 0000000000..586c2b17f0 --- /dev/null +++ b/docs/latest/_modules/evennia/web/api/root.html @@ -0,0 +1,124 @@ + + + + + + + + evennia.web.api.root — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.api.root

+"""
+Set a more useful description on the Api root.
+
+"""
+
+from rest_framework import routers
+
+
+
[docs]class EvenniaAPIRoot(routers.APIRootView): + """ + Root of the Evennia API tree. + + """ + + pass
+ + +
[docs]class APIRootRouter(routers.DefaultRouter): + APIRootView = EvenniaAPIRoot
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/api/serializers.html b/docs/latest/_modules/evennia/web/api/serializers.html new file mode 100644 index 0000000000..e7fadcd8a1 --- /dev/null +++ b/docs/latest/_modules/evennia/web/api/serializers.html @@ -0,0 +1,459 @@ + + + + + + + + evennia.web.api.serializers — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.api.serializers

+"""
+Serializers in the Django Rest Framework are similar to Forms in normal django.
+They're used for transmitting and validating data, both going to clients and
+coming to the server. However, where forms often contained presentation logic,
+such as specifying widgets to use for selection, serializers typically leave
+those decisions in the hands of clients, and are more focused on converting
+data from the server to JSON (serialization) for a response, and validating
+and converting JSON data sent from clients to our enpoints into python objects,
+often django model instances, that we can use (deserialization).
+
+"""
+
+from rest_framework import serializers
+
+from evennia.accounts.accounts import DefaultAccount
+from evennia.help.models import HelpEntry
+from evennia.objects.objects import DefaultObject
+from evennia.scripts.models import ScriptDB
+from evennia.typeclasses.attributes import Attribute
+from evennia.typeclasses.tags import Tag
+
+
+
[docs]class AttributeSerializer(serializers.ModelSerializer): + """ + Serialize Attribute views. + + """ + + value_display = serializers.SerializerMethodField(source="value") + db_value = serializers.CharField(write_only=True, required=False) + +
[docs] class Meta: + model = Attribute + fields = ["db_key", "db_category", "db_attrtype", "value_display", "db_value"]
+ +
[docs] @staticmethod + def get_value_display(obj: Attribute) -> str: + """ + Gets the string display of an Attribute's value for serialization + Args: + obj: Attribute being serialized + + Returns: + The Attribute's value in string format + + """ + if obj.db_strvalue: + return obj.db_strvalue + return str(obj.value)
+ + +
[docs]class TagSerializer(serializers.ModelSerializer): +
[docs] class Meta: + model = Tag + fields = ["db_key", "db_category", "db_data", "db_tagtype"]
+ + +
[docs]class SimpleObjectDBSerializer(serializers.ModelSerializer): +
[docs] class Meta: + model = DefaultObject + fields = ["id", "db_key"]
+ + +
[docs]class TypeclassSerializerMixin: + """ + Mixin that contains types shared by typeclasses. A note about tags, + aliases, and permissions. You might note that the methods and fields are + defined here, but they're included explicitly in each child class. What + gives? It's a DRF error: serializer method fields which are inherited do + not resolve correctly in child classes, and as of this current version + (3.11) you must have them in the child classes explicitly to avoid field + errors. Similarly, the child classes must contain the attribute serializer + explicitly to not have them render PK-related fields. + + """ + + shared_fields = [ + "id", + "db_key", + "attributes", + "db_typeclass_path", + "aliases", + "tags", + "permissions", + ] + +
[docs] @staticmethod + def get_tags(obj): + """ + Serializes tags from the object's Tagshandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer(obj.tags.get(return_tagobj=True, return_list=True), many=True).data
+ +
[docs] @staticmethod + def get_aliases(obj): + """ + Serializes tags from the object's Aliashandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer(obj.aliases.get(return_tagobj=True, return_list=True), many=True).data
+ +
[docs] @staticmethod + def get_permissions(obj): + """ + Serializes tags from the object's Permissionshandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of TagSerializer data + """ + return TagSerializer( + obj.permissions.get(return_tagobj=True, return_list=True), many=True + ).data
+ +
[docs] @staticmethod + def get_attributes(obj): + """ + Serializes attributes from the object's AttributeHandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of AttributeSerializer data + """ + return AttributeSerializer(obj.attributes.all(), many=True).data
+ +
[docs] @staticmethod + def get_nicks(obj): + """ + Serializes attributes from the object's NicksHandler + Args: + obj: Typeclassed object being serialized + + Returns: + List of AttributeSerializer data + """ + return AttributeSerializer(obj.nicks.all(), many=True).data
+ + +
[docs]class TypeclassListSerializerMixin: + """ + Shortened serializer for list views. + + """ + + shared_fields = [ + "id", + "db_key", + "db_typeclass_path", + ]
+ + +
[docs]class ObjectDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + """ + Serializing Objects. + + """ + + attributes = serializers.SerializerMethodField() + nicks = serializers.SerializerMethodField() + contents = serializers.SerializerMethodField() + exits = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + +
[docs] class Meta: + model = DefaultObject + fields = [ + "db_location", + "db_home", + "contents", + "exits", + "nicks", + ] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id"]
+ +
[docs] @staticmethod + def get_exits(obj): + """ + Gets exits for the object + Args: + obj: Object being serialized + + Returns: + List of data from SimpleObjectDBSerializer + """ + exits = [ob for ob in obj.contents if ob.destination] + return SimpleObjectDBSerializer(exits, many=True).data
+ +
[docs] @staticmethod + def get_contents(obj): + """ + Gets non-exits for the object + Args: + obj: Object being serialized + + Returns: + List of data from SimpleObjectDBSerializer + """ + non_exits = [ob for ob in obj.contents if not ob.destination] + return SimpleObjectDBSerializer(non_exits, many=True).data
+ + +
[docs]class ObjectListSerializer(TypeclassListSerializerMixin, serializers.ModelSerializer): + """ + Shortened representation for listings.] + + """ + +
[docs] class Meta: + model = DefaultObject + fields = [ + "db_location", + "db_home", + ] + TypeclassListSerializerMixin.shared_fields + read_only_fields = ["id"]
+ + +
[docs]class AccountSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + """ + This uses the DefaultAccount object to have access to the sessions property + + """ + + attributes = serializers.SerializerMethodField() + nicks = serializers.SerializerMethodField() + db_key = serializers.CharField(required=False) + session_ids = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + +
[docs] @staticmethod + def get_session_ids(obj): + """ + Gets a list of session IDs connected to this Account + Args: + obj (DefaultAccount): Account we're grabbing sessions from + + Returns: + List of session IDs + """ + return [sess.sessid for sess in obj.sessions.all() if hasattr(sess, "sessid")]
+ +
[docs] class Meta: + model = DefaultAccount + fields = ["username", "session_ids", "nicks"] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id"]
+ + +
[docs]class AccountListSerializer(TypeclassListSerializerMixin, serializers.ModelSerializer): + """ + A shortened form for listing. + + """ + +
[docs] class Meta: + model = DefaultAccount + fields = ["username"] + [ + fi for fi in TypeclassListSerializerMixin.shared_fields if fi != "db_key" + ] + read_only_fields = ["id"]
+ + +
[docs]class ScriptDBSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + """ + Serializing Account. + + """ + + attributes = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + +
[docs] class Meta: + model = ScriptDB + fields = [ + "db_interval", + "db_persistent", + "db_start_delay", + "db_is_active", + "db_repeats", + ] + TypeclassSerializerMixin.shared_fields + read_only_fields = ["id"]
+ + +
[docs]class ScriptListSerializer(TypeclassListSerializerMixin, serializers.ModelSerializer): + """ + Shortened form for listing. + + """ + +
[docs] class Meta: + model = ScriptDB + fields = [ + "db_interval", + "db_persistent", + "db_start_delay", + "db_is_active", + "db_repeats", + ] + TypeclassListSerializerMixin.shared_fields + read_only_fields = ["id"]
+ + +
[docs]class HelpSerializer(TypeclassSerializerMixin, serializers.ModelSerializer): + """ + Serializers Help entries (not a typeclass). + + """ + + tags = serializers.SerializerMethodField() + aliases = serializers.SerializerMethodField() + +
[docs] class Meta: + model = HelpEntry + fields = [ + "id", + "db_key", + "db_help_category", + "db_entrytext", + "db_date_created", + "tags", + "aliases", + ] + read_only_fields = ["id"]
+ + +
[docs]class HelpListSerializer(TypeclassListSerializerMixin, serializers.ModelSerializer): + """ + Shortened form for listings. + + """ + +
[docs] class Meta: + model = HelpEntry + fields = [ + "id", + "db_key", + "db_help_category", + "db_date_created", + ] + read_only_fields = ["id"]
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/api/tests.html b/docs/latest/_modules/evennia/web/api/tests.html new file mode 100644 index 0000000000..318fc72f90 --- /dev/null +++ b/docs/latest/_modules/evennia/web/api/tests.html @@ -0,0 +1,294 @@ + + + + + + + + evennia.web.api.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.api.tests

+"""
+Tests for the REST API.
+
+"""
+from collections import namedtuple
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.test import override_settings
+from django.urls import include, path, reverse
+from rest_framework.test import APIClient
+
+from evennia.utils.test_resources import BaseEvenniaTest
+from evennia.web.api import serializers
+
+urlpatterns = [
+    path(r"^", include("evennia.web.website.urls")),
+    path(r"^api/", include("evennia.web.api.urls", namespace="api")),
+]
+
+
+
[docs]@override_settings(REST_API_ENABLED=True, ROOT_URLCONF=__name__, AUTH_USERNAME_VALIDATORS=[]) +class TestEvenniaRESTApi(BaseEvenniaTest): + client_class = APIClient + maxDiff = None + +
[docs] def setUp(self): + super().setUp() + self.account.is_superuser = True + self.account.save() + self.client.force_login(self.account) + # scripts do not have default locks. Without them, even superuser access check fails + self.script.locks.add("edit: perm(Admin); examine: perm(Admin); delete: perm(Admin)")
+ +
[docs] def tearDown(self): + try: + super().tearDown() + except ObjectDoesNotExist: + pass
+ +
[docs] def get_view_details(self, action): + """Helper function for generating list of named tuples""" + View = namedtuple( + "View", + [ + "view_name", + "obj", + "list", + "serializer", + "list_serializer", + "create_data", + "retrieve_data", + ], + ) + views = [ + View( + "object-%s" % action, + self.obj1, + [self.obj1, self.char1, self.exit, self.room1, self.room2, self.obj2, self.char2], + serializers.ObjectDBSerializer, + serializers.ObjectListSerializer, + {"db_key": "object-create-test-name"}, + serializers.ObjectDBSerializer(self.obj1).data, + ), + View( + "character-%s" % action, + self.char1, + [self.char1, self.char2], + serializers.ObjectDBSerializer, + serializers.ObjectListSerializer, + {"db_key": "character-create-test-name"}, + serializers.ObjectDBSerializer(self.char1).data, + ), + View( + "exit-%s" % action, + self.exit, + [self.exit], + serializers.ObjectDBSerializer, + serializers.ObjectListSerializer, + {"db_key": "exit-create-test-name"}, + serializers.ObjectDBSerializer(self.exit).data, + ), + View( + "room-%s" % action, + self.room1, + [self.room1, self.room2], + serializers.ObjectDBSerializer, + serializers.ObjectListSerializer, + {"db_key": "room-create-test-name"}, + serializers.ObjectDBSerializer(self.room1).data, + ), + View( + "script-%s" % action, + self.script, + [self.script], + serializers.ScriptDBSerializer, + serializers.ScriptListSerializer, + {"db_key": "script-create-test-name"}, + serializers.ScriptDBSerializer(self.script).data, + ), + View( + "account-%s" % action, + self.account2, + [self.account, self.account2], + serializers.AccountSerializer, + serializers.AccountListSerializer, + {"username": "account-create-test-name"}, + serializers.AccountSerializer(self.account2).data, + ), + ] + return views
+ +
[docs] def test_retrieve(self): + views = self.get_view_details("detail") + for view in views: + with self.subTest(msg="Testing {} retrieve".format(view.view_name)): + view_url = reverse("api:{}".format(view.view_name), kwargs={"pk": view.obj.pk}) + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.data, view.retrieve_data)
+ +
[docs] def test_update(self): + views = self.get_view_details("detail") + for view in views: + with self.subTest(msg="Testing {} update".format(view.view_name)): + view_url = reverse("api:{}".format(view.view_name), kwargs={"pk": view.obj.pk}) + # test both PUT (update) and PATCH (partial update) here + for new_key, method in (("foobar", "put"), ("fizzbuzz", "patch")): + field = "username" if "account" in view.view_name else "db_key" + data = {field: new_key} + response = getattr(self.client, method)(view_url, data=data) + self.assertEqual(response.status_code, 200) + view.obj.refresh_from_db() + self.assertEqual(getattr(view.obj, field), new_key) + self.assertEqual(response.data[field], new_key)
+ +
[docs] def test_delete(self): + views = self.get_view_details("detail") + for view in views: + with self.subTest(msg="Testing {} delete".format(view.view_name)): + view_url = reverse("api:{}".format(view.view_name), kwargs={"pk": view.obj.pk}) + response = self.client.delete(view_url) + self.assertEqual(response.status_code, 204) + with self.assertRaises(ObjectDoesNotExist): + view.obj.refresh_from_db()
+ +
[docs] def test_list(self): + views = self.get_view_details("list") + for view in views: + with self.subTest(msg=f"Testing {view.view_name} "): + view_url = reverse(f"api:{view.view_name}") + response = self.client.get(view_url) + self.assertEqual(response.status_code, 200) + self.assertCountEqual( + response.data["results"], [view.list_serializer(obj).data for obj in view.list] + )
+ +
[docs] def test_create(self): + views = self.get_view_details("list") + for view in views: + with self.subTest(msg=f"Testing {view.view_name} create"): + # create is a POST request off of <type>-list + view_url = reverse(f"api:{view.view_name}") + # check failures from not sending required fields + response = self.client.post(view_url) + self.assertEqual(response.status_code, 400) + # check success when sending the required data + response = self.client.post(view_url, data=view.create_data) + self.assertEqual(response.status_code, 201, f"Response was {response.data}")
+ +
[docs] def test_set_attribute(self): + views = self.get_view_details("set-attribute") + for view in views: + with self.subTest(msg=f"Testing {view.view_name}"): + view_url = reverse(f"api:{view.view_name}", kwargs={"pk": view.obj.pk}) + # check failures from not sending required fields + response = self.client.post(view_url) + self.assertEqual(response.status_code, 400, f"Response was: {response.data}") + # test adding an attribute + self.assertEqual(view.obj.db.some_test_attr, None) + attr_name = "some_test_attr" + attr_data = {"db_key": attr_name, "db_value": "test_value"} + response = self.client.post(view_url, data=attr_data) + self.assertEqual(response.status_code, 200, f"Response was: {response.data}") + self.assertEquals(view.obj.attributes.get(attr_name), "test_value") + # now test removing it + attr_data = {"db_key": attr_name} + response = self.client.post(view_url, data=attr_data) + self.assertEqual(response.status_code, 200, f"Response was: {response.data}") + self.assertEquals(view.obj.attributes.get(attr_name), None)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/api/views.html b/docs/latest/_modules/evennia/web/api/views.html new file mode 100644 index 0000000000..0a4837fc56 --- /dev/null +++ b/docs/latest/_modules/evennia/web/api/views.html @@ -0,0 +1,275 @@ + + + + + + + + evennia.web.api.views — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.api.views

+"""
+Views are the functions that are called by different url endpoints. The Django
+Rest Framework provides collections called 'ViewSets', which can generate a
+number of views for the common CRUD operations.
+
+"""
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework import status
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from rest_framework.viewsets import ModelViewSet
+
+from evennia.accounts.models import AccountDB
+from evennia.help.models import HelpEntry
+from evennia.objects.models import ObjectDB
+from evennia.objects.objects import DefaultCharacter, DefaultExit, DefaultRoom
+from evennia.scripts.models import ScriptDB
+from evennia.web.api import filters, serializers
+from evennia.web.api.permissions import EvenniaPermission
+
+
+
[docs]class GeneralViewSetMixin: + """ + Mixin for both typeclass- and non-typeclass entities. + + """ + +
[docs] def get_serializer_class(self): + """ + Allow different serializers for certain actions. + + """ + if self.action == "list": + if hasattr(self, "list_serializer_class"): + return self.list_serializer_class + return self.serializer_class
+ + +
[docs]class TypeclassViewSetMixin(GeneralViewSetMixin): + """ + This mixin adds some shared functionality to each viewset of a typeclass. They all use the same + permission classes and filter backend. You can override any of these in your own viewsets. + + The `set_atribute` action is an example of a custom action added to a + viewset. Based on the name of the method, it will create a default url_name + (used for reversing) and url_path. The 'pk' argument is automatically + passed to this action because it has a url path of the format <object + type>/:pk/set-attribute. The get_object method is automatically set in the + expected viewset classes that will inherit this, using the pk that's passed + along to retrieve the object. + + """ + + # permission classes determine who is authorized to call the view + permission_classes = [EvenniaPermission] + # the filter backend allows for retrieval views to have filter arguments passed to it, + # for example: mygame.com/api/objects?db_key=bob to find matches based on objects having a db_key of bob + filter_backends = [DjangoFilterBackend] + +
[docs] @action(detail=True, methods=["put", "post"]) + def set_attribute(self, request, pk=None): + """ + This action will set an attribute if the db_value is defined, or remove + it if no db_value is provided. + + """ + attr = serializers.AttributeSerializer(data=request.data) + obj = self.get_object() + if attr.is_valid(raise_exception=True): + key = attr.validated_data["db_key"] + value = attr.validated_data.get("db_value") + category = attr.validated_data.get("db_category") + attr_type = attr.validated_data.get("db_attrtype") + if attr_type == "nick": + handler = obj.nicks + else: + handler = obj.attributes + if value: + handler.add(key=key, value=value, category=category) + else: + handler.remove(key=key, category=category) + return Response( + serializers.AttributeSerializer(obj.db_attributes.all(), many=True).data, + status=status.HTTP_200_OK, + ) + return Response(attr.errors, status=status.HTTP_400_BAD_REQUEST)
+ + +
[docs]class ObjectDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """ + The Object is the parent for all in-game entities that have a location + (rooms, exits, characters etc). + + """ + + # An example of a basic viewset for all ObjectDB instances. It declares the + # serializer to use for both retrieving and changing/creating/deleting + # instances. Serializers are similar to django forms, used for the + # transmitting of data (typically json). + + serializer_class = serializers.ObjectDBSerializer + queryset = ObjectDB.objects.all() + filterset_class = filters.ObjectDBFilterSet + list_serializer_class = serializers.ObjectListSerializer
+ + +
[docs]class CharacterViewSet(ObjectDBViewSet): + """ + Characters are a type of Object commonly used as player avatars in-game. + + """ + + queryset = DefaultCharacter.objects.all_family()
+ + +
[docs]class RoomViewSet(ObjectDBViewSet): + """ + Rooms indicate discrete locations in-game. + + """ + + queryset = DefaultRoom.objects.all_family()
+ + +
[docs]class ExitViewSet(ObjectDBViewSet): + """ + Exits are objects with a destination and allows for traversing from one + location to another. + + """ + + queryset = DefaultExit.objects.all_family()
+ + +
[docs]class AccountDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """ + Accounts represent the players connected to the game + + """ + + serializer_class = serializers.AccountSerializer + queryset = AccountDB.objects.all() + filterset_class = filters.AccountDBFilterSet + list_serializer_class = serializers.AccountListSerializer
+ + +
[docs]class ScriptDBViewSet(TypeclassViewSetMixin, ModelViewSet): + """ + Scripts are meta-objects for storing system data, running timers etc. They + have no in-game existence. + + """ + + serializer_class = serializers.ScriptDBSerializer + queryset = ScriptDB.objects.all() + filterset_class = filters.ScriptDBFilterSet + list_serializer_class = serializers.ScriptListSerializer
+ + +
[docs]class HelpViewSet(GeneralViewSetMixin, ModelViewSet): + """ + Database-stored help entries. + Note that command auto-help and file-based help entries are not accessible this way. + + """ + + serializer_class = serializers.HelpSerializer + queryset = HelpEntry.objects.all() + filterset_class = filters.HelpFilterSet + list_serializer_class = serializers.HelpListSerializer
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/templatetags/addclass.html b/docs/latest/_modules/evennia/web/templatetags/addclass.html new file mode 100644 index 0000000000..65d870194a --- /dev/null +++ b/docs/latest/_modules/evennia/web/templatetags/addclass.html @@ -0,0 +1,122 @@ + + + + + + + + evennia.web.templatetags.addclass — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.templatetags.addclass

+from django import template
+
+register = template.Library()
+
+
+
[docs]@register.filter(name="addclass") +def addclass(field, given_class): + existing_classes = field.field.widget.attrs.get("class", None) + if existing_classes: + if existing_classes.find(given_class) == -1: + # if the given class doesn't exist in the existing classes + classes = existing_classes + " " + given_class + else: + classes = existing_classes + else: + classes = given_class + return field.as_widget(attrs={"class": classes})
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/utils/adminsite.html b/docs/latest/_modules/evennia/web/utils/adminsite.html new file mode 100644 index 0000000000..3df9088710 --- /dev/null +++ b/docs/latest/_modules/evennia/web/utils/adminsite.html @@ -0,0 +1,150 @@ + + + + + + + + evennia.web.utils.adminsite — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.utils.adminsite

+"""
+Custom Evennia admin-site, for better customization of the admin-site
+as a whole.
+
+This must be located outside of the admin/ folder because it must be imported
+before any of the app-data (which in turn must be imported in the `__init__.py`
+of that folder for Django to find them).
+
+"""
+
+from django.conf import settings
+from django.contrib import admin
+from django.contrib.admin import apps
+
+
+
[docs]class EvenniaAdminApp(apps.AdminConfig): + """ + This is imported in INSTALLED_APPS instead of django.contrib.admin. + + """ + + default_site = "evennia.web.utils.adminsite.EvenniaAdminSite"
+ + +
[docs]class EvenniaAdminSite(admin.AdminSite): + """ + The main admin site root (replacing the default from Django). When doing + admin.register in the admin/ folder, this is what is being registered to. + + """ + + site_header = "Evennia web admin" + +
[docs] def get_app_list(self, request, app_label=None): + app_list = super().get_app_list(request, app_label=app_label) + app_mapping = {app["app_label"]: app for app in app_list} + out = [ + app_mapping.pop(app_label) + for app_label in settings.DJANGO_ADMIN_APP_ORDER + if app_label in app_mapping + ] + for app in settings.DJANGO_ADMIN_APP_EXCLUDE: + app_mapping.pop(app, None) + out += app_mapping.values() + return out
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/utils/backends.html b/docs/latest/_modules/evennia/web/utils/backends.html new file mode 100644 index 0000000000..e1dd6e3a64 --- /dev/null +++ b/docs/latest/_modules/evennia/web/utils/backends.html @@ -0,0 +1,148 @@ + + + + + + + + evennia.web.utils.backends — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.utils.backends

+from django.contrib.auth import get_user_model
+from django.contrib.auth.backends import ModelBackend
+
+
+
[docs]class CaseInsensitiveModelBackend(ModelBackend): + """ + By default ModelBackend does case _sensitive_ username + authentication, which isn't what is generally expected. This + backend supports case insensitive username authentication. + + """ + +
[docs] def authenticate(self, request, username=None, password=None, autologin=None): + """ + Custom authenticate with bypass for auto-logins + + Args: + request (Request): Request object. + username (str, optional): Name of user to authenticate. + password (str, optional): Password of user + autologin (Account, optional): If given, assume this is + an already authenticated account and bypass authentication. + """ + if autologin: + # Note: Setting .backend on account is critical in order to + # be allowed to call django.auth.login(account) later. This + # is necessary for the auto-login feature of the webclient, + # but it's important to make sure Django doesn't change this + # requirement or the name of the property down the line. /Griatch + autologin.backend = "evennia.web.utils.backends.CaseInsensitiveModelBackend" + return autologin + else: + # In this case .backend will be assigned automatically + # somewhere along the way. + Account = get_user_model() + try: + account = Account.objects.get(username__iexact=username) + if account.check_password(password): + return account + else: + return None + except Account.DoesNotExist: + return None
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/utils/general_context.html b/docs/latest/_modules/evennia/web/utils/general_context.html new file mode 100644 index 0000000000..8a156bb9b7 --- /dev/null +++ b/docs/latest/_modules/evennia/web/utils/general_context.html @@ -0,0 +1,244 @@ + + + + + + + + evennia.web.utils.general_context — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.utils.general_context

+"""
+This file defines global variables that will always be available in a view
+context without having to repeatedly include it.
+
+For this to work, this file is included in the settings file, in the
+TEMPLATES["OPTIONS"]["context_processors"] list.
+
+"""
+
+
+import os
+
+from django.conf import settings
+
+from evennia.utils.utils import get_evennia_version
+
+# Setup lists of the most relevant apps so
+# the adminsite becomes more readable.
+
+GAME_NAME = None
+GAME_SLOGAN = None
+SERVER_VERSION = None
+SERVER_HOSTNAME = None
+
+REGISTER_ENABLED = None
+
+TELNET_ENABLED = None
+TELNET_PORTS = None
+TELNET_SSL_ENABLED = None
+TELNET_SSL_PORTS = None
+
+SSH_ENABLED = None
+SSH_PORTS = None
+
+WEBCLIENT_ENABLED = None
+WEBSOCKET_CLIENT_ENABLED = None
+WEBSOCKET_PORT = None
+WEBSOCKET_URL = None
+
+REST_API_ENABLED = False
+
+ACCOUNT_RELATED = ["Accounts"]
+GAME_ENTITIES = ["Objects", "Scripts", "Comms", "Help"]
+GAME_SETUP = ["Permissions", "Config"]
+CONNECTIONS = ["Irc"]
+WEBSITE = ["Flatpages", "News", "Sites"]
+
+
+
[docs]def load_game_settings(): + """ + Load and cache game settings. + + """ + global GAME_NAME, GAME_SLOGAN, SERVER_VERSION, SERVER_HOSTNAME + global REGISTER_ENABLED + global TELNET_ENABLED, TELNET_PORTS + global TELNET_SSL_ENABLED, TELNET_SSL_PORTS + global SSH_ENABLED, SSH_PORTS + global WEBCLIENT_ENABLED, WEBSOCKET_CLIENT_ENABLED, WEBSOCKET_PORT, WEBSOCKET_URL + global REST_API_ENABLED + + try: + GAME_NAME = settings.SERVERNAME.strip() + except AttributeError: + GAME_NAME = "Evennia" + SERVER_VERSION = get_evennia_version() + try: + GAME_SLOGAN = settings.GAME_SLOGAN.strip() + except AttributeError: + GAME_SLOGAN = SERVER_VERSION + SERVER_HOSTNAME = settings.SERVER_HOSTNAME + + REGISTER_ENABLED = settings.NEW_ACCOUNT_REGISTRATION_ENABLED + + TELNET_ENABLED = settings.TELNET_ENABLED + TELNET_PORTS = settings.TELNET_PORTS + TELNET_SSL_ENABLED = settings.SSL_ENABLED + TELNET_SSL_PORTS = settings.SSL_PORTS + + SSH_ENABLED = settings.SSH_ENABLED + SSH_PORTS = settings.SSH_PORTS + + WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED + WEBSOCKET_CLIENT_ENABLED = settings.WEBSOCKET_CLIENT_ENABLED + # if we are working through a proxy or uses docker port-remapping, the webclient port encoded + # in the webclient should be different than the one the server expects. Use the environment + # variable WEBSOCKET_CLIENT_PROXY_PORT if this is the case. + WEBSOCKET_PORT = int( + os.environ.get("WEBSOCKET_CLIENT_PROXY_PORT", settings.WEBSOCKET_CLIENT_PORT) + ) + # this is determined dynamically by the client and is less of an issue + WEBSOCKET_URL = settings.WEBSOCKET_CLIENT_URL + + REST_API_ENABLED = settings.REST_API_ENABLED
+ + +load_game_settings() + + +# The main context processor function +
[docs]def general_context(request): + """ + Returns common Evennia-related context stuff, which is automatically added + to context of all views. + + """ + account = None + if request.user.is_authenticated: + account = request.user + + puppet = None + if account and request.session.get("puppet"): + pk = int(request.session.get("puppet")) + puppet = next((x for x in account.characters if x.pk == pk), None) + + return { + "account": account, + "puppet": puppet, + "game_name": GAME_NAME, + "game_slogan": GAME_SLOGAN, + "server_hostname": SERVER_HOSTNAME, + "evennia_userapps": ACCOUNT_RELATED, + "evennia_entityapps": GAME_ENTITIES, + "evennia_setupapps": GAME_SETUP, + "evennia_connectapps": CONNECTIONS, + "evennia_websiteapps": WEBSITE, + "register_enabled": REGISTER_ENABLED, + "telnet_enabled": TELNET_ENABLED, + "telnet_ports": TELNET_PORTS, + "telnet_ssl_enabled": TELNET_SSL_ENABLED, + "telnet_ssl_ports": TELNET_SSL_PORTS, + "ssh_enabled": SSH_ENABLED, + "ssh_ports": SSH_ENABLED, + "webclient_enabled": WEBCLIENT_ENABLED, + "websocket_enabled": WEBSOCKET_CLIENT_ENABLED, + "websocket_port": WEBSOCKET_PORT, + "websocket_url": WEBSOCKET_URL, + "rest_api_enabled": REST_API_ENABLED, + }
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/utils/middleware.html b/docs/latest/_modules/evennia/web/utils/middleware.html new file mode 100644 index 0000000000..1254a61f3d --- /dev/null +++ b/docs/latest/_modules/evennia/web/utils/middleware.html @@ -0,0 +1,192 @@ + + + + + + + + evennia.web.utils.middleware — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.utils.middleware

+from django.contrib.auth import authenticate, login
+
+from evennia.accounts.models import AccountDB
+from evennia.utils import logger, ip_from_request
+
+
+
[docs]class OriginIpMiddleware: + """ + This Django Middleware simply sets the request.origin_ip attribute to what is + respected by the Evennia Server, taking into account settings.UPSTREAM_IPS. + """ + +
[docs] def __init__(self, get_response): + self.get_response = get_response
+ + def __call__(self, request): + request.origin_ip = ip_from_request(request) + return self.get_response(request)
+ + +
[docs]class SharedLoginMiddleware(object): + """ + Handle the shared login between website and webclient. + + """ + +
[docs] def __init__(self, get_response): + # One-time configuration and initialization. + self.get_response = get_response
+ + def __call__(self, request): + # Code to be executed for each request before + # the view (and later middleware) are called. + + # Synchronize credentials between webclient and website + # Must be performed *before* rendering the view (issue #1723) + self.make_shared_login(request) + + # Process view + response = self.get_response(request) + + # Code to be executed for each request/response after + # the view is called. + + # Return processed view + return response + +
[docs] @classmethod + def make_shared_login(cls, request): + csession = request.session + account = request.user + website_uid = csession.get("website_authenticated_uid", None) + webclient_uid = csession.get("webclient_authenticated_uid", None) + + if not csession.session_key: + # this is necessary to build the sessid key + csession.save() + + if account.is_authenticated: + # Logged into website + if website_uid is None: + # fresh website login (just from login page) + csession["website_authenticated_uid"] = account.id + if webclient_uid is None: + # auto-login web client + csession["webclient_authenticated_uid"] = account.id + + elif webclient_uid: + # Not logged into website, but logged into webclient + if website_uid is None: + csession["website_authenticated_uid"] = account.id + account = AccountDB.objects.get(id=webclient_uid) + try: + # calls our custom authenticate, in web/utils/backend.py + authenticate(autologin=account) + login(request, account) + except AttributeError: + logger.log_trace() + + if csession.get("webclient_authenticated_uid", None): + # set a nonce to prevent the webclient from erasing the webclient_authenticated_uid value + csession["webclient_authenticated_nonce"] = ( + csession.get("webclient_authenticated_nonce", 0) + 1 + ) + # wrap around to prevent integer overflows + if csession["webclient_authenticated_nonce"] > 32: + csession["webclient_authenticated_nonce"] = 0
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/utils/tests.html b/docs/latest/_modules/evennia/web/utils/tests.html new file mode 100644 index 0000000000..61ca566d3e --- /dev/null +++ b/docs/latest/_modules/evennia/web/utils/tests.html @@ -0,0 +1,160 @@ + + + + + + + + evennia.web.utils.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.utils.tests

+from django.contrib.auth.models import AnonymousUser
+from django.test import RequestFactory, TestCase
+from mock import MagicMock, patch
+
+from . import general_context
+
+
+
[docs]class TestGeneralContext(TestCase): + maxDiff = None + +
[docs] @patch("evennia.web.utils.general_context.GAME_NAME", "test_name") + @patch("evennia.web.utils.general_context.GAME_SLOGAN", "test_game_slogan") + @patch("evennia.web.utils.general_context.REGISTER_ENABLED", "register_enabled_testvalue") + @patch( + "evennia.web.utils.general_context.WEBSOCKET_CLIENT_ENABLED", + "websocket_client_enabled_testvalue", + ) + @patch("evennia.web.utils.general_context.WEBCLIENT_ENABLED", "webclient_enabled_testvalue") + @patch("evennia.web.utils.general_context.WEBSOCKET_PORT", "websocket_client_port_testvalue") + @patch("evennia.web.utils.general_context.WEBSOCKET_URL", "websocket_client_url_testvalue") + @patch("evennia.web.utils.general_context.REST_API_ENABLED", True) + def test_general_context(self): + request = RequestFactory().get("/") + request.user = AnonymousUser() + request.session = {"account": None, "puppet": None} + + response = general_context.general_context(request) + + self.assertEqual( + response, + { + "account": None, + "puppet": None, + "game_name": "test_name", + "game_slogan": "test_game_slogan", + "evennia_userapps": ["Accounts"], + "evennia_entityapps": ["Objects", "Scripts", "Comms", "Help"], + "evennia_setupapps": ["Permissions", "Config"], + "evennia_connectapps": ["Irc"], + "evennia_websiteapps": ["Flatpages", "News", "Sites"], + "register_enabled": "register_enabled_testvalue", + "webclient_enabled": "webclient_enabled_testvalue", + "websocket_enabled": "websocket_client_enabled_testvalue", + "websocket_port": "websocket_client_port_testvalue", + "websocket_url": "websocket_client_url_testvalue", + "rest_api_enabled": True, + "server_hostname": "localhost", + "ssh_enabled": False, + "ssh_ports": False, + "telnet_enabled": True, + "telnet_ports": [4000], + "telnet_ssl_enabled": False, + "telnet_ssl_ports": [4003], + }, + )
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/webclient/views.html b/docs/latest/_modules/evennia/web/webclient/views.html new file mode 100644 index 0000000000..611a308e42 --- /dev/null +++ b/docs/latest/_modules/evennia/web/webclient/views.html @@ -0,0 +1,135 @@ + + + + + + + + evennia.web.webclient.views — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.webclient.views

+"""
+This contains a simple view for rendering the webclient
+page and serve it eventual static content.
+
+"""
+
+from django.conf import settings
+from django.contrib.auth import authenticate, login
+from django.http import Http404
+from django.shortcuts import render
+
+from evennia.accounts.models import AccountDB
+from evennia.utils import logger
+
+
+
[docs]def webclient(request): + """ + Webclient page template loading. + + """ + # auto-login is now handled by evennia.web.utils.middleware + + # check if webclient should be enabled + if not settings.WEBCLIENT_ENABLED: + raise Http404 + + # make sure to store the browser session's hash so the webclient can get to it! + pagevars = {"browser_sessid": request.session.session_key} + + return render(request, "webclient.html", pagevars)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/forms.html b/docs/latest/_modules/evennia/web/website/forms.html new file mode 100644 index 0000000000..5dcdba288d --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/forms.html @@ -0,0 +1,284 @@ + + + + + + + + evennia.web.website.forms — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.forms

+from django import forms
+from django.conf import settings
+from django.contrib.auth.forms import UserCreationForm, UsernameField
+from django.forms import ModelForm
+from django.utils.html import escape
+
+from evennia.utils import class_from_module
+
+
+
[docs]class EvenniaForm(forms.Form): + """ + This is a stock Django form, but modified so that all values provided + through it are escaped (sanitized). Validation is performed by the fields + you define in the form. + + This has little to do with Evennia itself and is more general web security- + related. + + https://www.owasp.org/index.php/Input_Validation_Cheat_Sheet#Goals_of_Input_Validation + + """ + +
[docs] def clean(self): + """ + Django hook. Performed on form submission. + + Returns: + cleaned (dict): Dictionary of key:value pairs submitted on the form. + + """ + # Call parent function + cleaned = super().clean() + + # Escape all values provided by user + cleaned = {k: escape(v) for k, v in cleaned.items()} + return cleaned
+ + +
[docs]class AccountForm(UserCreationForm): + """ + This is a generic Django form tailored to the Account model. + + In this incarnation it does not allow getting/setting of attributes, only + core User model fields (username, email, password). + + """ + +
[docs] class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ + + # The model/typeclass this form creates + model = class_from_module( + settings.BASE_ACCOUNT_TYPECLASS, fallback=settings.FALLBACK_ACCOUNT_TYPECLASS + ) + + # The fields to display on the form, in the given order + fields = ("username", "email") + + # Any overrides of field classes + field_classes = {"username": UsernameField}
+ + # Username is collected as part of the core UserCreationForm, so we just need + # to add a field to (optionally) capture email. + email = forms.EmailField( + help_text="A valid email address. Optional; used for password resets.", required=False + )
+ + +
[docs]class ObjectForm(EvenniaForm, ModelForm): + """ + This is a Django form for generic Evennia Objects that allows modification + of attributes when called from a descendent of ObjectUpdate or ObjectCreate + views. + + It defines no fields by default; you have to do that by extending this class + and defining what fields you want to be recorded. See the CharacterForm for + a simple example of how to do this. + + """ + +
[docs] class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ + + # The model/typeclass this form creates + model = class_from_module( + settings.BASE_OBJECT_TYPECLASS, fallback=settings.FALLBACK_OBJECT_TYPECLASS + ) + + # The fields to display on the form, in the given order + fields = ("db_key",) + + # This lets us rename ugly db-specific keys to something more human + labels = {"db_key": "Name"}
+ + +
[docs]class CharacterForm(ObjectForm): + """ + This is a Django form for Evennia Character objects. + + Since Evennia characters only have one attribute by default, this form only + defines a field for that single attribute. The names of fields you define should + correspond to their names as stored in the dbhandler; you can display + 'prettier' versions of the fieldname on the form using the 'label' kwarg. + + The basic field types are CharFields and IntegerFields, which let you enter + text and numbers respectively. IntegerFields have some neat validation tricks + they can do, like mandating values fall within a certain range. + + For example, a complete "age" field (which stores its value to + `character.db.age` might look like: + + age = forms.IntegerField( + label="Your Age", + min_value=18, max_value=9000, + help_text="Years since your birth.") + + Default input fields are generic single-line text boxes. You can control what + sort of input field users will see by specifying a "widget." An example of + this is used for the 'desc' field to show a Textarea box instead of a Textbox. + + For help in building out your form, please see: + https://docs.djangoproject.com/en/4.1/topics/forms/#building-a-form-in-django + + For more information on fields and their capabilities, see: + https://docs.djangoproject.com/en/4.1/ref/forms/fields/ + + For more on widgets, see: + https://docs.djangoproject.com/en/4.1/ref/forms/widgets/ + + """ + +
[docs] class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ + + # Get the correct object model + model = class_from_module( + settings.BASE_CHARACTER_TYPECLASS, fallback=settings.FALLBACK_CHARACTER_TYPECLASS + ) + + # Allow entry of the 'key' field + fields = ("db_key",) + + # Rename 'key' to something more intelligible + labels = {"db_key": "Name"}
+ + # Fields pertaining to configurable attributes on the Character object. + desc = forms.CharField( + label="Description", + max_length=2048, + required=False, + widget=forms.Textarea(attrs={"rows": 3}), + help_text="A brief description of your character.", + )
+ + +
[docs]class CharacterUpdateForm(CharacterForm): + """ + This is a Django form for updating Evennia Character objects. + + By default it is the same as the CharacterForm, but if there are circumstances + in which you don't want to let players edit all the same attributes they had + access to during creation, you can redefine this form with those fields you do + wish to allow. + + """ + + pass
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/tests.html b/docs/latest/_modules/evennia/web/website/tests.html new file mode 100644 index 0000000000..26e36ee3f9 --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/tests.html @@ -0,0 +1,471 @@ + + + + + + + + evennia.web.website.tests — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.tests

+from django.conf import settings
+from django.test import Client, override_settings
+from django.urls import reverse
+from django.utils.text import slugify
+
+from evennia.help import filehelp
+from evennia.utils import class_from_module
+from evennia.utils.create import create_help_entry
+from evennia.utils.test_resources import BaseEvenniaTest
+
+_FILE_HELP_ENTRIES = None
+
+
+
[docs]class EvenniaWebTest(BaseEvenniaTest): + # Use the same classes the views are expecting + account_typeclass = settings.BASE_ACCOUNT_TYPECLASS + object_typeclass = settings.BASE_OBJECT_TYPECLASS + character_typeclass = settings.BASE_CHARACTER_TYPECLASS + exit_typeclass = settings.BASE_EXIT_TYPECLASS + room_typeclass = settings.BASE_ROOM_TYPECLASS + script_typeclass = settings.BASE_SCRIPT_TYPECLASS + channel_typeclass = settings.BASE_CHANNEL_TYPECLASS + + # Default named url + url_name = "index" + + # Response to expect for unauthenticated requests + unauthenticated_response = 200 + + # Response to expect for authenticated requests + authenticated_response = 200 + +
[docs] def setUp(self): + super().setUp() + + # Add chars to account rosters + self.account.characters.add(self.char1) + self.account2.characters.add(self.char2) + + for account in (self.account, self.account2): + # Demote accounts to Player permissions + account.permissions.add("Player") + account.permissions.remove("Developer") + + # Grant permissions to chars + for char in account.characters: + char.locks.add("edit:id(%s) or perm(Admin)" % account.pk) + char.locks.add("delete:id(%s) or perm(Admin)" % account.pk) + char.locks.add("view:all()")
+ +
[docs] def test_valid_chars(self): + "Make sure account has playable characters" + self.assertTrue(self.char1 in self.account.characters) + self.assertTrue(self.char2 in self.account2.characters)
+ +
[docs] def get_kwargs(self): + return {}
+ +
[docs] def test_get(self): + # Try accessing page while not logged in + response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs())) + self.assertEqual(response.status_code, self.unauthenticated_response)
+ +
[docs] def login(self): + return self.client.login(username="TestAccount", password="testpassword")
+ +
[docs] def test_get_authenticated(self): + logged_in = self.login() + self.assertTrue(logged_in, "Account failed to log in!") + + # Try accessing page while logged in + response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True) + + self.assertEqual(response.status_code, self.authenticated_response)
+ + +# ------------------------------------------------------------------------------ + + +
[docs]class AdminTest(EvenniaWebTest): + url_name = "django_admin" + unauthenticated_response = 302
+ + +
[docs]class IndexTest(EvenniaWebTest): + url_name = "index"
+ + +
[docs]class RegisterTest(EvenniaWebTest): + url_name = "register"
+ + +
[docs]class LoginTest(EvenniaWebTest): + url_name = "login"
+ + +
[docs]class LogoutTest(EvenniaWebTest): + url_name = "logout"
+ + +
[docs]class PasswordResetTest(EvenniaWebTest): + url_name = "password_change" + unauthenticated_response = 302
+ + +
[docs]class WebclientTest(EvenniaWebTest): + url_name = "webclient:index" + +
[docs] @override_settings(WEBCLIENT_ENABLED=True) + def test_get(self): + self.authenticated_response = 200 + self.unauthenticated_response = 200 + super().test_get()
+ +
[docs] @override_settings(WEBCLIENT_ENABLED=False) + def test_get_disabled(self): + self.authenticated_response = 404 + self.unauthenticated_response = 404 + super().test_get()
+ + +
[docs]class ChannelListTest(EvenniaWebTest): + url_name = "channels"
+ + +
[docs]class ChannelDetailTest(EvenniaWebTest): + url_name = "channel-detail" + +
[docs] def setUp(self): + super().setUp() + + klass = class_from_module( + self.channel_typeclass, fallback=settings.FALLBACK_CHANNEL_TYPECLASS + ) + + # Create a channel + klass.create("demo")
+ +
[docs] def get_kwargs(self): + return {"slug": slugify("demo")}
+ + +
[docs]class HelpListTest(EvenniaWebTest): + url_name = "help"
+ + +HELP_ENTRY_DICTS = [ + {"key": "unit test file entry", "category": "General", "text": "cache test file entry text"} +] + + +
[docs]class HelpDetailTest(EvenniaWebTest): + url_name = "help-entry-detail" + +
[docs] def setUp(self): + super().setUp() + + # create a db help entry + create_help_entry("unit test db entry", "unit test db entry text", category="General")
+ +
[docs] def get_kwargs(self): + return {"category": slugify("general"), "topic": slugify("unit test db entry")}
+ +
[docs] def test_view(self): + response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True) + self.assertEqual(response.context["entry_text"], "unit test db entry text")
+ +
[docs] def test_object_cache(self): + # clear file help entries, use local HELP_ENTRY_DICTS to recreate new entries + global _FILE_HELP_ENTRIES + if _FILE_HELP_ENTRIES is None: + from evennia.help.filehelp import FILE_HELP_ENTRIES as _FILE_HELP_ENTRIES + help_module = "evennia.web.website.tests" + self.file_help_store = _FILE_HELP_ENTRIES.__init__(help_file_modules=[help_module]) + + # request access to an entry + response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True) + self.assertEqual(response.context["entry_text"], "unit test db entry text") + # request a second entry, verifing the cached object is not provided on a new topic request + entry_two_args = {"category": slugify("general"), "topic": slugify("unit test file entry")} + response = self.client.get(reverse(self.url_name, kwargs=entry_two_args), follow=True) + self.assertEqual(response.context["entry_text"], "cache test file entry text")
+ + +
[docs]class HelpLockedDetailTest(EvenniaWebTest): + url_name = "help-entry-detail" + +
[docs] def setUp(self): + super().setUp() + + # create a db entry with a lock + self.db_help_entry = create_help_entry( + "unit test locked topic", + "unit test locked entrytext", + category="General", + locks="read:perm(Developer)", + )
+ +
[docs] def get_kwargs(self): + return {"category": slugify("general"), "topic": slugify("unit test locked topic")}
+ +
[docs] def test_locked_entry(self): + # request access to an entry for permission the account does not have + response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True) + self.assertEqual(response.context["entry_text"], "Failed to find entry.")
+ +
[docs] def test_lock_with_perm(self): + # log TestAccount in, grant permission required, read the entry + self.login() + self.account.permissions.add("Developer") + response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True) + self.assertEqual(response.context["entry_text"], "unit test locked entrytext")
+ + +
[docs]class CharacterCreateView(EvenniaWebTest): + url_name = "character-create" + unauthenticated_response = 302 + +
[docs] @override_settings(MAX_NR_CHARACTERS=1) + def test_valid_access_multisession_0(self): + "Account1 with no characters should be able to create a new one" + self.account.characters.remove(self.char1) + + # Login account + self.login() + + # Post data for a new character + data = {"db_key": "gannon", "desc": "Some dude."} + + response = self.client.post(reverse(self.url_name), data=data, follow=True) + self.assertEqual(response.status_code, 200) + + # Make sure the character was actually created + self.assertTrue( + len(self.account.characters) == 1, + "Account only has the following characters attributed to it: %s" + % self.account.characters, + )
+ +
[docs] @override_settings(MAX_NR_CHARACTERS=5) + def test_valid_access_multisession_2(self): + "Account1 should be able to create multiple new characters" + # Login account + self.login() + + # Post data for a new character + data = {"db_key": "gannon", "desc": "Some dude."} + + response = self.client.post(reverse(self.url_name), data=data, follow=True) + self.assertEqual(response.status_code, 200) + + # Make sure the character was actually created + self.assertTrue( + len(self.account.characters) > 1, + "Account only has the following characters attributed to it: %s" + % self.account.characters, + )
+ + +
[docs]class CharacterPuppetView(EvenniaWebTest): + url_name = "character-puppet" + unauthenticated_response = 302 + +
[docs] def get_kwargs(self): + return {"pk": self.char1.pk, "slug": slugify(self.char1.name)}
+ +
[docs] def test_invalid_access(self): + "Account1 should not be able to puppet Account2:Char2" + # Login account + self.login() + + # Try to access puppet page for char2 + kwargs = {"pk": self.char2.pk, "slug": slugify(self.char2.name)} + response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True) + self.assertTrue( + response.status_code >= 400, + "Invalid access should return a 4xx code-- either obj not found or permission denied!" + " (Returned %s)" % response.status_code, + )
+ + +
[docs]class CharacterListView(EvenniaWebTest): + url_name = "characters" + unauthenticated_response = 302
+ + +
[docs]class CharacterManageView(EvenniaWebTest): + url_name = "character-manage" + unauthenticated_response = 302
+ + +
[docs]class CharacterUpdateView(EvenniaWebTest): + url_name = "character-update" + unauthenticated_response = 302 + +
[docs] def get_kwargs(self): + return {"pk": self.char1.pk, "slug": slugify(self.char1.name)}
+ +
[docs] def test_valid_access(self): + "Account1 should be able to update Account1:Char1" + # Login account + self.login() + + # Try to access update page for char1 + response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True) + self.assertEqual(response.status_code, 200) + + # Try to update char1 desc + data = {"db_key": self.char1.db_key, "desc": "Just a regular type of dude."} + response = self.client.post( + reverse(self.url_name, kwargs=self.get_kwargs()), data=data, follow=True + ) + self.assertEqual(response.status_code, 200) + + # Make sure the change was made successfully + self.assertEqual(self.char1.db.desc, data["desc"])
+ +
[docs] def test_invalid_access(self): + "Account1 should not be able to update Account2:Char2" + # Login account + self.login() + + # Try to access update page for char2 + kwargs = {"pk": self.char2.pk, "slug": slugify(self.char2.name)} + response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True) + self.assertEqual(response.status_code, 403)
+ + +
[docs]class CharacterDeleteView(EvenniaWebTest): + url_name = "character-delete" + unauthenticated_response = 302 + +
[docs] def get_kwargs(self): + return {"pk": self.char1.pk, "slug": slugify(self.char1.name)}
+ +
[docs] def test_valid_access(self): + "Account1 should be able to delete Account1:Char1" + # Login account + self.login() + + # Try to access delete page for char1 + response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True) + self.assertEqual(response.status_code, 200) + + # Proceed with deleting it + data = {"value": "yes"} + response = self.client.post( + reverse(self.url_name, kwargs=self.get_kwargs()), data=data, follow=True + ) + self.assertEqual(response.status_code, 200) + + # Make sure it deleted + self.assertFalse( + self.char1 in self.account.characters, + "Char1 is still in Account playable characters list.", + )
+ +
[docs] def test_invalid_access(self): + "Account1 should not be able to delete Account2:Char2" + # Login account + self.login() + + # Try to access delete page for char2 + kwargs = {"pk": self.char2.pk, "slug": slugify(self.char2.name)} + response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True) + self.assertEqual(response.status_code, 403)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/views/accounts.html b/docs/latest/_modules/evennia/web/website/views/accounts.html new file mode 100644 index 0000000000..4bfe307f58 --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/views/accounts.html @@ -0,0 +1,176 @@ + + + + + + + + evennia.web.website.views.accounts — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.views.accounts

+"""
+Views for managing accounts.
+
+"""
+
+
+from django.conf import settings
+from django.contrib import messages
+from django.http import HttpResponseRedirect
+from django.urls import reverse_lazy
+
+from evennia.utils import class_from_module
+from evennia.web.website import forms
+
+from .mixins import EvenniaCreateView, TypeclassMixin
+
+
+
[docs]class AccountMixin(TypeclassMixin): + """ + This is used to grant abilities to classes it is added to. + + Any view class with this in its inheritance list will be modified to work + with Account objects instead of generic Objects or otherwise. + + """ + + # -- Django constructs -- + model = class_from_module( + settings.BASE_ACCOUNT_TYPECLASS, fallback=settings.FALLBACK_ACCOUNT_TYPECLASS + ) + form_class = forms.AccountForm
+ + +
[docs]class AccountCreateView(AccountMixin, EvenniaCreateView): + """ + Account creation view. + + """ + + # -- Django constructs -- + template_name = "website/registration/register.html" + success_url = reverse_lazy("login") + +
[docs] def form_valid(self, form): + """ + Django hook, modified for Evennia. + + This hook is called after a valid form is submitted. + + When an account creation form is submitted and the data is deemed valid, + proceeds with creating the Account object. + + """ + # Get values provided + username = form.cleaned_data["username"] + password = form.cleaned_data["password1"] + email = form.cleaned_data.get("email", "") + + # Create account. This also runs all validations on the username/password. + account, errs = self.typeclass.create(username=username, password=password, email=email) + + if not account: + # password validation happens earlier, only username checks appear here. + form.add_error("username", ", ".join(errs)) + return self.form_invalid(form) + else: + # Inform user of success + messages.success( + self.request, f"Your account '{account.name}' was successfully created!" + ) + return HttpResponseRedirect(self.success_url)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/views/channels.html b/docs/latest/_modules/evennia/web/website/views/channels.html new file mode 100644 index 0000000000..0d333c92b7 --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/views/channels.html @@ -0,0 +1,284 @@ + + + + + + + + evennia.web.website.views.channels — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.views.channels

+"""
+Views for managing channels.
+
+"""
+
+from django.conf import settings
+from django.db.models.functions import Lower
+from django.http import HttpResponseBadRequest
+from django.utils.text import slugify
+from django.views.generic import ListView
+
+from evennia.utils import class_from_module
+from evennia.utils.logger import tail_log_file
+
+from .mixins import TypeclassMixin
+from .objects import ObjectDetailView
+
+
+
[docs]class ChannelMixin(TypeclassMixin): + """ + This is a "mixin", a modifier of sorts. + + Any view class with this in its inheritance list will be modified to work + with HelpEntry objects instead of generic Objects or otherwise. + + """ + + # -- Django constructs -- + model = class_from_module( + settings.BASE_CHANNEL_TYPECLASS, fallback=settings.FALLBACK_CHANNEL_TYPECLASS + ) + + # -- Evennia constructs -- + page_title = "Channels" + + # What lock type to check for the requesting user, authenticated or not. + # https://github.com/evennia/evennia/wiki/Locks#valid-access_types + access_type = "listen" + +
[docs] def get_queryset(self): + """ + Django hook; here we want to return a list of only those Channels + and other documentation that the current user is allowed to see. + + Returns: + queryset (QuerySet): List of Channels available to the user. + + """ + account = self.request.user + + # Get list of all Channels + channels = self.typeclass.objects.all().iterator() + + # Now figure out which ones the current user is allowed to see + bucket = [channel.id for channel in channels if channel.access(account, "listen")] + + # Re-query and set a sorted list + filtered = self.typeclass.objects.filter(id__in=bucket).order_by(Lower("db_key")) + + return filtered
+ + +
[docs]class ChannelListView(ChannelMixin, ListView): + """ + Returns a list of channels that can be viewed by a user, authenticated + or not. + + """ + + # -- Django constructs -- + paginate_by = 100 + template_name = "website/channel_list.html" + + # -- Evennia constructs -- + page_title = "Channel Index" + + max_popular = 10 + +
[docs] def get_context_data(self, **kwargs): + """ + Django hook; we override it to calculate the most popular channels. + + Returns: + context (dict): Django context object + + """ + context = super().get_context_data(**kwargs) + + # Calculate which channels are most popular + context["most_popular"] = sorted( + list(self.get_queryset()), + key=lambda channel: len(channel.subscriptions.all()), + reverse=True, + )[: self.max_popular] + + return context
+ + +
[docs]class ChannelDetailView(ChannelMixin, ObjectDetailView): + """ + Returns the log entries for a given channel. + + """ + + # -- Django constructs -- + template_name = "website/channel_detail.html" + + # -- Evennia constructs -- + # What attributes of the object you wish to display on the page. Model-level + # attributes will take precedence over identically-named db.attributes! + # The order you specify here will be followed. + attributes = ["name"] + + # How many log entries to read and display. + max_num_lines = 10000 + +
[docs] def get_context_data(self, **kwargs): + """ + Django hook; before we can display the channel logs, we need to recall + the logfile and read its lines. + + Returns: + context (dict): Django context object + + """ + # Get the parent context object, necessary first step + context = super().get_context_data(**kwargs) + channel = self.object + + # Get the filename this Channel is recording to + filename = channel.get_log_filename() + + # Split log entries so we can filter by time + bucket = [] + for log in (x.strip() for x in tail_log_file(filename, 0, self.max_num_lines)): + if not log: + continue + try: + time, msg = log.split(" [-] ") + time_key = time.split(":")[0] + except ValueError: + # malformed log line. Skip. + continue + + bucket.append({"key": time_key, "timestamp": time, "message": msg}) + + # Add the processed entries to the context + context["object_list"] = bucket + + # Get a list of unique timestamps by hour and sort them + context["object_filters"] = sorted(set([x["key"] for x in bucket])) + + return context
+ +
[docs] def get_object(self, queryset=None): + """ + Override of Django hook that retrieves an object by slugified channel + name. + + Returns: + channel (Channel): Channel requested in the URL. + + """ + # Get the queryset for the help entries the user can access + if not queryset: + queryset = self.get_queryset() + + # Find the object in the queryset + channel = slugify(self.kwargs.get("slug", "")) + obj = next((x for x in queryset if slugify(x.db_key) == channel), None) + + # Check if this object was requested in a valid manner + if not obj: + raise HttpResponseBadRequest( + "No %(verbose_name)s found matching the query" + % {"verbose_name": queryset.model._meta.verbose_name} + ) + + return obj
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/views/characters.html b/docs/latest/_modules/evennia/web/website/views/characters.html new file mode 100644 index 0000000000..987e5a701a --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/views/characters.html @@ -0,0 +1,367 @@ + + + + + + + + evennia.web.website.views.characters — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.views.characters

+"""
+Views for manipulating Characters (children of Objects often used for
+puppeting).
+
+"""
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.db.models.functions import Lower
+from django.http import HttpResponseRedirect
+from django.urls import reverse_lazy
+from django.views.generic import ListView
+from django.views.generic.base import RedirectView
+
+from evennia.utils import class_from_module
+from evennia.web.website import forms
+
+from .mixins import TypeclassMixin
+from .objects import (
+    ObjectCreateView,
+    ObjectDeleteView,
+    ObjectDetailView,
+    ObjectUpdateView,
+)
+
+
+
[docs]class CharacterMixin(TypeclassMixin): + """ + This is a "mixin", a modifier of sorts. + + Any view class with this in its inheritance list will be modified to work + with Character objects instead of generic Objects or otherwise. + + """ + + # -- Django constructs -- + model = class_from_module( + settings.BASE_CHARACTER_TYPECLASS, fallback=settings.FALLBACK_CHARACTER_TYPECLASS + ) + form_class = forms.CharacterForm + success_url = reverse_lazy("character-manage") + +
[docs] def get_queryset(self): + """ + This method will override the Django get_queryset method to only + return a list of characters associated with the current authenticated + user. + + Returns: + queryset (QuerySet): Django queryset for use in the given view. + + """ + # Get IDs of characters owned by account + account = self.request.user + ids = [getattr(x, "id") for x in account.characters if x] + + # Return a queryset consisting of those characters + return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key"))
+ + +
[docs]class CharacterListView(LoginRequiredMixin, CharacterMixin, ListView): + """ + This view provides a mechanism by which a logged-in player can view a list + of all other characters. + + This view requires authentication by default as a nominal effort to prevent + human stalkers and automated bots/scrapers from harvesting data on your users. + + """ + + # -- Django constructs -- + template_name = "website/character_list.html" + paginate_by = 100 + + # -- Evennia constructs -- + page_title = "Character List" + access_type = "view" + +
[docs] def get_queryset(self): + """ + This method will override the Django get_queryset method to return a + list of all characters (filtered/sorted) instead of just those limited + to the account. + + Returns: + queryset (QuerySet): Django queryset for use in the given view. + + """ + account = self.request.user + + # Return a queryset consisting of characters the user is allowed to + # see. + ids = [ + obj.id for obj in self.typeclass.objects.all() if obj.access(account, self.access_type) + ] + + return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key"))
+ + +
[docs]class CharacterPuppetView(LoginRequiredMixin, CharacterMixin, RedirectView, ObjectDetailView): + """ + This view provides a mechanism by which a logged-in player can "puppet" one + of their characters within the context of the website. + + It also ensures that any user attempting to puppet something is logged in, + and that their intended puppet is one that they own. + + """ + +
[docs] def get_redirect_url(self, *args, **kwargs): + """ + Django hook. + + This view returns the URL to which the user should be redirected after + a passed or failed puppet attempt. + + Returns: + url (str): Path to post-puppet destination. + + """ + # Get the requested character, if it belongs to the authenticated user + char = self.get_object() + + # Get the page the user came from + next_page = self.request.GET.get("next", self.success_url) + + if char: + # If the account owns the char, store the ID of the char in the + # Django request's session (different from Evennia session!). + # We do this because characters don't serialize well. + self.request.session["puppet"] = int(char.pk) + messages.success(self.request, "You become '%s'!" % char) + else: + # If the puppeting failed, clear out the cached puppet value + self.request.session["puppet"] = None + messages.error(self.request, "You cannot become '%s'." % char) + + return next_page
+ + +
[docs]class CharacterManageView(LoginRequiredMixin, CharacterMixin, ListView): + """ + This view provides a mechanism by which a logged-in player can browse, + edit, or delete their own characters. + + """ + + # -- Django constructs -- + paginate_by = 10 + template_name = "website/character_manage_list.html" + + # -- Evennia constructs -- + page_title = "Manage Characters"
+ + +
[docs]class CharacterUpdateView(CharacterMixin, ObjectUpdateView): + """ + This view provides a mechanism by which a logged-in player (enforced by + ObjectUpdateView) can edit the attributes of a character they own. + + """ + + # -- Django constructs -- + form_class = forms.CharacterUpdateForm + template_name = "website/character_form.html"
+ + +
[docs]class CharacterDetailView(CharacterMixin, ObjectDetailView): + """ + This view provides a mechanism by which a user can view the attributes of + a character, owned by them or not. + + """ + + # -- Django constructs -- + template_name = "website/object_detail.html" + + # -- Evennia constructs -- + # What attributes to display for this object + attributes = ["name", "desc"] + access_type = "view" + +
[docs] def get_queryset(self): + """ + This method will override the Django get_queryset method to return a + list of all characters the user may access. + + Returns: + queryset (QuerySet): Django queryset for use in the given view. + + """ + account = self.request.user + + # Return a queryset consisting of characters the user is allowed to + # see. + ids = [ + obj.id for obj in self.typeclass.objects.all() if obj.access(account, self.access_type) + ] + + return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key"))
+ + +
[docs]class CharacterDeleteView(CharacterMixin, ObjectDeleteView): + """ + This view provides a mechanism by which a logged-in player (enforced by + ObjectDeleteView) can delete a character they own. + + """ + + # using the character form fails there + form_class = forms.EvenniaForm
+ + +
[docs]class CharacterCreateView(CharacterMixin, ObjectCreateView): + """ + This view provides a mechanism by which a logged-in player (enforced by + ObjectCreateView) can create a new character. + + """ + + # -- Django constructs -- + template_name = "website/character_form.html" + +
[docs] def form_valid(self, form): + """ + Django hook, modified for Evennia. + + This hook is called after a valid form is submitted. + + When an character creation form is submitted and the data is deemed valid, + proceeds with creating the Character object. + + """ + # Get account object creating the character + account = self.request.user + character = None + + # Get attributes from the form + self.attributes = {k: form.cleaned_data[k] for k in form.cleaned_data.keys()} + charname = self.attributes.pop("db_key") + description = self.attributes.pop("desc") + # Create a character + character, errors = self.typeclass.create(charname, account, description=description) + + if errors: + # Echo error messages to the user + [messages.error(self.request, x) for x in errors] + + if character: + # Assign attributes from form + for key, value in self.attributes.items(): + setattr(character.db, key, value) + + # Return the user to the character management page, unless overridden + messages.success(self.request, "Your character '%s' was created!" % character.name) + return HttpResponseRedirect(self.success_url) + + else: + # Call the Django "form failed" hook + messages.error(self.request, "Your character could not be created.") + return self.form_invalid(form)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/views/errors.html b/docs/latest/_modules/evennia/web/website/views/errors.html new file mode 100644 index 0000000000..a1e1d428a5 --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/views/errors.html @@ -0,0 +1,122 @@ + + + + + + + + evennia.web.website.views.errors — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.views.errors

+"""
+Error views.
+
+"""
+
+from django.shortcuts import render
+
+
+
[docs]def to_be_implemented(request): + """ + A notice letting the user know that this particular feature hasn't been + implemented yet. + """ + + pagevars = {"page_title": "To Be Implemented..."} + + return render(request, "tbi.html", pagevars)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/views/help.html b/docs/latest/_modules/evennia/web/website/views/help.html new file mode 100644 index 0000000000..914325b307 --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/views/help.html @@ -0,0 +1,437 @@ + + + + + + + + evennia.web.website.views.help — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.views.help

+"""
+Views to manipulate help entries.
+
+Multi entry object type supported added by DaveWithTheNiceHat 2021
+    Pull Request #2429
+"""
+from django.conf import settings
+from django.http import HttpResponseBadRequest
+from django.utils.text import slugify
+from django.views.generic import DetailView, ListView
+
+from evennia.help.filehelp import FILE_HELP_ENTRIES
+from evennia.help.models import HelpEntry
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.utils import inherits_from
+
+DEFAULT_HELP_CATEGORY = settings.DEFAULT_HELP_CATEGORY
+
+
+
[docs]def get_help_category(help_entry, slugify_cat=True): + """Returns help category. + + Args: + help_entry (HelpEntry, FileHelpEntry or Command): Help entry instance. + slugify_cat (bool): If true the return string is slugified. Default is True. + + Notes: + If an entry does not have a `help_category` attribute, DEFAULT_HELP_CATEGORY from the + settings file is used. + If the entry does not have attribute 'web_help_entries'. One is created with a slugified + copy of the attribute help_category. This attribute is used for sorting the entries for the + help index (ListView) page. + + Returns: + help_category (str): The category for the help entry. + """ + help_category = getattr(help_entry, "help_category", None) + if not help_category: + help_category = getattr(help_entry, "db_help_category", DEFAULT_HELP_CATEGORY) + # if one does not exist, create a category for ease of use with web views html templates + if not hasattr(help_entry, "web_help_category"): + setattr(help_entry, "web_help_category", slugify(help_category)) + help_category = help_category.lower() + return slugify(help_category) if slugify_cat else help_category
+ + +
[docs]def get_help_topic(help_entry): + """Get the topic of the help entry. + + Args: + help_entry (HelpEntry, FileHelpEntry or Command): Help entry instance. + + Returns: + help_topic (str): The topic of the help entry. Default is 'unknown_topic'. + """ + help_topic = getattr(help_entry, "key", None) + # if object has no key, assume it is a db help entry. + if not help_topic: + help_topic = getattr(help_entry, "db_key", "unknown_topic") + # if one does not exist, create a key for ease of use with web views html templates + if not hasattr(help_entry, "web_help_key"): + setattr(help_entry, "web_help_key", slugify(help_topic)) + return help_topic.lower()
+ + +
[docs]def can_read_topic(cmd_or_topic, account): + """Check if an account or puppet has read access to a help entry. + + Args: + cmd_or_topic (Command, HelpEntry or FileHelpEntry): The topic/command to test. + account: the account or puppet checking for access. + + Returns: + bool: If command can be viewed or not. + + Notes: + This uses the 'read' lock. If no 'read' lock is defined, the topic is assumed readable + by all. + Even if this returns False, the entry will still be visible in the help index unless + `can_list_topic` is also returning False. + """ + if inherits_from(cmd_or_topic, "evennia.commands.command.Command"): + return cmd_or_topic.auto_help and cmd_or_topic.access(account, "read", default=True) + else: + return cmd_or_topic.access(account, "read", default=True)
+ + +
[docs]def collect_topics(account): + """Collect help topics from all sources (cmd/db/file). + + Args: + account (Character or Account): Account or Character to collect help topics from. + + Returns: + cmd_help_topics (dict): contains Command instances. + db_help_topics (dict): contains HelpEntry instances. + file_help_topics (dict): contains FileHelpEntry instances + """ + + # collect commands of account and all puppets + # skip a command if an entry is recorded with the same topics, category and help entry + cmd_help_topics = [] + if not str(account) == "AnonymousUser": + # create list of account and account's puppets + puppets = account.characters.all() + [account] + # add the account's and puppets' commands to cmd_help_topics list + for puppet in puppets: + for cmdset in puppet.cmdset.get(): + # removing doublets in cmdset, caused by cmdhandler + # having to allow doublet commands to manage exits etc. + cmdset.make_unique(puppet) + # retrieve all available commands and database / file-help topics. + # also check the 'cmd:' lock here + for cmd in cmdset: + # skip the command if the puppet does not have access + if not cmd.access(puppet, "cmd"): + continue + # skip the command if the puppet does not have read access + if not can_read_topic(cmd, puppet): + continue + # skip the command if it's help entry already exists in the topic list + entry_exists = False + for verify_cmd in cmd_help_topics: + if ( + verify_cmd.key + and cmd.key + and verify_cmd.help_category == cmd.help_category + and verify_cmd.__doc__ == cmd.__doc__ + ): + entry_exists = True + break + if entry_exists: + continue + # add this command to the list + cmd_help_topics.append(cmd) + + # Verify account has read access to filehelp entries and gather them into a dictionary + file_help_topics = { + topic.key.lower().strip(): topic + for topic in FILE_HELP_ENTRIES.all() + if can_read_topic(topic, account) + } + + # Verify account has read access to database entries and gather them into a dictionary + db_help_topics = { + topic.key.lower().strip(): topic + for topic in HelpEntry.objects.all() + if can_read_topic(topic, account) + } + + # Collect commands into a dictionary, read access verified at puppet level + cmd_help_topics = { + cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key: cmd + for cmd in cmd_help_topics + } + + return cmd_help_topics, db_help_topics, file_help_topics
+ + +
[docs]class HelpMixin: + """ + This is a "mixin", a modifier of sorts. + + Any view class with this in its inheritance list will be modified to work + with HelpEntry objects instead of generic Objects or otherwise. + + """ + + # -- Evennia constructs -- + page_title = "Help" + +
[docs] def get_queryset(self): + """ + Django hook; here we want to return a list of only those HelpEntries + and other documentation that the current user is allowed to see. + + Returns: + queryset (list): List of Help entries available to the user. + + """ + account = self.request.user + + # collect all help entries + cmd_help_topics, db_help_topics, file_help_topics = collect_topics(account) + + # merge the entries + file_db_help_topics = {**file_help_topics, **db_help_topics} + all_topics = {**file_db_help_topics, **cmd_help_topics} + all_entries = list(all_topics.values()) + + # sort the entries + all_entries.sort(key=get_help_topic) # sort alphabetically + all_entries.sort(key=get_help_category) # group by categories + + return all_entries
+ + +
[docs]class HelpListView(HelpMixin, ListView): + """ + Returns a list of help entries that can be viewed by a user, authenticated + or not. + + """ + + # -- Django constructs -- + paginate_by = 500 + template_name = "website/help_list.html" + + # -- Evennia constructs -- + page_title = "Help Index"
+ + +
[docs]class HelpDetailView(HelpMixin, DetailView): + """ + Returns the detail page for a given help entry. + + """ + + # -- Django constructs -- + # the html template to use when contructing the detail page for a help topic + template_name = "website/help_detail.html" + + @property + def page_title(self): + # Makes sure the page has a sensible title. + obj = self.get_object() + topic = get_help_topic(obj) + return f"{topic} detail" + +
[docs] def get_context_data(self, **kwargs): + """ + Adds navigational data to the template to let browsers go to the next + or previous entry in the help list. + + Returns: + context (dict): Django context object + + """ + context = super().get_context_data(**kwargs) + + # get a full query set + full_set = self.get_queryset() + + # Get the object in question + obj = self.get_object(full_set) + + # filter non related categories from the query set + obj_category = get_help_category(obj) + category_set = [] + for entry in full_set: + entry_category = get_help_category(entry) + if entry_category.lower() == obj_category.lower(): + category_set.append(entry) + context["topic_list"] = category_set + + # Find the index position of the given obj in the category set + objs = list(category_set) + for i, x in enumerate(objs): + if obj is x: + break + + # Find the previous and next topics, if either exist + try: + assert i + 1 <= len(objs) and objs[i + 1] is not obj + context["topic_next"] = objs[i + 1] + except: + context["topic_next"] = None + + try: + assert i - 1 >= 0 and objs[i - 1] is not obj + context["topic_previous"] = objs[i - 1] + except: + context["topic_previous"] = None + + # Get the help entry text + text = "Failed to find entry." + if inherits_from(obj, "evennia.commands.command.Command"): + text = obj.__doc__ + elif inherits_from(obj, "evennia.help.models.HelpEntry"): + text = obj.db_entrytext + elif inherits_from(obj, "evennia.help.filehelp.FileHelpEntry"): + text = obj.entrytext + text = strip_ansi(text) # remove ansii markups + context["entry_text"] = text.strip() + + return context
+ +
[docs] def get_object(self, queryset=None): + """ + Override of Django hook that retrieves an object by category and topic + instead of pk and slug. + + Args: + queryset (list): A list of help entry objects. + + Returns: + entry (HelpEntry, FileHelpEntry or Command): HelpEntry requested in the URL. + + """ + + if hasattr(self, "obj"): + return getattr(self, "obj", None) + + # Get the queryset for the help entries the user can access + if not queryset: + queryset = self.get_queryset() + + # get the category and topic requested + category = slugify(self.kwargs.get("category", "")) + topic = slugify(self.kwargs.get("topic", "")) + + # Find the object in the queryset + obj = None + for entry in queryset: + # continue to next entry if the topics do not match + entry_topic = get_help_topic(entry) + if not slugify(entry_topic) == topic: + continue + # if the category also matches, object requested is found + entry_category = get_help_category(entry) + if slugify(entry_category) == category: + obj = entry + break + + # Check if this object was requested in a valid manner + if not obj: + return HttpResponseBadRequest(f"No ({category}/{topic})s found matching the query.") + else: + # cache the object if one was found + self.obj = obj + + return obj
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/views/index.html b/docs/latest/_modules/evennia/web/website/views/index.html new file mode 100644 index 0000000000..de437f1a38 --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/views/index.html @@ -0,0 +1,223 @@ + + + + + + + + evennia.web.website.views.index — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.views.index

+"""
+The main index page, including the game stats
+
+"""
+
+from django.conf import settings
+from django.views.generic import TemplateView
+
+import evennia
+from evennia.accounts.models import AccountDB
+from evennia.objects.models import ObjectDB
+from evennia.utils import class_from_module
+
+
+def _gamestats():
+    """
+    Generate a the gamestat context for the main index page
+    """
+    # Some misc. configurable stuff.
+    # TODO: Move this to either SQL or settings.py based configuration.
+    fpage_account_limit = 4
+
+    # A QuerySet of the most recently connected accounts.
+    recent_users = AccountDB.objects.get_recently_connected_accounts()
+    nplyrs_conn_recent = len(recent_users) or "none"
+    nplyrs = AccountDB.objects.num_total_accounts() or "none"
+    nplyrs_reg_recent = len(AccountDB.objects.get_recently_created_accounts()) or "none"
+    nsess = evennia.SESSION_HANDLER.account_count()
+    # nsess = len(AccountDB.objects.get_connected_accounts()) or "no one"
+
+    nobjs = ObjectDB.objects.count()
+    nobjs = nobjs or 1  # fix zero-div error with empty database
+    Character = class_from_module(
+        settings.BASE_CHARACTER_TYPECLASS, fallback=settings.FALLBACK_CHARACTER_TYPECLASS
+    )
+    nchars = Character.objects.all_family().count()
+    Room = class_from_module(
+        settings.BASE_ROOM_TYPECLASS, fallback=settings.FALLBACK_ROOM_TYPECLASS
+    )
+    nrooms = Room.objects.all_family().count()
+    Exit = class_from_module(
+        settings.BASE_EXIT_TYPECLASS, fallback=settings.FALLBACK_EXIT_TYPECLASS
+    )
+    nexits = Exit.objects.all_family().count()
+    nothers = nobjs - nchars - nrooms - nexits
+
+    pagevars = {
+        "page_title": "Front Page",
+        "accounts_connected_recent": recent_users[:fpage_account_limit],
+        "num_accounts_connected": nsess or "no one",
+        "num_accounts_registered": nplyrs or "no",
+        "num_accounts_connected_recent": nplyrs_conn_recent or "no",
+        "num_accounts_registered_recent": nplyrs_reg_recent or "no one",
+        "num_rooms": nrooms or "none",
+        "num_exits": nexits or "no",
+        "num_objects": nobjs or "none",
+        "num_characters": nchars or "no",
+        "num_others": nothers or "no",
+    }
+    return pagevars
+
+
+
[docs]class EvenniaIndexView(TemplateView): + """ + This is a basic example of a Django class-based view, which are functionally + very similar to Evennia Commands but differ in structure. Commands are used + to interface with users using a terminal client. Views are used to interface + with users using a web browser. + + To use a class-based view, you need to have written a template in HTML, and + then you write a view like this to tell Django what values to display on it. + + While there are simpler ways of writing views using plain functions (and + Evennia currently contains a few examples of them), just like Commands, + writing views as classes provides you with more flexibility-- you can extend + classes and change things to suit your needs rather than having to copy and + paste entire code blocks over and over. Django also comes with many default + views for displaying things, all of them implemented as classes. + + This particular example displays the index page. + + """ + + # Tell the view what HTML template to use for the page + template_name = "website/index.html" + + # This method tells the view what data should be displayed on the template. +
[docs] def get_context_data(self, **kwargs): + """ + This is a common Django method. Think of this as the website + equivalent of the Evennia Command.func() method. + + If you just want to display a static page with no customization, you + don't need to define this method-- just create a view, define + template_name and you're done. + + The only catch here is that if you extend or overwrite this method, + you'll always want to make sure you call the parent method to get a + context object. It's just a dict, but it comes prepopulated with all + sorts of background data intended for display on the page. + + You can do whatever you want to it, but it must be returned at the end + of this method. + + Keyword Args: + any (any): Passed through. + + Returns: + context (dict): Dictionary of data you want to display on the page. + + """ + # Always call the base implementation first to get a context object + context = super().get_context_data(**kwargs) + + # Add game statistics and other pagevars + context.update(_gamestats()) + + return context
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/views/mixins.html b/docs/latest/_modules/evennia/web/website/views/mixins.html new file mode 100644 index 0000000000..f31dd6c045 --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/views/mixins.html @@ -0,0 +1,194 @@ + + + + + + + + evennia.web.website.views.mixins — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.views.mixins

+"""
+These are mixins for class-based views, granting functionality.
+
+"""
+from django.views.generic import DetailView
+from django.views.generic.edit import CreateView, DeleteView, UpdateView
+
+
+
[docs]class TypeclassMixin: + """ + This is a "mixin", a modifier of sorts. + + Django views typically work with classes called "models." Evennia objects + are an enhancement upon these Django models and are called "typeclasses." + But Django itself has no idea what a "typeclass" is. + + For the sake of mitigating confusion, any view class with this in its + inheritance list will be modified to work with Evennia Typeclass objects or + Django models interchangeably. + + """ + + @property + def typeclass(self): + return self.model + + @typeclass.setter + def typeclass(self, value): + self.model = value
+ + +
[docs]class EvenniaCreateView(CreateView, TypeclassMixin): + """ + This view extends Django's default CreateView. + + CreateView is used for creating new objects, be they Accounts, Characters or + otherwise. + + """ + + @property + def page_title(self): + # Makes sure the page has a sensible title. + return "Create %s" % self.typeclass._meta.verbose_name.title()
+ + +
[docs]class EvenniaDetailView(DetailView, TypeclassMixin): + """ + This view extends Django's default DetailView. + + DetailView is used for displaying objects, be they Accounts, Characters or + otherwise. + + """ + + @property + def page_title(self): + # Makes sure the page has a sensible title. + return "%s Detail" % self.typeclass._meta.verbose_name.title()
+ + +
[docs]class EvenniaUpdateView(UpdateView, TypeclassMixin): + """ + This view extends Django's default UpdateView. + + UpdateView is used for updating objects, be they Accounts, Characters or + otherwise. + + """ + + @property + def page_title(self): + # Makes sure the page has a sensible title. + return "Update %s" % self.typeclass._meta.verbose_name.title()
+ + +
[docs]class EvenniaDeleteView(DeleteView, TypeclassMixin): + """ + This view extends Django's default DeleteView. + + DeleteView is used for deleting objects, be they Accounts, Characters or + otherwise. + + """ + + @property + def page_title(self): + # Makes sure the page has a sensible title. + return "Delete %s" % self.typeclass._meta.verbose_name.title()
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/evennia/web/website/views/objects.html b/docs/latest/_modules/evennia/web/website/views/objects.html new file mode 100644 index 0000000000..43d24bccdd --- /dev/null +++ b/docs/latest/_modules/evennia/web/website/views/objects.html @@ -0,0 +1,363 @@ + + + + + + + + evennia.web.website.views.objects — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for evennia.web.website.views.objects

+"""
+Views for managing a specific object)
+
+"""
+
+from collections import OrderedDict
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponseBadRequest, HttpResponseRedirect
+from django.utils.text import slugify
+
+from evennia.utils import class_from_module
+
+from .mixins import (
+    EvenniaCreateView,
+    EvenniaDeleteView,
+    EvenniaDetailView,
+    EvenniaUpdateView,
+)
+
+
+
[docs]class ObjectDetailView(EvenniaDetailView): + """ + This is an important view. + + Any view you write that deals with displaying, updating or deleting a + specific object will want to inherit from this. It provides the mechanisms + by which to retrieve the object and make sure the user requesting it has + permissions to actually *do* things to it. + + """ + + # -- Django constructs -- + # + # Choose what class of object this view will display. Note that this should + # be an actual Python class (i.e. do `from typeclasses.characters import + # Character`, then put `Character`), not an Evennia typeclass path + # (i.e. `typeclasses.characters.Character`). + # + # So when you extend it, this line should look simple, like: + # model = Object + model = class_from_module( + settings.BASE_OBJECT_TYPECLASS, fallback=settings.FALLBACK_OBJECT_TYPECLASS + ) + + # What HTML template you wish to use to display this page. + template_name = "website/object_detail.html" + + # -- Evennia constructs -- + # + # What lock type to check for the requesting user, authenticated or not. + # https://github.com/evennia/evennia/wiki/Locks#valid-access_types + access_type = "view" + + # What attributes of the object you wish to display on the page. Model-level + # attributes will take precedence over identically-named db.attributes! + # The order you specify here will be followed. + attributes = ["name", "desc"] + +
[docs] def get_context_data(self, **kwargs): + """ + Adds an 'attributes' list to the request context consisting of the + attributes specified at the class level, and in the order provided. + + Django views do not provide a way to reference dynamic attributes, so + we have to grab them all before we render the template. + + Returns: + context (dict): Django context object + + """ + # Get the base Django context object + context = super().get_context_data(**kwargs) + + # Get the object in question + obj = self.get_object() + + # Create an ordered dictionary to contain the attribute map + attribute_list = OrderedDict() + + for attribute in self.attributes: + # Check if the attribute is a core fieldname (name, desc) + if attribute in self.typeclass._meta._property_names: + attribute_list[attribute.title()] = getattr(obj, attribute, "") + + # Check if the attribute is a db attribute (char1.db.favorite_color) + else: + attribute_list[attribute.title()] = getattr(obj.db, attribute, "") + + # Add our attribute map to the Django request context, so it gets + # displayed on the template + context["attribute_list"] = attribute_list + + # Return the comprehensive context object + return context
+ +
[docs] def get_object(self, queryset=None): + """ + Override of Django hook that provides some important Evennia-specific + functionality. + + Evennia does not natively store slugs, so where a slug is provided, + calculate the same for the object and make sure it matches. + + This also checks to make sure the user has access to view/edit/delete + this object! + + """ + # A queryset can be provided to pre-emptively limit what objects can + # possibly be returned. For example, you can supply a queryset that + # only returns objects whose name begins with "a". + if not queryset: + queryset = self.get_queryset() + + # Get the object, ignoring all checks and filters for now + obj = self.typeclass.objects.get(pk=self.kwargs.get("pk")) + + # Check if this object was requested in a valid manner + if slugify(obj.name) != self.kwargs.get(self.slug_url_kwarg): + raise HttpResponseBadRequest( + "No %(verbose_name)s found matching the query" + % {"verbose_name": queryset.model._meta.verbose_name} + ) + + # Check if the requestor account has permissions to access object + account = self.request.user + if not obj.access(account, self.access_type): + raise PermissionDenied("You are not authorized to %s this object." % self.access_type) + + # Get the object, if it is in the specified queryset + obj = super().get_object(queryset) + + return obj
+ + +
[docs]class ObjectCreateView(LoginRequiredMixin, EvenniaCreateView): + """ + This is an important view. + + Any view you write that deals with creating a specific object will want to + inherit from this. It provides the mechanisms by which to make sure the user + requesting creation of an object is authenticated, and provides a sane + default title for the page. + + """ + + model = class_from_module( + settings.BASE_OBJECT_TYPECLASS, fallback=settings.FALLBACK_OBJECT_TYPECLASS + )
+ + +
[docs]class ObjectDeleteView(LoginRequiredMixin, ObjectDetailView, EvenniaDeleteView): + """ + This is an important view for obvious reasons! + + Any view you write that deals with deleting a specific object will want to + inherit from this. It provides the mechanisms by which to make sure the user + requesting deletion of an object is authenticated, and that they have + permissions to delete the requested object. + + """ + + # -- Django constructs -- + model = class_from_module( + settings.BASE_OBJECT_TYPECLASS, fallback=settings.FALLBACK_OBJECT_TYPECLASS + ) + template_name = "website/object_confirm_delete.html" + + # -- Evennia constructs -- + access_type = "delete"
+ + +
[docs]class ObjectUpdateView(LoginRequiredMixin, ObjectDetailView, EvenniaUpdateView): + """ + This is an important view. + + Any view you write that deals with updating a specific object will want to + inherit from this. It provides the mechanisms by which to make sure the user + requesting editing of an object is authenticated, and that they have + permissions to edit the requested object. + + This functions slightly different from default Django UpdateViews in that + it does not update core model fields, *only* object attributes! + + """ + + # -- Django constructs -- + model = class_from_module( + settings.BASE_OBJECT_TYPECLASS, fallback=settings.FALLBACK_OBJECT_TYPECLASS + ) + + # -- Evennia constructs -- + access_type = "edit" + +
[docs] def get_success_url(self): + """ + Django hook. + + Can be overridden to return any URL you want to redirect the user to + after the object is successfully updated, but by default it goes to the + object detail page so the user can see their changes reflected. + + """ + if self.success_url: + return self.success_url + return self.object.web_get_detail_url()
+ +
[docs] def get_initial(self): + """ + Django hook, modified for Evennia. + + Prepopulates the update form field values based on object db attributes. + + Returns: + data (dict): Dictionary of key:value pairs containing initial form + data. + + """ + # Get the object we want to update + obj = self.get_object() + + # Get attributes + data = {k: getattr(obj.db, k, "") for k in self.form_class.base_fields} + + # Get model fields + data.update({k: getattr(obj, k, "") for k in self.form_class.Meta.fields}) + + return data
+ +
[docs] def form_valid(self, form): + """ + Override of Django hook. + + Updates object attributes based on values submitted. + + This is run when the form is submitted and the data on it is deemed + valid-- all values are within expected ranges, all strings contain + valid characters and lengths, etc. + + This method is only called if all values for the fields submitted + passed form validation, so at this point we can assume the data is + validated and sanitized. + + """ + # Get the attributes after they've been cleaned and validated + data = {k: v for k, v in form.cleaned_data.items() if k not in self.form_class.Meta.fields} + + # Update the object attributes + for key, value in data.items(): + self.object.attributes.add(key, value) + messages.success(self.request, "Successfully updated '%s' for %s." % (key, self.object)) + + # Do not return super().form_valid; we don't want to update the model + # instance, just its attributes. + return HttpResponseRedirect(self.get_success_url())
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/functools.html b/docs/latest/_modules/functools.html new file mode 100644 index 0000000000..b32059eb96 --- /dev/null +++ b/docs/latest/_modules/functools.html @@ -0,0 +1,1115 @@ + + + + + + + + functools — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for functools

+"""functools.py - Tools for working with functions and callable objects
+"""
+# Python module wrapper for _functools C module
+# to allow utilities written in Python to be added
+# to the functools module.
+# Written by Nick Coghlan <ncoghlan at gmail.com>,
+# Raymond Hettinger <python at rcn.com>,
+# and Łukasz Langa <lukasz at langa.pl>.
+#   Copyright (C) 2006-2013 Python Software Foundation.
+# See C source code for _functools credits/copyright
+
+__all__ = ['update_wrapper', 'wraps', 'WRAPPER_ASSIGNMENTS', 'WRAPPER_UPDATES',
+           'total_ordering', 'cache', 'cmp_to_key', 'lru_cache', 'reduce',
+           'partial', 'partialmethod', 'singledispatch', 'singledispatchmethod',
+           'cached_property']
+
+from abc import get_cache_token
+from collections import namedtuple
+# import types, weakref  # Deferred to single_dispatch()
+from reprlib import recursive_repr
+from _thread import RLock
+from types import GenericAlias
+
+
+################################################################################
+### update_wrapper() and wraps() decorator
+################################################################################
+
+# update_wrapper() and wraps() are tools to help write
+# wrapper functions that can handle naive introspection
+
+WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__',
+                       '__annotations__')
+WRAPPER_UPDATES = ('__dict__',)
+def update_wrapper(wrapper,
+                   wrapped,
+                   assigned = WRAPPER_ASSIGNMENTS,
+                   updated = WRAPPER_UPDATES):
+    """Update a wrapper function to look like the wrapped function
+
+       wrapper is the function to be updated
+       wrapped is the original function
+       assigned is a tuple naming the attributes assigned directly
+       from the wrapped function to the wrapper function (defaults to
+       functools.WRAPPER_ASSIGNMENTS)
+       updated is a tuple naming the attributes of the wrapper that
+       are updated with the corresponding attribute from the wrapped
+       function (defaults to functools.WRAPPER_UPDATES)
+    """
+    for attr in assigned:
+        try:
+            value = getattr(wrapped, attr)
+        except AttributeError:
+            pass
+        else:
+            setattr(wrapper, attr, value)
+    for attr in updated:
+        getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
+    # Issue #17482: set __wrapped__ last so we don't inadvertently copy it
+    # from the wrapped function when updating __dict__
+    wrapper.__wrapped__ = wrapped
+    # Return the wrapper so this can be used as a decorator via partial()
+    return wrapper
+
+def wraps(wrapped,
+          assigned = WRAPPER_ASSIGNMENTS,
+          updated = WRAPPER_UPDATES):
+    """Decorator factory to apply update_wrapper() to a wrapper function
+
+       Returns a decorator that invokes update_wrapper() with the decorated
+       function as the wrapper argument and the arguments to wraps() as the
+       remaining arguments. Default arguments are as for update_wrapper().
+       This is a convenience function to simplify applying partial() to
+       update_wrapper().
+    """
+    return partial(update_wrapper, wrapped=wrapped,
+                   assigned=assigned, updated=updated)
+
+
+################################################################################
+### total_ordering class decorator
+################################################################################
+
+# The total ordering functions all invoke the root magic method directly
+# rather than using the corresponding operator.  This avoids possible
+# infinite recursion that could occur when the operator dispatch logic
+# detects a NotImplemented result and then calls a reflected method.
+
+def _gt_from_lt(self, other):
+    'Return a > b.  Computed by @total_ordering from (not a < b) and (a != b).'
+    op_result = type(self).__lt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result and self != other
+
+def _le_from_lt(self, other):
+    'Return a <= b.  Computed by @total_ordering from (a < b) or (a == b).'
+    op_result = type(self).__lt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return op_result or self == other
+
+def _ge_from_lt(self, other):
+    'Return a >= b.  Computed by @total_ordering from (not a < b).'
+    op_result = type(self).__lt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result
+
+def _ge_from_le(self, other):
+    'Return a >= b.  Computed by @total_ordering from (not a <= b) or (a == b).'
+    op_result = type(self).__le__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result or self == other
+
+def _lt_from_le(self, other):
+    'Return a < b.  Computed by @total_ordering from (a <= b) and (a != b).'
+    op_result = type(self).__le__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return op_result and self != other
+
+def _gt_from_le(self, other):
+    'Return a > b.  Computed by @total_ordering from (not a <= b).'
+    op_result = type(self).__le__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result
+
+def _lt_from_gt(self, other):
+    'Return a < b.  Computed by @total_ordering from (not a > b) and (a != b).'
+    op_result = type(self).__gt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result and self != other
+
+def _ge_from_gt(self, other):
+    'Return a >= b.  Computed by @total_ordering from (a > b) or (a == b).'
+    op_result = type(self).__gt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return op_result or self == other
+
+def _le_from_gt(self, other):
+    'Return a <= b.  Computed by @total_ordering from (not a > b).'
+    op_result = type(self).__gt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result
+
+def _le_from_ge(self, other):
+    'Return a <= b.  Computed by @total_ordering from (not a >= b) or (a == b).'
+    op_result = type(self).__ge__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result or self == other
+
+def _gt_from_ge(self, other):
+    'Return a > b.  Computed by @total_ordering from (a >= b) and (a != b).'
+    op_result = type(self).__ge__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return op_result and self != other
+
+def _lt_from_ge(self, other):
+    'Return a < b.  Computed by @total_ordering from (not a >= b).'
+    op_result = type(self).__ge__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result
+
+_convert = {
+    '__lt__': [('__gt__', _gt_from_lt),
+               ('__le__', _le_from_lt),
+               ('__ge__', _ge_from_lt)],
+    '__le__': [('__ge__', _ge_from_le),
+               ('__lt__', _lt_from_le),
+               ('__gt__', _gt_from_le)],
+    '__gt__': [('__lt__', _lt_from_gt),
+               ('__ge__', _ge_from_gt),
+               ('__le__', _le_from_gt)],
+    '__ge__': [('__le__', _le_from_ge),
+               ('__gt__', _gt_from_ge),
+               ('__lt__', _lt_from_ge)]
+}
+
+def total_ordering(cls):
+    """Class decorator that fills in missing ordering methods"""
+    # Find user-defined comparisons (not those inherited from object).
+    roots = {op for op in _convert if getattr(cls, op, None) is not getattr(object, op, None)}
+    if not roots:
+        raise ValueError('must define at least one ordering operation: < > <= >=')
+    root = max(roots)       # prefer __lt__ to __le__ to __gt__ to __ge__
+    for opname, opfunc in _convert[root]:
+        if opname not in roots:
+            opfunc.__name__ = opname
+            setattr(cls, opname, opfunc)
+    return cls
+
+
+################################################################################
+### cmp_to_key() function converter
+################################################################################
+
+def cmp_to_key(mycmp):
+    """Convert a cmp= function into a key= function"""
+    class K(object):
+        __slots__ = ['obj']
+        def __init__(self, obj):
+            self.obj = obj
+        def __lt__(self, other):
+            return mycmp(self.obj, other.obj) < 0
+        def __gt__(self, other):
+            return mycmp(self.obj, other.obj) > 0
+        def __eq__(self, other):
+            return mycmp(self.obj, other.obj) == 0
+        def __le__(self, other):
+            return mycmp(self.obj, other.obj) <= 0
+        def __ge__(self, other):
+            return mycmp(self.obj, other.obj) >= 0
+        __hash__ = None
+    return K
+
+try:
+    from _functools import cmp_to_key
+except ImportError:
+    pass
+
+
+################################################################################
+### reduce() sequence to a single item
+################################################################################
+
+_initial_missing = object()
+
+def reduce(function, sequence, initial=_initial_missing):
+    """
+    reduce(function, iterable[, initial]) -> value
+
+    Apply a function of two arguments cumulatively to the items of a sequence
+    or iterable, from left to right, so as to reduce the iterable to a single
+    value.  For example, reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) calculates
+    ((((1+2)+3)+4)+5).  If initial is present, it is placed before the items
+    of the iterable in the calculation, and serves as a default when the
+    iterable is empty.
+    """
+
+    it = iter(sequence)
+
+    if initial is _initial_missing:
+        try:
+            value = next(it)
+        except StopIteration:
+            raise TypeError(
+                "reduce() of empty iterable with no initial value") from None
+    else:
+        value = initial
+
+    for element in it:
+        value = function(value, element)
+
+    return value
+
+try:
+    from _functools import reduce
+except ImportError:
+    pass
+
+
+################################################################################
+### partial() argument application
+################################################################################
+
+# Purely functional, no descriptor behaviour
+class partial:
+    """New function with partial application of the given arguments
+    and keywords.
+    """
+
+    __slots__ = "func", "args", "keywords", "__dict__", "__weakref__"
+
+    def __new__(cls, func, /, *args, **keywords):
+        if not callable(func):
+            raise TypeError("the first argument must be callable")
+
+        if hasattr(func, "func"):
+            args = func.args + args
+            keywords = {**func.keywords, **keywords}
+            func = func.func
+
+        self = super(partial, cls).__new__(cls)
+
+        self.func = func
+        self.args = args
+        self.keywords = keywords
+        return self
+
+    def __call__(self, /, *args, **keywords):
+        keywords = {**self.keywords, **keywords}
+        return self.func(*self.args, *args, **keywords)
+
+    @recursive_repr()
+    def __repr__(self):
+        qualname = type(self).__qualname__
+        args = [repr(self.func)]
+        args.extend(repr(x) for x in self.args)
+        args.extend(f"{k}={v!r}" for (k, v) in self.keywords.items())
+        if type(self).__module__ == "functools":
+            return f"functools.{qualname}({', '.join(args)})"
+        return f"{qualname}({', '.join(args)})"
+
+    def __reduce__(self):
+        return type(self), (self.func,), (self.func, self.args,
+               self.keywords or None, self.__dict__ or None)
+
+    def __setstate__(self, state):
+        if not isinstance(state, tuple):
+            raise TypeError("argument to __setstate__ must be a tuple")
+        if len(state) != 4:
+            raise TypeError(f"expected 4 items in state, got {len(state)}")
+        func, args, kwds, namespace = state
+        if (not callable(func) or not isinstance(args, tuple) or
+           (kwds is not None and not isinstance(kwds, dict)) or
+           (namespace is not None and not isinstance(namespace, dict))):
+            raise TypeError("invalid partial state")
+
+        args = tuple(args) # just in case it's a subclass
+        if kwds is None:
+            kwds = {}
+        elif type(kwds) is not dict: # XXX does it need to be *exactly* dict?
+            kwds = dict(kwds)
+        if namespace is None:
+            namespace = {}
+
+        self.__dict__ = namespace
+        self.func = func
+        self.args = args
+        self.keywords = kwds
+
+try:
+    from _functools import partial
+except ImportError:
+    pass
+
+# Descriptor version
+class partialmethod(object):
+    """Method descriptor with partial application of the given arguments
+    and keywords.
+
+    Supports wrapping existing descriptors and handles non-descriptor
+    callables as instance methods.
+    """
+
+    def __init__(self, func, /, *args, **keywords):
+        if not callable(func) and not hasattr(func, "__get__"):
+            raise TypeError("{!r} is not callable or a descriptor"
+                                 .format(func))
+
+        # func could be a descriptor like classmethod which isn't callable,
+        # so we can't inherit from partial (it verifies func is callable)
+        if isinstance(func, partialmethod):
+            # flattening is mandatory in order to place cls/self before all
+            # other arguments
+            # it's also more efficient since only one function will be called
+            self.func = func.func
+            self.args = func.args + args
+            self.keywords = {**func.keywords, **keywords}
+        else:
+            self.func = func
+            self.args = args
+            self.keywords = keywords
+
+    def __repr__(self):
+        args = ", ".join(map(repr, self.args))
+        keywords = ", ".join("{}={!r}".format(k, v)
+                                 for k, v in self.keywords.items())
+        format_string = "{module}.{cls}({func}, {args}, {keywords})"
+        return format_string.format(module=self.__class__.__module__,
+                                    cls=self.__class__.__qualname__,
+                                    func=self.func,
+                                    args=args,
+                                    keywords=keywords)
+
+    def _make_unbound_method(self):
+        def _method(cls_or_self, /, *args, **keywords):
+            keywords = {**self.keywords, **keywords}
+            return self.func(cls_or_self, *self.args, *args, **keywords)
+        _method.__isabstractmethod__ = self.__isabstractmethod__
+        _method._partialmethod = self
+        return _method
+
+    def __get__(self, obj, cls=None):
+        get = getattr(self.func, "__get__", None)
+        result = None
+        if get is not None:
+            new_func = get(obj, cls)
+            if new_func is not self.func:
+                # Assume __get__ returning something new indicates the
+                # creation of an appropriate callable
+                result = partial(new_func, *self.args, **self.keywords)
+                try:
+                    result.__self__ = new_func.__self__
+                except AttributeError:
+                    pass
+        if result is None:
+            # If the underlying descriptor didn't do anything, treat this
+            # like an instance method
+            result = self._make_unbound_method().__get__(obj, cls)
+        return result
+
+    @property
+    def __isabstractmethod__(self):
+        return getattr(self.func, "__isabstractmethod__", False)
+
+    __class_getitem__ = classmethod(GenericAlias)
+
+
+# Helper functions
+
+def _unwrap_partial(func):
+    while isinstance(func, partial):
+        func = func.func
+    return func
+
+################################################################################
+### LRU Cache function decorator
+################################################################################
+
+_CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"])
+
+class _HashedSeq(list):
+    """ This class guarantees that hash() will be called no more than once
+        per element.  This is important because the lru_cache() will hash
+        the key multiple times on a cache miss.
+
+    """
+
+    __slots__ = 'hashvalue'
+
+    def __init__(self, tup, hash=hash):
+        self[:] = tup
+        self.hashvalue = hash(tup)
+
+    def __hash__(self):
+        return self.hashvalue
+
+def _make_key(args, kwds, typed,
+             kwd_mark = (object(),),
+             fasttypes = {int, str},
+             tuple=tuple, type=type, len=len):
+    """Make a cache key from optionally typed positional and keyword arguments
+
+    The key is constructed in a way that is flat as possible rather than
+    as a nested structure that would take more memory.
+
+    If there is only a single argument and its data type is known to cache
+    its hash value, then that argument is returned without a wrapper.  This
+    saves space and improves lookup speed.
+
+    """
+    # All of code below relies on kwds preserving the order input by the user.
+    # Formerly, we sorted() the kwds before looping.  The new way is *much*
+    # faster; however, it means that f(x=1, y=2) will now be treated as a
+    # distinct call from f(y=2, x=1) which will be cached separately.
+    key = args
+    if kwds:
+        key += kwd_mark
+        for item in kwds.items():
+            key += item
+    if typed:
+        key += tuple(type(v) for v in args)
+        if kwds:
+            key += tuple(type(v) for v in kwds.values())
+    elif len(key) == 1 and type(key[0]) in fasttypes:
+        return key[0]
+    return _HashedSeq(key)
+
+def lru_cache(maxsize=128, typed=False):
+    """Least-recently-used cache decorator.
+
+    If *maxsize* is set to None, the LRU features are disabled and the cache
+    can grow without bound.
+
+    If *typed* is True, arguments of different types will be cached separately.
+    For example, f(3.0) and f(3) will be treated as distinct calls with
+    distinct results.
+
+    Arguments to the cached function must be hashable.
+
+    View the cache statistics named tuple (hits, misses, maxsize, currsize)
+    with f.cache_info().  Clear the cache and statistics with f.cache_clear().
+    Access the underlying function with f.__wrapped__.
+
+    See:  https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)
+
+    """
+
+    # Users should only access the lru_cache through its public API:
+    #       cache_info, cache_clear, and f.__wrapped__
+    # The internals of the lru_cache are encapsulated for thread safety and
+    # to allow the implementation to change (including a possible C version).
+
+    if isinstance(maxsize, int):
+        # Negative maxsize is treated as 0
+        if maxsize < 0:
+            maxsize = 0
+    elif callable(maxsize) and isinstance(typed, bool):
+        # The user_function was passed in directly via the maxsize argument
+        user_function, maxsize = maxsize, 128
+        wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
+        wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
+        return update_wrapper(wrapper, user_function)
+    elif maxsize is not None:
+        raise TypeError(
+            'Expected first argument to be an integer, a callable, or None')
+
+    def decorating_function(user_function):
+        wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
+        wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
+        return update_wrapper(wrapper, user_function)
+
+    return decorating_function
+
+def _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo):
+    # Constants shared by all lru cache instances:
+    sentinel = object()          # unique object used to signal cache misses
+    make_key = _make_key         # build a key from the function arguments
+    PREV, NEXT, KEY, RESULT = 0, 1, 2, 3   # names for the link fields
+
+    cache = {}
+    hits = misses = 0
+    full = False
+    cache_get = cache.get    # bound method to lookup a key or return None
+    cache_len = cache.__len__  # get cache size without calling len()
+    lock = RLock()           # because linkedlist updates aren't threadsafe
+    root = []                # root of the circular doubly linked list
+    root[:] = [root, root, None, None]     # initialize by pointing to self
+
+    if maxsize == 0:
+
+        def wrapper(*args, **kwds):
+            # No caching -- just a statistics update
+            nonlocal misses
+            misses += 1
+            result = user_function(*args, **kwds)
+            return result
+
+    elif maxsize is None:
+
+        def wrapper(*args, **kwds):
+            # Simple caching without ordering or size limit
+            nonlocal hits, misses
+            key = make_key(args, kwds, typed)
+            result = cache_get(key, sentinel)
+            if result is not sentinel:
+                hits += 1
+                return result
+            misses += 1
+            result = user_function(*args, **kwds)
+            cache[key] = result
+            return result
+
+    else:
+
+        def wrapper(*args, **kwds):
+            # Size limited caching that tracks accesses by recency
+            nonlocal root, hits, misses, full
+            key = make_key(args, kwds, typed)
+            with lock:
+                link = cache_get(key)
+                if link is not None:
+                    # Move the link to the front of the circular queue
+                    link_prev, link_next, _key, result = link
+                    link_prev[NEXT] = link_next
+                    link_next[PREV] = link_prev
+                    last = root[PREV]
+                    last[NEXT] = root[PREV] = link
+                    link[PREV] = last
+                    link[NEXT] = root
+                    hits += 1
+                    return result
+                misses += 1
+            result = user_function(*args, **kwds)
+            with lock:
+                if key in cache:
+                    # Getting here means that this same key was added to the
+                    # cache while the lock was released.  Since the link
+                    # update is already done, we need only return the
+                    # computed result and update the count of misses.
+                    pass
+                elif full:
+                    # Use the old root to store the new key and result.
+                    oldroot = root
+                    oldroot[KEY] = key
+                    oldroot[RESULT] = result
+                    # Empty the oldest link and make it the new root.
+                    # Keep a reference to the old key and old result to
+                    # prevent their ref counts from going to zero during the
+                    # update. That will prevent potentially arbitrary object
+                    # clean-up code (i.e. __del__) from running while we're
+                    # still adjusting the links.
+                    root = oldroot[NEXT]
+                    oldkey = root[KEY]
+                    oldresult = root[RESULT]
+                    root[KEY] = root[RESULT] = None
+                    # Now update the cache dictionary.
+                    del cache[oldkey]
+                    # Save the potentially reentrant cache[key] assignment
+                    # for last, after the root and links have been put in
+                    # a consistent state.
+                    cache[key] = oldroot
+                else:
+                    # Put result in a new link at the front of the queue.
+                    last = root[PREV]
+                    link = [last, root, key, result]
+                    last[NEXT] = root[PREV] = cache[key] = link
+                    # Use the cache_len bound method instead of the len() function
+                    # which could potentially be wrapped in an lru_cache itself.
+                    full = (cache_len() >= maxsize)
+            return result
+
+    def cache_info():
+        """Report cache statistics"""
+        with lock:
+            return _CacheInfo(hits, misses, maxsize, cache_len())
+
+    def cache_clear():
+        """Clear the cache and cache statistics"""
+        nonlocal hits, misses, full
+        with lock:
+            cache.clear()
+            root[:] = [root, root, None, None]
+            hits = misses = 0
+            full = False
+
+    wrapper.cache_info = cache_info
+    wrapper.cache_clear = cache_clear
+    return wrapper
+
+try:
+    from _functools import _lru_cache_wrapper
+except ImportError:
+    pass
+
+
+################################################################################
+### cache -- simplified access to the infinity cache
+################################################################################
+
+def cache(user_function, /):
+    'Simple lightweight unbounded cache.  Sometimes called "memoize".'
+    return lru_cache(maxsize=None)(user_function)
+
+
+################################################################################
+### singledispatch() - single-dispatch generic function decorator
+################################################################################
+
+def _c3_merge(sequences):
+    """Merges MROs in *sequences* to a single MRO using the C3 algorithm.
+
+    Adapted from https://www.python.org/download/releases/2.3/mro/.
+
+    """
+    result = []
+    while True:
+        sequences = [s for s in sequences if s]   # purge empty sequences
+        if not sequences:
+            return result
+        for s1 in sequences:   # find merge candidates among seq heads
+            candidate = s1[0]
+            for s2 in sequences:
+                if candidate in s2[1:]:
+                    candidate = None
+                    break      # reject the current head, it appears later
+            else:
+                break
+        if candidate is None:
+            raise RuntimeError("Inconsistent hierarchy")
+        result.append(candidate)
+        # remove the chosen candidate
+        for seq in sequences:
+            if seq[0] == candidate:
+                del seq[0]
+
+def _c3_mro(cls, abcs=None):
+    """Computes the method resolution order using extended C3 linearization.
+
+    If no *abcs* are given, the algorithm works exactly like the built-in C3
+    linearization used for method resolution.
+
+    If given, *abcs* is a list of abstract base classes that should be inserted
+    into the resulting MRO. Unrelated ABCs are ignored and don't end up in the
+    result. The algorithm inserts ABCs where their functionality is introduced,
+    i.e. issubclass(cls, abc) returns True for the class itself but returns
+    False for all its direct base classes. Implicit ABCs for a given class
+    (either registered or inferred from the presence of a special method like
+    __len__) are inserted directly after the last ABC explicitly listed in the
+    MRO of said class. If two implicit ABCs end up next to each other in the
+    resulting MRO, their ordering depends on the order of types in *abcs*.
+
+    """
+    for i, base in enumerate(reversed(cls.__bases__)):
+        if hasattr(base, '__abstractmethods__'):
+            boundary = len(cls.__bases__) - i
+            break   # Bases up to the last explicit ABC are considered first.
+    else:
+        boundary = 0
+    abcs = list(abcs) if abcs else []
+    explicit_bases = list(cls.__bases__[:boundary])
+    abstract_bases = []
+    other_bases = list(cls.__bases__[boundary:])
+    for base in abcs:
+        if issubclass(cls, base) and not any(
+                issubclass(b, base) for b in cls.__bases__
+            ):
+            # If *cls* is the class that introduces behaviour described by
+            # an ABC *base*, insert said ABC to its MRO.
+            abstract_bases.append(base)
+    for base in abstract_bases:
+        abcs.remove(base)
+    explicit_c3_mros = [_c3_mro(base, abcs=abcs) for base in explicit_bases]
+    abstract_c3_mros = [_c3_mro(base, abcs=abcs) for base in abstract_bases]
+    other_c3_mros = [_c3_mro(base, abcs=abcs) for base in other_bases]
+    return _c3_merge(
+        [[cls]] +
+        explicit_c3_mros + abstract_c3_mros + other_c3_mros +
+        [explicit_bases] + [abstract_bases] + [other_bases]
+    )
+
+def _compose_mro(cls, types):
+    """Calculates the method resolution order for a given class *cls*.
+
+    Includes relevant abstract base classes (with their respective bases) from
+    the *types* iterable. Uses a modified C3 linearization algorithm.
+
+    """
+    bases = set(cls.__mro__)
+    # Remove entries which are already present in the __mro__ or unrelated.
+    def is_related(typ):
+        return (typ not in bases and hasattr(typ, '__mro__')
+                                 and not isinstance(typ, GenericAlias)
+                                 and issubclass(cls, typ))
+    types = [n for n in types if is_related(n)]
+    # Remove entries which are strict bases of other entries (they will end up
+    # in the MRO anyway.
+    def is_strict_base(typ):
+        for other in types:
+            if typ != other and typ in other.__mro__:
+                return True
+        return False
+    types = [n for n in types if not is_strict_base(n)]
+    # Subclasses of the ABCs in *types* which are also implemented by
+    # *cls* can be used to stabilize ABC ordering.
+    type_set = set(types)
+    mro = []
+    for typ in types:
+        found = []
+        for sub in typ.__subclasses__():
+            if sub not in bases and issubclass(cls, sub):
+                found.append([s for s in sub.__mro__ if s in type_set])
+        if not found:
+            mro.append(typ)
+            continue
+        # Favor subclasses with the biggest number of useful bases
+        found.sort(key=len, reverse=True)
+        for sub in found:
+            for subcls in sub:
+                if subcls not in mro:
+                    mro.append(subcls)
+    return _c3_mro(cls, abcs=mro)
+
+def _find_impl(cls, registry):
+    """Returns the best matching implementation from *registry* for type *cls*.
+
+    Where there is no registered implementation for a specific type, its method
+    resolution order is used to find a more generic implementation.
+
+    Note: if *registry* does not contain an implementation for the base
+    *object* type, this function may return None.
+
+    """
+    mro = _compose_mro(cls, registry.keys())
+    match = None
+    for t in mro:
+        if match is not None:
+            # If *match* is an implicit ABC but there is another unrelated,
+            # equally matching implicit ABC, refuse the temptation to guess.
+            if (t in registry and t not in cls.__mro__
+                              and match not in cls.__mro__
+                              and not issubclass(match, t)):
+                raise RuntimeError("Ambiguous dispatch: {} or {}".format(
+                    match, t))
+            break
+        if t in registry:
+            match = t
+    return registry.get(match)
+
+def singledispatch(func):
+    """Single-dispatch generic function decorator.
+
+    Transforms a function into a generic function, which can have different
+    behaviours depending upon the type of its first argument. The decorated
+    function acts as the default implementation, and additional
+    implementations can be registered using the register() attribute of the
+    generic function.
+    """
+    # There are many programs that use functools without singledispatch, so we
+    # trade-off making singledispatch marginally slower for the benefit of
+    # making start-up of such applications slightly faster.
+    import types, weakref
+
+    registry = {}
+    dispatch_cache = weakref.WeakKeyDictionary()
+    cache_token = None
+
+    def dispatch(cls):
+        """generic_func.dispatch(cls) -> <function implementation>
+
+        Runs the dispatch algorithm to return the best available implementation
+        for the given *cls* registered on *generic_func*.
+
+        """
+        nonlocal cache_token
+        if cache_token is not None:
+            current_token = get_cache_token()
+            if cache_token != current_token:
+                dispatch_cache.clear()
+                cache_token = current_token
+        try:
+            impl = dispatch_cache[cls]
+        except KeyError:
+            try:
+                impl = registry[cls]
+            except KeyError:
+                impl = _find_impl(cls, registry)
+            dispatch_cache[cls] = impl
+        return impl
+
+    def _is_union_type(cls):
+        from typing import get_origin, Union
+        return get_origin(cls) in {Union, types.UnionType}
+
+    def _is_valid_dispatch_type(cls):
+        if isinstance(cls, type):
+            return True
+        from typing import get_args
+        return (_is_union_type(cls) and
+                all(isinstance(arg, type) for arg in get_args(cls)))
+
+    def register(cls, func=None):
+        """generic_func.register(cls, func) -> func
+
+        Registers a new implementation for the given *cls* on a *generic_func*.
+
+        """
+        nonlocal cache_token
+        if _is_valid_dispatch_type(cls):
+            if func is None:
+                return lambda f: register(cls, f)
+        else:
+            if func is not None:
+                raise TypeError(
+                    f"Invalid first argument to `register()`. "
+                    f"{cls!r} is not a class or union type."
+                )
+            ann = getattr(cls, '__annotations__', {})
+            if not ann:
+                raise TypeError(
+                    f"Invalid first argument to `register()`: {cls!r}. "
+                    f"Use either `@register(some_class)` or plain `@register` "
+                    f"on an annotated function."
+                )
+            func = cls
+
+            # only import typing if annotation parsing is necessary
+            from typing import get_type_hints
+            argname, cls = next(iter(get_type_hints(func).items()))
+            if not _is_valid_dispatch_type(cls):
+                if _is_union_type(cls):
+                    raise TypeError(
+                        f"Invalid annotation for {argname!r}. "
+                        f"{cls!r} not all arguments are classes."
+                    )
+                else:
+                    raise TypeError(
+                        f"Invalid annotation for {argname!r}. "
+                        f"{cls!r} is not a class."
+                    )
+
+        if _is_union_type(cls):
+            from typing import get_args
+
+            for arg in get_args(cls):
+                registry[arg] = func
+        else:
+            registry[cls] = func
+        if cache_token is None and hasattr(cls, '__abstractmethods__'):
+            cache_token = get_cache_token()
+        dispatch_cache.clear()
+        return func
+
+    def wrapper(*args, **kw):
+        if not args:
+            raise TypeError(f'{funcname} requires at least '
+                            '1 positional argument')
+
+        return dispatch(args[0].__class__)(*args, **kw)
+
+    funcname = getattr(func, '__name__', 'singledispatch function')
+    registry[object] = func
+    wrapper.register = register
+    wrapper.dispatch = dispatch
+    wrapper.registry = types.MappingProxyType(registry)
+    wrapper._clear_cache = dispatch_cache.clear
+    update_wrapper(wrapper, func)
+    return wrapper
+
+
+# Descriptor version
+class singledispatchmethod:
+    """Single-dispatch generic method descriptor.
+
+    Supports wrapping existing descriptors and handles non-descriptor
+    callables as instance methods.
+    """
+
+    def __init__(self, func):
+        if not callable(func) and not hasattr(func, "__get__"):
+            raise TypeError(f"{func!r} is not callable or a descriptor")
+
+        self.dispatcher = singledispatch(func)
+        self.func = func
+
+    def register(self, cls, method=None):
+        """generic_method.register(cls, func) -> func
+
+        Registers a new implementation for the given *cls* on a *generic_method*.
+        """
+        return self.dispatcher.register(cls, func=method)
+
+    def __get__(self, obj, cls=None):
+        def _method(*args, **kwargs):
+            method = self.dispatcher.dispatch(args[0].__class__)
+            return method.__get__(obj, cls)(*args, **kwargs)
+
+        _method.__isabstractmethod__ = self.__isabstractmethod__
+        _method.register = self.register
+        update_wrapper(_method, self.func)
+        return _method
+
+    @property
+    def __isabstractmethod__(self):
+        return getattr(self.func, '__isabstractmethod__', False)
+
+
+################################################################################
+### cached_property() - computed once per instance, cached as attribute
+################################################################################
+
+_NOT_FOUND = object()
+
+
+class cached_property:
+    def __init__(self, func):
+        self.func = func
+        self.attrname = None
+        self.__doc__ = func.__doc__
+        self.lock = RLock()
+
+    def __set_name__(self, owner, name):
+        if self.attrname is None:
+            self.attrname = name
+        elif name != self.attrname:
+            raise TypeError(
+                "Cannot assign the same cached_property to two different names "
+                f"({self.attrname!r} and {name!r})."
+            )
+
+    def __get__(self, instance, owner=None):
+        if instance is None:
+            return self
+        if self.attrname is None:
+            raise TypeError(
+                "Cannot use cached_property instance without calling __set_name__ on it.")
+        try:
+            cache = instance.__dict__
+        except AttributeError:  # not all objects have __dict__ (e.g. class defines slots)
+            msg = (
+                f"No '__dict__' attribute on {type(instance).__name__!r} "
+                f"instance to cache {self.attrname!r} property."
+            )
+            raise TypeError(msg) from None
+        val = cache.get(self.attrname, _NOT_FOUND)
+        if val is _NOT_FOUND:
+            with self.lock:
+                # check if another thread filled cache while we awaited lock
+                val = cache.get(self.attrname, _NOT_FOUND)
+                if val is _NOT_FOUND:
+                    val = self.func(instance)
+                    try:
+                        cache[self.attrname] = val
+                    except TypeError:
+                        msg = (
+                            f"The '__dict__' attribute on {type(instance).__name__!r} instance "
+                            f"does not support item assignment for caching {self.attrname!r} property."
+                        )
+                        raise TypeError(msg) from None
+        return val
+
+    __class_getitem__ = classmethod(GenericAlias)
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/index.html b/docs/latest/_modules/index.html new file mode 100644 index 0000000000..3b9206a66a --- /dev/null +++ b/docs/latest/_modules/index.html @@ -0,0 +1,414 @@ + + + + + + + + Overview: module code — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

All modules for which code is available

+ + +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/re.html b/docs/latest/_modules/re.html new file mode 100644 index 0000000000..653c7640b3 --- /dev/null +++ b/docs/latest/_modules/re.html @@ -0,0 +1,477 @@ + + + + + + + + re — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for re

+#
+# Secret Labs' Regular Expression Engine
+#
+# re-compatible interface for the sre matching engine
+#
+# Copyright (c) 1998-2001 by Secret Labs AB.  All rights reserved.
+#
+# This version of the SRE library can be redistributed under CNRI's
+# Python 1.6 license.  For any other use, please contact Secret Labs
+# AB (info@pythonware.com).
+#
+# Portions of this engine have been developed in cooperation with
+# CNRI.  Hewlett-Packard provided funding for 1.6 integration and
+# other compatibility work.
+#
+
+r"""Support for regular expressions (RE).
+
+This module provides regular expression matching operations similar to
+those found in Perl.  It supports both 8-bit and Unicode strings; both
+the pattern and the strings being processed can contain null bytes and
+characters outside the US ASCII range.
+
+Regular expressions can contain both special and ordinary characters.
+Most ordinary characters, like "A", "a", or "0", are the simplest
+regular expressions; they simply match themselves.  You can
+concatenate ordinary characters, so last matches the string 'last'.
+
+The special characters are:
+    "."      Matches any character except a newline.
+    "^"      Matches the start of the string.
+    "$"      Matches the end of the string or just before the newline at
+             the end of the string.
+    "*"      Matches 0 or more (greedy) repetitions of the preceding RE.
+             Greedy means that it will match as many repetitions as possible.
+    "+"      Matches 1 or more (greedy) repetitions of the preceding RE.
+    "?"      Matches 0 or 1 (greedy) of the preceding RE.
+    *?,+?,?? Non-greedy versions of the previous three special characters.
+    {m,n}    Matches from m to n repetitions of the preceding RE.
+    {m,n}?   Non-greedy version of the above.
+    "\\"     Either escapes special characters or signals a special sequence.
+    []       Indicates a set of characters.
+             A "^" as the first character indicates a complementing set.
+    "|"      A|B, creates an RE that will match either A or B.
+    (...)    Matches the RE inside the parentheses.
+             The contents can be retrieved or matched later in the string.
+    (?aiLmsux) The letters set the corresponding flags defined below.
+    (?:...)  Non-grouping version of regular parentheses.
+    (?P<name>...) The substring matched by the group is accessible by name.
+    (?P=name)     Matches the text matched earlier by the group named name.
+    (?#...)  A comment; ignored.
+    (?=...)  Matches if ... matches next, but doesn't consume the string.
+    (?!...)  Matches if ... doesn't match next.
+    (?<=...) Matches if preceded by ... (must be fixed length).
+    (?<!...) Matches if not preceded by ... (must be fixed length).
+    (?(id/name)yes|no) Matches yes pattern if the group with id/name matched,
+                       the (optional) no pattern otherwise.
+
+The special sequences consist of "\\" and a character from the list
+below.  If the ordinary character is not on the list, then the
+resulting RE will match the second character.
+    \number  Matches the contents of the group of the same number.
+    \A       Matches only at the start of the string.
+    \Z       Matches only at the end of the string.
+    \b       Matches the empty string, but only at the start or end of a word.
+    \B       Matches the empty string, but not at the start or end of a word.
+    \d       Matches any decimal digit; equivalent to the set [0-9] in
+             bytes patterns or string patterns with the ASCII flag.
+             In string patterns without the ASCII flag, it will match the whole
+             range of Unicode digits.
+    \D       Matches any non-digit character; equivalent to [^\d].
+    \s       Matches any whitespace character; equivalent to [ \t\n\r\f\v] in
+             bytes patterns or string patterns with the ASCII flag.
+             In string patterns without the ASCII flag, it will match the whole
+             range of Unicode whitespace characters.
+    \S       Matches any non-whitespace character; equivalent to [^\s].
+    \w       Matches any alphanumeric character; equivalent to [a-zA-Z0-9_]
+             in bytes patterns or string patterns with the ASCII flag.
+             In string patterns without the ASCII flag, it will match the
+             range of Unicode alphanumeric characters (letters plus digits
+             plus underscore).
+             With LOCALE, it will match the set [0-9_] plus characters defined
+             as letters for the current locale.
+    \W       Matches the complement of \w.
+    \\       Matches a literal backslash.
+
+This module exports the following functions:
+    match     Match a regular expression pattern to the beginning of a string.
+    fullmatch Match a regular expression pattern to all of a string.
+    search    Search a string for the presence of a pattern.
+    sub       Substitute occurrences of a pattern found in a string.
+    subn      Same as sub, but also return the number of substitutions made.
+    split     Split a string by the occurrences of a pattern.
+    findall   Find all occurrences of a pattern in a string.
+    finditer  Return an iterator yielding a Match object for each match.
+    compile   Compile a pattern into a Pattern object.
+    purge     Clear the regular expression cache.
+    escape    Backslash all non-alphanumerics in a string.
+
+Each function other than purge and escape can take an optional 'flags' argument
+consisting of one or more of the following module constants, joined by "|".
+A, L, and U are mutually exclusive.
+    A  ASCII       For string patterns, make \w, \W, \b, \B, \d, \D
+                   match the corresponding ASCII character categories
+                   (rather than the whole Unicode categories, which is the
+                   default).
+                   For bytes patterns, this flag is the only available
+                   behaviour and needn't be specified.
+    I  IGNORECASE  Perform case-insensitive matching.
+    L  LOCALE      Make \w, \W, \b, \B, dependent on the current locale.
+    M  MULTILINE   "^" matches the beginning of lines (after a newline)
+                   as well as the string.
+                   "$" matches the end of lines (before a newline) as well
+                   as the end of the string.
+    S  DOTALL      "." matches any character at all, including the newline.
+    X  VERBOSE     Ignore whitespace and comments for nicer looking RE's.
+    U  UNICODE     For compatibility only. Ignored for string patterns (it
+                   is the default), and forbidden for bytes patterns.
+
+This module also defines an exception 'error'.
+
+"""
+
+import enum
+from . import _compiler, _parser
+import functools
+
+
+# public symbols
+__all__ = [
+    "match", "fullmatch", "search", "sub", "subn", "split",
+    "findall", "finditer", "compile", "purge", "template", "escape",
+    "error", "Pattern", "Match", "A", "I", "L", "M", "S", "X", "U",
+    "ASCII", "IGNORECASE", "LOCALE", "MULTILINE", "DOTALL", "VERBOSE",
+    "UNICODE", "NOFLAG", "RegexFlag",
+]
+
+__version__ = "2.2.1"
+
+@enum.global_enum
+@enum._simple_enum(enum.IntFlag, boundary=enum.KEEP)
+class RegexFlag:
+    NOFLAG = 0
+    ASCII = A = _compiler.SRE_FLAG_ASCII # assume ascii "locale"
+    IGNORECASE = I = _compiler.SRE_FLAG_IGNORECASE # ignore case
+    LOCALE = L = _compiler.SRE_FLAG_LOCALE # assume current 8-bit locale
+    UNICODE = U = _compiler.SRE_FLAG_UNICODE # assume unicode "locale"
+    MULTILINE = M = _compiler.SRE_FLAG_MULTILINE # make anchors look for newline
+    DOTALL = S = _compiler.SRE_FLAG_DOTALL # make dot match newline
+    VERBOSE = X = _compiler.SRE_FLAG_VERBOSE # ignore whitespace and comments
+    # sre extensions (experimental, don't rely on these)
+    TEMPLATE = T = _compiler.SRE_FLAG_TEMPLATE # unknown purpose, deprecated
+    DEBUG = _compiler.SRE_FLAG_DEBUG # dump pattern after compilation
+    __str__ = object.__str__
+    _numeric_repr_ = hex
+
+# sre exception
+error = _compiler.error
+
+# --------------------------------------------------------------------
+# public interface
+
+def match(pattern, string, flags=0):
+    """Try to apply the pattern at the start of the string, returning
+    a Match object, or None if no match was found."""
+    return _compile(pattern, flags).match(string)
+
+def fullmatch(pattern, string, flags=0):
+    """Try to apply the pattern to all of the string, returning
+    a Match object, or None if no match was found."""
+    return _compile(pattern, flags).fullmatch(string)
+
+def search(pattern, string, flags=0):
+    """Scan through string looking for a match to the pattern, returning
+    a Match object, or None if no match was found."""
+    return _compile(pattern, flags).search(string)
+
+def sub(pattern, repl, string, count=0, flags=0):
+    """Return the string obtained by replacing the leftmost
+    non-overlapping occurrences of the pattern in string by the
+    replacement repl.  repl can be either a string or a callable;
+    if a string, backslash escapes in it are processed.  If it is
+    a callable, it's passed the Match object and must return
+    a replacement string to be used."""
+    return _compile(pattern, flags).sub(repl, string, count)
+
+def subn(pattern, repl, string, count=0, flags=0):
+    """Return a 2-tuple containing (new_string, number).
+    new_string is the string obtained by replacing the leftmost
+    non-overlapping occurrences of the pattern in the source
+    string by the replacement repl.  number is the number of
+    substitutions that were made. repl can be either a string or a
+    callable; if a string, backslash escapes in it are processed.
+    If it is a callable, it's passed the Match object and must
+    return a replacement string to be used."""
+    return _compile(pattern, flags).subn(repl, string, count)
+
+def split(pattern, string, maxsplit=0, flags=0):
+    """Split the source string by the occurrences of the pattern,
+    returning a list containing the resulting substrings.  If
+    capturing parentheses are used in pattern, then the text of all
+    groups in the pattern are also returned as part of the resulting
+    list.  If maxsplit is nonzero, at most maxsplit splits occur,
+    and the remainder of the string is returned as the final element
+    of the list."""
+    return _compile(pattern, flags).split(string, maxsplit)
+
+def findall(pattern, string, flags=0):
+    """Return a list of all non-overlapping matches in the string.
+
+    If one or more capturing groups are present in the pattern, return
+    a list of groups; this will be a list of tuples if the pattern
+    has more than one group.
+
+    Empty matches are included in the result."""
+    return _compile(pattern, flags).findall(string)
+
+def finditer(pattern, string, flags=0):
+    """Return an iterator over all non-overlapping matches in the
+    string.  For each match, the iterator returns a Match object.
+
+    Empty matches are included in the result."""
+    return _compile(pattern, flags).finditer(string)
+
+def compile(pattern, flags=0):
+    "Compile a regular expression pattern, returning a Pattern object."
+    return _compile(pattern, flags)
+
+def purge():
+    "Clear the regular expression caches"
+    _cache.clear()
+    _compile_repl.cache_clear()
+
+def template(pattern, flags=0):
+    "Compile a template pattern, returning a Pattern object, deprecated"
+    import warnings
+    warnings.warn("The re.template() function is deprecated "
+                  "as it is an undocumented function "
+                  "without an obvious purpose. "
+                  "Use re.compile() instead.",
+                  DeprecationWarning)
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", DeprecationWarning)  # warn just once
+        return _compile(pattern, flags|T)
+
+# SPECIAL_CHARS
+# closing ')', '}' and ']'
+# '-' (a range in character set)
+# '&', '~', (extended character set operations)
+# '#' (comment) and WHITESPACE (ignored) in verbose mode
+_special_chars_map = {i: '\\' + chr(i) for i in b'()[]{}?*+-|^$\\.&~# \t\n\r\v\f'}
+
+def escape(pattern):
+    """
+    Escape special characters in a string.
+    """
+    if isinstance(pattern, str):
+        return pattern.translate(_special_chars_map)
+    else:
+        pattern = str(pattern, 'latin1')
+        return pattern.translate(_special_chars_map).encode('latin1')
+
+Pattern = type(_compiler.compile('', 0))
+Match = type(_compiler.compile('', 0).match(''))
+
+# --------------------------------------------------------------------
+# internals
+
+_cache = {}  # ordered!
+
+_MAXCACHE = 512
+def _compile(pattern, flags):
+    # internal: compile pattern
+    if isinstance(flags, RegexFlag):
+        flags = flags.value
+    try:
+        return _cache[type(pattern), pattern, flags]
+    except KeyError:
+        pass
+    if isinstance(pattern, Pattern):
+        if flags:
+            raise ValueError(
+                "cannot process flags argument with a compiled pattern")
+        return pattern
+    if not _compiler.isstring(pattern):
+        raise TypeError("first argument must be string or compiled pattern")
+    if flags & T:
+        import warnings
+        warnings.warn("The re.TEMPLATE/re.T flag is deprecated "
+                  "as it is an undocumented flag "
+                  "without an obvious purpose. "
+                  "Don't use it.",
+                  DeprecationWarning)
+    p = _compiler.compile(pattern, flags)
+    if not (flags & DEBUG):
+        if len(_cache) >= _MAXCACHE:
+            # Drop the oldest item
+            try:
+                del _cache[next(iter(_cache))]
+            except (StopIteration, RuntimeError, KeyError):
+                pass
+        _cache[type(pattern), pattern, flags] = p
+    return p
+
+@functools.lru_cache(_MAXCACHE)
+def _compile_repl(repl, pattern):
+    # internal: compile replacement pattern
+    return _parser.parse_template(repl, pattern)
+
+def _expand(pattern, match, template):
+    # internal: Match.expand implementation hook
+    template = _parser.parse_template(template, pattern)
+    return _parser.expand_template(template, match)
+
+def _subx(pattern, template):
+    # internal: Pattern.sub/subn implementation helper
+    template = _compile_repl(template, pattern)
+    if not template[0] and len(template[1]) == 1:
+        # literal replacement
+        return template[1][0]
+    def filter(match, template=template):
+        return _parser.expand_template(template, match)
+    return filter
+
+# register myself for pickling
+
+import copyreg
+
+def _pickle(p):
+    return _compile, (p.pattern, p.flags)
+
+copyreg.pickle(Pattern, _pickle, _compile)
+
+# --------------------------------------------------------------------
+# experimental stuff (see python-dev discussions for details)
+
+class Scanner:
+    def __init__(self, lexicon, flags=0):
+        from ._constants import BRANCH, SUBPATTERN
+        if isinstance(flags, RegexFlag):
+            flags = flags.value
+        self.lexicon = lexicon
+        # combine phrases into a compound pattern
+        p = []
+        s = _parser.State()
+        s.flags = flags
+        for phrase, action in lexicon:
+            gid = s.opengroup()
+            p.append(_parser.SubPattern(s, [
+                (SUBPATTERN, (gid, 0, 0, _parser.parse(phrase, flags))),
+                ]))
+            s.closegroup(gid, p[-1])
+        p = _parser.SubPattern(s, [(BRANCH, (None, p))])
+        self.scanner = _compiler.compile(p)
+    def scan(self, string):
+        result = []
+        append = result.append
+        match = self.scanner.scanner(string).match
+        i = 0
+        while True:
+            m = match()
+            if not m:
+                break
+            j = m.end()
+            if i == j:
+                break
+            action = self.lexicon[m.lastindex-1][1]
+            if callable(action):
+                self.match = m
+                action = action(self, m.group())
+            if action is not None:
+                append(action)
+            i = j
+        return result, string[i:]
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/rest_framework/test.html b/docs/latest/_modules/rest_framework/test.html new file mode 100644 index 0000000000..ec5901fdc3 --- /dev/null +++ b/docs/latest/_modules/rest_framework/test.html @@ -0,0 +1,515 @@ + + + + + + + + rest_framework.test — Evennia latest documentation + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Source code for rest_framework.test

+# Note that we import as `DjangoRequestFactory` and `DjangoClient` in order
+# to make it harder for the user to import the wrong thing without realizing.
+import io
+from importlib import import_module
+
+import django
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.core.handlers.wsgi import WSGIHandler
+from django.test import override_settings, testcases
+from django.test.client import Client as DjangoClient
+from django.test.client import ClientHandler
+from django.test.client import RequestFactory as DjangoRequestFactory
+from django.utils.encoding import force_bytes
+from django.utils.http import urlencode
+
+from rest_framework.compat import coreapi, requests
+from rest_framework.settings import api_settings
+
+
+def force_authenticate(request, user=None, token=None):
+    request._force_auth_user = user
+    request._force_auth_token = token
+
+
+if requests is not None:
+    class HeaderDict(requests.packages.urllib3._collections.HTTPHeaderDict):
+        def get_all(self, key, default):
+            return self.getheaders(key)
+
+    class MockOriginalResponse:
+        def __init__(self, headers):
+            self.msg = HeaderDict(headers)
+            self.closed = False
+
+        def isclosed(self):
+            return self.closed
+
+        def close(self):
+            self.closed = True
+
+    class DjangoTestAdapter(requests.adapters.HTTPAdapter):
+        """
+        A transport adapter for `requests`, that makes requests via the
+        Django WSGI app, rather than making actual HTTP requests over the network.
+        """
+        def __init__(self):
+            self.app = WSGIHandler()
+            self.factory = DjangoRequestFactory()
+
+        def get_environ(self, request):
+            """
+            Given a `requests.PreparedRequest` instance, return a WSGI environ dict.
+            """
+            method = request.method
+            url = request.url
+            kwargs = {}
+
+            # Set request content, if any exists.
+            if request.body is not None:
+                if hasattr(request.body, 'read'):
+                    kwargs['data'] = request.body.read()
+                else:
+                    kwargs['data'] = request.body
+            if 'content-type' in request.headers:
+                kwargs['content_type'] = request.headers['content-type']
+
+            # Set request headers.
+            for key, value in request.headers.items():
+                key = key.upper()
+                if key in ('CONNECTION', 'CONTENT-LENGTH', 'CONTENT-TYPE'):
+                    continue
+                kwargs['HTTP_%s' % key.replace('-', '_')] = value
+
+            return self.factory.generic(method, url, **kwargs).environ
+
+        def send(self, request, *args, **kwargs):
+            """
+            Make an outgoing request to the Django WSGI application.
+            """
+            raw_kwargs = {}
+
+            def start_response(wsgi_status, wsgi_headers, exc_info=None):
+                status, _, reason = wsgi_status.partition(' ')
+                raw_kwargs['status'] = int(status)
+                raw_kwargs['reason'] = reason
+                raw_kwargs['headers'] = wsgi_headers
+                raw_kwargs['version'] = 11
+                raw_kwargs['preload_content'] = False
+                raw_kwargs['original_response'] = MockOriginalResponse(wsgi_headers)
+
+            # Make the outgoing request via WSGI.
+            environ = self.get_environ(request)
+            wsgi_response = self.app(environ, start_response)
+
+            # Build the underlying urllib3.HTTPResponse
+            raw_kwargs['body'] = io.BytesIO(b''.join(wsgi_response))
+            raw = requests.packages.urllib3.HTTPResponse(**raw_kwargs)
+
+            # Build the requests.Response
+            return self.build_response(request, raw)
+
+        def close(self):
+            pass
+
+    class RequestsClient(requests.Session):
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, **kwargs)
+            adapter = DjangoTestAdapter()
+            self.mount('http://', adapter)
+            self.mount('https://', adapter)
+
+        def request(self, method, url, *args, **kwargs):
+            if not url.startswith('http'):
+                raise ValueError('Missing "http:" or "https:". Use a fully qualified URL, eg "http://testserver%s"' % url)
+            return super().request(method, url, *args, **kwargs)
+
+else:
+    def RequestsClient(*args, **kwargs):
+        raise ImproperlyConfigured('requests must be installed in order to use RequestsClient.')
+
+
+if coreapi is not None:
+    class CoreAPIClient(coreapi.Client):
+        def __init__(self, *args, **kwargs):
+            self._session = RequestsClient()
+            kwargs['transports'] = [coreapi.transports.HTTPTransport(session=self.session)]
+            super().__init__(*args, **kwargs)
+
+        @property
+        def session(self):
+            return self._session
+
+else:
+    def CoreAPIClient(*args, **kwargs):
+        raise ImproperlyConfigured('coreapi must be installed in order to use CoreAPIClient.')
+
+
+class APIRequestFactory(DjangoRequestFactory):
+    renderer_classes_list = api_settings.TEST_REQUEST_RENDERER_CLASSES
+    default_format = api_settings.TEST_REQUEST_DEFAULT_FORMAT
+
+    def __init__(self, enforce_csrf_checks=False, **defaults):
+        self.enforce_csrf_checks = enforce_csrf_checks
+        self.renderer_classes = {}
+        for cls in self.renderer_classes_list:
+            self.renderer_classes[cls.format] = cls
+        super().__init__(**defaults)
+
+    def _encode_data(self, data, format=None, content_type=None):
+        """
+        Encode the data returning a two tuple of (bytes, content_type)
+        """
+
+        if data is None:
+            return ('', content_type)
+
+        assert format is None or content_type is None, (
+            'You may not set both `format` and `content_type`.'
+        )
+
+        if content_type:
+            # Content type specified explicitly, treat data as a raw bytestring
+            ret = force_bytes(data, settings.DEFAULT_CHARSET)
+
+        else:
+            format = format or self.default_format
+
+            assert format in self.renderer_classes, (
+                "Invalid format '{}'. Available formats are {}. "
+                "Set TEST_REQUEST_RENDERER_CLASSES to enable "
+                "extra request formats.".format(
+                    format,
+                    ', '.join(["'" + fmt + "'" for fmt in self.renderer_classes])
+                )
+            )
+
+            # Use format and render the data into a bytestring
+            renderer = self.renderer_classes[format]()
+            ret = renderer.render(data)
+
+            # Determine the content-type header from the renderer
+            content_type = renderer.media_type
+            if renderer.charset:
+                content_type = "{}; charset={}".format(
+                    content_type, renderer.charset
+                )
+
+            # Coerce text to bytes if required.
+            if isinstance(ret, str):
+                ret = ret.encode(renderer.charset)
+
+        return ret, content_type
+
+    def get(self, path, data=None, **extra):
+        r = {
+            'QUERY_STRING': urlencode(data or {}, doseq=True),
+        }
+        if not data and '?' in path:
+            # Fix to support old behavior where you have the arguments in the
+            # url. See #1461.
+            query_string = force_bytes(path.split('?')[1])
+            query_string = query_string.decode('iso-8859-1')
+            r['QUERY_STRING'] = query_string
+        r.update(extra)
+        return self.generic('GET', path, **r)
+
+    def post(self, path, data=None, format=None, content_type=None, **extra):
+        data, content_type = self._encode_data(data, format, content_type)
+        return self.generic('POST', path, data, content_type, **extra)
+
+    def put(self, path, data=None, format=None, content_type=None, **extra):
+        data, content_type = self._encode_data(data, format, content_type)
+        return self.generic('PUT', path, data, content_type, **extra)
+
+    def patch(self, path, data=None, format=None, content_type=None, **extra):
+        data, content_type = self._encode_data(data, format, content_type)
+        return self.generic('PATCH', path, data, content_type, **extra)
+
+    def delete(self, path, data=None, format=None, content_type=None, **extra):
+        data, content_type = self._encode_data(data, format, content_type)
+        return self.generic('DELETE', path, data, content_type, **extra)
+
+    def options(self, path, data=None, format=None, content_type=None, **extra):
+        data, content_type = self._encode_data(data, format, content_type)
+        return self.generic('OPTIONS', path, data, content_type, **extra)
+
+    def generic(self, method, path, data='',
+                content_type='application/octet-stream', secure=False, **extra):
+        # Include the CONTENT_TYPE, regardless of whether or not data is empty.
+        if content_type is not None:
+            extra['CONTENT_TYPE'] = str(content_type)
+
+        return super().generic(
+            method, path, data, content_type, secure, **extra)
+
+    def request(self, **kwargs):
+        request = super().request(**kwargs)
+        request._dont_enforce_csrf_checks = not self.enforce_csrf_checks
+        return request
+
+
+class ForceAuthClientHandler(ClientHandler):
+    """
+    A patched version of ClientHandler that can enforce authentication
+    on the outgoing requests.
+    """
+
+    def __init__(self, *args, **kwargs):
+        self._force_user = None
+        self._force_token = None
+        super().__init__(*args, **kwargs)
+
+    def get_response(self, request):
+        # This is the simplest place we can hook into to patch the
+        # request object.
+        force_authenticate(request, self._force_user, self._force_token)
+        return super().get_response(request)
+
+
+class APIClient(APIRequestFactory, DjangoClient):
+    def __init__(self, enforce_csrf_checks=False, **defaults):
+        super().__init__(**defaults)
+        self.handler = ForceAuthClientHandler(enforce_csrf_checks)
+        self._credentials = {}
+
+    def credentials(self, **kwargs):
+        """
+        Sets headers that will be used on every outgoing request.
+        """
+        self._credentials = kwargs
+
+    def force_authenticate(self, user=None, token=None):
+        """
+        Forcibly authenticates outgoing requests with the given
+        user and/or token.
+        """
+        self.handler._force_user = user
+        self.handler._force_token = token
+        if user is None and token is None:
+            self.logout()  # Also clear any possible session info if required
+
+    def request(self, **kwargs):
+        # Ensure that any credentials set get added to every request.
+        kwargs.update(self._credentials)
+        return super().request(**kwargs)
+
+    def get(self, path, data=None, follow=False, **extra):
+        response = super().get(path, data=data, **extra)
+        if follow:
+            response = self._handle_redirects(response, data=data, **extra)
+        return response
+
+    def post(self, path, data=None, format=None, content_type=None,
+             follow=False, **extra):
+        response = super().post(
+            path, data=data, format=format, content_type=content_type, **extra)
+        if follow:
+            response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra)
+        return response
+
+    def put(self, path, data=None, format=None, content_type=None,
+            follow=False, **extra):
+        response = super().put(
+            path, data=data, format=format, content_type=content_type, **extra)
+        if follow:
+            response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra)
+        return response
+
+    def patch(self, path, data=None, format=None, content_type=None,
+              follow=False, **extra):
+        response = super().patch(
+            path, data=data, format=format, content_type=content_type, **extra)
+        if follow:
+            response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra)
+        return response
+
+    def delete(self, path, data=None, format=None, content_type=None,
+               follow=False, **extra):
+        response = super().delete(
+            path, data=data, format=format, content_type=content_type, **extra)
+        if follow:
+            response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra)
+        return response
+
+    def options(self, path, data=None, format=None, content_type=None,
+                follow=False, **extra):
+        response = super().options(
+            path, data=data, format=format, content_type=content_type, **extra)
+        if follow:
+            response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra)
+        return response
+
+    def logout(self):
+        self._credentials = {}
+
+        # Also clear any `force_authenticate`
+        self.handler._force_user = None
+        self.handler._force_token = None
+
+        if self.session:
+            super().logout()
+
+
+class APITransactionTestCase(testcases.TransactionTestCase):
+    client_class = APIClient
+
+
+class APITestCase(testcases.TestCase):
+    client_class = APIClient
+
+
+class APISimpleTestCase(testcases.SimpleTestCase):
+    client_class = APIClient
+
+
+class APILiveServerTestCase(testcases.LiveServerTestCase):
+    client_class = APIClient
+
+
+def cleanup_url_patterns(cls):
+    if hasattr(cls, '_module_urlpatterns'):
+        cls._module.urlpatterns = cls._module_urlpatterns
+    else:
+        del cls._module.urlpatterns
+
+
+class URLPatternsTestCase(testcases.SimpleTestCase):
+    """
+    Isolate URL patterns on a per-TestCase basis. For example,
+
+    class ATestCase(URLPatternsTestCase):
+        urlpatterns = [...]
+
+        def test_something(self):
+            ...
+
+    class AnotherTestCase(URLPatternsTestCase):
+        urlpatterns = [...]
+
+        def test_something_else(self):
+            ...
+    """
+    @classmethod
+    def setUpClass(cls):
+        # Get the module of the TestCase subclass
+        cls._module = import_module(cls.__module__)
+        cls._override = override_settings(ROOT_URLCONF=cls.__module__)
+
+        if hasattr(cls._module, 'urlpatterns'):
+            cls._module_urlpatterns = cls._module.urlpatterns
+
+        cls._module.urlpatterns = cls.urlpatterns
+
+        cls._override.enable()
+
+        if django.VERSION > (4, 0):
+            cls.addClassCleanup(cls._override.disable)
+            cls.addClassCleanup(cleanup_url_patterns, cls)
+
+        super().setUpClass()
+
+    if django.VERSION < (4, 0):
+        @classmethod
+        def tearDownClass(cls):
+            super().tearDownClass()
+            cls._override.disable()
+
+            if hasattr(cls, '_module_urlpatterns'):
+                cls._module.urlpatterns = cls._module_urlpatterns
+            else:
+                del cls._module.urlpatterns
+
+ +
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/docs/latest/_sources/Coding/Changelog.md.txt b/docs/latest/_sources/Coding/Changelog.md.txt new file mode 100644 index 0000000000..e6f9234a3b --- /dev/null +++ b/docs/latest/_sources/Coding/Changelog.md.txt @@ -0,0 +1,1055 @@ +# Changelog + +## Main branch + +- Dependency: Twisted 23.10 (<24) to address upstream CVE alert. +- Dependency (potentially Backwards incompatible): Django 4.2 (<4.3). Increases + minimum supported versions of MariaDB, MySQL and PostgreSQL, + see [django release nodes][django-release-notes] +- [Feature][pull3313] (Backwards incompatible): `OptionHandler.set` now returns + `BaseOption` rather than its `.value`. Instead access `.value` or `.display()` + on this return for more control. (Volund) +- [Feature][pull3278]: (Backwards incompatible): Refactor home page into multiple sub-parts for easier + overriding and composition (johnnyvoruz) +- [Feature][pull3180]: (Potentially Backwards incompatible): Make build commands + easier to override, with new utility hooks (Volund) +- [Feature][issue3273]: Allow passing `text_kwargs` kwarg to `EvMore.msg` in order to expand + the outputfunc used for every evmore page. +- [Feature][pull3286]: Allow Discord bot to change user's nickname and assign + roles for a user on a given server (holl0wstar). +- [Feature][pull3301]: Make EvenniaAdminSite include custom models better; adds + `DJANGO_ADMIN_APP_ORDER` and `DJANGO_ADMIN_APP_EXCLUDE` as modifable + settings.(Volund) +- [Feature][pull3179]: Handling of the `.db._playable_characters` helper + methods. Also adds events hooks to modify effects when this list changes (Volund) + avoiding race conditions until server starts (Volund) +- [Feature][pull3281]: Add `$your()` and `$Your()` for actor stance emoting (Volund) +- [Feature][pull3177]: Add `Account.get_character_slots()`, + `.get_available_character_slots()`, `.check_available_slots` and + `at_post_create_character` methods to allow better customization of character creation (Volund) +- [Feature][pull3319]: Refactor/cleanup of Evennia server/portal startup files + into services for easier overriding (Volund) +- [Feature][issue3307]: Add support for Attribute-categories when using the monitorhandler + with input funcs to monitor Attribute changes. +- [Feature][pull3342]: Add `Command.cmdset_source`, referring to the cmdset each + command was originally pulled from (Volund) +- [Feature][pull3343]: Add `access_type` as optional kwarg to lockfuncs (Volund) +- [Feature][pull3344]: New middleware for checking IP/subnets from requests. New + tools `evennia.utils.match_ip` and `utils.ip_from_request` to help. (Volund) +- [Feature][pull3349]: Refactored almost all default commands to use + `Command.msg` over the `command.caller.msg` direct call (more flexible) (Volund) +- [Feature][pull3346]: Refactor cmdhandler to be more extensible; make cmd merge + a bit more deterministic (Volund) +- [Feature][pull3348]: Make Fallback AJAX web client more customizable (same as + the websocket client) (Volund) +- Fix (Backwards incompatible): Change `settings._TEST_ENVIRONMENT` to + `settings.TEST_ENVIRONMENT` to address issues during refactored startup sequence. +- [Fix][pull3347]: New `generate_default_locks()` method on typeclasses; + `.create` and `lockhandler.add()` will now properly handle emptry strings +(Volund) +- [Fix][pull3197]: Make sure Global scripts only start in one place, +- [Fix][pull3324]: Make account-post-login-fail signal fire properly. Add + `CUSTOM_SIGNAL` for adding one's own signals (Volund) +- [Fix][pull3267]: Missing recache step in ObjectSessionHandler (InspectorCaracal) +- [Fix][pull3270]: Evennia is its own MSSP family now, so we should return that + instead of 'Custom' (InspectorCaracal) +- [Fix][pull3274]: Traceback when creating objects with initial nattributes + (InspectorCaracal) +- [Fix][issue3272]: Make sure `ScriptHandler.add` does not fail if passed an + instantiated script. (Volund) +- [Fix][pull3350]: `CmdHelp` was using the wrong protocol-key identifier when + routing to the ajax web client. +- [Fix][pull3338]: Resolve if/elif bug in XYZGrid contrib launch command + (jaborsh) +- [fix][issue3331]: Made XYZGrid query zcoords in a case-insensitive manner. +- [Fix][pull3322]: Fix `BaseOption.display` to always return a string. +- [Fix][pull3358]: Fix so Portal resets `server_restart_mode` flag when having + successfully reconnected to the Server after a restart. (InspectorCaracal) +- [Fix][pull3359]: Fix gendersub contrib to use proper pronoun when referencing + other objects than oneself (InspectorCaracal) +- [Fix][pull3361]: Fix of monitoring Attributes with categories (scyfris) +- Docs & docstrings: Lots of Typo and other fixes (iLPdev, InspectorCaracal, jaborsh, + HouseOfPoe etc) +- Beginner tutorial: Cleanup and starting earlier with explaining how to add to + the default cmdsets. + +[pull3267]: https://github.com/evennia/evennia/pull/3267 +[pull3270]: https://github.com/evennia/evennia/pull/3270 +[pull3274]: https://github.com/evennia/evennia/pull/3274 +[pull3278]: https://github.com/evennia/evennia/pull/3278 +[pull3286]: https://github.com/evennia/evennia/pull/3286 +[pull3301]: https://github.com/evennia/evennia/pull/3301 +[pull3179]: https://github.com/evennia/evennia/pull/3179 +[pull3197]: https://github.com/evennia/evennia/pull/3197 +[pull3313]: https://github.com/evennia/evennia/pull/3313 +[pull3281]: https://github.com/evennia/evennia/pull/3281 +[pull3322]: https://github.com/evennia/evennia/pull/3322 +[pull3177]: https://github.com/evennia/evennia/pull/3177 +[pull3180]: https://github.com/evennia/evennia/pull/3180 +[pull3319]: https://github.com/evennia/evennia/pull/3319 +[pull3324]: https://github.com/evennia/evennia/pull/3324 +[pull3338]: https://github.com/evennia/evennia/pull/3338 +[pull3342]: https://github.com/evennia/evennia/pull/3342 +[pull3343]: https://github.com/evennia/evennia/pull/3343 +[pull3344]: https://github.com/evennia/evennia/pull/3344 +[pull3349]: https://github.com/evennia/evennia/pull/3349 +[pull3350]: https://github.com/evennia/evennia/pull/3350 +[pull3346]: https://github.com/evennia/evennia/pull/3346 +[pull3348]: https://github.com/evennia/evennia/pull/3348 +[pull3358]: https://github.com/evennia/evennia/pull/3358 +[pull3359]: https://github.com/evennia/evennia/pull/3359 +[pull3361]: https://github.com/evennia/evennia/pull/3361 +[pull3347]: https://github.com/evennia/evennia/pull/3347 +[issue3272]: https://github.com/evennia/evennia/issues/3272 +[issue3273]: https://github.com/evennia/evennia/issues/3273 +[issue3308]: https://github.com/evennia/evennia/issues/3307 +[issue3331]: https://github.com/evennia/evennia/issues/3331 + +[django-release-notes]: https://docs.djangoproject.com/en/4.2/releases/4.2/#backwards-incompatible-changes-in-4-2 + +## Evennia 2.3.0 + +Sept 3, 2023 + +- Feat: EvMenu tooltips for multiple help categories in a node (Seannio). +- Feat: Default `examine` command now also shows an account's `last_login` + (michaelfaith84) +- Fix: Portal would accidentally start global scripts. (blongden) +- Fix: Traceback when printing CounterTrait contrib objects. (InspectorCaracal) +- Fix: Typo in evadventure twitch combat's call of `create_combathandler`. +- Docs: Fix bug in evadventure equipmenthandler blocking creation of npcs. + in-game. +- Docs: Plenty of typo fixes (iLPDev, moldikins, others) + +## Evennia 2.2.0 + +Aug 6, 2023 + +- Contrib: Large-language-model (LLM) AI integration; allows NPCs to talk using + responses from an LLM server. +- Fix: Make sure `at_server_reload` is called also on non-repeating Scripts. +- Fix: Webclient was not giving a proper error when sending an unknown outputfunc to it. +- Fix: Make `py` command always send strings unless `client_raw` flag is set. +- Fix: `Script.start` with an integer `start_delay` caused a traceback. +- Fix: Removing "Guest" from the permission-hierarchy setting messed up access. +- Docs: Remove doc pages for Travis/TeamCity CI tools, they were both very much + out of date, and Travis is not free for OSS anymore. +- Docs: A lot fixes of typos and bugs in tutorials. + +## Evennia 2.1.0 + +July 14, 2023 + +- Fix: The new `ExtendedRoom` contrib has a bug when dug with no descriptions. +- Fix: Clean up `get_sides` function in evadventure tutorial to return also + the calling combatant with its `allies` return, to make it easier to reason around. +- Feature: Add `SSL_CERTIFICATE_ISSUERS` setting for customizing Telnet+SSL. +- Contrib: Refactored `dice.roll` contrib function to use `safe_eval`. Can now + optionally be used as `dice.roll("2d10 + 4 > 10")`. Old way works too. +- Lots of doc updates. + +## Evennia 2.0.1 + +June 17, 2023 + +- Fix: A look-bug in the `ExtendedRoom` contrib (InspectorCaracal) + +## Evennia 2.0.0 + +June 10, 2023 + +- **Possible backwards incompatibility**: Updated contrib `ExtendedRoom` now + supports arbitrary room-states, state-based descriptions, embedded funcparser + tags, details and random messages. While this feature is made to be as + backwards-compatible as possible, so many people depend on this contrib class + that we are updating the major Evennia version to indicate the big changes. +- New Contrib: `Container` typeclass with new commands for storing and retrieving + things inside them (InspectorCaracal) +- Feature: Add `TagCategoryProperty` for setting categories with multiple tags + as properties directly on objects. Complements `TagProperty`. +- Feature: Attribute-support for saving/loading `deques` with `maxlen=` set. +- Feature: Refactor to provide `evennia.SESSION_HANDLER` for easier overloading + and less risks of circular import problems (Volund) +- Fix: Allow webclient's goldenlayout UI (default) to understand `msg` + `cls` kwarg for customizing the CSS class for every resulting `div` (friarzen) +- Fix: The `AttributeHandler.all()` now actually accepts `category=` as + keyword arg, like our docs already claimed it should (Volund) +- Fix: `TickerHandler` store key updating was refactored, fixing an issue with + updating intervals (InspectorCaracal) +- Docs: Removed warning about Python3.11 on Windows; upstream Twistd now + supports 3.11 on Windows. +- Docs: New Beginner-Tutorial lessons for NPCs, Base-Combat Twitch-Combat and + Turnbased-combat (note that the Beginner tutorial is still WIP). +- Stabilize how to make the major update in the docs. +- Fix: A lot of other minor bug fixes. + + +## Evennia 1.3.0 + +Apr 29, 2023 + +- Feature: Better ANSI color fallbacks (InspectorCaracal). +- Feature: Add support for saving `deque` with `maxlen` to Attributes (before + `maxlen` was ignored). +- Fix: The username validator did not display errors correctly in web + registration form. +- Fix: Components contrib had issues with inherited typeclasses (ChrisLR) +- Fix: f-string fix in clothing contrib (aMiss-aWry) +- Fix: Have `EvenniaTestCase` properly flush idmapper cache (bradleymarques) +- Tools: More unit tests for scripts (Storsorken) +- Docs: Made separate doc pages for Exits, Characters and Rooms. Expanded on how + to change the description of an in-game object with templating. +- Docs: A multitude of doc issues and typos fixed. + +## Evennia 1.2.1 + +Feb 26, 2023 + +- Bug fix: Make sure command parser gives precedence to longer cmd-aliases. So + if sending `smile at` and the cmd `smile` has alias `smile at`, the match is + ordered so the result is never interpreted as `smile` with an argument `at`. +- Bug fix: || (escaped color tags) were parsed too early in help entries, + leading to colors when wanting a | separator +- Bug fix: Make sure spawned objects get `typeclass_path` pointing to the true + location rather than alias (in line with `create_object`). +- Bug fix: Building Menu contrib menu no using Replace over Union mergetype to + avoid clashing with in-game commands while building +- Feature: RPSystem contrib `sdesc` command can now view/delete your sdesc. +- Bug fix: Change so `script obj = [scriptname|id]` is required to manipulate + scripts on objects; `script scriptname|id` only works on global scripts. +- Doc: Add warning about `Django-wiki` (in wiki tutorial) only supporting + Django <4.0. +- Doc: Expanded `XYZGrid` docstring to clarify `MapLink` class will not itself + spawn anything, children must define their prototypes explicitly. +- Doc: Explained why `AttributeProperty.at_get/set` will not be called if + accessing the Attribute from the `AttributeHandler` (bypassing the property) +- Bug fix: Evtable options showed spurious empty lines if set without desc +- Usage fix: The `teleport:` and `teleport_here:` locks where checked in + `CmdTeleport`, but not actually set on any entities. These locks are now + set with defaults on all objects,characters,rooms and exits. + +## Evennia 1.2.0 + +Feb 25, 2023 + +- Bug fix: `TagHandler.get` did not consistently cast to string (aMiss-aWry) +- Bug fix: Channels hard to manage if given in different case (aMiss-aWry) +- Feature: `logger.delete_log` function for deleting custom logs from inside the + server (aMiss-aWry) +- Doc: Nginx setup (InspectorCaracal) +- Feature: Add `fly/dive` commands to `XYZGrid` contrib to showcase treating its + Z-axis as a full 3D grid. Also fixed minor bug in `XYZGrid` contrib when using + a Z axis named using an integer rather than a string. +- Bug fix: `$an()` inlinefunc didn't understand to use 'an' words starting with a + capital vowel +- Bug fix: Another case of the 'duplicate Discord bot connections' bug + (InspectorCaracal) +- Fix: Make XYZGrid contrib's MapParserErrors more succinct + +## Evennia 1.1.1 + +Jan 15, 2023 + +- Bug fix: Better handler malformed alias-regex given to nickhandler. A + regex-relevant character in a channel alias could cause server to not restart. +- Feature: Add `attr` keyword to `create_channel`. This allows setting + attributes on channels at creation, also from `DEFAULT_CHANNELS` definitions. + +## Evennia 1.1.0 +Jan 7, 2023 + +- Stop new registrations with `settings.NEW_ACCOUNT_REGISTRATION_ENABLED` + (inspectorcaracal) +- Bug fixes. + +## Evennia 1.0.2 +Dec 21, 2022 + +- Bug fix release. Fix more issues with discord bot reconnecting. Some doc +updates. + +## Evennia 1.0.1 +Dec 7, 2022 + +- Bug fix release. Main issue was reconnect bug for discord bot. + +## Evennia 1.0.0 + +2019-2022 + +_Changed to using `main` branch to follow github standard. Old `master` branch remains +for now but will not be used anymore, so as to not break installs during transition._ + +Also changing to using semantic versioning with this version. + +Increase requirements: Django 4.1+, Twisted 22.10+ Python 3.10, 3.11. PostgreSQL 11+. + +- 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 +- REST API allows you external access to db objects through HTTP requests (Tehom) +- `Object.normalize_name` and `.validate_name` added to (by default) enforce latinify + on character name and avoid potential exploits using clever Unicode chars (trhr) +- New `utils.format_grid` for easily displaying long lists of items in a block. +- Using `lunr` search indexing for better `help` matching and suggestions. Also improve + the main help command's default listing output. +- Added `content_types` indexing to DefaultObject's ContentsHandler. (volund) +- Made most of the networking classes such as Protocols and the SessionHandlers + replaceable via `settings.py` for modding enthusiasts. (volund) +- The `initial_setup.py` file can now be substituted in `settings.py` to customize + initial game database state. (volund) +- Added new Traits contrib, converted and expanded from Ainneve project. +- Added new `requirements_extra.txt` file for easily getting all optional dependencies. +- Change default multi-match syntax from 1-obj, 2-obj to obj-1, obj-2. +- Make `object.search` support 'stacks=0' keyword - if ``>0``, the method will return + N identical matches instead of triggering a multi-match error. +- Add `tags.has()` method for checking if an object has a tag or tags (PR by ChrisLR) +- Make IP throttle use Django-based cache system for optional persistence (PR by strikaco) +- Renamed Tutorial classes "Weapon" and "WeaponRack" to "TutorialWeapon" and + "TutorialWeaponRack" to prevent collisions with classes in mygame +- New `crafting` contrib, adding a full crafting subsystem (Griatch 2020) +- The `rplanguage` contrib now auto-capitalizes sentences and retains ellipsis (...). This + change means that proper nouns at the start of sentences will not be treated as nouns. +- Make MuxCommand `lhs/rhslist` always be lists, also if empty (used to be the empty string) +- Fix typo in UnixCommand contrib, where `help` was given as `--hel`. +- Latin (la) i18n translation (jamalainm) +- Made the `evennia` dir possible to use without gamedir for purpose of doc generation. +- Make Scripts' timer component independent from script object deletion; can now start/stop + timer without deleting Script. The `.persistent` flag now only controls if timer survives + reload - Script has to be removed with `.delete()` like other typeclassed entities. +- Add `utils.repeat` and `utils.unrepeat` as shortcuts to TickerHandler add/remove, similar + to how `utils.delay` is a shortcut for TaskHandler add. +- Refactor the classic `red_button` example to use `utils.delay/repeat` and modern recommended + code style and paradigms instead of relying on `Scripts` for everything. +- Expand `CommandTest` with ability to check multiple message-receivers; inspired by PR by + user davewiththenicehat. Also add new doc string. +- Add central `FuncParser` as a much more powerful replacement for the old `parse_inlinefunc` + function. +- Attribute/NAttribute got a homogenous representation, using intefaces, both + `AttributeHandler` and `NAttributeHandler` has same api now. +- Add `evennia/utils/verb_conjugation` for automatic verb conjugation (English only). This + is useful for implementing actor-stance emoting for sending a string to different targets. +- New version of Italian translation (rpolve) +- `utils.evmenu.ask_yes_no` is a helper function that makes it easy to ask a yes/no question + to the user and respond to their input. This complements the existing `get_input` helper. +- Allow sending messages with `page/tell` without a `=` if target name contains no spaces. +- New FileHelpStorage system allows adding help entries via external files. +- `sethelp` command now warns if shadowing other help-types when creating a new + entry. +- Help command now uses `view` lock to determine if cmd/entry shows in index and + `read` lock to determine if it can be read. It used to be `view` in the role + of the latter. Migration swaps these around. +- In modules given by `settings.PROTOTYPE_MODULES`, spawner will now first look for a global + list `PROTOTYPE_LIST` of dicts before loading all dicts in the module as prototypes. +- New Channel-System using the `channel` command and nicks. Removed the `ChannelHandler` and the + concept of a dynamically created `ChannelCmdSet`. +- Add `Msg.db_receiver_external` field to allowe external, string-id message-receivers. +- Renamed `app.css` to `website.css` for consistency. Removed old prosimii-css files. +- Remove `mygame/web/static_overrides` and -`template_overrides`, reorganize website/admin/client/api + into a more consistent structure for overriding. Expanded webpage documentation considerably. +- REST API list-view was shortened (#2401). New CSS/HTML. Add ReDoc for API autodoc page. +- Update and fix dummyrunner with cleaner code and setup. +- Made `iter_to_str` format prettier strings, using Oxford comma. +- Added an MXP anchor tag to also support clickable web links. +- New `tasks` command for managing tasks started with `utils.delay` (PR by davewiththenicehat) +- Make `help` index output clickable for webclient/clients with MXP (PR by davewiththenicehat) +- Custom `evennia` launcher commands (e.g. `evennia mycmd foo bar`). Add new commands as callables + accepting `*args`, as `settings.EXTRA_LAUNCHER_COMMANDS = {'mycmd': 'path.to.callable', ...}`. +- New `XYZGrid` contrib, adding x,y,z grid coordinates with in-game map and + pathfinding. Controlled outside of the game via custom evennia launcher command. +- `Script.delete` has new kwarg `stop_task=True`, that can be used to avoid + infinite recursion when wanting to set up Script to delete-on-stop. +- Command executions now done on copies to make sure `yield` don't cause crossovers. Add + `Command.retain_instance` flag for reusing the same command instance. +- The `typeclass` command will now correctly search the correct database-table for the target + obj (avoids mistakenly assigning an AccountDB-typeclass to a Character etc). +- Merged `script` and `scripts` commands into one, for both managing global- and + on-object Scripts. Moved `CmdScripts` and `CmdObjects` to `commands/default/building.py`. +- Keep GMCP function case if outputfunc starts with capital letter (so `cmd_name` -> `Cmd.Name` + but `Cmd_nAmE` -> `Cmd.nAmE`). This helps e.g Mudlet's legacy `Client_GUI` implementation) +- Prototypes now allow setting `prototype_parent` directly to a prototype-dict. + This makes it easier when dynamically building in-module prototypes. +- `RPSystem contrib` was expanded to support case, so /tall becomes 'tall man' + while /Tall becomes 'Tall man'. One can turn this off if wanting the old style. +- Change `EvTable` fixed-height rebalance algorithm to fill with empty lines at end of + column instead of inserting rows based on cell-size (could be mistaken for a bug). +- Split `return_appearance` hook with helper methods and have it use a template + string in order to make it easier to override. +- Add validation question to default account creation. +- Add `LOCALECHO` client option to add server-side echo for clients that does + not support this (useful for getting a complete log). +- Make `@lazy_property` decorator create read/delete-protected properties. This is + because it's used for handlers, and e.g. self.locks=[] is a common beginner mistake. +- Add `$pron()` inlinefunc for pronoun parsing in actor-stance strings using + `msg_contents`. +- Update defauklt website to show Telnet/SSL/SSH connect info. Added new + `SERVER_HOSTNAME` setting for use in the server:port stanza. +- Changed all `at_before/after_*` hooks to `at_pre/post_*` for consistency + across Evennia (the old names still work but are deprecated) +- Change `settings.COMMAND_DEFAULT_ARG_REGEX` default from `None` to a regex meaning that + a space or `/` must separate the cmdname and args. This better fits common expectations. +- Add confirmation question to `ban`/`unban` commands. +- Check new `teleport` and `teleport_here` lock-types in `teleport` command to optionally + allow to limit teleportation of an object or to a specific destination. +- Add `settings.MXP_ENABLED=True` and `settings.MXP_OUTGOING_ONLY=True` as sane defaults, + to avoid known security issues with players entering MXP links. +- Add browser name to webclient `CLIENT_NAME` in `session.protocol_flags`, e.g. + `"Evennia webclient (websocket:firefox)"` or `"evennia webclient (ajax:chrome)"`. +- `TagHandler.add/has(tag=...)` kwarg changed to `add/has(key=...)` for consistency + with other handlers. +- Make `DefaultScript.delete`, `DefaultChannel.delete` and `DefaultAccount.delete` return + bool True/False if deletion was successful (like `DefaultObject.delete` before them) +- `contrib.custom_gametime` days/weeks/months now always starts from 1 (to match + the standard calendar form ... there is no month 0 every year after all). +- `AttributeProperty`/`NAttributeProperty` to allow managing Attributes/NAttributes + on typeclasses in the same way as Django fields. +- Give build/system commands a `@name` to fall back to if the non-@ name is used + by another command (like `open` and `@open`. If no duplicate, @ is optional. +- Move legacy channel-management commands (`ccreate`, `addcom` etc) to a contrib + since their work is now fully handled by the single `channel` command. +- Expand `examine` command's code to much more extensible and modular. Show + attribute categories and value types (when not strings). +- `AttributeHandler.remove(key, return_exception=False, category=None, ...)` changed + to `.remove(key, category=None, return_exception=False, ...)` for consistency. +- New `command cooldown` contrib for making it easier to manage commands using + dynamic cooldowns between uses (owllex) +- Restructured `contrib/` folder, placing all contribs as separate packages under + subfolders. All imports will need to be updated. +- Made `MonitorHandler.add/remove` support `category` for monitoring Attributes + with a category (before only key was used, ignoring category entirely). +- Move `create_*` functions into db managers, leaving `utils.create` only being + wrapper functions (consistent with `utils.search`). No change of api otherwise. +- Add support for `$dbref()` and `$search` when assigning an Attribute value + with the `set` command. This allows assigning real objects from in-game. +- Add ability to examine `/script` and `/channel` entities with `examine` command. +- Homogenize manager search methods to return querysets and not lists. +- Restructure unit tests to always honor default settings; make new parents in + on location for easy use in game dir. +- The `Lunr` search engine used by help excludes common words; the settings-list + `LUNR_STOP_WORD_FILTER_EXCEPTIONS` can be extended to make sure common names are included. +- Add `.deserialize()` method to `_Saver*` structures to help completely + decouple structures from database without needing separate import. +- Add `run_in_main_thread` as a helper for those wanting to code server code + from a web view. +- Update `evennia.utils.logger` to use Twisted's new logging API. No change in Evennia API + except more standard aliases logger.error/info/exception/debug etc can now be used. +- Have `type/force` default to `update`-mode rather than `reset`mode and add more verbose + warning when using reset mode. +- Attribute storage support defaultdics (Hendher) +- Add ObjectParent mixin to default game folder template as an easy, ready-made + way to override features on all ObjectDB-inheriting objects easily. + source location, mimicking behavior of `at_pre_move` hook - returning False will abort move. +- Add `TagProperty`, `AliasProperty` and `PermissionProperty` to assign these + data in a similar way to django fields. +- New `at_pre_object_receive(obj, source_location)` method on Objects. Called on + destination, mimicking behavior of `at_pre_move` hook - returning False will abort move. +- New `at_pre_object_leave(obj, destination)` method on Objects. Called on +- The db pickle-serializer now checks for methods `__serialize_dbobjs__` and `__deserialize_dbobjs__` + to allow custom packing/unpacking of nested dbobjs, to allow storing in Attribute. +- Optimizations to rpsystem contrib performance. Breaking change: `.get_sdesc()` will + now return `None` instead of `.db.desc` if no sdesc is set; fallback in hook (inspectorCaracal) +- Reworked text2html parser to avoid problems with stateful color tags (inspectorCaracal) +- Simplified `EvMenu.options_formatter` hook to use `EvColumn` and f-strings (inspectorcaracal) +- Allow `# CODE`, `# HEADER` etc as well as `#CODE`/`#HEADER` in batchcode + files - this works better with black linting. +- Added `move_type` str kwarg to `move_to()` calls, optionally identifying the type of + move being done ('teleport', 'disembark', 'give' etc). (volund) +- Made RPSystem contrib msg calls pass `pose` or `say` as msg-`type` for use in + e.g. webclient pane filtering where desired. (volund) +- Added `Account.uses_screenreader(session=None)` as a quick shortcut for + finding if a user uses a screenreader (and adjust display accordingly). +- Fixed bug in `cmdset.remove()` where a command could not be deleted by `key`, + even though doc suggested one could (ChrisLR) +- New contrib `name_generator` for building random real-world based or fantasy-names + based on phonetic rules. +- Enable proper serialization of dict subclasses in Attributes (aogier) +- `object.search` fuzzy-matching now uses `icontains` instead of `istartswith` + to better match how search works elsewhere (volund) +- The `.at_traverse` hook now receives a `exit_obj` kwarg, linking back to the + exit triggering the hook (volund) +- Contrib `buffs` for managing temporary and permanent RPG status buffs effects (tegiminis) +- New `at_server_init()` hook called before all other startup hooks for all + startup modes. Used for more generic overriding (volund) +- New `search` lock type used to completely hide an object from being found by + the `DefaultObject.search` (`caller.search`) method. (CloudKeeper) +- Change setting `MULTISESSION_MODE` to now only control sessions, not how many + characters can be puppeted simultaneously. New settings now control that. +- Add new setting `AUTO_CREATE_CHARACTER_WITH_ACCOUNT`, a boolean deciding if + the new account should also get a matching character (legacy MUD style). +- Add new setting `AUTO_PUPPET_ON_LOGIN`, boolean deciding if one should + automatically puppet the last/available character on connection (legacy MUD style) +- Add new setting `MAX_NR_SIMULTANEUS_PUPPETS` - how many puppets the account + can run at the same time. Used to limit multi-playing. +- Make setting `MAX_NR_CHARACTERS` interact better with the new settings above. +- Allow `$search` funcparser func to search tags and to accept kwargs for more + powerful searches passed into the regular search functions. +- `spawner.spawn` and linked methods now has a kwarg `protfunc_raise_errors` + (default True) to disable strict errors on malformed/not-found protfuncs +- Improve search performance when having many DB-based prototypes via caching. +- Remove the `return_parents` kwarg of `evennia.prototypes.spawner.spawn` since it + was inefficient and unused. +- Made all id fields BigAutoField for all databases. (owllex) +- `EvForm` refactored. New `literals` mapping, for literal mappings into the + main template (e.g. for single-character replacements). +- `EvForm` `cells` kwarg now accepts `EvCells` with custom formatting options + (mainly for custom align/valign). `EvCells` now makes use of `utils.justify`. +- `utils.justify` now supports `align="a"` (absolute alignments. This keeps + the given left indent but crops/fills to the width. Used in EvCells. +- `EvTable` now supports passing `EvColumn`s as a list directly, (`EvTable(table=[colA,colB])`) +- Add `tags=` search criterion to `DefaultObject.search`. +- Add `AT_EXIT_TRAVERSE` signal, firing when an exit is traversed. +- Add integration between Evennia and Discord channels (PR by Inspector Cararacal) +- Support for using a Godot-powered client with Evennia (PR by ChrisLR) +- Added German translation (patch by Zhuraj) + +## Evennia 0.9.5 + +> 2019-2020 +> Released 2020-11-14. +> Transitional release, including new doc system. + +Backported from develop: Python 3.8, 3.9 support. Django 3.2+ support, Twisted 21+ support. + +- `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. +- Webclient default to a single input pane instead of two. Now defaults to no help-popup. +- Webclient fix of prompt display +- Webclient multimedia support for relaying images, video and sounds via + `.msg(image=URL)`, `.msg(video=URL)` + and `.msg(audio=URL)` +- Add Spanish translation (fermuch) +- Expand `GLOBAL_SCRIPTS` container to always start scripts and to include all + global scripts regardless of how they were created. +- Change settings to always use lists instead of tuples, to make mutable + settings easier to add to. (#1912) +- Make new `CHANNEL_MUDINFO` setting for specifying the mudinfo channel +- Make `CHANNEL_CONNECTINFO` take full channel definition +- Make `DEFAULT_CHANNELS` list auto-create channels missing at reload +- Webclient `ANSI->HTML` parser updated. Webclient line width changed from 1.6em to 1.1em + to better make ANSI graphics look the same as for third-party clients +- `AttributeHandler.get(return_list=True)` will return `[]` if there are no + Attributes instead of `[None]`. +- Remove `pillow` requirement (install especially if using imagefield) +- Add Simplified Korean translation (aceamro) +- Show warning on `start -l` if settings contains values unsafe for production. +- Make code auto-formatted with Black. +- Make default `set` command able to edit nested structures (PR by Aaron McMillan) +- Allow running Evennia test suite from core repo with `make test`. +- Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to + the `TickerHandler.remove` method. This makes it easier to manage tickers. +- EvMore auto-justify now defaults to False since this works better with all types + of texts (such as tables). New `justify` bool. Old `justify_kwargs` remains + but is now only used to pass extra kwargs into the justify function. +- EvMore `text` argument can now also be a list or a queryset. Querysets will be + sliced to only return the required data per page. +- Improve performance of `find` and `objects` commands on large data sets (strikaco) +- New `CHANNEL_HANDLER_CLASS` setting allows for replacing the ChannelHandler entirely. +- Made `py` interactive mode support regular quit() and more verbose. +- Made `Account.options.get` accept `default=None` kwarg to mimic other uses of get. Set + the new `raise_exception` boolean if ranting to raise KeyError on a missing key. +- Moved behavior of unmodified `Command` and `MuxCommand` `.func()` to new + `.get_command_info()` method for easier overloading and access. (Volund) +- Removed unused `CYCLE_LOGFILES` setting. Added `SERVER_LOG_DAY_ROTATION` + and `SERVER_LOG_MAX_SIZE` (and equivalent for PORTAL) to control log rotation. +- Addded `inside_rec` lockfunc - if room is locked, the normal `inside()` lockfunc will + fail e.g. for your inventory objs (since their loc is you), whereas this will pass. +- RPSystem contrib's CmdRecog will now list all recogs if no arg is given. Also multiple + bugfixes. +- Remove `dummy@example.com` as a default account email when unset, a string is no longer + required by Django. +- Fixes to `spawn`, make updating an existing prototype/object work better. Add `/raw` switch + to `spawn` command to extract the raw prototype dict for manual editing. +- `list_to_string` is now `iter_to_string` (but old name still works as legacy alias). It will + now accept any input, including generators and single values. +- EvTable should now correctly handle columns with wider asian-characters in them. +- Update Twisted requirement to >=2.3.0 to close security vulnerability +- Add `$random` inlinefunc, supports minval,maxval arguments that can be ints and floats. +- Add `evennia.utils.inlinefuncs.raw()` as a helper to escape inlinefuncs in a string. +- Make CmdGet/Drop/Give give proper error if `obj.move_to` returns `False`. +- Make `Object/Room/Exit.create`'s `account` argument optional. If not given, will set perms + to that of the object itself (along with normal Admin/Dev permission). +- Make `INLINEFUNC_STACK_MAXSIZE` default visible in `settings_default.py`. +- Change how `ic` finds puppets; non-priveleged users will use `_playable_characters` list as + candidates, Builders+ will use list, local search and only global search if no match found. +- Make `cmd.at_post_cmd()` always run after `cmd.func()`, even when the latter uses delays + with yield. +- `EvMore` support for db queries and django paginators as well as easier to override for custom + 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. +- Add `Command.client_height()` method to match existing `.client_width` (stricako) +- Include more Web-client info in `session.protocol_flags`. +- Fixes in multi-match situations - don't allow finding/listing multimatches for 3-box when + only two boxes in location. +- Fix for TaskHandler with proper deferred returns/ability to cancel etc (PR by davewiththenicehat) +- Add `PermissionHandler.check` method for straight string perm-checks without needing lockstrings. +- Add `evennia.utils.utils.strip_unsafe_input` for removing html/newlines/tags from user input. The + `INPUT_CLEANUP_BYPASS_PERMISSIONS` is a list of perms that bypass this safety stripping. +- Make default `set` and `examine` commands aware of Attribute categories. + +## Evennia 0.9 + +> 2018-2019 +> Released Oct 2019 + +### Distribution + +- New requirement: Python 3.7 (py2.7 support removed) +- Django 2.1 +- Twisted 19.2.1 +- Autobahn websockets (removed old tmwx) +- Docker image updated + +### Commands + +- Remove `@`-prefix from all default commands (prefixes still work, optional) +- Removed default `@delaccount` command, incorporating as `@account/delete` instead. Added confirmation + question. +- Add new `@force` command to have another object perform a command. +- Add the Portal uptime to the `@time` command. +- Make the `@link` command first make a local search before a global search. +- Have the default Unloggedin-look command look for optional `connection_screen()` callable in + `mygame/server/conf/connection_screen.py`. This allows for more flexible welcome screens + that are calculated on the fly. +- `@py` command now defaults to escaping html tags in its output when viewing in the webclient. + Use new `/clientraw` switch to get old behavior (issue #1369). +- Shorter and more informative, dynamic, listing of on-command vars if not + setting func() in child command class. +- New Command helper methods + - `.client_width()` returns client width of the session running the command. + - `.styled_table(*args, **kwargs)` returns a formatted evtable styled by user's options + - `.style_header(*args, **kwargs)` creates styled header entry + - `.style_separator(*args, **kwargs)` " separator + - `.style_footer(*args, **kwargs)` " footer + +### Web + +- Change webclient from old txws version to use more supported/feature-rich Autobahn websocket library + +#### Evennia game index + +- Made Evennia game index client a part of core - now configured from settings file (old configs + need to be moved) +- The `evennia connections` command starts a wizard that helps you connect your game to the game index. +- The game index now accepts games with no public telnet/webclient info (for early prototypes). + +#### New golden-layout based Webclient UI (@friarzen) +- Features + - Much slicker behavior and more professional look + - Allows tabbing as well as click and drag of panes in any grid position + - Renaming tabs, assignments of data tags and output types are simple per-pane menus now + - Any number of input panes, with separate histories + - Button UI (disabled in JS by default) + +#### Web/Django standard initiative (@strikaco) +- Features + - Adds a series of web-based forms and generic class-based views + - Accounts + - Register - Enhances registration; allows optional collection of email address + - Form - Adds a generic Django form for creating Accounts from the web + - Characters + - Create - Authenticated users can create new characters from the website (requires associated form) + - Detail - Authenticated and authorized users can view select details about characters + - List - Authenticated and authorized users can browse a list of all characters + - Manage - Authenticated users can edit or delete owned characters from the web + - Form - Adds a generic Django form for creating characters from the web + - Channels + - Detail - Authorized users can view channel logs from the web + - List - Authorized users can browse a list of all channels + - Help Entries + - Detail - Authorized users can view help entries from the web + - List - Authorized users can browse a list of all help entries from the web + - Navbar changes + - Characters - Link to character list + - Channels - Link to channel list + - Help - Link to help entry list + - Puppeting + - Users can puppet their own characters within the context of the website + - Dropdown + - Link to create characters + - Link to manage characters + - Link to quick-select puppets + - Link to password change workflow +- Functions + - Updates Bootstrap to v4 stable + - Enables use of Django Messages framework to communicate with users in browser + - Implements webclient/website `_shared_login` functionality as Django middleware + - 'account' and 'puppet' are added to all request contexts for authenticated users + - Adds unit tests for all web views +- Cosmetic + - Prettifies Django 'forgot password' workflow (requires SMTP to actually function) + - Prettifies Django 'change password' workflow +- Bugfixes + - Fixes bug on login page where error messages were not being displayed + - Remove strvalue field from admin; it made no sense to have here, being an optimization field + for internal use. + +### Prototypes + +- `evennia.prototypes.save_prototype` now takes the prototype as a normal + argument (`prototype`) instead of having to give it as `**prototype`. +- `evennia.prototypes.search_prototype` has a new kwarg `require_single=False` that + raises a KeyError exception if query gave 0 or >1 results. +- `evennia.prototypes.spawner` can now spawn by passing a `prototype_key` + +### Typeclasses + +- Add new methods on all typeclasses, useful specifically for object handling from the website/admin: + + `web_get_admin_url()`: Returns the path to the object detail page in the Admin backend. + + `web_get_create_url()`: Returns the path to the typeclass' creation page on the website, if implemented. + + `web_get_absolute_url()`: Returns the path to the object's detail page on the website, if implemented. + + `web_get_update_url()`: Returns the path to the object's update page on the website, if implemented. + + `web_get_delete_url()`: Returns the path to the object's delete page on the website, if implemented. +- All typeclasses have new helper class method `create`, which encompasses useful functionality + that used to be embedded for example in the respective `@create` or `@connect` commands. +- DefaultAccount now has new class methods implementing many things that used to be in unloggedin + commands (these can now be customized on the class instead): + + `is_banned()`: Checks if a given username or IP is banned. + + `get_username_validators`: Return list of validators for username validation (see + `settings.AUTH_USERNAME_VALIDATORS`) + + `authenticate`: Method to check given username/password. + + `normalize_username`: Normalizes names so (for Unicode environments) users cannot mimic existing usernames by replacing select characters with visually-similar Unicode chars. + + `validate_username`: Mechanism for validating a username based on predefined Django validators. + + `validate_password`: Mechanism for validating a password based on predefined Django validators. + + `set_password`: Apply password to account, using validation checks. +- `AttributeHandler.remove` and `TagHandler.remove` can now be used to delete by-category. If neither + key nor category is given, they now work the same as .clear(). + +### Protocols + +- Support for `Grapevine` MUD-chat network ("channels" supported) + +### Server + +- Convert ServerConf model to store its values as a Picklefield (same as + Attributes) instead of using a custom solution. +- OOB: Add support for MSDP LIST, REPORT, UNREPORT commands (re-mapped to `msdp_list`, + `msdp_report`, `msdp_unreport`, inlinefuncs) +- Added `evennia.ANSIString` to flat API. +- Server/Portal log files now cycle to names on the form `server_.log_19_03_08_` instead of `server.log___19.3.8`, retaining + unix file sorting order. +- Django signals fire for important events: Puppet/Unpuppet, Object create/rename, Login, + Logout, Login fail Disconnect, Account create/rename + +### Settings + +- `GLOBAL_SCRIPTS` - dict defining typeclasses of global scripts to store on the new + `evennia.GLOBAL_SCRIPTS` container. These will auto-start when Evennia start and will always + exist. +- `OPTIONS_ACCOUNTS_DEFAULT` - option dict with option defaults and Option classes +- `OPTION_CLASS_MODULES` - classes representing an on-Account Option, on special form +- `VALIDATOR_FUNC_MODULES` - (general) text validator functions, for verifying an input + is on a specific form. + +### Utils + +- `evennia` launcher now fully handles all django-admin commands, like running tests in parallel. +- `evennia.utils.create.account` now also takes `tags` and `attrs` keywords. +- `evennia.utils.interactive` decorator can now allow you to use yield(secs) to pause operation + in any function, not just in Command.func. Likewise, response = yield(question) will work + if the decorated function has an argument or kwarg `caller`. +- Added many more unit tests. +- Swap argument order of `evennia.set_trace` to `set_trace(term_size=(140, 40), debugger='auto')` + since the size is more likely to be changed on the command line. +- `utils.to_str(text, session=None)` now acts as the old `utils.to_unicode` (which was removed). + This converts to the str() type (not to a byte-string as in Evennia 0.8), trying different + encodings. This function will also force-convert any object passed to it into a string (so + `force_string` flag was removed and assumed always set). +- `utils.to_bytes(text, session=None)` replaces the old `utils.to_str()` functionality and converts + str to bytes. +- `evennia.MONITOR_HANDLER.all` now takes keyword argument `obj` to only retrieve monitors from that specific + Object (rather than all monitors in the entire handler). +- Support adding `\f` in command doc strings to force where EvMore puts page breaks. +- Validation Functions now added with standard API to homogenize user input validation. +- Option Classes added to make storing user-options easier and smoother. +- `evennia.VALIDATOR_CONTAINER` and `evennia.OPTION_CONTAINER` added to load these. + +### Contribs + +- Evscaperoom - a full puzzle engine for making multiplayer escape rooms in Evennia. Used to make + the entry for the MUD-Coder's Guild's 2019 Game Jam with the theme "One Room", where it ranked #1. +- Evennia game-index client no longer a contrib - moved into server core and configured with new + setting `GAME_INDEX_ENABLED`. +- The `extended_room` contrib saw some backwards-incompatible refactoring: + + All commands now begin with `CmdExtendedRoom`. So before it was `CmdExtendedLook`, now + it's `CmdExtendedRoomLook` etc. + + The `detail` command was broken out of the `desc` command and is now a new, stand-alone command + `CmdExtendedRoomDetail`. This was done to make things easier to extend and to mimic how the detail + command works in the tutorial-world. + + The `detail` command now also supports deleting details (like the tutorial-world version). + + The new `ExtendedRoomCmdSet` includes all the extended-room commands and is now the recommended way + to install the extended-room contrib. +- Reworked `menu_login` contrib to use latest EvMenu standards. Now also supports guest logins. +- Mail contrib was refactored to have optional Command classes `CmdMail` for OOC+IC mail (added + to the CharacterCmdSet and `CmdMailCharacter` for IC-only mailing between chars (added to CharacterCmdSet) + +### Translations + +- Simplified chinese, courtesy of user MaxAlex. + + +## Evennia 0.8 + +> 2017-2018 +> Released Nov 2018 + +### Requirements + +- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0 +- Add `inflect` dependency for automatic pluralization of object names. + +### Server/Portal + +- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program) + with different functionality). +- Both Portal/Server are now stand-alone processes (easy to run as daemon) +- Made Portal the AMP Server for starting/restarting the Server (the AMP client) +- Dynamic logging now happens using `evennia -l` rather than by interactive mode. +- Made AMP secure against erroneous HTTP requests on the wrong port (return error messages). +- The `evennia istart` option will start/switch the Server in foreground (interactive) mode, where it logs + to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will + return Server to normal daemon operation. +- For validating passwords, use safe Django password-validation backend instead of custom Evennia one. +- Alias `evennia restart` to mean the same as `evennia reload`. + +### Prototype changes + +- New OLC started from `olc` command for loading/saving/manipulating prototypes in a menu. +- Moved evennia/utils/spawner.py into the new evennia/prototypes/ along with all new + functionality around prototypes. +- A new form of prototype - database-stored prototypes, editable from in-game, was added. The old, + module-created prototypes remain as read-only prototypes. +- All prototypes must have a key `prototype_key` identifying the prototype in listings. This is + checked to be server-unique. Prototypes created in a module will use the global variable name they + are assigned to if no `prototype_key` is given. +- Prototype field `prototype` was renamed to `prototype_parent` to avoid mixing terms. +- All prototypes must either have `typeclass` or `prototype_parent` defined. If using + `prototype_parent`, `typeclass` must be defined somewhere in the inheritance chain. This is a + change from Evennia 0.7 which allowed 'mixin' prototypes without `typeclass`/`prototype_key`. To + make a mixin now, give it a default typeclass, like `evennia.objects.objects.DefaultObject` and just + override in the child as needed. +- Spawning an object using a prototype will automatically assign a new tag to it, named the same as + the `prototype_key` and with the category `from_prototype`. +- The spawn command was extended to accept a full prototype on one line. +- The spawn command got the /save switch to save the defined prototype and its key +- The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. + +### EvMenu + +- Added `EvMenu.helptext_formatter(helptext)` to allow custom formatting of per-node help. +- Added `evennia.utils.evmenu.list_node` decorator for turning an EvMenu node into a multi-page listing. +- A `goto` option callable returning None (rather than the name of the next node) will now rerun the + current node instead of failing. +- Better error handling of in-node syntax errors. +- Improve dedent of default text/helptext formatter. Right-strip whitespace. +- Add `debug` option when creating menu - this turns off persistence and makes the `menudebug` + command available for examining the current menu state. + + +### Webclient + +- Webclient now uses a plugin system to inject new components from the html file. +- Split-windows - divide input field into any number of horizontal/vertical panes and + assign different types of server messages to them. +- Lots of cleanup and bug fixes. +- Hot buttons plugin (friarzen) (disabled by default). + +### Locks + +- New function `evennia.locks.lockhandler.check_lockstring`. This allows for checking an object + against an arbitrary lockstring without needing the lock to be stored on an object first. +- New function `evennia.locks.lockhandler.validate_lockstring` allows for stand-alone validation + of a lockstring. +- New function `evennia.locks.lockhandler.get_all_lockfuncs` gives a dict {"name": lockfunc} for + all available lock funcs. This is useful for dynamic listings. + + +### Utils + +- Added new `columnize` function for easily splitting text into multiple columns. At this point it + is not working too well with ansi-colored text however. +- Extend the `dedent` function with a new `baseline_index` kwarg. This allows to force all lines to + the indentation given by the given line regardless of if other lines were already a 0 indentation. + This removes a problem with the original `textwrap.dedent` which will only dedent to the least + indented part of a text. +- Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. +- `get_all_typeclasses` will return dict `{"path": typeclass, ...}` for all typeclasses available + in the system. This is used by the new `@typeclass/list` subcommand (useful for builders etc). +- `evennia.utils.dbserialize.deserialize(obj)` is a new helper function to *completely* disconnect + a mutable recovered from an Attribute from the database. This will convert all nested `_Saver*` + classes to their plain-Python counterparts. + +### General + +- Start structuring the `CHANGELOG` to list features in more detail. +- Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch. +- Inflection and grouping of multiple objects in default room (an box, three boxes) +- `evennia.set_trace()` is now a shortcut for launching pdb/pudb on a line in the Evennia event loop. +- Removed the enforcing of `MAX_NR_CHARACTERS=1` for `MULTISESSION_MODE` `0` and `1` by default. +- Add `evennia.utils.logger.log_sec` for logging security-related messages (marked SS in log). + +### Contribs + +- `Auditing` (Johnny): Log and filter server input/output for security purposes +- `Build Menu` (vincent-lg): New @edit command to edit object properties in a menu. +- `Field Fill` (Tim Ashley Jenkins): Wraps EvMenu for creating submittable forms. +- `Health Bar` (Tim Ashley Jenkins): Easily create colorful bars/meters. +- `Tree select` (Fluttersprite): Wrap EvMenu to create a common type of menu from a string. +- `Turnbattle suite` (Tim Ashley Jenkins)- the old `turnbattle.py` was moved into its own + `turnbattle/` package and reworked with many different flavors of combat systems: + - `tb_basic` - The basic turnbattle system, with initiative/turn order attack/defense/damage. + - `tb_equip` - Adds weapon and armor, wielding, accuracy modifiers. + - `tb_items` - Extends `tb_equip` with item use with conditions/status effects. + - `tb_magic` - Extends `tb_equip` with spellcasting. + - `tb_range` - Adds system for abstract positioning and movement. + - The `extended_room` contrib saw some backwards-incompatible refactoring: + - All commands now begin with `CmdExtendedRoom`. So before it was `CmdExtendedLook`, now + it's `CmdExtendedRoomLook` etc. + - The `detail` command was broken out of the `desc` command and is now a new, stand-alone command + `CmdExtendedRoomDetail`. This was done to make things easier to extend and to mimic how the detail + command works in the tutorial-world. + - The `detail` command now also supports deleting details (like the tutorial-world version). + - The new `ExtendedRoomCmdSet` includes all the extended-room commands and is now the recommended way + to install the extended-room contrib. +- Updates and some cleanup of existing contribs. + + +### Internationalization + +- Polish translation by user ogotai + +# Overview-Changelogs + +> These are changelogs from a time before we used formal version numbers. + +## Sept 2017: +Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to +'Account', rework the website template and a slew of other updates. +Info on what changed and how to migrate is found here: +https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ + +## Feb 2017: +New devel branch created, to lead up to Evennia 0.7. + +## Dec 2016: +Lots of bugfixes and considerable uptick in contributors. Unittest coverage +and PEP8 adoption and refactoring. + +## May 2016: +Evennia 0.6 with completely reworked Out-of-band system, making +the message path completely flexible and built around input/outputfuncs. +A completely new webclient, split into the evennia.js library and a +gui library, making it easier to customize. + +## Feb 2016: +Added the new EvMenu and EvMore utilities, updated EvEdit and cleaned up +a lot of the batchcommand functionality. Started work on new Devel branch. + +## Sept 2015: +Evennia 0.5. Merged devel branch, full library format implemented. + +## Feb 2015: +Development currently in devel/ branch. Moved typeclasses to use +django's proxy functionality. Changed the Evennia folder layout to a +library format with a stand-alone launcher, in preparation for making +an 'evennia' pypy package and using versioning. The version we will +merge with will likely be 0.5. There is also work with an expanded +testing structure and the use of threading for saves. We also now +use Travis for automatic build checking. + +## Sept 2014: +Updated to Django 1.7+ which means South dependency was dropped and +minimum Python version upped to 2.7. MULTISESSION_MODE=3 was added +and the web customization system was overhauled using the latest +functionality of django. Otherwise, mostly bug-fixes and +implementation of various smaller feature requests as we got used +to github. Many new users have appeared. + +## Jan 2014: +Moved Evennia project from Google Code to github.com/evennia/evennia. + +## Nov 2013: +Moved the internal webserver into the Server and added support for +out-of-band protocols (MSDP initially). This large development push +also meant fixes and cleanups of the way attributes were handled. +Tags were added, along with proper handlers for permissions, nicks +and aliases. + +## May 2013: +Made players able to control more than one Character at the same +time, through the MULTISESSION_MODE=2 addition. This lead to a lot +of internal changes for the server. + +## Oct 2012: +Changed Evennia from the Modified Artistic 1.0 license to the more +standard and permissive BSD license. Lots of updates and bug fixes as +more people start to use it in new ways. Lots of new caching and +speed-ups. + +## March 2012: +Evennia's API has changed and simplified slightly in that the +base-modules where removed from game/gamesrc. Instead admins are +encouraged to explicitly create new modules under game/gamesrc/ when +they want to implement their game - gamesrc/ is empty by default +except for the example folders that contain template files to use for +this purpose. We also added the ev.py file, implementing a new, flat +API. Work is ongoing to add support for mud-specific telnet +extensions, notably the MSDP and GMCP out-of-band extensions. On the +community side, evennia's dev blog was started and linked on planet +Mud-dev aggregator. + +## Nov 2011: +After creating several different proof-of-concept game systems (in +contrib and privately) as well testing lots of things to make sure the +implementation is basically sound, we are declaring Evennia out of +Alpha. This can mean as much or as little as you want, admittedly - +development is still heavy but the issue list is at an all-time low +and the server is slowly stabilizing as people try different things +with it. So Beta it is! + +## Aug 2011: +Split Evennia into two processes: Portal and Server. After a lot of +work trying to get in-memory code-reloading to work, it's clear this +is not Python's forte - it's impossible to catch all exceptions, +especially in asynchronous code like this. Trying to do so results in +hackish, flakey and unstable code. With the Portal-Server split, the +Server can simply be rebooted while players connected to the Portal +remain connected. The two communicates over twisted's AMP protocol. + +## May 2011: +The new version of Evennia, originally hitting trunk in Aug2010, is +maturing. All commands from the pre-Aug version, including IRC/IMC2 +support works again. An ajax web-client was added earlier in the year, +including moving Evennia to be its own webserver (no more need for +Apache or django-testserver). Contrib-folder added. + +## Aug 2010: +Evennia-griatch-branch is ready for merging with trunk. This marks a +rather big change in the inner workings of the server, such as the +introduction of TypeClasses and Scripts (as compared to the old +ScriptParents and Events) but should hopefully bring everything +together into one consistent package as code development continues. + +## May 2010: +Evennia is currently being heavily revised and cleaned from +the years of gradual piecemeal development. It is thus in a very +'Alpha' stage at the moment. This means that old code snippets +will not be backwards compatabile. Changes touch almost all +parts of Evennia's innards, from the way Objects are handled +to Events, Commands and Permissions. + +## April 2010: +Griatch takes over Maintainership of the Evennia project from +the original creator Greg Taylor. + +# Older + +Earlier revisions, with previous maintainer, used SVN on Google Code +and have no changelogs. + +First commit (Evennia's birthday) was November 20, 2006. diff --git a/docs/latest/_sources/Coding/Coding-Overview.md.txt b/docs/latest/_sources/Coding/Coding-Overview.md.txt new file mode 100644 index 0000000000..19c95798d5 --- /dev/null +++ b/docs/latest/_sources/Coding/Coding-Overview.md.txt @@ -0,0 +1,33 @@ +# Coding and development help + +This documentation aims to help you set up a sane development environment to +make your game, also if you never coded before. + +See also the [Beginner Tutorial](../Howtos/Beginner-Tutorial/Beginner-Tutorial-Overview.md). + +```{toctree} +:maxdepth: 2 + +Evennia-Code-Style.md +Default-Command-Syntax.md +Version-Control.md +Debugging.md +Unit-Testing.md +Profiling.md +Continuous-Integration.md +Setting-up-PyCharm.md +``` + +## Evennia Changelog + +```{toctree} +:maxdepth: 2 + +Changelog.md +``` + + +```{toctree} +:hidden: +Release-Notes-1.0 +``` \ No newline at end of file diff --git a/docs/latest/_sources/Coding/Continuous-Integration-TeamCity.md.txt b/docs/latest/_sources/Coding/Continuous-Integration-TeamCity.md.txt new file mode 100644 index 0000000000..59b3391266 --- /dev/null +++ b/docs/latest/_sources/Coding/Continuous-Integration-TeamCity.md.txt @@ -0,0 +1,198 @@ +# Continuous Integration - TeamCity (linux) + +This sets up a TeamCity build integration environment on Linux. + +## Prerequisites + +- Follow [TeamCity](https://www.jetbrains.com/teamcity/) 's in-depth +[Setup Guide](https://confluence.jetbrains.com/display/TCD8/Installing+and+Configuring+the+TeamCity+Server). +- You need to use [Version Control](./Version-Control.md). + +After meeting the preparation steps for your specific environment, log on to your teamcity interface +at `http://:8111/`. + +Create a new project named "Evennia" and in it construct a new template called `continuous-integration`. + +## A Quick Overview + +_Templates_ are fancy objects in TeamCity that allow an administrator to define build steps that are +shared between one or more build projects. Assigning a VCS Root (Source Control) is unnecessary at +this stage, primarily you'll be worrying about the build steps and your default parameters (both +visible on the tabs to the left.) + +## Template Setup + +In this template, you'll be outlining the steps necessary to build your specific game. (A number of +sample scripts are provided under this section below!) Click Build Steps and prepare your general +flow. For this example, we will be doing a few basic example steps: + +* Transforming the Settings.py file - We do this to update ports or other information that make your production + environment unique from your development environment. +* Making migrations and migrating the game database. +* Publishing the game files. +* Reloading the server. + +For each step we'll being use the "Command Line Runner" (a fancy name for a shell script executor). + +Create a build step with the name: "Transform Configuration" and add the script: + +```bash +#!/bin/bash +# Replaces the game configuration with one +# appropriate for this deployment. + +CONFIG="%system.teamcity.build.checkoutDir%/server/conf/settings.py" +MYCONF="%system.teamcity.build.checkoutDir%/server/conf/my.cnf" + +sed -e 's/TELNET_PORTS = [4000]/TELNET_PORTS = [%game.ports%]/g' "$CONFIG" > "$CONFIG".tmp && mv +"$CONFIG".tmp "$CONFIG" +sed -e 's/WEBSERVER_PORTS = [(4001, 4002)]/WEBSERVER_PORTS = [%game.webports%]/g' "$CONFIG" > +"$CONFIG".tmp && mv "$CONFIG".tmp "$CONFIG" +`````` + +```bash + +# settings.py MySQL DB configuration +echo Configuring Game Database... +echo "" >> "$CONFIG" +echo "######################################################################" >> "$CONFIG" +echo "# MySQL Database Configuration" >> "$CONFIG" +echo "######################################################################" >> "$CONFIG" + +echo "DATABASES = {" >> "$CONFIG" +echo " 'default': {" >> "$CONFIG" +echo " 'ENGINE': 'django.db.backends.mysql'," >> "$CONFIG" +echo " 'OPTIONS': {" >> "$CONFIG" +echo " 'read_default_file': 'server/conf/my.cnf'," >> "$CONFIG" +echo " }," >> "$CONFIG" +echo " }" >> "$CONFIG" +echo "}" >> "$CONFIG" + +# Create the My.CNF file. +echo "[client]" >> "$MYCONF" +echo "database = %mysql.db%" >> "$MYCONF" +echo "user = %mysql.user%" >> "$MYCONF" +echo "password = %mysql.pass%" >> "$MYCONF" +echo "default-character-set = utf8" >> "$MYCONF" + +``` + +If you look at the parameters side of the page after saving this script, you'll notice that some new +parameters have been populated for you. This is because we've included new teamcity configuration +parameters that are populated when the build itself is ran. When creating projects that inherit this +template, we'll be able to fill in or override those parameters for project-specific configuration. + +Go ahead and create another build step called "Make Database Migration" +If you're using Sqlite3 for your game (default database), it's prudent to change working directory on this + step to your game dir. + + ```bash + #!/bin/bash + # Update the DB migration + + LOGDIR="server/logs" + + . %evenv.dir%/bin/activate + + # Check that the logs directory exists. + if [ ! -d "$LOGDIR" ]; then + # Control will enter here if $LOGDIR doesn't exist. + mkdir "$LOGDIR" + fi + + evennia makemigrations + ``` + +Create yet another build step, this time named: "Execute Database Migration": +If you're using Sqlite3 for your game (default database), it's prudent to change working directory on this + step to your game dir. + +```bash +#!/bin/bash +# Apply the database migration. + +LOGDIR="server/logs" + +. %evenv.dir%/bin/activate + +# Check that the logs directory exists. +if [ ! -d "$LOGDIR" ]; then + # Control will enter here if $LOGDIR doesn't exist. + mkdir "$LOGDIR" +fi + +evennia migrate + +``` + +Our next build step is where we actually publish our build. Up until now, all work on game has been +done in a 'work' directory on TeamCity's build agent. From that directory we will now copy our files +to where our game actually exists on the local server. + +Create a new build step called "Publish Build". If you're using SQlite3 on your game, be sure to order this step ABOVE +the Database Migration steps. The build order will matter! + + ```bash + #!/bin/bash + # Publishes the build to the proper build directory. + + DIRECTORY="" + + if [ ! -d "$DIRECTORY" ]; then + # Control will enter here if $DIRECTORY doesn't exist. + mkdir "$DIRECTORY" + fi + + # Copy all the files. + cp -ruv %teamcity.build.checkoutDir%/* "$DIRECTORY" + chmod -R 775 "$DIRECTORY" + + ``` + +Finally the last script will reload our game for us. + +Create a new script called "Reload Game": +The working directory on this build step will be: `%game.dir%` + +```bash +#!/bin/bash +# Apply the database migration. + +LOGDIR="server/logs" +PIDDIR="server/server.pid" + +. %evenv.dir%/bin/activate + +# Check that the logs directory exists. +if [ ! -d "$LOGDIR" ]; then + # Control will enter here if $LOGDIR doesn't exist. + mkdir "$LOGDIR" +fi + +# Check that the server is running. +if [ -d "$PIDDIR" ]; then + # Control will enter here if the game is running. + evennia reload +fi +``` + +Now the template is ready for use! It would be useful this time to revisit the parameters page and +set the evenv parameter to the directory where your virtualenv exists: IE "/srv/mush/evenv". + +### Creating the Project + +Now it's time for the last few steps to set up a CI environment. + +* Return to the Evennia Project overview/administration page. +* Create a new Sub-Project called "Production". This will be the category that holds our actual game. +* Create a new Build Configuration in Production with the name of your MUSH. Base this configuration off of the + continuous-integration template we made earlier. +* In the build configuration, enter VCS roots and create a new VCS root that points to the + branch/version control that you are using. +* Go to the parameters page and fill in the undefined parameters for your specific configuration. +* If you wish for the CI to run every time a commit is made, go to the VCS triggers and add one for + "On Every Commit". + +And you're done! At this point, you can return to the project overview page and queue a new build +for your game. If everything was set up correctly, the build will complete successfully. Additional +build steps could be added or removed at this point, adding some features like Unit Testing or more! \ No newline at end of file diff --git a/docs/latest/_sources/Coding/Continuous-Integration-Travis.md.txt b/docs/latest/_sources/Coding/Continuous-Integration-Travis.md.txt new file mode 100644 index 0000000000..15c84f9eee --- /dev/null +++ b/docs/latest/_sources/Coding/Continuous-Integration-Travis.md.txt @@ -0,0 +1,39 @@ +# Continuous integration with Travis + +[Travis CI](https://travis-ci.org/) is an online service for checking, validating and potentially +deploying code automatically. It can check that every commit is building successfully after every +commit to its Github repository. + +If your game is open source on Github you may use Travis for free. +See [the Travis docs](https://docs.travis-ci.com/user/getting- started/) for how to get started. + +After logging in you will get to point Travis to your repository on github. One further thing you +need to set up yourself is a Travis config file named `.travis.yml` (note the initial period `.`). +This should be created in the root of your game directory. The idea with this file is that it +describes what Travis needs to import and build in order to create an instance of Evennia from +scratch and then run validation tests on it. Here is an example: + +``` yaml +language: python +python: + - "3.10" +install: + - git clone https://github.com/evennia/evennia.git + - cd evennia + - pip install -e . + - cd $TRAVIS_BUILD_DIR +script: + - evennia migrate + - evennia test --settings settings.py . +``` + +This will tell travis how to download Evennia, install it, set up a database and then run +your own test suite (inside the game dir). Use `evennia test evennia` if you also want to +run the Evennia full test suite. + +You need to add this file to git (`git add .travis.yml`) and then commit your changes before Travis +will be able to see it. + +For properly testing your game you of course also need to write unittests. +The [Unit testing](./Unit-Testing.md) doc page gives some ideas on how to set those up for Evennia. +You should be able to refer to that for making tests fitting your game. diff --git a/docs/latest/_sources/Coding/Continuous-Integration.md.txt b/docs/latest/_sources/Coding/Continuous-Integration.md.txt new file mode 100644 index 0000000000..34dbfe8be7 --- /dev/null +++ b/docs/latest/_sources/Coding/Continuous-Integration.md.txt @@ -0,0 +1,18 @@ +# Continuous Integration (CI) + +[Continuous Integration (CI)](https://en.wikipedia.org/wiki/Continuous_integration) is a development practice that requires developers to integrate code into a shared repository. Each check-in is then verified by an automated build, allowing teams to detect problems early. This can be set up to safely deploy data to a production server only after tests have passed, for example. + +For Evennia, continuous integration allows an automated build process to: + +* Pull down a latest build from Source Control. +* Run migrations on the backing SQL database. +* Automate additional unique tasks for that project. +* Run unit tests. +* Publish those files to the server directory +* Reload the game. + +## Continuous-Integration guides + +Evennia itself is making heavy use of [github actions](https://github.com/features/actions). This is integrated with Github and is probably the one to go for most people, especially if your code is on Github already. You can see and analyze how Evennia's actions are running [here](https://github.com/evennia/evennia/actions). + +There are however a lot of tools and services providing CI functionality. [Here is a blog overview](https://www.atlassian.com/continuous-delivery/continuous-integration/tools) (external link). \ No newline at end of file diff --git a/docs/latest/_sources/Coding/Debugging.md.txt b/docs/latest/_sources/Coding/Debugging.md.txt new file mode 100644 index 0000000000..7fc291e471 --- /dev/null +++ b/docs/latest/_sources/Coding/Debugging.md.txt @@ -0,0 +1,254 @@ +# Debugging + +Sometimes, an error is not trivial to resolve. A few simple `print` statements is not enough to find the cause of the issue. The traceback is not informative or even non-existing. + +Running a *debugger* can then be very helpful and save a lot of time. Debugging means running Evennia under control of a special *debugger* program. This allows you to stop the action at a given point, view the current state and step forward through the program to see how its logic works. + +Evennia natively supports these debuggers: + +- [Pdb](https://docs.python.org/2/library/pdb.html) is a part of the Python distribution and + available out-of-the-box. +- [PuDB](https://pypi.org/project/pudb/) is a third-party debugger that has a slightly more + 'graphical', curses-based user interface than pdb. It is installed with `pip install pudb`. + +## Debugging Evennia + +To run Evennia with the debugger, follow these steps: + +1. Find the point in the code where you want to have more insight. Add the following line at that + point. + ```python + from evennia import set_trace;set_trace() + ``` +2. (Re-)start Evennia in interactive (foreground) mode with `evennia istart`. This is important - without this step the debugger will not start correctly - it will start in this interactive terminal. +3. Perform the steps that will trigger the line where you added the `set_trace()` call. The debugger will start in the terminal from which Evennia was interactively started. + +The `evennia.set_trace` function takes the following arguments: + + +```python + evennia.set_trace(debugger='auto', term_size=(140, 40)) +``` + +Here, `debugger` is one of `pdb`, `pudb` or `auto`. If `auto`, use `pudb` if available, otherwise use `pdb`. The `term_size` tuple sets the viewport size for `pudb` only (it's ignored by `pdb`). + + +## A simple example using pdb + +The debugger is useful in different cases, but to begin with, let's see it working in a command. +Add the following test command (which has a range of deliberate errors) and also add it to your +default cmdset. Then restart Evennia in interactive mode with `evennia istart`. + + +```python +# In file commands/command.py + + +class CmdTest(Command): + + """ + A test command just to test pdb. + + Usage: + test + + """ + + key = "test" + + def func(self): + from evennia import set_trace; set_trace() # <--- start of debugger + obj = self.search(self.args) + self.msg("You've found {}.".format(obj.get_display_name())) + +``` + +If you type `test` in your game, everything will freeze. You won't get any feedback from the game, and you won't be able to enter any command (nor anyone else). It's because the debugger has started in your console, and you will find it here. Below is an example with `pdb`. + +``` +... +> .../mygame/commands/command.py(79)func() +-> obj = self.search(self.args) +(Pdb) + +``` + +`pdb` notes where it has stopped execution and, what line is about to be executed (in our case, `obj = self.search(self.args)`), and ask what you would like to do. + +### Listing surrounding lines of code + +When you have the `pdb` prompt `(Pdb)`, you can type in different commands to explore the code. The first one you should know is `list` (you can type `l` for short): + +``` +(Pdb) l + 43 + 44 key = "test" + 45 + 46 def func(self): + 47 from evennia import set_trace; set_trace() # <--- start of debugger + 48 -> obj = self.search(self.args) + 49 self.msg("You've found {}.".format(obj.get_display_name())) + 50 + 51 # ------------------------------------------------------------- + 52 # + 53 # The default commands inherit from +(Pdb) +``` + +Okay, this didn't do anything spectacular, but when you become more confident with `pdb` and find yourself in lots of different files, you sometimes need to see what's around in code. Notice that there is a little arrow (`->`) before the line that is about to be executed. + +This is important: **about to be**, not **has just been**. You need to tell `pdb` to go on (we'll soon see how). + +### Examining variables + +`pdb` allows you to examine variables (or really, to run any Python instruction). It is very useful to know the values of variables at a specific line. To see a variable, just type its name (as if you were in the Python interpreter: + +``` +(Pdb) self + +(Pdb) self.args +u'' +(Pdb) self.caller + +(Pdb) +``` + +If you try to see the variable `obj`, you'll get an error: + +``` +(Pdb) obj +*** NameError: name 'obj' is not defined +(Pdb) +``` + +That figures, since at this point, we haven't created the variable yet. + +> Examining variable in this way is quite powerful. You can even run Python code and keep on +> executing, which can help to check that your fix is actually working when you have identified an +> error. If you have variable names that will conflict with `pdb` commands (like a `list` +> variable), you can prefix your variable with `!`, to tell `pdb` that what follows is Python code. + +### Executing the current line + +It's time we asked `pdb` to execute the current line. To do so, use the `next` command. You can +shorten it by just typing `n`: + +``` +(Pdb) n +AttributeError: "'CmdTest' object has no attribute 'search'" +> .../mygame/commands/command.py(79)func() +-> obj = self.search(self.args) +(Pdb) +``` + +`Pdb` is complaining that you try to call the `search` method on a command... whereas there's no `search` method on commands. The character executing the command is in `self.caller`, so we might change our line: + +```python +obj = self.caller.search(self.args) +``` + +### Letting the program run + +`pdb` is waiting to execute the same instruction... it provoked an error but it's ready to try again, just in case. We have fixed it in theory, but we need to reload, so we need to enter a command. To tell `pdb` to terminate and keep on running the program, use the `continue` (or `c`) command: + +``` +(Pdb) c +... +``` + +You see an error being caught, that's the error we have fixed... or hope to have. Let's reload the game and try again. You need to run `evennia istart` again and then run `test` to get into the command again. + +``` +> .../mygame/commands/command.py(79)func() +-> obj = self.caller.search(self.args) +(Pdb) + +``` + +`pdb` is about to run the line again. + +``` +(Pdb) n +> .../mygame/commands/command.py(80)func() +-> self.msg("You've found {}.".format(obj.get_display_name())) +(Pdb) +``` + +This time the line ran without error. Let's see what is in the `obj` variable: + +``` +(Pdb) obj +(Pdb) print obj +None +(Pdb) +``` + +We have entered the `test` command without parameter, so no object could be found in the search +(`self.args` is an empty string). + +Let's allow the command to continue and try to use an object name as parameter (although, we should +fix that bug too, it would be better): + +``` +(Pdb) c +... +``` + +Notice that you'll have an error in the game this time. Let's try with a valid parameter. I have another character, `barkeep`, in this room: + +```test barkeep``` + +And again, the command freezes, and we have the debugger opened in the console. + +Let's execute this line right away: + +``` +> .../mygame/commands/command.py(79)func() +-> obj = self.caller.search(self.args) +(Pdb) n +> .../mygame/commands/command.py(80)func() +-> self.msg("You've found {}.".format(obj.get_display_name())) +(Pdb) obj + +(Pdb) +``` + +At least this time we have found the object. Let's process... + +``` +(Pdb) n +TypeError: 'get_display_name() takes exactly 2 arguments (1 given)' +> .../mygame/commands/command.py(80)func() +-> self.msg("You've found {}.".format(obj.get_display_name())) +(Pdb) +``` + +As an exercise, fix this error, reload and run the debugger again. Nothing better than some experimenting! + +Your debugging will often follow the same strategy: + +1. Receive an error you don't understand. +2. Put a breaking point **BEFORE** the error occurs. +3. Run `evennia istart` +4. Run the code again and see the debugger open. +5. Run the program line by line, examining variables, checking the logic of instructions. +6. Continue and try again, each step a bit further toward the truth and the working feature. + +## Cheat-sheet of pdb/pudb commands + +PuDB and Pdb share the same commands. The only real difference is how it's presented. The `look` +command is not needed much in `pudb` since it displays the code directly in its user interface. + +| Pdb/PuDB command | To do what | +| ----------- | ---------- | +| list (or l) | List the lines around the point of execution (not needed for `pudb`, it will show +this directly). | +| print (or p) | Display one or several variables. | +| `!` | Run Python code (using a `!` is often optional). | +| continue (or c) | Continue execution and terminate the debugger for this time. | +| next (or n) | Execute the current line and goes to the next one. | +| step (or s) | Step inside of a function or method to examine it. | +| `` | Repeat the last command (don't type `n` repeatedly, just type it once and then press +`` to repeat it). | + +If you want to learn more about debugging with Pdb, you will find an [interesting tutorial on that topic here](https://pymotw.com/3/pdb/). \ No newline at end of file diff --git a/docs/latest/_sources/Coding/Default-Command-Syntax.md.txt b/docs/latest/_sources/Coding/Default-Command-Syntax.md.txt new file mode 100644 index 0000000000..10f1e4ccbf --- /dev/null +++ b/docs/latest/_sources/Coding/Default-Command-Syntax.md.txt @@ -0,0 +1,24 @@ +# Default Command Syntax + + +Evennia allows for any command syntax. + +If you like the way DikuMUDs, LPMuds or MOOs handle things, you could emulate that with Evennia. If you are ambitious you could even design a whole new style, perfectly fitting your own dreams of the ideal game. See the [Command](../Components/Commands.md) documentation for how to do this. + +We do offer a default however. The default Evennia setup tends to *resemble* [MUX2](https://www.tinymux.org/), and its cousins [PennMUSH](https://www.pennmush.org), [TinyMUSH](https://github.com/TinyMUSH/TinyMUSH/wiki), and [RhostMUSH](http://www.rhostmush.com/): + +``` +command[/switches] object [= options] +``` + +While the reason for this similarity is partly historical, these codebases offer very mature feature sets for administration and building. + +Evennia is *not* a MUX system though. It works very differently in many ways. For example, Evennia +deliberately lacks an online softcode language (a policy explained on our [softcode policy page](./Soft-Code.md)). Evennia also does not shy from using its own syntax when deemed appropriate: the +MUX syntax has grown organically over a long time and is, frankly, rather arcane in places. All in +all the default command syntax should at most be referred to as "MUX-like" or "MUX-inspired". + +```{toctree} +:hidden: +Soft-Code +``` \ No newline at end of file diff --git a/docs/latest/_sources/Coding/Evennia-Code-Style.md.txt b/docs/latest/_sources/Coding/Evennia-Code-Style.md.txt new file mode 100644 index 0000000000..52b6bcfb6a --- /dev/null +++ b/docs/latest/_sources/Coding/Evennia-Code-Style.md.txt @@ -0,0 +1,278 @@ +# Evennia Code Style + +All code submitted or committed to the Evennia project should aim to follow the +guidelines outlined in [Python PEP 8][pep8]. Keeping the code style uniform +makes it much easier for people to collaborate and read the code. + +A good way to check if your code follows PEP8 is to use the [PEP8 tool][pep8tool] +on your sources. + +## Main code style specification + + * 4-space indentation, NO TABS! + * Unix line endings. + * 100 character line widths + * CamelCase is only used for classes, nothing else. + * All non-global variable names and all function names are to be + lowercase, words separated by underscores. Variable names should + always be more than two letters long. + * Module-level global variables (only) are to be in CAPITAL letters. + * Imports should be done in this order: + - Python modules (builtins and standard library) + - Twisted modules + - Django modules + - Evennia library modules (`evennia`) + - Evennia contrib modules (`evennia.contrib`) + * All modules, classes, functions and methods should have doc strings formatted + as outlined below. + * All default commands should have a consistent docstring formatted as + outlined below. + +## Code Docstrings + +All modules, classes, functions and methods should have docstrings +formatted with [Google style][googlestyle] -inspired indents, using +[Markdown][githubmarkdown] formatting where needed. Evennia's `api2md` +parser will use this to create pretty API documentation. + + +### Module docstrings + +Modules should all start with at least a few lines of docstring at +their top describing the contents and purpose of the module. + +Example of module docstring (top of file): + +```python +""" +This module handles the creation of `Objects` that +are useful in the game ... + +""" +``` + +Sectioning (`# title`, `## subtile` etc) should not be used in +freeform docstrings - this will confuse the sectioning of the auto +documentation page and the auto-api will create this automatically. +Write just the section name bolded on its own line to mark a section. +Beyond sections markdown should be used as needed to format +the text. + +Code examples should use [multi-line syntax highlighting][markdown-hilight] +to mark multi-line code blocks, using the "python" identifier. Just +indenting code blocks (common in markdown) will not produce the +desired look. + +When using any code tags (inline or blocks) it's recommended that you +don't let the code extend wider than about 70 characters or it will +need to be scrolled horizontally in the wiki (this does not affect any +other text, only code). + +### Class docstrings + +The root class docstring should describe the over-arching use of the +class. It should usually not describe the exact call sequence nor list +important methods, this tends to be hard to keep updated as the API +develops. Don't use section markers (`#`, `##` etc). + +Example of class docstring: + +```python +class MyClass(object): + """ + This class describes the creation of `Objects`. It is useful + in many situations, such as ... + + """ +``` + +### Function / method docstrings + +Example of function or method docstring: + +```python + +def funcname(a, b, c, d=False, **kwargs): + """ + This is a brief introduction to the function/class/method + + Args: + a (str): This is a string argument that we can talk about + over multiple lines. + b (int or str): Another argument. + c (list): A list argument. + d (bool, optional): An optional keyword argument. + + Keyword Args: + test (list): A test keyword. + + Returns: + str: The result of the function. + + Raises: + RuntimeException: If there is a critical error, + this is raised. + IOError: This is only raised if there is a + problem with the database. + + Notes: + This is an example function. If `d=True`, something + amazing will happen. + + """ +``` + +The syntax is very "loose" but the indentation matters. That is, you +should end the block headers (like `Args:`) with a line break followed by +an indent. When you need to break a line you should start the next line +with another indent. For consistency with the code we recommend all +indents to be 4 spaces wide (no tabs!). + +Here are all the supported block headers: + +``` + """ + Args + argname (freeform type): Description endind with period. + Keyword Args: + argname (freeform type): Description. + Returns/Yields: + type: Description. + Raises: + Exceptiontype: Description. + Notes/Note/Examples/Example: + Freeform text. + """ +``` + +Parts marked with "freeform" means that you can in principle put any +text there using any formatting except for sections markers (`#`, `##` +etc). You must also keep indentation to mark which block you are part +of. You should normally use the specified format rather than the +freeform counterpart (this will produce nicer output) but in some +cases the freeform may produce a more compact and readable result +(such as when describing an `*args` or `**kwargs` statement in general +terms). The first `self` argument of class methods should never be +documented. + +Note that + +``` +""" +Args: + argname (type, optional): Description. +""" +``` + +and + +``` +""" +Keyword Args: + sargname (type): Description. +""" +``` + +mean the same thing! Which one is used depends on the function or +method documented, but there are no hard rules; If there is a large +`**kwargs` block in the function, using the `Keyword Args:` block may be a +good idea, for a small number of arguments though, just using `Args:` +and marking keywords as `optional` will shorten the docstring and make +it easier to read. + +## Default Command Docstrings + +These represent a special case since Commands in Evennia use their class +docstrings to represent the in-game help entry for that command. + +All the commands in the _default command_ sets should have their doc-strings +formatted on a similar form. For contribs, this is loosened, but if there is +no particular reason to use a different form, one should aim to use the same +style for contrib-command docstrings as well. + +```python + """ + Short header + + Usage: + key[/switches, if any] [optional] choice1||choice2||choice3 + + Switches: + switch1 - description + switch2 - description + + Examples: + Usage example and output + + Longer documentation detailing the command. + + """ +``` + +- Two spaces are used for *indentation* in all default commands. +- Square brackets `[ ]` surround *optional, skippable arguments*. +- Angled brackets `< >` surround a _description_ of what to write rather than the exact syntax. +- Explicit choices are separated by `|`. To avoid this being parsed as a color code, use `||` (this +will come out as a single `|`) or put spaces around the character ("` | `") if there's plenty of room. +- The `Switches` and `Examples` blocks are optional and based on the Command. + +Here is the `nick` command as an example: + +```python + """ + Define a personal alias/nick + + Usage: + nick[/switches] = [] + alias '' + + Switches: + object - alias an object + account - alias an account + clearall - clear all your aliases + list - show all defined aliases (also "nicks" works) + + Examples: + nick hi = say Hello, I'm Sarah! + nick/object tom = the tall man + + A 'nick' is a personal shortcut you create for your own use [...] + + """ +``` + +For commands that *require arguments*, the policy is for it to return a `Usage:` +string if the command is entered without any arguments. So for such commands, +the Command body should contain something to the effect of + +```python + if not self.args: + self.caller.msg("Usage: nick[/switches] = []") + return +``` + +## Tools for auto-linting + +### black + +Automatic pep8 compliant formatting and linting can be performed using the +`black` formatter: + + black --line-length 100 + +### PyCharm + +The Python IDE [Pycharm][pycharm] can auto-generate empty doc-string stubs. The +default is to use `reStructuredText` form, however. To change to Evennia's +Google-style docstrings, follow [this guide][pycharm-guide]. + + + +[pep8]: http://www.python.org/dev/peps/pep-0008 +[pep8tool]: https://pypi.python.org/pypi/pep8 +[googlestyle]: https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html +[githubmarkdown]: https://help.github.com/articles/github-flavored-markdown/ +[markdown-hilight]: https://help.github.com/articles/github-flavored-markdown/#syntax-highlighting +[command-docstrings]: https://github.com/evennia/evennia/wiki/Using%20MUX%20As%20a%20Standard#documentation-policy +[pycharm]: https://www.jetbrains.com/pycharm/ +[pycharm-guide]: https://www.jetbrains.com/help/pycharm/2016.3/python-integrated-tools.html diff --git a/docs/latest/_sources/Coding/Profiling.md.txt b/docs/latest/_sources/Coding/Profiling.md.txt new file mode 100644 index 0000000000..fe10513921 --- /dev/null +++ b/docs/latest/_sources/Coding/Profiling.md.txt @@ -0,0 +1,197 @@ +# Profiling + +```{important} +This is considered an advanced topic. It's mainly of interest to server developers. +``` + +Sometimes it can be useful to try to determine just how efficient a particular piece of code is, or to figure out if one could speed up things more than they are. There are many ways to test the performance of Python and the running server. + +Before digging into this section, remember Donald Knuth's [words of wisdom](https://en.wikipedia.org/wiki/Program_optimization#When_to_optimize): + +> *[...]about 97% of the time: Premature optimization is the root of all evil*. + +That is, don't start to try to optimize your code until you have actually identified a need to do so. This means your code must actually be working before you start to consider optimization. Optimization will also often make your code more complex and harder to read. Consider readability and maintainability and you may find that a small gain in speed is just not worth it. + +## Simple timer tests + +Python's `timeit` module is very good for testing small things. For example, in +order to test if it is faster to use a `for` loop or a list comprehension you +could use the following code: + +```python + import timeit + # Time to do 1000000 for loops + timeit.timeit("for i in range(100):\n a.append(i)", setup="a = []") + <<< 10.70982813835144 + # Time to do 1000000 list comprehensions + timeit.timeit("a = [i for i in range(100)]") + <<< 5.358283996582031 +``` + +The `setup` keyword is used to set up things that should not be included in the time measurement, like `a = []` in the first call. + +By default the `timeit` function will re-run the given test 1000000 times and returns the *total time* to do so (so *not* the average per test). A hint is to not use this default for testing something that includes database writes - for that you may want to use a lower number of repeats (say 100 or 1000) using the `number=100` keyword. + +In the example above, we see that this number of calls, using a list comprehension is about twice as fast as building a list using `.append()`. + +## Using cProfile + +Python comes with its own profiler, named cProfile (this is for cPython, no tests have been done with `pypy` at this point). Due to the way Evennia's processes are handled, there is no point in using the normal way to start the profiler (`python -m cProfile evennia.py`). Instead you start the profiler through the launcher: + + evennia --profiler start + +This will start Evennia with the Server component running (in daemon mode) under cProfile. You could instead try `--profile` with the `portal` argument to profile the Portal (you would then need to [start the Server separately](../Setup/Running-Evennia.md)). + +Please note that while the profiler is running, your process will use a lot more memory than usual. Memory usage is even likely to climb over time. So don't leave it running perpetually but monitor it carefully (for example using the `top` command on Linux or the Task Manager's memory display on Windows). + +Once you have run the server for a while, you need to stop it so the profiler can give its report. Do *not* kill the program from your task manager or by sending it a kill signal - this will most likely also mess with the profiler. Instead either use `evennia.py stop` or (which may be even better), use `@shutdown` from inside the game. + +Once the server has fully shut down (this may be a lot slower than usual) you will find that profiler has created a new file `mygame/server/logs/server.prof`. + +### Analyzing the profile + +The `server.prof` file is a binary file. There are many ways to analyze and display its contents, all of which has only been tested in Linux (If you are a Windows/Mac user, let us know what works). + +You can look at the contents of the profile file with Python's in-built `pstats` module in the evennia shell (it's recommended you install `ipython` with `pip install ipython` in your virtualenv first, for prettier output): + + evennia shell + +Then in the shell + +```python +import pstats +from pstats import SortKey + +p = pstats.Stats('server/log/server.prof') +p.strip_dirs().sort_stats(-1).print_stats() + +``` + +See the [Python profiling documentation](https://docs.python.org/3/library/profile.html#instant-user-s-manual) for more information. + +You can also visualize the data in various ways. +- [Runsnake](https://pypi.org/project/RunSnakeRun/) visualizes the profile to + give a good overview. Install with `pip install runsnakerun`. Note that this + may require a C compiler and be quite slow to install. +- For more detailed listing of usage time, you can use + [KCachegrind](http://kcachegrind.sourceforge.net/html/Home.html). To make + KCachegrind work with Python profiles you also need the wrapper script + [pyprof2calltree](https://pypi.python.org/pypi/pyprof2calltree/). You can get + `pyprof2calltree` via `pip` whereas KCacheGrind is something you need to get + via your package manager or their homepage. + +How to analyze and interpret profiling data is not a trivial issue and depends on what you are profiling for. Evennia being an asynchronous server can also confuse profiling. Ask on the mailing list if you need help and be ready to be able to supply your `server.prof` file for comparison, along with the exact conditions under which it was obtained. + +## The Dummyrunner + +It is difficult to test "actual" game performance without having players in your game. For this reason Evennia comes with the *Dummyrunner* system. The Dummyrunner is a stress-testing system: a separate program that logs into your game with simulated players (aka "bots" or "dummies"). Once connected, these dummies will semi-randomly perform various tasks from a list of possible actions. Use `Ctrl-C` to stop the Dummyrunner. + +```{warning} + + You should not run the Dummyrunner on a production database. It + will spawn many objects and also needs to run with general permissions. + +This is the recommended process for using the dummy runner: +``` + +1. Stop your server completely with `evennia stop`. +1. At _the end_ of your `mygame/server/conf.settings.py` file, add the line + + from evennia.server.profiling.settings_mixin import * + + This will override your settings and disable Evennia's rate limiters and DoS-protections, which would otherwise block mass-connecting clients from one IP. Notably, it will also change to a different (faster) password hasher. +1. (recommended): Build a new database. If you use default Sqlite3 and want to + keep your existing database, just rename `mygame/server/evennia.db3` to + `mygame/server/evennia.db3_backup` and run `evennia migrate` and `evennia + start` to create a new superuser as usual. +1. (recommended) Log into the game as your superuser. This is just so you + can manually check response. If you kept an old database, you will _not_ + be able to connect with an _existing_ user since the password hasher changed! +1. Start the dummyrunner with 10 dummy users from the terminal with + + evennia --dummyrunner 10 + + Use `Ctrl-C` (or `Cmd-C`) to stop it. + +If you want to see what the dummies are actually doing you can run with a single dummy: + + evennia --dummyrunner 1 + +The inputs/outputs from the dummy will then be printed. By default the runner uses the 'looker' profile, which just logs in and sends the 'look' command over and over. To change the settings, copy the file `evennia/server/profiling/dummyrunner_settings.py` to your `mygame/server/conf/` directory, then add this line to your settings file to use it in the new location: + + DUMMYRUNNER_SETTINGS_MODULE = "server/conf/dummyrunner_settings.py" + +The dummyrunner settings file is a python code module in its own right - it defines the actions available to the dummies. These are just tuples of command strings (like "look here") for the dummy to send to the server along with a probability of them happening. The dummyrunner looks for a global variable `ACTIONS`, a list of tuples, where the first two elements define the commands for logging in/out of the server. + +Below is a simplified minimal setup (the default settings file adds a lot more functionality and info): + +```python +# minimal dummyrunner setup file + +# Time between each dummyrunner "tick", in seconds. Each dummy will be called +# with this frequency. +TIMESTEP = 1 + +# Chance of a dummy actually performing an action on a given tick. This +# spreads out usage randomly, like it would be in reality. +CHANCE_OF_ACTION = 0.5 + +# Chance of a currently unlogged-in dummy performing its login action every +# tick. This emulates not all accounts logging in at exactly the same time. +CHANCE_OF_LOGIN = 0.01 + +# Which telnet port to connect to. If set to None, uses the first default +# telnet port of the running server. +TELNET_PORT = None + +# actions + +def c_login(client): + name = f"Character-{client.gid}" + pwd = f"23fwsf23sdfw23wef23" + return ( + f"create {name} {pwd}" + f"connect {name} {pwd}" + ) + +def c_logout(client): + return ("quit", ) + +def c_look(client): + return ("look here", "look me") + +# this is read by dummyrunner. +ACTIONS = ( + c_login, + c_logout, + (1.0, c_look) # (probability, command-generator) +) + +``` + +At the bottom of the default file are a few default profiles you can test out by just setting the `PROFILE` variable to one of the options. + +### Dummyrunner hints + +- Don't start with too many dummies. The Dummyrunner taxes the server much more + than 'real' users tend to do. Start with 10-100 to begin with. +- Stress-testing can be fun, but also consider what a 'realistic' number of + users would be for your game. +- Note in the dummyrunner output how many commands/s are being sent to the + server by all dummies. This is usually a lot higher than what you'd + realistically expect to see from the same number of users. +- The default settings sets up a 'lag' measure to measaure the round-about + message time. It updates with an average every 30 seconds. It can be worth to + have this running for a small number of dummies in one terminal before adding + more by starting another dummyrunner in another terminal - the first one will + act as a measure of how lag changes with different loads. Also verify the + lag-times by entering commands manually in-game. +- Check the CPU usage of your server using `top/htop` (linux). In-game, use the + `server` command. +- You can run the server with `--profiler start` to test it with dummies. Note + that the profiler will itself affect server performance, especially memory + consumption. +- Generally, the dummyrunner system makes for a decent test of general + performance; but it is of course hard to actually mimic human user behavior. + For this, actual real-game testing is required. + diff --git a/docs/latest/_sources/Coding/Release-Notes-1.0.md.txt b/docs/latest/_sources/Coding/Release-Notes-1.0.md.txt new file mode 100644 index 0000000000..2f35334f0c --- /dev/null +++ b/docs/latest/_sources/Coding/Release-Notes-1.0.md.txt @@ -0,0 +1,151 @@ +# Evennia 1.0 Release Notes + +This summarizes the changes. See the [Changelog](./Changelog.md) for the full list. + +- Main development now on `main` branch. `master` branch remains, but will not be updated anymore. + +## Minimum requirements + +- Python 3.10 is now required minimum. Ubuntu LTS now installs with 3.10. Evennia 1.0 is also tested with Python 3.11 - this is the recommended version for Linux/Mac. Windows users may want to stay on Python 3.10 unless they are okay with installing a C++ compiler. +- Twisted 22.10+ +- Django 4.1+ + +## Major new features + +- Evennia is now on PyPi and is installable as [pip install evennia](../Setup/Installation.md). +- A completely revamped documentation at https://www.evennia.com/docs/latest. The old wiki and readmedocs pages will close. +- Evennia 1.0 now has a REST API which allows you access game objects using CRUD operations GET/POST etc. See [The Web-API docs][Web-API] for more information. +- [Evennia<>Discord Integration](../Setup/Channels-to-Discord.md) between Evennia channels and Discord servers. +- [Script](../Components/Scripts.md) overhaul: Scripts' timer component independent from script object deletion; can now start/stop timer without deleting Script. The `.persistent` flag now only controls if timer survives reload - Script has to be removed with `.delete()` like other typeclassed entities. This makes Scripts even more useful as general storage entities. +- The [FuncParser](../Components/FuncParser.md) centralizes and vastly improves all in-string function calls, such as `say the result is $eval(3 * 7)` and say the result `the result is 21`. The parser completely replaces the old `parse_inlinefunc`. The new parser can handle both arguments and kwargs and are also used for in-prototype parsing as well as director stance messaging, such as using `$You()` to represent yourself in a string and having the result come out differently depending on who see you. +- [Channels](../Components/Channels.md) New Channel-System using the `channel` command and nicks. The old `ChannelHandler` was removed and the customization and operation of channels have been simplified a lot. The old command syntax commands are now available as a contrib. +- [Help System](../Components/Help-System.md) was refactored. + - A new type of `FileHelp` system allows you to add in-game help files as external Python files. This means there are three ways to add help entries in Evennia: 1) Auto-generated from Command's code. 2) Manually added to the database from the `sethelp` command in-game and 3) Created as external Python files that Evennia loads and makes available in-game. + - We now use `lunr` search indexing for better `help` matching and suggestions. Also improve + the main help command's default listing output. + - Help command now uses `view` lock to determine if cmd/entry shows in index and `read` lock to determine if it can be read. It used to be `view` in the role of the latter. + - `sethelp` command now warns if shadowing other help-types when creating a new entry. + - Make `help` index output clickable for webclient/clients with MXP (PR by davewiththenicehat) +- Rework of the [Web](../Components/Website.md) setup, into a much more consistent structure and update to latest Django. The `mygame/web/static_overrides` and `-template_overrides` were removed. The folders are now just `mygame/web/static` and `/templates` and handle the automatic copying of data behind the scenes. `app.css` to `website.css` for consistency. The old `prosimii-css` files were removed. +- [AttributeProperty](../Components/Attributes.md#using-attributeproperty)/[TagProperty](../Components/Tags.md) along with `AliasProperty` and `PermissionProperty` to allow managing Attributes, Tags, Aliases and Permissios on typeclasses in the same way as Django fields. This dramatically reduces the need to assign Attributes/Tags in `at_create_object` hook. +- The old `MULTISESSION_MODE` was divided into smaller settings, for better controlling what happens when a user connects, if a character should be auto-created, and how many characters they can control at the same time. See [Connection-Styles](../Concepts/Connection-Styles.md) for a detailed explanation. +- Evennia now supports custom `evennia` launcher commands (e.g. `evennia mycmd foo bar`). Add new commands as callables accepting `*args`, as `settings.EXTRA_LAUNCHER_COMMANDS = {'mycmd': 'path.to.callable', ...}`. + + +## Contribs + +The `contrib` folder structure was changed from 0.9.5. All contribs are now in sub-folders and organized into categories. All import paths must be updated. See [Contribs overview](../Contribs/Contribs-Overview.md). + +- New [Traits contrib](../Contribs/Contrib-Traits.md), converted and expanded from Ainneve project. (whitenoise, Griatch) +- New [Crafting contrib](../Contribs/Contrib-Crafting.md), adding a full crafting subsystem (Griatch) +- New [XYZGrid contrib](../Contribs/Contrib-XYZGrid.md), adding x,y,z grid coordinates with in-game map and pathfinding. Controlled outside of the game via custom evennia launcher command (Griatch) +- New [Command cooldown contrib](../Contribs/Contrib-Cooldowns.md) contrib for making it easier to manage commands using + dynamic cooldowns between uses (owllex) +- New [Godot Protocol contrib](../Contribs/Contrib-Godotwebsocket.md) for connecting to Evennia from a client written in the open-source game engine [Godot](https://godotengine.org/) (ChrisLR). +- New [name_generator contrib](../Contribs/Contrib-Name-Generator.md) for building random real-world based or fantasy-names based on phonetic rules (InspectorCaracal) +- New [Buffs contrib](../Contribs/Contrib-Buffs.md) for managing temporary and permanent RPG status buffs effects (tegiminis) +- The existing [RPSystem contrib](../Contribs/Contrib-RPSystem.md) was refactored and saw a speed boost (InspectorCaracal, other contributors) + +## Translations + +- New Latin (la) translation (jamalainm) +- New German (de) translation (Zhuraj) +- Updated Italian translation (rpolve) +- Updated Swedish translation + +## Utils + +- New `utils.format_grid` for easily displaying long lists of items in a block. This is now used for the default help display. +- Add `utils.repeat` and `utils.unrepeat` as shortcuts to TickerHandler add/remove, similar + to how `utils.delay` is a shortcut for TaskHandler add. +- Add `utils/verb_conjugation` for automatic verb conjugation (English only). This + is useful for implementing actor-stance emoting for sending a string to different targets. +- `utils.evmenu.ask_yes_no` is a helper function that makes it easy to ask a yes/no question + to the user and respond to their input. This complements the existing `get_input` helper. +- New `tasks` command for managing tasks started with `utils.delay` (PR by davewiththenicehat) +- Add `.deserialize()` method to `_Saver*` structures to help completely + decouple structures from database without needing separate import. +- Add `run_in_main_thread` as a helper for those wanting to code server code + from a web view. +- Update `evennia.utils.logger` to use Twisted's new logging API. No change in Evennia API + except more standard aliases logger.error/info/exception/debug etc can now be used. +- Made `utils.iter_to_str` format prettier strings, using Oxford comma. +- Move `create_*` functions into db managers, leaving `utils.create` only being + wrapper functions (consistent with `utils.search`). No change of api otherwise. + +## Locks + +- New `search:` lock type used to completely hide an object from being found by + the `DefaultObject.search` (`caller.search`) method. (CloudKeeper) +- New default for `holds()` lockfunc - changed from default of `True` to default of `False` in order to disallow dropping nonsensical things (such as things you don't hold). + +## Hook changes + +- Changed all `at_before/after_*` hooks to `at_pre/post_*` for consistency + across Evennia (the old names still work but are deprecated) +- New `at_pre_object_leave(obj, destination)` method on `Objects`. +- New `at_server_init()` hook called before all other startup hooks for all + startup modes. Used for more generic overriding (volund) +- New `at_pre_object_receive(obj, source_location)` method on Objects. Called on + destination, mimicking behavior of `at_pre_move` hook - returning False will abort move. +- `Object.normalize_name` and `.validate_name` added to (by default) enforce latinify + on character name and avoid potential exploits using clever Unicode chars (trhr) +- Make `object.search` support 'stacks=0' keyword - if ``>0``, the method will return + N identical matches instead of triggering a multi-match error. +- Add `tags.has()` method for checking if an object has a tag or tags (PR by ChrisLR) +- Add `Msg.db_receiver_external` field to allowe external, string-id message-receivers. +- Add `$pron()` and `$You()` inlinefuncs for pronoun parsing in actor-stance strings using `msg_contents`. + +## Command changes + +- Change default multi-match syntax from `1-obj`, `2-obj` to `obj-1`, `obj-2`, which seems to be what most expect. +- Split `return_appearance` hook with helper methods and have it use a template + string in order to make it easier to override. +- Command executions now done on copies to make sure `yield` don't cause crossovers. Add + `Command.retain_instance` flag for reusing the same command instance. +- Allow sending messages with `page/tell` without a `=` if target name contains no spaces. +- The `typeclass` command will now correctly search the correct database-table for the target + obj (avoids mistakenly assigning an AccountDB-typeclass to a Character etc). +- Merged `script` and `scripts` commands into one, for both managing global- and + on-object Scripts. Moved `CmdScripts` and `CmdObjects` to `commands/default/building.py`. +- The `channel` commands replace all old channel-related commands, such as `cset` etc +- Expand `examine` command's code to much more extensible and modular. Show + attribute categories and value types (when not strings). + - Add ability to examine `/script` and `/channel` entities with `examine` command. +- Add support for `$dbref()` and `$search` when assigning an Attribute value + with the `set` command. This allows assigning real objects from in-game. +- Have `type/force` default to `update`-mode rather than `reset`mode and add more verbose + warning when using reset mode. + +## Coding improvement highlights + +- The db pickle-serializer now checks for methods `__serialize_dbobjs__` and `__deserialize_dbobjs__` to allow custom packing/unpacking of nested dbobjs, to allow storing in Attribute. See [Attributes](../Components/Attributes.md) documentation. +- Add `ObjectParent` mixin to default game folder template as an easy, ready-made + way to override features on all ObjectDB-inheriting objects easily. + source location, mimicking behavior of `at_pre_move` hook - returning False will abort move. +- New Unit test parent classes, for use both in Evenia core and in mygame. Restructured unit tests to always honor default settings. + + +## Other + +- Homogenize manager search methods to always return querysets and not sometimes querysets and sometimes lists. +- Attribute/NAttribute got a homogenous representation, using intefaces, both + `AttributeHandler` and `NAttributeHandler` has same api now. +- Added `content_types` indexing to DefaultObject's ContentsHandler. (volund) +- Made most of the networking classes such as Protocols and the SessionHandlers + replaceable via `settings.py` for modding enthusiasts. (volund) +- The `initial_setup.py` file can now be substituted in `settings.py` to customize + initial game database state. (volund) +- Make IP throttle use Django-based cache system for optional persistence (PR by strikaco) +- In modules given by `settings.PROTOTYPE_MODULES`, spawner will now first look for a global + list `PROTOTYPE_LIST` of dicts before loading all dicts in the module as prototypes. + concept of a dynamically created `ChannelCmdSet`. +- Prototypes now allow setting `prototype_parent` directly to a prototype-dict. + This makes it easier when dynamically building in-module prototypes. +- Make `@lazy_property` decorator create read/delete-protected properties. This is because it's used for handlers, and e.g. self.locks=[] is a common beginner mistake. +- Change `settings.COMMAND_DEFAULT_ARG_REGEX` default from `None` to a regex meaning that + a space or `/` must separate the cmdname and args. This better fits common expectations. +- Add `settings.MXP_ENABLED=True` and `settings.MXP_OUTGOING_ONLY=True` as sane defaults, to avoid known security issues with players entering MXP links. +- Made `MonitorHandler.add/remove` support `category` for monitoring Attributes with a category (before only key was used, ignoring category entirely). + + diff --git a/docs/latest/_sources/Coding/Setting-up-PyCharm.md.txt b/docs/latest/_sources/Coding/Setting-up-PyCharm.md.txt new file mode 100644 index 0000000000..262b62b78c --- /dev/null +++ b/docs/latest/_sources/Coding/Setting-up-PyCharm.md.txt @@ -0,0 +1,84 @@ +# Setting up PyCharm with Evennia + +[PyCharm](https://www.jetbrains.com/pycharm/) is a Python developer's IDE from Jetbrains available for Windows, Mac and Linux. It is a commercial product but offer free trials, a scaled-down community edition and also generous licenses for OSS projects like Evennia. + +> This page was originally tested on Windows (so use Windows-style path examples), but should work the same for all platforms. + +First, install Evennia on your local machine with [[Getting Started]]. If you're new to PyCharm, loading your project is as easy as selecting the `Open` option when PyCharm starts, and browsing to your game folder (the one created with `evennia --init`). We refer to it as `mygame` here. + +If you want to be able to examine evennia's core code or the scripts inside your virtualenv, you'll +need to add them to your project too: + +1. Go to `File > Open...` +1. Select the folder (i.e. the `evennia` root) +1. Select "Open in current window" and "Add to currently opened projects" + +It's a good idea to set up the interpreter this before attempting anything further. The rest of this page assumes your project is already configured in PyCharm. + +1. Go to `File > Settings... > Project: \ > Project Interpreter` +1. Click the Gear symbol `> Add local` +1. Navigate to your `evenv/scripts directory`, and select Python.exe + +Enjoy seeing all your imports checked properly, setting breakpoints, and live variable watching! + +## Debug Evennia from inside PyCharm + +1. Launch Evennia in your preferred way (usually from a console/terminal) +1. Open your project in PyCharm +1. In the PyCharm menu, select `Run > Attach to Local Process...` +1. From the list, pick the `twistd` process with the `server.py` parameter (Example: `twistd.exe --nodaemon --logfile=\\server\logs\server.log --python=\\evennia\server\server.py`) + +Of course you can attach to the `portal` process as well. If you want to debug the Evennia launcher +or runner for some reason (or just learn how they work!), see Run Configuration below. + +> NOTE: Whenever you reload Evennia, the old Server process will die and a new one start. So when you restart you have to detach from the old and then reattach to the new process that was created. + +> To make the process less tedious you can apply a filter in settings to show only the server.py process in the list. To do that navigate to: `Settings/Preferences | Build, Execution, Deployment | Python Debugger` and then in `Attach to process` field put in: `twistd.exe" --nodaemon`. This is an example for windows, I don't have a working mac/linux box. + +![Example process filter configuration](https://i.imgur.com/vkSheR8.png) + +## Run Evennia from inside PyCharm + +This configuration allows you to launch Evennia from inside PyCharm. Besides convenience, it also allows suspending and debugging the evennia_launcher or evennia_runner at points earlier than you could by running them externally and attaching. In fact by the time the server and/or portal are running the launcher will have exited already. + +1. Go to `Run > Edit Configutations...` +1. Click the plus-symbol to add a new configuration and choose Python +1. Add the script: `\\evenv\Scripts\evennia_launcher.py` (substitute your virtualenv if it's not named `evenv`) +1. Set script parameters to: `start -l` (-l enables console logging) +1. Ensure the chosen interpreter is from your virtualenv +1. Set Working directory to your `mygame` folder (not evenv nor evennia) +1. You can refer to the PyCharm documentation for general info, but you'll want to set at least a config name (like "MyMUD start" or similar). + +Now set up a "stop" configuration by following the same steps as above, but set your Script parameters to: stop (and name the configuration appropriately). + +A dropdown box holding your new configurations should appear next to your PyCharm run button. Select MyMUD start and press the debug icon to begin debugging. Depending on how far you let the program run, you may need to run your "MyMUD stop" config to actually stop the server, before you'll be able start it again. + +### Alternative config - utilizing logfiles as source of data + +This configuration takes a bit different approach as instead of focusing on getting the data back through logfiles. Reason for that is this way you can easily separate data streams, for example you rarely want to follow both server and portal at the same time, and this will allow it. This will also make sure to stop the evennia before starting it, essentially working as reload command (it will also include instructions how to disable that part of functionality). We will start by defining a configuration that will stop evennia. This assumes that `upfire` is your pycharm project name, and also the game name, hence the `upfire/upfire` path. + +1. Go to `Run > Edit Configutations...`\ +1. Click the plus-symbol to add a new configuration and choose the python interpreter to use (should be project default) +1. Name the configuration as "stop evennia" and fill rest of the fields accordingly to the image: +![Stop run configuration](https://i.imgur.com/gbkXhlG.png) +1. Press `Apply` + +Now we will define the start/reload command that will make sure that evennia is not running already, and then start the server in one go. +1. Go to `Run > Edit Configutations...`\ +1. Click the plus-symbol to add a new configuration and choose the python interpreter to use (should be project default) +1. Name the configuration as "start evennia" and fill rest of the fields accordingly to the image: +![Start run configuration](https://i.imgur.com/5YEjeHq.png) +1. Navigate to the `Logs` tab and add the log files you would like to follow. The picture shows +adding `portal.log` which will show itself in `portal` tab when running: +![Configuring logs following](https://i.imgur.com/gWYuOWl.png) +1. Skip the following steps if you don't want the launcher to stop evennia before starting. +1. Head back to `Configuration` tab and press the `+` sign at the bottom, under `Before launch....` +and select `Run another configuration` from the submenu that will pop up. +1. Click `stop evennia` and make sure that it's added to the list like on the image above. +1. Click `Apply` and close the run configuration window. + +You are now ready to go, and if you will fire up `start evennia` configuration you should see +following in the bottom panel: +![Example of running alternative configuration](https://i.imgur.com/nTfpC04.png) +and you can click through the tabs to check appropriate logs, or even the console output as it is +still running in interactive mode. \ No newline at end of file diff --git a/docs/latest/_sources/Coding/Soft-Code.md.txt b/docs/latest/_sources/Coding/Soft-Code.md.txt new file mode 100644 index 0000000000..95e7d955f5 --- /dev/null +++ b/docs/latest/_sources/Coding/Soft-Code.md.txt @@ -0,0 +1,64 @@ +# Soft Code + + +Softcode is a simple programming language that was created for in-game development on TinyMUD derivatives such as MUX, PennMUSH, TinyMUSH, and RhostMUSH. The idea was that by providing a stripped down, minimalistic language for in-game use, you could allow quick and easy building and game development to happen without builders having to learn the 'hardcode' language for those servers (C/C++). There is an added benefit of not having to have to hand out shell access to all developers. Permissions in softcode can be used to alleviate many security problems. + +Writing and installing softcode is done through a MUD client. Thus it is not a formatted language. Each softcode function is a single line of varying size. Some functions can be a half of a page long or more which is obviously not very readable nor (easily) maintainable over time. + +## Examples of Softcode + +Here is a simple 'Hello World!' command: + +```bash + @set me=HELLO_WORLD.C:$hello:@pemit %#=Hello World! +``` + + Pasting this into a MUD client, sending it to a MUX/MUSH server and typing 'hello' will theoretically yield 'Hello World!', assuming certain flags are not set on your account object. + +Setting attributes in Softcode is done via `@set`. Softcode also allows the use of the ampersand (`&`) symbol. This shorter version looks like this: + +```bash + &HELLO_WORLD.C me=$hello:@pemit %#=Hello World! +``` + +We could also read the text from an attribute which is retrieved when emitting: + +```bash + &HELLO_VALUE.D me=Hello World + &HELLO_WORLD.C me=$hello:@pemit %#=[v(HELLO_VALUE.D)] +``` + +The `v()` function returns the `HELLO_VALUE.D` attribute on the object that the command resides (`me`, which is yourself in this case). This should yield the same output as the first example. + +If you are curious about how MUSH/MUX Softcode works, take a look at some external resources: + +- https://wiki.tinymux.org/index.php/Softcode +- https://www.duh.com/discordia/mushman/man2x1 + +## Problems with Softcode + +Softcode is excellent at what it was intended for: *simple things*. It is a great tool for making an interactive object, a room with ambiance, simple global commands, simple economies and coded systems. However, once you start to try to write something like a complex combat system or a higher end economy, you're likely to find yourself buried under a mountain of functions that span multiple objects across your entire code. + +Not to mention, softcode is not an inherently fast language. It is not compiled, it is parsed with each calling of a function. While MUX and MUSH parsers have jumped light years ahead of where they once were, they can still stutter under the weight of more complex systems if those are not designed properly. + +Also, Softcode is not a standardized language. Different servers each have their own slight variations. Code tools and resources are also limited to the documentation from those servers. + +## Changing Times + +Now that starting text-based games is easy and an option for even the most technically inarticulate, new projects are a dime a dozen. People are starting new MUDs every day with varying levels of commitment and ability. Because of this shift from fewer, larger, well-staffed games to a bunch of small, one or two developer games, the benefit of softcode fades. + +Softcode is great in that it allows a mid to large sized staff all work on the same game without stepping on one another's toes without shell access. However, the rise of modern code collaboration tools (such as private github/gitlab repos) has made it trivial to collaborate on code. + +## Our Solution + +Evennia shuns in-game softcode for on-disk Python modules. Python is a popular, mature and professional programming language. Evennia developers have access to the entire library of Python modules out there in the wild - not to mention the vast online help resources available. Python code is not bound to one-line functions on objects; complex systems may be organized neatly into real source code modules, sub-modules, or even broken out into entire Python packages as desired. + +So what is *not* included in Evennia is a MUX/MOO-like online player-coding system (aka Softcode). Advanced coding in Evennia is primarily intended to be done outside the game, in full-fledged Python modules (what MUSH would call 'hardcode'). Advanced building is best handled by extending Evennia's command system with your own sophisticated building commands. + +In Evennia you develop your MU like you would any piece of modern software - using your favorite code editor/IDE and online code sharing tools. + +## Your Solution + +Adding advanced and flexible building commands to your game is easy and will probably be enough to satisfy most creative builders. However, if you really, *really* want to offer online coding, there is of course nothing stopping you from adding that to Evennia, no matter our recommendations. You could even re-implement MUX' softcode in Python should you be very ambitious. + +In default Evennia, the [Funcparser](Funcparser) system allows for simple remapping of text on-demand without becomeing a full softcode language. The [contribs](Contrib-Overview) has several tools and utililities to start from when adding more complex in-game building. \ No newline at end of file diff --git a/docs/latest/_sources/Coding/Unit-Testing.md.txt b/docs/latest/_sources/Coding/Unit-Testing.md.txt new file mode 100644 index 0000000000..b4825a3c44 --- /dev/null +++ b/docs/latest/_sources/Coding/Unit-Testing.md.txt @@ -0,0 +1,306 @@ +# Unit Testing + +*Unit testing* means testing components of a program in isolation from each other to make sure every part works on its own before using it with others. Extensive testing helps avoid new updates causing unexpected side effects as well as alleviates general code rot (a more comprehensive wikipedia article on unit testing can be found [here](https://en.wikipedia.org/wiki/Unit_test)). + +A typical unit test set calls some function or method with a given input, looks at the result and makes sure that this result looks as expected. Rather than having lots of stand-alone test programs, Evennia makes use of a central *test runner*. This is a program that gathers all available tests all over the Evennia source code (called *test suites*) and runs them all in one go. Errors and tracebacks are reported. + + By default Evennia only tests itself. But you can also add your own tests to your game code and have Evennia run those for you. + +## Running the Evennia test suite + +To run the full Evennia test suite, go to your game folder and issue the command + + evennia test evennia + +This will run all the evennia tests using the default settings. You could also run only a subset of +all tests by specifying a subpackage of the library: + + evennia test evennia.commands.default + +A temporary database will be instantiated to manage the tests. If everything works out you will see +how many tests were run and how long it took. If something went wrong you will get error messages. +If you contribute to Evennia, this is a useful sanity check to see you haven't introduced an +unexpected bug. + +## Running custom game-dir unit tests + +If you have implemented your own tests for your game you can run them from your game dir +with + + evennia test --settings settings.py . + +The period (`.`) means to run all tests found in the current directory and all subdirectories. You +could also specify, say, `typeclasses` or `world` if you wanted to just run tests in those subdirs. + +An important thing to note is that those tests will all be run using the _default Evennia settings_. +To run the tests with your own settings file you must use the `--settings` option: + + evennia test --settings settings.py . + +The `--settings` option of Evennia takes a file name in the `mygame/server/conf` folder. It is +normally used to swap settings files for testing and development. In combination with `test`, it +forces Evennia to use this settings file over the default one. + +You can also test specific things by giving their path + + evennia test --settings settings.py world.tests.YourTest + + +## Writing new unit tests + +Evennia's test suite makes use of Django unit test system, which in turn relies on Python's +*unittest* module. + +To make the test runner find the tests, they must be put in a module named `test*.py` (so `test.py`, +`tests.py` etc). Such a test module will be found wherever it is in the package. It can be a good +idea to look at some of Evennia's `tests.py` modules to see how they look. + +Inside the module you need to put a class inheriting (at any distance) from `unittest.TestCase`. Each +method on that class that starts with `test_` will be run separately as a unit test. There +are two special, optional methods `setUp` and `tearDown` that will (if you define them) run before +_every_ test. This can be useful for setting up and deleting things. + +To actually test things, you use special `assert...` methods on the class. Most common on is +`assertEqual`, which makes sure a result is what you expect it to be. + +Here's an example of the principle. Let's assume you put this in `mygame/world/tests.py` +and want to test a function in `mygame/world/myfunctions.py` + +```python + # in a module tests.py somewhere i your game dir + import unittest + + from evennia import create_object + # the function we want to test + from .myfunctions import myfunc + + + class TestObj(unittest.TestCase): + "This tests a function myfunc." + + def setUp(self): + """done before every of the test_ * methods below""" + self.obj = create_object("mytestobject") + + def tearDown(self): + """done after every test_* method below """ + self.obj.delete() + + def test_return_value(self): + """test method. Makes sure return value is as expected.""" + actual_return = myfunc(self.obj) + expected_return = "This is the good object 'mytestobject'." + # test + self.assertEqual(expected_return, actual_return) + def test_alternative_call(self): + """test method. Calls with a keyword argument.""" + actual_return = myfunc(self.obj, bad=True) + expected_return = "This is the baaad object 'mytestobject'." + # test + self.assertEqual(expected_return, actual_return) +``` + +To test this, run + + evennia test --settings settings.py . + +to run the entire test module + + evennia test --settings settings.py world.tests + +or a specific class: + + evennia test --settings settings.py world.tests.TestObj + +You can also run a specific test: + + evennia test --settings settings.py world.tests.TestObj.test_alternative_call + +You might also want to read the [Python documentation for the unittest module](https://docs.python.org/library/unittest.html). + +### Using the Evennia testing classes + +Evennia offers many custom testing classes that helps with testing Evennia features. +They are all found in [evennia.utils.test_resources](evennia.utils.test_resources). Note that +these classes implement the `setUp` and `tearDown` already, so if you want to add stuff in them +yourself you should remember to use e.g. `super().setUp()` in your code. + +#### Classes for testing your game dir + +These all use whatever setting you pass to them and works well for testing code in your game dir. + +- `EvenniaTest` - this sets up a full object environment for your test. All the created entities + can be accesses as properties on the class: + - `.account` - A fake [Account](evennia.accounts.accounts.DefaultAccount) named "TestAccount". + - `.account2` - Another account named "TestAccount2" + - `char1` - A [Character](evennia.objects.objects.DefaultCharacter) linked to `.account`, named `Char`. + This has 'Developer' permissions but is not a superuser. + - `.char2` - Another character linked to `account`, named `Char2`. This has base permissions (player). + - `.obj1` - A regular [Object](evennia.objects.objects.DefaultObject) named "Obj". + - `.obj2` - Another object named "Obj2". + - `.room1` - A [Room](evennia.objects.objects.DefaultRoom) named "Room". Both characters and both + objects are located inside this room. It has a description of "room_desc". + - `.room2` - Another room named "Room2". It is empty and has no set description. + - `.exit` - An exit named "out" that leads from `.room1` to `.room2`. + - `.script` - A [Script](evennia.scripts.scripts.DefaultScript) named "Script". It's an inert script + without a timing component. + - `.session` - A fake [Session](evennia.server.serversession.ServerSession) that mimics a player + connecting to the game. It is used by `.account1` and has a sessid of 1. +- `EvenniaCommandTest` - has the same environment like `EvenniaTest` but also adds a special + [.call()](evennia.utils.test_resources.EvenniaCommandTestMixin.call) method specifically for + testing Evennia [Commands](../Components/Commands.md). It allows you to compare what the command _actually_ + returns to the player with what you expect. Read the `call` api doc for more info. +- `EvenniaTestCase` - This is identical to the regular Python `TestCase` class, it's + just there for naming symmetry with `BaseEvenniaTestCase` below. + +Here's an example of using `EvenniaTest` + +```python +# in a test module + +from evennia.utils.test_resources import EvenniaTest + +class TestObject(EvenniaTest): + """Remember that the testing class creates char1 and char2 inside room1 ...""" + def test_object_search_character(self): + """Check that char1 can search for char2 by name""" + self.assertEqual(self.char1.search(self.char2.key), self.char2) + + def test_location_search(self): + """Check so that char1 can find the current location by name""" + self.assertEqual(self.char1.search(self.char1.location.key), self.char1.location) + # ... +``` + +This example tests a custom command. + +```python + from evennia.commands.default.tests import EvenniaCommandTest +from commands import command as mycommand + + +class TestSet(EvenniaCommandTest): + "tests the look command by simple call, using Char2 as a target" + + def test_mycmd_char(self): + self.call(mycommand.CmdMyLook(), "Char2", "Char2(#7)") + + def test_mycmd_room(self): + "tests the look command by simple call, with target as room" + self.call(mycommand.CmdMyLook(), "Room", + "Room(#1)\nroom_desc\nExits: out(#3)\n" + "You see: Obj(#4), Obj2(#5), Char2(#7)") +``` + +When using `.call`, you don't need to specify the entire string; you can just give the beginning +of it and if it matches, that's enough. Use `\n` to denote line breaks and (this is a special for +the `.call` helper), `||` to indicate multiple uses of `.msg()` in the Command. The `.call` helper +has a lot of arguments for mimicing different ways of calling a Command, so make sure to +[read the API docs for .call()](evennia.utils.test_resources.EvenniaCommandTestMixin.call). + +#### Classes for testing Evennia core + +These are used for testing Evennia itself. They provide the same resources as the classes +above but enforce Evennias default settings found in `evennia/settings_default.py`, ignoring +any settings changes in your game dir. + +- `BaseEvenniaTest` - all the default objects above but with enforced default settings +- `BaseEvenniaCommandTest` - for testing Commands, but with enforced default settings +- `BaseEvenniaTestCase` - no default objects, only enforced default settings + +There are also two special 'mixin' classes. These are uses in the classes above, but may also +be useful if you want to mix your own testing classes: + +- `EvenniaTestMixin` - A class mixin that creates all test environment objects. +- `EvenniaCommandMixin` - A class mixin that adds the `.call()` Command-tester helper. + +If you want to help out writing unittests for Evennia, take a look at Evennia's [coveralls.io +page](https://coveralls.io/github/evennia/evennia). There you see which modules have any form of +test coverage and which does not. All help is appreciated! + +### Unit testing contribs with custom models + +A special case is if you were to create a contribution to go to the `evennia/contrib` folder that +uses its [own database models](../Concepts/Models.md). The problem with this is that Evennia (and Django) will +only recognize models in `settings.INSTALLED_APPS`. If a user wants to use your contrib, they will +be required to add your models to their settings file. But since contribs are optional you cannot +add the model to Evennia's central `settings_default.py` file - this would always create your +optional models regardless of if the user wants them. But at the same time a contribution is a part +of the Evennia distribution and its unit tests should be run with all other Evennia tests using +`evennia test evennia`. + +The way to do this is to only temporarily add your models to the `INSTALLED_APPS` directory when the test runs. here is an example of how to do it. + +> Note that this solution, derived from this [stackexchange answer](http://stackoverflow.com/questions/502916/django-how-to-create-a-model-dynamically-just-for-testing#503435) is currently untested! Please report your findings. + +```python +# a file contrib/mycontrib/tests.py + +from django.conf import settings +import django +from evennia.utils.test_resources import BaseEvenniaTest + +OLD_DEFAULT_SETTINGS = settings.INSTALLED_APPS +DEFAULT_SETTINGS = dict( + INSTALLED_APPS=( + 'contrib.mycontrib.tests', + ), + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3" + } + }, + SILENCED_SYSTEM_CHECKS=["1_7.W001"], +) + + +class TestMyModel(BaseEvenniaTest): + def setUp(self): + if not settings.configured: + settings.configure(**DEFAULT_SETTINGS) + django.setup() + + from django.core.management import call_command + from django.db.models import loading + loading.cache.loaded = False + call_command('syncdb', verbosity=0) + + def tearDown(self): + settings.configure(**OLD_DEFAULT_SETTINGS) + django.setup() + + from django.core.management import call_command + from django.db.models import loading + loading.cache.loaded = False + call_command('syncdb', verbosity=0) + + # test cases below ... + + def test_case(self): +# test case here +``` + + +### A note on making the test runner faster + +If you have custom models with a large number of migrations, creating the test database can take a very long time. If you don't require migrations to run for your tests, you can disable them with the +django-test-without-migrations package. To install it, simply: + +``` +$ pip install django-test-without-migrations +``` + +Then add it to your `INSTALLED_APPS` in your `server.conf.settings.py`: + +```python +INSTALLED_APPS = ( + # ... + 'test_without_migrations', +) +``` + +After doing so, you can then run tests without migrations by adding the `--nomigrations` argument: + +``` +evennia test --settings settings.py --nomigrations . +``` diff --git a/docs/latest/_sources/Coding/Version-Control.md.txt b/docs/latest/_sources/Coding/Version-Control.md.txt new file mode 100644 index 0000000000..f8af7a380e --- /dev/null +++ b/docs/latest/_sources/Coding/Version-Control.md.txt @@ -0,0 +1,344 @@ +# Coding using Version Control + +[Version control](https://en.wikipedia.org/wiki/Version_control) allows you to track changes to your code. You can save 'snapshots' of your progress which means you can roll back undo things easily. Version control also allows you to easily back up your code to an online _repository_ such as Github. It also allows you to collaborate with others on the same code without clashing or worry about who changed what. + +```{sidebar} Do it! +It's _strongly_ recommended that you [put your game folder under version control](#putting-your-game-dir-under-version-control). Using git is is also the way to contribue to Evennia itself. +``` + +Evennia uses the most commonly used version control system, [Git](https://git-scm.com/) . For additional help on using Git, please refer to the [Official GitHub documentation](https://help.github.com/articles/set-up-git#platform-all). + +## Setting up Git + +- **Fedora Linux** + + yum install git-core + +- **Debian Linux** _(Ubuntu, Linux Mint, etc.)_ + + apt-get install git + +- **Windows**: It is recommended to use [Git for Windows](https://gitforwindows.org/). +- **Mac**: Mac platforms offer two methods for installation, one via MacPorts, which you can find out about [here](https://git-scm.com/book/en/Getting-Started-Installing-Git#Installing-on-Mac), or you can use the [Git OSX Installer](https://sourceforge.net/projects/git-osx-installer/). + +> You can find expanded instructions for installation [here](https://git-scm.com/book/en/Getting-Started-Installing-Git). + +```{sidebar} Git user nickname +If you ever make your code available online (or contribute to Evennia), your name will be visible to those reading the code-commit history. So if you are not comfortable with using your real, full name online, put a nickname (or your github handler) here. +``` +To avoid a common issue later, you will need to set a couple of settings; first you will need to tell Git your username, followed by your e-mail address, so that when you commit code later you will be properly credited. + + +1. Set the default name for git to use when you commit: + + git config --global user.name "Your Name Here" + +2. Set the default email for git to use when you commit: + + git config --global user.email "your_email@example.com" + +> To get a running start with Git, here's [a good YouTube talk about it](https://www.youtube.com/watch?v=1ffBJ4sVUb4#t=1m58s). It's a bit long but it will help you understand the underlying ideas behind GIT (which in turn makes it a lot more intuitive to use). + +## Common Git commands + +```{sidebar} Git repository +This is just a fancy name for the folder you have designated to be under version control. We will make your `mygame` game folder into such a repository. The Evennia code is also in a (separate) git repository. +``` +Git can be controlled via a GUI. But it's often easier to use the base terminal/console commands, since it makes it clear if something goes wrong. + +All these actions need to be done from inside the _git repository_ . + +Git may seem daunting at first. But when working with git, you'll be using the same 2-3 commands 99% of the time. And you can make git _aliases_ to have them be even easier to remember. + + +### `git init` + +This initializes a folder/directory on your drive as a 'git repository' + + git init . + +The `.` means to apply to the current directory. If you are inside `mygame`, this makes your game dir into a git repository. That's all there is to it, really. You only need to do this once. + +### `git add` + + git add + +This tells Git to start to _track_ the file under version control. You need to do this when you create a new file. You can also add all files in your current directory: + + git add . + +Or + + git add * + +All files in the current directory are now tracked by Git. You only need to do this once for every file you want to track. + +### `git commit` + + git commit -a -m "This is the initial commit" + +This _commits_ your changes. It stores a snapshot of all (`-a`) your code at the current time, adding a message `-m` so you know what you did. Later you can _check out_ your code the way it was at a given time. The message is mandatory and you will thank yourself later if write clear and descriptive log messages. If you don't add `-m`, a text editor opens for you to write the message instead. + +The `git commit` is something you'll be using all the time, so it can be useful to make a _git alias_ for it: + + git config --global alias.cma 'commit -a -m' + +After you've run this, you can commit much simpler, like this: + + git cma "This is the initial commit" + +Much easier to remember! + +### `git status`, `git diff` and `git log` + + + git status -s + +This gives a short (`-s`) of the files that changes since your last `git commit`. + + git diff --word-diff` + +This shows exactly what changed in each file since you last made a `git commit`. The `--word-diff` option means it will mark if a single word changed on a line. + + git log + +This shows the log of all `commits` done. Each log will show you who made the change, the commit-message and a unique _hash_ (like `ba214f12ab12e123...`) that uniquely describes that commit. + +You can make the `log` command more succinct with some more options: + + ls=log --pretty=format:%C(green)%h\ %C(yellow)[%ad]%Cred%d\ %Creset%s%Cblue\ [%an] --decorate --date=relative + +This adds coloration and another fancy effects (use `git help log` to see what they mean). + +Let's add aliases: + + git config --global alias.st 'status -s' + git config --global alias.df 'diff --word-diff' + git config --global alias.ls 'log --pretty=format:%C(green)%h\ %C(yellow)[%ad]%Cred%d\ %Creset%s%Cblue\ [%an] --decorate --date=relative' + +You can now use the much shorter + + git st # short status + git dif # diff with word-marking + git ls # log with pretty formatting + +for these useful functions. + +### `git branch`, `checkout` and `merge` + +Git allows you to work with _branches_. These are separate development paths your code may take, completely separate from each other. You can later _merge_ the code from a branch back into another branch. Evennia's `main` and `develop` branches are examples of this. + + git branch -b branchaname + +This creates a new branch, exactly identical to the branch you were on. It also moves you to that branch. + + git branch -D branchname + +Deletes a branch. + + git branch + +Shows all your branches, marking which one you are currently on. + + git checkout branchname + +This checks out another branch. As long as you are in a branch all `git commit`s will commit the code to that branch only. + + git checkout . + +This checks out your _current branch_ and has the effect of throwing away all your changes since your last commit. This is like undoing what you did since the last save point. + + git checkout b2342bc21c124 + +This checks out a particular _commit_, identified by the hash you find with `git log`. This open a 'temporary branch' where the code is as it was when you made this commit. As an example, you can use this to check where a bug was introduced. Check out an existing branch to go back to your normal timeline, or use `git branch -b newbranch` to break this code off into a new branch you can continue working from. + + git merge branchname + +This _merges_ the code from `branchname` into the branch you are currently in. Doing so may lead to _merge conflicts_ if the same code changed in different ways in the two branches. See [how to resolve merge conflicts in git](https://phoenixnap.com/kb/how-to-resolve-merge-conflicts-in-git) for more help. + +### `git glone`, `git push` and `git pull` + +All of these other commands have dealt with code only sitting in your local repository-folder. These commands instead allows you to exchange code with a _remote_ repository - usually one that is online (like on github). + +> How you actually set up a remote repository is described [in the next section](#pushing-your-code-online). + + git clone repository/path + +This copies the remote repository to your current location. If you used the [Git installation instructions](../Setup/Installation-Git.md) to install Evennia, this is what you used to get your local copy of the Evennia repository. + + git pull + +Once you cloned or otherwise set up a remote repository, using `git pull` will re-sync the remote with what you have locally. If what you download clashes with local changes, git will force you to `git commit` your changes before you can continue with `git pull`. + + git push + +This uploads your local changes _of your current branch_ to the same-named branch on the remote repository. To be able to do this you must have write-permissions to the remote repository. + +### Other git commands + +There are _many_ other git commands. Read up on them online: + + git reflog + +Shows hashes of individual git actions. This allows you to go back in the git event history itself. + + + git reset + +Force reset a branch to an earlier commit. This could throw away some history, so be careful. + + git grep -n -I -i + +Quickly search for a phrase/text in all files tracked by git. Very useful to quickly find where things are. Set up an alias `git gr` with + +``` +git config --global alias.gr 'grep -n -I -i' +``` + +## Putting your game dir under version control + +This makes use of the git commands listed in the previous section. + +```{sidebar} git aliases +If you set up the git aliases for commands suggested in the previous section, you can use them instead! +``` + + cd mygame + git init . + git add * + git commit -a -m "Initial commit" + + +Your game-dir is now tracked by git. + +You will notice that some files are not covered by your git version control, notably your secret-settings file (`mygame/server/conf/secret_settings.py`) and your sqlite3 database file `mygame/server/evennia.db3`. This is intentional and controlled from the file `mygame/.gitignore`. + +```{warning} +You should *never* put your sqlite3 database file into git by removing its entry +in `.gitignore`. GIT is for backing up your code, not your database. That way +lies madness and a good chance you'll confuse yourself. Make one mistake or local change and after a few commits and reverts you will have lost track of what is in your database or not. If you want to backup your SQlite3 database, do so by simply copying the database file to a safe location. +``` + +### Pushing your code online + +So far your code is only located on your private machine. A good idea is to back it up online. The easiest way to do this is to `git push` it to your own remote repository on GitHub. So for this you need a (free) Github account. + +If you don't want your code to be publicly visible, Github also allows you set up a _private_ repository, only visible to you. + +Create a new, empty repository on Github. [Github explains how here](https://help.github.com/articles/create-a-repo/) . _Don't_ allow it to add a README, license etc, that will just clash with what we upload later. + +```{sidebar} Origin +We label the remote repository 'origin'. This is the git default and means we won't need to specify it explicitly later. +``` + +Make sure you are in your local game dir (previously initialized as a git repo). + + git remote add origin + +This tells Git that there is a remote repository at ``. See the github docs as to which URL to use. Verify that the remote works with `git remote -v` + +Now we push to the remote (labeled 'origin' which is the default): + + git push + +Depending on how you set up your authentication with github, you may be asked to enter your github username and password. If you set up SSH authentication, this command will just work. + +You use `git push` to upload your local changes so the remote repository is in sync with your local one. If you edited a file online using the Github editor (or a collaborator pushed code), you use `git pull` to sync in the other direction. + +## Contributing to Evennia + +If you want to help contributing to Evennia you must do so by _forking_ - making your own remote copy of the Evennia repository on Github. So for this, you need a (free) Github account. Doing so is a completely separate process from [putting your game dir under version control](#putting-your-game-dir-under-version-control) (which you should also do!). + +At the top right of [the evennia github page](https://github.com/evennia/evennia), click the "Fork" button: + +![fork button](../_static/images/fork_button.png) + +This will create a new online fork Evennia under your github account. + +The fork only exists online as of yet. In a terminal, `cd` to the folder you wish to develop in. This folder should _not_ be your game dir, nor the place you cloned Evennia into if you used the [Git installation](../Setup/Installation-Git.md). + +From this directory run the following command: + + git clone https://github.com/yourusername/evennia.git evennia + +This will download your fork to your computer. It creates a new folder `evennia/` at your current location. If you installed Evennia using the [Git installation](../Setup/Installation-Git.md), this folder will be identical in content to the `evennia` folder you cloned during that installation. The difference is that this repo is connected to your remote fork and not to the 'original' _upstream_ Evennia. + +When we cloned our fork, git automatically set up a 'remote repository' labeled `origin` pointing to it. So if we do `git pull` and `git push`, we'll push to our fork. + +We now want to add a second remote repository linked to the original Evennia repo. We will label this remote repository `upstream`: + + cd evennia + git remote add upstream https://github.com/evennia/evennia.git + +If you also want to access Evennia's `develop` branch (the bleeding edge development) do the following: + + git fetch upstream develop + git checkout develop + +Use + + git checkout main + git checkout develop + +to switch between the branches. + +To pull the latest from upstream Evennia, just checkout the branch you want and do + + git pull upstream + +```{sidebar} Pushing to upstream +You can't do `git push upstream` unless you have write-access to the upstream Evennia repository. So there is no risk of you accidentally pushing your own code into the main, public repository. +``` + +### Fixing an Evennia bug or feature + +This should be done in your fork of Evennia. You should _always_ do this in a _separate git branch_ based off the Evennia branch you want to improve. + + git checkout main (or develop) + git branch -b myfixbranch + +Now fix whatever needs fixing. Abide by the [Evennia code style](./Evennia-Code-Style.md). You can `git commit` commit your changes along the way as normal. + +Upstream Evennia is not standing still, so you want to make sure that your work is up-to-date with upstream changes. Make sure to first commit your `myfixbranch` changes, then + + git checkout main (or develop) + git pull upstream + git checkout myfixbranch + git merge main (or develop) + +Up to this point your `myfixbranch` branch only exists on your local computer. No +one else can see it. + + git push + +This will automatically create a matching `myfixbranch` in your forked version of Evennia and push to it. On github you will be able to see appear it in the `branches` dropdown. You can keep pushing to your remote `myfixbranch` as much as you like. + +Once you feel you have something to share, you need to [create a pull request](https://github.com/evennia/evennia/pulls) (PR): +This is a formal request for upstream Evennia to adopt and pull your code into the main repository. +1. Click `New pull request` +2. Choose `compare across forks` +3. Select your fork from dropdown list of `head repository` repos. Pick the right branch to `compare`. +4. On the Evennia side (to the left) make sure to pick the right `base` branch: If you want to contribute a change to the `develop` branch, you must pick `develop` as the `base`. +5. Then click `Create pull request` and fill in as much information as you can in the form. +6. Optional: Once you saved your PR, you can go into your code (on github) and add some per-line comments; this can help reviewers by explaining complex code or decisions you made. + +Now you just need to wait for your code to be reviewed. Expect to get feedback and be asked to make changes, add more documentation etc. Getting as PR merged can take a few iterations. + +```{sidebar} Not all PRs can merge +While most PRs get merged, Evennia can't **guarantee** that your PR code will be deemed suitable to merge into upstream Evennia. For this reason it's a good idea to check in with the community _before_ you spend a lot of time on a large piece of code (fixing bugs is always a safe bet though!) +``` + + +## Troubleshooting + +### Getting 403: Forbidden access + +Some users have experienced this on `git push` to their remote repository. They are not asked for username/password (and don't have a ssh key set up). + +Some users have reported that the workaround is to create a file `.netrc` under your home directory and add your github credentials there: + +```bash +machine github.com +login +password +``` \ No newline at end of file diff --git a/docs/latest/_sources/Components/Accounts.md.txt b/docs/latest/_sources/Components/Accounts.md.txt new file mode 100644 index 0000000000..8365757c62 --- /dev/null +++ b/docs/latest/_sources/Components/Accounts.md.txt @@ -0,0 +1,90 @@ +# Accounts + +``` +┌──────┐ │ ┌───────┐ ┌───────┐ ┌──────┐ +│Client├─┼──►│Session├───►│Account├──►│Object│ +└──────┘ │ └───────┘ └───────┘ └──────┘ + ^ +``` + +An _Account_ represents a unique game account - one player playing the game. Whereas a player can potentially connect to the game from several Clients/Sessions, they will normally have only one Account. + +The Account object has no in-game representation. In order to actually get on the game the Account must *puppet* an [Object](./Objects.md) (normally a [Character](./Objects.md#characters)). + +Exactly how many Sessions can interact with an Account and its Puppets at once is determined by +Evennia's [MULTISESSION_MODE](../Concepts/Connection-Styles.md#multisession-mode-and-multiplaying) + +Apart from storing login information and other account-specific data, the Account object is what is chatting on Evennia's default [Channels](./Channels.md). It is also a good place to store [Permissions](./Locks.md) to be consistent between different in-game characters. It can also hold player-level configuration options. + +The Account object has its own default [CmdSet](./Command-Sets.md), the `AccountCmdSet`. The commands in this set are available to the player no matter which character they are puppeting. Most notably the default game's `exit`, `who` and chat-channel commands are in the Account cmdset. + + > ooc + +The default `ooc` command causes you to leave your current puppet and go into OOC mode. In this mode you have no location and have only the Account-cmdset available. It acts a staging area for switching characters (if your game supports that) as well as a safety fallback if your character gets accidentally deleted. + + > ic + +This re-puppets the latest character. + +Note that the Account object can have, and often does have, a different set of [Permissions](./Permissions.md) from the Character they control. Normally you should put your permissions on the Account level - this will overrule permissions set on the Character level. For the permissions of the Character to come into play the default `quell` command can be used. This allows for exploring the game using a different permission set (but you can't escalate your permissions this way - for hierarchical permissions like `Builder`, `Admin` etc, the *lower* of the permissions on the Character/Account will always be used). + + +## Working with Accounts + +You will usually not want more than one Account typeclass for all new accounts. + +An Evennia Account is, per definition, a Python class that includes `evennia.DefaultAccount` among its parents. In `mygame/typeclasses/accounts.py` there is an empty class ready for you to modify. Evennia defaults to using this (it inherits directly from `DefaultAccount`). + +Here's an example of modifying the default Account class in code: + +```python + # in mygame/typeclasses/accounts.py + + from evennia import DefaultAccount + + class Account(DefaultAccount): + # [...] + def at_account_creation(self): + "this is called only once, when account is first created" + self.db.real_name = None # this is set later + self.db.real_address = None # " + self.db.config_1 = True # default config + self.db.config_2 = False # " + self.db.config_3 = 1 # " + + # ... whatever else our game needs to know +``` + +Reload the server with `reload`. + +... However, if you use `examine *self` (the asterisk makes you examine your Account object rather than your Character), you won't see your new Attributes yet. This is because `at_account_creation` is only called the very *first* time the Account is called and your Account object already exists (any new Accounts that connect will see them though). To update yourself you need to make sure to re-fire the hook on all the Accounts you have already created. Here is an example of how to do this using `py`: + +``` py [account.at_account_creation() for account in evennia.managers.accounts.all()] ``` + +You should now see the Attributes on yourself. + +> If you wanted Evennia to default to a completely *different* Account class located elsewhere, you > must point Evennia to it. Add `BASE_ACCOUNT_TYPECLASS` to your settings file, and give the python path to your custom class as its value. By default this points to `typeclasses.accounts.Account`, the empty template we used above. + + +### Properties on Accounts + +Beyond those properties assigned to all typeclassed objects (see [Typeclasses](./Typeclasses.md)), the Account also has the following custom properties: + +- `user` - a unique link to a `User` Django object, representing the logged-in user. +- `obj` - an alias for `character`. +- `name` - an alias for `user.username` +- `sessions` - an instance of [ObjectSessionHandler](github:evennia.objects.objects#objectsessionhandler) managing all connected Sessions (physical connections) this object listens to (Note: In older versions of Evennia, this was a list). The so-called `session-id` (used in many places) is found as a property `sessid` on each Session instance. +- `is_superuser` (bool: True/False) - if this account is a superuser. + +Special handlers: +- `cmdset` - This holds all the current [Commands](./Commands.md) of this Account. By default these are + the commands found in the cmdset defined by `settings.CMDSET_ACCOUNT`. +- `nicks` - This stores and handles [Nicks](./Nicks.md), in the same way as nicks it works on Objects. For Accounts, nicks are primarily used to store custom aliases for [Channels](./Channels.md). + +Selection of special methods (see `evennia.DefaultAccount` for details): +- `get_puppet` - get a currently puppeted object connected to the Account and a given session id, if any. +- `puppet_object` - connect a session to a puppetable Object. +- `unpuppet_object` - disconnect a session from a puppetable Object. +- `msg` - send text to the Account +- `execute_cmd` - runs a command as if this Account did it. +- `search` - search for Accounts. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Attributes.md.txt b/docs/latest/_sources/Components/Attributes.md.txt new file mode 100644 index 0000000000..e971b18b4b --- /dev/null +++ b/docs/latest/_sources/Components/Attributes.md.txt @@ -0,0 +1,502 @@ +# Attributes + +```{code-block} +:caption: In-game +> set obj/myattr = "test" +``` +```{code-block} python +:caption: In-code, using the .db wrapper +obj.db.foo = [1, 2, 3, "bar"] +value = obj.db.foo +``` +```{code-block} python +:caption: In-code, using the .attributes handler +obj.attributes.add("myattr", 1234, category="bar") +value = attributes.get("myattr", category="bar") +``` +```{code-block} python +:caption: In-code, using `AttributeProperty` at class level +from evennia import DefaultObject +from evennia import AttributeProperty + +class MyObject(DefaultObject): + foo = AttributeProperty(default=[1, 2, 3, "bar"]) + myattr = AttributeProperty(100, category='bar') + +``` + +_Attributes_ allow you to to store arbitrary data on objects and make sure the data survives a server reboot. An Attribute can store pretty much any Python data structure and data type, like numbers, strings, lists, dicts etc. You can also store (references to) database objects like characters and rooms. + +## Working with Attributes + +Attributes are usually handled in code. All [Typeclassed](./Typeclasses.md) entities ([Accounts](./Accounts.md), [Objects](./Objects.md), [Scripts](./Scripts.md) and [Channels](./Channels.md)) can (and usually do) have Attributes associated with them. There are three ways to manage Attributes, all of which can be mixed. + +### Using .db + +The simplest way to get/set Attributes is to use the `.db` shortcut. This allows for setting and getting Attributes that lack a _category_ (having category `None`) + +```python +import evennia + +obj = evennia.create_object(key="Foo") + +obj.db.foo1 = 1234 +obj.db.foo2 = [1, 2, 3, 4] +obj.db.weapon = "sword" +obj.db.self_reference = obj # stores a reference to the obj + +# (let's assume a rose exists in-game) +rose = evennia.search_object(key="rose")[0] # returns a list, grab 0th element +rose.db.has_thorns = True + +# retrieving +val1 = obj.db.foo1 +val2 = obj.db.foo2 +weap = obj.db.weapon +myself = obj.db.self_reference # retrieve reference from db, get object back + +is_ouch = rose.db.has_thorns + +# this will return None, not AttributeError! +not_found = obj.db.jiwjpowiwwerw + +# returns all Attributes on the object +obj.db.all + +# delete an Attribute +del obj.db.foo2 +``` +Trying to access a non-existing Attribute will never lead to an `AttributeError`. Instead you will get `None` back. The special `.db.all` will return a list of all Attributes on the object. You can replace this with your own Attribute `all` if you want, it will replace the default `all` functionality until you delete it again. + +### Using .attributes + +If you want to group your Attribute in a category, or don't know the name of the Attribute beforehand, you can make use of the [AttributeHandler](evennia.typeclasses.attributes.AttributeHandler), available as `.attributes` on all typeclassed entities. With no extra keywords, this is identical to using the `.db` shortcut (`.db` is actually using the `AttributeHandler` internally): + +```python +is_ouch = rose.attributes.get("has_thorns") + +obj.attributes.add("helmet", "Knight's helmet") +helmet = obj.attributes.get("helmet") + +# you can give space-separated Attribute-names (can't do that with .db) +obj.attributes.add("my game log", "long text about ...") +``` + +By using a category you can separate same-named Attributes on the same object to help organization. + +```python +# store (let's say we have gold_necklace and ringmail_armor from before) +obj.attributes.add("neck", gold_necklace, category="clothing") +obj.attributes.add("neck", ringmail_armor, category="armor") + +# retrieve later - we'll get back gold_necklace and ringmail_armor +neck_clothing = obj.attributes.get("neck", category="clothing") +neck_armor = obj.attributes.get("neck", category="armor") +``` + +If you don't specify a category, the Attribute's `category` will be `None` and can thus also be found via `.db`. `None` is considered a category of its own, so you won't find `None`-category Attributes mixed with Attributes having categories. + +Here are the methods of the `AttributeHandler`. See the [AttributeHandler API](evennia.typeclasses.attributes.AttributeHandler) for more details. + +- `has(...)` - this checks if the object has an Attribute with this key. This is equivalent to doing `obj.db.attrname` except you can also check for a specific `category. +- `get(...)` - this retrieves the given Attribute. You can also provide a `default` value to return if the Attribute is not defined (instead of None). By supplying an `accessing_object` to the call one can also make sure to check permissions before modifying anything. The `raise_exception` kwarg allows you to raise an `AttributeError` instead of returning `None` when you access a non-existing `Attribute`. The `strattr` kwarg tells the system to store the Attribute as a raw string rather than to pickle it. While an optimization this should usually not be used unless the Attribute is used for some particular, limited purpose. +- `add(...)` - this adds a new Attribute to the object. An optional [lockstring](./Locks.md) can be supplied here to restrict future access and also the call itself may be checked against locks. +- `remove(...)` - Remove the given Attribute. This can optionally be made to check for permission before performing the deletion. - `clear(...)` - removes all Attributes from object. +- `all(category=None)` - returns all Attributes (of the given category) attached to this object. + +Examples: + +```python +try: + # raise error if Attribute foo does not exist + val = obj.attributes.get("foo", raise_exception=True): +except AttributeError: + # ... + +# return default value if foo2 doesn't exist +val2 = obj.attributes.get("foo2", default=[1, 2, 3, "bar"]) + +# delete foo if it exists (will silently fail if unset, unless +# raise_exception is set) +obj.attributes.remove("foo") + +# view all clothes on obj +all_clothes = obj.attributes.all(category="clothes") +``` + +### Using AttributeProperty + +The third way to set up an Attribute is to use an `AttributeProperty`. This is done on the _class level_ of your typeclass and allows you to treat Attributes a bit like Django database Fields. Unlike using `.db` and `.attributes`, an `AttributeProperty` can't be created on the fly, you must assign it in the class code. + +```python +# mygame/typeclasses/characters.py + +from evennia import DefaultCharacter +from evennia.typeclasses.attributes import AttributeProperty + +class Character(DefaultCharacter): + + strength = AttributeProperty(10, category='stat') + constitution = AttributeProperty(11, category='stat') + agility = AttributeProperty(12, category='stat') + magic = AttributeProperty(13, category='stat') + + sleepy = AttributeProperty(False, autocreate=False) + poisoned = AttributeProperty(False, autocreate=False) + + def at_object_creation(self): + # ... +``` + +When a new instance of the class is created, new `Attributes` will be created with the value and category given. + +With `AttributeProperty`'s set up like this, one can access the underlying `Attribute` like a regular property on the created object: + +```python +char = create_object(Character) + +char.strength # returns 10 +char.agility = 15 # assign a new value (category remains 'stat') + +char.db.magic # returns None (wrong category) +char.attributes.get("agility", category="stat") # returns 15 + +char.db.sleepy # returns None because autocreate=False (see below) + +``` + +```{warning} +Be careful to not assign AttributeProperty's to names of properties and methods already existing on the class, like 'key' or 'at_object_creation'. That could lead to very confusing errors. +``` + +The `autocreate=False` (default is `True`) used for `sleepy` and `poisoned` is worth a closer explanation. When `False`, _no_ Attribute will be auto-created for these AttributProperties unless they are _explicitly_ set. + +The advantage of not creating an Attribute is that the default value given to `AttributeProperty` is returned with no database access unless you change it. This also means that if you want to change the default later, all entities previously create will inherit the new default. + +The drawback is that without a database precense you can't find the Attribute via `.db` and `.attributes.get` (or by querying for it in other ways in the database): + +```python +char.sleepy # returns False, no db access + +char.db.sleepy # returns None - no Attribute exists +char.attributes.get("sleepy") # returns None too + +char.sleepy = True # now an Attribute is created +char.db.sleepy # now returns True! +char.attributes.get("sleepy") # now returns True + +char.sleepy # now returns True, involves db access +``` + +You can e.g. `del char.strength` to set the value back to the default (the value defined in the `AttributeProperty`). + +See the [AttributeProperty API](evennia.typeclasses.attributes.AttributeProperty) for more details on how to create it with special options, like giving access-restrictions. + +```{warning} +While the `AttributeProperty` uses the `AttributeHandler` (`.attributes`) under the hood, the reverse is _not_ true. The `AttributeProperty` has helper methods, like `at_get` and `at_set`. These will _only_ be called if you access the Attribute using the property. + +That is, if you do `obj.yourattribute = 1`, the `AttributeProperty.at_set` will be called. But while doing `obj.db.yourattribute = 1`, will lead to the same Attribute being saved, this is 'bypassing' the `AttributeProperty` and using the `AttributeHandler` directly. So in this case the `AttributeProperty.at_set` will _not_ be called. If you added some special functionality in `at_get` this may be confusing. + +To avoid confusion, you should aim to be consistent in how you access your Attributes - if you use a `AttributeProperty` to define it, use that also to access and modify the Attribute later. +``` + + +### Properties of Attributes + +An `Attribute` object is stored in the database. It has the following properties: + +- `key` - the name of the Attribute. When doing e.g. `obj.db.attrname = value`, this property is set to `attrname`. +- `value` - this is the value of the Attribute. This value can be anything which can be pickled - objects, lists, numbers or what have you (see [this section](./Attributes.md#what-types-of-data-can-i-save-in-an-attribute) for more info). In the example + `obj.db.attrname = value`, the `value` is stored here. +- `category` - this is an optional property that is set to None for most Attributes. Setting this allows to use Attributes for different functionality. This is usually not needed unless you want to use Attributes for very different functionality ([Nicks](./Nicks.md) is an example of using + Attributes in this way). To modify this property you need to use the [Attribute Handler](#attributes) +- `strvalue` - this is a separate value field that only accepts strings. This severely limits the data possible to store, but allows for easier database lookups. This property is usually not used except when re-using Attributes for some other purpose ([Nicks](./Nicks.md) use it). It is only accessible via the [Attribute Handler](#attributes). + +There are also two special properties: + +- `attrtype` - this is used internally by Evennia to separate [Nicks](./Nicks.md), from Attributes (Nicks use Attributes behind the scenes). +- `model` - this is a *natural-key* describing the model this Attribute is attached to. This is on the form *appname.modelclass*, like `objects.objectdb`. It is used by the Attribute and NickHandler to quickly sort matches in the database. Neither this nor `attrtype` should normally need to be modified. + +Non-database attributes are not stored in the database and have no equivalence to `category` nor `strvalue`, `attrtype` or `model`. + + +### Managing Attributes in-game + +Attributes are mainly used by code. But one can also allow the builder to use Attributes to 'turn knobs' in-game. For example a builder could want to manually tweak the "level" Attribute of an enemy NPC to lower its difficuly. + +When setting Attributes this way, you are severely limited in what can be stored - this is because giving players (even builders) the ability to store arbitrary Python would be a severe security problem. + +In game you can set an Attribute like this: + + set myobj/foo = "bar" + +To view, do + + set myobj/foo + +or see them together with all object-info with + + examine myobj + +The first `set`-example will store a new Attribute `foo` on the object `myobj` and give it the value "bar". You can store numbers, booleans, strings, tuples, lists and dicts this way. But if you store a list/tuple/dict they must be proper Python structures and may _only_ contain strings +or numbers. If you try to insert an unsupported structure, the input will be converted to a +string. + + set myobj/mybool = True + set myobj/mybool = True + set myobj/mytuple = (1, 2, 3, "foo") + set myobj/mylist = ["foo", "bar", 2] + set myobj/mydict = {"a": 1, "b": 2, 3: 4} + set mypobj/mystring = [1, 2, foo] # foo is invalid Python (no quotes) + +For the last line you'll get a warning and the value instead will be saved as a string `"[1, 2, foo]"`. + +### Locking and checking Attributes + +While the `set` command is limited to builders, individual Attributes are usually not locked down. You may want to lock certain sensitive Attributes, in particular for games where you allow player building. You can add such limitations by adding a [lock string](./Locks.md) to your Attribute. A NAttribute have no locks. + +The relevant lock types are + +- `attrread` - limits who may read the value of the Attribute +- `attredit` - limits who may set/change this Attribute + +You must use the `AttributeHandler` to assign the lockstring to the Attribute: + +```python +lockstring = "attread:all();attredit:perm(Admins)" +obj.attributes.add("myattr", "bar", lockstring=lockstring)" +``` + +If you already have an Attribute and want to add a lock in-place you can do so by having the `AttributeHandler` return the `Attribute` object itself (rather than its value) and then assign the lock to it directly: + +```python + lockstring = "attread:all();attredit:perm(Admins)" + obj.attributes.get("myattr", return_obj=True).locks.add(lockstring) +``` + +Note the `return_obj` keyword which makes sure to return the `Attribute` object so its LockHandler could be accessed. + +A lock is no good if nothing checks it -- and by default Evennia does not check locks on Attributes. To check the `lockstring` you provided, make sure you include `accessing_obj` and set `default_access=False` as you make a `get` call. + +```python + # in some command code where we want to limit + # setting of a given attribute name on an object + attr = obj.attributes.get(attrname, + return_obj=True, + accessing_obj=caller, + default=None, + default_access=False) + if not attr: + caller.msg("You cannot edit that Attribute!") + return + # edit the Attribute here +``` + +The same keywords are available to use with `obj.attributes.set()` and `obj.attributes.remove()`, those will check for the `attredit` lock type. + + +## What types of data can I save in an Attribute? + +The database doesn't know anything about Python objects, so Evennia must *serialize* Attribute values into a string representation before storing it to the database. This is done using the [pickle](https://docs.python.org/library/pickle.html) module of Python. + +> The only exception is if you use the `strattr` keyword of the `AttributeHandler` to save to the `strvalue` field of the Attribute. In that case you can _only_ save *strings* and those will not be pickled). + +### Storing single objects + +With a single object, we mean anything that is *not iterable*, like numbers, strings or custom class instances without the `__iter__` method. + +* You can generally store any non-iterable Python entity that can be _pickled_. +* Single database objects/typeclasses can be stored, despite them normally not being possible to pickle. Evennia will convert them to an internal representation using theihr classname, database-id and creation-date with a microsecond precision. When retrieving, the object instance will be re-fetched from the database using this information. +* If you 'hide' a db-obj as a property on a custom class, Evennia will not be able to find it to serialize it. For that you need to help it out (see below). + +```{code-block} python +:caption: Valid assignments + +# Examples of valid single-value attribute data: +obj.db.test1 = 23 +obj.db.test1 = False +# a database object (will be stored as an internal representation) +obj.db.test2 = myobj +``` + +As mentioned, Evennia will not be able to automatically serialize db-objects 'hidden' in arbitrary properties on an object. This will lead to an error when saving the Attribute. + +```{code-block} python +:caption: Invalid, 'hidden' dbobject +# example of storing an invalid, "hidden" dbobject in Attribute +class Container: + def __init__(self, mydbobj): + # no way for Evennia to know this is a database object! + self.mydbobj = mydbobj + +# let's assume myobj is a db-object +container = Container(myobj) +obj.db.mydata = container # will raise error! + +``` + +By adding two methods `__serialize_dbobjs__` and `__deserialize_dbobjs__` to the object you want to save, you can pre-serialize and post-deserialize all 'hidden' objects before Evennia's main serializer gets to work. Inside these methods, use Evennia's [evennia.utils.dbserialize.dbserialize](evennia.utils.dbserialize.dbserialize) and [dbunserialize](evennia.utils.dbserialize.dbunserialize) functions to safely serialize the db-objects you want to store. + +```{code-block} python +:caption: Fixing an invalid 'hidden' dbobj for storing in Attribute + +from evennia.utils import dbserialize # important + +class Container: + def __init__(self, mydbobj): + # A 'hidden' db-object + self.mydbobj = mydbobj + + def __serialize_dbobjs__(self): + """This is called before serialization and allows + us to custom-handle those 'hidden' dbobjs""" + self.mydbobj = dbserialize.dbserialize(self.mydbobj + + def __deserialize_dbobjs__(self): + """This is called after deserialization and allows you to + restore the 'hidden' dbobjs you serialized before""" + if isinstance(self.mydbobj, bytes): + # make sure to check if it's bytes before trying dbunserialize + self.mydbobj = dbserialize.dbunserialize(self.mydbobj) + +# let's assume myobj is a db-object +container = Container(myobj) +obj.db.mydata = container # will now work fine! +``` + +> Note the extra check in `__deserialize_dbobjs__` to make sure the thing you are deserializing is a `bytes` object. This is needed because the Attribute's cache reruns deserializations in some situations when the data was already once deserialized. If you see errors in the log saying `Could not unpickle data for storage: ...`, the reason is likely that you forgot to add this check. + + +### Storing multiple objects + +This means storing objects in a collection of some kind and are examples of *iterables*, pickle-able entities you can loop over in a for-loop. Attribute-saving supports the following iterables: + +* [Tuples](https://docs.python.org/3/library/functions.html#tuple), like `(1,2,"test", )`. +* [Lists](https://docs.python.org/3/tutorial/datastructures.html#more-on-lists), like `[1,2,"test", ]`. +* [Dicts](https://docs.python.org/3/tutorial/datastructures.html#dictionaries), like `{1:2, "test":]`. +* [Sets](https://docs.python.org/2/tutorial/datastructures.html#sets), like `{1,2,"test",}`. +* [collections.OrderedDict](https://docs.python.org/3/library/collections.html#collections.OrderedDict), +like `OrderedDict((1,2), ("test", ))`. +* [collections.Deque](https://docs.python.org/3/library/collections.html#collections.deque), like `deque((1,2,"test",))`. +* [collections.DefaultDict](https://docs.python.org/3/library/collections.html#collections.defaultdict) like `defaultdict(list)`. +* *Nestings* of any combinations of the above, like lists in dicts or an OrderedDict of tuples, each containing dicts, etc. +* All other iterables (i.e. entities with the `__iter__` method) will be converted to a *list*. Since you can use any combination of the above iterables, this is generally not much of a limitation. + +Any entity listed in the [Single object](./Attributes.md#storing-single-objects) section above can be stored in the iterable. + +> As mentioned in the previous section, database entities (aka typeclasses) are not possible to pickle. So when storing an iterable, Evennia must recursively traverse the iterable *and all its nested sub-iterables* in order to find eventual database objects to convert. This is a very fast process but for efficiency you may want to avoid too deeply nested structures if you can. + +```python +# examples of valid iterables to store +obj.db.test3 = [obj1, 45, obj2, 67] +# a dictionary +obj.db.test4 = {'str':34, 'dex':56, 'agi':22, 'int':77} +# a mixed dictionary/list +obj.db.test5 = {'members': [obj1,obj2,obj3], 'enemies':[obj4,obj5]} +# a tuple with a list in it +obj.db.test6 = (1, 3, 4, 8, ["test", "test2"], 9) +# a set +obj.db.test7 = set([1, 2, 3, 4, 5]) +# in-situ manipulation +obj.db.test8 = [1, 2, {"test":1}] +obj.db.test8[0] = 4 +obj.db.test8[2]["test"] = 5 +# test8 is now [4,2,{"test":5}] +``` + +Note that if make some advanced iterable object, and store an db-object on it in a way such that it is _not_ returned by iterating over it, you have created a 'hidden' db-object. See [the previous section](#storing-single-objects) for how to tell Evennia how to serialize such hidden objects safely. + + +### Retrieving Mutable objects + +A side effect of the way Evennia stores Attributes is that *mutable* iterables (iterables that can be modified in-place after they were created, which is everything except tuples) are handled by custom objects called `_SaverList`, `_SaverDict` etc. These `_Saver...` classes behave just like the normal variant except that they are aware of the database and saves to it whenever new data gets assigned to them. This is what allows you to do things like `self.db.mylist[7] = val` and be sure that the new version of list is saved. Without this you would have to load the list into a temporary variable, change it and then re-assign it to the Attribute in order for it to save. + +There is however an important thing to remember. If you retrieve your mutable iterable into another variable, e.g. `mylist2 = obj.db.mylist`, your new variable (`mylist2`) will *still* be a `_SaverList`. This means it will continue to save itself to the database whenever it is updated! + +```python +obj.db.mylist = [1, 2, 3, 4] +mylist = obj.db.mylist + +mylist[3] = 5 # this will also update database + +print(mylist) # this is now [1, 2, 3, 5] +print(obj.db.mylist) # now also [1, 2, 3, 5] +``` + +When you extract your mutable Attribute data into a variable like `mylist`, think of it as getting a _snapshot_ of the variable. If you update the snapshot, it will save to the database, but this change _will not propagate to any other snapshots you may have done previously_. + +```python +obj.db.mylist = [1, 2, 3, 4] +mylist1 = obj.db.mylist +mylist2 = obj.db.mylist +mylist1[3] = 5 + +print(mylist1) # this is now [1, 2, 3, 5] +print(obj.db.mylist) # also updated to [1, 2, 3, 5] + +print(mylist2) # still [1, 2, 3, 4] ! + +``` + +```{sidebar} +Remember, the complexities of this section only relate to *mutable* iterables - things you can update +in-place, like lists and dicts. [Immutable](https://en.wikipedia.org/wiki/Immutable) objects (strings, +numbers, tuples etc) are already disconnected from the database from the onset. +``` + +To avoid confusion with mutable Attributes, only work with one variable (snapshot) at a time and save back the results as needed. + +You can also choose to "disconnect" the Attribute entirely from the +database with the help of the `.deserialize()` method: + +```python +obj.db.mylist = [1, 2, 3, 4, {1: 2}] +mylist = obj.db.mylist.deserialize() +``` + +The result of this operation will be a structure only consisting of normal Python mutables (`list` instead of `_SaverList`, `dict` instead of `_SaverDict` and so on). If you update it, you need to explicitly save it back to the Attribute for it to save. + + +## In-memory Attributes (NAttributes) + +_NAttributes_ (short of Non-database Attributes) mimic Attributes in most things except they +are **non-persistent** - they will _not_ survive a server reload. + +- Instead of `.db` use `.ndb`. +- Instead of `.attributes` use `.nattributes` +- Instead of `AttributeProperty`, use `NAttributeProperty`. + +```python + rose.ndb.has_thorns = True + is_ouch = rose.ndb.has_thorns + + rose.nattributes.add("has_thorns", True) + is_ouch = rose.nattributes.get("has_thorns") +``` + +Differences between `Attributes` and `NAttributes`: + +- `NAttribute`s are always wiped on a server reload. +- They only exist in memory and never involve the database at all, making them faster to + access and edit than `Attribute`s. +- `NAttribute`s can store _any_ Python structure (and database object) without limit. However, if you were to _delete_ a database object you previously stored in an `NAttribute`, the `NAttribute` will not know about this and may give you a python object without a matching database entry. In comparison, an `Attribute` always checks this). If this is a concern, use an `Attribute` or check that the object's `.pk` property is not `None` before saving it. +- They can _not_ be set with the standard `set` command (but they are visible with `examine`) + +There are some important reasons we recommend using `ndb` to store temporary data rather than the simple alternative of just storing a variable directly on an object: +[]() +- NAttributes are tracked by Evennia and will not be purged in various cache-cleanup operations the server may do. So using them guarantees that they'll remain available at least as long as the server lives. +- It's a consistent style - `.db/.attributes` and `.ndb/.nattributes` makes for clean-looking code where it's clear how long-lived (or not) your data is to be. + +### Persistent vs non-persistent + +So *persistent* data means that your data will survive a server reboot, whereas with +*non-persistent* data it will not ... + +... So why would you ever want to use non-persistent data? The answer is, you don't have to. Most of +the time you really want to save as much as you possibly can. Non-persistent data is potentially +useful in a few situations though. + +- You are worried about database performance. Since Evennia caches Attributes very aggressively, this is not an issue unless you are reading *and* writing to your Attribute very often (like many times per second). Reading from an already cached Attribute is as fast as reading any Python property. But even then this is not likely something to worry about: Apart from Evennia's own caching, modern database systems themselves also cache data very efficiently for speed. Our default database even runs completely in RAM if possible, alleviating much of the need to write to disk during heavy loads. +- A more valid reason for using non-persistent data is if you *want* to lose your state when logging off. Maybe you are storing throw-away data that are re-initialized at server startup. Maybe you are implementing some caching of your own. Or maybe you are testing a buggy [Script](./Scripts.md) that does potentially harmful stuff to your character object. With non-persistent storage you can be sure that whatever is messed up, it's nothing a server reboot can't clear up. +- `NAttribute`s have no restrictions at all on what they can store, since they don't need to worry about being saved to the database - they work very well for temporary storage. +- You want to implement a fully or partly *non-persistent world*. Who are we to argue with your grand vision! diff --git a/docs/latest/_sources/Components/Batch-Code-Processor.md.txt b/docs/latest/_sources/Components/Batch-Code-Processor.md.txt new file mode 100644 index 0000000000..a008e9c15a --- /dev/null +++ b/docs/latest/_sources/Components/Batch-Code-Processor.md.txt @@ -0,0 +1,163 @@ +# Batch Code Processor + + +For an introduction and motivation to using batch processors, see [here](./Batch-Processors.md). This page describes the Batch-*code* processor. The Batch-*command* one is covered [here](Batch-Command- Processor). + +The batch-code processor is a superuser-only function, invoked by + + > batchcode path.to.batchcodefile + +Where `path.to.batchcodefile` is the path to a *batch-code file*. Such a file should have a name ending in "`.py`" (but you shouldn't include that in the path). The path is given like a python path relative to a folder you define to hold your batch files, set by `BATCH_IMPORT_PATH` in your settings. Default folder is (assuming your game is called "mygame") `mygame/world/`. So if you want to run the example batch file in `mygame/world/batch_code.py`, you could simply use + + > batchcode batch_code + +This will try to run through the entire batch file in one go. For more gradual, *interactive* control you can use the `/interactive` switch. The switch `/debug` will put the processor in *debug* mode. Read below for more info. + +## The batch file + +A batch-code file is a normal Python file. The difference is that since the batch processor loads and executes the file rather than importing it, you can reliably update the file, then call it again, over and over and see your changes without needing to `reload` the server. This makes for easy testing. In the batch-code file you have also access to the following global variables: + +- `caller` - This is a reference to the object running the batchprocessor. +- `DEBUG` - This is a boolean that lets you determine if this file is currently being run in debug-mode or not. See below how this can be useful. + +Running a plain Python file through the processor will just execute the file from beginning to end. If you want to get more control over the execution you can use the processor's *interactive* mode. This runs certain code blocks on their own, rerunning only that part until you are happy with it. In order to do this you need to add special markers to your file to divide it up into smaller chunks. These take the form of comments, so the file remains valid Python. + +- `#CODE` as the first on a line marks the start of a *code* block. It will last until the beginning of another marker or the end of the file. Code blocks contain functional python code. Each `#CODE` block will be run in complete isolation from other parts of the file, so make sure it's self- contained. +- `#HEADER` as the first on a line marks the start of a *header* block. It lasts until the next marker or the end of the file. This is intended to hold imports and variables you will need for all other blocks .All python code defined in a header block will always be inserted at the top of every `#CODE` blocks in the file. You may have more than one `#HEADER` block, but that is equivalent to having one big one. Note that you can't exchange data between code blocks, so editing a header- variable in one code block won't affect that variable in any other code block! +- `#INSERT path.to.file` will insert another batchcode (Python) file at that position. - A `#` that is not starting a `#HEADER`, `#CODE` or `#INSERT` instruction is considered a comment. +- Inside a block, normal Python syntax rules apply. For the sake of indentation, each block acts as a separate python module. + +Below is a version of the example file found in `evennia/contrib/tutorial_examples/`. + +```python + # + # This is an example batch-code build file for Evennia. + # + + #HEADER + + # This will be included in all other #CODE blocks + + from evennia import create_object, search_object + from evennia.contrib.tutorial_examples import red_button + from typeclasses.objects import Object + + limbo = search_object('Limbo')[0] + + + #CODE + + red_button = create_object(red_button.RedButton, key="Red button", + location=limbo, aliases=["button"]) + + # caller points to the one running the script + caller.msg("A red button was created.") + + # importing more code from another batch-code file + #INSERT batch_code_insert + + #CODE + + table = create_object(Object, key="Blue Table", location=limbo) + chair = create_object(Object, key="Blue Chair", location=limbo) + + string = f"A {table} and {chair} were created." + if DEBUG: + table.delete() + chair.delete() + string += " Since debug was active, they were deleted again." + caller.msg(string) +``` + +This uses Evennia's Python API to create three objects in sequence. + +## Debug mode + +Try to run the example script with + + > batchcode/debug tutorial_examples.example_batch_code + +The batch script will run to the end and tell you it completed. You will also get messages that the button and the two pieces of furniture were created. Look around and you should see the button there. But you won't see any chair nor a table! This is because we ran this with the `/debug` switch, which is directly visible as `DEBUG==True` inside the script. In the above example we handled this state by deleting the chair and table again. + +The debug mode is intended to be used when you test out a batchscript. Maybe you are looking for bugs in your code or try to see if things behave as they should. Running the script over and over would then create an ever-growing stack of chairs and tables, all with the same name. You would have to go back and painstakingly delete them later. + +## Interactive mode + +Interactive mode works very similar to the [batch-command processor counterpart](Batch-Command- Processor). It allows you more step-wise control over how the batch file is executed. This is useful for debugging or for picking and choosing only particular blocks to run. Use `batchcode` with the `/interactive` flag to enter interactive mode. + + > batchcode/interactive tutorial_examples.batch_code + +You should see the following: + + 01/02: red_button = create_object(red_button.RedButton, [...] (hh for help) + +This shows that you are on the first `#CODE` block, the first of only two commands in this batch file. Observe that the block has *not* actually been executed at this point! + +To take a look at the full code snippet you are about to run, use `ll` (a batch-processor version of `look`). + +```python + from evennia.utils import create, search + from evennia.contrib.tutorial_examples import red_button + from typeclasses.objects import Object + + limbo = search.objects(caller, 'Limbo', global_search=True)[0] + + red_button = create.create_object(red_button.RedButton, key="Red button", + location=limbo, aliases=["button"]) + + # caller points to the one running the script + caller.msg("A red button was created.") +``` + +Compare with the example code given earlier. Notice how the content of `#HEADER` has been pasted at the top of the `#CODE` block. Use `pp` to actually execute this block (this will create the button +and give you a message). Use `nn` (next) to go to the next command. Use `hh` for a list of commands. + +If there are tracebacks, fix them in the batch file, then use `rr` to reload the file. You will still be at the same code block and can rerun it easily with `pp` as needed. This makes for a simple debug cycle. It also allows you to rerun individual troublesome blocks - as mentioned, in a large batch file this can be very useful (don't forget the `/debug` mode either). + +Use `nn` and `bb` (next and back) to step through the file; e.g. `nn 12` will jump 12 steps forward (without processing any blocks in between). All normal commands of Evennia should work too while working in interactive mode. + +## Limitations and Caveats + +The batch-code processor is by far the most flexible way to build a world in Evennia. There are however some caveats you need to keep in mind. + +### Safety +Or rather the lack of it. There is a reason only *superusers* are allowed to run the batch-code +processor by default. The code-processor runs **without any Evennia security checks** and allows +full access to Python. If an untrusted party could run the code-processor they could execute +arbitrary python code on your machine, which is potentially a very dangerous thing. If you want to +allow other users to access the batch-code processor you should make sure to run Evennia as a +separate and very limited-access user on your machine (i.e. in a 'jail'). By comparison, the batch- +command processor is much safer since the user running it is still 'inside' the game and can't +really do anything outside what the game commands allow them to. + +### No communication between code blocks +Global variables won't work in code batch files, each block is executed as stand-alone environments. `#HEADER` blocks are literally pasted on top of each `#CODE` block so updating some header-variable in your block will not make that change available in another block. Whereas a python execution limitation, allowing this would also lead to very hard-to-debug code when using the interactive mode - this would be a classical example of "spaghetti code". + +The main practical issue with this is when building e.g. a room in one code block and later want to connect that room with a room you built in the current block. There are two ways to do this: + +- Perform a database search for the name of the room you created (since you cannot know in advance which dbref it got assigned). The problem is that a name may not be unique (you may have a lot of "A dark forest" rooms). There is an easy way to handle this though - use [Tags](./Tags.md) or *Aliases*. You can assign any number of tags and/or aliases to any object. Make sure that one of those tags or aliases is unique to the room (like "room56") and you will henceforth be able to always uniquely search and find it later. +- Use the `caller` global property as an inter-block storage. For example, you could have a dictionary of room references in an `ndb`: + ```python + #HEADER + if caller.ndb.all_rooms is None: + caller.ndb.all_rooms = {} + + #CODE + # create and store the castle + castle = create_object("rooms.Room", key="Castle") + caller.ndb.all_rooms["castle"] = castle + + #CODE + # in another node we want to access the castle + castle = caller.ndb.all_rooms.get("castle") + ``` +Note how we check in `#HEADER` if `caller.ndb.all_rooms` doesn't already exist before creating the dict. Remember that `#HEADER` is copied in front of every `#CODE` block. Without that `if` statement +we'd be wiping the dict every block! + +### Don't treat a batchcode file like any Python file + +Despite being a valid Python file, a batchcode file should *only* be run by the batchcode processor. You should not do things like define Typeclasses or Commands in them, or import them into other code. Importing a module in Python will execute base level of the module, which in the case of your average batchcode file could mean creating a lot of new objects every time. + +### Don't let code rely on the batch-file's real file path + +When you import things into your batchcode file, don't use relative imports but always import with paths starting from the root of your game directory or evennia library. Code that relies on the batch file's "actual" location *will fail*. Batch code files are read as text and the strings executed. When the code runs it has no knowledge of what file those strings where once a part of. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Batch-Command-Processor.md.txt b/docs/latest/_sources/Components/Batch-Command-Processor.md.txt new file mode 100644 index 0000000000..dda41e610c --- /dev/null +++ b/docs/latest/_sources/Components/Batch-Command-Processor.md.txt @@ -0,0 +1,130 @@ +# Batch Command Processor + + +For an introduction and motivation to using batch processors, see [here](./Batch-Processors.md). This +page describes the Batch-*command* processor. The Batch-*code* one is covered [here](Batch-Code- +Processor). + +The batch-command processor is a superuser-only function, invoked by + + > batchcommand path.to.batchcmdfile + +Where `path.to.batchcmdfile` is the path to a *batch-command file* with the "`.ev`" file ending. +This path is given like a python path relative to a folder you define to hold your batch files, set +with `BATCH_IMPORT_PATH` in your settings. Default folder is (assuming your game is in the `mygame` +folder) `mygame/world`. So if you want to run the example batch file in +`mygame/world/batch_cmds.ev`, you could use + + > batchcommand batch_cmds + +A batch-command file contains a list of Evennia in-game commands separated by comments. The +processor will run the batch file from beginning to end. Note that *it will not stop if commands in +it fail* (there is no universal way for the processor to know what a failure looks like for all +different commands). So keep a close watch on the output, or use *Interactive mode* (see below) to +run the file in a more controlled, gradual manner. + +## The batch file + +The batch file is a simple plain-text file containing Evennia commands. Just like you would write +them in-game, except you have more freedom with line breaks. + +Here are the rules of syntax of an `*.ev` file. You'll find it's really, really simple: + +- All lines having the `#` (hash)-symbol *as the first one on the line* are considered *comments*. All non-comment lines are treated as a command and/or their arguments. +- Comment lines have an actual function -- they mark the *end of the previous command definition*. So never put two commands directly after one another in the file - separate them with a comment, or the second of the two will be considered an argument to the first one. Besides, using plenty of comments is good practice anyway. +- A line that starts with the word `#INSERT` is a comment line but also signifies a special instruction. The syntax is `#INSERT ` and tries to import a given batch-cmd file into this one. The inserted batch file (file ending `.ev`) will run normally from the point of the `#INSERT` instruction. +- Extra whitespace in a command definition is *ignored*. - A completely empty line translates in to a line break in texts. Two empty lines thus means a new paragraph (this is obviously only relevant for commands accepting such formatting, such as the `@desc` command). +- The very last command in the file is not required to end with a comment. +- You *cannot* nest another `batchcommand` statement into your batch file. If you want to link many batch-files together, use the `#INSERT` batch instruction instead. You also cannot launch the `batchcode` command from your batch file, the two batch processors are not compatible. + +Below is a version of the example file found in `evennia/contrib/tutorial_examples/batch_cmds.ev`. + +```bash + # + # This is an example batch build file for Evennia. + # + + # This creates a red button + @create button:tutorial_examples.red_button.RedButton + # (This comment ends input for @create) + # Next command. Let's create something. + @set button/desc = + This is a large red button. Now and then + it flashes in an evil, yet strangely tantalizing way. + + A big sign sits next to it. It says: + + + ----------- + + Press me! + + ----------- + + + ... It really begs to be pressed! You + know you want to! + + # This inserts the commands from another batch-cmd file named + # batch_insert_file.ev. + #INSERT examples.batch_insert_file + + + # (This ends the @set command). Note that single line breaks + # and extra whitespace in the argument are ignored. Empty lines + # translate into line breaks in the output. + # Now let's place the button where it belongs (let's say limbo #2 is + # the evil lair in our example) + @teleport #2 + # (This comments ends the @teleport command.) + # Now we drop it so others can see it. + # The very last command in the file needs not be ended with #. + drop button +``` + +To test this, run `@batchcommand` on the file: + + > batchcommand contrib.tutorial_examples.batch_cmds + +A button will be created, described and dropped in Limbo. All commands will be executed by the user calling the command. + +> Note that if you interact with the button, you might find that its description changes, loosing your custom-set description above. This is just the way this particular object works. + +## Interactive mode + +Interactive mode allows you to more step-wise control over how the batch file is executed. This is useful for debugging and also if you have a large batch file and is only updating a small part of it -- running the entire file again would be a waste of time (and in the case of `create`-ing objects you would to end up with multiple copies of same-named objects, for example). Use `batchcommand` with the `/interactive` flag to enter interactive mode. + + > @batchcommand/interactive tutorial_examples.batch_cmds + +You will see this: + + 01/04: @create button:tutorial_examples.red_button.RedButton (hh for help) + +This shows that you are on the `@create` command, the first out of only four commands in this batch file. Observe that the command `@create` has *not* been actually processed at this point! + +To take a look at the full command you are about to run, use `ll` (a batch-processor version of +`look`). Use `pp` to actually process the current command (this will actually `@create` the button) -- and make sure it worked as planned. Use `nn` (next) to go to the next command. Use `hh` for a list of commands. + +If there are errors, fix them in the batch file, then use `rr` to reload the file. You will still be at the same command and can rerun it easily with `pp` as needed. This makes for a simple debug cycle. It also allows you to rerun individual troublesome commands - as mentioned, in a large batch file this can be very useful. Do note that in many cases, commands depend on the previous ones (e.g. if `create` in the example above had failed, the following commands would have had nothing to operate on). + +Use `nn` and `bb` (next and back) to step through the file; e.g. `nn 12` will jump 12 steps forward (without processing any command in between). All normal commands of Evennia should work too while working in interactive mode. + +## Limitations and Caveats + +The batch-command processor is great for automating smaller builds or for testing new commands and objects repeatedly without having to write so much. There are several caveats you have to be aware of when using the batch-command processor for building larger, complex worlds though. + +The main issue is that when you run a batch-command script you (*you*, as in your superuser +character) are actually moving around in the game creating and building rooms in sequence, just as if you had been entering those commands manually, one by one. You have to take this into account when creating the file, so that you can 'walk' (or teleport) to the right places in order. + +This also means there are several pitfalls when designing and adding certain types of objects. Here are some examples: + +- *Rooms that change your [Command Set](./Command-Sets.md)*: Imagine that you build a 'dark' room, which severely limits the cmdsets of those entering it (maybe you have to find the light switch to proceed). In your batch script you would create this room, then teleport to it - and promptly be shifted into the dark state where none of your normal build commands work ... +- *Auto-teleportation*: Rooms that automatically teleport those that enter them to another place (like a trap room, for example). You would be teleported away too. +- *Mobiles*: If you add aggressive mobs, they might attack you, drawing you into combat. If they have AI they might even follow you around when building - or they might move away from you before you've had time to finish describing and equipping them! + +The solution to all these is to plan ahead. Make sure that superusers are never affected by whatever effects are in play. Add an on/off switch to objects and make sure it's always set to *off* upon creation. It's all doable, one just needs to keep it in mind. + +## Editor highlighting for .ev files + +- [GNU Emacs](https://www.gnu.org/software/emacs/) users might find it interesting to use emacs' *evennia mode*. This is an Emacs major mode found in `evennia/utils/evennia-mode.el`. It offers correct syntax highlighting and indentation with `` when editing `.ev` files in Emacs. See the header of that file for installation instructions. +- [VIM](https://www.vim.org/) users can use amfl's [vim-evennia](https://github.com/amfl/vim-evennia) mode instead, see its readme for install instructions. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Batch-Processors.md.txt b/docs/latest/_sources/Components/Batch-Processors.md.txt new file mode 100644 index 0000000000..e29e7b10f8 --- /dev/null +++ b/docs/latest/_sources/Components/Batch-Processors.md.txt @@ -0,0 +1,47 @@ +# Batch Processors + +```{toctree} +:maxdepth: 2 + +Batch-Command-Processor.md +Batch-Code-Processor.md +``` + +Building a game world is a lot of work, especially when starting out. Rooms should be created, descriptions have to be written, objects must be detailed and placed in their proper places. In many +traditional MUD setups you had to do all this online, line by line, over a telnet session. + +Evennia already moves away from much of this by shifting the main coding work to external Python modules. But also building would be helped if one could do some or all of it externally. Enter Evennia's *batch processors* (there are two of them). The processors allows you, as a game admin, to build your game completely offline in normal text files (*batch files*) that the processors understands. Then, when you are ready, you use the processors to read it all into Evennia (and into the database) in one go. + +You can of course still build completely online should you want to - this is certainly the easiest way to go when learning and for small build projects. But for major building work, the advantages of using the batch-processors are many: +- It's hard to compete with the comfort of a modern desktop text editor; Compared to a traditional MUD line input, you can get much better overview and many more features. Also, accidentally pressing Return won't immediately commit things to the database. +- You might run external spell checkers on your batch files. In the case of one of the batch- processors (the one that deals with Python code), you could also run external debuggers and code analyzers on your file to catch problems before feeding it to Evennia. +- The batch files (as long as you keep them) are records of your work. They make a natural starting point for quickly re-building your world should you ever decide to start over. +- If you are an Evennia developer, using a batch file is a fast way to setup a test-game after having reset the database. +- The batch files might come in useful should you ever decide to distribute all or part of your world to others. + +There are two batch processors, the Batch-*command* processor and the Batch-*code* processor. The +first one is the simpler of the two. It doesn't require any programming knowledge - you basically +just list in-game commands in a text file. The code-processor on the other hand is much more +powerful but also more complex - it lets you use Evennia's API to code your world in full-fledged +Python code. + + +## A note on File Encodings + +As mentioned, both the processors take text files as input and then proceed to process them. As long as you stick to the standard [ASCII](https://en.wikipedia.org/wiki/Ascii) character set (which means the normal English characters, basically) you should not have to worry much about this section. + +Many languages however use characters outside the simple `ASCII` table. Common examples are various apostrophes and umlauts but also completely different symbols like those of the greek or cyrillic alphabets. + +First, we should make it clear that Evennia itself handles international characters just fine. It (and Django) uses [unicode](https://en.wikipedia.org/wiki/Unicode) strings internally. + +The problem is that when reading a text file like the batchfile, we need to know how to decode the byte-data stored therein to universal unicode. That means we need an *encoding* (a mapping) for how the file stores its data. There are many, many byte-encodings used around the world, with opaque names such as `Latin-1`, `ISO-8859-3` or `ARMSCII-8` to pick just a few examples. Problem is that it's practially impossible to determine which encoding was used to save a file just by looking at it (it's just a bunch of bytes!). You have to *know*. + +With this little introduction it should be clear that Evennia can't guess but has to *assume* an encoding when trying to load a batchfile. The text editor and Evennia must speak the same "language" so to speak. Evennia will by default first try the international `UTF-8` encoding, but you can have Evennia try any sequence of different encodings by customizing the `ENCODINGS` list in your settings file. Evennia will use the first encoding in the list that do not raise any errors. Only if none work will the server give up and return an error message. + +You can often change the text editor encoding (this depends on your editor though), otherwise you need to add the editor's encoding to Evennia's `ENCODINGS` list. If you are unsure, write a test file with lots of non-ASCII letters in the editor of your choice, then import to make sure it works as it should. + +More help with encodings can be found in the entry [Text Encodings](../Concepts/Text-Encodings.md) and also in the Wikipedia article [here](https://en.wikipedia.org/wiki/Text_encodings). + +**A footnote for the batch-code processor**: Just because *Evennia* can parse your file and your +fancy special characters, doesn't mean that *Python* allows their use. Python syntax only allows international characters inside *strings*. In all other source code only `ASCII` set characters are +allowed. diff --git a/docs/latest/_sources/Components/Channels.md.txt b/docs/latest/_sources/Components/Channels.md.txt new file mode 100644 index 0000000000..b125373c34 --- /dev/null +++ b/docs/latest/_sources/Components/Channels.md.txt @@ -0,0 +1,387 @@ +# Channels + +In a multiplayer game, players often need other means of in-game communication +than moving to the same room and use `say` or `emote`. + +_Channels_ allows Evennia's to act as a fancy chat program. When a player is +connected to a channel, sending a message to it will automatically distribute +it to every other subscriber. + +Channels can be used both for chats between [Accounts](./Accounts.md) and between +[Objects](./Objects.md) (usually Characters). Chats could be both OOC +(out-of-character) or IC (in-charcter) in nature. Some examples: + +- A support channel for contacting staff (OOC) +- A general chat for discussing anything and foster community (OOC) +- Admin channel for private staff discussions (OOC) +- Private guild channels for planning and organization (IC/OOC depending on game) +- Cyberpunk-style retro chat rooms (IC) +- In-game radio channels (IC) +- Group telepathy (IC) +- Walkie-talkies (IC) + +```{versionchanged} 1.0 + + Channel system changed to use a central 'channel' command and nicks instead of + auto-generated channel-commands and -cmdset. ChannelHandler was removed. + +``` + +## Working with channels + +### Viewing and joining channels + +In the default command set, channels are all handled via the mighty [channel command](evennia.commands.default.comms.CmdChannel), `channel` (or `chan`). By default, this command will assume all entities dealing with channels are `Accounts`. + +Viewing channels + + channel - shows your subscriptions + channel/all - shows all subs available to you + channel/who - shows who subscribes to this channel + +To join/unsub a channel do + + channel/sub channelname + channel/unsub channelname + +If you temporarily don't want to hear the channel for a while (without actually +unsubscribing), you can mute it: + + channel/mute channelname + channel/unmute channelname + +### Talk on channels + +To speak on a channel, do + + channel public Hello world! + +If the channel-name has spaces in it, you need to use a '`=`': + + channel rest room = Hello world! + +Now, this is more to type than we'd like, so when you join a channel, the +system automatically sets up an personal alias so you can do this instead: + + public Hello world + +```{warning} + + This shortcut will not work if the channel-name has spaces in it. + So channels with long names should make sure to provide a one-word alias as + well. +``` + +Any user can make up their own channel aliases: + + channel/alias public = foo;bar + +You can now just do + + foo Hello world! + bar Hello again! + +And even remove the default one if they don't want to use it + + channel/unalias public + public Hello (gives a command-not-found error now) + +But you can also use your alias with the `channel` command: + + channel foo Hello world! + +> What happens when aliasing is that a [nick](./Nicks.md) is created that maps your +> alias + argument onto calling the `channel` command. So when you enter `foo hello`, +> what the server sees is actually `channel foo = hello`. The system is also +> clever enough to know that whenever you search for channels, your channel-nicks +> should also be considered so as to convert your input to an existing channel name. + +You can check if you missed channel conversations by viewing the channel's +scrollback with + + channel/history public + +This retrieves the last 20 lines of text (also from a time when you were +offline). You can step further back by specifying how many lines back to start: + + channel/history public = 30 + +This again retrieve 20 lines, but starting 30 lines back (so you'll get lines +30-50 counting backwards). + + +### Channel administration + +Evennia can create certain channels when it starts. Channels can also +be created on-the-fly in-game. + +#### Default channels from settings + +You can specify 'default' channels you want to auto-create from the Evennia +settings. New accounts will automatically be subscribed to such 'default' channels if +they have the right permissions. This is a list of one dict per channel (example is the default public channel): + +```python +# in mygame/server/conf/settings.py +DEFAULT_CHANNELS = [ + { + "key": "Public", + "aliases": ("pub",), + "desc": "Public discussion", + "locks": "control:perm(Admin);listen:all();send:all()", + }, +] +``` + +Each dict is fed as `**channeldict` into the [create_channel](evennia.utils.create.create_channel) function, and thus supports all the same keywords. + +Evennia also has two system-related channels: + +- `CHANNEL_MUDINFO` is a dict describing the "MudInfo" channel. This is assumed to exist and is a place for Evennia to echo important server information. The idea is that server admins and staff can subscribe to this channel to stay in the loop. +- `CHANNEL_CONECTINFO` is not defined by default. It will receive connect/disconnect-messages and could be visible also for regular players. If not given, connection-info will just be logged quietly. + +#### Managing channels in-game + +To create/destroy a new channel on the fly you can do + + channel/create channelname;alias;alias = description + channel/destroy channelname + +Aliases are optional but can be good for obvious shortcuts everyone may want to +use. The description is used in channel-listings. You will automatically join a +channel you created and will be controlling it. You can also use `channel/desc` to +change the description on a channel you own later. + +If you control a channel you can also kick people off it: + + channel/boot mychannel = annoyinguser123 : stop spamming! + +The last part is an optional reason to send to the user before they are booted. +You can give a comma-separated list of channels to kick the same user from all +those channels at once. The user will be unsubbed from the channel and all +their aliases will be wiped. But they can still rejoin if they like. + + channel/ban mychannel = annoyinguser123 + channel/ban - view bans + channel/unban mychannel = annoyinguser123 + +Banning adds the user to the channels blacklist. This means they will not be +able to _rejoin_ if you boot them. You will need to run `channel/boot` to +actually kick them out. + +See the [Channel command](evennia.commands.default.comms.CmdChannel) api docs (and in-game help) for more details. + +Admin-level users can also modify channel's [locks](./Locks.md): + + channel/lock buildchannel = listen:all();send:perm(Builders) + +Channels use three lock-types by default: + +- `listen` - who may listen to the channel. Users without this access will not + even be able to join the channel and it will not appear in listings for them. +- `send` - who may send to the channel. +- `control` - this is assigned to you automatically when you create the channel. With + control over the channel you can edit it, boot users and do other management tasks. + + +#### Restricting channel administration + +By default everyone can use the channel command ([evennia.commands.default.comms.CmdChannel](evennia.commands.default.comms.CmdChannel)) to create channels and will then control the channels they created (to boot/ban people etc). If you as a developer does not want regular players to do this (perhaps you want only staff to be able to spawn new channels), you can override the `channel` command and change its `locks` property. + +The default `help` command has the following `locks` property: + +```python + locks = "cmd:not perm(channel_banned); admin:all(); manage:all(); changelocks: perm(Admin)" +``` + +This is a regular [lockstring](./Locks.md). + +- `cmd: pperm(channel_banned)` - The `cmd` locktype is the standard one used for all Commands. + an accessing object failing this will not even know that the command exists. The `pperm()` lockfunc + checks an on-account [Permission](Building Permissions) 'channel_banned' - and the `not` means + that if they _have_ that 'permission' they are cut off from using the `channel` command. You usually + don't need to change this lock. +- `admin:all()` - this is a lock checked in the `channel` command itself. It controls access to the + `/boot`, `/ban` and `/unban` switches (by default letting everyone use them). +- `manage:all()` - this controls access to the `/create`, `/destroy`, `/desc` switches. +- `changelocks: perm(Admin)` - this controls access to the `/lock` and `/unlock` switches. By + default this is something only [Admins](Building Permissions) can change. + +> Note - while `admin:all()` and `manage:all()` will let everyone use these switches, users +> will still only be able to admin or destroy channels they actually control! + +If you only want (say) Builders and higher to be able to create and admin +channels you could override the `help` command and change the lockstring to: + +```python + # in for example mygame/commands/commands.py + + from evennia import default_cmds + + class MyCustomChannelCmd(default_cmds.CmdChannel): + locks = "cmd: not pperm(channel_banned);admin:perm(Builder);manage:perm(Builder);changelocks:perm(Admin)" + +``` + +Add this custom command to your default cmdset and regular users will now get an +access-denied error when trying to use use these switches. + +## Using channels in code + +For most common changes, the default channel, the recipient hooks and possibly +overriding the `channel` command will get you very far. But you can also tweak +channels themselves. + +### Allowing Characters to use Channels + +The default `channel` command ([evennia.commands.default.comms.CmdChannel](evennia.commands.default.comms.CmdChannel)) sits in the `Account` [command set](./Command-Sets.md). It is set up such that it will always operate on `Accounts`, even if you were to add it to the `CharacterCmdSet`. + +It's a one-line change to make this command accept non-account callers. But for convenience we provide a version for Characters/Objects. Just import [evennia.commands.default.comms.CmdObjectChannel](evennia.commands.default.comms.CmdObjectChannel) and inherit from that instead. + +### Customizing channel output and behavior + +When distributing a message, the channel will call a series of hooks on itself +and (more importantly) on each recipient. So you can customize things a lot by +just modifying hooks on your normal Object/Account typeclasses. + +Internally, the message is sent with +`channel.msg(message, senders=sender, bypass_mute=False, **kwargs)`, where +`bypass_mute=True` means the message ignores muting (good for alerts or if you +delete the channel etc) and `**kwargs` are any extra info you may want to pass +to the hooks. The `senders` (it's always only one in the default implementation +but could in principle be multiple) and `bypass_mute` are part of the `kwargs` +below: + + 1. `channel.at_pre_msg(message, **kwargs)` + 2. For each recipient: + - `message = recipient.at_pre_channel_msg(message, channel, **kwargs)` - + allows for the message to be tweaked per-receiver (for example coloring it depending + on the users' preferences). If this method returns `False/None`, that + recipient is skipped. + - `recipient.channel_msg(message, channel, **kwargs)` - actually sends to recipient. + - `recipient.at_post_channel_msg(message, channel, **kwargs)` - any post-receive effects. + 3. `channel.at_post_channel_msg(message, **kwargs)` + +Note that `Accounts` and `Objects` both have their have separate sets of hooks. +So make sure you modify the set actually used by your subscribers (or both). +Default channels all use `Account` subscribers. + +### Channel class + +Channels are [Typeclassed](./Typeclasses.md) entities. This means they are persistent in the database, can have [attributes](./Attributes.md) and [Tags](./Tags.md) and can be easily extended. + +To change which channel typeclass Evennia uses for default commands, change `settings.BASE_CHANNEL_TYPECLASS`. The base command class is [`evennia.comms.comms.DefaultChannel`](evennia.comms.comms.DefaultChannel). There is an empty child class in `mygame/typeclasses/channels.py`, same as for other typelass-bases. + +In code you create a new channel with `evennia.create_channel` or +`Channel.create`: + +```python + from evennia import create_channel, search_object + from typeclasses.channels import Channel + + channel = create_channel("my channel", aliases=["mychan"], locks=..., typeclass=...) + # alternative + channel = Channel.create("my channel", aliases=["mychan"], locks=...) + + # connect to it + me = search_object(key="Foo")[0] + channel.connect(me) + + # send to it (this will trigger the channel_msg hooks described earlier) + channel.msg("Hello world!", senders=me) + + # view subscriptions (the SubscriptionHandler handles all subs under the hood) + channel.subscriptions.has(me) # check we subbed + channel.subscriptions.all() # get all subs + channel.subscriptions.online() # get only subs currently online + channel.subscriptions.clear() # unsub all + + # leave channel + channel.disconnect(me) + + # permanently delete channel (will unsub everyone) + channel.delete() + +``` + +The Channel's `.connect` method will accept both `Account` and `Object` subscribers +and will handle them transparently. + +The channel has many more hooks, both hooks shared with all typeclasses as well as special ones related to muting/banning etc. See the channel class for +details. + +### Channel logging + +```{versionchanged} 0.7 + + Channels changed from using Msg to TmpMsg and optional log files. +``` +```{versionchanged} 1.0 + + Channels stopped supporting Msg and TmpMsg, using only log files. +``` + +The channel messages are not stored in the database. A channel is instead always logged to a regular text log-file `mygame/server/logs/channel_.log`. This is where `channels/history channelname` gets its data from. A channel's log will rotate when it grows too big, which thus also automatically limits the max amount of history a user can view with +`/history`. + +The log file name is set on the channel class as the `log_file` property. This +is a string that takes the formatting token `{channelname}` to be replaced with +the (lower-case) name of the channel. By default the log is written to in the +channel's `at_post_channel_msg` method. + +### Properties on Channels + +Channels have all the standard properties of a Typeclassed entity (`key`, +`aliases`, `attributes`, `tags`, `locks` etc). This is not an exhaustive list; +see the [Channel api docs](evennia.comms.comms.DefaultChannel) for details. + +- `send_to_online_only` - this class boolean defaults to `True` and is a + sensible optimization since people offline people will not see the message anyway. +- `log_file` - this is a string that determines the name of the channel log file. Default + is `"channel_{channelname}.log"`. The log file will appear in `settings.LOG_DIR` (usually + `mygame/server/logs/`). You should usually not change this. +- `channel_prefix_string` - this property is a string to easily change how + the channel is prefixed. It takes the `channelname` format key. Default is `"[{channelname}] "` + and produces output like `[public] ...`. +- `subscriptions` - this is the [SubscriptionHandler](evennia.comms.models.SubscriptionHandler), which + has methods `has`, `add`, `remove`, `all`, `clear` and also `online` (to get + only actually online channel-members). +- `wholist`, `mutelist`, `banlist` are properties that return a list of subscribers, + as well as who are currently muted or banned. +- `channel_msg_nick_pattern` - this is a regex pattern for performing the in-place nick + replacement (detect that `channelalias ` to `channel channelname = `. +- `remove_user_channel_alias(user, alias, **kwargs)` - remove an alias. Note that this is + a class-method that will happily remove found channel-aliases from the user linked to _any_ + channel, not only from the channel the method is called on. +- `pre_join_channel(subscriber)` - if this returns `False`, connection will be refused. +- `post_join_channel(subscriber)` - by default this sets up a users' channel-nicks/aliases. +- `pre_leave_channel(subscriber)` - if this returns `False`, the user is not allowed to leave. +- `post_leave_channel(subscriber)` - this will clean up any channel aliases/nicks of the user. +- `delete` the standard typeclass-delete mechanism will also automatically un-subscribe all + subscribers (and thus wipe all their aliases). + diff --git a/docs/latest/_sources/Components/Characters.md.txt b/docs/latest/_sources/Components/Characters.md.txt new file mode 100644 index 0000000000..45ceeb1bde --- /dev/null +++ b/docs/latest/_sources/Components/Characters.md.txt @@ -0,0 +1,29 @@ +# Characters +**Inheritance Tree: +``` +┌─────────────┐ +│DefaultObject│ +└─────▲───────┘ + │ +┌─────┴──────────┐ +│DefaultCharacter│ +└─────▲──────────┘ + │ ┌────────────┐ + │ ┌─────────►ObjectParent│ + │ │ └────────────┘ + ┌───┴─┴───┐ + │Character│ + └─────────┘ +``` + +_Characters_ is an in-game [Object](./Objects.md) commonly used to represent the player's in-game avatar. The empty `Character` class is found in `mygame/typeclasses/characters.py`. It inherits from [DefaultCharacter](evennia.objects.objects.DefaultCharacter) and the (by default empty) `ObjectParent` class (used if wanting to add share properties between all in-game Objects). + +When a new [Account](./Accounts.md) logs in to Evennia for the first time, a new `Character` object is created and the [Account](./Accounts.md) will be set to _puppet_ it. By default this first Character will get the same name as the Account (but Evennia supports [alternative connection-styles](../Concepts/Connection-Styles.md) if so desired). + +A `Character` object will usually have a [Default Commandset](./Command-Sets.md) set on itself at creation, or the account will not be able to issue any in-game commands! + +If you want to change the default character created by the default commands, you can change it in settings: + + BASE_CHARACTER_TYPECLASS = "typeclasses.characters.Character" + +This deafult points at the empty class in `mygame/typeclasses/characters.py` , ready for you to modify as you please. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Coding-Utils.md.txt b/docs/latest/_sources/Components/Coding-Utils.md.txt new file mode 100644 index 0000000000..b4c148cb62 --- /dev/null +++ b/docs/latest/_sources/Components/Coding-Utils.md.txt @@ -0,0 +1,229 @@ +# Coding Utils + + +Evennia comes with many utilities to help with common coding tasks. Most are accessible directly +from the flat API, otherwise you can find them in the `evennia/utils/` folder. + +> This is just a small selection of the tools in `evennia/utils`. It's worth to browse [the directory](evennia.utils) and in particular the content of [evennia/utils/utils.py](evennia.utils.utils) directly to find more useful stuff. + +## Searching + +A common thing to do is to search for objects. There it's easiest to use the `search` method defined +on all objects. This will search for objects in the same location and inside the self object: + +```python + obj = self.search(objname) +``` + +The most common time one needs to do this is inside a command body. `obj = self.caller.search(objname)` will search inside the caller's (typically, the character that typed the command) `.contents` (their "inventory") and `.location` (their "room"). + +Give the keyword `global_search=True` to extend search to encompass entire database. Aliases will also be matched by this search. You will find multiple examples of this functionality in the default command set. + +If you need to search for objects in a code module you can use the functions in +`evennia.utils.search`. You can access these as shortcuts `evennia.search_*`. + +```python + from evennia import search_object + obj = search_object(objname) +``` + +- [evennia.search_account](evennia.accounts.manager.AccountDBManager.search_account) +- [evennia.search_object](evennia.objects.manager.ObjectDBManager.search_object) +- [evennia.search(object)_by_tag](evennia.utils.search.search_tag) +- [evennia.search_script](evennia.scripts.manager.ScriptDBManager.search_script) +- [evennia.search_channel](evennia.comms.managers.ChannelDBManager.search_channel) +- [evennia.search_message](evennia.comms.managers.MsgManager.search_message) +- [evennia.search_help](evennia.help.manager.HelpEntryManager.search_help) + +Note that these latter methods will always return a `list` of results, even if the list has one or zero entries. + +## Create + +Apart from the in-game build commands (`@create` etc), you can also build all of Evennia's game entities directly in code (for example when defining new create commands). + +```python + import evennia + + myobj = evennia.create_objects("game.gamesrc.objects.myobj.MyObj", key="MyObj") +``` + +- [evennia.create_account](evennia.utils.create.create_account) +- [evennia.create_object](evennia.utils.create.create_object) +- [evennia.create_script](evennia.utils.create.create_script) +- [evennia.create_channel](evennia.utils.create.create_channel) +- [evennia.create_help_entry](evennia.utils.create.create_help_entry) +- [evennia.create_message](evennia.utils.create.create_message) + +Each of these create-functions have a host of arguments to further customize the created entity. See `evennia/utils/create.py` for more information. + +## Logging + +Normally you can use Python `print` statements to see output to the terminal/log. The `print` +statement should only be used for debugging though. For producion output, use the `logger` which will create proper logs either to terminal or to file. + +```python + from evennia import logger + # + logger.log_err("This is an Error!") + logger.log_warn("This is a Warning!") + logger.log_info("This is normal information") + logger.log_dep("This feature is deprecated") +``` + +There is a special log-message type, `log_trace()` that is intended to be called from inside a traceback - this can be very useful for relaying the traceback message back to log without having it +kill the server. + +```python + try: + # [some code that may fail...] + except Exception: + logger.log_trace("This text will show beneath the traceback itself.") +``` + +The `log_file` logger, finally, is a very useful logger for outputting arbitrary log messages. This is a heavily optimized asynchronous log mechanism using [threads](https://en.wikipedia.org/wiki/Thread_%28computing%29) to avoid overhead. You should be able to use it for very heavy custom logging without fearing disk-write delays. + +```python + logger.log_file(message, filename="mylog.log") +``` + +If not an absolute path is given, the log file will appear in the `mygame/server/logs/` directory. If the file already exists, it will be appended to. Timestamps on the same format as the normal Evennia logs will be automatically added to each entry. If a filename is not specified, output will be written to a file `game/logs/game.log`. + +See also the [Debugging](../Coding/Debugging.md) documentation for help with finding elusive bugs. + +## Time Utilities + +### Game time + +Evennia tracks the current server time. You can access this time via the `evennia.gametime` shortcut: + +```python +from evennia import gametime + +# all the functions below return times in seconds). + +# total running time of the server +runtime = gametime.runtime() +# time since latest hard reboot (not including reloads) +uptime = gametime.uptime() +# server epoch (its start time) +server_epoch = gametime.server_epoch() + +# in-game epoch (this can be set by `settings.TIME_GAME_EPOCH`. +# If not, the server epoch is used. +game_epoch = gametime.game_epoch() +# in-game time passed since time started running +gametime = gametime.gametime() +# in-game time plus game epoch (i.e. the current in-game +# time stamp) +gametime = gametime.gametime(absolute=True) +# reset the game time (back to game epoch) +gametime.reset_gametime() + +``` + +The setting `TIME_FACTOR` determines how fast/slow in-game time runs compared to the real world. The setting `TIME_GAME_EPOCH` sets the starting game epoch (in seconds). The functions from the `gametime` module all return their times in seconds. You can convert this to whatever units of time you desire for your game. You can use the `@time` command to view the server time info. +You can also *schedule* things to happen at specific in-game times using the [gametime.schedule](evennia.utils.gametime.schedule) function: + +```python +import evennia + +def church_clock: + limbo = evennia.search_object(key="Limbo") + limbo.msg_contents("The church clock chimes two.") + +gametime.schedule(church_clock, hour=2) +``` + +### utils.time_format() + +This function takes a number of seconds as input (e.g. from the `gametime` module above) and converts it to a nice text output in days, hours etc. It's useful when you want to show how old something is. It converts to four different styles of output using the *style* keyword: + +- style 0 - `5d:45m:12s` (standard colon output) +- style 1 - `5d` (shows only the longest time unit) +- style 2 - `5 days, 45 minutes` (full format, ignores seconds) +- style 3 - `5 days, 45 minutes, 12 seconds` (full format, with seconds) + +### utils.delay() + +This allows for making a delayed call. + +```python +from evennia import utils + +def _callback(obj, text): + obj.msg(text) + +# wait 10 seconds before sending "Echo!" to obj (which we assume is defined) +utils.delay(10, _callback, obj, "Echo!", persistent=False) + +# code here will run immediately, not waiting for the delay to fire! + +``` + +See [The Asynchronous process](../Concepts/Async-Process.md#delay) for more information. + +## Finding Classes + +### utils.inherits_from() + +This useful function takes two arguments - an object to check and a parent. It returns `True` if object inherits from parent *at any distance* (as opposed to Python's in-built `is_instance()` that +will only catch immediate dependence). This function also accepts as input any combination of +classes, instances or python-paths-to-classes. + +Note that Python code should usually work with [duck typing](https://en.wikipedia.org/wiki/Duck_typing). But in Evennia's case it can sometimes be useful to check if an object inherits from a given [Typeclass](./Typeclasses.md) as a way of identification. Say for example that we have a typeclass *Animal*. This has a subclass *Felines* which in turn has a subclass *HouseCat*. Maybe there are a bunch of other animal types too, like horses and dogs. Using `inherits_from` will allow you to check for all animals in one go: + +```python + from evennia import utils + if (utils.inherits_from(obj, "typeclasses.objects.animals.Animal"): + obj.msg("The bouncer stops you in the door. He says: 'No talking animals allowed.'") +``` + +## Text utilities + +In a text game, you are naturally doing a lot of work shuffling text back and forth. Here is a *non- +complete* selection of text utilities found in `evennia/utils/utils.py` (shortcut `evennia.utils`). +If nothing else it can be good to look here before starting to develop a solution of your own. + +### utils.fill() + +This flood-fills a text to a given width (shuffles the words to make each line evenly wide). It also indents as needed. + +```python + outtxt = fill(intxt, width=78, indent=4) +``` + +### utils.crop() + +This function will crop a very long line, adding a suffix to show the line actually continues. This +can be useful in listings when showing multiple lines would mess up things. + +```python + intxt = "This is a long text that we want to crop." + outtxt = crop(intxt, width=19, suffix="[...]") + # outtxt is now "This is a long text[...]" +``` + +### utils.dedent() + +This solves what may at first glance appear to be a trivial problem with text - removing indentations. It is used to shift entire paragraphs to the left, without disturbing any further formatting they may have. A common case for this is when using Python triple-quoted strings in code - they will retain whichever indentation they have in the code, and to make easily-readable source code one usually don't want to shift the string to the left edge. + +```python + #python code is entered at a given indentation + intxt = """ + This is an example text that will end + up with a lot of whitespace on the left. + It also has indentations of + its own.""" + outtxt = dedent(intxt) + # outtxt will now retain all internal indentation + # but be shifted all the way to the left. +``` + +Normally you do the dedent in the display code (this is for example how the help system homogenizes +help entries). + +### to_str() and to_bytes() + +Evennia supplies two utility functions for converting text to the correct encodings. `to_str()` and `to_bytes()`. Unless you are adding a custom protocol and need to send byte-data over the wire, `to_str` is the only one you'll need. + +The difference from Python's in-built `str()` and `bytes()` operators are that the Evennia ones makes use of the `ENCODINGS` setting and will try very hard to never raise a traceback but instead echo errors through logging. See [here](../Concepts/Text-Encodings.md) for more info. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Command-Sets.md.txt b/docs/latest/_sources/Components/Command-Sets.md.txt new file mode 100644 index 0000000000..22c8bdbd91 --- /dev/null +++ b/docs/latest/_sources/Components/Command-Sets.md.txt @@ -0,0 +1,376 @@ +# Command Sets + + +Command Sets are intimately linked with [Commands](./Commands.md) and you should be familiar with +Commands before reading this page. The two pages were split for ease of reading. + +A *Command Set* (often referred to as a CmdSet or cmdset) is the basic unit for storing one or more +*Commands*. A given Command can go into any number of different command sets. Storing Command +classes in a command set is the way to make commands available to use in your game. + +When storing a CmdSet on an object, you will make the commands in that command set available to the +object. An example is the default command set stored on new Characters. This command set contains +all the useful commands, from `look` and `inventory` to `@dig` and `@reload` +([permissions](./Permissions.md) then limit which players may use them, but that's a separate +topic). + +When an account enters a command, cmdsets from the Account, Character, its location, and elsewhere +are pulled together into a *merge stack*. This stack is merged together in a specific order to +create a single "merged" cmdset, representing the pool of commands available at that very moment. + +An example would be a `Window` object that has a cmdset with two commands in it: `look through +window` and `open window`. The command set would be visible to players in the room with the window, +allowing them to use those commands only there. You could imagine all sorts of clever uses of this, +like a `Television` object which had multiple commands for looking at it, switching channels and so +on. The tutorial world included with Evennia showcases a dark room that replaces certain critical +commands with its own versions because the Character cannot see. + +If you want a quick start into defining your first commands and using them with command sets, you +can head over to the [Adding Command Tutorial](../Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md) which steps through things +without the explanations. + +## Defining Command Sets + +A CmdSet is, as most things in Evennia, defined as a Python class inheriting from the correct parent +(`evennia.CmdSet`, which is a shortcut to `evennia.commands.cmdset.CmdSet`). The CmdSet class only +needs to define one method, called `at_cmdset_creation()`. All other class parameters are optional, +but are used for more advanced set manipulation and coding (see the [merge rules](Command- +Sets#merge-rules) section). + +```python +# file mygame/commands/mycmdset.py + +from evennia import CmdSet + +# this is a theoretical custom module with commands we +# created previously: mygame/commands/mycommands.py +from commands import mycommands + +class MyCmdSet(CmdSet): + def at_cmdset_creation(self): + """ + The only thing this method should need + to do is to add commands to the set. + """ + self.add(mycommands.MyCommand1()) + self.add(mycommands.MyCommand2()) + self.add(mycommands.MyCommand3()) +``` + +The CmdSet's `add()` method can also take another CmdSet as input. In this case all the commands +from that CmdSet will be appended to this one as if you added them line by line: + +```python + def at_cmdset_creation(): + ... + self.add(AdditionalCmdSet) # adds all command from this set + ... +``` + +If you added your command to an existing cmdset (like to the default cmdset), that set is already +loaded into memory. You need to make the server aware of the code changes: + +``` +@reload +``` + +You should now be able to use the command. + +If you created a new, fresh cmdset, this must be added to an object in order to make the commands +within available. A simple way to temporarily test a cmdset on yourself is use the `@py` command to +execute a python snippet: + +```python +@py self.cmdset.add('commands.mycmdset.MyCmdSet') +``` + +This will stay with you until you `@reset` or `@shutdown` the server, or you run + +```python +@py self.cmdset.delete('commands.mycmdset.MyCmdSet') +``` + +In the example above, a specific Cmdset class is removed. Calling `delete` without arguments will +remove the latest added cmdset. + +> Note: Command sets added using `cmdset.add` are, by default, *not* persistent in the database. + +If you want the cmdset to survive a reload, you can do: + +``` +@py self.cmdset.add(commands.mycmdset.MyCmdSet, persistent=True) +``` + +Or you could add the cmdset as the *default* cmdset: + +``` +@py self.cmdset.add_default(commands.mycmdset.MyCmdSet) +``` + +An object can only have one "default" cmdset (but can also have none). This is meant as a safe fall- +back even if all other cmdsets fail or are removed. It is always persistent and will not be affected +by `cmdset.delete()`. To remove a default cmdset you must explicitly call `cmdset.remove_default()`. + +Command sets are often added to an object in its `at_object_creation` method. For more examples of +adding commands, read the [Step by step tutorial](../Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md). Generally you can +customize which command sets are added to your objects by using `self.cmdset.add()` or +`self.cmdset.add_default()`. + +> Important: Commands are identified uniquely by key *or* alias (see [Commands](./Commands.md)). If any +overlap exists, two commands are considered identical. Adding a Command to a command set that +already has an identical command will *replace* the previous command. This is very important. You +must take this behavior into account when attempting to overload any default Evennia commands with +your own. Otherwise, you may accidentally "hide" your own command in your command set when adding a +new one that has a matching alias. + +### Properties on Command Sets + +There are several extra flags that you can set on CmdSets in order to modify how they work. All are +optional and will be set to defaults otherwise. Since many of these relate to *merging* cmdsets, +you might want to read the [Adding and Merging Command Sets](./Command-Sets.md#adding-and-merging- +command-sets) section for some of these to make sense. + +- `key` (string) - an identifier for the cmdset. This is optional, but should be unique. It is used +for display in lists, but also to identify special merging behaviours using the `key_mergetype` +dictionary below. +- `mergetype` (string) - allows for one of the following string values: "*Union*", "*Intersect*", +"*Replace*", or "*Remove*". +- `priority` (int) - This defines the merge order of the merge stack - cmdsets will merge in rising +order of priority with the highest priority set merging last. During a merger, the commands from the +set with the higher priority will have precedence (just what happens depends on the [merge +type](./Command-Sets.md#adding-and-merging-command-sets)). If priority is identical, the order in the +merge stack determines preference. The priority value must be greater or equal to `-100`. Most in- +game sets should usually have priorities between `0` and `100`. Evennia default sets have priorities +as follows (these can be changed if you want a different distribution): + - EmptySet: `-101` (should be lower than all other sets) + - SessionCmdSet: `-20` + - AccountCmdSet: `-10` + - CharacterCmdSet: `0` + - ExitCmdSet: ` 101` (generally should always be available) + - ChannelCmdSet: `101` (should usually always be available) - since exits never accept +arguments, there is no collision between exits named the same as a channel even though the commands +"collide". +- `key_mergetype` (dict) - a dict of `key:mergetype` pairs. This allows this cmdset to merge +differently with certain named cmdsets. If the cmdset to merge with has a `key` matching an entry in +`key_mergetype`, it will not be merged according to the setting in `mergetype` but according to the +mode in this dict. Please note that this is more complex than it may seem due to the [merge +order](./Command-Sets.md#adding-and-merging-command-sets) of command sets. Please review that section +before using `key_mergetype`. +- `duplicates` (bool/None default `None`) - this determines what happens when merging same-priority +cmdsets containing same-key commands together. The`dupicate` option will *only* apply when merging +the cmdset with this option onto one other cmdset with the same priority. The resulting cmdset will +*not* retain this `duplicate` setting. + - `None` (default): No duplicates are allowed and the cmdset being merged "onto" the old one +will take precedence. The result will be unique commands. *However*, the system will assume this +value to be `True` for cmdsets on Objects, to avoid dangerous clashes. This is usually the safe bet. + - `False`: Like `None` except the system will not auto-assume any value for cmdsets defined on +Objects. + - `True`: Same-named, same-prio commands will merge into the same cmdset. This will lead to a +multimatch error (the user will get a list of possibilities in order to specify which command they +meant). This is is useful e.g. for on-object cmdsets (example: There is a `red button` and a `green +button` in the room. Both have a `press button` command, in cmdsets with the same priority. This +flag makes sure that just writing `press button` will force the Player to define just which object's +command was intended). +- `no_objs` this is a flag for the cmdhandler that builds the set of commands available at every +moment. It tells the handler not to include cmdsets from objects around the account (nor from rooms +or inventory) when building the merged set. Exit commands will still be included. This option can +have three values: + - `None` (default): Passthrough of any value set explicitly earlier in the merge stack. If never +set explicitly, this acts as `False`. + - `True`/`False`: Explicitly turn on/off. If two sets with explicit `no_objs` are merged, +priority determines what is used. +- `no_exits` - this is a flag for the cmdhandler that builds the set of commands available at every +moment. It tells the handler not to include cmdsets from exits. This flag can have three values: + - `None` (default): Passthrough of any value set explicitly earlier in the merge stack. If +never set explicitly, this acts as `False`. + - `True`/`False`: Explicitly turn on/off. If two sets with explicit `no_exits` are merged, +priority determines what is used. +- `no_channels` (bool) - this is a flag for the cmdhandler that builds the set of commands available +at every moment. It tells the handler not to include cmdsets from available in-game channels. This +flag can have three values: + - `None` (default): Passthrough of any value set explicitly earlier in the merge stack. If +never set explicitly, this acts as `False`. + - `True`/`False`: Explicitly turn on/off. If two sets with explicit `no_channels` are merged, +priority determines what is used. + +## Command Sets Searched + +When a user issues a command, it is matched against the [merged](./Command-Sets.md#adding-and-merging- +command-sets) command sets available to the player at the moment. Which those are may change at any +time (such as when the player walks into the room with the `Window` object described earlier). + +The currently valid command sets are collected from the following sources: + +- The cmdsets stored on the currently active [Session](./Sessions.md). Default is the empty +`SessionCmdSet` with merge priority `-20`. +- The cmdsets defined on the [Account](./Accounts.md). Default is the AccountCmdSet with merge priority +`-10`. +- All cmdsets on the Character/Object (assuming the Account is currently puppeting such a +Character/Object). Merge priority `0`. +- The cmdsets of all objects carried by the puppeted Character (checks the `call` lock). Will not be +included if `no_objs` option is active in the merge stack. +- The cmdsets of the Character's current location (checks the `call` lock). Will not be included if +`no_objs` option is active in the merge stack. +- The cmdsets of objects in the current location (checks the `call` lock). Will not be included if +`no_objs` option is active in the merge stack. +- The cmdsets of Exits in the location. Merge priority `+101`. Will not be included if `no_exits` +*or* `no_objs` option is active in the merge stack. +- The [channel](./Channels.md) cmdset containing commands for posting to all channels the account +or character is currently connected to. Merge priority `+101`. Will not be included if `no_channels` +option is active in the merge stack. + +Note that an object does not *have* to share its commands with its surroundings. A Character's +cmdsets should not be shared for example, or all other Characters would get multi-match errors just +by being in the same room. The ability of an object to share its cmdsets is managed by its `call` +[lock](./Locks.md). For example, [Character objects](./Objects.md) defaults to `call:false()` so that any +cmdsets on them can only be accessed by themselves, not by other objects around them. Another +example might be to lock an object with `call:inside()` to only make their commands available to +objects inside them, or `cmd:holds()` to make their commands available only if they are held. + +## Adding and Merging Command Sets + +*Note: This is an advanced topic. It's very useful to know about, but you might want to skip it if +this is your first time learning about commands.* + +CmdSets have the special ability that they can be *merged* together into new sets. Which of the +ingoing commands end up in the merged set is defined by the *merge rule* and the relative +*priorities* of the two sets. Removing the latest added set will restore things back to the way it +was before the addition. + +CmdSets are non-destructively stored in a stack inside the cmdset handler on the object. This stack +is parsed to create the "combined" cmdset active at the moment. CmdSets from other sources are also +included in the merger such as those on objects in the same room (like buttons to press) or those +introduced by state changes (such as when entering a menu). The cmdsets are all ordered after +priority and then merged together in *reverse order*. That is, the higher priority will be merged +"onto" lower-prio ones. By defining a cmdset with a merge-priority between that of two other sets, +you will make sure it will be merged in between them. +The very first cmdset in this stack is called the *Default cmdset* and is protected from accidental +deletion. Running `obj.cmdset.delete()` will never delete the default set. Instead one should add +new cmdsets on top of the default to "hide" it, as described below. Use the special +`obj.cmdset.delete_default()` only if you really know what you are doing. + +CmdSet merging is an advanced feature useful for implementing powerful game effects. Imagine for +example a player entering a dark room. You don't want the player to be able to find everything in +the room at a glance - maybe you even want them to have a hard time to find stuff in their backpack! +You can then define a different CmdSet with commands that override the normal ones. While they are +in the dark room, maybe the `look` and `inv` commands now just tell the player they cannot see +anything! Another example would be to offer special combat commands only when the player is in +combat. Or when being on a boat. Or when having taken the super power-up. All this can be done on +the fly by merging command sets. + +### Merge Rules + +Basic rule is that command sets are merged in *reverse priority order*. That is, lower-prio sets are +merged first and higher prio sets are merged "on top" of them. Think of it like a layered cake with +the highest priority on top. + +To further understand how sets merge, we need to define some examples. Let's call the first command +set **A** and the second **B**. We assume **B** is the command set already active on our object and +we will merge **A** onto **B**. In code terms this would be done by `object.cdmset.add(A)`. +Remember, B is already active on `object` from before. + +We let the **A** set have higher priority than **B**. A priority is simply an integer number. As +seen in the list above, Evennia's default cmdsets have priorities in the range `-101` to `120`. You +are usually safe to use a priority of `0` or `1` for most game effects. + +In our examples, both sets contain a number of commands which we'll identify by numbers, like `A1, +A2` for set **A** and `B1, B2, B3, B4` for **B**. So for that example both sets contain commands +with the same keys (or aliases) "1" and "2" (this could for example be "look" and "get" in the real +game), whereas commands 3 and 4 are unique to **B**. To describe a merge between these sets, we +would write `A1,A2 + B1,B2,B3,B4 = ?` where `?` is a list of commands that depend on which merge +type **A** has, and which relative priorities the two sets have. By convention, we read this +statement as "New command set **A** is merged onto the old command set **B** to form **?**". + +Below are the available merge types and how they work. Names are partly borrowed from [Set +theory](https://en.wikipedia.org/wiki/Set_theory). + +- **Union** (default) - The two cmdsets are merged so that as many commands as possible from each +cmdset ends up in the merged cmdset. Same-key commands are merged by priority. + + # Union + A1,A2 + B1,B2,B3,B4 = A1,A2,B3,B4 + +- **Intersect** - Only commands found in *both* cmdsets (i.e. which have the same keys) end up in +the merged cmdset, with the higher-priority cmdset replacing the lower one's commands. + + # Intersect + A1,A3,A5 + B1,B2,B4,B5 = A1,A5 + +- **Replace** - The commands of the higher-prio cmdset completely replaces the lower-priority +cmdset's commands, regardless of if same-key commands exist or not. + + # Replace + A1,A3 + B1,B2,B4,B5 = A1,A3 + +- **Remove** - The high-priority command sets removes same-key commands from the lower-priority +cmdset. They are not replaced with anything, so this is a sort of filter that prunes the low-prio +set using the high-prio one as a template. + + # Remove + A1,A3 + B1,B2,B3,B4,B5 = B2,B4,B5 + +Besides `priority` and `mergetype`, a command-set also takes a few other variables to control how +they merge: + +- `duplicates` (bool) - determines what happens when two sets of equal priority merge. Default is +that the new set in the merger (i.e. **A** above) automatically takes 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 player receiving a multiple-match error higher up the road, but can be good for +things like cmdsets on non-player objects in a room, to allow the system to warn that more than one +'ball' in the room has the same 'kick' command defined on it and offer a chance to select which +ball to kick ... Allowing duplicates only makes sense for *Union* and *Intersect*, the setting is +ignored for the other mergetypes. +- `key_mergetypes` (dict) - allows the cmdset to define a unique mergetype for particular cmdsets, +identified by their cmdset `key`. Format is `{CmdSetkey:mergetype}`. Example: +`{'Myevilcmdset','Replace'}` which would make sure for this set to always use 'Replace' on the +cmdset with the key `Myevilcmdset` only, no matter what the main `mergetype` is set to. + +> Warning: The `key_mergetypes` dictionary *can only work on the cmdset we merge onto*. When using +`key_mergetypes` it is thus important to consider the merge priorities - you must make sure that you +pick a priority *between* the cmdset you want to detect and the next higher one, if any. That is, if +we define a cmdset with a high priority and set it to affect a cmdset that is far down in the merge +stack, we would not "see" that set when it's time for us to merge. Example: Merge stack is +`A(prio=-10), B(prio=-5), C(prio=0), D(prio=5)`. We now merge a cmdset `E(prio=10)` onto this stack, +with a `key_mergetype={"B":"Replace"}`. But priorities dictate that we won't be merged onto B, we +will be merged onto E (which is a merger of the lower-prio sets at this point). Since we are merging +onto E and not B, our `key_mergetype` directive won't trigger. To make sure it works we must make +sure we merge onto B. Setting E's priority to, say, -4 will make sure to merge it onto B and affect +it appropriately. + +More advanced cmdset example: + +```python +from commands import mycommands + +class MyCmdSet(CmdSet): + + key = "MyCmdSet" + priority = 4 + mergetype = "Replace" + key_mergetypes = {'MyOtherCmdSet':'Union'} + + def at_cmdset_creation(self): + """ + The only thing this method should need + to do is to add commands to the set. + """ + self.add(mycommands.MyCommand1()) + self.add(mycommands.MyCommand2()) + self.add(mycommands.MyCommand3()) +``` + +### Assorted Notes + +It is very important to remember that two commands are compared *both* by their `key` properties +*and* by their `aliases` properties. If either keys or one of their aliases match, the two commands +are considered the *same*. So consider these two Commands: + + - A Command with key "kick" and alias "fight" + - A Command with key "punch" also with an alias "fight" + +During the cmdset merging (which happens all the time since also things like channel commands and +exits are merged in), these two commands will be considered *identical* since they share alias. It +means only one of them will remain after the merger. Each will also be compared with all other +commands having any combination of the keys and/or aliases "kick", "punch" or "fight". + +... So avoid duplicate aliases, it will only cause confusion. diff --git a/docs/latest/_sources/Components/Commands.md.txt b/docs/latest/_sources/Components/Commands.md.txt new file mode 100644 index 0000000000..b2a8a0964e --- /dev/null +++ b/docs/latest/_sources/Components/Commands.md.txt @@ -0,0 +1,474 @@ +# Commands + + +Commands are intimately linked to [Command Sets](./Command-Sets.md) and you need to read that page too to +be familiar with how the command system works. The two pages were split for easy reading. + +The basic way for users to communicate with the game is through *Commands*. These can be commands directly related to the game world such as *look*, *get*, *drop* and so on, or administrative commands such as *examine* or *dig*. + +The [default commands](./Default-Commands.md) coming with Evennia are 'MUX-like' in that they use @ for admin commands, support things like switches, syntax with the '=' symbol etc, but there is nothing that prevents you from implementing a completely different command scheme for your game. You can find the default commands in `evennia/commands/default`. You should not edit these directly - they will be updated by the Evennia team as new features are added. Rather you should look to them for inspiration and inherit your own designs from them. + +There are two components to having a command running - the *Command* class and the [Command Set](./Command-Sets.md) (command sets were split into a separate wiki page for ease of reading). + +1. A *Command* is a python class containing all the functioning code for what a command does - for example, a *get* command would contain code for picking up objects. +1. A *Command Set* (often referred to as a CmdSet or cmdset) is like a container for one or more Commands. A given Command can go into any number of different command sets. Only by putting the command set on a character object you will make all the commands therein available to use by that character. You can also store command sets on normal objects if you want users to be able to use the object in various ways. Consider a "Tree" object with a cmdset defining the commands *climb* and *chop down*. Or a "Clock" with a cmdset containing the single command *check time*. + +This page goes into full detail about how to use Commands. To fully use them you must also read the page detailing [Command Sets](./Command-Sets.md). There is also a step-by-step [Adding Command Tutorial](../Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md) that will get you started quickly without the extra explanations. + +## Defining Commands + +All commands are implemented as normal Python classes inheriting from the base class `Command` +(`evennia.Command`). You will find that this base class is very "bare". The default commands of +Evennia actually inherit from a child of `Command` called `MuxCommand` - this is the class that +knows all the mux-like syntax like `/switches`, splitting by "=" etc. Below we'll avoid mux- +specifics and use the base `Command` class directly. + +```python + # basic Command definition + from evennia import Command + + class MyCmd(Command): + """ + This is the help-text for the command + """ + key = "mycommand" + def parse(self): + # parsing the command line here + def func(self): + # executing the command here +``` + +Here is a minimalistic command with no custom parsing: + +```python + from evennia import Command + + class CmdEcho(Command): + key = "echo" + + def func(self): + # echo the caller's input back to the caller + self.caller.msg(f"Echo: {self.args}") + +``` + +You define a new command by assigning a few class-global properties on your inherited class and +overloading one or two hook functions. The full gritty mechanic behind how commands work are found +towards the end of this page; for now you only need to know that the command handler creates an +instance of this class and uses that instance whenever you use this command - it also dynamically +assigns the new command instance a few useful properties that you can assume to always be available. + +### Who is calling the command? + +In Evennia there are three types of objects that may call the command. It is important to be aware +of this since this will also assign appropriate `caller`, `session`, `sessid` and `account` +properties on the command body at runtime. Most often the calling type is `Session`. + +* A [Session](./Sessions.md). This is by far the most common case when a user is entering a command in +their client. + * `caller` - this is set to the puppeted [Object](./Objects.md) if such an object exists. If no +puppet is found, `caller` is set equal to `account`. Only if an Account is not found either (such as +before being logged in) will this be set to the Session object itself. + * `session` - a reference to the [Session](./Sessions.md) object itself. + * `sessid` - `sessid.id`, a unique integer identifier of the session. + * `account` - the [Account](./Accounts.md) object connected to this Session. None if not logged in. +* An [Account](./Accounts.md). This only happens if `account.execute_cmd()` was used. No Session +information can be obtained in this case. + * `caller` - this is set to the puppeted Object if such an object can be determined (without +Session info this can only be determined in `MULTISESSION_MODE=0` or `1`). If no puppet is found, +this is equal to `account`. + * `session` - `None*` + * `sessid` - `None*` + * `account` - Set to the Account object. +* An [Object](./Objects.md). This only happens if `object.execute_cmd()` was used (for example by an +NPC). + * `caller` - This is set to the calling Object in question. + * `session` - `None*` + * `sessid` - `None*` + * `account` - `None` + +> `*)`: There is a way to make the Session available also inside tests run directly on Accounts and Objects, and that is to pass it to `execute_cmd` like so: `account.execute_cmd("...", session=)`. Doing so *will* make the `.session` and `.sessid` properties available in the command. + +### Properties assigned to the command instance at run-time + +Let's say account *Bob* with a character *BigGuy* enters the command *look at sword*. After the system having successfully identified this as the "look" command and determined that BigGuy really has access to a command named `look`, it chugs the `look` command class out of storage and either loads an existing Command instance from cache or creates one. After some more checks it then assigns it the following properties: + +- `caller` - The character BigGuy, in this example. This is a reference to the object executing the command. The value of this depends on what type of object is calling the command; see the previous section. +- `session` - the [Session](./Sessions.md) Bob uses to connect to the game and control BigGuy (see also previous section). +- `sessid` - the unique id of `self.session`, for quick lookup. +- `account` - the [Account](./Accounts.md) Bob (see previous section). +- `cmdstring` - the matched key for the command. This would be *look* in our example. +- `args` - this is the rest of the string, except the command name. So if the string entered was *look at sword*, `args` would be " *at sword*". Note the space kept - Evennia would correctly interpret `lookat sword` too. This is useful for things like `/switches` that should not use space. In the `MuxCommand` class used for default commands, this space is stripped. Also see the `arg_regex` property if you want to enforce a space to make `lookat sword` give a command-not-found error. +- `obj` - the game [Object](./Objects.md) on which this command is defined. This need not be the caller, but since `look` is a common (default) command, this is probably defined directly on *BigGuy* - so `obj` will point to BigGuy. Otherwise `obj` could be an Account or any interactive object with commands defined on it, like in the example of the "check time" command defined on a "Clock" object. - `cmdset` - this is a reference to the merged CmdSet (see below) from which this command was +matched. This variable is rarely used, it's main use is for the [auto-help system](./Help-System.md#command-auto-help-system) (*Advanced note: the merged cmdset need NOT be the same as `BigGuy.cmdset`. The merged set can be a combination of the cmdsets from other objects in the room, for example*). +- `raw_string` - this is the raw input coming from the user, without stripping any surrounding +whitespace. The only thing that is stripped is the ending newline marker. + +#### Other useful utility methods: + +- `.get_help(caller, cmdset)` - Get the help entry for this command. By default the arguments are not used, but they could be used to implement alternate help-display systems. +- `.client_width()` - Shortcut for getting the client's screen-width. Note that not all clients will + truthfully report this value - that case the `settings.DEFAULT_SCREEN_WIDTH` will be returned. - `.styled_table(*args, **kwargs)` - This returns an [EvTable](module- evennia.utils.evtable) styled based on the session calling this command. The args/kwargs are the same as for EvTable, except styling defaults are set. +- `.styled_header`, `_footer`, `separator` - These will produce styled decorations for display to the user. They are useful for creating listings and forms with colors adjustable per-user. + +### Defining your own command classes + +Beyond the properties Evennia always assigns to the command at run-time (listed above), your job is to define the following class properties: + +- `key` (string) - the identifier for the command, like `look`. This should (ideally) be unique. A key can consist of more than one word, like "press button" or "pull left lever". Note that *both* `key` and `aliases` below determine the identity of a command. So two commands are considered if either matches. This is important for merging cmdsets described below. +- `aliases` (optional list) - a list of alternate names for the command (`["glance", "see", "l"]`). Same name rules as for `key` applies. +- `locks` (string) - a [lock definition](./Locks.md), usually on the form `cmd:`. Locks is a rather big topic, so until you learn more about locks, stick to giving the lockstring `"cmd:all()"` to make the command available to everyone (if you don't provide a lock string, this will be assigned for you). +- `help_category` (optional string) - setting this helps to structure the auto-help into categories. If none is set, this will be set to *General*. +- `save_for_next` (optional boolean). This defaults to `False`. If `True`, a copy of this command object (along with any changes you have done to it) will be stored by the system and can be accessed by the next command by retrieving `self.caller.ndb.last_cmd`. The next run command will either clear or replace the storage. +- `arg_regex` (optional raw string): Used to force the parser to limit itself and tell it when the command-name ends and arguments begin (such as requiring this to be a space or a /switch). This is done with a regular expression. [See the arg_regex section](./Commands.md#arg_regex) for the details. +- `auto_help` (optional boolean). Defaults to `True`. This allows for turning off the [auto-help system](./Help-System.md#command-auto-help-system) on a per-command basis. This could be useful if you either want to write your help entries manually or hide the existence of a command from `help`'s generated list. +- `is_exit` (bool) - this marks the command as being used for an in-game exit. This is, by default, set by all Exit objects and you should not need to set it manually unless you make your own Exit system. It is used for optimization and allows the cmdhandler to easily disregard this command when the cmdset has its `no_exits` flag set. +- `is_channel` (bool)- this marks the command as being used for an in-game channel. This is, by default, set by all Channel objects and you should not need to set it manually unless you make your own Channel system. is used for optimization and allows the cmdhandler to easily disregard this command when its cmdset has its `no_channels` flag set. +- `msg_all_sessions` (bool): This affects the behavior of the `Command.msg` method. If unset (default), calling `self.msg(text)` from the Command will always only send text to the Session that actually triggered this Command. If set however, `self.msg(text)` will send to all Sessions relevant to the object this Command sits on. Just which Sessions receives the text depends on the object and the server's `MULTISESSION_MODE`. + +You should also implement at least two methods, `parse()` and `func()` (You could also implement +`perm()`, but that's not needed unless you want to fundamentally change how access checks work). + +- `at_pre_cmd()` is called very first on the command. If this function returns anything that evaluates to `True` the command execution is aborted at this point. +- `parse()` is intended to parse the arguments (`self.args`) of the function. You can do this in any way you like, then store the result(s) in variable(s) on the command object itself (i.e. on `self`). To take an example, the default mux-like system uses this method to detect "command switches" and store them as a list in `self.switches`. Since the parsing is usually quite similar inside a command scheme you should make `parse()` as generic as possible and then inherit from it rather than re- implementing it over and over. In this way, the default `MuxCommand` class implements a `parse()` for all child commands to use. +- `func()` is called right after `parse()` and should make use of the pre-parsed input to actually do whatever the command is supposed to do. This is the main body of the command. The return value from this method will be returned from the execution as a Twisted Deferred. +- `at_post_cmd()` is called after `func()` to handle eventual cleanup. + +Finally, you should always make an informative [doc string](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring) (`__doc__`) at the top of your class. This string is dynamically read by the [Help System](./Help-System.md) to create the help entry for this command. You should decide on a way to format your help and stick to that. + +Below is how you define a simple alternative "`smile`" command: + +```python +from evennia import Command + +class CmdSmile(Command): + """ + A smile command + + Usage: + smile [at] [] + grin [at] [] + + Smiles to someone in your vicinity or to the room + in general. + + (This initial string (the __doc__ string) + is also used to auto-generate the help + for this command) + """ + + key = "smile" + aliases = ["smile at", "grin", "grin at"] + locks = "cmd:all()" + help_category = "General" + + def parse(self): + "Very trivial parser" + self.target = self.args.strip() + + def func(self): + "This actually does things" + caller = self.caller + + if not self.target or self.target == "here": + string = f"{caller.key} smiles" + else: + target = caller.search(self.target) + if not target: + return + string = f"{caller.key} smiles at {target.key}" + + caller.location.msg_contents(string) + +``` + +The power of having commands as classes and to separate `parse()` and `func()` lies in the ability to inherit functionality without having to parse every command individually. For example, as mentioned the default commands all inherit from `MuxCommand`. `MuxCommand` implements its own version of `parse()` that understands all the specifics of MUX-like commands. Almost none of the default commands thus need to implement `parse()` at all, but can assume the incoming string is already split up and parsed in suitable ways by its parent. + +Before you can actually use the command in your game, you must now store it within a *command set*. See the [Command Sets](./Command-Sets.md) page. + +### Command prefixes + +Historically, many MU* servers used to use prefix, such as `@` or `&` to signify that a command is used for administration or requires staff privileges. The problem with this is that newcomers to MU often find such extra symbols confusing. Evennia allows commands that can be accessed both with- or without such a prefix. + + CMD_IGNORE_PREFIXES = "@&/+` + +This is a setting consisting of a string of characters. Each is a prefix that will be considered a skippable prefix - _if the command is still unique in its cmdset when skipping the prefix_. + +So if you wanted to write `@look` instead of `look` you can do so - the `@` will be ignored. But If we added an actual `@look` command (with a `key` or alias `@look`) then we would need to use the `@` to separate between the two. + +This is also used in the default commands. For example, `@open` is a building command that allows you to create new exits to link two rooms together. Its `key` is set to `@open`, including the `@` (no alias is set). By default you can use both `@open` and `open` for this command. But "open" is a pretty common word and let's say a developer adds a new `open` command for opening a door. Now `@open` and `open` are two different commands and the `@` must be used to separate them. + +> The `help` command will prefer to show all command names without prefix if +> possible. Only if there is a collision, will the prefix be shown in the help system. + +### arg_regex + +The command parser is very general and does not require a space to end your command name. This means that the alias `:` to `emote` can be used like `:smiles` without modification. It also means `getstone` will get you the stone (unless there is a command specifically named `getstone`, then that will be used). If you want to tell the parser to require a certain separator between the command name and its arguments (so that `get stone` works but `getstone` gives you a 'command not found' error) you can do so with the `arg_regex` property. + +The `arg_regex` is a [raw regular expression string](https://docs.python.org/library/re.html). The regex will be compiled by the system at runtime. This allows you to customize how the part *immediately following* the command name (or alias) must look in order for the parser to match for this command. Some examples: + +- `commandname argument` (`arg_regex = r"\s.+"`): This forces the parser to require the command name to be followed by one or more spaces. Whatever is entered after the space will be treated as an argument. However, if you'd forget the space (like a command having no arguments), this would *not* match `commandname`. +- `commandname` or `commandname argument` (`arg_regex = r"\s.+|$"`): This makes both `look` and `look me` work but `lookme` will not. +- `commandname/switches arguments` (`arg_regex = r"(?:^(?:\s+|\/).*$)|^$"`. If you are using Evennia's `MuxCommand` Command parent, you may wish to use this since it will allow `/switche`s to work as well as having or not having a space. + +The `arg_regex` allows you to customize the behavior of your commands. You can put it in the parent class of your command to customize all children of your Commands. However, you can also change the base default behavior for all Commands by modifying `settings.COMMAND_DEFAULT_ARG_REGEX`. + +## Exiting a command + +Normally you just use `return` in one of your Command class' hook methods to exit that method. That will however still fire the other hook methods of the Command in sequence. That's usually what you want but sometimes it may be useful to just abort the command, for example if you find some unacceptable input in your parse method. To exit the command this way you can raise `evennia.InterruptCommand`: + +```python +from evennia import InterruptCommand + +class MyCommand(Command): + + # ... + + def parse(self): + # ... + # if this fires, `func()` and `at_post_cmd` will not + # be called at all + raise InterruptCommand() + +``` + +## Pauses in commands + +Sometimes you want to pause the execution of your command for a little while before continuing - maybe you want to simulate a heavy swing taking some time to finish, maybe you want the echo of your voice to return to you with an ever-longer delay. Since Evennia is running asynchronously, you cannot use `time.sleep()` in your commands (or anywhere, really). If you do, the *entire game* will +be frozen for everyone! So don't do that. Fortunately, Evennia offers a really quick syntax for +making pauses in commands. + +In your `func()` method, you can use the `yield` keyword. This is a Python keyword that will freeze +the current execution of your command and wait for more before processing. + +> Note that you *cannot* just drop `yield` into any code and expect it to pause. Evennia will only pause for you if you `yield` inside the Command's `func()` method. Don't expect it to work anywhere else. + +Here's an example of a command using a small pause of five seconds between messages: + +```python +from evennia import Command + +class CmdWait(Command): + """ + A dummy command to show how to wait + + Usage: + wait + + """ + + key = "wait" + locks = "cmd:all()" + help_category = "General" + + def func(self): + """Command execution.""" + self.msg("Beginner-Tutorial to wait ...") + yield 5 + self.msg("... This shows after 5 seconds. Waiting ...") + yield 2 + self.msg("... And now another 2 seconds have passed.") +``` + +The important line is the `yield 5` and `yield 2` lines. It will tell Evennia to pause execution here and not continue until the number of seconds given has passed. + +There are two things to remember when using `yield` in your Command's `func` method: + +1. The paused state produced by the `yield` is not saved anywhere. So if the server reloads in the middle of your command pausing, it will *not* resume when the server comes back up - the remainder of the command will never fire. So be careful that you are not freezing the character or account in a way that will not be cleared on reload. +2. If you use `yield` you may not also use `return ` in your `func` method. You'll get an error explaining this. This is due to how Python generators work. You can however use a "naked" `return` just fine. Usually there is no need for `func` to return a value, but if you ever do need to mix `yield` with a final return value in the same `func`, look at [twisted.internet.defer.returnValue](https://twistedmatrix.com/documents/current/api/twisted.internet.defer.html#returnValue). + +## Asking for user input + +The `yield` keyword can also be used to ask for user input. Again you can't use Python's `input` in your command, for it would freeze Evennia for everyone while waiting for that user to input their text. Inside a Command's `func` method, the following syntax can also be used: + +```python +answer = yield("Your question") +``` + +Here's a very simple example: + +```python +class CmdConfirm(Command): + + """ + A dummy command to show confirmation. + + Usage: + confirm + + """ + + key = "confirm" + + def func(self): + answer = yield("Are you sure you want to go on?") + if answer.strip().lower() in ("yes", "y"): + self.msg("Yes!") + else: + self.msg("No!") +``` + +This time, when the user enters the 'confirm' command, she will be asked if she wants to go on. Entering 'yes' or "y" (regardless of case) will give the first reply, otherwise the second reply will show. + +> Note again that the `yield` keyword does not store state. If the game reloads while waiting for the user to answer, the user will have to start over. It is not a good idea to use `yield` for important or complex choices, a persistent [EvMenu](./EvMenu.md) might be more appropriate in this case. + +## System commands + +*Note: This is an advanced topic. Skip it if this is your first time learning about commands.* + +There are several command-situations that are exceptional in the eyes of the server. What happens if the account enters an empty string? What if the 'command' given is infact the name of a channel the user wants to send a message to? Or if there are multiple command possibilities? + +Such 'special cases' are handled by what's called *system commands*. A system command is defined in the same way as other commands, except that their name (key) must be set to one reserved by the engine (the names are defined at the top of `evennia/commands/cmdhandler.py`). You can find (unused) implementations of the system commands in `evennia/commands/default/system_commands.py`. Since these are not (by default) included in any `CmdSet` they are not actually used, they are just there for show. When the special situation occurs, Evennia will look through all valid `CmdSet`s for your custom system command. Only after that will it resort to its own, hard-coded implementation. + +Here are the exceptional situations that triggers system commands. You can find the command keys they use as properties on `evennia.syscmdkeys`: + +- No input (`syscmdkeys.CMD_NOINPUT`) - the account just pressed return without any input. Default is to do nothing, but it can be useful to do something here for certain implementations such as line editors that interpret non-commands as text input (an empty line in the editing buffer). +- Command not found (`syscmdkeys.CMD_NOMATCH`) - No matching command was found. Default is to display the "Huh?" error message. +- Several matching commands where found (`syscmdkeys.CMD_MULTIMATCH`) - Default is to show a list of matches. +- User is not allowed to execute the command (`syscmdkeys.CMD_NOPERM`) - Default is to display the "Huh?" error message. +- Channel (`syscmdkeys.CMD_CHANNEL`) - This is a [Channel](./Channels.md) name of a channel you are subscribing to - Default is to relay the command's argument to that channel. Such commands are created by the Comm system on the fly depending on your subscriptions. +- New session connection (`syscmdkeys.CMD_LOGINSTART`). This command name should be put in the `settings.CMDSET_UNLOGGEDIN`. Whenever a new connection is established, this command is always called on the server (default is to show the login screen). + +Below is an example of redefining what happens when the account doesn't provide any input (e.g. just presses return). Of course the new system command must be added to a cmdset as well before it will work. + +```python + from evennia import syscmdkeys, Command + + class MyNoInputCommand(Command): + "Usage: Just press return, I dare you" + key = syscmdkeys.CMD_NOINPUT + def func(self): + self.caller.msg("Don't just press return like that, talk to me!") +``` + +## Dynamic Commands + +*Note: This is an advanced topic.* + +Normally Commands are created as fixed classes and used without modification. There are however situations when the exact key, alias or other properties is not possible (or impractical) to pre- code. + +To create a command with a dynamic call signature, first define the command body normally in a class (set your `key`, `aliases` to default values), then use the following call (assuming the command class you created is named `MyCommand`): + +```python + cmd = MyCommand(key="newname", + aliases=["test", "test2"], + locks="cmd:all()", + ...) +``` + +*All* keyword arguments you give to the Command constructor will be stored as a property on the command object. This will overload existing properties defined on the parent class. + +Normally you would define your class and only overload things like `key` and `aliases` at run-time. But you could in principle also send method objects (like `func`) as keyword arguments in order to make your command completely customized at run-time. + +### Dynamic commands - Exits + +Exits are examples of the use of a [Dynamic Command](./Commands.md#dynamic-commands). + +The functionality of [Exit](./Objects.md) objects in Evennia is not hard-coded in the engine. Instead Exits are normal [typeclassed](./Typeclasses.md) objects that auto-create a [CmdSet](./Command-Sets.md) on themselves when they load. This cmdset has a single dynamically created Command with the same properties (key, aliases and locks) as the Exit object itself. When entering the name of the exit, this dynamic exit-command is triggered and (after access checks) moves the Character to the exit's destination. + +Whereas you could customize the Exit object and its command to achieve completely different behaviour, you will usually be fine just using the appropriate `traverse_*` hooks on the Exit object. But if you are interested in really changing how things work under the hood, check out `evennia/objects/objects.py` for how the `Exit` typeclass is set up. + +## Command instances are re-used + +*Note: This is an advanced topic that can be skipped when first learning about Commands.* + +A Command class sitting on an object is instantiated once and then re-used. So if you run a command from object1 over and over you are in fact running the same command instance over and over (if you run the same command but sitting on object2 however, it will be a different instance). This is usually not something you'll notice, since every time the Command-instance is used, all the relevant properties on it will be overwritten. But armed with this knowledge you can implement some of the more exotic command mechanism out there, like the command having a 'memory' of what you last entered so that you can back-reference the previous arguments etc. + +> Note: On a server reload, all Commands are rebuilt and memory is flushed. + +To show this in practice, consider this command: + +```python +class CmdTestID(Command): + key = "testid" + + def func(self): + + if not hasattr(self, "xval"): + self.xval = 0 + self.xval += 1 + + self.caller.msg(f"Command memory ID: {id(self)} (xval={self.xval})") + +``` + +Adding this to the default character cmdset gives a result like this in-game: + +``` +> testid +Command memory ID: 140313967648552 (xval=1) +> testid +Command memory ID: 140313967648552 (xval=2) +> testid +Command memory ID: 140313967648552 (xval=3) +``` + +Note how the in-memory address of the `testid` command never changes, but `xval` keeps ticking up. + +## Create a command on the fly + +*This is also an advanced topic.* + +Commands can also be created and added to a cmdset on the fly. Creating a class instance with a keyword argument, will assign that keyword argument as a property on this paricular command: + +``` +class MyCmdSet(CmdSet): + + def at_cmdset_creation(self): + + self.add(MyCommand(myvar=1, foo="test") + +``` + +This will start the `MyCommand` with `myvar` and `foo` set as properties (accessable as `self.myvar` and `self.foo`). How they are used is up to the Command. Remember however the discussion from the previous section - since the Command instance is re-used, those properties will *remain* on the command as long as this cmdset and the object it sits is in memory (i.e. until the next reload). Unless `myvar` and `foo` are somehow reset when the command runs, they can be modified and that change will be remembered for subsequent uses of the command. + +## How commands actually work + +*Note: This is an advanced topic mainly of interest to server developers.* + +Any time the user sends text to Evennia, the server tries to figure out if the text entered +corresponds to a known command. This is how the command handler sequence looks for a logged-in user: + +1. A user enters a string of text and presses enter. +2. The user's Session determines the text is not some protocol-specific control sequence or OOB command, but sends it on to the command handler. +3. Evennia's *command handler* analyzes the Session and grabs eventual references to Account and eventual puppeted Characters (these will be stored on the command object later). The *caller* property is set appropriately. +4. If input is an empty string, resend command as `CMD_NOINPUT`. If no such command is found in cmdset, ignore. +5. If command.key matches `settings.IDLE_COMMAND`, update timers but don't do anything more. +6. The command handler gathers the CmdSets available to *caller* at this time: + - The caller's own currently active CmdSet. + - CmdSets defined on the current account, if caller is a puppeted object. + - CmdSets defined on the Session itself. + - The active CmdSets of eventual objects in the same location (if any). This includes commands on [Exits](./Objects.md#exits). + - Sets of dynamically created *System commands* representing available [Communications](./Channels.md) +7. All CmdSets *of the same priority* are merged together in groups. Grouping avoids order- dependent issues of merging multiple same-prio sets onto lower ones. +8. All the grouped CmdSets are *merged* in reverse priority into one combined CmdSet according to each set's merge rules. +9. Evennia's *command parser* takes the merged cmdset and matches each of its commands (using its key and aliases) against the beginning of the string entered by *caller*. This produces a set of candidates. +10. The *cmd parser* next rates the matches by how many characters they have and how many percent matches the respective known command. Only if candidates cannot be separated will it return multiple matches. + - If multiple matches were returned, resend as `CMD_MULTIMATCH`. If no such command is found in cmdset, return hard-coded list of matches. + - If no match was found, resend as `CMD_NOMATCH`. If no such command is found in cmdset, give hard-coded error message. +11. If a single command was found by the parser, the correct command object is plucked out of storage. This usually doesn't mean a re-initialization. +12. It is checked that the caller actually has access to the command by validating the *lockstring* of the command. If not, it is not considered as a suitable match and `CMD_NOMATCH` is triggered. +13. If the new command is tagged as a channel-command, resend as `CMD_CHANNEL`. If no such command is found in cmdset, use hard-coded implementation. +14. Assign several useful variables to the command instance (see previous sections). +15. Call `at_pre_command()` on the command instance. +16. Call `parse()` on the command instance. This is fed the remainder of the string, after the name of the command. It's intended to pre-parse the string into a form useful for the `func()` method. +17. Call `func()` on the command instance. This is the functional body of the command, actually doing useful things. +18. Call `at_post_command()` on the command instance. + +## Assorted notes + +The return value of `Command.func()` is a Twisted [deferred](https://twistedmatrix.com/documents/current/core/howto/defer.html). +Evennia does not use this return value at all by default. If you do, you must +thus do so asynchronously, using callbacks. + +```python + # in command class func() + def callback(ret, caller): + caller.msg(f"Returned is {ret}") + deferred = self.execute_command("longrunning") + deferred.addCallback(callback, self.caller) +``` + +This is probably not relevant to any but the most advanced/exotic designs (one might use it to create a "nested" command structure for example). + +The `save_for_next` class variable can be used to implement state-persistent commands. For example it can make a command operate on "it", where it is determined by what the previous command operated on. diff --git a/docs/latest/_sources/Components/Components-Overview.md.txt b/docs/latest/_sources/Components/Components-Overview.md.txt new file mode 100644 index 0000000000..194a6cf2c1 --- /dev/null +++ b/docs/latest/_sources/Components/Components-Overview.md.txt @@ -0,0 +1,78 @@ +# Core Components + +These are the 'building blocks' out of which Evennia is built. This documentation is complementary to, and often goes deeper than, the doc-strings of each component in the [API](../Evennia-API.md). + +## Base components + +These are base pieces used to make an Evennia game. Most are long-lived and are persisted in the database. + +```{toctree} +:maxdepth: 2 +Portal-And-Server.md +Sessions.md +Typeclasses.md +Accounts.md +Objects.md +Characters.md +Rooms.md +Exits.md +Scripts.md +Channels.md +Msg.md +Attributes.md +Nicks.md +Tags.md +Prototypes.md +Help-System.md +Permissions.md +Locks.md +``` + +## Commands + +Evennia's Command system handle everything sent to the server by the user. + +```{toctree} +:maxdepth: 2 + +Commands.md +Command-Sets.md +Default-Commands.md +Batch-Processors.md +Inputfuncs.md +``` + + +## Utils and tools + +Evennia provides a library of code resources to help the creation of a game. + +```{toctree} +:maxdepth: 2 + +Coding-Utils.md +EvEditor.md +EvForm.md +EvMenu.md +EvMore.md +EvTable.md +FuncParser.md +MonitorHandler.md +TickerHandler.md +Signals.md +``` + +## Web components + +Evennia is also its own webserver, with a website and in-browser webclient you can expand on. + +```{toctree} +:maxdepth: 2 + +Website.md +Webclient.md +Web-Admin.md +Webserver.md +Web-API.md +Web-Bootstrap-Framework.md +``` \ No newline at end of file diff --git a/docs/latest/_sources/Components/Default-Commands.md.txt b/docs/latest/_sources/Components/Default-Commands.md.txt new file mode 100644 index 0000000000..8a19fc8523 --- /dev/null +++ b/docs/latest/_sources/Components/Default-Commands.md.txt @@ -0,0 +1,96 @@ +# Default Commands + +The full set of default Evennia commands currently contains 89 commands in 9 source +files. Our policy for adding default commands is outlined [here](../Coding/Default-Command-Syntax.md). The +[Commands](./Commands.md) documentation explains how Commands work as well as how to make new or customize +existing ones. + +> Note that this page is auto-generated. Report problems to the [issue tracker](github:issues). + +```{note} +Some game-states add their own Commands which are not listed here. Examples include editing a text +with [EvEditor](./EvEditor.md), flipping pages in [EvMore](./EvMore.md) or using the +[Batch-Processor](./Batch-Processors.md)'s interactive mode. +``` + +- [**@about** [@version]](CmdAbout) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@accounts** [@account]](CmdAccounts) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@alias** [setobjalias]](CmdSetObjAlias) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@channel** [@chan, @channels]](CmdChannel) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _Comms_) +- [**@cmdsets**](CmdListCmdSets) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@copy**](CmdCopy) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@cpattr**](CmdCpAttr) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@create**](CmdCreate) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@desc**](CmdDesc) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@destroy** [@del, @delete]](CmdDestroy) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@dig**](CmdDig) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@examine** [@ex, @exam]](CmdExamine) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _Building_) +- [**@find** [@locate, @search]](CmdFind) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@link**](CmdLink) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@lock** [@locks]](CmdLock) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@mvattr**](CmdMvAttr) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@name** [@rename]](CmdName) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@objects**](CmdObjects) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@open**](CmdOpen) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@py** [@!]](CmdPy) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_) +- [**@reload** [@restart]](CmdReload) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_) +- [**@reset** [@reboot]](CmdReset) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_) +- [**@scripts** [@script]](CmdScripts) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@server** [@serverload]](CmdServerLoad) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@service** [@services]](CmdService) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@set**](CmdSetAttribute) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@sethome**](CmdSetHome) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@shutdown**](CmdShutdown) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_) +- [**@spawn** [@olc]](CmdSpawn) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@tag** [@tags]](CmdTag) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@tasks** [@delays, @task]](CmdTasks) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@teleport** [@tel]](CmdTeleport) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@tickers**](CmdTickers) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@time** [@uptime]](CmdTime) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_) +- [**@tunnel** [@tun]](CmdTunnel) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@typeclass** [@parent, @swap, @type, @typeclasses, @update]](CmdTypeclass) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**@wipe**](CmdWipe) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**__unloggedin_look_command** [l, look]](CmdUnconnectedLook) (cmdset: [UnloggedinCmdSet](UnloggedinCmdSet), help-category: _General_) +- [**access** [groups, hierarchy]](CmdAccess) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**batchcode** [batchcodes]](CmdBatchCode) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**batchcommands** [batchcmd, batchcommand]](CmdBatchCommands) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**charcreate**](CmdCharCreate) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**chardelete**](CmdCharDelete) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**color**](CmdColorTest) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**connect** [co, con, conn]](CmdUnconnectedConnect) (cmdset: [UnloggedinCmdSet](UnloggedinCmdSet), help-category: _General_) +- [**create** [cr, cre]](CmdUnconnectedCreate) (cmdset: [UnloggedinCmdSet](UnloggedinCmdSet), help-category: _General_) +- [**discord2chan** [discord]](CmdDiscord2Chan) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _Comms_) +- [**drop**](CmdDrop) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**encoding** [encode]](CmdUnconnectedEncoding) (cmdset: [UnloggedinCmdSet](UnloggedinCmdSet), help-category: _General_) +- [**get** [grab]](CmdGet) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**give**](CmdGive) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**grapevine2chan**](CmdGrapevine2Chan) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _Comms_) +- [**help** [?]](CmdHelp) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**help** [?, h]](CmdUnconnectedHelp) (cmdset: [UnloggedinCmdSet](UnloggedinCmdSet), help-category: _General_) +- [**home**](CmdHome) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**ic** [puppet]](CmdIC) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**info**](CmdUnconnectedInfo) (cmdset: [UnloggedinCmdSet](UnloggedinCmdSet), help-category: _General_) +- [**inventory** [i, inv]](CmdInventory) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**irc2chan**](CmdIRC2Chan) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _Comms_) +- [**ircstatus**](CmdIRCStatus) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _Comms_) +- [**look** [l, ls]](CmdOOCLook) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**look** [l, ls]](CmdLook) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**nick** [nickname, nicks]](CmdNick) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**ooc** [unpuppet]](CmdOOC) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**option** [options]](CmdOption) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**page** [tell]](CmdPage) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _Comms_) +- [**password**](CmdPassword) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**pose** [:, emote]](CmdPose) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**quell** [unquell]](CmdQuell) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**quit**](CmdQuit) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**quit** [q, qu]](CmdUnconnectedQuit) (cmdset: [UnloggedinCmdSet](UnloggedinCmdSet), help-category: _General_) +- [**rss2chan**](CmdRSS2Chan) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _Comms_) +- [**say** [", ']](CmdSay) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**screenreader**](CmdUnconnectedScreenreader) (cmdset: [UnloggedinCmdSet](UnloggedinCmdSet), help-category: _General_) +- [**sessions**](CmdSessions) (cmdset: [SessionCmdSet](SessionCmdSet), help-category: _General_) +- [**setdesc**](CmdSetDesc) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**sethelp**](CmdSetHelp) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**style**](CmdStyle) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) +- [**unlink**](CmdUnLink) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_) +- [**whisper**](CmdWhisper) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _General_) +- [**who** [doing]](CmdWho) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _General_) \ No newline at end of file diff --git a/docs/latest/_sources/Components/EvEditor.md.txt b/docs/latest/_sources/Components/EvEditor.md.txt new file mode 100644 index 0000000000..edc554aab1 --- /dev/null +++ b/docs/latest/_sources/Components/EvEditor.md.txt @@ -0,0 +1,172 @@ +# EvEditor + + +Evennia offers a powerful in-game line editor in `evennia.utils.eveditor.EvEditor`. This editor, +mimicking the well-known VI line editor. It offers line-by-line editing, undo/redo, line deletes, +search/replace, fill, dedent and more. + +## Launching the editor + +The editor is created as follows: + +```python +from evennia.utils.eveditor import EvEditor + +EvEditor(caller, + loadfunc=None, savefunc=None, quitfunc=None, + key="") +``` + + - `caller` (Object or Account): The user of the editor. + - `loadfunc` (callable, optional): This is a function called when the editor is first started. It +is called with `caller` as its only argument. The return value from this function is used as the +starting text in the editor buffer. + - `savefunc` (callable, optional): This is called when the user saves their buffer in the editor is +called with two arguments, `caller` and `buffer`, where `buffer` is the current buffer. + - `quitfunc` (callable, optional): This is called when the user quits the editor. If given, all +cleanup and exit messages to the user must be handled by this function. + - `key` (str, optional): This text will be displayed as an identifier and reminder while editing. +It has no other mechanical function. + - `persistent` (default `False`): if set to `True`, the editor will survive a reboot. + +## Working with EvEditor + +This is an example command for setting a specific Attribute using the editor. + +```python +from evennia import Command +from evennia.utils import eveditor + +class CmdSetTestAttr(Command): + """ + Set the "test" Attribute using + the line editor. + + Usage: + settestattr + + """ + key = "settestattr" + def func(self): + "Set up the callbacks and launch the editor" + def load(caller): + "get the current value" + return caller.attributes.get("test") + def save(caller, buffer): + "save the buffer" + caller.attributes.add("test", buffer) + def quit(caller): + "Since we define it, we must handle messages" + caller.msg("Editor exited") + key = f"{self.caller}/test" + # launch the editor + eveditor.EvEditor(self.caller, + loadfunc=load, savefunc=save, quitfunc=quit, + key=key) +``` + +### Persistent editor + +If you set the `persistent` keyword to `True` when creating the editor, it will remain open even +when reloading the game. In order to be persistent, an editor needs to have its callback functions +(`loadfunc`, `savefunc` and `quitfunc`) as top-level functions defined in the module. Since these +functions will be stored, Python will need to find them. + +```python +from evennia import Command +from evennia.utils import eveditor + +def load(caller): + "get the current value" + return caller.attributes.get("test") + +def save(caller, buffer): + "save the buffer" + caller.attributes.add("test", buffer) + +def quit(caller): + "Since we define it, we must handle messages" + caller.msg("Editor exited") + +class CmdSetTestAttr(Command): + """ + Set the "test" Attribute using + the line editor. + + Usage: + settestattr + + """ + key = "settestattr" + def func(self): + "Set up the callbacks and launch the editor" + key = f"{self.caller}/test" + # launch the editor + eveditor.EvEditor(self.caller, + loadfunc=load, savefunc=save, quitfunc=quit, + key=key, persistent=True) +``` + +### Line editor usage + +The editor mimics the `VIM` editor as best as possible. The below is an excerpt of the return from +the in-editor help command (`:h`). + +``` + - any non-command is appended to the end of the buffer. + : - view buffer or only line + :: - view buffer without line numbers or other parsing + ::: - print a ':' as the only character on the line... + :h - this help. + + :w - save the buffer (don't quit) + :wq - save buffer and quit + :q - quit (will be asked to save if buffer was changed) + :q! - quit without saving, no questions asked + + :u - (undo) step backwards in undo history + :uu - (redo) step forward in undo history + :UU - reset all changes back to initial state + + :dd - delete line + :dw - delete word or regex in entire buffer or on line + :DD - clear buffer + + :y - yank (copy) line to the copy buffer + :x - cut line and store it in the copy buffer + :p - put (paste) previously copied line directly after + :i - insert new text at line . Old line will move down + :r - replace line with text + :I - insert text at the beginning of line + :A - append text after the end of line + + :s - search/replace word or regex in buffer or on line + + :f - flood-fill entire buffer or line + :fi - indent entire buffer or line + :fd - de-indent entire buffer or line + + :echo - turn echoing of the input on/off (helpful for some clients) + + Legend: + - line numbers, or range lstart:lend, e.g. '3:7'. + - one word or several enclosed in quotes. + - longer string, usually not needed to be enclosed in quotes. +``` + +### The EvEditor to edit code + +The `EvEditor` is also used to edit some Python code in Evennia. The `py` command supports an `/edit` switch that will open the EvEditor in code mode. This mode isn't significantly different from the standard one, except it handles automatic indentation of blocks and a few options to control this behavior. + +- `:<` to remove a level of indentation for the future lines. +- `:+` to add a level of indentation for the future lines. +- `:=` to disable automatic indentation altogether. + +Automatic indentation is there to make code editing more simple. Python needs correct indentation, not as an aesthetic addition, but as a requirement to determine beginning and ending of blocks. The EvEditor will try to guess the next level of indentation. If you type a block "if", for instance, the EvEditor will propose you an additional level of indentation at the next line. This feature cannot be perfect, however, and sometimes, you will have to use the above options to handle indentation. + +`:=` can be used to turn automatic indentation off completely. This can be very useful when trying +to paste several lines of code that are already correctly indented, for instance. + +To see the EvEditor in code mode, you can use the `@py/edit` command. Type in your code (on one or several lines). You can then use the `:w` option (save without quitting) and the code you have +typed will be executed. The `:!` will do the same thing. Executing code while not closing the +editor can be useful if you want to test the code you have typed but add new lines after your test. diff --git a/docs/latest/_sources/Components/EvForm.md.txt b/docs/latest/_sources/Components/EvForm.md.txt new file mode 100644 index 0000000000..93372cce65 --- /dev/null +++ b/docs/latest/_sources/Components/EvForm.md.txt @@ -0,0 +1,3 @@ +# EvForm + +[Docstring in evennia/utils/evform.py](evennia.utils.evform) \ No newline at end of file diff --git a/docs/latest/_sources/Components/EvMenu.md.txt b/docs/latest/_sources/Components/EvMenu.md.txt new file mode 100644 index 0000000000..8a1342c7ae --- /dev/null +++ b/docs/latest/_sources/Components/EvMenu.md.txt @@ -0,0 +1,1271 @@ +# EvMenu + +```shell +Is your answer yes or no? +_________________________________________ +[Y]es! - Answer yes. +[N]o! - Answer no. +[A]bort - Answer neither, and abort. + +> Y +You chose yes! + +Thanks for your answer. Goodbye! +``` + +_EvMenu_ is used for generate branching multi-choice menus. Each menu 'node' can +accepts specific options as input or free-form input. Depending what the player +chooses, they are forwarded to different nodes in the menu. + +The `EvMenu` utility class is located in [evennia/utils/evmenu.py](evennia.utils.evmenu). +It allows for easily adding interactive menus to the game; for example to implement Character creation, building commands or similar. Below is an example of offering NPC conversation choices: + +This is how the example menu at the top of this page will look in code: + +```python +from evennia.utils import evmenu + +def _handle_answer(caller, raw_input, **kwargs): + answer = kwargs.get("answer") + caller.msg(f"You chose {answer}!") + return "end" # name of next node + +def node_question(caller, raw_input, **kwargs): + text = "Is your answer yes or no?" + options = ( + {"key": ("[Y]es!", "yes", "y"), + "desc": Answer yes.", + "goto": _handle_answer, {"answer": "yes"}}, + {"key": ("[N]o!", "no", "n"), + "desc": "Answer no.", + "goto": _handle_answer, {"answer": "no"}}, + {"key": ("[A]bort", "abort", "a"), + "desc": "Answer neither, and abort.", + "goto": "end"} + ) + return text, options + +def node_end(caller, raw_input, **kwargs): + text "Thanks for your answer. Goodbye!" + return text, None # empty options ends the menu + +evmenu.EvMenu(caller, {"start": node_question, "end": node_end}) + +``` + +Note the call to `EvMenu` at the end; this immediately creates the menu for the +`caller`. It also assigns the two node-functions to menu node-names `start` and +`end`, which is what the menu then uses to reference the nodes. + +Each node of the menu is a function that returns the text and a list of dicts +describing the choices you can make on that node. + +Each option details what it should show (key/desc) as well as which node to go +to (goto) next. The "goto" should be the name of the next node to go (if `None`, +the same node will be rerun again). + +Above, the `Abort` option gives the "end" node name just as a string whereas the +yes/no options instead uses the callable `_handle_answer` but pass different +arguments to it. `_handle_answer` then returns the name of the next node (this +allows you to perform actions when making a choice before you move on to the +next node the menu). Note that `_handle_answer` is _not_ a node in the menu, +it's just a helper function. + +When choosing 'yes' (or 'no') what happens here is that `_handle_answer` gets +called and echoes your choice before directing to the "end" node, which exits +the menu (since it doesn't return any options). + +You can also write menus using the [EvMenu templating language](#evmenu-templating-language). This +allows you to use a text string to generate simpler menus with less boiler +plate. Let's create exactly the same menu using the templating language: + +```python +from evennia.utils import evmenu + +def _handle_answer(caller, raw_input, **kwargs): + answer = kwargs.get("answer") + caller.msg(f"You chose {answer}!") + return "end" # name of next node + +menu_template = """ + +## node start + +Is your answer yes or no? + +## options + +[Y]es!;yes;y: Answer yes. -> handle_answer(answer=yes) +[N]o!;no;n: Answer no. -> handle_answer(answer=no) +[A]bort;abort;a: Answer neither, and abort. -> end + +## node end + +Thanks for your answer. Goodbye! + +""" + +evmenu.template2menu(caller, menu_template, {"handle_answer": _handle_answer}) + +``` + +As seen, the `_handle_answer` is the same, but the menu structure is +described in the `menu_template` string. The `template2menu` helper +uses the template-string and a mapping of callables (we must add +`_handle_answer` here) to build a full EvMenu for us. + +Here's another menu example, where we can choose how to interact with an NPC: + +``` +The guard looks at you suspiciously. +"No one is supposed to be in here ..." +he says, a hand on his weapon. +_______________________________________________ + 1. Try to bribe him [Cha + 10 gold] + 2. Convince him you work here [Int] + 3. Appeal to his vanity [Cha] + 4. Try to knock him out [Luck + Dex] + 5. Try to run away [Dex] +``` + +```python + +def _skill_check(caller, raw_string, **kwargs): + skills = kwargs.get("skills", []) + gold = kwargs.get("gold", 0) + + # perform skill check here, decide if check passed or not + # then decide which node-name to return based on + # the result ... + + return next_node_name + +def node_guard(caller, raw_string, **kwarg): + text = ( + 'The guard looks at you suspiciously.\n' + '"No one is supposed to be in here ..."\n' + 'he says, a hand on his weapon.' + options = ( + {"desc": "Try to bribe on [Cha + 10 gold]", + "goto": (_skill_check, {"skills": ["Cha"], "gold": 10})}, + {"desc": "Convince him you work here [Int].", + "goto": (_skill_check, {"skills": ["Int"]})}, + {"desc": "Appeal to his vanity [Cha]", + "goto": (_skill_check, {"skills": ["Cha"]})}, + {"desc": "Try to knock him out [Luck + Dex]", + "goto": (_skill_check, {"skills"" ["Luck", "Dex"]})}, + {"desc": "Try to run away [Dex]", + "goto": (_skill_check, {"skills": ["Dex"]})} + return text, options + ) + +# EvMenu called below, with all the nodes ... + +``` + +Note that by skipping the `key` of the options, we instead get an +(auto-generated) list of numbered options to choose from. + +Here the `_skill_check` helper will check (roll your stats, exactly what this +means depends on your game) to decide if your approach succeeded. It may then +choose to point you to nodes that continue the conversation or maybe dump you +into combat! + + +## Launching the menu + +Initializing the menu is done using a call to the `evennia.utils.evmenu.EvMenu` class. This is the most common way to do so - from inside a [Command](./Commands.md): + +```python +# in, for example gamedir/commands/command.py + +from evennia.utils.evmenu import EvMenu + +class CmdTestMenu(Command): + + key = "testcommand" + + def func(self): + + EvMenu(self.caller, "world.mymenu") + +``` + +When running this command, the menu will start using the menu nodes loaded from +`mygame/world/mymenu.py`. See next section on how to define menu nodes. + +The `EvMenu` has the following optional callsign: + +```python +EvMenu(caller, menu_data, + startnode="start", + cmdset_mergetype="Replace", cmdset_priority=1, + auto_quit=True, auto_look=True, auto_help=True, + cmd_on_exit="look", + persistent=False, + startnode_input="", + session=None, + debug=False, + **kwargs) + +``` + + - `caller` (Object or Account): is a reference to the object using the menu. This object will get a new [CmdSet](./Command-Sets.md) assigned to it, for handling the menu. + - `menu_data` (str, module or dict): is a module or python path to a module where the global-level functions will each be considered to be a menu node. Their names in the module will be the names by which they are referred to in the module. Importantly, function names starting with an underscore `_` will be ignored by the loader. Alternatively, this can be a direct mapping +`{"nodename":function, ...}`. + - `startnode` (str): is the name of the menu-node to start the menu at. Changing this means that you can jump into a menu tree at different positions depending on circumstance and thus possibly re-use menu entries. + - `cmdset_mergetype` (str): This is usually one of "Replace" or "Union" (see [CmdSets](Command- Sets). The first means that the menu is exclusive - the user has no access to any other commands while in the menu. The Union mergetype means the menu co-exists with previous commands (and may overload them, so be careful as to what to name your menu entries in this case). + - `cmdset_priority` (int): The priority with which to merge in the menu cmdset. This allows for advanced usage. + - `auto_quit`, `auto_look`, `auto_help` (bool): If either of these are `True`, the menu automatically makes a `quit`, `look` or `help` command available to the user. The main reason why you'd want to turn this off is if you want to use the aliases "q", "l" or "h" for something in your menu. The `auto_help` also activates the ability to have arbitrary "tool tips" in your menu node (see below), At least `quit` is highly recommend - if `False`, the menu *must* itself supply an "exit node" (a node without any options), or the user will be stuck in the menu until the server reloads (or eternally if the menu is `persistent`)! + - `cmd_on_exit` (str): This command string will be executed right *after* the menu has closed down. From experience, it's useful to trigger a "look" command to make sure the user is aware of the change of state; but any command can be used. If set to `None`, no command will be triggered after exiting the menu. + - `persistent` (bool) - if `True`, the menu will survive a reload (so the user will not be kicked + out by the reload - make sure they can exit on their own!) + - `startnode_input` (str or (str, dict) tuple): Pass an input text or a input text + kwargs to the + start node as if it was entered on a fictional previous node. This can be very useful in order to + start a menu differently depending on the Command's arguments in which it was initialized. + - `session` (Session): Useful when calling the menu from an [Account](./Accounts.md) in + `MULTISESSION_MODE` higher than 2, to make sure only the right Session sees the menu output. + - `debug` (bool): If set, the `menudebug` command will be made available in the menu. Use it to + list the current state of the menu and use `menudebug ` to inspect a specific state + variable from the list. + - All other keyword arguments will be available as initial data for the nodes. They will be available in all nodes as properties on `caller.ndb._evmenu` (see below). These will also survive a `reload` if the menu is `persistent`. + +You don't need to store the EvMenu instance anywhere - the very act of initializing it will store it +as `caller.ndb._evmenu` on the `caller`. This object will be deleted automatically when the menu +is exited and you can also use it to store your own temporary variables for access throughout the +menu. Temporary variables you store on a persistent `_evmenu` as it runs will +*not* survive a `@reload`, only those you set as part of the original `EvMenu` call. + +## The Menu nodes + +The EvMenu nodes consist of functions on one of these forms. + +```python +def menunodename1(caller): + # code + return text, options + +def menunodename2(caller, raw_string): + # code + return text, options + +def menunodename3(caller, raw_string, **kwargs): + # code + return text, options + +``` + +> While all of the above forms are okay, it's recommended to stick to the third and last form since it gives the most flexibility. The previous forms are mainly there for backwards compatibility with existing menus from a time when EvMenu was less able and may become deprecated at some time in the future. + + +### Input arguments to the node + + - `caller` (Object or Account): The object using the menu - usually a Character but could also be a Session or Account depending on where the menu is used. + - `raw_string` (str): If this is given, it will be set to the exact text the user entered on the + *previous* node (that is, the command entered to get to this node). On the starting-node of the menu, this will be an empty string, unless `startnode_input` was set. + - `kwargs` (dict): These extra keyword arguments are extra optional arguments passed to the node when the user makes a choice on the *previous* node. This may include things like status flags and details about which exact option was chosen (which can be impossible to determine from + `raw_string` alone). Just what is passed in `kwargs` is up to you when you create the previous node. + +### Return values from the node + +Each node function must return two variables, `text` and `options`. + + +#### text + +The `text` variable is either a string or a tuple. This is the simplest form: + +```python +text = "Node text" +``` + +This is what will be displayed as text in the menu node when entering it. You can modify this dynamically in the node if you want. Returning a `None` node text text is allowed - this leads to a node with no text and only options. + +```python +text = ("Node text", "help text to show with h|elp") +``` + +In this form, we also add an optional help text. If `auto_help=True` when initializing the EvMenu, the user will be able to use `h` or `help` to see this text when viewing this node. If the user were to provide a custom option overriding `h` or `help`, that will be shown instead. + +If `auto_help=True` and no help text is provided, using `h|elp` will give a generic error message. + +```python +text = ("Node text", {"help topic 1": "Help 1", + ("help topic 2", "alias1", ...): "Help 2", ...}) +``` + +This is 'tooltip' or 'multi-help category' mode. This also requires `auto_help=True` when initializing the EvMenu. By providing a `dict` as the second element of the `text` tuple, the user will be able to help about any of these topics. Use a tuple as key to add multiple aliases to the same help entry. This allows the user to get more detailed help text without leaving the given node. + +Note that in 'tooltip' mode, the normal `h|elp` command won't work. The `h|elp` entry must be added manually in the dict. As an example, this would reproduce the normal help functionality: + +```python +text = ("Node text", {("help", "h"): "Help entry...", ...}) +``` + +#### options + +The `options` list describe all the choices available to the user when viewing this node. If `options` is returned as `None`, it means that this node is an *Exit node* - any text is displayed and then the menu immediately exits, running the `exit_cmd` if given. + +Otherwise, `options` should be a list (or tuple) of dictionaries, one for each option. If only one option is available, a single dictionary can also be returned. This is how it could look: + + +```python +def node_test(caller, raw_string, **kwargs): + + text = "A goblin attacks you!" + + options = ( + {"key": ("Attack", "a", "att"), + "desc": "Strike the enemy with all your might", + "goto": "node_attack"}, + {"key": ("Defend", "d", "def"), + "desc": "Hold back and defend yourself", + "goto": (_defend, {"str": 10, "enemyname": "Goblin"})}) + + return text, options + +``` + +This will produce a menu node looking like this: + + +``` +A goblin attacks you! +________________________________ + +Attack: Strike the enemy with all your might +Defend: Hold back and defend yourself + +``` + +##### option-key 'key' + +The option's `key` is what the user should enter in order to choose that option. If given as a tuple, the first string of that tuple will be what is shown on-screen while the rest are aliases for picking that option. In the above example, the user could enter "Attack" (or "attack", it's not case-sensitive), "a" or "att" in order to attack the goblin. Aliasing is useful for adding custom coloring to the choice. The first element of the aliasing tuple should then be the colored version, followed by a version without color - since otherwise the user would have to enter the color codes to select that choice. + +Note that the `key` is *optional*. If no key is given, it will instead automatically be replaced +with a running number starting from `1`. If removing the `key` part of each option, the resulting +menu node would look like this instead: + + +``` +A goblin attacks you! +________________________________ + +1: Strike the enemy with all your might +2: Hold back and defend yourself + +``` + +Whether you want to use a key or rely on numbers is mostly a matter of style and the type of menu. + +EvMenu accepts one important special `key` given only as `"_default"`. This key is used when a user enters something that does not match any other fixed keys. It is particularly useful for getting user input: + +```python +def node_readuser(caller, raw_string, **kwargs): + text = "Please enter your name" + + options = {"key": "_default", + "goto": "node_parse_input"} + + return text, options + +``` + +A `"_default"` option does not show up in the menu, so the above will just be a node saying +`"Please enter your name"`. The name they entered will appear as `raw_string` in the next node. + + +#### option-key 'desc' + +This simply contains the description as to what happens when selecting the menu option. For `"_default"` options or if the `key` is already long or descriptive, it is not strictly needed. But usually it's better to keep the `key` short and put more detail in `desc`. + + +#### option-key 'goto' + +This is the operational part of the option and fires only when the user chooses said option. Here are three ways to write it + +```python + +def _action_two(caller, raw_string, **kwargs): + # do things ... + return "calculated_node_to_go_to" + +def _action_three(caller, raw_string, **kwargs): + # do things ... + return "node_four", {"mode": 4} + +def node_select(caller, raw_string, **kwargs): + + text = ("select one", + "help - they all do different things ...") + + options = ({"desc": "Option one", + "goto": "node_one"}, + {"desc": "Option two", + "goto": _action_two}, + {"desc": "Option three", + "goto": (_action_three, {"key": 1, "key2": 2})} + ) + + return text, options + +``` + +As seen above, `goto` could just be pointing to a single `nodename` string - the name of the node to go to. When given like this, EvMenu will look for a node named like this and call its associated function as + +```python + nodename(caller, raw_string, **kwargs) +``` + +Here, `raw_string` is always the input the user entered to make that choice and `kwargs` are the same as those `kwargs` that already entered the *current* node (they are passed on). + +Alternatively the `goto` could point to a "goto-callable". Such callables are usually defined in the same module as the menu nodes and given names starting with `_` (to avoid being parsed as nodes themselves). These callables will be called the same as a node function - `callable(caller, raw_string, **kwargs)`, where `raw_string` is what the user entered on this node and `**kwargs` is forwarded from the node's own input. + +The `goto` option key could also point to a tuple `(callable, kwargs)` - this allows for customizing the kwargs passed into the goto-callable, for example you could use the same callable but change the kwargs passed into it depending on which option was actually chosen. + +The "goto callable" must either return a string `"nodename"` or a tuple `("nodename", mykwargs)`. This will lead to the next node being called as either `nodename(caller, raw_string, **kwargs)` or `nodename(caller, raw_string, **mykwargs)` - so this allows changing (or replacing) the options going into the next node depending on what option was chosen. + +There is one important case - if the goto-callable returns `None` for a `nodename`, *the current node will run again*, possibly with different kwargs. This makes it very easy to re-use a node over and over, for example allowing different options to update some text form being passed and manipulated for every iteration. + + +### Temporary storage + +When the menu starts, the EvMenu instance is stored on the caller as `caller.ndb._evmenu`. Through this object you can in principle reach the menu's internal state if you know what you are doing. This is also a good place to store temporary, more global variables that may be cumbersome to keep passing from node to node via the `**kwargs`. The `_evmnenu` will be deleted automatically when the menu closes, meaning you don't need to worry about cleaning anything up. + +If you want *permanent* state storage, it's instead better to use an Attribute on `caller`. Remember that this will remain after the menu closes though, so you need to handle any needed cleanup yourself. + + +### Customizing Menu formatting + +The `EvMenu` display of nodes, options etc are controlled by a series of formatting methods on the `EvMenu` class. To customize these, simply create a new child class of `EvMenu` and override as needed. Here is an example: + +```python +from evennia.utils.evmenu import EvMenu + +class MyEvMenu(EvMenu): + + def nodetext_formatter(self, nodetext): + """ + Format the node text itself. + + Args: + nodetext (str): The full node text (the text describing the node). + + Returns: + nodetext (str): The formatted node text. + + """ + + def helptext_formatter(self, helptext): + """ + Format the node's help text + + Args: + helptext (str): The unformatted help text for the node. + + Returns: + helptext (str): The formatted help text. + + """ + + def options_formatter(self, optionlist): + """ + Formats the option block. + + Args: + optionlist (list): List of (key, description) tuples for every + option related to this node. + caller (Object, Account or None, optional): The caller of the node. + + Returns: + options (str): The formatted option display. + + """ + + def node_formatter(self, nodetext, optionstext): + """ + Formats the entirety of the node. + + Args: + nodetext (str): The node text as returned by `self.nodetext_formatter`. + optionstext (str): The options display as returned by `self.options_formatter`. + caller (Object, Account or None, optional): The caller of the node. + + Returns: + node (str): The formatted node to display. + + """ + +``` +See `evennia/utils/evmenu.py` for the details of their default implementations. + +## EvMenu templating language + +In evmenu.py are two helper functions `parse_menu_template` and `template2menu` that is used to parse a _menu template_ string into an EvMenu: + + evmenu.template2menu(caller, menu_template, goto_callables) + +One can also do it in two steps, by generate a menutree and using that to call +EvMenu normally: + + menutree = evmenu.parse_menu_template(caller, menu_template, goto_callables) + EvMenu(caller, menutree) + +With this latter solution, one could mix and match normally created menu nodes +with those generated by the template engine. + +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: + +```python +menu_template = """ + +## node node1 + +Text for node + +## options + +key1: desc1 -> node2 +key2: desc2 -> node3 +key3: desc3 -> node4 +""" +``` + +Each menu node is defined by a `## node ` 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. + +Except for defining the node/options, `#` act as comments - everything following +will be ignored by the template parser. + +### Template Options + +The option syntax is + + : [desc ->] nodename or function-call + +The 'desc' part is optional, and if that is not given, the `->` can be skipped +too: + + key: nodename + +The key can both be strings and numbers. Separate the aliases with `;`. + + key: node1 + 1: node2 + key;k: node3 + foobar;foo;bar;f;b: node4 + +Starting the key with the special letter `>` indicates that what follows is a +glob/regex matcher. + + >: node1 - matches empty input + > foo*: node1 - everything starting with foo + > *foo: node3 - everything ending with foo + > [0-9]+?: node4 - regex (all numbers) + > *: node5 - catches everything else (put as last option) + +Here's how to call a goto-function from an option: + + key: desc -> myfunc(foo=bar) + +For this to work `template2menu` or `parse_menu_template` must be given a dict +that includes `{"myfunc": _actual_myfunc_callable}`. All callables to be +available in the template must be mapped this way. Goto callables act like +normal EvMenu goto-callables and should have a callsign of +`_actual_myfunc_callable(caller, raw_string, **kwargs)` and return the next node +(passing dynamic kwargs into the next node does not work with the template +- use the full EvMenu if you want advanced dynamic data passing). + +Only no or named keywords are allowed in these callables. So + + myfunc() # OK + myfunc(foo=bar) # OK + myfunc(foo) # error! + +This is because these properties are passed as `**kwargs` into the goto callable. + +### Templating example + +```python +from random import random +from evennia.utils import evmenu + +def _gamble(caller, raw_string, **kwargs): + + caller.msg("You roll the dice ...") + if random() < 0.5: + return "loose" + else: + return "win" + +template_string = """ + +## node start + +Death patiently holds out a set of bone dice to you. + +"ROLL" + +he says. + +## options + +1: Roll the dice -> gamble() +2: Try to talk yourself out of rolling -> start + +## node win + +The dice clatter over the stones. + +"LOOKS LIKE YOU WIN THIS TIME" + +says Death. + +# (this ends the menu since there are no options) + +## node loose + +The dice clatter over the stones. + +"YOUR LUCK RAN OUT" + +says Death. + +"YOU ARE COMING WITH ME." + +# (this ends the menu, but what happens next - who knows!) + +""" + +# map the in-template callable-name to real python code +goto_callables = {"gamble": _gamble} +# this starts the evmenu for the caller +evmenu.template2menu(caller, template_string, goto_callables) + +``` + +## Asking for one-line input + +This describes two ways for asking for simple questions from the user. Using Python's `input` +will *not* work in Evennia. `input` will *block* the entire server for *everyone* until that one +player has entered their text, which is not what you want. + +### The `yield` way + +In the `func` method of your Commands (only) you can use Python's built-in `yield` command to +request input in a similar way to `input`. It looks like this: + +```python +result = yield("Please enter your answer:") +``` + +This will send "Please enter your answer" to the Command's `self.caller` and then pause at that +point. All other players at the server will be unaffected. Once caller enteres a reply, the code +execution will continue and you can do stuff with the `result`. Here is an example: + +```python +from evennia import Command +class CmdTestInput(Command): + key = "test" + def func(self): + result = yield("Please enter something:") + self.caller.msg(f"You entered {result}.") + result2 = yield("Now enter something else:") + self.caller.msg(f"You now entered {result2}.") +``` + +Using `yield` is simple and intuitive, but it will only access input from `self.caller` and you +cannot abort or time out the pause until the player has responded. Under the hood, it is actually +just a wrapper calling `get_input` described in the following section. + +> Important Note: In Python you *cannot mix `yield` and `return ` in the same method*. It has +> to do with `yield` turning the method into a +> [generator](https://www.learnpython.org/en/Generators). A `return` without an argument works, you +> can just not do `return `. This is usually not something you need to do in `func()` anyway, +> but worth keeping in mind. + +### The `get_input` way + +The evmenu module offers a helper function named `get_input`. This is wrapped by the `yield` +statement which is often easier and more intuitive to use. But `get_input` offers more flexibility +and power if you need it. While in the same module as `EvMenu`, `get_input` is technically unrelated +to it. The `get_input` allows you to ask and receive simple one-line input from the user without +launching the full power of a menu to do so. To use, call `get_input` like this: + +```python +get_input(caller, prompt, callback) +``` + +Here `caller` is the entity that should receive the prompt for input given as `prompt`. The +`callback` is a callable `function(caller, prompt, user_input)` that you define to handle the answer +from the user. When run, the caller will see `prompt` appear on their screens and *any* text they +enter will be sent into the callback for whatever processing you want. + +Below is a fully explained callback and example call: + +```python +from evennia import Command +from evennia.utils.evmenu import get_input + +def callback(caller, prompt, user_input): + """ + This is a callback you define yourself. + + Args: + caller (Account or Object): The one being asked + for input + prompt (str): A copy of the current prompt + user_input (str): The input from the account. + + Returns: + repeat (bool): If not set or False, exit the + input prompt and clean up. If returning anything + True, stay in the prompt, which means this callback + will be called again with the next user input. + """ + caller.msg(f"When asked '{prompt}', you answered '{user_input}'.") + +get_input(caller, "Write something! ", callback) +``` + +This will show as + +``` +Write something! +> Hello +When asked 'Write something!', you answered 'Hello'. + +``` + +Normally, the `get_input` function quits after any input, but as seen in the example docs, you could +return True from the callback to repeat the prompt until you pass whatever check you want. + +> Note: You *cannot* link consecutive questions by putting a new `get_input` call inside the +> callback If you want that you should use an EvMenu instead (see the [Repeating the same +> node](./EvMenu.md#example-repeating-the-same-node) example above). Otherwise you can either peek at the +> implementation of `get_input` and implement your own mechanism (it's just using cmdset nesting) or +> you can look at [this extension suggested on the mailing +> list](https://groups.google.com/forum/#!category-topic/evennia/evennia-questions/16pi0SfMO5U). + + +#### Example: Yes/No prompt + +Below is an example of a Yes/No prompt using the `get_input` function: + +```python +def yesno(caller, prompt, result): + if result.lower() in ("y", "yes", "n", "no"): + # do stuff to handle the yes/no answer + # ... + # if we return None/False the prompt state + # will quit after this + else: + # the answer is not on the right yes/no form + caller.msg("Please answer Yes or No. \n{prompt}") +@ # returning True will make sure the prompt state is not exited + return True + +# ask the question +get_input(caller, "Is Evennia great (Yes/No)?", yesno) +``` + +## The `@list_node` decorator + +The `evennia.utils.evmenu.list_node` is an advanced decorator for use with `EvMenu` node functions. +It is used to quickly create menus for manipulating large numbers of items. + + +``` +text here +______________________________________________ + +1. option1 7. option7 13. option13 +2. option2 8. option8 14. option14 +3. option3 9. option9 [p]revius page +4. option4 10. option10 page 2 +5. option5 11. option11 [n]ext page +6. option6 12. option12 + +``` + +The menu will automatically create an multi-page option listing that one can flip through. One can +inpect each entry and then select them with prev/next. This is how it is used: + + +```python +from evennia.utils.evmenu import list_node + + +... + +_options(caller): + return ['option1', 'option2', ... 'option100'] + +_select(caller, menuchoice, available_choices): + # analyze choice + return "next_node" + +@list_node(options, select=_select, pagesize=10) +def node_mylist(caller, raw_string, **kwargs): + ... + + return text, options + +``` + +The `options` argument to `list_node` is either a list, a generator or a callable returning a list +of strings for each option that should be displayed in the node. + +The `select` is a callable in the example above but could also be the name of a menu node. If a +callable, the `menuchoice` argument holds the selection done and `available_choices` holds all the +options available. The callable should return the menu to go to depending on the selection (or +`None` to rerun the same node). If the name of a menu node, the selection will be passed as +`selection` kwarg to that node. + +The decorated node itself should return `text` to display in the node. It must return at least an +empty dictionary for its options. It returning options, those will supplement the options +auto-created by the `list_node` decorator. + +## Example Menus + +Here is a diagram to help visualize the flow of data from node to node, including goto-callables in-between: + +``` + ┌─ + │ def nodeA(caller, raw_string, **kwargs): + │ text = "Choose how to operate on 2 and 3." + │ options = ( + │ { + │ "key": "A", + │ "desc": "Multiply 2 with 3", + │ "goto": (_callback, {"type": "mult", "a": 2, "b": 3}) + │ }, ───────────────────┬──────────── + │ { │ + │ "key": "B", └───────────────┐ + │ "desc": "Add 2 and 3", │ + Node A│ "goto": (_callback, {"type": "add", "a": 2, "b": 3}) │ + │ }, ─────────────────┬───────────── │ + │ { │ │ + │ "key": "C", │ │ + │ "desc": "Show the value 5", │ │ + │ "goto": ("node_B", {"c": 5}) │ │ + │ } ───────┐ │ │ + │ ) └──────────┼─────────────────┼───┐ + │ return text, options │ │ │ + └─ ┌──────────┘ │ │ + │ │ │ + │ ┌──────────────────────────┘ │ + ┌─ ▼ ▼ │ + │ def _callback(caller, raw_string, **kwargs): │ + │ if kwargs["type"] == "mult": │ + │ return "node_B", {"c": kwargs["a"] * kwargs["b"]} │ +Goto- │ ───────────────┬──────────────── │ +callable│ │ │ + │ └───────────────────┐ │ + │ │ │ + │ elif kwargs["type"] == "add": │ │ + │ return "node_B", {"c": kwargs["a"] + kwargs["b"]} │ │ + └─ ────────┬─────────────────────── │ │ + │ │ │ + │ ┌────────────────────────┼──────────┘ + │ │ │ + │ │ ┌──────────────────────┘ + ┌─ ▼ ▼ ▼ + │ def nodeB(caller, raw_string, **kwargs): + Node B│ text = "Result of operation: " + kwargs["c"] + │ return text, {} + └─ + + ┌─ + Menu │ EvMenu(caller, {"node_A": nodeA, "node_B": nodeB}, startnode="node_A") + Start│ + └─ +``` + +Above we create a very simple/stupid menu (in the `EvMenu` call at the end) where we map the node identifier `"node_A"` to the Python function `nodeA` and `"node_B"` to the function `nodeB`. + +We start the menu in `"node_A"` where we get three options A, B and C. Options A and B will route via a a goto-callable `_callback` that either multiples or adds the numbers 2 and 3 together before continuing to `"node_B"`. Option C routes directly to `"node_B"`, passing the number 5. + +In every step, we pass a dict which becomes the ingoing `**kwargs` in the next step. If we didn't pass anything (it's optional), the next step's `**kwargs` would just be empty. + +More examples: + +- **[Simple branching menu](./EvMenu.md#example-simple-branching-menu)** - choose from options +- **[Dynamic goto](./EvMenu.md#example-dynamic-goto)** - jumping to different nodes based on response +- **[Set caller properties](./EvMenu.md#example-set-caller-properties)** - a menu that changes things +- **[Getting arbitrary input](./EvMenu.md#example-get-arbitrary-input)** - entering text +- **[Storing data between nodes](./EvMenu.md#example-storing-data-between-nodes)** - keeping states and +information while in the menu +- **[Repeating the same node](./EvMenu.md#example-repeating-the-same-node)** - validating within the node +before moving to the next +- **[Yes/No prompt](#example-yesno-prompt)** - entering text with limited possible responses +(this is *not* using EvMenu but the conceptually similar yet technically unrelated `get_input` +helper function accessed as `evennia.utils.evmenu.get_input`). + + +### Example: Simple branching menu + +Below is an example of a simple branching menu node leading to different other nodes depending on choice: + +```python +# in mygame/world/mychargen.py + +def define_character(caller): + text = \ + """ + What aspect of your character do you want + to change next? + """ + options = ({"desc": "Change the name", + "goto": "set_name"}, + {"desc": "Change the description", + "goto": "set_description"}) + return text, options + +EvMenu(caller, "world.mychargen", startnode="define_character") + +``` + +This will result in the following node display: + +``` +What aspect of your character do you want +to change next? +_________________________ +1: Change the name +2: Change the description +``` + +Note that since we didn't specify the "name" key, EvMenu will let the user enter numbers instead. In +the following examples we will not include the `EvMenu` call but just show nodes running inside the +menu. Also, since `EvMenu` also takes a dictionary to describe the menu, we could have called it +like this instead in the example: + +```python +EvMenu(caller, {"define_character": define_character}, startnode="define_character") + +``` + +### Example: Dynamic goto + +```python + +def _is_in_mage_guild(caller, raw_string, **kwargs): + if caller.tags.get('mage', category="guild_member"): + return "mage_guild_welcome" + else: + return "mage_guild_blocked" + +def enter_guild: + text = 'You say to the mage guard:' + options ({'desc': 'I need to get in there.', + 'goto': _is_in_mage_guild}, + {'desc': 'Never mind', + 'goto': 'end_conversation'}) + return text, options +``` + +This simple callable goto will analyse what happens depending on who the `caller` is. The +`enter_guild` node will give you a choice of what to say to the guard. If you try to enter, you will +end up in different nodes depending on (in this example) if you have the right [Tag](./Tags.md) set on +yourself or not. Note that since we don't include any 'key's in the option dictionary, you will just +get to pick between numbers. + +### Example: Set caller properties + +Here is an example of passing arguments into the `goto` callable and use that to influence +which node it should go to next: + +```python + +def _set_attribute(caller, raw_string, **kwargs): + "Get which attribute to modify and set it" + + attrname, value = kwargs.get("attr", (None, None)) + next_node = kwargs.get("next_node") + + caller.attributes.add(attrname, attrvalue) + + return next_node + + +def node_background(caller): + text = \ + f""" + {caller.key} experienced a traumatic event + in their childhood. What was it? + """ + + options = ({"key": "death", + "desc": "A violent death in the family", + "goto": (_set_attribute, {"attr": ("experienced_violence", True), + "next_node": "node_violent_background"})}, + {"key": "betrayal", + "desc": "The betrayal of a trusted grown-up", + "goto": (_set_attribute, {"attr": ("experienced_betrayal", True), + "next_node": "node_betrayal_background"})}) + return text, options +``` + +This will give the following output: + +``` +Kovash the magnificent experienced a traumatic event +in their childhood. What was it? +____________________________________________________ +death: A violent death in the family +betrayal: The betrayal of a trusted grown-up + +``` + +Note above how we use the `_set_attribute` helper function to set the attribute depending on the +User's choice. In thie case the helper function doesn't know anything about what node called it - we +even tell it which nodename it should return, so the choices leads to different paths in the menu. +We could also imagine the helper function analyzing what other choices + + +### Example: Get arbitrary input + +An example of the menu asking the user for input - any input. + +```python + +def _set_name(caller, raw_string, **kwargs): + + inp = raw_string.strip() + + prev_entry = kwargs.get("prev_entry") + + if not inp: + # a blank input either means OK or Abort + if prev_entry: + caller.key = prev_entry + caller.msg(f"Set name to {prev_entry}.") + return "node_background" + else: + caller.msg("Aborted.") + return "node_exit" + else: + # re-run old node, but pass in the name given + return None, {"prev_entry": inp} + + +def enter_name(caller, raw_string, **kwargs): + + # check if we already entered a name before + prev_entry = kwargs.get("prev_entry") + + if prev_entry: + text = "Current name: {}.\nEnter another name or to accept." + else: + text = "Enter your character's name or to abort." + + options = {"key": "_default", + "goto": (_set_name, {"prev_entry": prev_entry})} + + return text, options + +``` + +This will display as + +``` +Enter your character's name or to abort. + +> Gandalf + +Current name: Gandalf +Enter another name or to accept. + +> + +Set name to Gandalf. + +``` + +Here we re-use the same node twice for reading the input data from the user. Whatever we enter will +be caught by the `_default` option and passed into the helper function. We also pass along whatever +name we have entered before. This allows us to react correctly on an "empty" input - continue to the +node named `"node_background"` if we accept the input or go to an exit node if we presses Return +without entering anything. By returning `None` from the helper function we automatically re-run the +previous node, but updating its ingoing kwargs to tell it to display a different text. + + + +### Example: Storing data between nodes + +A convenient way to store data is to store it on the `caller.ndb._evmenu` which you can reach from +every node. The advantage of doing this is that the `_evmenu` NAttribute will be deleted +automatically when you exit the menu. + +```python + +def _set_name(caller, raw_string, **kwargs): + + caller.ndb._evmenu.charactersheet = {} + caller.ndb._evmenu.charactersheet['name'] = raw_string + caller.msg(f"You set your name to {raw_string}") + return "background" + +def node_set_name(caller): + text = 'Enter your name:' + options = {'key': '_default', + 'goto': _set_name} + + return text, options + +... + + +def node_view_sheet(caller): + text = f"Character sheet:\n {self.ndb._evmenu.charactersheet}" + + options = ({"key": "Accept", + "goto": "finish_chargen"}, + {"key": "Decline", + "goto": "start_over"}) + + return text, options + +``` + +Instead of passing the character sheet along from node to node through the `kwargs` we instead +set it up temporarily on `caller.ndb._evmenu.charactersheet`. This makes it easy to reach from +all nodes. At the end we look at it and, if we accept the character the menu will likely save the +result to permanent storage and exit. + +> One point to remember though is that storage on `caller.ndb._evmenu` is not persistent across +> `@reloads`. If you are using a persistent menu (using `EvMenu(..., persistent=True)` you should +use +> `caller.db` to store in-menu data like this as well. You must then yourself make sure to clean it +> when the user exits the menu. + + +### Example: Repeating the same node + +Sometimes you want to make a chain of menu nodes one after another, but you don't want the user to be able to continue to the next node until you have verified that what they input in the previous node is ok. A common example is a login menu: + + +```python + +def _check_username(caller, raw_string, **kwargs): + # we assume lookup_username() exists + if not lookup_username(raw_string): + # re-run current node by returning `None` + caller.msg("|rUsername not found. Try again.") + return None + else: + # username ok - continue to next node + return "node_password" + + +def node_username(caller): + text = "Please enter your user name." + options = {"key": "_default", + "goto": _check_username} + return text, options + + +def _check_password(caller, raw_string, **kwargs): + + nattempts = kwargs.get("nattempts", 0) + if nattempts > 3: + caller.msg("Too many failed attempts. Logging out") + return "node_abort" + elif not validate_password(raw_string): + caller.msg("Password error. Try again.") + return None, {"nattempts", nattempts + 1} + else: + # password accepted + return "node_login" + +def node_password(caller, raw_string, **kwargs): + text = "Enter your password." + options = {"key": "_default", + "goto": _check_password} + return text, options + +``` + +This will display something like + + +``` +--------------------------- +Please enter your username. +--------------------------- + +> Fo + +------------------------------ +Username not found. Try again. +______________________________ +abort: (back to start) +------------------------------ + +> Foo + +--------------------------- +Please enter your password. +--------------------------- + +> Bar + +-------------------------- +Password error. Try again. +-------------------------- +``` + +And so on. + +Here the goto-callables will return to the previous node if there is an error. In the case of +password attempts, this will tick up the `nattempts` argument that will get passed on from iteration +to iteration until too many attempts have been made. + + +### Defining nodes in a dictionary + +You can also define your nodes directly in a dictionary to feed into the `EvMenu` creator. + +```python +def mynode(caller): + # a normal menu node function + return text, options + +menu_data = {"node1": mynode, + "node2": lambda caller: ( + "This is the node text", + ({"key": "lambda node 1", + "desc": "go to node 1 (mynode)", + "goto": "node1"}, + {"key": "lambda node 2", + "desc": "go to thirdnode", + "goto": "node3"})), + "node3": lambda caller, raw_string: ( + # ... etc ) } + +# start menu, assuming 'caller' is available from earlier +EvMenu(caller, menu_data, startnode="node1") + +``` + +The keys of the dictionary become the node identifiers. You can use any callable on the right form +to describe each node. If you use Python `lambda` expressions you can make nodes really on the fly. +If you do, the lambda expression must accept one or two arguments and always return a tuple with two +elements (the text of the node and its options), same as any menu node function. + +Creating menus like this is one way to present a menu that changes with the circumstances - you +could for example remove or add nodes before launching the menu depending on some criteria. The +drawback is that a `lambda` expression [is much more +limited](https://docs.python.org/2/tutorial/controlflow.html#lambda-expressions) than a full +function - for example you can't use other Python keywords like `if` inside the body of the +`lambda`. + +Unless you are dealing with a relatively simple dynamic menu, defining menus with lambda's is +probably more work than it's worth: You can create dynamic menus by instead making each node +function more clever. See the [NPC shop tutorial](../Howtos/Tutorial-NPC-Merchants.md) for an example of this. diff --git a/docs/latest/_sources/Components/EvMore.md.txt b/docs/latest/_sources/Components/EvMore.md.txt new file mode 100644 index 0000000000..2048e623b7 --- /dev/null +++ b/docs/latest/_sources/Components/EvMore.md.txt @@ -0,0 +1,35 @@ +# EvMore + + +When sending a very long text to a user client, it might scroll beyond of the height of the client +window. The `evennia.utils.evmore.EvMore` class gives the user the in-game ability to only view one +page of text at a time. It is usually used via its access function, `evmore.msg`. + +The name comes from the famous unix pager utility *more* which performs just this function. + +To use the pager, just pass the long text through it: + +```python +from evennia.utils import evmore + +evmore.msg(receiver, long_text) +``` +Where receiver is an [Object](./Objects.md) or a [Account](./Accounts.md). If the text is longer than the +client's screen height (as determined by the NAWS handshake or by `settings.CLIENT_DEFAULT_HEIGHT`) +the pager will show up, something like this: + +>[...] +aute irure dolor in reprehenderit in voluptate velit +esse cillum dolore eu fugiat nulla pariatur. Excepteur +sint occaecat cupidatat non proident, sunt in culpa qui +officia deserunt mollit anim id est laborum. + +>(**more** [1/6] retur**n**|**b**ack|**t**op|**e**nd|**a**bort) + + +where the user will be able to hit the return key to move to the next page, or use the suggested +commands to jump to previous pages, to the top or bottom of the document as well as abort the +paging. + +The pager takes several more keyword arguments for controlling the message output. See the +[evmore-API](github:evennia.utils.evmore) for more info. diff --git a/docs/latest/_sources/Components/EvTable.md.txt b/docs/latest/_sources/Components/EvTable.md.txt new file mode 100644 index 0000000000..18f8f64128 --- /dev/null +++ b/docs/latest/_sources/Components/EvTable.md.txt @@ -0,0 +1,3 @@ +# EvTable + +[Docstring in evennia/utils/evtable.py](evennia.utils.evtable) \ No newline at end of file diff --git a/docs/latest/_sources/Components/Exits.md.txt b/docs/latest/_sources/Components/Exits.md.txt new file mode 100644 index 0000000000..02a6f73a2a --- /dev/null +++ b/docs/latest/_sources/Components/Exits.md.txt @@ -0,0 +1,61 @@ +# Exits + +**Inheritance Tree:** +``` +┌─────────────┐ +│DefaultObject│ +└─────▲───────┘ + │ +┌─────┴─────┐ +│DefaultExit│ +└─────▲─────┘ + │ ┌────────────┐ + │ ┌─────►ObjectParent│ + │ │ └────────────┘ + ┌─┴─┴┐ + │Exit│ + └────┘ +``` + +*Exits* are in-game [Objects](./Objects.md) connecting other objects (usually [Rooms](./Rooms.md)) together. + +> Note that Exits are one-way objects, so in order for two Rooms to be linked bi-directionally, there will need to be two exits. + +An object named `north` or `in` might be exits, as well as `door`, `portal` or `jump out the window`. + +An exit has two things that separate them from other objects. +1. Their `.destination` property is set and points to a valid target location. This fact makes it easy and fast to locate exits in the database. +2. Exits define a special [Transit Command](./Commands.md) on themselves when they are created. This command is named the same as the exit object and will, when called, handle the practicalities of moving the character to the Exits's `.destination` - this allows you to just enter the name of the exit on its own to move around, just as you would expect. + +The default exit functionality is all defined on the [DefaultExit](DefaultExit) typeclass. You could in principle completely change how exits work in your game by overriding this - it's not recommended though, unless you really know what you are doing). + +Exits are [locked](./Locks.md) using an `access_type` called *traverse* and also make use of a few hook methods for giving feedback if the traversal fails. See `evennia.DefaultExit` for more info. + +Exits are normally overridden on a case-by-case basis, but if you want to change the default exit created by rooms like `dig`, `tunnel` or `open` you can change it in settings: + + BASE_EXIT_TYPECLASS = "typeclasses.exits.Exit" + +In `mygame/typeclasses/exits.py` there is an empty `Exit` class for you to modify. + +### Exit details + +The process of traversing an exit is as follows: + +1. The traversing `obj` sends a command that matches the Exit-command name on the Exit object. The [cmdhandler](./Commands.md) detects this and triggers the command defined on the Exit. Traversal always involves the "source" (the current location) and the `destination` (this is stored on the Exit object). +1. The Exit command checks the `traverse` lock on the Exit object +1. The Exit command triggers `at_traverse(obj, destination)` on the Exit object. +1. In `at_traverse`, `object.move_to(destination)` is triggered. This triggers the following hooks, in order: + 1. `obj.at_pre_move(destination)` - if this returns False, move is aborted. + 1. `origin.at_pre_leave(obj, destination)` + 1. `obj.announce_move_from(destination)` + 1. Move is performed by changing `obj.location` from source location to `destination`. + 1. `obj.announce_move_to(source)` + 1. `destination.at_object_receive(obj, source)` + 1. `obj.at_post_move(source)` +1. On the Exit object, `at_post_traverse(obj, source)` is triggered. + +If the move fails for whatever reason, the Exit will look for an Attribute `err_traverse` on itself and display this as an error message. If this is not found, the Exit will instead call `at_failed_traverse(obj)` on itself. + +### Creating Exits in code + +For an example of how to create Exits programatically please see [this guide](../Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Creating-Things.md#linking-exits-and-rooms-in-code). diff --git a/docs/latest/_sources/Components/FuncParser.md.txt b/docs/latest/_sources/Components/FuncParser.md.txt new file mode 100644 index 0000000000..05aee2d509 --- /dev/null +++ b/docs/latest/_sources/Components/FuncParser.md.txt @@ -0,0 +1,396 @@ +# FuncParser inline text parsing + +The [FuncParser](evennia.utils.funcparser.FuncParser) extracts and executes 'inline functions' embedded in a string on the form `$funcname(args, kwargs)`, executes the matching 'inline function' and replaces the call with the return from the call. + +To test it, let's tell Evennia to apply the Funcparser on every outgoing message. This is disabled by default (not everyone needs this functionality). To activate, add to your settings file: + + FUNCPARSER_PARSE_OUTGOING_MESSAGES_ENABLED = True + +After a reload, you can try this in-game + +```shell +> say I got $randint(1,5) gold! +You say "I got 3 gold!" +``` + +To escape the inlinefunc (e.g. to explain to someone how it works, use `$$`) + +```{shell} +> say To get a random value from 1 to 5, use $$randint(1,5). +You say "To get a random value from 1 to 5, use $randint(1,5)." +``` + +While `randint` may look and work just like `random.randint` from the standard Python library, it is _not_. Instead it's a `inlinefunc` named `randint` made available to Evennia (which in turn uses the standard library function). For security reasons, only functions explicitly assigned to be used as inlinefuncs are viable. + +You can apply the `FuncParser` manually. The parser is initialized with the inlinefunc(s) it's supposed to recognize in that string. Below is an example of a parser only understanding a single `$pow` inlinefunc: + +```python +from evennia.utils.funcparser import FuncParser + +def _power_callable(*args, **kwargs): + """This will be callable as $pow(number, power=) in string""" + pow = int(kwargs.get('power', 2)) + return float(args[0]) ** pow + +# create a parser and tell it that '$pow' means using _power_callable +parser = FuncParser({"pow": _power_callable}) + +``` +Next, just pass a string into the parser, containing `$func(...)` markers: + +```python +parser.parse("We have that 4 x 4 x 4 is $pow(4, power=3).") +"We have that 4 x 4 x 4 is 64." +``` + +Normally the return is always converted to a string but you can also get the actual data type from the call: + +```python +parser.parse_to_any("$pow(4)") +16 +``` + +You don't have to define all your inline functions from scratch. In `evennia.utils.funcparser` you'll find ready-made dicts of inline-funcs you can import and plug into your parsers. See [default funcparser callables](#default-funcparser-callables) below for the defails. + +## Working with FuncParser + +The FuncParser can be applied to any string. Out of the box it's applied in a few situations: + +- _Outgoing messages_. All messages sent from the server is processed through FuncParser and every callable is provided the [Session](./Sessions.md) of the object receiving the message. This potentially allows a message to be modified on the fly to look different for different recipients. +- _Prototype values_. A [Prototype](./Prototypes.md) dict's values are run through the parser such that every callable gets a reference to the rest of the prototype. In the Prototype ORM, this would allow builders to safely call functions to set non-string values to prototype values, get random values, reference + other fields of the prototype, and more. +- _Actor-stance in messages to others_. In the [Object.msg_contents](evennia.objects.objects.DefaultObject.msg_contents) method, the outgoing string is parsed for special `$You()` and `$conj()` callables to decide if a given recipient + should see "You" or the character's name. + +```{important} + +The inline-function parser is not intended as a 'softcode' programming language. It does not have things like loops and conditionals, for example. While you could in principle extend it to do very advanced things and allow builders a lot of power, all-out coding is something Evennia expects you to do in a proper text editor, outside of the game, not from inside it. +``` + +You can apply inline function parsing to any string. The +[FuncParser](evennia.utils.funcparser.FuncParser) is imported as `evennia.utils.funcparser`. + +```python +from evennia.utils import funcparser + +parser = FuncParser(callables, **default_kwargs) +parsed_string = parser.parse(input_string, raise_errors=False, + escape=False, strip=False, + return_str=True, **reserved_kwargs) + +# callables can also be passed as paths to modules +parser = FuncParser(["game.myfuncparser_callables", "game.more_funcparser_callables"]) +``` + +Here, `callables` points to a collection of normal Python functions (see next section) for you to make +available to the parser as you parse strings with it. It can either be +- A `dict` of `{"functionname": callable, ...}`. This allows you do pick and choose exactly which callables + to include and how they should be named. Do you want a callable to be available under more than one name? + Just add it multiple times to the dict, with a different key. +- A `module` or (more commonly) a `python-path` to a module. This module can define a dict + `FUNCPARSER_CALLABLES = {"funcname": callable, ...}` - this will be imported and used like the `dict` above. + If no such variable is defined, _every_ top-level function in the module (whose name doesn't start with + an underscore `_`) will be considered a suitable callable. The name of the function will be the `$funcname` + by which it can be called. +- A `list` of modules/paths. This allows you to pull in modules from many sources for your parsing. +- The `**default` kwargs are optional kwargs that will be passed to _all_ + callables every time this parser is used - unless the user overrides it explicitly in + their call. This is great for providing sensible standards that the user can + tweak as needed. + +`FuncParser.parse` takes further arguments, and can vary for every string parsed. + +- `raise_errors` - By default, any errors from a callable will be quietly ignored and the result + will be that the failing function call will show verbatim. If `raise_errors` is set, + then parsing will stop and whatever exception happened will be raised. It'd be up to you to handle + this properly. +- `escape` - Returns a string where every `$func(...)` has been escaped as `\$func()`. +- `strip` - Remove all `$func(...)` calls from string (as if each returned `''`). +- `return_str` - When `True` (default), `parser` always returns a string. If `False`, it may return + the return value of a single function call in the string. This is the same as using the `.parse_to_any` + method. +- The `**reserved_keywords` are _always_ passed to every callable in the string. + They override any `**defaults` given when instantiating the parser and cannot + be overridden by the user - if they enter the same kwarg it will be ignored. + This is great for providing the current session, settings etc. +- The `funcparser` and `raise_errors` + are always added as reserved keywords - the first is a + back-reference to the `FuncParser` instance and the second + is the `raise_errors` boolean given to `FuncParser.parse`. + +Here's an example of using the default/reserved keywords: + +```python +def _test(*args, **kwargs): + # do stuff + return something + +parser = funcparser.FuncParser({"test": _test}, mydefault=2) +result = parser.parse("$test(foo, bar=4)", myreserved=[1, 2, 3]) +``` +Here the callable will be called as + +```python +_test('foo', bar='4', mydefault=2, myreserved=[1, 2, 3], + funcparser=, raise_errors=False) +``` + +The `mydefault=2` kwarg could be overwritten if we made the call as `$test(mydefault=...)` but `myreserved=[1, 2, 3]` will _always_ be sent as-is and will override a call `$test(myreserved=...)`. +The `funcparser`/`raise_errors` kwargs are also always included as reserved kwargs. + +## Defining custom callables + +All callables made available to the parser must have the following signature: + +```python +def funcname(*args, **kwargs): + # ... + return something +``` + +> The `*args` and `**kwargs` must always be included. If you are unsure how `*args` and `**kwargs` work in Python, [read about them here](https://www.digitalocean.com/community/tutorials/how-to-use-args-and-kwargs-in-python-3). + +The input from the innermost `$funcname(...)` call in your callable will always be a `str`. Here's +an example of an `$toint` function; it converts numbers to integers. + + "There's a $toint(22.0)% chance of survival." + +What will enter the `$toint` callable (as `args[0]`) is the _string_ `"22.0"`. The function is responsible for converting this to a number so that we can convert it to an integer. We must also properly handle invalid inputs (like non-numbers). + +If you want to mark an error, raise `evennia.utils.funcparser.ParsingError`. This stops the entire parsing of the string and may or may not raise the exception depending on what you set `raise_errors` to when you created the parser. + +However, if you _nest_ functions, the return of the innermost function may be something other than +a string. Let's introduce the `$eval` function, which evaluates simple expressions using +Python's `literal_eval` and/or `simple_eval`. It returns whatever data type it +evaluates to. + + "There's a $toint($eval(10 * 2.2))% chance of survival." + +Since the `$eval` is the innermost call, it will get a string as input - the string `"10 * 2.2"`. +It evaluates this and returns the `float` `22.0`. This time the outermost `$toint` will be called with +this `float` instead of with a string. + +> It's important to safely validate your inputs since users may end up nesting your callables in any order. See the next section for useful tools to help with this. + +In these examples, the result will be embedded in the larger string, so the result of the entire parsing will be a string: + +```python + parser.parse(above_string) + "There's a 22% chance of survival." +``` + +However, if you use the `parse_to_any` (or `parse(..., return_str=False)`) and _don't add any extra string around the outermost function call_, you'll get the return type of the outermost callable back: + +```python +parser.parse_to_any("$toint($eval(10 * 2.2)") +22 +parser.parse_to_any("the number $toint($eval(10 * 2.2).") +"the number 22" +parser.parse_to_any("$toint($eval(10 * 2.2)%") +"22%" +``` + +### Escaping special character + +When entering funcparser callables in strings, it looks like a regular +function call inside a string: + +```python +"This is a $myfunc(arg1, arg2, kwarg=foo)." +``` + +Commas (`,`) and equal-signs (`=`) are considered to separate the arguments and +kwargs. In the same way, the right parenthesis (`)`) closes the argument list. +Sometimes you want to include commas in the argument without it breaking the +argument list. + +```python +"The $format(forest's smallest meadow, with dandelions) is to the west." +``` + +You can escape in various ways. + +- Prepending special characters like `,` and `=` with the escape character `\` + +```python +"The $format(forest's smallest meadow\, with dandelions) is to the west." +``` + +- Wrapping your strings in double quotes. Unlike in raw Python, you +can't escape with single quotes `'` since these could also be apostrophes (like +`forest's` above). The result will be a verbatim string that contains +everything but the outermost double quotes. + +```python +'The $format("forest's smallest meadow, with dandelions") is to the west.' +``` +- If you want verbatim double-quotes to appear in your string, you can escape + them with `\"` in turn. + +```python +'The $format("forest's smallest meadow, with \"dandelions\"') is to the west.' +``` + +### Safe convertion of inputs + +Since you don't know in which order users may use your callables, they should +always check the types of its inputs and convert to the type the callable needs. +Note also that when converting from strings, there are limits what inputs you +can support. This is because FunctionParser strings can be used by +non-developer players/builders and some things (such as complex +classes/callables etc) are just not safe/possible to convert from string +representation. + +In `evennia.utils.utils` is a helper called [safe_convert_to_types](evennia.utils.utils.safe_convert_to_types). This function automates the conversion of simple data types in a safe way: + +```python +from evennia.utils.utils import safe_convert_to_types + +def _process_callable(*args, **kwargs): + """ + $process(expression, local, extra1=34, extra2=foo) + + """ + args, kwargs = safe_convert_to_type( + (('py', str), {'extra1': int, 'extra2': str}), + *args, **kwargs) + + # args/kwargs should be correct types now + +``` + +In other words, in the callable `$process(expression, local, extra1=.., extra2=...)`, the first argument will be handled by the 'py' converter (described below), the second will passed through regular Python `str`, kwargs will be handled by `int` and `str` respectively. You can supply your own converter function as long as it takes one argument and returns the converted result. + +```python +args, kwargs = safe_convert_to_type( + (tuple_of_arg_converters, dict_of_kwarg_converters), *args, **kwargs) +``` + +The special converter `"py"` will try to convert a string argument to a Python structure with the help of the following tools (which you may also find useful to experiment with on your own): + +- [ast.literal_eval](https://docs.python.org/3.8/library/ast.html#ast.literal_eval) is an in-built Python function. It _only_ supports strings, bytes, numbers, tuples, lists, dicts, sets, booleans and `None`. That's it - no arithmetic or modifications of data is allowed. This is good for converting individual values and lists/dicts from the input line to real Python objects. +- [simpleeval](https://pypi.org/project/simpleeval/) is a third-party tool included with Evennia. This allows for evaluation of simple (and thus safe) expressions. One can operate on numbers and strings with `+-/*` as well as do simple comparisons like `4 > 3` and more. It does _not_ accept more complex containers like lists/dicts etc, so this and `literal_eval` are complementary to each other. + +```{warning} +It may be tempting to run use Python's in-built ``eval()`` or ``exec()`` functions as converters since these are able to convert any valid Python source code to Python. NEVER DO THIS unless you really, really know that ONLY developers will ever modify the string going into the callable. The parser is intended for untrusted users (if you were trusted you'd have access to Python already). Letting untrusted users pass strings to ``eval``/``exec`` is a MAJOR security risk. It allows the caller to run arbitrary Python code on your server. This is the path to maliciously deleted hard drives. Just don't do it and sleep better at night. +``` + +## Default funcparser callables + +These are some example callables you can import and add your parser. They are divided into global-level dicts in `evennia.utils.funcparser`. Just import the dict(s) and merge/add one or more to them when you create your `FuncParser` instance to have those callables be available. + +### `evennia.utils.funcparser.FUNCPARSER_CALLABLES` + +These are the 'base' callables. + +- `$eval(expression)` ([code](evennia.utils.funcparser.funcparser_callable_eval)) - this uses `literal_eval` and `simple_eval` (see previous section) attemt to convert a string expression to a python object. This handles e.g. lists of literals `[1, 2, 3]` and simple expressions like `"1 + 2"`. +- `$toint(number)` ([code](evennia.utils.funcparser.funcparser_callable_toint)) - always converts an output to an integer, if possible. +- `$add/sub/mult/div(obj1, obj2)` ([code](evennia.utils.funcparser.funcparser_callable_add)) - + this adds/subtracts/multiplies and divides to elements together. While simple addition could be done with `$eval`, this could for example be used also to add two lists together, which is not possible with `eval`; for example `$add($eval([1,2,3]), $eval([4,5,6])) -> [1, 2, 3, 4, 5, 6]`. +- `$round(float, significant)` ([code](evennia.utils.funcparser.funcparser_callable_round)) - rounds an input float into the number of provided significant digits. For example `$round(3.54343, 3) -> 3.543`. +- `$random([start, [end]])` ([code](evennia.utils.funcparser.funcparser_callable_random)) - this works like the Python `random()` function, but will randomize to an integer value if both start/end are + integers. Without argument, will return a float between 0 and 1. +- `$randint([start, [end]])` ([code](evennia.utils.funcparser.funcparser_callable_randint)) - works like the `randint()` python function and always returns an integer. +- `$choice(list)` ([code](evennia.utils.funcparser.funcparser_callable_choice)) - the input will automatically be parsed the same way as `$eval` and is expected to be an iterable. A random element of this list will be returned. +- `$pad(text[, width, align, fillchar])` ([code](evennia.utils.funcparser.funcparser_callable_pad)) - this will pad content. `$pad("Hello", 30, c, -)` will lead to a text centered in a 30-wide block surrounded by `-` characters. +- `$crop(text, width=78, suffix='[...]')` ([code](evennia.utils.funcparser.funcparser_callable_crop)) - this will crop a text longer than the width, by default ending it with a `[...]`-suffix that also fits within the width. If no width is given, the client width or `settings.DEFAULT_CLIENT_WIDTH` will be used. +- `$space(num)` ([code](evennia.utils.funcparser.funcparser_callable_space)) - this will insert `num` spaces. +- `$just(string, width=40, align=c, indent=2)` ([code](evennia.utils.funcparser.funcparser_callable_justify)) - justifies the text to a given width, aligning it left/right/center or 'f' for full (spread text across width). +- `$ljust` - shortcut to justify-left. Takes all other kwarg of `$just`. +- `$rjust` - shortcut to right justify. +- `$cjust` - shortcut to center justify. +- `$clr(startcolor, text[, endcolor])` ([code](evennia.utils.funcparser.funcparser_callable_clr)) - color text. The color is given with one or two characters without the preceeding `|`. If no endcolor is given, the string will go back to neutral, so `$clr(r, Hello)` is equivalent to `|rHello|n`. + +### `evennia.utils.funcparser.SEARCHING_CALLABLES` + +These are callables that requires access-checks in order to search for objects. So they require some extra reserved kwargs to be passed when running the parser: +```python + +parser.parse_to_any(string, caller=, access="control", ...) + +``` +The `caller` is required, it's the the object to do the access-check for. The `access` kwarg is the + [lock type](./Locks.md) to check, default being `"control"`. + +- `$search(query,type=account|script,return_list=False)` ([code](evennia.utils.funcparser.funcparser_callable_search)) - this will look up and try to match an object by key or alias. Use the `type` kwarg to search for `account` or `script` instead. By default this will return nothing if there are more than one match; if `return_list` is `True` a list of 0, 1 or more matches will be returned instead. +- `$obj(query)`, `$dbref(query)` - legacy aliases for `$search`. +- `$objlist(query)` - legacy alias for `$search`, always returning a list. + + +### `evennia.utils.funcparser.ACTOR_STANCE_CALLABLES` + +These are used to implement actor-stance emoting. They are used by the [DefaultObject.msg_contents](evennia.objects.objects.DefaultObject.msg_contents) method by default. You can read a lot more about this on the page +[Change messages per receiver](../Concepts/Change-Message-Per-Receiver.md). + +On the parser side, all these inline functions require extra kwargs be passed into the parser (done by `msg_contents` by default): + +```python +parser.parse(string, caller=, receiver=, mapping={'key': , ...}) +``` + +Here the `caller` is the one sending the message and `receiver` the one to see it. The `mapping` contains references to other objects accessible via these callables. + +- `$you([key])` ([code](evennia.utils.funcparser.funcparser_callable_you)) - + if no `key` is given, this represents the `caller`, otherwise an object from `mapping` + will be used. As this message is sent to different recipients, the `receiver` will change and this will + be replaced either with the string `you` (if you and the receiver is the same entity) or with the + result of `you_obj.get_display_name(looker=receiver)`. This allows for a single string to echo differently + depending on who sees it, and also to reference other people in the same way. +- `$You([key])` - same as `$you` but always capitalized. +- `$conj(verb)` ([code](evennia.utils.funcparser.funcparser_callable_conjugate)) - conjugates a verb + between 2nd person presens to 3rd person presence depending on who + sees the string. For example `"$You() $conj(smiles)".` will show as "You smile." and "Tom smiles." depending + on who sees it. This makes use of the tools in [evennia.utils.verb_conjugation](evennia.utils.verb_conjugation) + to do this, and only works for English verbs. +- `$pron(pronoun [,options])` ([code](evennia.utils.funcparser.funcparser_callable_pronoun)) - Dynamically + map pronouns (like his, herself, you, its etc) between 1st/2nd person to 3rd person. + + +### `evennia.prototypes.protfuncs` + +This is used by the [Prototype system](./Prototypes.md) and allows for adding references inside the prototype. The funcparsing will happen before the spawn. + +Available inlinefuncs to prototypes: + +- All `FUNCPARSER_CALLABLES` and `SEARCHING_CALLABLES` +- `$protkey(key)` - returns the value of another key within the same prototype. Note that the system will try to convert this to a 'real' value (like turning the string "3" into the integer 3), for security reasons, not all embedded values can be converted this way. Note however that you can do nested calls with inlinefuncs, including adding your own converters. + +### Example + +Here's an example of including the default callables together with two custom ones. + +```python +from evennia.utils import funcparser +from evennia.utils import gametime + +def _dashline(*args, **kwargs): + if args: + return f"\n-------- {args[0]} --------" + return '' + +def _uptime(*args, **kwargs): + return gametime.uptime() + +callables = { + "dashline": _dashline, + "uptime": _uptime, + **funcparser.FUNCPARSER_CALLABLES, + **funcparser.ACTOR_STANCE_CALLABLES, + **funcparser.SEARCHING_CALLABLES +} + +parser = funcparser.FuncParser(callables) + +string = "This is the current uptime:$dashline($toint($uptime()) seconds)" +result = parser.parse(string) + +``` + +Above we define two callables `_dashline` and `_uptime` and map them to names `"dashline"` and `"uptime"`, +which is what we then can call as `$header` and `$uptime` in the string. We also have access to +all the defaults (like `$toint()`). + +The parsed result of the above would be something like this: + + This is the current uptime: + ------- 343 seconds ------- diff --git a/docs/latest/_sources/Components/Help-System.md.txt b/docs/latest/_sources/Components/Help-System.md.txt new file mode 100644 index 0000000000..055c5e1378 --- /dev/null +++ b/docs/latest/_sources/Components/Help-System.md.txt @@ -0,0 +1,315 @@ +# Help System + + +```shell +> help theatre +``` + +```shell +------------------------------------------------------------------------------ +Help for The theatre (aliases: the hub, curtains) + +The theatre is at the centre of the city, both literally and figuratively ... +(A lot more text about it follows ...) + +Subtopics: + theatre/lore + theatre/layout + theatre/dramatis personae +------------------------------------------------------------------------------ +``` + +```shell +> help evennia +``` + +```shell +------------------------------------------------------------------------------ +No help found + +There is no help topic matching 'evennia'. +... But matches where found within the help texts of the suggestions below. + +Suggestions: + grapevine2chan, about, irc2chan +----------------------------------------------------------------------------- +``` + +Evennia has an extensive help system covering both command-help and regular free-form help documentation. It supports subtopics and if failing to find a match it will provide suggestsions, first from alternative topics and then by finding mentions of the search term in help entries. + +The help system is accessed in-game by use of the `help` command: + + help + +Sub-topics are accessed as `help //...`. + +## Working with three types of help entries + +There are three ways to generate help entries: + +- In the database +- As Python modules +- From Command doc strings + +### Database-stored help entries + +Creating a new help entry from in-game is done with + + sethelp [;aliases] [,category] [,lockstring] = + +For example + + sethelp The Gods;pantheon, Lore = In the beginning all was dark ... + +This will create a new help entry in the database. Use the `/edit` switch to open the EvEditor for more convenient in-game writing (but note that devs can also create help entries outside the game using their regular code editor, see below). + +The [HelpEntry](evennia.help.models.HelpEntry) stores database help. It is _not_ a Typeclassed entity and can't be extended using the typeclass mechanism. + +Here's how to create a database-help entry in code: +```python +from evennia import create_help_entry +entry = create_help_entry("emote", + "Emoting is important because ...", + category="Roleplaying", locks="view:all()") +``` + +### File-stored help entries + +```{versionadded} 1.0 +``` + +File-help entries are created by the game development team outside of the game. The help entries are defined in normal Python modules (`.py` file ending) containing a `dict` to represent each entry. They require a server `reload` before any changes apply. + +- Evennia will look through all modules given by + `settings.FILE_HELP_ENTRY_MODULES`. This should be a list of python-paths for + Evennia to import. +- If this module contains a top-level variable `HELP_ENTRY_DICTS`, this will be + imported and must be a `list` of help-entry dicts. +- If no `HELP_ENTRY_DICTS` list is found, _every_ top-level variable in the + module that is a `dict` will be read as a help entry. The variable-names will + be ignored in this case. + +If you add multiple modules to be read, same-keyed help entries added later in +the list will override coming before. + +Each entry dict must define keys to match that needed by all help entries. +Here's an example of a help module: + +```python + +# in a module pointed to by settings.FILE_HELP_ENTRY_MODULES + +HELP_ENTRY_DICTS = [ + { + "key": "The Gods", # case-insensitive, can be searched by 'gods' too + "aliases": ['pantheon', 'religion'] + "category": "Lore", + "locks": "read:all()", # optional + "text": ''' + The gods formed the world ... + + # Subtopics + + ## Pantheon + + The pantheon consists of 40 gods that ... + + ### God of love + + The most prominent god is ... + + ### God of war + + Also known as 'the angry god', this god is known to ... + + ''' + }, + { + "key": "The mortals", + + } +] + +``` + +The help entry text will be dedented and will retain paragraphs. You should try +to keep your strings a reasonable width (it will look better). Just reload the +server and the file-based help entries will be available to view. + +### Command-help entries + +The `__docstring__` of [Command classes](./Commands.md) are automatically extracted into a help entry. You set `help_category` directly on the class. + +```python +from evennia import Command + +class MyCommand(Command): + """ + This command is great! + + Usage: + mycommand [argument] + + When this command is called, great things happen. If you + pass an argument, even GREATER things HAPPEN! + + """ + + key = "mycommand" + + locks: "cmd:all();read:all()" # default + help_category = "General" # default + auto_help = True # default + + # ... +``` + +When you update your code, the command's help will follow. The idea is that the command docs are easier to maintain and keep up-to-date if the developer can change them at the same time as they do the code. + +### Locking help entries + +The default `help` command gather all available commands and help entries +together so they can be searched or listed. By setting locks on the command/help +entry one can limit who can read help about it. + +- Commands failing the normal `cmd`-lock will be removed before even getting + to the help command. In this case the other two lock types below are ignored. +- The `view` access type determines if the command/help entry should be visible in + the main help index. If not given, it is assumed everyone can view. +- The `read` access type determines if the command/help entry can be actually read. + If a `read` lock is given and `view` is not, the `read`-lock is assumed to + apply to `view`-access as well (so if you can't read the help entry it will + also not show up in the index). If `read`-lock is not given, it's assume + everyone can read the help entry. + +For Commands you set the help-related locks the same way you would any lock: + +```python +class MyCommand(Command): + """ + + """ + key = "mycommand" + # everyone can use the command, builders can view it in the help index + # but only devs can actually read the help (a weird setup for sure!) + locks = "cmd:all();view:perm(Builders);read:perm(Developers) + +``` + +Db-help entries and File-Help entries work the same way (except the `cmd`-type +lock is not used. A file-help example: + +```python +help_entry = { + # ... + locks = "read:perm(Developer)", + # ... +} + +``` + +```{versionchanged} 1.0 + Changed the old 'view' lock to control the help-index inclusion and added + the new 'read' lock-type to control access to the entry itself. +``` + +### Customizing the look of the help system + +This is done almost exclusively by overriding the `help` command [evennia.commands.default.help.CmdHelp](evennia.commands.default.help.CmdHelp). + +Since the available commands may vary from moment to moment, `help` is responsible for collating the three sources of help-entries (commands/db/file) together and search through them on the fly. It also does all the formatting of the output. + +To make it easier to tweak the look, the parts of the code that changes the visual presentation and entity searching has been broken out into separate methods on the command class. Override these in your version of `help` to change the display or tweak as you please. See the api link above for details. + +## Subtopics + +```{versionadded} 1.0 +``` + +Rather than making a very long help entry, the `text` may also be broken up into _subtopics_. A list of the next level of subtopics are shown below the main help text and allows the user to read more about some particular detail that wouldn't fit in the main text. + +Subtopics use a markup slightly similar to markdown headings. The top level heading must be named `# subtopics` (non case-sensitive) and the following headers must be sub-headings to this (so `## subtopic name` etc). All headings are non-case sensitive (the help command will format them). The topics can be nested at most to a depth of 5 (which is probably too many levels already). The parser uses fuzzy matching to find the subtopic, so one does not have to type it all out exactly. + +Below is an example of a `text` with sub topics. + +``` +The theatre is the heart of the city, here you can find ... +(This is the main help text, what you get with `help theatre`) + +# subtopics + +## lore + +The theatre holds many mysterious things... +(`help theatre/lore`) + +### the grand opening + +The grand opening is the name for a mysterious event where ghosts appeared ... +(`this is a subsub-topic to lore, accessible as `help theatre/lore/grand` or +any other partial match). + +### the Phantom + +Deep under the theatre, rumors has it a monster hides ... +(another subsubtopic, accessible as `help theatre/lore/phantom`) + +## layout + +The theatre is a two-story building situated at ... +(`help theatre/layout`) + +## dramatis personae + +There are many interesting people prowling the halls of the theatre ... +(`help theatre/dramatis` or `help theathre/drama` or `help theatre/personae` would work) + +### Primadonna Ada + +Everyone knows the primadonna! She is ... +(A subtopic under dramatis personae, accessible as `help theatre/drama/ada` etc) + +### The gatekeeper + +He always keeps an eye on the door and ... +(`help theatre/drama/gate`) + +``` + + +## Technical notes + +#### Help-entry clashes + +Should you have clashing help-entries (of the same name) between the three types of available entries, the priority is + + Command-auto-help > Db-help > File-help + +The `sethelp` command (which only deals with creating db-based help entries) will warn you if a new help entry might shadow/be shadowed by a same/similar-named command or file-based help entry. + +#### The Help Entry container + +All help entries (no matter the source) are parsed into an object with the following properties: + +- `key` - This is the main topic-name. For Commands, this is literally the command's `key`. +- `aliases` - Alternate names for the help entry. This can be useful if the main name is hard to remember. +- `help_category` - The general grouping of the entry. This is optional. If not given it will use the default category given by `settings.COMMAND_DEFAULT_HELP_CATEGORY` for Commands and + `settings.DEFAULT_HELP_CATEGORY` for file+db help entries. +- `locks` - Lock string (for commands) or LockHandler (all help entries). This defines who may read this entry. See the next section. +- `tags` - This is not used by default, but could be used to further organize help entries. +- `text` - The actual help entry text. This will be dedented and stripped of extra space at beginning and end. + +#### Help pagination + +A `text` that scrolls off the screen will automatically be paginated by the [EvMore](./EvMore.md) pager (you can control this with `settings.HELP_MORE_ENABLED=False`). If you use EvMore and want to control exactly where the pager should break the page, mark the break with the control character `\f`. + +#### Search engine + +Since it needs to search so different types of data, the help system has to collect all possibilities in memory before searching through the entire set. It uses the [Lunr](https://github.com/yeraydiazdiaz/lunr.py) search engine to search through the main bulk of help entries. Lunr is a mature engine used for web-pages and produces much more sensible results than previous solutions. + +Once the main entry has been found, subtopics are then searched with simple `==`, `startswith` and `in` matching (there are so relatively few of them at that point). + +```{versionchanged} 1.0 + Replaced the old bag-of-words algorithm with lunr package. + +``` diff --git a/docs/latest/_sources/Components/Inputfuncs.md.txt b/docs/latest/_sources/Components/Inputfuncs.md.txt new file mode 100644 index 0000000000..a3bf848729 --- /dev/null +++ b/docs/latest/_sources/Components/Inputfuncs.md.txt @@ -0,0 +1,169 @@ +# Inputfuncs + +``` + Internet│ + ┌─────┐ │ ┌────────┐ +┌──────┐ │Text │ │ ┌────────────┐ ┌─────────┐ │Command │ +│Client├────┤JSON ├─┼──►commandtuple├────►Inputfunc├────►DB query│ +└──────┘ │etc │ │ └────────────┘ └─────────┘ │etc │ + └─────┘ │ └────────┘ + │Evennia + +``` + +The Inputfunc is the last fixed step on the [Ingoing message path](../Concepts/Messagepath.md#ingoing-message-path). The available Inputfuncs are looked up and called using `commandtuple` structures sent from the client. The job of the Inputfunc is to perform whatever action is requested, by firing a Command, performing a database query or whatever is needed. + +Given a `commandtuple` on the form + + (commandname, (args), {kwargs}) + +Evennia will try to find and call an Inputfunc on the form + +```python +def commandname(session, *args, **kwargs): + # ... + +``` +Or, if no match was found, it will call an inputfunc named "default" on this form + +```python +def default(session, cmdname, *args, **kwargs): + # cmdname is the name of the mismatched inputcommand + +``` + +The default inputfuncs are found in [evennia/server/inputfuncs.py](evennia.server.inputfuncs). + +## Adding your own inputfuncs + +1. Add a function on the above form to `mygame/server/conf/inputfuncs.py`. Your function must be in the global, outermost scope of that module and not start with an underscore (`_`) to be recognized as an inputfunc. i +2. `reload` the server. + +To overload a default inputfunc (see below), just add a function with the same name. You can also extend the settings-list `INPUT_FUNC_MODULES`. + + INPUT_FUNC_MODULES += ["path.to.my.inputfunc.module"] + +All global-level functions with a name not starting with `_` in these module(s) will be used by Evennia as an inputfunc. The list is imported from left to right, so latter imported functions will replace earlier ones. + +## Default inputfuncs + +Evennia defines a few default inputfuncs to handle the common cases. These are defined in +`evennia/server/inputfuncs.py`. + +### text + + - Input: `("text", (textstring,), {})` + - Output: Depends on Command triggered + +This is the most common of inputs, and the only one supported by every traditional mud. The argument is usually what the user sent from their command line. Since all text input from the user +like this is considered a [Command](./Commands.md), this inputfunc will do things like nick-replacement and then pass on the input to the central Commandhandler. + +### echo + + - Input: `("echo", (args), {})` + - Output: `("text", ("Echo returns: %s" % args), {})` + +This is a test input, which just echoes the argument back to the session as text. Can be used for testing custom client input. + +### default + +The default function, as mentioned above, absorbs all non-recognized inputcommands. The default one will just log an error. + +### client_options + + - Input: `("client_options, (), {key:value, ...})` + - Output: + - normal: None + - get: `("client_options", (), {key:value, ...})` + +This is a direct command for setting protocol options. These are settable with the `@option` +command, but this offers a client-side way to set them. Not all connection protocols makes use of +all flags, but here are the possible keywords: + + - get (bool): If this is true, ignore all other kwargs and immediately return the current settings +as an outputcommand `("client_options", (), {key=value, ...})`- + - client (str): A client identifier, like "mushclient". + - version (str): A client version + - ansi (bool): Supports ansi colors + - xterm256 (bool): Supports xterm256 colors or not + - mxp (bool): Supports MXP or not + - utf-8 (bool): Supports UTF-8 or not + - screenreader (bool): Screen-reader mode on/off + - mccp (bool): MCCP compression on/off + - screenheight (int): Screen height in lines + - screenwidth (int): Screen width in characters + - inputdebug (bool): Debug input functions + - nomarkup (bool): Strip all text tags + - raw (bool): Leave text tags unparsed + +> Note that there are two GMCP aliases to this inputfunc - `hello` and `supports_set`, which means it will be accessed via the GMCP `Hello` and `Supports.Set` instructions assumed by some clients. + +### get_client_options + + - Input: `("get_client_options, (), {key:value, ...})` + - Output: `("client_options, (), {key:value, ...})` + +This is a convenience wrapper that retrieves the current options by sending "get" to `client_options` above. + +### get_inputfuncs + +- Input: `("get_inputfuncs", (), {})` +- Output: `("get_inputfuncs", (), {funcname:docstring, ...})` + +Returns an outputcommand on the form `("get_inputfuncs", (), {funcname:docstring, ...})` - a list of all the available inputfunctions along with their docstrings. + +### login + +> Note: this is currently experimental and not very well tested. + + - Input: `("login", (username, password), {})` + - Output: Depends on login hooks + +This performs the inputfunc version of a login operation on the current Session. It's meant to be used by custom client setups. + +### get_value + +Input: `("get_value", (name, ), {})` +Output: `("get_value", (value, ), {})` + +Retrieves a value from the Character or Account currently controlled by this Session. Takes one argument, This will only accept particular white-listed names, you'll need to overload the function to expand. By default the following values can be retrieved: + + - "name" or "key": The key of the Account or puppeted Character. + - "location": Name of the current location, or "None". + - "servername": Name of the Evennia server connected to. + +### repeat + + - Input: `("repeat", (), {"callback":funcname, "interval": secs, "stop": False})` + - Output: Depends on the repeated function. Will return `("text", (repeatlist),{}` with a list of +accepted names if given an unfamiliar callback name. + +This will tell evennia to repeatedly call a named function at a given interval. Behind the scenes this will set up a [Ticker](./TickerHandler.md). Only previously acceptable functions are possible to repeat-call in this way, you'll need to overload this inputfunc to add the ones you want to offer. By default only two example functions are allowed, "test1" and "test2", which will just echo a text back at the given interval. Stop the repeat by sending `"stop": True` (note that you must include both the callback name and interval for Evennia to know what to stop). + +### unrepeat + + - Input: `("unrepeat", (), ("callback":funcname, + "interval": secs)` + - Output: None + +This is a convenience wrapper for sending "stop" to the `repeat` inputfunc. + +### monitor + + - Input: `("monitor", (), ("name":field_or_argname, stop=False)` + - Output (on change): `("monitor", (), {"name":name, "value":value})` + +This sets up on-object monitoring of Attributes or database fields. Whenever the field or Attribute changes in any way, the outputcommand will be sent. This is using the [MonitorHandler](./MonitorHandler.md) behind the scenes. Pass the "stop" key to stop monitoring. Note that you must supply the name also when stopping to let the system know which monitor should be cancelled. + +Only fields/attributes in a whitelist are allowed to be used, you have to overload this function to add more. By default the following fields/attributes can be monitored: + + - "name": The current character name + - "location": The current location + - "desc": The description Argument + +### unmonitor + + - Input: `("unmonitor", (), {"name":name})` + - Output: None + +A convenience wrapper that sends "stop" to the `monitor` function. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Locks.md.txt b/docs/latest/_sources/Components/Locks.md.txt new file mode 100644 index 0000000000..3d07b9b583 --- /dev/null +++ b/docs/latest/_sources/Components/Locks.md.txt @@ -0,0 +1,294 @@ +# Locks + + +For most games it is a good idea to restrict what people can do. In Evennia such restrictions are applied and checked by something called *locks*. All Evennia entities ([Commands](./Commands.md), [Objects](./Objects.md), [Scripts](./Scripts.md), [Accounts](./Accounts.md), [Help System](./Help-System.md), [messages](./Msg.md) and [channels](./Channels.md)) are accessed through locks. + +A lock can be thought of as an "access rule" restricting a particular use of an Evennia entity. +Whenever another entity wants that kind of access the lock will analyze that entity in different ways to determine if access should be granted or not. Evennia implements a "lockdown" philosophy - all entities are inaccessible unless you explicitly define a lock that allows some or full access. + +Let's take an example: An object has a lock on itself that restricts how people may "delete" that object. Apart from knowing that it restricts deletion, the lock also knows that only players with the specific ID of, say, `34` are allowed to delete it. So whenever a player tries to run `delete` on the object, the `delete` command makes sure to check if this player is really allowed to do so. It calls the lock, which in turn checks if the player's id is `34`. Only then will it allow `delete` to go on with its job. + +## Working with locks + +The in-game command for setting locks on objects is `lock`: + + > lock obj = + +The `` is a string of a certain form that defines the behaviour of the lock. We will go into more detail on how `` should look in the next section. + +Code-wise, Evennia handles locks through what is usually called `locks` on all relevant entities. This is a handler that allows you to add, delete and check locks. + +```python + myobj.locks.add() +``` + +One can call `locks.check()` to perform a lock check, but to hide the underlying implementation all objects also have a convenience function called `access`. This should preferably be used. In the example below, `accessing_obj` is the object requesting the 'delete' access whereas `obj` is the object that might get deleted. This is how it would look (and does look) from inside the `delete` command: + +```python + if not obj.access(accessing_obj, 'delete'): + accessing_obj.msg("Sorry, you may not delete that.") + return +``` + +### Defining locks + +Defining a lock (i.e. an access restriction) in Evennia is done by adding simple strings of lock +definitions to the object's `locks` property using `obj.locks.add()`. + +Here are some examples of lock strings (not including the quotes): + +```python + delete:id(34) # only allow obj #34 to delete + edit:all() # let everyone edit + # only those who are not "very_weak" or are Admins may pick this up + get: not attr(very_weak) or perm(Admin) +``` + +Formally, a lockstring has the following syntax: + +```python + access_type: [NOT] lockfunc1([arg1,..]) [AND|OR] [NOT] lockfunc2([arg1,...]) [...] +``` + +where `[]` marks optional parts. `AND`, `OR` and `NOT` are not case sensitive and excess spaces are ignored. `lockfunc1, lockfunc2` etc are special _lock functions_ available to the lock system. + +So, a lockstring consists of the type of restriction (the `access_type`), a colon (`:`) and then an expression involving function calls that determine what is needed to pass the lock. Each function returns either `True` or `False`. `AND`, `OR` and `NOT` work as they do normally in Python. If the total result is `True`, the lock is passed. + +You can create several lock types one after the other by separating them with a semicolon (`;`) in the lockstring. The string below yields the same result as the previous example: + + delete:id(34);edit:all();get: not attr(very_weak) or perm(Admin) + + +### Valid access_types + +An `access_type`, the first part of a lockstring, defines what kind of capability a lock controls, such as "delete" or "edit". You may in principle name your `access_type` anything as long as it is unique for the particular object. The name of the access types is not case-sensitive. + +If you want to make sure the lock is used however, you should pick `access_type` names that you (or the default command set) actually checks for, as in the example of `delete` above that uses the 'delete' `access_type`. + +Below are the access_types checked by the default commandset. + +- [Commands](./Commands.md) + - `cmd` - this defines who may call this command at all. +- [Objects](./Objects.md): + - `control` - who is the "owner" of the object. Can set locks, delete it etc. Defaults to the creator of the object. + - `call` - who may call Object-commands stored on this Object except for the Object itself. By default, Objects share their Commands with anyone in the same location (e.g. so you can 'press' a `Button` object in the room). For Characters and Mobs (who likely only use those Commands for themselves and don't want to share them) this should usually be turned off completely, using something like `call:false()`. + - `examine` - who may examine this object's properties. + - `delete` - who may delete the object. + - `edit` - who may edit properties and attributes of the object. + - `view` - if the `look` command will display/list this object in descriptions and if you will be able to see its description. Note that if you target it specifically by name, the system will still find it, just not be able to look at it. See `search` lock to completely hide the item. + - `search` - this controls if the object can be found with the `DefaultObject.search` method (usually referred to with `caller.search` in Commands). This is how to create entirely 'undetectable' in-game objects. If not setting this lock explicitly, all objects are assumed searchable. + - `get`- who may pick up the object and carry it around. + - `puppet` - who may "become" this object and control it as their "character". + - `attrcreate` - who may create new attributes on the object (default True) +- [Characters](./Objects.md#characters): + - Same as for Objects +- [Exits](./Objects.md#exits): + - Same as for Objects + - `traverse` - who may pass the exit. +- [Accounts](./Accounts.md): + - `examine` - who may examine the account's properties. + - `delete` - who may delete the account. + - `edit` - who may edit the account's attributes and properties. + - `msg` - who may send messages to the account. + - `boot` - who may boot the account. +- [Attributes](./Attributes.md): (only checked by `obj.secure_attr`) + - `attrread` - see/access attribute + - `attredit` - change/delete attribute +- [Channels](./Channels.md): + - `control` - who is administrating the channel. This means the ability to delete the channel, boot listeners etc. + - `send` - who may send to the channel. + - `listen` - who may subscribe and listen to the channel. +- [HelpEntry](./Help-System.md): + - `examine` - who may view this help entry (usually everyone) + - `edit` - who may edit this help entry. + +So to take an example, whenever an exit is to be traversed, a lock of the type *traverse* will be checked. Defining a suitable lock type for an exit object would thus involve a lockstring `traverse: `. +### Custom access_types + +As stated above, the `access_type` part of the lock is simply the 'name' or 'type' of the lock. The text is an arbitrary string that must be unique for an object. If adding a lock with the same `access_type` as one that already exists on the object, the new one override the old one. + +For example, if you wanted to create a bulletin board system and wanted to restrict who can either read a board or post to a board. You could then define locks such as: + +```python + obj.locks.add("read:perm(Player);post:perm(Admin)") +``` + +This will create a 'read' access type for Characters having the `Player` permission or above and a 'post' access type for those with `Admin` permissions or above (see below how the `perm()` lock function works). When it comes time to test these permissions, simply check like this (in this example, the `obj` may be a board on the bulletin board system and `accessing_obj` is the player trying to read the board): + +```python + if not obj.access(accessing_obj, 'read'): + accessing_obj.msg("Sorry, you may not read that.") + return +``` + +### Lock functions + +A _lock function_ is a normal Python function put in a place Evennia looks for such functions. The modules Evennia looks at is the list `settings.LOCK_FUNC_MODULES`. *All functions* in any of those modules will automatically be considered a valid lock function. The default ones are found in `evennia/locks/lockfuncs.py` and you can start adding your own in `mygame/server/conf/lockfuncs.py`. You can append the setting to add more module paths. To replace a default lock function, just add your own with the same name. + +This is the basic definition of a lock function: + +```python +def lockfunc_name(accessing_obj, accessed_obj, *args, **kwargs): + return True # or False +``` +The `accessing object` is the object wanting to get access. The `accessed object` is the object being accessed (the object with the lock). The function always return a boolean determining if the lock is passed or not. + +The `*args` will become the tuple of arguments given to the lockfunc. So for a lockstring `"edit:id(3)"` (a lockfunc named `id`), `*args` in the lockfunc would be `(3,)` . + +The `**kwargs` dict has one default keyword always provided by Evennia, the `access_type`, which is a string with the access type being checked for. For the lockstring `"edit:id(3)"`, `access_type"` would be `"edit"`. This is unused by default Evennia. + +Any arguments explicitly given in the lock definition will appear as extra arguments. + +```python +# A simple example lock function. Called with e.g. `id(34)`. This is +# defined in, say mygame/server/conf/lockfuncs.py + +def id(accessing_obj, accessed_obj, *args, **kwargs): + if args: + wanted_id = args[0] + return accessing_obj.id == wanted_id + return False +``` + +The above could for example be used in a lock function like this: + +```python + # we have `obj` and `owner_object` from before + obj.locks.add(f"edit: id({owner_object.id})") +``` + +We could check if the "edit" lock is passed with something like this: + +```python + # as part of a Command's func() method, for example + if not obj.access(caller, "edit"): + caller.msg("You don't have access to edit this!") + return +``` + +In this example, everyone except the `caller` with the right `id` will get the error. + +> (Using the `*` and `**` syntax causes Python to magically put all extra arguments into a list `args` and all keyword arguments into a dictionary `kwargs` respectively. If you are unfamiliar with how `*args` and `**kwargs` work, see the Python manuals). + +Some useful default lockfuncs (see `src/locks/lockfuncs.py` for more): + +- `true()/all()` - give access to everyone +- `false()/none()/superuser()` - give access to none. Superusers bypass the check entirely and are thus the only ones who will pass this check. +- `perm(perm)` - this tries to match a given `permission` property, on an Account firsthand, on a Character second. See [below](./Permissions.md). +- `perm_above(perm)` - like `perm` but requires a "higher" permission level than the one given. +- `id(num)/dbref(num)` - checks so the access_object has a certain dbref/id. +- `attr(attrname)` - checks if a certain [Attribute](./Attributes.md) exists on accessing_object. +- `attr(attrname, value)` - checks so an attribute exists on accessing_object *and* has the given value. +- `attr_gt(attrname, value)` - checks so accessing_object has a value larger (`>`) than the given value. +- `attr_ge, attr_lt, attr_le, attr_ne` - corresponding for `>=`, `<`, `<=` and `!=`. +- `holds(objid)` - checks so the accessing objects contains an object of given name or dbref. +- `inside()` - checks so the accessing object is inside the accessed object (the inverse of `holds()`). +- `pperm(perm)`, `pid(num)/pdbref(num)` - same as `perm`, `id/dbref` but always looks for permissions and dbrefs of *Accounts*, not on Characters. +- `serversetting(settingname, value)` - Only returns True if Evennia has a given setting or a setting set to a given value. + +### Checking simple strings + +Sometimes you don't really need to look up a certain lock, you just want to check a lockstring. A common use is inside Commands, in order to check if a user has a certain permission. The lockhandler has a method `check_lockstring(accessing_obj, lockstring, bypass_superuser=False)` that allows this. + +```python + # inside command definition + if not self.caller.locks.check_lockstring(self.caller, "dummy:perm(Admin)"): + self.caller.msg("You must be an Admin or higher to do this!") + return +``` + +Note here that the `access_type` can be left to a dummy value since this method does not actually do a Lock lookup. + +### Default locks + +Evennia sets up a few basic locks on all new objects and accounts (if we didn't, noone would have any access to anything from the start). This is all defined in the root [Typeclasses](./Typeclasses.md) of the respective entity, in the hook method `basetype_setup()` (which you usually don't want to edit unless you want to change how basic stuff like rooms and exits store their internal variables). This is called once, before `at_object_creation`, so just put them in the latter method on your child object to change the default. Also creation commands like `create` changes the locks of objects you create - for example it sets the `control` lock_type so as to allow you, its creator, to control and delete the object. + + +## More Lock definition examples + + examine: attr(eyesight, excellent) or perm(Builders) + +You are only allowed to do *examine* on this object if you have 'excellent' eyesight (that is, has an Attribute `eyesight` with the value `excellent` defined on yourself) or if you have the "Builders" permission string assigned to you. + + open: holds('the green key') or perm(Builder) + +This could be called by the `open` command on a "door" object. The check is passed if you are a Builder or has the right key in your inventory. + + cmd: perm(Builders) + +Evennia's command handler looks for a lock of type `cmd` to determine if a user is allowed to even call upon a particular command or not. When you define a command, this is the kind of lock you must set. See the default command set for lots of examples. If a character/account don't pass the `cmd` lock type the command will not even appear in their `help` list. + + cmd: not perm(no_tell) + +"Permissions" can also be used to block users or implement highly specific bans. The above example would be be added as a lock string to the `tell` command. This will allow everyone *not* having the "permission" `no_tell` to use the `tell` command. You could easily give an account the "permission" `no_tell` to disable their use of this particular command henceforth. + + +```python + dbref = caller.id + lockstring = "control:id(%s);examine:perm(Builders);delete:id(%s) or perm(Admin);get:all()" % +(dbref, dbref) + new_obj.locks.add(lockstring) +``` + +This is how the `create` command sets up new objects. In sequence, this permission string sets the owner of this object be the creator (the one running `create`). Builders may examine the object whereas only Admins and the creator may delete it. Everyone can pick it up. + +### A complete example of setting locks on an object + +Assume we have two objects - one is ourselves (not superuser) and the other is an [Object](./Objects.md) +called `box`. + + > create/drop box + > desc box = "This is a very big and heavy box." + +We want to limit which objects can pick up this heavy box. Let's say that to do that we require the would-be lifter to to have an attribute *strength* on themselves, with a value greater than 50. We assign it to ourselves to begin with. + + > set self/strength = 45 + +Ok, so for testing we made ourselves strong, but not strong enough. Now we need to look at what happens when someone tries to pick up the the box - they use the `get` command (in the default set). This is defined in `evennia/commands/default/general.py`. In its code we find this snippet: + +```python + if not obj.access(caller, 'get'): + if obj.db.get_err_msg: + caller.msg(obj.db.get_err_msg) + else: + caller.msg("You can't get that.") + return +``` + +So the `get` command looks for a lock with the type *get* (not so surprising). It also looks for an [Attribute](./Attributes.md) on the checked object called _get_err_msg_ in order to return a customized error message. Sounds good! Let's start by setting that on the box: + + > set box/get_err_msg = You are not strong enough to lift this box. + +Next we need to craft a Lock of type *get* on our box. We want it to only be passed if the accessing object has the attribute *strength* of the right value. For this we would need to create a lock function that checks if attributes have a value greater than a given value. Luckily there is already such a one included in Evennia (see `evennia/locks/lockfuncs.py`), called `attr_gt`. + +So the lock string will look like this: `get:attr_gt(strength, 50)`. We put this on the box now: + + lock box = get:attr_gt(strength, 50) + +Try to `get` the object and you should get the message that we are not strong enough. Increase your strength above 50 however and you'll pick it up no problem. Done! A very heavy box! + +If you wanted to set this up in python code, it would look something like this: + +```python + + from evennia import create_object + + # create, then set the lock + box = create_object(None, key="box") + box.locks.add("get:attr_gt(strength, 50)") + + # or we can assign locks in one go right away + box = create_object(None, key="box", locks="get:attr_gt(strength, 50)") + + # set the attributes + box.db.desc = "This is a very big and heavy box." + box.db.get_err_msg = "You are not strong enough to lift this box." + + # one heavy box, ready to withstand all but the strongest... +``` + +## On Django's permission system + +Django also implements a comprehensive permission/security system of its own. The reason we don't use that is because it is app-centric (app in the Django sense). Its permission strings are of the form `appname.permstring` and it automatically adds three of them for each database model in the app - for the app evennia/object this would be for example 'object.create', 'object.admin' and 'object.edit'. This makes a lot of sense for a web application, not so much for a MUD, especially when we try to hide away as much of the underlying architecture as possible. + +The django permissions are not completely gone however. We use it for validating passwords during login. It is also used exclusively for managing Evennia's web-based admin site, which is a graphical front-end for the database of Evennia. You edit and assign such permissions directly from the web interface. It's stand-alone from the permissions described above. diff --git a/docs/latest/_sources/Components/MonitorHandler.md.txt b/docs/latest/_sources/Components/MonitorHandler.md.txt new file mode 100644 index 0000000000..fd2e2f3b8a --- /dev/null +++ b/docs/latest/_sources/Components/MonitorHandler.md.txt @@ -0,0 +1,77 @@ +# MonitorHandler + + +The *MonitorHandler* is a system for watching changes in properties or Attributes on objects. A +monitor can be thought of as a sort of trigger that responds to change. + +The main use for the MonitorHandler is to report changes to the client; for example the client +Session may ask Evennia to monitor the value of the Character's `health` attribute and report +whenever it changes. This way the client could for example update its health bar graphic as needed. + +## Using the MonitorHandler + +The MontorHandler is accessed from the singleton `evennia.MONITOR_HANDLER`. The code for the handler +is in `evennia.scripts.monitorhandler`. + +Here's how to add a new monitor: + +```python +from evennia import MONITOR_HANDLER + +MONITOR_HANDLER.add(obj, fieldname, callback, + idstring="", persistent=False, **kwargs) + +``` + + - `obj` ([Typeclassed](./Typeclasses.md) entity) - the object to monitor. Since this must be +typeclassed, it means you can't monitor changes on [Sessions](./Sessions.md) with the monitorhandler, for +example. + - `fieldname` (str) - the name of a field or [Attribute](./Attributes.md) on `obj`. If you want to +monitor a database field you must specify its full name, including the starting `db_` (like +`db_key`, `db_location` etc). Any names not starting with `db_` are instead assumed to be the names +of Attributes. This difference matters, since the MonitorHandler will automatically know to watch +the `db_value` field of the Attribute. + - `callback`(callable) - This will be called as `callback(fieldname=fieldname, obj=obj, **kwargs)` +when the field updates. + - `idstring` (str) - this is used to separate multiple monitors on the same object and fieldname. +This is required in order to properly identify and remove the monitor later. It's also used for +saving it. + - `persistent` (bool) - if True, the monitor will survive a server reboot. + +Example: + +```python +from evennia import MONITOR_HANDLER as monitorhandler + +def _monitor_callback(fieldname="", obj=None, **kwargs): + # reporting callback that works both + # for db-fields and Attributes + if fieldname.startswith("db_"): + new_value = getattr(obj, fieldname) + else: # an attribute + new_value = obj.attributes.get(fieldname) + obj.msg(f"{obj.key}.{fieldname} changed to '{new_value}'.") + +# (we could add _some_other_monitor_callback here too) + +# monitor Attribute (assume we have obj from before) +monitorhandler.add(obj, "desc", _monitor_callback) + +# monitor same db-field with two different callbacks (must separate by id_string) +monitorhandler.add(obj, "db_key", _monitor_callback, id_string="foo") +monitorhandler.add(obj, "db_key", _some_other_monitor_callback, id_string="bar") + +``` + +A monitor is uniquely identified by the combination of the *object instance* it is monitoring, the +*name* of the field/attribute to monitor on that object and its `idstring` (`obj` + `fieldname` + +`idstring`). The `idstring` will be the empty string unless given explicitly. + +So to "un-monitor" the above you need to supply enough information for the system to uniquely find +the monitor to remove: + +``` +monitorhandler.remove(obj, "desc") +monitorhandler.remove(obj, "db_key", idstring="foo") +monitorhandler.remove(obj, "db_key", idstring="bar") +``` \ No newline at end of file diff --git a/docs/latest/_sources/Components/Msg.md.txt b/docs/latest/_sources/Components/Msg.md.txt new file mode 100644 index 0000000000..5464818fca --- /dev/null +++ b/docs/latest/_sources/Components/Msg.md.txt @@ -0,0 +1,81 @@ +# Msg + +The [Msg](evennia.comms.models.Msg) object represents a database-saved piece of communication. Think of it as a discrete piece of email - it contains a message, some metadata and will always have a sender and one or more recipients. + +Once created, a Msg is normally not changed. It is persitently saved in the database. This allows for comprehensive logging of communications. Here are some good uses for `Msg` objects: + +- page/tells (the `page` command is how Evennia uses them out of the box) +- messages in a bulletin board +- game-wide email stored in 'mailboxes'. + +```{important} + + A `Msg` does not have any in-game representation. So if you want to use them + to represent in-game mail/letters, the physical letters would never be + visible in a room (possible to steal, spy on etc) unless you make your + spy-system access the Msgs directly (or go to the trouble of spawning an + actual in-game letter-object based on the Msg) + + +``` + +```{versionchanged} 1.0 + Channels dropped Msg-support. Now only used in `page` command by default. +``` + +## Working with Msg + +The Msg is intended to be used exclusively in code, to build other game systems. It is _not_ a [Typeclassed](./Typeclasses.md) entity, which means it cannot (easily) be overridden. It doesn't support Attributes (but it _does_ support [Tags](./Tags.md)). It tries to be lean and small since a new one is created for every message. +You create a new message with `evennia.create_message`: + +```python + from evennia import create_message + message = create_message(senders, message, receivers, + locks=..., tags=..., header=...) +``` + +You can search for `Msg` objects in various ways: + + +```python + from evennia import search_message, Msg + + # args are optional. Only a single sender/receiver should be passed + messages = search_message(sender=..., receiver=..., freetext=..., dbref=...) + + # get all messages for a given sender/receiver + messages = Msg.objects.get_msg_by_sender(sender) + messages = Msg.objects.get_msg_by_receiver(recipient) + +``` + +### Properties on Msg + +- `senders` - there must always be at least one sender. This is a set of +- [Account](./Accounts.md), [Object](./Objects.md), [Script](./Scripts.md) + or `str` in any combination (but usually a message only targets one type). + Using a `str` for a sender indicates it's an 'external' sender and + and can be used to point to a sender that is not a typeclassed entity. This is not used by default + and what this would be depends on the system (it could be a unique id or a + python-path, for example). While most systems expect a single sender, it's + possible to have any number of them. +- `receivers` - these are the ones to see the Msg. These are again any combination of + [Account](./Accounts.md), [Object](./Objects.md) or [Script](./Scripts.md) or `str` (an 'external' receiver). + It's in principle possible to have zero receivers but most usages of Msg expects one or more. +- `header` - this is an optional text field that can contain meta-information about the message. For + an email-like system it would be the subject line. This can be independently searched, making + this a powerful place for quickly finding messages. +- `message` - the actual text being sent. +- `date_sent` - this is auto-set to the time the Msg was created (and thus presumably sent). +- `locks` - the Evennia [lock handler](./Locks.md). Use with `locks.add()` etc and check locks with `msg.access()` + like for all other lockable entities. This can be used to limit access to the contents + of the Msg. The default lock-type to check is `'read'`. +- `hide_from` - this is an optional list of [Accounts](./Accounts.md) or [Objects](./Objects.md) that + will not see this Msg. This relationship is available mainly for optimization + reasons since it allows quick filtering of messages not intended for a given + target. + + +## TempMsg + +[evennia.comms.models.TempMsg](evennia.comms.models.TempMsg) is an object that implements the same API as the regular `Msg`, but which has no database component (and thus cannot be searched). It's meant to plugged into systems expecting a `Msg` but where you just want to process the message without saving it. diff --git a/docs/latest/_sources/Components/Nicks.md.txt b/docs/latest/_sources/Components/Nicks.md.txt new file mode 100644 index 0000000000..e0392b501f --- /dev/null +++ b/docs/latest/_sources/Components/Nicks.md.txt @@ -0,0 +1,120 @@ +# Nicks + + +*Nicks*, short for *Nicknames* is a system allowing an object (usually a [Account](./Accounts.md)) to +assign custom replacement names for other game entities. + +Nicks are not to be confused with *Aliases*. Setting an Alias on a game entity actually changes an +inherent attribute on that entity, and everyone in the game will be able to use that alias to +address the entity thereafter. A *Nick* on the other hand, is used to map a different way *you +alone* can refer to that entity. Nicks are also commonly used to replace your input text which means +you can create your own aliases to default commands. + +Default Evennia use Nicks in three flavours that determine when Evennia actually tries to do the +substitution. + +- inputline - replacement is attempted whenever you write anything on the command line. This is the +default. +- objects - replacement is only attempted when referring to an object +- accounts - replacement is only attempted when referring an account + +Here's how to use it in the default command set (using the `nick` command): + + nick ls = look + +This is a good one for unix/linux users who are accustomed to using the `ls` command in their daily +life. It is equivalent to `nick/inputline ls = look`. + + nick/object mycar2 = The red sports car + +With this example, substitutions will only be done specifically for commands expecting an object +reference, such as + + look mycar2 + +becomes equivalent to "`look The red sports car`". + + nick/accounts tom = Thomas Johnsson + +This is useful for commands searching for accounts explicitly: + + @find *tom + +One can use nicks to speed up input. Below we add ourselves a quicker way to build red buttons. In +the future just writing *rb* will be enough to execute that whole long string. + + nick rb = @create button:examples.red_button.RedButton + +Nicks could also be used as the start for building a "recog" system suitable for an RP mud. + + nick/account Arnold = The mysterious hooded man + +The nick replacer also supports unix-style *templating*: + + nick build $1 $2 = @create/drop $1;$2 + +This will catch space separated arguments and store them in the the tags `$1` and `$2`, to be +inserted in the replacement string. This example allows you to do `build box crate` and have Evennia +see `@create/drop box;crate`. You may use any `$` numbers between 1 and 99, but the markers must +match between the nick pattern and the replacement. + +> If you want to catch "the rest" of a command argument, make sure to put a `$` tag *with no spaces +to the right of it* - it will then receive everything up until the end of the line. + +You can also use [shell-type wildcards](http://www.linfo.org/wildcard.html): + +- \* - matches everything. +- ? - matches a single character. +- [seq] - matches everything in the sequence, e.g. [xyz] will match both x, y and z +- [!seq] - matches everything *not* in the sequence. e.g. [!xyz] will match all but x,y z. + +## Coding with nicks + +Nicks are stored as the `Nick` database model and are referred from the normal Evennia +[object](./Objects.md) through the `nicks` property - this is known as the *NickHandler*. The NickHandler +offers effective error checking, searches and conversion. + +```python + # A command/channel nick: + obj.nicks.add("greetjack", "tell Jack = Hello pal!") + + # An object nick: + obj.nicks.add("rose", "The red flower", nick_type="object") + + # An account nick: + obj.nicks.add("tom", "Tommy Hill", nick_type="account") + + # My own custom nick type (handled by my own game code somehow): + obj.nicks.add("hood", "The hooded man", nick_type="my_identsystem") + + # get back the translated nick: + full_name = obj.nicks.get("rose", nick_type="object") + + # delete a previous set nick + object.nicks.remove("rose", nick_type="object") +``` + +In a command definition you can reach the nick handler through `self.caller.nicks`. See the `nick` +command in `evennia/commands/default/general.py` for more examples. + +As a last note, The Evennia [channel](./Channels.md) alias systems are using nicks with the +`nick_type="channel"` in order to allow users to create their own custom aliases to channels. + +## Advanced note + +Internally, nicks are [Attributes](./Attributes.md) saved with the `db_attrype` set to "nick" (normal +Attributes has this set to `None`). + +The nick stores the replacement data in the Attribute.db_value field as a tuple with four fields +`(regex_nick, template_string, raw_nick, raw_template)`. Here `regex_nick` is the converted regex +representation of the `raw_nick` and the `template-string` is a version of the `raw_template` +prepared for efficient replacement of any `$`- type markers. The `raw_nick` and `raw_template` are +basically the unchanged strings you enter to the `nick` command (with unparsed `$` etc). + +If you need to access the tuple for some reason, here's how: + +```python +tuple = obj.nicks.get("nickname", return_tuple=True) +# or, alternatively +tuple = obj.nicks.get("nickname", return_obj=True).value +``` \ No newline at end of file diff --git a/docs/latest/_sources/Components/Objects.md.txt b/docs/latest/_sources/Components/Objects.md.txt new file mode 100644 index 0000000000..b547bf976b --- /dev/null +++ b/docs/latest/_sources/Components/Objects.md.txt @@ -0,0 +1,164 @@ +# Objects + +**Message-path:** +``` +┌──────┐ │ ┌───────┐ ┌───────┐ ┌──────┐ +│Client├─┼──►│Session├───►│Account├──►│Object│ +└──────┘ │ └───────┘ └───────┘ └──────┘ + ^ +``` + +All in-game objects in Evennia, be it characters, chairs, monsters, rooms or hand grenades are jointly referred to as an Evennia *Object*. An Object is generally something you can look and interact with in the game world. When a message travels from the client, the Object-level is the last stop. + +Objects form the core of Evennia and is probably what you'll spend most time working with. Objects are [Typeclassed](./Typeclasses.md) entities. + +An Evennia Object is, by definition, a Python class that includes [evennia.objects.objects.DefaultObject](evennia.objects.objects.DefaultObject) among its parents. Evennia defines several subclasses of `DefaultObject`: + +- `Object` - the base in-game entity. Found in `mygame/typeclasses/objects.py`. Inherits directly from `DefaultObject`. +- [Characters](./Characters.md) - the normal in-game Character, controlled by a player. Found in `mygame/typeclasses/characters.py`. Inherits from `DefaultCharacter`, which is turn a child of `DefaultObject`. +- [Rooms](./Rooms.md) - a location in the game world. Found in `mygame/typeclasses/rooms.py`. Inherits from `DefaultRoom`, which is in turn a child of `DefaultObject`). +- [Exits](./Exits.md) - represents a one-way connection to another location. Found in `mygame/typeclasses/exits.py` (inherits from `DefaultExit`, which is in turn a child of `DefaultObject`). + +## Object + +**Inheritance Tree:** +``` +┌─────────────┐ +│DefaultObject│ +└──────▲──────┘ + │ ┌────────────┐ + │ ┌─────►ObjectParent│ + │ │ └────────────┘ + ┌─┴─┴──┐ + │Object│ + └──────┘ +``` + +> For an explanation of `ObjectParent`, see next section. + +The `Object` class is meant to be used as the basis for creating things that are neither characters, rooms or exits - anything from weapons and armour, equipment and houses can be represented by extending the Object class. Depending on your game, this also goes for NPCs and monsters (in some games you may want to treat NPCs as just an un-puppeted [Character](./Characters.md) instead). + +You should not use Objects for game _systems_. Don't use an 'invisible' Object for tracking weather, combat, economy or guild memberships - that's what [Scripts](./Scripts.md) are for. + +## ObjectParent - Adding common functionality + +`Object`, as well as `Character`, `Room` and `Exit` classes all additionally inherit from `mygame.typeclasses.objects.ObjectParent`. + +`ObjectParent` is an empty 'mixin' class. You can add stuff to this class that you want _all_ in-game entities to have. + +Here is an example: + +```python +# in mygame/typeclasses/objects.py +# ... + +from evennia.objects.objects import DefaultObject + +class ObjectParent: + def at_pre_get(self, getter, **kwargs): + # make all entities by default un-pickable + return False +``` + +Now all of `Object`, `Exit`. `Room` and `Character` default to not being able to be picked up using the `get` command. + +## Working with children of DefaultObject + +This functionality is shared by all sub-classes of `DefaultObject`. You can easily add your own in-game behavior by either modifying one of the typeclasses in your game dir or by inheriting further from them. + +You can put your new typeclass directly in the relevant module, or you could organize your code in some other way. Here we assume we make a new module `mygame/typeclasses/flowers.py`: + +```python + # mygame/typeclasses/flowers.py + + from typeclasses.objects import Object + + class Rose(Object): + """ + This creates a simple rose object + """ + def at_object_creation(self): + "this is called only once, when object is first created" + # add a persistent attribute 'desc' + # to object (silly example). + self.db.desc = "This is a pretty rose with thorns." +``` + +Now you just need to point to the class *Rose* with the `create` command to make a new rose: + + create/drop MyRose:flowers.Rose + +What the `create` command actually *does* is to use the [evennia.create_object](evennia.utils.create.create_object) function. You can do the same thing yourself in code: + +```python + from evennia import create_object + new_rose = create_object("typeclasses.flowers.Rose", key="MyRose") +``` + +(The `create` command will auto-append the most likely path to your typeclass, if you enter the call manually you have to give the full path to the class. The `create.create_object` function is powerful and should be used for all coded object creating (so this is what you use when defining your own building commands). + +This particular Rose class doesn't really do much, all it does it make sure the attribute `desc`(which is what the `look` command looks for) is pre-set, which is pretty pointless since you will usually want to change this at build time (using the `desc` command or using the [Spawner](./Prototypes.md)). + +### Properties and functions on Objects + +Beyond the properties assigned to all [typeclassed](./Typeclasses.md) objects (see that page for a list +of those), the Object also has the following custom properties: + +- `aliases` - a handler that allows you to add and remove aliases from this object. Use `aliases.add()` to add a new alias and `aliases.remove()` to remove one. +- `location` - a reference to the object currently containing this object. +- `home` is a backup location. The main motivation is to have a safe place to move the object to if its `location` is destroyed. All objects should usually have a home location for safety. +- `destination` - this holds a reference to another object this object links to in some way. Its main use is for [Exits](./Exits.md), it's otherwise usually unset. +- `nicks` - as opposed to aliases, a [Nick](./Nicks.md) holds a convenient nickname replacement for a real name, word or sequence, only valid for this object. This mainly makes sense if the Object is used as a game character - it can then store briefer shorts, example so as to quickly reference game commands or other characters. Use nicks.add(alias, realname) to add a new one. +- `account` - this holds a reference to a connected [Account](./Accounts.md) controlling this object (if any). Note that this is set also if the controlling account is *not* currently online - to test if an account is online, use the `has_account` property instead. +- `sessions` - if `account` field is set *and the account is online*, this is a list of all active sessions (server connections) to contact them through (it may be more than one if multiple connections are allowed in settings). +- `has_account` - a shorthand for checking if an *online* account is currently connected to this object. +- `contents` - this returns a list referencing all objects 'inside' this object (i,e. which has this object set as their `location`). +- `exits` - this returns all objects inside this object that are *Exits*, that is, has the `destination` property set. +- `appearance_template` - this helps formatting the look of the Object when someone looks at it (see next section).l +- `cmdset` - this is a handler that stores all [command sets](./Command-Sets.md) defined on the object (if any). +- `scripts` - this is a handler that manages [Scripts](./Scripts.md) attached to the object (if any). + +The Object also has a host of useful utility functions. See the function headers in `src/objects/objects.py` for their arguments and more details. + +- `msg()` - this function is used to send messages from the server to an account connected to this object. +- `msg_contents()` - calls `msg` on all objects inside this object. +- `search()` - this is a convenient shorthand to search for a specific object, at a given location or globally. It's mainly useful when defining commands (in which case the object executing the command is named `caller` and one can do `caller.search()` to find objects in the room to operate on). +- `execute_cmd()` - Lets the object execute the given string as if it was given on the command line. +- `move_to` - perform a full move of this object to a new location. This is the main move method and will call all relevant hooks, do all checks etc. +- `clear_exits()` - will delete all [Exits](./Exits.md) to *and* from this object. +- `clear_contents()` - this will not delete anything, but rather move all contents (except Exits) to their designated `Home` locations. +- `delete()` - deletes this object, first calling `clear_exits()` and `clear_contents()`. +- `return_appearance` is the main hook letting the object visually describe itself. + +The Object Typeclass defines many more *hook methods* beyond `at_object_creation`. Evennia calls these hooks at various points. When implementing your custom objects, you will inherit from the base parent and overload these hooks with your own custom code. See `evennia.objects.objects` for an updated list of all the available hooks or the [API for DefaultObject here](evennia.objects.objects.DefaultObject). + + +## Changing an Object's appearance + +When you type `look `, this is the sequence of events that happen: + +1. The command checks if the `caller` of the command (the 'looker') passes the `view` [lock](./Locks.md) of the target `obj`. If not, they will not find anything to look at (this is how you make objects invisible). +1. The `look` command calls `caller.at_look(obj)` - that is, the `at_look` hook on the 'looker' (the caller of the command) is called to perform the look on the target object. The command will echo whatever this hook returns. +2. `caller.at_look` calls and returns the outcome of `obj.return_apperance(looker, **kwargs)`. Here `looker` is the `caller` of the command. In other words, we ask the `obj` to descibe itself to `looker`. +3. `obj.return_appearance` makes use of its `.appearance_template` property and calls a slew of helper-hooks to populate this template. This is how the template looks by default: + + ```python + appearance_template = """ + {header} + |c{name}|n + {desc} + {exits}{characters}{things} + {footer} + """``` + +4. Each field of the template is populated by a matching helper method (and their default returns): + - `name` -> `obj.get_display_name(looker, **kwargs)` - returns `obj.name`. + - `desc` -> `obj.get_display_desc(looker, **kwargs)` - returns `obj.db.desc`. + - `header` -> `obj.get_display_header(looker, **kwargs)` - empty by default. + - `footer` -> `obj.get_display_footer(looker, **kwargs)` - empty by default. + - `exits` -> `obj.get_display_exits(looker, **kwargs)` - a list of `DefaultExit`-inheriting objects found inside this object (usually only present if `obj` is a `Room`). + - `characters` -> `obj.get_display_characters(looker, **kwargs)` - a list of `DefaultCharacter`-inheriting entities inside this object. + - `things` -> `obj.get_display_things(looker, **kwargs)` - a list of all other Objects inside `obj`. +5. `obj.format_appearance(string, looker, **kwargs)` is the last step the populated template string goes through. This can be used for final adjustments, such as stripping whitespace. The return from this method is what the user will see. + +As each of these hooks (and the template itself) can be overridden in your child class, you can customize your look extensively. You can also have objects look different depending on who is looking at them. The extra `**kwargs` are not used by default, but are there to allow you to pass extra data into the system if you need it (like light conditions etc.) \ No newline at end of file diff --git a/docs/latest/_sources/Components/Permissions.md.txt b/docs/latest/_sources/Components/Permissions.md.txt new file mode 100644 index 0000000000..c117b5de53 --- /dev/null +++ b/docs/latest/_sources/Components/Permissions.md.txt @@ -0,0 +1,179 @@ +# Permissions + +A *permission* is simply a text string stored in the handler `permissions` on `Objects` and `Accounts`. Think of it as a specialized sort of [Tag](./Tags.md) - one specifically dedicated to access checking. They are thus often tightly coupled to [Locks](./Locks.md). Permission strings are not case-sensitive, so "Builder" is the same as "builder" etc. + +Permissions are used as a convenient way to structure access levels and hierarchies. It is set by the `perm` command and checked by the `PermissionHandler.check` method as well as by the specially the `perm()` and `pperm()` [lock functions](./Locks.md). + +All new accounts are given a default set of permissions defined by `settings.PERMISSION_ACCOUNT_DEFAULT`. + +## The super user + +There are strictly speaking two types of users in Evennia, the *super user* and everyone else. The +superuser is the first user you create, object `#1`. This is the all-powerful server-owner account. +Technically the superuser not only has access to everything, it *bypasses* the permission checks +entirely. + +This makes the superuser impossible to lock out, but makes it unsuitable to actually play- +test the game's locks and restrictions with (see `quell` below). Usually there is no need to have +but one superuser. + +## Working with Permissions + +In-game, you use the `perm` command to add and remove permissions + + > perm/account Tommy = Builders + > perm/account/del Tommy = Builders + +Note the use of the `/account` switch. It means you assign the permission to the [Accounts](./Accounts.md) Tommy instead of any [Character](./Objects.md) that also happens to be named "Tommy". If you don't want to use `/account`, you can also prefix the name with `*` to indicate an Account is sought: + + > perm *Tommy = Builders + +There can be reasons for putting permissions on Objects (especially NPCS), but for granting powers to players, you should usually put the permission on the `Account` - this guarantees that they are kept, *regardless* of which Character they are currently puppeting. + +This is especially important to remember when assigning permissions from the *hierarchy tree* (see below), as an Account's permissions will overrule that of its character. So to be sure to avoid confusion you should generally put hierarchy permissions on the Account, not on their Characters/puppets. + +If you _do_ want to start using the permissions on your _puppet_, you use `quell` + + > quell + > unquell + +This drops to the permissions on the puppeted object, and then back to your Account-permissions again. Quelling is useful if you want to try something "as" someone else. It's also useful for superusers since this makes them susceptible to locks (so they can test things). + +In code, you add/remove Permissions via the `PermissionHandler`, which sits on all +typeclassed entities as the property `.permissions`: + +```python + account.permissions.add("Builders") + account.permissions.add("cool_guy") + obj.permissions.add("Blacksmith") + obj.permissions.remove("Blacksmith") +``` + +### The permission hierarchy + +Selected permission strings can be organized in a *permission hierarchy* by editing the tuple +`settings.PERMISSION_HIERARCHY`. Evennia's default permission hierarchy is as follows +(in increasing order of power): + + Guest # temporary account, only used if GUEST_ENABLED=True (lowest) + Player # can chat and send tells (default level) + Helper # can edit help files + Builder # can edit the world + Admin # can administrate accounts + Developer # like superuser but affected by locks (highest) + +(Besides being case-insensitive, hierarchical permissions also understand the plural form, so you could use `Developers` and `Developer` interchangeably). + +When checking a hierarchical permission (using one of the methods to follow), you will pass checks for your level *and below*. That is, if you have the "Admin" hierarchical permission, you will also pass checks asking for "Builder", "Helper" and so on. + +By contrast, if you check for a non-hierarchical permission, like "Blacksmith" you must have *exactly* that permission to pass. + +### Checking permissions + +It's important to note that you check for the permission of a *puppeted* [Object](./Objects.md) (like a Character), the check will always first use the permissions of any `Account` connected to that Object before checking for permissions on the Object. In the case of hierarchical permissions (Admins, Builders etc), the Account permission will always be used (this stops an Account from escalating their permission by puppeting a high-level Character). If the permission looked for is not in the hierarchy, an exact match is required, first on the Account and if not found there (or if no Account is connected), then on the Object itself. + +### Checking with obj.permissions.check() + +The simplest way to check if an entity has a permission is to check its _PermissionHandler_, stored as `.permissions` on all typeclassed entities. + + if obj.permissions.check("Builder"): + # allow builder to do stuff + + if obj.permissions.check("Blacksmith", "Warrior"): + # do stuff for blacksmiths OR warriors + + if obj.permissions.check("Blacksmith", "Warrior", require_all=True): + # only for those that are both blacksmiths AND warriors + +Using the `.check` method is the way to go, it will take hierarchical +permissions into account, check accounts/sessions etc. + +```{warning} + + Don't confuse `.permissions.check()` with `.permissions.has()`. The .has() + method checks if a string is defined specifically on that PermissionHandler. + It will not consider permission-hierarchy, puppeting etc. `.has` can be useful + if you are manipulating permissions, but use `.check` for access checking. + +``` + +### Lock funcs + +While the `PermissionHandler` offers a simple way to check perms, [Lock +strings](./Locks.md) offers a mini-language for describing how something is accessed. +The `perm()` _lock function_ is the main tool for using Permissions in locks. + +Let's say we have a `red_key` object. We also have red chests that we want to +unlock with this key. + + perm red_key = unlocks_red_chests + +This gives the `red_key` object the permission "unlocks_red_chests". Next we +lock our red chests: + + lock red chest = unlock:perm(unlocks_red_chests) + +When trying to unlock the red chest with this key, the chest Typeclass could +then take the key and do an access check: + +```python +# in some typeclass file where chest is defined + +class TreasureChest(Object): + + # ... + + def open_chest(self, who, tried_key): + + if not chest.access(who, tried_key, "unlock"): + who.msg("The key does not fit!") + return + else: + who.msg("The key fits! The chest opens.") + # ... + +``` + +There are several variations to the default `perm` lockfunc: + +- `perm_above` - requires a hierarchical permission *higher* than the one + provided. Example: `"edit: perm_above(Player)"` +- `pperm` - looks *only* for permissions on `Accounts`, never at any puppeted + objects (regardless of hierarchical perm or not). +- `pperm_above` - like `perm_above`, but for Accounts only. + +### Some examples + +Adding permissions and checking with locks + +```python + account.permissions.add("Builder") + account.permissions.add("cool_guy") + account.locks.add("enter:perm_above(Player) and perm(cool_guy)") + account.access(obj1, "enter") # this returns True! +``` + +An example of a puppet with a connected account: + +```python + account.permissions.add("Player") + puppet.permissions.add("Builders") + puppet.permissions.add("cool_guy") + obj2.locks.add("enter:perm_above(Accounts) and perm(cool_guy)") + + obj2.access(puppet, "enter") # this returns False, since puppet permission + # is lower than Account's perm, and perm takes + # precedence. +``` + + +## Quelling + +The `quell` command can be used to enforce the `perm()` lockfunc to ignore +permissions on the Account and instead use the permissions on the Character +only. This can be used e.g. by staff to test out things with a lower permission +level. Return to the normal operation with `unquell`. Note that quelling will +use the smallest of any hierarchical permission on the Account or Character, so +one cannot escalate one's Account permission by quelling to a high-permission +Character. Also the superuser can quell their powers this way, making them +affectable by locks. diff --git a/docs/latest/_sources/Components/Portal-And-Server.md.txt b/docs/latest/_sources/Components/Portal-And-Server.md.txt new file mode 100644 index 0000000000..4918275fec --- /dev/null +++ b/docs/latest/_sources/Components/Portal-And-Server.md.txt @@ -0,0 +1,26 @@ +# Portal And Server + +``` +Internet│ ┌──────────┐ ┌─┐ ┌─┐ ┌─────────┐ + │ │Portal │ │S│ ┌───┐ │S│ │Server │ + P │ │ │ │e│ │AMP│ │e│ │ │ + l ──┼──┤ Telnet ├─┤s├───┤ ├───┤s├─┤ │ + a │ │ Webclient│ │s│ │ │ │s│ │ Game │ + y ──┼──┤ SSH ├─┤i├───┤ ├───┤i├─┤ Database│ + e │ │ ... │ │o│ │ │ │o│ │ │ + r ──┼──┤ ├─┤n├───┤ ├───┤n├─┤ │ + s │ │ │ │s│ └───┘ │s│ │ │ + │ └──────────┘ └─┘ └─┘ └─────────┘ + │Evennia +``` + +The _Portal_ and _Server_ consitutes the two main halves of Evennia. + +These are two separate `twistd` processes and can be controlled from inside the game or from the command line as described [in the Running-Evennia doc](../Setup/Running-Evennia.md). + +- The Portal knows everything about internet protocols (telnet, websockets etc), but knows very little about the game. +- The Server knows everything about the game. It knows that a player has connected but now _how_ they connected. + +The effect of this is that you can fully `reload` the Server and have players still connected to the game. One the server comes back up, it will re-connect to the Portal and re-sync all players as if nothing happened. + +The Portal and Server are intended to always run on the same machine. They are glued together via an AMP (Asynchronous Messaging Protocol) connection. This allows the two programs to communicate seamlessly. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Prototypes.md.txt b/docs/latest/_sources/Components/Prototypes.md.txt new file mode 100644 index 0000000000..901859a2f1 --- /dev/null +++ b/docs/latest/_sources/Components/Prototypes.md.txt @@ -0,0 +1,244 @@ +# Spawner and Prototypes + +```shell +> spawn goblin + +Spawned Goblin Grunt(#45) +``` + +The *spawner* is a system for defining and creating individual objects from a base template called a *prototype*. It is only designed for use with in-game [Objects](./Objects.md), not any other type of entity. + +The normal way to create a custom object in Evennia is to make a [Typeclass](./Typeclasses.md). If you haven't read up on Typeclasses yet, think of them as normal Python classes that save to the database behind the scenes. Say you wanted to create a "Goblin" enemy. A common way to do this would be to first create a `Mobile` typeclass that holds everything common to mobiles in the game, like generic AI, combat code and various movement methods. A `Goblin` subclass is then made to inherit from `Mobile`. The `Goblin` class adds stuff unique to goblins, like group-based AI (because goblins are smarter in a group), the ability to panic, dig for gold etc. + +But now it's time to actually start to create some goblins and put them in the world. What if we wanted those goblins to not all look the same? Maybe we want grey-skinned and green-skinned goblins or some goblins that can cast spells or which wield different weapons? We *could* make subclasses of `Goblin`, like `GreySkinnedGoblin` and `GoblinWieldingClub`. But that seems a bit excessive (and a lot of Python code for every little thing). Using classes can also become impractical when wanting to combine them - what if we want a grey-skinned goblin shaman wielding a spear - setting up a web of classes inheriting each other with multiple inheritance can be tricky. + +This is what the *prototype* is for. It is a Python dictionary that describes these per-instance changes to an object. The prototype also has the advantage of allowing an in-game builder to customize an object without access to the Python backend. Evennia also allows for saving and searching prototypes so other builders can find and use (and tweak) them later. Having a library of interesting prototypes is a good reasource for builders. The OLC system allows for creating, saving, loading and manipulating prototypes using a menu system. + +The *spawner* takes a prototype and uses it to create (spawn) new, custom objects. + +## Working with Prototypes + +### Using the OLC + +Enter the `olc` command or `spawn/olc` to enter the prototype wizard. This is a menu system for creating, loading, saving and manipulating prototypes. It's intended to be used by in-game builders and will give a better understanding of prototypes in general. Use `help` on each node of the menu for more information. Below are further details about how prototypes work and how they are used. + +### The prototype + +The prototype dictionary can either be created for you by the OLC (see above), be written manually in a Python module (and then referenced by the `spawn` command/OLC), or created on-the-fly and manually loaded into the spawner function or `spawn` command. + +The dictionary defines all possible database-properties of an Object. It has a fixed set of allowed keys. When preparing to store the prototype in the database (or when using the OLC), some of these keys are mandatory. When just passing a one-time prototype-dict to the spawner the system is more lenient and will use defaults for keys not explicitly provided. + +In dictionary form, a prototype can look something like this: + +```python +{ + "prototype_key": "house" + "key": "Large house" + "typeclass": "typeclasses.rooms.house.House" + } +``` +If you wanted to load it into the spawner in-game you could just put all on one line: + + spawn {"prototype_key="house", "key": "Large house", ...} + +> Note that the prototype dict as given on the command line must be a valid Python structure - so you need to put quotes around strings etc. For security reasons, a dict inserted from-in game cannot have any other advanced Python functionality, such as executable code, `lambda` etc. If builders are supposed to be able to use such features, you need to offer them through [$protfuncs](Spawner-and- Prototypes#protfuncs), embedded runnable functions that you have full control to check and vet before running. + +### Prototype keys + +All keys starting with `prototype_` are for book keeping. + + - `prototype_key` - the 'name' of the prototype, used for referencing the prototype + when spawning and inheritance. If defining a prototype in a module and this + not set, it will be auto-set to the name of the prototype's variable in the module. + - `prototype_parent` - If given, this should be the `prototype_key` of another prototype stored in the system or available in a module. This makes this prototype *inherit* the keys from the + parent and only override what is needed. Give a tuple `(parent1, parent2, ...)` for multiple left-right inheritance. If this is not given, a `typeclass` should usually be defined (below). + - `prototype_desc` - this is optional and used when listing the prototype in in-game listings. + - `protototype_tags` - this is optional and allows for tagging the prototype in order to find it + easier later. + - `prototype_locks` - two lock types are supported: `edit` and `spawn`. The first lock restricts the copying and editing of the prototype when loaded through the OLC. The second determines who may use the prototype to create new objects. + + +The remaining keys determine actual aspects of the objects to spawn from this prototype: + + - `key` - the main object identifier. Defaults to "Spawned Object *X*", where *X* is a random integer. + - `typeclass` - A full python-path (from your gamedir) to the typeclass you want to use. If not set, the `prototype_parent` should be defined, with `typeclass` defined somewhere in the parent chain. When creating a one-time prototype dict just for spawning, one could omit this - `settings.BASE_OBJECT_TYPECLASS` will be used instead. + - `location` - this should be a `#dbref`. + - `home` - a valid `#dbref`. Defaults to `location` or `settings.DEFAULT_HOME` if location does not exist. + - `destination` - a valid `#dbref`. Only used by exits. + - `permissions` - list of permission strings, like `["Accounts", "may_use_red_door"]` + - `locks` - a [lock-string](./Locks.md) like `"edit:all();control:perm(Builder)"` + - `aliases` - list of strings for use as aliases + - `tags` - list [Tags](./Tags.md). These are given as tuples `(tag, category, data)`. + - `attrs` - list of [Attributes](./Attributes.md). These are given as tuples `(attrname, value, category, lockstring)` + - Any other keywords are interpreted as non-category [Attributes](./Attributes.md) and their values. This is convenient for simple Attributes - use `attrs` for full control of Attributes. + +#### More on prototype inheritance + +- A prototype can inherit by defining a `prototype_parent` pointing to the name (`prototype_key` of another prototype). If a list of `prototype_keys`, this will be stepped through from left to right, giving priority to the first in the list over those appearing later. That is, if your inheritance is `prototype_parent = ('A', 'B,' 'C')`, and all parents contain colliding keys, then the one from `A` will apply. +- The prototype keys that start with `prototype_*` are all unique to each prototype. They are _never_ inherited from parent to child. +- The prototype fields `'attr': [(key, value, category, lockstring),...]` and `'tags': [(key, category, data), ...]` are inherited in a _complementary_ fashion. That means that only colliding key+category matches will be replaced, not the entire list. Remember that the category `None` is also considered a valid category! +- Adding an Attribute as a simple `key:value` will under the hood be translated into an Attribute tuple `(key, value, None, '')` and may replace an Attribute in the parent if it the same key and a `None` category. +- All other keys (`permissions`, `destination`, `aliases` etc) are completely _replaced_ by the child's value if given. For the parent's value to be retained, the child must not define these keys at all. + +### Prototype values + +The prototype supports values of several different types. + +It can be a hard-coded value: + +```python + {"key": "An ugly goblin", ...} + +``` + +It can also be a *callable*. This callable is called without arguments whenever the prototype is used to spawn a new object: + +```python + {"key": _get_a_random_goblin_name, ...} + +``` + +By use of Python `lambda` one can wrap the callable so as to make immediate settings in the prototype: + +```python + {"key": lambda: random.choice(("Urfgar", "Rick the smelly", "Blargh the foul", ...)), ...} + +``` + +#### Protfuncs + +Finally, the value can be a *prototype function* (*Protfunc*). These look like simple function calls that you embed in strings and that has a `$` in front, like + +```python + {"key": "$choice(Urfgar, Rick the smelly, Blargh the foul)", + "attrs": {"desc": "This is a large $red(and very red) demon. " + "He has $randint(2,5) skulls in a chain around his neck."} +``` + +> If you want to escape a protfunc and have it appear verbatim, use `$$funcname()`. + +At spawn time, the place of the protfunc will be replaced with the result of that protfunc being called (this is always a string). A protfunc is a [FuncParser function](./FuncParser.md) run every time the prototype is used to spawn a new object. See the FuncParse for a lot more information. + +Here is how a protfunc is defined (same as other funcparser functions). + +```python +# this is a silly example, you can just color the text red with |r directly! +def red(*args, **kwargs): + """ + Usage: $red() + Returns the same text you entered, but red. + """ + if not args or len(args) > 1: + raise ValueError("Must have one argument, the text to color red!") + return f"|r{args[0]}|n" +``` + +> Note that we must make sure to validate input and raise `ValueError` on failure. + +The parser will always include the following reserved `kwargs`: +- `session` - the current [Session](evennia.server.ServerSession) performing the spawning. +- `prototype` - The Prototype-dict this function is a part of. This is intended to be used _read-only_. Be careful to modify a mutable structure like this from inside the function - you can cause really hard-to-find bugs this way. +- `current_key` - The current key of the `prototype` dict under which this protfunc is executed. + +To make this protfunc available to builders in-game, add it to a new module and add the path to that module to `settings.PROT_FUNC_MODULES`: + +```python +# in mygame/server/conf/settings.py + +PROT_FUNC_MODULES += ["world.myprotfuncs"] + +``` +All *global callables* in your added module will be considered a new protfunc. To avoid this (e.g. to have helper functions that are not protfuncs on their own), name your function something starting with `_`. + +The default protfuncs available out of the box are defined in `evennia/prototypes/profuncs.py`. To override the ones available, just add the same-named function in your own protfunc module. + +| Protfunc | Description | +| --- | --- | +| `$random()` | Returns random value in range `[0, 1)` | +| `$randint(start, end)` | Returns random value in range [start, end] | +| `$left_justify()` | Left-justify text | +| `$right_justify()` | Right-justify text to screen width | +| `$center_justify()` | Center-justify text to screen width | +| `$full_justify()` | Spread text across screen width by adding spaces | +| `$protkey()` | Returns value of another key in this prototype (self-reference) | +| `$add(, )` | Returns value1 + value2. Can also be lists, dicts etc | +| `$sub(, )` | Returns value1 - value2 | +| `$mult(, )` | Returns value1 * value2 | +| `$div(, )` | Returns value2 / value1 | +| `$toint()` | Returns value converted to integer (or value if not possible) | +| `$eval()` | Returns result of [literal-eval](https://docs.python.org/2/library/ast.html#ast.literal_eval) of code string. Only simple python expressions. | +| `$obj()` | Returns object #dbref searched globally by key, tag or #dbref. Error if more than one found. | +| `$objlist()` | Like `$obj`, except always returns a list of zero, one or more results. | +| `$dbref(dbref)` | Returns argument if it is formed as a #dbref (e.g. #1234), otherwise error. | + +For developers with access to Python, using protfuncs in prototypes is generally not useful. Passing real Python functions is a lot more powerful and flexible. Their main use is to allow in-game builders to do limited coding/scripting for their prototypes without giving them direct access to raw Python. + +## Database prototypes + +Stored as [Scripts](./Scripts.md) in the database. These are sometimes referred to as *database- prototypes* This is the only way for in-game builders to modify and add prototypes. They have the advantage of being easily modifiable and sharable between builders but you need to work with them using in-game tools. + +## Module-based prototypes + +These prototypes are defined as dictionaries assigned to global variables in one of the modules defined in `settings.PROTOTYPE_MODULES`. They can only be modified from outside the game so they are are necessarily "read-only" from in-game and cannot be modified (but copies of them could be made into database-prototypes). These were the only prototypes available before Evennia 0.8. Module based prototypes can be useful in order for developers to provide read-only "starting" or "base" prototypes to build from or if they just prefer to work offline in an external code editor. + +By default `mygame/world/prototypes.py` is set up for you to add your own prototypes. *All global +dicts* in this module will be considered by Evennia to be a prototype. You could also tell Evennia +to look for prototypes in more modules if you want: + +```python +# in mygame/server/conf.py + +PROTOTYPE_MODULES = += ["world.myownprototypes", "combat.prototypes"] + +``` + +Here is an example of a prototype defined in a module: + + ```python + # in a module Evennia looks at for prototypes, + # (like mygame/world/prototypes.py) + + ORC_SHAMAN = {"key":"Orc shaman", + "typeclass": "typeclasses.monsters.Orc", + "weapon": "wooden staff", + "health": 20} + ``` + +> Note that in the example above, `"ORC_SHAMAN"` will become the `prototype_key` of this prototype. It's the only case when `prototype_key` can be skipped in a prototype. However, if `prototype_key`was given explicitly, that would take precedence. This is a legacy behavior and it's recommended > that you always add `prototype_key` to be consistent. + + +## Spawning + +The spawner can be used from inside the game through the Builder-only `@spawn` command. Assuming the "goblin" typeclass is available to the system (either as a database-prototype or read from module), you can spawn a new goblin with + + spawn goblin + +You can also specify the prototype directly as a valid Python dictionary: + + spawn {"prototype_key": "shaman", \ + "key":"Orc shaman", \ + "prototype_parent": "goblin", \ + "weapon": "wooden staff", \ + "health": 20} + +> Note: The `spawn` command is more lenient about the prototype dictionary than shown here. So you can for example skip the `prototype_key` if you are just testing a throw-away prototype. A random hash will be used to please the validation. You could also skip `prototype_parent/typeclass` - then the typeclass given by `settings.BASE_OBJECT_TYPECLASS` will be used. + +### Using evennia.prototypes.spawner() + +In code you access the spawner mechanism directly via the call + +```python + new_objects = evennia.prototypes.spawner.spawn(*prototypes) +``` + +All arguments are prototype dictionaries. The function will return a +matching list of created objects. Example: + +```python + obj1, obj2 = evennia.prototypes.spawner.spawn({"key": "Obj1", "desc": "A test"}, + {"key": "Obj2", "desc": "Another test"}) +``` + +> Hint: Same as when using `spawn`, when spawning from a one-time prototype dict like this, you can skip otherwise required keys, like `prototype_key` or `typeclass`/`prototype_parent`. Defaults will be used. + +Note that no `location` will be set automatically when using `evennia.prototypes.spawner.spawn()`, you have to specify `location` explicitly in the prototype dict. If the prototypes you supply are using `prototype_parent` keywords, the spawner will read prototypes from modules in `settings.PROTOTYPE_MODULES` as well as those saved to the database to determine the body of available parents. The `spawn` command takes many optional keywords, you can find its definition [in the api docs](https://www.evennia.com/docs/latest/api/evennia.prototypes.spawner.html#evennia.prototypes.spawner.spawn) \ No newline at end of file diff --git a/docs/latest/_sources/Components/Rooms.md.txt b/docs/latest/_sources/Components/Rooms.md.txt new file mode 100644 index 0000000000..1543c4629d --- /dev/null +++ b/docs/latest/_sources/Components/Rooms.md.txt @@ -0,0 +1,31 @@ + +# Rooms + +**Inheritance Tree:** +``` +┌─────────────┐ +│DefaultObject│ +└─────▲───────┘ + │ +┌─────┴─────┐ +│DefaultRoom│ +└─────▲─────┘ + │ ┌────────────┐ + │ ┌─────►ObjectParent│ + │ │ └────────────┘ + ┌─┴─┴┐ + │Room│ + └────┘ +``` + +[Rooms](evennia.objects.objects.DefaultRoom) are in-game [Objects](./Objects.md) representing the root containers of all other objects. + +The only thing technically separating a room from any other object is that they have no `location` of their own and that default commands like `dig` creates objects of this class - so if you want to expand your rooms with more functionality, just inherit from `evennia.DefaultRoom`. + +To change the default room created by `dig`, `tunnel` and other default commands, change it in settings: + + BASE_ROOM_TYPECLASS = "typeclases.rooms.Room" + +The empty class in `mygame/typeclasses/rooms.py` is a good place to start! + +While the default Room is very simple, there are several Evennia [contribs](../Contribs/Contribs-Overview.md) customizing and extending rooms with more functionality. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Scripts.md.txt b/docs/latest/_sources/Components/Scripts.md.txt new file mode 100644 index 0000000000..d4e8b4ada7 --- /dev/null +++ b/docs/latest/_sources/Components/Scripts.md.txt @@ -0,0 +1,384 @@ +# Scripts + +[Script API reference](evennia.scripts.scripts) + +*Scripts* are the out-of-character siblings to the in-character [Objects](./Objects.md). Scripts are so flexible that the name "Script" is a bit limiting in itself - but we had to pick _something_ to name them. Other possible names (depending on what you'd use them for) would be `OOBObjects`, `StorageContainers` or `TimerObjects`. + +If you ever consider creating an [Object](./Objects.md) with a `None`-location just to store some game data, you should really be using a Script instead. + +- Scripts are full [Typeclassed](./Typeclasses.md) entities - they have [Attributes](./Attributes.md) and can be modified in the same way. But they have _no in-game existence_, so no location or command-execution like [Objects](./Objects.md) and no connection to a particular player/session like [Accounts](./Accounts.md). This means they are perfectly suitable for acting as database-storage backends for game _systems_: Storing the current state of the economy, who is involved in the current fight, tracking an ongoing barter and so on. They are great as persistent system handlers. +- Scripts have an optional _timer component_. This means that you can set up the script to tick the `at_repeat` hook on the Script at a certain interval. The timer can be controlled independently of the rest of the script as needed. This component is optional and complementary to other timing functions in Evennia, like [evennia.utils.delay](evennia.utils.utils.delay) and [evennia.utils.repeat](evennia.utils.utils.repeat). +- Scripts can _attach_ to Objects and Accounts via e.g. `obj.scripts.add/remove`. In the script you can then access the object/account as `self.obj` or `self.account`. This can be used to dynamically extend other typeclasses but also to use the timer component to affect the parent object in various ways. For historical reasons, a Script _not_ attached to an object is referred to as a _Global_ Script. + +```{versionchanged} 1.0 + In previous Evennia versions, stopping the Script's timer also meant deleting the Script object. + Starting with this version, the timer can be start/stopped separately and `.delete()` must be called + on the Script explicitly to delete it. + +``` + +## Working with Scripts + +There are two main commands controlling scripts in the default cmdset: + +The `addscript` command is used for attaching scripts to existing objects: + + > addscript obj = bodyfunctions.BodyFunctions + +The `scripts` command is used to view all scripts and perform operations on them: + + > scripts + > scripts/stop bodyfunctions.BodyFunctions + > scripts/start #244 + > scripts/pause #11 + > scripts/delete #566 + +```{versionchanged} 1.0 +The `addscript` command used to be only `script` which was easy to confuse with `scripts`. +``` + +### Code examples + +Here are some examples of working with Scripts in-code (more details to follow in later +sections). + +Create a new script: +```python +new_script = evennia.create_script(key="myscript", typeclass=...) +``` + +Create script with timer component: + +```python +# (note that this will call `timed_script.at_repeat` which is empty by default) +timed_script = evennia.create_script(key="Timed script", + interval=34, # seconds <=0 means off + start_delay=True, # wait interval before first call + autostart=True) # start timer (else needing .start() ) + +# manipulate the script's timer +timed_script.stop() +timed_script.start() +timed_script.pause() +timed_script.unpause() +``` + +Attach script to another object: + +```python +myobj.scripts.add(new_script) +myobj.scripts.add(evennia.DefaultScript) +all_scripts_on_obj = myobj.scripts.all() +``` + +Search/find scripts in various ways: + +```python +# regular search (this is always a list, also if there is only one match) +list_of_myscripts = evennia.search_script("myscript") + +# search through Evennia's GLOBAL_SCRIPTS container (based on +# script's key only) +from evennia import GLOBAL_SCRIPTS + +myscript = GLOBAL_SCRIPTS.myscript +GLOBAL_SCRIPTS.get("Timed script").db.foo = "bar" +``` + +Delete the Script (this will also stop its timer): + +```python +new_script.delete() +timed_script.delete() +``` + +### Defining new Scripts + +A Script is defined as a class and is created in the same way as other +[typeclassed](./Typeclasses.md) entities. The parent class is `evennia.DefaultScript`. + + +#### Simple storage script + +In `mygame/typeclasses/scripts.py` is an empty `Script` class already set up. You +can use this as a base for your own scripts. + +```python +# in mygame/typeclasses/scripts.py + +from evennia import DefaultScript + +class Script(DefaultScript): + # stuff common for all your scripts goes here + +class MyScript(Script): + def at_script_creation(self): + """Called once, when script is first created""" + self.key = "myscript" + self.db.foo = "bar" + +``` + +Once created, this simple Script could act as a global storage: + +```python +evennia.create_script('typeclasses.scripts.MyScript') + +# from somewhere else + +myscript = evennia.search_script("myscript").first() +bar = myscript.db.foo +myscript.db.something_else = 1000 + +``` + +Note that if you give keyword arguments to `create_script` you can override the values +you set in your `at_script_creation`: + +```python + +evennia.create_script('typeclasses.scripts.MyScript', key="another name", + attributes=[("foo", "bar-alternative")]) + + +``` + +See the [create_script](evennia.utils.create.create_script) and [search_script](evennia.utils.search.search_script) API documentation for more options on creating and finding Scripts. + +#### Timed Script + +There are several properties one can set on the Script to control its timer component. + +```python +# in mygame/typeclasses/scripts.py + +class TimerScript(Script): + + def at_script_creation(self): + self.key = "myscript" + self.desc = "An example script" + self.interval = 60 # 1 min repeat + + def at_repeat(self): + # do stuff every minute + +``` + +This example will call `at_repeat` every minute. The `create_script` function has an `autostart=True` keyword +set by default - this means the script's timer component will be started automatically. Otherwise +`.start()` must be called separately. + +Supported properties are: + +- `key` (str): The name of the script. This makes it easier to search for it later. If it's a script + attached to another object one can also get all scripts off that object and get the script that way. +- `desc` (str): Note - not `.db.desc`! This is a database field on the Script shown in script listings + to help identifying what does what. +- `interval` (int): The amount of time (in seconds) between every 'tick' of the timer. Note that + it's generally bad practice to use sub-second timers for anything in a text-game - the player will + not be able to appreciate the precision (and if you print it, it will just spam the screen). For + calculations you can pretty much always do them on-demand, or at a much slower interval without the player being the wiser. +- `start_delay` (bool): If timer should start right away or wait `interval` seconds first. +- `repeats` (int): If >0, the timer will only run this many times before stopping. Otherwise the + number of repeats are infinite. If set to 1, the Script mimics a `delay` action. +- `persistent` (bool): This defaults to `True` and means the timer will survive a server reload/reboot. + If not, a reload will have the timer come back in a stopped state. Setting this to `False` will _not_ + delete the Script object itself (use `.delete()` for this). + +The timer component is controlled with methods on the Script class: + +- `.at_repeat()` - this method is called every `interval` seconds while the timer is + active. +- `.is_valid()` - this method is called by the timer just before `at_repeat()`. If it returns `False` + the timer is immediately stopped. +- `.start()` - start/update the timer. If keyword arguments are given, they can be used to + change `interval`, `start_delay` etc on the fly. This calls the `.at_start()` hook. + This is also called after a server reload assuming the timer was not previously stopped. +- `.update()` - legacy alias for `.start`. +- `.stop()` - stops and resets the timer. This calls the `.at_stop()` hook. +- `.pause()` - pauses the timer where it is, storing its current position. This calls + the `.at_pause(manual_pause=True)` hook. This is also called on a server reload/reboot, + at which time the `manual_pause` will be `False`. +- `.unpause()` - unpause a previously paused script. This will call the `at_start` hook. +- `.time_until_next_repeat()` - get the time until next time the timer fires. +- `.remaining_repeats()` - get the number of repeats remaining, or `None` if repeats are infinite. +- `.reset_callcount()` - this resets the repeat counter to start over from 0. Only useful if `repeats>0`. +- `.force_repeat()` - this prematurely forces `at_repeat` to be called right away. Doing so will reset the countdown so that next call will again happen after `interval` seconds. + +### Script timers vs delay/repeat + +If the _only_ goal is to get a repeat/delay effect, the [evennia.utils.delay](evennia.utils.utils.delay) and [evennia.utils.repeat](evennia.utils.utils.repeat) functions should generally be considered first. A Script is a lot 'heavier' to create/delete on the fly. In fact, for making a single delayed call (`script.repeats==1`), the `utils.delay` call is probably always the better choice. + +For repeating tasks, the `utils.repeat` is optimized for quick repeating of a large number of objects. It uses the TickerHandler under the hood. Its subscription-based model makes it very efficient to start/stop the repeating action for an object. The side effect is however that all objects set to tick at a given interval will _all do so at the same time_. This may or may not look strange in-game depending on the situation. By contrast the Script uses its own ticker that will operate independently from the tickers of all other Scripts. + +It's also worth noting that once the script object has _already been created_, starting/stopping/pausing/unpausing the timer has very little overhead. The pause/unpause and update methods of the script also offers a bit more fine-control than using `utils.delays/repeat`. + +### Script attached to another object + +Scripts can be attached to an [Account](./Accounts.md) or (more commonly) an [Object](./Objects.md). +If so, the 'parent object' will be available to the script as either `.obj` or `.account`. + + +```python + # mygame/typeclasses/scripts.py + # Script class is defined at the top of this module + + import random + + class Weather(Script): + """ + A timer script that displays weather info. Meant to + be attached to a room. + + """ + def at_script_creation(self): + self.key = "weather_script" + self.desc = "Gives random weather messages." + self.interval = 60 * 5 # every 5 minutes + + def at_repeat(self): + "called every self.interval seconds." + rand = random.random() + if rand < 0.5: + weather = "A faint breeze is felt." + elif rand < 0.7: + weather = "Clouds sweep across the sky." + else: + weather = "There is a light drizzle of rain." + # send this message to everyone inside the object this + # script is attached to (likely a room) + self.obj.msg_contents(weather) +``` + +If attached to a room, this Script will randomly report some weather +to everyone in the room every 5 minutes. + +```python + myroom.scripts.add(scripts.Weather) +``` + +> Note that `typeclasses` in your game dir is added to the setting `TYPECLASS_PATHS`. +> Therefore we don't need to give the full path (`typeclasses.scripts.Weather` +> but only `scripts.Weather` above. + +You can also attach the script as part of creating it: + +```python + create_script('typeclasses.weather.Weather', obj=myroom) +``` + +### Other Script methods + +A Script has all the properties of a typeclassed object, such as `db` and `ndb`(see +[Typeclasses](./Typeclasses.md)). Setting `key` is useful in order to manage scripts (delete them by name +etc). These are usually set up in the Script's typeclass, but can also be assigned on the fly as +keyword arguments to `evennia.create_script`. + +- `at_script_creation()` - this is only called once - when the script is first created. +- `at_server_reload()` - this is called whenever the server is warm-rebooted (e.g. with the `reload` command). It's a good place to save non-persistent data you might want to survive a reload. +- `at_server_shutdown()` - this is called when a system reset or systems shutdown is invoked. +- `at_server_start()` - this is called when the server comes back (from reload/shutdown/reboot). It can be usuful for initializations and caching of non-persistent data when starting up a script's functionality. +- `at_repeat()` +- `at_start()` +- `at_pause()` +- `at_stop()` +- `delete()` - same as for other typeclassed entities, this will delete the Script. Of note is that + it will also stop the timer (if it runs), leading to the `at_stop` hook being called. + +In addition, Scripts support [Attributes](./Attributes.md), [Tags](./Tags.md) and [Locks](./Locks.md) etc like other Typeclassed entities. + +See also the methods involved in controlling a [Timed Script](#timed-script) above. + +### Dealing with Script Errors + +Errors inside a timed, executing script can sometimes be rather terse or point to parts of the execution mechanism that is hard to interpret. One way to make it easier to debug scripts is to import Evennia's native logger and wrap your functions in a try/catch block. Evennia's logger can show you where the traceback occurred in your script. + +```python + +from evennia.utils import logger + +class Weather(Script): + + # [...] + + def at_repeat(self): + + try: + # [...] + except Exception: + logger.log_trace() +``` + + +## Using GLOBAL_SCRIPTS + +A Script not attached to another entity is commonly referred to as a _Global_ script since it't available +to access from anywhere. This means they need to be searched for in order to be used. + +Evennia supplies a convenient "container" `evennia.GLOBAL_SCRIPTS` to help organize your global +scripts. All you need is the Script's `key`. + +```python +from evennia import GLOBAL_SCRIPTS + +# access as a property on the container, named the same as the key +my_script = GLOBAL_SCRIPTS.my_script +# needed if there are spaces in name or name determined on the fly +another_script = GLOBAL_SCRIPTS.get("another script") +# get all global scripts (this returns a Django Queryset) +all_scripts = GLOBAL_SCRIPTS.all() +# you can operate directly on the script +GLOBAL_SCRIPTS.weather.db.current_weather = "Cloudy" + +``` + +```{warning} +Note that global scripts appear as properties on `GLOBAL_SCRIPTS` based on their `key`. If you were to create two global scripts with the same `key` (even with different typeclasses), the `GLOBAL_SCRIPTS` container will only return one of them (which one depends on order in the database). Best is to organize your scripts so that this does not happen. Otherwise, use `evennia.search_script` to get exactly the script you want. +``` + +There are two ways to make a script appear as a property on `GLOBAL_SCRIPTS`: + +1. Manually create a new global script with a `key` using `create_script`. +2. Define the script's properties in the `GLOBAL_SCRIPTS` settings variable. This tells Evennia + that it should check if a script with that `key` exists and if not, create it for you. + This is very useful for scripts that must always exist and/or should be auto-created + when your server restarts. If you use this method, you must make sure all + script keys are globally unique. + +Here's how to tell Evennia to manage the script in settings: + +```python +# in mygame/server/conf/settings.py + +GLOBAL_SCRIPTS = { + "my_script": { + "typeclass": "typeclasses.scripts.Weather", + "repeats": -1, + "interval": 50, + "desc": "Weather script" + }, + "storagescript": {} +} +``` + +Above we add two scripts with keys `myscript` and `storagescript`respectively. The following dict can be empty - the `settings.BASE_SCRIPT_TYPECLASS` will then be used. Under the hood, the provided dict (along with the `key`) will be passed into `create_script` automatically, so all the [same keyword arguments as for create_script](evennia.utils.create.create_script) are supported here. +```{warning} + +Before setting up Evennia to manage your script like this, make sure that your Script typeclass does not have any critical errors (test it separately). If there are, you'll see errors in your log and your Script will temporarily fall back to being a `DefaultScript` type. +``` + +Moreover, a script defined this way is *guaranteed* to exist when you try to access it: + +```python +from evennia import GLOBAL_SCRIPTS +# Delete the script +GLOBAL_SCRIPTS.storagescript.delete() +# running the `scripts` command now will show no storagescript +# but below it's automatically recreated again! +storage = GLOBAL_SCRIPTS.storagescript +``` + +That is, if the script is deleted, next time you get it from `GLOBAL_SCRIPTS`, Evennia will use the +information in settings to recreate it for you on the fly. + + diff --git a/docs/latest/_sources/Components/Sessions.md.txt b/docs/latest/_sources/Components/Sessions.md.txt new file mode 100644 index 0000000000..df8f38ea8d --- /dev/null +++ b/docs/latest/_sources/Components/Sessions.md.txt @@ -0,0 +1,121 @@ +# Sessions + +``` +┌──────┐ │ ┌───────┐ ┌───────┐ ┌──────┐ +│Client├─┼──►│Session├───►│Account├──►│Object│ +└──────┘ │ └───────┘ └───────┘ └──────┘ + ^ +``` + +An Evennia *Session* represents one single established connection to the server. Depending on the +Evennia session, it is possible for a person to connect multiple times, for example using different +clients in multiple windows. Each such connection is represented by a session object. + +A session object has its own [cmdset](./Command-Sets.md), usually the "unloggedin" cmdset. This is what is used to show the login screen and to handle commands to create a new account (or [Account](./Accounts.md) in evennia lingo) read initial help and to log into the game with an existing account. A session object can either be "logged in" or not. Logged in means that the user has authenticated. When this happens the session is associated with an Account object (which is what holds account-centric stuff). The account can then in turn puppet any number of objects/characters. + +A Session is not *persistent* - it is not a [Typeclass](./Typeclasses.md) and has no connection to the database. The Session will go away when a user disconnects and you will lose any custom data on it if the server reloads. The `.db` handler on Sessions is there to present a uniform API (so you can assume `.db` exists even if you don't know if you receive an Object or a Session), but this is just an alias to `.ndb`. So don't store any data on Sessions that you can't afford to lose in a reload. + +## Working with Sessions + +### Properties on Sessions + +Here are some important properties available on (Server-)Sessions + +- `sessid` - The unique session-id. This is an integer starting from 1. +- `address` - The connected client's address. Different protocols give different information here. +- `logged_in` - `True` if the user authenticated to this session. +- `account` - The [Account](./Accounts.md) this Session is attached to. If not logged in yet, this is `None`. +- `puppet` - The [Character/Object](./Objects.md) currently puppeted by this Account/Session combo. If not logged in or in OOC mode, this is `None`. +- `ndb` - The [Non-persistent Attribute](./Attributes.md) handler. +- `db` - As noted above, Sessions don't have regular Attributes. This is an alias to `ndb`. +- `cmdset` - The Session's [CmdSetHandler](./Command-Sets.md) + +Session statistics are mainly used internally by Evennia. + +- `conn_time` - How long this Session has been connected +- `cmd_last` - Last active time stamp. This will be reset by sending `idle` keepalives. +- `cmd_last_visible` - last active time stamp. This ignores `idle` keepalives and representes the +last time this session was truly visibly active. +- `cmd_total` - Total number of Commands passed through this Session. + +### Returning data to the session + +When you use `msg()` to return data to a user, the object on which you call the `msg()` matters. The +`MULTISESSION_MODE` also matters, especially if greater than 1. + +For example, if you use `account.msg("hello")` there is no way for evennia to know which session it +should send the greeting to. In this case it will send it to all sessions. If you want a specific +session you need to supply its session to the `msg` call (`account.msg("hello", +session=mysession)`). + +On the other hand, if you call the `msg()` message on a puppeted object, like +`character.msg("hello")`, the character already knows the session that controls it - it will +cleverly auto-add this for you (you can specify a different session if you specifically want to send +stuff to another session). + +Finally, there is a wrapper for `msg()` on all command classes: `command.msg()`. This will +transparently detect which session was triggering the command (if any) and redirects to that session +(this is most often what you want). If you are having trouble redirecting to a given session, +`command.msg()` is often the safest bet. + +You can get the `session` in two main ways: +* [Accounts](./Accounts.md) and [Objects](./Objects.md) (including Characters) have a `sessions` property. +This is a *handler* that tracks all Sessions attached to or puppeting them. Use e.g. +`accounts.sessions.get()` to get a list of Sessions attached to that entity. +* A Command instance has a `session` property that always points back to the Session that triggered +it (it's always a single one). It will be `None` if no session is involved, like when a mob or +script triggers the Command. + +### Customizing the Session object + +When would one want to customize the Session object? Consider for example a character creation system: You might decide to keep this on the out-of-character level. This would mean that you create the character at the end of some sort of menu choice. The actual char-create cmdset would then normally be put on the account. This works fine as long as you are `MULTISESSION_MODE` below 2. For higher modes, replacing the Account cmdset will affect *all* your connected sessions, also those not involved in character creation. In this case you want to instead put the char-create cmdset on the Session level - then all other sessions will keep working normally despite you creating a new character in one of them. + +By default, the session object gets the `commands.default_cmdsets.UnloggedinCmdSet` when the user first connects. Once the session is authenticated it has *no* default sets. To add a "logged-in" cmdset to the Session, give the path to the cmdset class with `settings.CMDSET_SESSION`. This set +will then henceforth always be present as soon as the account logs in. + +To customize further you can completely override the Session with your own subclass. To replace the default Session class, change `settings.SERVER_SESSION_CLASS` to point to your custom class. This is a dangerous practice and errors can easily make your game unplayable. Make sure to take heed of the [original](evennia.server.session) and make your changes carefully. + +## Portal and Server Sessions + +*Note: This is considered an advanced topic. You don't need to know this on a first read-through.* + +Evennia is split into two parts, the [Portal and the Server](./Portal-And-Server.md). Each side tracks its own Sessions, syncing them to each other. + +The "Session" we normally refer to is actually the `ServerSession`. Its counter-part on the Portal +side is the `PortalSession`. Whereas the server sessions deal with game states, the portal session +deals with details of the connection-protocol itself. The two are also acting as backups of critical +data such as when the server reboots. + +New Account connections are listened for and handled by the Portal using the [protocols](Portal-And- Server) it understands (such as telnet, ssh, webclient etc). When a new connection is established, a `PortalSession` is created on the Portal side. This session object looks different depending on which protocol is used to connect, but all still have a minimum set of attributes that are generic to all sessions. + +These common properties are piped from the Portal, through the AMP connection, to the Server, which is now informed a new connection has been established. On the Server side, a `ServerSession` object is created to represent this. There is only one type of `ServerSession`; It looks the same regardless of how the Account connects. + +From now on, there is a one-to-one match between the `ServerSession` on one side of the AMP +connection and the `PortalSession` on the other. Data arriving to the Portal Session is sent on to +its mirror Server session and vice versa. + +During certain situations, the portal- and server-side sessions are +"synced" with each other: +- The Player closes their client, killing the Portal Session. The Portal syncs with the Server to +make sure the corresponding Server Session is also deleted. +- The Player quits from inside the game, killing the Server Session. The Server then syncs with the +Portal to make sure to close the Portal connection cleanly. +- The Server is rebooted/reset/shutdown - The Server Sessions are copied over ("saved") to the +Portal side. When the Server comes back up, this data is returned by the Portal so the two are again +in sync. This way an Account's login status and other connection-critical things can survive a +server reboot (assuming the Portal is not stopped at the same time, obviously). + +### Sessionhandlers + +Both the Portal and Server each have a *sessionhandler* to manage the connections. These handlers +are global entities contain all methods for relaying data across the AMP bridge. All types of +Sessions hold a reference to their respective Sessionhandler (the property is called +`sessionhandler`) so they can relay data. See [protocols](../Concepts/Protocols.md) for more info on building new protocols. + +To get all Sessions in the game (i.e. all currently connected clients), you access the server-side Session handler, which you get by +``` +from evennia.server.sessionhandler import SESSION_HANDLER +``` +> Note: The `SESSION_HANDLER` singleton has an older alias `SESSIONS` that is commonly seen in various places as well. + +See the [sessionhandler.py](evennia.server.sessionhandler) module for details on the capabilities of the `ServerSessionHandler`. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Signals.md.txt b/docs/latest/_sources/Components/Signals.md.txt new file mode 100644 index 0000000000..3c30794fe0 --- /dev/null +++ b/docs/latest/_sources/Components/Signals.md.txt @@ -0,0 +1,122 @@ +# Signals + + +_This is feature available from evennia 0.9 and onward_. + +There are multiple ways for you to plug in your own functionality into Evennia. +The most common way to do so is through *hooks* - methods on typeclasses that +gets called at particular events. Hooks are great when you want a game entity +to behave a certain way when something happens to it. _Signals_ complements +hooks for cases when you want to easily attach new functionality without +overriding things on the typeclass. + +When certain events happen in Evennia, a _Signal_ is fired. The idea is that +you can "attach" any number of event-handlers to these signals. You can attach +any number of handlers and they'll all fire whenever any entity triggers the +signal. + +Evennia uses the [Django Signal system](https://docs.djangoproject.com/en/4.1/topics/signals/). + + +## Working with Signals + +First you create your handler + +```python + +def myhandler(sender, **kwargs): + # do stuff + +``` + +The `**kwargs` is mandatory. Then you attach it to the signal of your choice: + +```python +from evennia.server import signals + +signals.SIGNAL_OBJECT_POST_CREATE.connect(myhandler) + +``` + +This particular signal fires after (post) an Account has connected to the game. +When that happens, `myhandler` will fire with the `sender` being the Account that just connected. + +If you want to respond only to the effects of a specific entity you can do so +like this: + +```python +from evennia import search_account +from evennia import signals + +account = search_account("foo")[0] +signals.SIGNAL_ACCOUNT_POST_CONNECT.connect(myhandler, account) +``` + +### Available signals + +All signals (including some django-specific defaults) are available in the module +`evennia.server.signals` +(with a shortcut `evennia.signals`). Signals are named by the sender type. So `SIGNAL_ACCOUNT_*` +returns +`Account` instances as senders, `SIGNAL_OBJECT_*` returns `Object`s etc. Extra keywords (kwargs) +should +be extracted from the `**kwargs` dict in the signal handler. + +- `SIGNAL_ACCOUNT_POST_CREATE` - this is triggered at the very end of `Account.create()`. Note that + calling `evennia.create.create_account` (which is called internally by `Account.create`) will +*not* + trigger this signal. This is because using `Account.create()` is expected to be the most commonly + used way for users to themselves create accounts during login. It passes and extra kwarg `ip` with + the client IP of the connecting account. +- `SIGNAL_ACCOUNT_POST_LOGIN` - this will always fire when the account has authenticated. Sends + extra kwarg `session` with the new [Session](./Sessions.md) object involved. +- `SIGNAL_ACCCOUNT_POST_FIRST_LOGIN` - this fires just before `SIGNAL_ACCOUNT_POST_LOGIN` but only +if + this is the *first* connection done (that is, if there are no previous sessions connected). Also + passes the `session` along as a kwarg. +- `SIGNAL_ACCOUNT_POST_LOGIN_FAIL` - sent when someone tried to log into an account by failed. +Passes + the `session` as an extra kwarg. +- `SIGNAL_ACCOUNT_POST_LOGOUT` - always fires when an account logs off, no matter if other sessions + remain or not. Passes the disconnecting `session` along as a kwarg. +- `SIGNAL_ACCOUNT_POST_LAST_LOGOUT` - fires before `SIGNAL_ACCOUNT_POST_LOGOUT`, but only if this is + the *last* Session to disconnect for that account. Passes the `session` as a kwarg. +- `SIGNAL_OBJECT_POST_PUPPET` - fires when an account puppets this object. Extra kwargs `session` + and `account` represent the puppeting entities. + `SIGNAL_OBJECT_POST_UNPUPPET` - fires when the sending object is unpuppeted. Extra kwargs are + `session` and `account`. +- `SIGNAL_ACCOUNT_POST_RENAME` - triggered by the setting of `Account.username`. Passes extra + kwargs `old_name`, `new_name`. +- `SIGNAL_TYPED_OBJECT_POST_RENAME` - triggered when any Typeclassed entity's `key` is changed. +Extra + kwargs passed are `old_key` and `new_key`. +- `SIGNAL_SCRIPT_POST_CREATE` - fires when a script is first created, after any hooks. +- `SIGNAL_CHANNEL_POST_CREATE` - fires when a Channel is first created, after any hooks. +- `SIGNAL_HELPENTRY_POST_CREATE` - fires when a help entry is first created. +- `SIGNAL_EXIT_TRAVERSED` - fires when an exit is traversed, just after `at_traverse` hook. The `sender` is the exit itself, `traverser=` keyword hold the one traversing the exit. + +The `evennia.signals` module also gives you conveneient access to the default Django signals (these +use a +different naming convention). + +- `pre_save` - fired when any database entitiy's `.save` method fires, before any saving has +happened. +- `post_save` - fires after saving a database entity. +- `pre_delete` - fires just before a database entity is deleted. +- `post_delete` - fires after a database entity was deleted. +- `pre_init` - fires before a typeclass' `__init__` method (which in turn + happens before the `at_init` hook fires). +- `post_init` - triggers at the end of `__init__` (still before the `at_init` hook). + +These are highly specialized Django signals that are unlikely to be useful to most users. But +they are included here for completeness. + +- `m2m_changed` - fires after a Many-to-Many field (like `db_attributes`) changes. +- `pre_migrate` - fires before database migration starts with `evennia migrate`. +- `post_migrate` - fires after database migration finished. +- `request_started` - sent when HTTP request begins. +- `request_finished` - sent when HTTP request ends. +- `settings_changed` - sent when changing settings due to `@override_settings` + decorator (only relevant for unit testing) +- `template_rendered` - sent when test system renders http template (only useful for unit tests). +- `connection_creation` - sent when making initial connection to database. diff --git a/docs/latest/_sources/Components/Tags.md.txt b/docs/latest/_sources/Components/Tags.md.txt new file mode 100644 index 0000000000..42aaa92455 --- /dev/null +++ b/docs/latest/_sources/Components/Tags.md.txt @@ -0,0 +1,223 @@ +# Tags + +```{code-block} +:caption: In game +> tag obj = tagname +``` +```{code-block} python +:caption: In code, using .tags (TagHandler) + +obj.tags.add("mytag", category="foo") +obj.tags.get("mytag", category="foo") +``` + +```{code-block} python +:caption: In code, using TagProperty or TagCategoryProperty + +from evennia import DefaultObject +from evennia import TagProperty, TagCategoryProperty + +class Sword(DefaultObject): + # name of property is the tagkey, category as argument + can_be_wielded = TagProperty(category='combat') + has_sharp_edge = TagProperty(category='combat') + + # name of property is the category, tag-keys are arguments + damage_type = TagCategoryProperty("piercing", "slashing") + crafting_element = TagCategoryProperty("blade", "hilt", "pommel") + +``` + +In-game, tags are controlled `tag` command: + + > tag Chair = furniture + > tag Chair = furniture + > tag Table = furniture + + > tag/search furniture + Chair, Sofa, Table + +_Tags_ are short text lables one can 'hang' on objects in order to organize, group and quickly find out their properties. An Evennia entity can be tagged by any number of tags. They are more efficient than [Attributes](./Attributes.md) since on the database-side, Tags are _shared_ between all objects with that particular tag. A tag does not carry a value in itself; it either sits on the entity + +You manage Tags using the `TagHandler` (`.tags`) on typeclassed entities. You can also assign Tags on the class level through the `TagProperty` (one tag, one category per line) or the `TagCategoryProperty` (one category, multiple tags per line). Both of these use the `TagHandler` under the hood, they are just convenient ways to add tags already when you define your class. + +Above, the tags inform us that the `Sword` is both sharp and can be wielded. If that's all they do, they could just be a normal Python flag. When tags become important is if there are a lot of objects with different combinations of tags. Maybe you have a magical spell that dulls _all_ sharp-edged objects in the castle - whether sword, dagger, spear or kitchen knife! You can then just grab all objects with the `has_sharp_edge` tag. +Another example would be a weather script affecting all rooms tagged as `outdoors` or finding all characters tagged with `belongs_to_fighter_guild`. + +In Evennia, Tags are technically also used to implement `Aliases` (alternative names for objects) and `Permissions` (simple strings for [Locks](./Locks.md) to check for). + + +## Working with Tags + +### Searching for tags + +The common way to use tags (once they have been set) is find all objects tagged with a particular tag combination: + + objs = evennia.search_tag(key=("foo", "bar"), category='mycategory') + +As shown above, you can also have tags without a category (category of `None`). + +```python + import evennia + + # all methods return Querysets + + # search for objects + objs = evennia.search_tag("furniture") + objs2 = evennia.search_tag("furniture", category="luxurious") + dungeon = evennia.search_tag("dungeon#01") + forest_rooms = evennia.search_tag(category="forest") + forest_meadows = evennia.search_tag("meadow", category="forest") + magic_meadows = evennia.search_tag("meadow", category="magical") + + # search for scripts + weather = evennia.search_tag_script("weather") + climates = evennia.search_tag_script(category="climate") + + # search for accounts + accounts = evennia.search_tag_account("guestaccount") +``` + +> Note that searching for just "furniture" will only return the objects tagged with the "furniture" tag that has a category of `None`. We must explicitly give the category to get the "luxurious" furniture. + +Using any of the `search_tag` variants will all return [Django Querysets](https://docs.djangoproject.com/en/4.1/ref/models/querysets/), including if you only have one match. You can treat querysets as lists and iterate over them, or continue building search queries with them. + +Remember when searching that not setting a category means setting it to `None` - this does *not* mean that category is undefined, rather `None` is considered the default, unnamed category. + +```python +import evennia + +myobj1.tags.add("foo") # implies category=None +myobj2.tags.add("foo", category="bar") + +# this returns a queryset with *only* myobj1 +objs = evennia.search_tag("foo") + +# these return a queryset with *only* myobj2 +objs = evennia.search_tag("foo", category="bar") +# or +objs = evennia.search_tag(category="bar") +``` + +There is also an in-game command that deals with assigning and using ([Object-](./Objects.md)) tags: + + tag/search furniture + + +### TagHandler + +This is the main way to work with tags when you have the entry already. This handler sits on all typeclassed entities as `.tags` and you use `.tags.add()`, `.tags.remove()` and `.tags.has()` to manage Tags on the object. [See the api docs](evennia.typeclasses.tags.TagHandler) for more useful methods. + +The TagHandler can be found on any of the base *typeclassed* objects, namely [Objects](./Objects.md), [Accounts](./Accounts.md), [Scripts](./Scripts.md) and [Channels](./Channels.md) (as well as their children). Here are some examples of use: + +```python + mychair.tags.add("furniture") + mychair.tags.add("furniture", category="luxurious") + myroom.tags.add("dungeon#01") + myscript.tags.add("weather", category="climate") + myaccount.tags.add("guestaccount") + + mychair.tags.all() # returns a list of Tags + mychair.tags.remove("furniture") + mychair.tags.clear() +``` + +Adding a new tag will either create a new Tag or re-use an already existing one. Note that there are _two_ "furniture" tags, one with a `None` category, and one with the "luxurious" category. + +When using `remove`, the `Tag` is not deleted but are just disconnected from the tagged object. This makes for very quick operations. The `clear` method removes (disconnects) all Tags from the object. + + +### TagProperty + +This is used as a property when you create a new class: + +```python +from evennia import TagProperty +from typeclasses import Object + +class MyClass(Object): + mytag = TagProperty(tagcategory) +``` + +This will create a Tag named `mytag` and category `tagcategory` in the database. You'll be able to find it by `obj.mytag` but more useful you can find it with the normal Tag searching methods in the database. + +Note that if you were to delete this tag with `obj.tags.remove("mytag", "tagcategory")`, that tag will be _re-added_ to the object next time this property is accessed! + +### TagCategoryProperty + +This is the inverse of `TagProperty`: + +```python +from evennia import TagCategoryProperty +from typeclasses import Object + +class MyClass(Object): + tagcategory = TagCategroyProperty(tagkey1, tagkey2) +``` + +The above example means you'll have two tags (`tagkey1` and `tagkey2`), each with the `tagcategory` category, assigned to this object. + +Note that similarly to how it works for `TagProperty`, if you were to delete these tags from the object with the `TagHandler` (`obj.tags.remove("tagkey1", "tagcategory")`, then these tags will be _re-added_ automatically next time the property is accessed. + +The reverse is however not true: If you were to _add_ a new tag of the same category to the object, via the `TagHandler`, then this property will include that in the list of returned tags. + +If you want to 're-sync' the tags in the property with that in the database, you can use the `del` operation on it - next time the property is accessed, it will then only show the default keys you specify in it. Here's how it works: + +```python +>>> obj.tagcategory +["tagkey1", "tagkey2"] + +# remove one of the default tags outside the property +>>> obj.tags.remove("tagkey1", "tagcategory") +>>> obj.tagcategory +["tagkey1", "tagkey2"] # missing tag is auto-created! + +# add a new tag from outside the property +>>> obj.tags.add("tagkey3", "tagcategory") +>>> obj.tagcategory +["tagkey1", "tagkey2", "tagkey3"] # includes the new tag! + +# sync property with datbase +>>> del obj.tagcategory +>>> obj.tagcategory +["tagkey1", "tagkey2"] # property/database now in sync +``` + +## Properties of Tags (and Aliases and Permissions) + +Tags are *unique*. This means that there is only ever one Tag object with a given key and category. + +```{important} +Not specifying a category (default) gives the tag a category of `None`, which is also considered a unique key + category combination. You cannot use `TagCategoryProperty` to set Tags with `None` categories, since the property name may not be `None`. Use the `TagHandler` (or `TagProperty`) for this. + +``` +When Tags are assigned to game entities, these entities are actually sharing the same Tag. This means that Tags are not suitable for storing information about a single object - use an +[Attribute](./Attributes.md) for this instead. Tags are a lot more limited than Attributes but this also +makes them very quick to lookup in the database - this is the whole point. + +Tags have the following properties, stored in the database: + +- **key** - the name of the Tag. This is the main property to search for when looking up a Tag. +- **category** - this category allows for retrieving only specific subsets of tags used for different purposes. You could have one category of tags for "zones", another for "outdoor locations", for example. If not given, the category will be `None`, which is also considered a separate, default, category. +- **data** - this is an optional text field with information about the tag. Remember that Tags are shared between entities, so this field cannot hold any object-specific information. Usually it would be used to hold info about the group of entities the Tag is tagging - possibly used for contextual help like a tool tip. It is not used by default. + +There are also two special properties. These should usually not need to be changed or set, it is used internally by Evennia to implement various other uses it makes of the `Tag` object: + +- **model** - this holds a *natural-key* description of the model object that this tag deals with, on the form *application.modelclass*, for example `objects.objectdb`. It used by the TagHandler of each entity type for correctly storing the data behind the scenes. +- **tagtype** - this is a "top-level category" of sorts for the inbuilt children of Tags, namely *Aliases* and *Permissions*. The Taghandlers using this special field are especially intended to free up the *category* property for any use you desire. + +## Aliases and Permissions + +Aliases and Permissions are implemented using normal TagHandlers that simply save Tags with a +different `tagtype`. These handlers are named `aliases` and `permissions` on all Objects. They are +used in the same way as Tags above: + +```python + boy.aliases.add("rascal") + boy.permissions.add("Builders") + boy.permissions.remove("Builders") + + all_aliases = boy.aliases.all() +``` + +and so on. Similarly to how `tag` works in-game, there is also the `perm` command for assigning permissions and `@alias` command for aliases. \ No newline at end of file diff --git a/docs/latest/_sources/Components/TickerHandler.md.txt b/docs/latest/_sources/Components/TickerHandler.md.txt new file mode 100644 index 0000000000..329132d940 --- /dev/null +++ b/docs/latest/_sources/Components/TickerHandler.md.txt @@ -0,0 +1,102 @@ +# TickerHandler + + +One way to implement a dynamic MUD is by using "tickers", also known as "heartbeats". A ticker is a timer that fires ("ticks") at a given interval. The tick triggers updates in various game systems. + +Tickers are very common or even unavoidable in other mud code bases. Certain code bases are even hard-coded to rely on the concept of the global 'tick'. Evennia has no such notion - the decision to use tickers is very much up to the need of your game and which requirements you have. The "ticker recipe" is just one way of cranking the wheels. + +The most fine-grained way to manage the flow of time is to use [utils.delay](evennia.utils.utils.delay) (using the [TaskHandler](evennia.scripts.taskhandler.TaskHandler)). Another is to use the time-repeat capability of [Scripts](./Scripts.md). These tools operate on individual objects. + +Many types of operations (weather being the classic example) are however done on multiple objects in the same way at regular intervals, and for this, it's inefficient to set up separate delays/scripts for every such object. + +The way to do this is to use a ticker with a "subscription model" - let objects sign up to be +triggered at the same interval, unsubscribing when the updating is no longer desired. This means that the time-keeping mechanism is only set up once for all objects, making subscribing/unsubscribing faster. + +Evennia offers an optimized implementation of the subscription model - the *TickerHandler*. This is a singleton global handler reachable from [evennia.TICKER_HANDLER](evennia.utils.tickerhandler.TickerHandler). You can assign any *callable* (a function or, more commonly, a method on a database object) to this handler. The TickerHandler will then call this callable at an interval you specify, and with the arguments you supply when adding it. This continues until the callable un-subscribes from the ticker. The handler survives a reboot and is highly optimized in resource usage. + +## Usage + +Here is an example of importing `TICKER_HANDLER` and using it: + +```python + # we assume that obj has a hook "at_tick" defined on itself + from evennia import TICKER_HANDLER as tickerhandler + + tickerhandler.add(20, obj.at_tick) +``` +That's it - from now on, `obj.at_tick()` will be called every 20 seconds. + +```{important} +Everything you supply to `TickerHandler.add` will need to be pickled at some point to be saved into the database - also if you use `persistent=False`. Most of the time the handler will correctly store things like database objects, but the same restrictions as for [Attributes](./Attributes.md) apply to what the TickerHandler may store. +``` + +You can also import a function and tick that: + +```python + from evennia import TICKER_HANDLER as tickerhandler + from mymodule import myfunc + + tickerhandler.add(30, myfunc) +``` + +Removing (stopping) the ticker works as expected: + +```python + tickerhandler.remove(20, obj.at_tick) + tickerhandler.remove(30, myfunc) +``` + +Note that you have to also supply `interval` to identify which subscription to remove. This is because the TickerHandler maintains a pool of tickers and a given callable can subscribe to be ticked at any number of different intervals. + +The full definition of the `tickerhandler.add` method is + +```python + tickerhandler.add(interval, callback, + idstring="", persistent=True, *args, **kwargs) +``` + +Here `*args` and `**kwargs` will be passed to `callback` every `interval` seconds. If `persistent` +is `False`, this subscription will be wiped by a _server shutdown_ (it will still survive a normal reload). + +Tickers are identified and stored by making a key of the callable itself, the ticker-interval, the `persistent` flag and the `idstring` (the latter being an empty string when not given explicitly). + +Since the arguments are not included in the ticker's identification, the `idstring` must be used to have a specific callback triggered multiple times on the same interval but with different arguments: + +```python + tickerhandler.add(10, obj.update, "ticker1", True, 1, 2, 3) + tickerhandler.add(10, obj.update, "ticker2", True, 4, 5) +``` + +> Note that, when we want to send arguments to our callback within a ticker handler, we need to specify `idstring` and `persistent` before, unless we call our arguments as keywords, which would often be more readable: + +```python + tickerhandler.add(10, obj.update, caller=self, value=118) +``` + +If you add a ticker with exactly the same combination of callback, interval and idstring, it will +overload the existing ticker. This identification is also crucial for later removing (stopping) the subscription: + +```python + tickerhandler.remove(10, obj.update, idstring="ticker1") + tickerhandler.remove(10, obj.update, idstring="ticker2") +``` + +The `callable` can be on any form as long as it accepts the arguments you give to send to it in `TickerHandler.add`. + +When testing, you can stop all tickers in the entire game with `tickerhandler.clear()`. You can also view the currently subscribed objects with `tickerhandler.all()`. + +See the [Weather Tutorial](../Howtos/Tutorial-Weather-Effects.md) for an example of using the TickerHandler. + +### When *not* to use TickerHandler + +Using the TickerHandler may sound very useful but it is important to consider when not to use it. Even if you are used to habitually relying on tickers for everything in other code bases, stop and think about what you really need it for. This is the main point: + +> You should *never* use a ticker to catch *changes*. + +Think about it - you might have to run the ticker every second to react to the change fast enough. Most likely nothing will have changed at a given moment. So you are doing pointless calls (since skipping the call gives the same result as doing it). Making sure nothing's changed might even be computationally expensive depending on the complexity of your system. Not to mention that you might need to run the check *on every object in the database*. Every second. Just to maintain status quo ... + +Rather than checking over and over on the off-chance that something changed, consider a more proactive approach. Could you implement your rarely changing system to *itself* report when its status changes? It's almost always much cheaper/efficient if you can do things "on demand". Evennia itself uses hook methods for this very reason. + +So, if you consider a ticker that will fire very often but which you expect to have no effect 99% of the time, consider handling things things some other way. A self-reporting on-demand solution is usually cheaper also for fast-updating properties. Also remember that some things may not need to be updated until someone actually is examining or using them - any interim changes happening up to that moment are pointless waste of computing time. + +The main reason for needing a ticker is when you want things to happen to multiple objects at the same time without input from something else. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Typeclasses.md.txt b/docs/latest/_sources/Components/Typeclasses.md.txt new file mode 100644 index 0000000000..2cdc2034b3 --- /dev/null +++ b/docs/latest/_sources/Components/Typeclasses.md.txt @@ -0,0 +1,318 @@ +# Typeclasses + +*Typeclasses* form the core of Evennia's data storage. It allows Evennia to represent any number of different game entities as Python classes, without having to modify the database schema for every new type. + +In Evennia the most important game entities, [Accounts](./Accounts.md), [Objects](./Objects.md), [Scripts](./Scripts.md) and [Channels](./Channels.md) are all Python classes inheriting, at varying distance, from `evennia.typeclasses.models.TypedObject`. In the documentation we refer to these objects as being "typeclassed" or even "being a typeclass". + +This is how the inheritance looks for the typeclasses in Evennia: + +``` + ┌───────────┐ + │TypedObject│ + └─────▲─────┘ + ┌───────────────┬────────┴──────┬────────────────┐ + ┌────┴────┐ ┌────┴───┐ ┌────┴────┐ ┌────┴───┐ +1: │AccountDB│ │ScriptDB│ │ChannelDB│ │ObjectDB│ + └────▲────┘ └────▲───┘ └────▲────┘ └────▲───┘ + ┌───────┴──────┐ ┌──────┴──────┐ ┌──────┴───────┐ ┌──────┴──────┐ +2: │DefaultAccount│ │DefaultScript│ │DefaultChannel│ │DefaultObject│ + └───────▲──────┘ └──────▲──────┘ └──────▲───────┘ └──────▲──────┘ + │ │ │ │ Evennia + ────────┼───────────────┼───────────────┼────────────────┼───────── + │ │ │ │ Gamedir + ┌───┴───┐ ┌───┴──┐ ┌───┴───┐ ┌──────┐ │ +3: │Account│ │Script│ │Channel│ │Object├─┤ + └───────┘ └──────┘ └───────┘ └──────┘ │ + ┌─────────┐ │ + │Character├─┤ + └─────────┘ │ + ┌────┐ │ + │Room├─┤ + └────┘ │ + ┌────┐ │ + │Exit├─┘ + └────┘ +``` + +- **Level 1** above is the "database model" level. This describes the database tables and fields (this is technically a [Django model](https://docs.djangoproject.com/en/4.1/topics/db/models/)). +- **Level 2** is where we find Evennia's default implementations of the various game entities, on top of the database. These classes define all the hook methods that Evennia calls in various situations. `DefaultObject` is a little special since it's the parent for `DefaultCharacter`, `DefaultRoom` and `DefaultExit`. They are all grouped under level 2 because they all represents defaults to build from. +- **Level 3**, finally, holds empty template classes created in your game directory. This is the level you are meant to modify and tweak as you please, overloading the defaults as befits your game. The templates inherit directly from their defaults, so `Object` inherits from `DefaultObject` and `Room` inherits from `DefaultRoom`. + +> This diagram doesn't include the `ObjectParent` mixin for `Object`, `Character`, `Room` and `Exit`. This establishes a common parent for those classes, for shared properties. See [Objects](./Objects.md) for more details. + +The `typeclass/list` command will provide a list of all typeclasses known to Evennia. This can be useful for getting a feel for what is available. Note however that if you add a new module with a class in it but do not import that module from anywhere, the `typeclass/list` will not find it. To make it known to Evennia you must import that module from somewhere. + + +## Difference between typeclasses and classes + +All Evennia classes inheriting from class in the table above share one important feature and two +[]()important limitations. This is why we don't simply call them "classes" but "typeclasses". + + 1. A typeclass can save itself to the database. This means that some properties (actually not that many) on the class actually represents database fields and can only hold very specific data types. + 1. Due to its connection to the database, the typeclass' name must be *unique* across the _entire_ server namespace. That is, there must never be two same-named classes defined anywhere. So the below code would give an error (since `DefaultObject` is now globally found both in this module and in the default library): + + ```python + from evennia import DefaultObject as BaseObject + class DefaultObject(BaseObject): + pass + ``` + + 1. A typeclass' `__init__` method should normally not be overloaded. This has mostly to do with the fact that the `__init__` method is not called in a predictable way. Instead Evennia suggest you use the `at_*_creation` hooks (like `at_object_creation` for Objects) for setting things the very first time the typeclass is saved to the database or the `at_init` hook which is called every time the object is cached to memory. If you know what you are doing and want to use `__init__`, it *must* both accept arbitrary keyword arguments and use `super` to call its parent: + + ```python + def __init__(self, **kwargs): + # my content + super().__init__(**kwargs) + # my content + ``` + +Apart from this, a typeclass works like any normal Python class and you can +treat it as such. + +## Working with typeclasses + +### Creating a new typeclass + + It's easy to work with Typeclasses. Either you use an existing typeclass or you create a new Python class inheriting from an existing typeclass. Here is an example of creating a new type of Object: + +```python + from evennia import DefaultObject + + class Furniture(DefaultObject): + # this defines what 'furniture' is, like + # storing who sits on it or something. + pass + +``` + +You can now create a new `Furniture` object in two ways. First (and usually not the most +convenient) way is to create an instance of the class and then save it manually to the database: + +```python +chair = Furniture(db_key="Chair") +chair.save() + +``` + +To use this you must give the database field names as keywords to the call. Which are available +depends on the entity you are creating, but all start with `db_*` in Evennia. This is a method you +may be familiar with if you know Django from before. + +It is recommended that you instead use the `create_*` functions to create typeclassed entities: + + +```python +from evennia import create_object + +chair = create_object(Furniture, key="Chair") +# or (if your typeclass is in a module furniture.py) +chair = create_object("furniture.Furniture", key="Chair") +``` + +The `create_object` (`create_account`, `create_script` etc) takes the typeclass as its first +argument; this can both be the actual class or the python path to the typeclass as found under your +game directory. So if your `Furniture` typeclass sits in `mygame/typeclasses/furniture.py`, you +could point to it as `typeclasses.furniture.Furniture`. Since Evennia will itself look in +`mygame/typeclasses`, you can shorten this even further to just `furniture.Furniture`. The create- +functions take a lot of extra keywords allowing you to set things like [Attributes](./Attributes.md) and +[Tags](./Tags.md) all in one go. These keywords don't use the `db_*` prefix. This will also automatically +save the new instance to the database, so you don't need to call `save()` explicitly. + +An example of a database field is `db_key`. This stores the "name" of the entity you are modifying +and can thus only hold a string. This is one way of making sure to update the `db_key`: + +```python +chair.db_key = "Table" +chair.save() + +print(chair.db_key) +<<< Table +``` + +That is, we change the chair object to have the `db_key` "Table", then save this to the database. +However, you almost never do things this way; Evennia defines property wrappers for all the database +fields. These are named the same as the field, but without the `db_` part: + +```python +chair.key = "Table" + +print(chair.key) +<<< Table + +``` + +The `key` wrapper is not only shorter to write, it will make sure to save the field for you, and +does so more efficiently by levering sql update mechanics under the hood. So whereas it is good to +be aware that the field is named `db_key` you should use `key` as much as you can. + +Each typeclass entity has some unique fields relevant to that type. But all also share the +following fields (the wrapper name without `db_` is given): + + - `key` (str): The main identifier for the entity, like "Rose", "myscript" or "Paul". `name` is an +alias. + - `date_created` (datetime): Time stamp when this object was created. + - `typeclass_path` (str): A python path pointing to the location of this (type)class + +There is one special field that doesn't use the `db_` prefix (it's defined by Django): + + - `id` (int): the database id (database ref) of the object. This is an ever-increasing, unique +integer. It can also be accessed as `dbid` (database ID) or `pk` (primary key). The `dbref` property +returns the string form "#id". + +The typeclassed entity has several common handlers: + + - `tags` - the [TagHandler](./Tags.md) that handles tagging. Use `tags.add()` , `tags.get()` etc. + - `locks` - the [LockHandler](./Locks.md) that manages access restrictions. Use `locks.add()`, +`locks.get()` etc. + - `attributes` - the [AttributeHandler](./Attributes.md) that manages Attributes on the object. Use +`attributes.add()` +etc. + - `db` (DataBase) - a shortcut property to the AttributeHandler; allowing `obj.db.attrname = value` + - `nattributes` - the [Non-persistent AttributeHandler](./Attributes.md) for attributes not saved in the +database. + - `ndb` (NotDataBase) - a shortcut property to the Non-peristent AttributeHandler. Allows +`obj.ndb.attrname = value` + + +Each of the typeclassed entities then extend this list with their own properties. Go to the +respective pages for [Objects](./Objects.md), [Scripts](./Scripts.md), [Accounts](./Accounts.md) and +[Channels](./Channels.md) for more info. It's also recommended that you explore the available +entities using [Evennia's flat API](../Evennia-API.md) to explore which properties and methods they have +available. + +### Overloading hooks + +The way to customize typeclasses is usually to overload *hook methods* on them. Hooks are methods that Evennia call in various situations. An example is the `at_object_creation` hook on `Objects`, which is only called once, the very first time this object is saved to the database. Other examples are the `at_login` hook of Accounts and the `at_repeat` hook of Scripts. + +### Querying for typeclasses + +Most of the time you search for objects in the database by using convenience methods like the +`caller.search()` of [Commands](./Commands.md) or the search functions like `evennia.search_objects`. + +You can however also query for them directly using [Django's query +language](https://docs.djangoproject.com/en/4.1/topics/db/queries/). This makes use of a _database +manager_ that sits on all typeclasses, named `objects`. This manager holds methods that allow +database searches against that particular type of object (this is the way Django normally works +too). When using Django queries, you need to use the full field names (like `db_key`) to search: + +```python +matches = Furniture.objects.get(db_key="Chair") + +``` + +It is important that this will *only* find objects inheriting directly from `Furniture` in your +database. If there was a subclass of `Furniture` named `Sitables` you would not find any chairs +derived from `Sitables` with this query (this is not a Django feature but special to Evennia). To +find objects from subclasses Evennia instead makes the `get_family` and `filter_family` query +methods available: + +```python +# search for all furnitures and subclasses of furnitures +# whose names starts with "Chair" +matches = Furniture.objects.filter_family(db_key__startswith="Chair") + +``` + +To make sure to search, say, all `Scripts` *regardless* of typeclass, you need to query from the +database model itself. So for Objects, this would be `ObjectDB` in the diagram above. Here's an +example for Scripts: + +```python +from evennia import ScriptDB +matches = ScriptDB.objects.filter(db_key__contains="Combat") +``` + +When querying from the database model parent you don't need to use `filter_family` or `get_family` - +you will always query all children on the database model. + +### Updating existing typeclass instances + +If you already have created instances of Typeclasses, you can modify the *Python code* at any time - +due to how Python inheritance works your changes will automatically be applied to all children once you have reloaded the server. + +However, database-saved data, like `db_*` fields, [Attributes](./Attributes.md), [Tags](./Tags.md) etc, are +not themselves embedded into the class and will *not* be updated automatically. This you need to +manage yourself, by searching for all relevant objects and updating or adding the data: + +```python +# add a worth Attribute to all existing Furniture +for obj in Furniture.objects.all(): + # this will loop over all Furniture instances + obj.db.worth = 100 +``` + +A common use case is putting all Attributes in the `at_*_creation` hook of the entity, such as +`at_object_creation` for `Objects`. This is called every time an object is created - and only then. +This is usually what you want but it does mean already existing objects won't get updated if you +change the contents of `at_object_creation` later. You can fix this in a similar way as above +(manually setting each Attribute) or with something like this: + +```python +# Re-run at_object_creation only on those objects not having the new Attribute +for obj in Furniture.objects.all(): + if not obj.db.worth: + obj.at_object_creation() +``` + +The above examples can be run in the command prompt created by `evennia shell`. You could also run +it all in-game using `@py`. That however requires you to put the code (including imports) as one +single line using `;` and [list +comprehensions](http://www.secnetix.de/olli/Python/list_comprehensions.hawk), like this (ignore the +line break, that's only for readability in the wiki): + +``` +py from typeclasses.furniture import Furniture; +[obj.at_object_creation() for obj in Furniture.objects.all() if not obj.db.worth] +``` + +It is recommended that you plan your game properly before starting to build, to avoid having to +retroactively update objects more than necessary. + +### Swap typeclass + +If you want to swap an already existing typeclass, there are two ways to do so: From in-game and via code. From inside the game you can use the default `@typeclass` command: + +``` +typeclass objname = path.to.new.typeclass +``` + +There are two important switches to this command: +- `/reset` - This will purge all existing Attributes on the object and re-run the creation hook (like `at_object_creation` for Objects). This assures you get an object which is purely of this new class. +- `/force` - This is required if you are changing the class to be *the same* class the object already has - it's a safety check to avoid user errors. This is usually used together with `/reset` to re-run the creation hook on an existing class. + +In code you instead use the `swap_typeclass` method which you can find on all typeclassed entities: + +```python +obj_to_change.swap_typeclass(new_typeclass_path, clean_attributes=False, + run_start_hooks="all", no_default=True, clean_cmdsets=False) +``` + +The arguments to this method are described [in the API docs here](github:evennia.typeclasses.models#typedobjectswap_typeclass). + + +## How typeclasses actually work + +*This is considered an advanced section.* + +Technically, typeclasses are [Django proxy models](https://docs.djangoproject.com/en/4.1/topics/db/models/#proxy-models). The only database +models that are "real" in the typeclass system (that is, are represented by actual tables in the database) are `AccountDB`, `ObjectDB`, `ScriptDB` and `ChannelDB` (there are also [Attributes](./Attributes.md) and [Tags](./Tags.md) but they are not typeclasses themselves). All the subclasses of them are "proxies", extending them with Python code without actually modifying the database layout. + +Evennia modifies Django's proxy model in various ways to allow them to work without any boiler plate (for example you don't need to set the Django "proxy" property in the model `Meta` subclass, Evennia handles this for you using metaclasses). Evennia also makes sure you can query subclasses as well as patches django to allow multiple inheritance from the same base class. + +### Caveats + +Evennia uses the *idmapper* to cache its typeclasses (Django proxy models) in memory. The idmapper allows things like on-object handlers and properties to be stored on typeclass instances and to not get lost as long as the server is running (they will only be cleared on a Server reload). Django does not work like this by default; by default every time you search for an object in the database you'll get a *different* instance of that object back and anything you stored on it that was not in the database would be lost. The bottom line is that Evennia's Typeclass instances subside in memory a lot longer than vanilla Django model instance do. + +There is one caveat to consider with this, and that relates to [making your own models](New- +Models): Foreign relationships to typeclasses are cached by Django and that means that if you were to change an object in a foreign relationship via some other means than via that relationship, the object seeing the relationship may not reliably update but will still see its old cached version. Due to typeclasses staying so long in memory, stale caches of such relationships could be more +visible than common in Django. See the [closed issue #1098 and its comments](https://github.com/evennia/evennia/issues/1098) for examples and solutions. + +## Will I run out of dbrefs? + +Evennia does not re-use its `#dbrefs`. This means new objects get an ever-increasing `#dbref`, also if you delete older objects. There are technical and safety reasons for this. But you may wonder if this means you have to worry about a big game 'running out' of dbref integers eventually. + +The answer is simply **no**. + +For example, the max dbref value for the default sqlite3 database is `2**64`. If you *created 10 000 new objects every second of every minute of every day of the year it would take about **60 million years** for you to run out of dbref numbers*. That's a database of 140 TeraBytes, just to store the dbrefs, no other data. + +If you are still using Evennia at that point and have this concern, get back to us and we can discuss adding dbref reuse then. diff --git a/docs/latest/_sources/Components/Web-API.md.txt b/docs/latest/_sources/Components/Web-API.md.txt new file mode 100644 index 0000000000..da4b70e31c --- /dev/null +++ b/docs/latest/_sources/Components/Web-API.md.txt @@ -0,0 +1,122 @@ +# Evennia REST API + +Evennia makes its database accessible via a REST API found on +[http://localhost:4001/api](http://localhost:4001/api) if running locally with default setup. The API allows you to retrieve, edit and create resources from outside the game, for example with your own custom client or game editor. While you can view and learn about the api in the web browser, it is really +meant to be accessed in code, by other programs. + +The API is using [Django Rest Framework][drf]. This automates the process +of setting up _views_ (Python code) to process the result of web requests. +The process of retrieving data is similar to that explained on the +[Webserver](./Webserver.md) page, except the views will here return [JSON][json] +data for the resource you want. You can also _send_ such JSON data +in order to update the database from the outside. + + +## Usage + +To activate the API, add this to your settings file. + + REST_API_ENABLED = True + +The main controlling setting is `REST_FRAMEWORK`, which is a dict. The keys +`DEFAULT_LIST_PERMISSION` and `DEFAULT_CREATE_PERMISSIONS` control who may +view and create new objects via the api respectively. By default, users with +['Builder'-level permission](./Permissions.md) or higher may access both actions. + +While the api is meant to be expanded upon, Evennia supplies several operations +out of the box. If you click the `Autodoc` button in the upper right of the `/api` +website you'll get a fancy graphical presentation of the available endpoints. + +Here is an example of calling the api in Python using the standard `requests` library. + + >>> import requests + >>> response = requests.get("https://www.mygame.com/api", auth=("MyUsername", "password123")) + >>> response.json() + {'accounts': 'http://www.mygame.com/api/accounts/', + 'objects': 'http://www.mygame.com/api/objects/', + 'characters': 'http://www.mygame.comg/api/characters/', + 'exits': 'http://www.mygame.com/api/exits/', + 'rooms': 'http://www.mygame.com/api/rooms/', + 'scripts': 'http://www.mygame.com/api/scripts/' + 'helpentries': 'http://www.mygame.com/api/helpentries/' } + +To list a specific type of object: + + >>> response = requests.get("https://www.mygame.com/api/objects", + auth=("Myusername", "password123")) + >>> response.json() + { + "count": 125, + "next": "https://www.mygame.com/api/objects/?limit=25&offset=25", + "previous": null, + "results" : [{"db_key": "A rusty longsword", "id": 57, "db_location": 213, ...}]} + +In the above example, it now displays the objects inside the "results" array, while it has a "count" value for the number of total objects, and "next" and "previous" links for the next and previous page, if any. This is called [pagination][pagination], and the link displays "limit" and "offset" as query parameters that can be added to the url to control the output. + + +Other query parameters can be defined as [filters][filters] which allow you to further narrow the results. For example, to only get accounts with developer permissions: + + >>> response = requests.get("https://www.mygame.com/api/accounts/?permission=developer", + auth=("MyUserName", "password123")) + >>> response.json() + { + "count": 1, + "results": [{"username": "bob",...}] + } + +Now suppose that you want to use the API to create an [Object](./Objects.md): + + >>> data = {"db_key": "A shiny sword"} + >>> response = requests.post("https://www.mygame.com/api/objects", + data=data, auth=("Anotherusername", "mypassword")) + >>> response.json() + {"db_key": "A shiny sword", "id": 214, "db_location": None, ...} + + +Here we made a HTTP POST request to the `/api/objects` endpoint with the `db_key` we wanted. We got back info for the newly created object. You can now make another request with PUT (replace everything) or PATCH (replace only what you provide). By providing the id to the endpoint (`/api/objects/214`), we make sure to update the right sword: + + >>> data = {"db_key": "An even SHINIER sword", "db_location": 50} + >>> response = requests.put("https://www.mygame.com/api/objects/214", + data=data, auth=("Anotherusername", "mypassword")) + >>> response.json() + {"db_key": "An even SHINIER sword", "id": 214, "db_location": 50, ...} + + +In most cases, you won't be making API requests to the backend with Python, +but with Javascript from some frontend application. +There are many Javascript libraries which are meant to make this process +easier for requests from the frontend, such as [AXIOS][axios], or using +the native [Fetch][fetch]. + +## Customizing the API + +Overall, reading up on [Django Rest Framework ViewSets](https://www.django-rest-framework.org/api-guide/viewsets) and +other parts of their documentation is required for expanding and +customizing the API. + +Check out the [Website](./Website.md) page for help on how to override code, templates +and static files. +- API templates (for the web-display) is located in `evennia/web/api/templates/rest_framework/` (it must + be named such to allow override of the original REST framework templates). +- Static files is in `evennia/web/api/static/rest_framework/` +- The api code is located in `evennia/web/api/` - the `url.py` file here is responsible for + collecting all view-classes. + +Contrary to other web components, there is no pre-made urls.py set up for +`mygame/web/api/`. This is because the registration of models with the api is +strongly integrated with the REST api functionality. Easiest is probably to +copy over `evennia/web/api/urls.py` and modify it in place. + + +[wiki-api]: https://en.wikipedia.org/wiki/Application_programming_interface +[drf]: https://www.django-rest-framework.org/ +[pagination]: https://www.django-rest-framework.org/api-guide/pagination/ +[filters]: https://www.django-rest-framework.org/api-guide/filtering/#filtering +[json]: https://en.wikipedia.org/wiki/JSON +[crud]: https://en.wikipedia.org/wiki/Create,_read,_update_and_delete +[serializers]: https://www.django-rest-framework.org/api-guide/serializers/ +[ajax]: https://en.wikipedia.org/wiki/Ajax_(programming) +[rest]: https://en.wikipedia.org/wiki/Representational_state_transfer +[requests]: https://requests.readthedocs.io/en/master/ +[axios]: https://github.com/axios/axios +[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API diff --git a/docs/latest/_sources/Components/Web-Admin.md.txt b/docs/latest/_sources/Components/Web-Admin.md.txt new file mode 100644 index 0000000000..54f0df51ea --- /dev/null +++ b/docs/latest/_sources/Components/Web-Admin.md.txt @@ -0,0 +1,158 @@ +# The Web Admin + +The Evennia _Web admin_ is a customized [Django admin site](https://docs.djangoproject.com/en/4.1/ref/contrib/admin/) +used for manipulating the game database using a graphical interface. You +have to be logged into the site to use it. It then appears as an `Admin` link +the top of your website. You can also go to [http://localhost:4001/admin](http://localhost:4001/admin) when +running locally. + +Almost all actions done in the admin can also be done in-game by use of Admin- +or Builder-commands. + +## Usage + +The admin is pretty self-explanatory - you can see lists of each object type, +create new instances of each type and also add new Attributes/tags them. The +admin frontpage will give a summary of all relevant entities and how they are +used. + +There are a few use cases that requires some additional explanation though. + +### Adding objects to Attributes + +The `value` field of an Attribute is pickled into a special form. This is usually not +something you need to worry about (the admin will pickle/unpickle) the value +for you), _except_ if you want to store a database-object in an attribute. Such +objects are actually stored as a `tuple` with object-unique data. + +1. Find the object you want to add to the Attribute. At the bottom of the first section + you'll find the field _Serialized string_. This string shows a Python tuple like + + ('__packed_dbobj__', ('objects', 'objectdb'), '2021:05:15-08:59:30:624660', 358) + + Mark and copy this tuple-string to your clipboard exactly as it stands (parentheses and all). +2. Go to the entity that should have the new Attribute and create the Attribute. In its `value` + field, paste the tuple-string you copied before. Save! +3. If you want to store multiple objects in, say, a list, you can do so by literally + typing a python list `[tuple, tuple, tuple, ...]` where you paste in the serialized + tuple-strings with commas. At some point it's probably easier to do this in code though ... + +### Linking Accounts and Characters + +In `MULTISESSION_MODE` 0 or 1, each connection can have one Account and one +Character, usually with the same name. Normally this is done by the user +creating a new account and logging in - a matching Character will then be +created for them. You can however also do so manually in the admin: + +1. First create the complete Account in the admin. +2. Next, create the Object (usually of `Character` typeclass) and name it the same + as the Account. It also needs a command-set. The default CharacterCmdset is a good bet. +3. In the `Puppeting Account` field, select the Account. +4. Make sure to save everything. +5. Click the `Link to Account` button (this will only work if you saved first). This will + add the needed locks and Attributes to the Account to allow them to immediately + connect to the Character when they next log in. This will (where possible): + - Set `account.db._last_puppet` to the Character. + - Add Character to `account.db._playabel_characters` list. + - Add/extend the `puppet:` lock on the Character to include `puppet:pid()` + +### Building with the Admin + +It's possible (if probably not very practical at scale) to build and describe +rooms in the Admin. + +1. Create an `Object` of a Room-typeclass with a suitable room-name. +2. Set an Attribute 'desc' on the room - the value of this Attribute is the + room's description. +3. Add `Tags` of `type` 'alias' to add room-aliases (no type for regular tags) + +Exits: + +1. Exits are `Objects` of an `Exit` typeclass, so create one. +2. The exit has `Location` of the room you just created. +3. Set `Destination` set to where the exit leads to. +4. Set a 'desc' Attribute, this is shown if someone looks at the exit. +5. `Tags` of `type` 'alias' are alternative names users can use to go through + this exit. + +## Grant others access to the admin + +The access to the admin is controlled by the `Staff status` flag on the +Account. Without this flag set, even superusers will not even see the admin +link on the web page. The staff-status has no in-game equivalence. + + +Only Superusers can change the `Superuser status` flag, and grant new +permissions to accounts. The superuser is the only permission level that is +also relevant in-game. `User Permissions` and `Groups` found on the `Account` +admin page _only_ affects the admin - they have no connection to the in-game +[Permissions](./Permissions.md) (Player, Builder, Admin etc). + +For a staffer with `Staff status` to be able to actually do anything, the +superuser must grant at least some permissions for them on their Account. This +can also be good in order to limit mistakes. It can be a good idea to not allow +the `Can delete Account` permission, for example. + +```{important} + + If you grant staff-status and permissions to an Account and they still cannot + access the admin's content, try reloading the server. + +``` + +```{warning} + + If a staff member has access to the in-game ``py`` command, they can just as + well have their admin ``Superuser status`` set too. The reason is that ``py`` + grants them all the power they need to set the ``is_superuser`` flag on their + account manually. There is a reason access to the ``py`` command must be + considered carefully ... + +``` + +## Customizing the web admin + +Customizing the admin is a big topic and something beyond the scope of this +documentation. See the [official Django docs](https://docs.djangoproject.com/en/4.1/ref/contrib/admin/) for +the details. This is just a brief summary. + +See the [Website](./Website.md) page for an overview of the components going into +generating a web page. The Django admin uses the same principle except that +Django provides a lot of tools to automate the admin-generation for us. + +Admin templates are found in `evennia/web/templates/admin/` but you'll find +this is relatively empty. This is because most of the templates are just +inherited directly from their original location in the Django package +(`django/contrib/admin/templates/`). So if you wanted to override one you'd have +to copy it from _there_ into your `mygame/templates/admin/` folder. Same is true +for CSS files. + +The admin site's backend code (the views) is found in `evennia/web/admin/`. It +is organized into `admin`-classes, like `ObjectAdmin`, `AccountAdmin` etc. +These automatically use the underlying database models to generate useful views +for us without us havint go code the forms etc ourselves. + +The top level `AdminSite` (the admin configuration referenced in django docs) +is found in `evennia/web/utils/adminsite.py`. + + +### Change the title of the admin + +By default the admin's title is `Evennia web admin`. To change this, add the +following to your `mygame/web/urls.py`: + +```python +# in mygame/web/urls.py + +# ... + +from django.conf.admin import site + +#... + +site.site_header = "My great game admin" + + +``` + +Reload the server and the admin's title header will have changed. diff --git a/docs/latest/_sources/Components/Web-Bootstrap-Framework.md.txt b/docs/latest/_sources/Components/Web-Bootstrap-Framework.md.txt new file mode 100644 index 0000000000..131f8c703b --- /dev/null +++ b/docs/latest/_sources/Components/Web-Bootstrap-Framework.md.txt @@ -0,0 +1,164 @@ +# Bootstrap frontend framework + +Evennia's default web page uses a framework called [Bootstrap](https://getbootstrap.com/). This framework is in use across the internet - you'll probably start to recognize its influence once you learn some of the common design patterns. This switch is great for web developers, perhaps like yourself, because instead of wondering about setting up different grid systems or what custom class another designer used, we have a base, a bootstrap, to work from. Bootstrap is responsive by default, and comes with some default styles that Evennia has lightly overrode to keep some of the same colors and styles you're used to from the previous design. + +e, a brief overview of Bootstrap follows. For more in-depth info, please +read [the documentation](https://getbootstrap.com/docs/4.0/getting-started/introduction/). + +## Grid system + +Other than the basic styling Bootstrap includes, it also includes [a built in layout and grid system](https://getbootstrap.com/docs/4.0/layout/overview/). + +### The container + +The first part of the grid system is [the container](https://getbootstrap.com/docs/4.0/layout/overview/#containers). + +The container is meant to hold all your page content. Bootstrap provides two types: fixed-width and +full-width. Fixed-width containers take up a certain max-width of the page - they're useful for limiting the width on Desktop or Tablet platforms, instead of making the content span the width of the page. + +``` +
+ +
+``` +Full width containers take up the maximum width available to them - they'll span across a wide- +screen desktop or a smaller screen phone, edge-to-edge. +``` +
+ +
+``` + +### The grid + +The second part of the layout system is [the grid](https://getbootstrap.com/docs/4.0/layout/grid/). + +This is the bread-and-butter of the layout of Bootstrap - it allows you to change the size of elements depending on the size of the screen, without writing any media queries. We'll briefly go over it - to learn more, please read the docs or look at the source code for Evennia's home page in your browser. + +> Important! Grid elements should be in a .container or .container-fluid. This will center the +contents of your site. + +Bootstrap's grid system allows you to create rows and columns by applying classes based on breakpoints. The default breakpoints are extra small, small, medium, large, and extra-large. If you'd like to know more about these breakpoints, please [take a look at the documentation for +them.](https://getbootstrap.com/docs/4.0/layout/overview/#responsive-breakpoints) + +To use the grid system, first create a container for your content, then add your rows and columns like so: +``` +
+
+
+ 1 of 3 +
+
+ 2 of 3 +
+
+ 3 of 3 +
+
+
+``` +This layout would create three equal-width columns. + +To specify your sizes - for instance, Evennia's default site has three columns on desktop and +tablet, but reflows to single-column on smaller screens. Try it out! +``` +
+
+
+ 1 of 4 +
+
+ 2 of 4 +
+
+ 3 of 4 +
+
+ 4 of 4 +
+
+
+``` +This layout would be 4 columns on large screens, 2 columns on medium screens, and 1 column on +anything smaller. + +To learn more about Bootstrap's grid, please [take a look at the +docs](https://getbootstrap.com/docs/4.0/layout/grid/) +I +## General Styling elements + +Bootstrap provides base styles for your site. These can be customized through CSS, but the default +styles are intended to provide a consistent, clean look for sites. + +### Color +Most elements can be styled with default colors. [Take a look at the documentation](https://getbootstrap.com/docs/4.0/utilities/colors/) to learn more about these colors +- suffice to say, adding a class of text-* or bg-*, for instance, text-primary, sets the text color +or background color. + +### Borders + +Simply adding a class of 'border' to an element adds a border to the element. For more in-depth +info, please [read the documentation on borders.](https://getbootstrap.com/docs/4.0/utilities/borders/). +``` + +``` +You can also easily round corners just by adding a class. +``` + +``` + +### Spacing +Bootstrap provides classes to easily add responsive margin and padding. Most of the time, you might like to add margins or padding through CSS itself - however these classes are used in the default Evennia site. [Take a look at the docs](https://getbootstrap.com/docs/4.0/utilities/spacing/) to +learn more. + +### Buttons + +[Buttons](https://getbootstrap.com/docs/4.0/components/buttons/) in Bootstrap are very easy to use - button styling can be added to ` + + + +``` + +### Cards + +[Cards](https://getbootstrap.com/docs/4.0/components/card/) provide a container for other elements +that stands out from the rest of the page. The "Accounts", "Recently Connected", and "Database +Stats" on the default webpage are all in cards. Cards provide quite a bit of formatting options - +the following is a simple example, but read the documentation or look at the site's source for more. +``` +
+
+

Card title

+
Card subtitle
+

Fancy, isn't it?

+ Card link +
+
+``` + +### Jumbotron + +[Jumbotrons](https://getbootstrap.com/docs/4.0/components/jumbotron/) are useful for featuring an +image or tagline for your game. They can flow with the rest of your content or take up the full +width of the page - Evennia's base site uses the former. +``` +
+
+

Full Width Jumbotron

+

Look at the source of the default Evennia page for a regular Jumbotron

+
+
+``` + +### Forms + +[Forms](https://getbootstrap.com/docs/4.0/components/forms/) are highly customizable with Bootstrap. +For a more in-depth look at how to use forms and their styles in your own Evennia site, please read +over [the web character gen tutorial.](../Howtos/Web-Character-Generation.md) + +## Further reading + +Bootstrap also provides a huge amount of utilities, as well as styling and content elements. To learn more about them, please [read the Bootstrap docs](https://getbootstrap.com/docs/4.0/getting- started/introduction/) or read one of our other web tutorials. \ No newline at end of file diff --git a/docs/latest/_sources/Components/Webclient.md.txt b/docs/latest/_sources/Components/Webclient.md.txt new file mode 100644 index 0000000000..2505566d7d --- /dev/null +++ b/docs/latest/_sources/Components/Webclient.md.txt @@ -0,0 +1,286 @@ +# Web Client + +Evennia comes with a MUD client accessible from a normal web browser. During development you can try +it at `http://localhost:4001/webclient`. The client consists of several parts, all under +`evennia/web`: + +`templates/webclient/webclient.html` and `templates/webclient/base.html` are the very simplistic +django html templates describing the webclient layout. + +`static/webclient/js/evennia.js` is the main evennia javascript library. This handles all +communication between Evennia and the client over websockets and via AJAX/COMET if the browser can't +handle websockets. It will make the Evennia object available to the javascript namespace, which +offers methods for sending and receiving data to/from the server transparently. This is intended to +be used also if swapping out the gui front end. + +`static/webclient/js/webclient_gui.js` is the default plugin manager. It adds the `plugins` and +`plugin_manager` objects to the javascript namespace, coordinates the GUI operations between the +various plugins, and uses the Evennia object library for all in/out. + +`static/webclient/js/plugins` provides a default set of plugins that implement a "telnet-like" +interface, and a couple of example plugins to show how you could implement new plugin features. + +`static/webclient/css/webclient.css` is the CSS file for the client; it also defines things like how +to display ANSI/Xterm256 colors etc. + +The server-side webclient protocols are found in `evennia/server/portal/webclient.py` and +`webclient_ajax.py` for the two types of connections. You can't (and should not need to) modify +these. + +## Customizing the web client + +Like was the case for the website, you override the webclient from your game directory. You need to +add/modify a file in the matching directory locations within your project's `mygame/web/` directories. +These directories are NOT directly used by the web server when the game is running, the +server copies everything web related in the Evennia folder over to `mygame/server/.static/` and then +copies in all of your `mygame/web/` files. This can cause some cases were you edit a file, but it doesn't +seem to make any difference in the servers behavior. **Before doing anything else, try shutting +down the game and running `evennia collectstatic` from the command line then start it back up, clear +your browser cache, and see if your edit shows up.** + +Example: To change the list of in-use plugins, you need to override base.html by copying +`evennia/web/templates/webclient/base.html` to +`mygame/web/templates/webclient/base.html` and editing it to add your new plugin. + +## Evennia Web Client API (from evennia.js) +* `Evennia.init( opts )` +* `Evennia.connect()` +* `Evennia.isConnected()` +* `Evennia.msg( cmdname, args, kwargs, callback )` +* `Evennia.emit( cmdname, args, kwargs )` +* `log()` + +## Plugin Manager API (from webclient_gui.js) +* `options` Object, Stores key/value 'state' that can be used by plugins to coordinate behavior. +* `plugins` Object, key/value list of the all the loaded plugins. +* `plugin_handler` Object + * `plugin_handler.add("name", plugin)` + * `plugin_handler.onSend(string)` + +## Plugin callbacks API +* `init()` -- The only required callback +* `boolean onKeydown(event)` This plugin listens for Keydown events +* `onBeforeUnload()` This plugin does something special just before the webclient page/tab is +closed. +* `onLoggedIn(args, kwargs)` This plugin does something when the webclient first logs in. +* `onGotOptions(args, kwargs)` This plugin does something with options sent from the server. +* `boolean onText(args, kwargs)` This plugin does something with messages sent from the server. +* `boolean onPrompt(args, kwargs)` This plugin does something when the server sends a prompt. +* `boolean onUnknownCmd(cmdname, args, kwargs)` This plugin does something with "unknown commands". +* `onConnectionClose(args, kwargs)` This plugin does something when the webclient disconnects from +the server. +* `newstring onSend(string)` This plugin examines/alters text that other plugins generate. **Use +with caution** + +The order of the plugins defined in `base.html` is important. All the callbacks for each plugin +will be executed in that order. Functions marked "boolean" above must return true/false. Returning +true will short-circuit the execution, so no other plugins lower in the base.html list will have +their callback for this event called. This enables things like the up/down arrow keys for the +history.js plugin to always occur before the default_in.js plugin adds that key to the current input +buffer. + +### Example/Default Plugins (`plugins/*.js`) + +* `clienthelp.js` Defines onOptionsUI from the options2 plugin. This is a mostly empty plugin to +add some "How To" information for your game. +* `default_in.js` Defines onKeydown. `` key or mouse clicking the arrow will send the currently typed text. +* `default_out.js` Defines onText, onPrompt, and onUnknownCmd. Generates HTML output for the user. +* `default_unload.js` Defines onBeforeUnload. Prompts the user to confirm that they meant to +leave/close the game. +* `font.js` Defines onOptionsUI. The plugin adds the ability to select your font and font size. +* `goldenlayout_default_config.js` Not actually a plugin, defines a global variable that +goldenlayout uses to determine its window layout, known tag routing, etc. +* `goldenlayout.js` Defines onKeydown, onText and custom functions. A very powerful "tabbed" window manager for drag-n-drop windows, text routing and more. +* `history.js` Defines onKeydown and onSend. Creates a history of past sent commands, and uses arrow keys to peruse. +* `hotbuttons.js` Defines onGotOptions. A Disabled-by-default plugin that defines a button bar with +user-assignable commands. +* `html.js` A basic plugin to allow the client to handle "raw html" messages from the server, this +allows the server to send native HTML messages like >div style='s'<styled text>/div< +* `iframe.js` Defines onOptionsUI. A goldenlayout-only plugin to create a restricted browsing sub- +window for a side-by-side web/text interface, mostly an example of how to build new HTML +"components" for goldenlayout. +* `message_routing.js` Defines onOptionsUI, onText, onKeydown. This goldenlayout-only plugin +implements regex matching to allow users to "tag" arbitrary text that matches, so that it gets +routed to proper windows. Similar to "Spawn" functions for other clients. +* `multimedia.js` An basic plugin to allow the client to handle "image" "audio" and "video" messages from the server and display them as inline HTML. +* `notifications.js` Defines onText. Generates browser notification events for each new message +while the tab is hidden. +* `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. +* `text2html.js` Provides a new message handler type: `text2html`, similar to the multimedia and html plugins. This plugin provides a way to offload rendering the regular pipe-styled ASCII messages to the client. This allows the server to do less work, while also allowing the client a place to customize this conversion process. To use this plugin you will need to override the current commands in Evennia, changing any place where a raw text output message is generated and turn it into a `text2html` message. For example: `target.msg("my text")` becomes: `target.msg(text2html=("my text"))` (even better, use a webclient pane routing tag: `target.msg(text2html=("my text", {"type": "sometag"}))`) `text2html` messages should format and behave identically to the server-side generated text2html() output. + +### A side note on html messages vs text2html messages + +So...lets say you have a desire to make your webclient output more like standard webpages... +For telnet clients, you could collect a bunch of text lines together, with ASCII formatted borders, etc. Then send the results to be rendered client-side via the text2html plugin. + +But for webclients, you could format a message directly with the html plugin to render the whole thing as an HTML table, like so: + +``` + # Server Side Python Code: + + if target.is_webclient(): + # This can be styled however you like using CSS, just add the CSS file to web/static/webclient/css/... + table = [ + "", + "", + "", + "
123
456
" + ] + target.msg( html=( "".join(table), {"type": "mytag"}) ) + else: + # This will use the client to render this as "plain, simple" ASCII text, the same + # as if it was rendered server-side via the Portal's text2html() functions + table = [ + "#############", + "# 1 # 2 # 3 #", + "#############", + "# 4 # 5 # 6 #", + "#############" + ] + target.msg( html2html=( "\n".join(table), {"type": "mytag"}) ) +``` + +## 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. +The Goldenlayout plugin framework can help with this. + +### GoldenLayout + +GoldenLayout is a web framework that allows web developers and their users to create their own +tabbed/windowed layouts. Windows/tabs can be click-and-dragged from location to location by +clicking on their titlebar and dragging until the "frame lines" appear. Dragging a window onto +another window's titlebar will create a tabbed "Stack". The Evennia goldenlayout plugin defines 3 +basic types of window: The Main window, input windows and non-main text output windows. The Main +window and the first input window are unique in that they can't be "closed". + +The most basic customization is to provide your users with a default layout other than just one Main +output and the one starting input window. This is done by modifying your server's +goldenlayout_default_config.js. + +Start by creating a new +`mygame/web/static/webclient/js/plugins/goldenlayout_default_config.js` file, and adding +the following JSON variable: + +``` +var goldenlayout_config = { + content: [{ + type: 'column', + content: [{ + type: 'row', + content: [{ + type: 'column', + content: [{ + type: 'component', + componentName: 'Main', + isClosable: false, + tooltip: 'Main - drag to desired position.', + componentState: { + cssClass: 'content', + types: 'untagged', + updateMethod: 'newlines', + }, + }, { + type: 'component', + componentName: 'input', + id: 'inputComponent', + height: 10, + tooltip: 'Input - The last input in the layout is always the default.', + }, { + type: 'component', + componentName: 'input', + id: 'inputComponent', + height: 10, + isClosable: false, + tooltip: 'Input - The last input in the layout is always the default.', + }] + },{ + type: 'column', + content: [{ + type: 'component', + componentName: 'evennia', + componentId: 'evennia', + title: 'example', + height: 60, + isClosable: false, + componentState: { + types: 'some-tag-here', + updateMethod: 'newlines', + }, + }, { + type: 'component', + componentName: 'evennia', + componentId: 'evennia', + title: 'sheet', + isClosable: false, + componentState: { + types: 'sheet', + updateMethod: 'replace', + }, + }], + }], + }] + }] +}; +``` +This is a bit ugly, but hopefully, from the indentation, you can see that it creates a side-by-side +(2-column) interface with 3 windows down the left side (The Main and 2 inputs) and a pair of windows +on the right side for extra outputs. Any text tagged with "some-tag-here" will flow to the bottom +of the "example" window, and any text tagged "sheet" will replace the text already in the "sheet" +window. + +Note: GoldenLayout gets VERY confused and will break if you create two windows with the "Main" +componentName. + +Now, let's say you want to display text on each window using different CSS. This is where new +goldenlayout "components" come in. Each component is like a blueprint that gets stamped out when +you create a new instance of that component, once it is defined, it won't be easily altered. You +will need to define a new component, preferably in a new plugin file, and then add that into your +page (either dynamically to the DOM via javascript, or by including the new plugin file into the +base.html). + +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: +``` + +``` +Remember, plugins are load-order dependent, so make sure the new ` + +``` +#-#-#-# # +| / d +#-# | # + \ u |\ +o---#-----#---+-#-# +| ^ |/ +| | # +v | \ +#-#-#-#-#-# #---# + |x|x| / + #-#-# #- +``` + +``` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + #---# + / + @- +-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Dungeon Entrance +To the east, a narrow opening leads into darkness. +Exits: northeast and east + +``` + +## Installation + +1. XYZGrid requires the `scipy` library. Easiest is to get the 'extra' + dependencies of Evennia with + + pip install evennia[extra] + + If you use the `git` install, you can also + + (cd to evennia/ folder) + pip install --upgrade -e .[extra] + + This will install all optional requirements of Evennia. +2. Import and [add] the `evennia.contrib.grid.xyzgrid.commands.XYZGridCmdSet` to the + `CharacterCmdset` cmdset in `mygame/commands.default_cmds.py`. Reload + the server. This makes the `map`, `goto/path` and the modified `teleport` and + `open` commands available in-game. + +[add]: ../Components/Command-Sets + +3. Edit `mygame/server/conf/settings.py` and add + + EXTRA_LAUNCHER_COMMANDS['xyzgrid'] = 'evennia.contrib.grid.xyzgrid.launchcmd.xyzcommand' + PROTOTYPE_MODULES += ['evennia.contrib.grid.xyzgrid.prototypes'] + + This will add the new ability to enter `evennia xyzgrid