diff --git a/.release.sh b/.release.sh index b9e131d7e8..55da41b193 100755 --- a/.release.sh +++ b/.release.sh @@ -9,13 +9,14 @@ echo " 2. On main branch, update CHANGELOG.md." echo " 3. Make sure pyproject.toml is set to the same major.minor.patch version as evennia/VERSION.txt ($VERSION)." echo " 4. If major release:" echo " a. Update docs/sources/conf.py, Add '[MAJOR_VERSION].x' to 'legacy_versions' and 'v$VERSION' to 'legacy_branches'." -echo " b. Make sure all changes are committed, e.g. as 'Evennia $VERSION major/minor/patch release'." -echo " c. Check out a new branch v$VERSION." -echo " d. Push the v$VERSION branch to github." -echo " e. On the v$VERSION branch, temporarily set 'current_is_legacy=True' in source/conf.py, then (re)build " +echo " b. Update 'SECURITY.md' with latest new version." +echo " c. Make sure all changes are committed, e.g. as 'Evennia $VERSION major/minor/patch release'." +echo " d. Check out a new branch v$VERSION." +echo " e. Push the v$VERSION branch to github." +echo " f. On the v$VERSION branch, temporarily set 'current_is_legacy=True' in source/conf.py, then (re)build " echo " the docs for this release with 'make local' and old-version warning headers. Throw away git changes after." -echo " f. Rename the created build/html folder to '[MAJOR_VERSION].x'. Manually copy it to the gh-pages branch's build/ folder." -echo " g. Add the folder, commit and push to the gh-pages branch. Then checkout main branch again." +echo " g. Rename the created build/html folder to '[MAJOR_VERSION].x'. Manually copy it to the gh-pages branch's build/ folder." +echo " h. Add the folder, commit and push to the gh-pages branch. Then checkout main branch again." echo " 5. Run 'make local' in docs/ to update dynamic docs (like Changelog.md) and autodocstrings (may have to run twice)." echo " 6. Make sure all changes are committed (if not already), e.g. as 'Evennia $VERSION major/minor/patch release' (un-staged files will be wiped)." echo " 7. Make sure all unit tests pass!" diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b1034a9e..f92cdab5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,120 @@ ## Main branch +- [Feature][pull3470]: New `exit_order` kwarg to + `DefaultObject.get_display_exits` to easier customize the order in which + standard exits are displayed in a room (chiizujin) + +[pull3470]: https://github.com/evennia/evennia/pull/3470 + +## Evennia 4.1.1 + +April 6, 2024 + +- [Fix][pull3438]: Error with 'you' mapping in third-person style of + `msg_contents` (InspectorCaracal) +- [Fix][pull3472]: The new `filter_visible` didn't exclude oneself by default + (InspectorCaracal) +- Fix: `find #dbref` results didn't include the results of + `.get_extra_display_name_info` (the #dbref display by default) (Griatch) +- Fix: Add `DefaultAccount.get_extra_display_name_info` method for API + compliance with `DefaultObject` in commands. (Griatch) +- Fix: Show `XYZRoom` subclass when repr() it. (Griatch) +- [Fix][pull3485]: Typo in `sethome` message (chiizujin) +- [Fix][pull3487]: Fix traceback when using `get`,`drop` and `give` with no + arguments (InspectorCaracal) +- [Fix][issue3476]: Don't ignore EvEditor commands with wrong capitalization (Griatch) +- [Fix][issue3477]: The `at_server_reload_start()` hook was not firing on + a reload (regression). +- [Fix][issue3488]: `AttributeProperty(, autocreate=False)`, where + `` was mutable would not update/save properly in-place (Griatch) +- [Docs] Added new [Server-Lifecycle][doc-server-lifecycle] page to describe + the hooks called on server start/stop/reload (Griatch) +- [Docs] Doc typo fixes (Griatch, chiizujin) + +[pull3438]: https://github.com/evennia/evennia/pull/3446 +[pull3485]: https://github.com/evennia/evennia/pull/3485 +[pull3487]: https://github.com/evennia/evennia/pull/3487 +[issue3476]: https://github.com/evennia/evennia/issues/3476 +[issue3477]: https://github.com/evennia/evennia/issues/3477 +[issue3488]: https://github.com/evennia/evennia/issues/3488 +[doc-server-lifecycle]: https://www.evennia.com/docs/latest/Concepts/Server-Lifecycle.html + + +## Evennia 4.1.0 + +April 1, 2024 + +- [Deprecation]: `DefaultObject.get_visible_contents` - unused in core, will be + removed. Use the new `.filter_visible` together with the `.get_display_*` methods instead.. +- [Deprecation]: `DefaultObject.get_content_names` - unused in core, will be + removed. Use the `DefaultObject.get_display_*` methods instead. + - [Feature][pull3421]: New `utils.compress_whitespace` utility used with default object's `.format_appearance` to make it easier to overload without adding line breaks in hook returns. (InspectorCaracal) +- [Feature][pull3458]: New `sethelp/category` switch to change a help topic's + category after it was created (chiizujin) +- [Feature][pull3467]: Add `alias/delete` switch for removing object aliases + from in-game with default command (chiizujin) +- [Feature][issue3450]: The default `page` command now tags its `Msg` objects + with tag 'page' (category 'comms') and also checks the `Msg`' 'read' lock. + made backwards compatible for old pages (Griatch) +- [Feature][pull3466]: Add optional `no_article` kwarg to + `DefaultObject.get_numbered_name` for the system to skip adding automatic + articles. (chiizujin) +- [Feature][pull3433]: Add ability to default get/drop to affect stacks of + items, such as `get/drop 3 rock` by a custom class parent (InspectorCaracal) +- Feature: Clean up the default Command variable list shown when a command has + no `func()` defined (Griatch) +- [Feature][issue3461]: Add `DefaultObject.filter_display_visible` helper method + to make it easier to customize object visibility rules. (Griatch) - [Fix][pull3446]: Use plural ('no apples') instead of singular ('no apple') in `get_numbered_name` for better grammatical form (InspectorCaracal) -- Doc: Added Beginner Tutorial lessons for AI and Procedural dungeon (Griatch) -- Doc fixes (Griatch, InspectorCaracal) +- [Fix][pull3453]: Object aliases not showing in search multi-match + disambiguation display (chiizujin) +- [Fix][pull3455]: `sethelp/edit ` without a `= text` created a `None` + entry that would lose the edit. (chiiziujin) +- [Fix][pull3456]: `format_grid` utility used for `help` command caused commands + to disappear for wider client widths (chiizujin) +- [Fix][pull3457]: Help topic categories with different case would appear as + duplicates (chiizujin) +- [Fix][pull3454]: Traceback in crafting contrib's `recipe.msg` + (InspectorCaracal) +- [Fix][pull3459]: EvEditor line-echo compacted whitespace erroneously (chiizujin) +- [Fix][pull3463]: EvEditor :help described the :paste operation in the wrong + way (chiizujin) +- [Fix][pull3464]: EvEditor range:range specification didn't return correct + range (chiizujin) +- [Fix][issue3462]: EvEditor :UU and :DD etc commands were not properly + differentiating from their lower-case alternatives (Griatch) +- [Fix][issue3460]: The `menu_login` contrib regression caused it to error out + when creating a new character (Griatch) +- Doc: Added Beginner Tutorial lessons for [Monster and NPC AI][docAI], + [Quests][docQuests] and [Making a Procedural dungeon][docDungeon] (Griatch) +- Doc fixes (Griatch, InspectorCaracal, homeofpoe) [pull3421]: https://github.com/evennia/evennia/pull/3421 [pull3446]: https://github.com/evennia/evennia/pull/3446 +[pull3453]: https://github.com/evennia/evennia/pull/3453 +[pull3455]: https://github.com/evennia/evennia/pull/3455 +[pull3456]: https://github.com/evennia/evennia/pull/3456 +[pull3457]: https://github.com/evennia/evennia/pull/3457 +[pull3458]: https://github.com/evennia/evennia/pull/3458 +[pull3454]: https://github.com/evennia/evennia/pull/3454 +[pull3459]: https://github.com/evennia/evennia/pull/3459 +[pull3463]: https://github.com/evennia/evennia/pull/3463 +[pull3464]: https://github.com/evennia/evennia/pull/3464 +[pull3466]: https://github.com/evennia/evennia/pull/3466 +[pull3467]: https://github.com/evennia/evennia/pull/3467 +[pull3433]: https://github.com/evennia/evennia/pull/3433 +[issue3450]: https://github.com/evennia/evennia/issues/3450 +[issue3462]: https://github.com/evennia/evennia/issues/3462 +[issue3460]: https://github.com/evennia/evennia/issues/3460 +[issue3461]: https://github.com/evennia/evennia/issues/3461 +[docAI]: https://www.evennia.com/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.html +[docQuests]: https://www.evennia.com/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.html +[docDungeon]: https://www.evennia.com/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.html ## Evennia 4.0.0 diff --git a/SECURITY.md b/SECURITY.md index 6ddcce0bee..99517cd032 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,8 @@ to use the latest minor/patch version within each major version. | Version | Supported | | ------- | ------------------ | -| 3.x | :white_check_mark: | +| 4.x | :white_check_mark: | +| 3.x | :x: | | 2.x | :x: | | 1.x | :x: | | < 1.0 | :x: | diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index 3740618239..b603ec36ca 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -1,16 +1,113 @@ # Changelog -## Main branch +## Evennia 4.1.1 + +April 6, 2024 + +- [Fix][pull3438]: Error with 'you' mapping in third-person style of + `msg_contents` (InspectorCaracal) +- [Fix][pull3472]: The new `filter_visible` didn't exclude oneself by default + (InspectorCaracal) +- Fix: `find #dbref` results didn't include the results of + `.get_extra_display_name_info` (the #dbref display by default) (Griatch) +- Fix: Add `DefaultAccount.get_extra_display_name_info` method for API + compliance with `DefaultObject` in commands. (Griatch) +- Fix: Show `XYZRoom` subclass when repr() it. (Griatch) +- [Fix][pull3485]: Typo in `sethome` message (chiizujin) +- [Fix][pull3487]: Fix traceback when using `get`,`drop` and `give` with no + arguments (InspectorCaracal) +- [Fix][issue3476]: Don't ignore EvEditor commands with wrong capitalization (Griatch) +- [Fix][issue3477]: The `at_server_reload_start()` hook was not firing on + a reload (regression). +- [Fix][issue3488]: `AttributeProperty(, autocreate=False)`, where + `` was mutable would not update/save properly in-place (Griatch) +- [Docs] Added new [Server-Lifecycle][doc-server-lifecycle] page to describe + the hooks called on server start/stop/reload (Griatch) +- [Docs] Doc typo fixes (Griatch, chiizujin) + +[pull3438]: https://github.com/evennia/evennia/pull/3446 +[pull3485]: https://github.com/evennia/evennia/pull/3485 +[pull3487]: https://github.com/evennia/evennia/pull/3487 +[issue3476]: https://github.com/evennia/evennia/issues/3476 +[issue3477]: https://github.com/evennia/evennia/issues/3477 +[issue3488]: https://github.com/evennia/evennia/issues/3488 +[doc-server-lifecycle]: https://www.evennia.com/docs/latest/Concepts/Server-Lifecycle.html + + +## Evennia 4.1.0 + +April 1, 2024 + +- [Deprecation]: `DefaultObject.get_visible_contents` - unused in core, will be + removed. Use the new `.filter_visible` together with the `.get_display_*` methods instead.. +- [Deprecation]: `DefaultObject.get_content_names` - unused in core, will be + removed. Use the `DefaultObject.get_display_*` methods instead. - [Feature][pull3421]: New `utils.compress_whitespace` utility used with default object's `.format_appearance` to make it easier to overload without adding line breaks in hook returns. (InspectorCaracal) +- [Feature][pull3458]: New `sethelp/category` switch to change a help topic's + category after it was created (chiizujin) +- [Feature][pull3467]: Add `alias/delete` switch for removing object aliases + from in-game with default command (chiizujin) +- [Feature][issue3450]: The default `page` command now tags its `Msg` objects + with tag 'page' (category 'comms') and also checks the `Msg`' 'read' lock. + made backwards compatible for old pages (Griatch) +- [Feature][pull3466]: Add optional `no_article` kwarg to + `DefaultObject.get_numbered_name` for the system to skip adding automatic + articles. (chiizujin) +- [Feature][pull3433]: Add ability to default get/drop to affect stacks of + items, such as `get/drop 3 rock` by a custom class parent (InspectorCaracal) +- Feature: Clean up the default Command variable list shown when a command has + no `func()` defined (Griatch) +- [Feature][issue3461]: Add `DefaultObject.filter_display_visible` helper method + to make it easier to customize object visibility rules. (Griatch) - [Fix][pull3446]: Use plural ('no apples') instead of singular ('no apple') in `get_numbered_name` for better grammatical form (InspectorCaracal) -- Doc fixes (Griatch, InspectorCaracal) +- [Fix][pull3453]: Object aliases not showing in search multi-match + disambiguation display (chiizujin) +- [Fix][pull3455]: `sethelp/edit ` without a `= text` created a `None` + entry that would lose the edit. (chiiziujin) +- [Fix][pull3456]: `format_grid` utility used for `help` command caused commands + to disappear for wider client widths (chiizujin) +- [Fix][pull3457]: Help topic categories with different case would appear as + duplicates (chiizujin) +- [Fix][pull3454]: Traceback in crafting contrib's `recipe.msg` + (InspectorCaracal) +- [Fix][pull3459]: EvEditor line-echo compacted whitespace erroneously (chiizujin) +- [Fix][pull3463]: EvEditor :help described the :paste operation in the wrong + way (chiizujin) +- [Fix][pull3464]: EvEditor range:range specification didn't return correct + range (chiizujin) +- [Fix][issue3462]: EvEditor :UU and :DD etc commands were not properly + differentiating from their lower-case alternatives (Griatch) +- [Fix][issue3460]: The `menu_login` contrib regression caused it to error out + when creating a new character (Griatch) +- Doc: Added Beginner Tutorial lessons for [Monster and NPC AI][docAI], + [Quests][docQuests] and [Making a Procedural dungeon][docDungeon] (Griatch) +- Doc fixes (Griatch, InspectorCaracal, homeofpoe) [pull3421]: https://github.com/evennia/evennia/pull/3421 [pull3446]: https://github.com/evennia/evennia/pull/3446 +[pull3453]: https://github.com/evennia/evennia/pull/3453 +[pull3455]: https://github.com/evennia/evennia/pull/3455 +[pull3456]: https://github.com/evennia/evennia/pull/3456 +[pull3457]: https://github.com/evennia/evennia/pull/3457 +[pull3458]: https://github.com/evennia/evennia/pull/3458 +[pull3454]: https://github.com/evennia/evennia/pull/3454 +[pull3459]: https://github.com/evennia/evennia/pull/3459 +[pull3463]: https://github.com/evennia/evennia/pull/3463 +[pull3464]: https://github.com/evennia/evennia/pull/3464 +[pull3466]: https://github.com/evennia/evennia/pull/3466 +[pull3467]: https://github.com/evennia/evennia/pull/3467 +[pull3433]: https://github.com/evennia/evennia/pull/3433 +[issue3450]: https://github.com/evennia/evennia/issues/3450 +[issue3462]: https://github.com/evennia/evennia/issues/3462 +[issue3460]: https://github.com/evennia/evennia/issues/3460 +[issue3461]: https://github.com/evennia/evennia/issues/3461 +[docAI]: https://www.evennia.com/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.html +[docQuests]: https://www.evennia.com/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.html +[docDungeon]: https://www.evennia.com/docs/latest/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.html ## Evennia 4.0.0 diff --git a/docs/source/Components/EvEditor.md b/docs/source/Components/EvEditor.md index edc554aab1..d4b6f5fbbb 100644 --- a/docs/source/Components/EvEditor.md +++ b/docs/source/Components/EvEditor.md @@ -134,7 +134,7 @@ the in-editor help command (`:h`). :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 + :p - put (paste) previously copied line directly before :i - insert new text at line . Old line will move down :r - replace line with text :I - insert text at the beginning of line diff --git a/docs/source/Concepts/Concepts-Overview.md b/docs/source/Concepts/Concepts-Overview.md index 84d0aa5ca4..60cb066e7f 100644 --- a/docs/source/Concepts/Concepts-Overview.md +++ b/docs/source/Concepts/Concepts-Overview.md @@ -37,6 +37,7 @@ Banning.md ```{toctree} :maxdepth: 2 +Server-Lifecycle Protocols.md Models.md Zones.md diff --git a/docs/source/Concepts/Server-Lifecycle.md b/docs/source/Concepts/Server-Lifecycle.md new file mode 100644 index 0000000000..274d0e1d63 --- /dev/null +++ b/docs/source/Concepts/Server-Lifecycle.md @@ -0,0 +1,80 @@ + +# Evennia Server Lifecycle + +As part of your game design you may want to change how Evennia behaves when starting or stopping. A common use case would be to start up some piece of custom code you want to always have available once the server is up. + +Evennia has three main life cycles, all of which you can add custom behavior for: + +- **Database life cycle**: Evennia uses a database. This exists in parallel to the code changes you do. The database exists until you choose to reset or delete it. Doing so doesn't require re-downloading Evennia. +- **Reboot life cycle**: From When Evennia starts to it being fully shut down, which means both Portal and Server are stopped. At the end of this cycle, all players are disconnected. +- **Reload life cycle:** This is the main runtime, until a "reload" event. Reloads refreshes game code but do not kick any players. + +## When Evennia starts for the first time + +This is the beginning of the **Database life cycle**, just after the database is created and migrated for the first time (or after it was deleted and re-built). See [Choosing a Database](../Setup/Choosing-a-Database.md) for instructions on how to reset a database, should you want to re-run this sequence after the first time. + +Hooks called, in sequence: + +1. `evennia.server.initial_setup.handle_setup(last_step=None)`: Evennia's core initialization function. This is what creates the #1 Character (tied to the superuser account) and `Limbo` room. It calls the next hook below and also understands to restart at the last failed step if there was some issue. You should normally not override this function unless you _really_ know what you are doing. To override, change `settings.INITIAL_SETUP_MODULE` to your own module with a `handle_setup` function in it. +2. `mygame/server/conf/at_initial_setup.py` contains a single function, `at_initial_setup()`, which will be called without arguments. It's called last in the setup sequence by the above function. Use this to add your own custom behavior or to tweak the initialization. If you for example wanted to change the auto-generated Limbo room, you should do it from here. If you want to change where this function is found, you can do so by changing `settings.AT_INITIAL_SETUP_HOOK_MODULE`. + +## When Evennia starts and shutdowns + +This is part of the **Reboot life cycle**. Evennia consists of two main processes, the [Portal and the Server](../Components/Portal-And-Server.md). On a reboot or shutdown, both Portal and Server shuts down, which means all players are disconnected. + +Each process call a series of hooks located in `mygame/server/conf/at_server_startstop.py`. You can customize the module used with `settings.AT_SERVER_STARTSTOP_MODULE` - this can even be a list of modules, if so, the appropriately-named functions will be called from each module, in sequence. + +All hooks are called without arguments. + +> The use of the term 'server' in the hook-names indicate the whole of Evennia, not just the `Server` component. + +### Server cold start + +Starting the server from zero, after a full stop. This is done with `evennia start` from the terminal. + +1. `at_server_init()` - Always called first in the startup sequence. +2. `at_server_cold_start()` - Only called on cold starts. +3. `at_server_start()` - Always called last in the startup sequece. + +### Server cold shutdown + +Shutting everything down. Done with `shutdown` in-game or `evennia stop` from the terminal. + +1. `at_server_cold_stop()` - Only called on cold stops. +2. `at_server_stop()` - Always called last in the stopping sequence. + +### Server reboots + +This is done with `evennia reboot` and effectively constitutes an automatic cold shutdown followed by a cold start controlled from the `evennia` launcher. There are no special `reboot` hooks for this, instead it looks like you'd expect: + +1. `at_server_cold_stop()` +2. `at_server_stop()` (after this, both `Server` + `Portal` have both shut down) +3. `at_server_init()` (like a cold start) +4. `at_server_cold_start()` +5. `at_server_start()` + +## When Evennia reloads and resets + +This is the **Reload life cycle**. As mentioned above, Evennia consists of two components, the [Portal and Server](../Components/Portal-And-Server.md). During a reload, only the `Server` component is shut down and restarted. Since the Portal stays up, players are not disconnected. + +All hooks are called without arguments. + +### Server reload + +Reloads are initiated with the `reload` command in-game, or with `evennia reload` from the terminal. + +1. `at_server_reload_stop()` - Only called on reload stops. +2. `at_server_stop` - Always called last in the stopping sequence. +3. `at_server_init()` - Always called first in startup sequence. +4. `at_server_reload_start()` - Only called on a reload (re)start. +5. `at_server_start()` - Always called last in the startup sequence. + +### Server reset + +A 'reset' is a hybrid reload state, where the reload is treated as a cold shutdown only for the sake of running hooks (players are not disconnected). It's run with `reset` in-game or with `evennia reset` from the terminal. + +1. `at_server_cold_stop()` +2. `at_server_stop()` (after this, only `Server` has shut down) +3. `at_server_init()` (`Server` coming back up) +4. `at_server_cold_start()` +5. `at_server_start()` diff --git a/docs/source/Contributing-Docs.md b/docs/source/Contributing-Docs.md index d595fd398a..0308cfdceb 100644 --- a/docs/source/Contributing-Docs.md +++ b/docs/source/Contributing-Docs.md @@ -23,12 +23,25 @@ at the root of `evennia/docs/source/`. 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. +## Automatically generated doc pages + +Some doc pages are automatically generated. Changes to their generated markdown file will be overwritten. Instead they must be modified at the point the automation reads the text from. + +- All API docs under `source/api` are built from the doc strings of Evennia core code. Documentation fixes for these needs to be done in the doc strings of the relevant module, function, class or method. +- [Contribs/Contribs-Overview.md](Contribs/Contribs-Overview.md) is completely generated from scratch when building the docs, by the script `evennia/docs/pylib/contrib_readmes2docs.py`. + - All contrib blurbs on the above page are taken from the first paragraph of each contrib's `README.md`, found under `evennia/contrib/*/*/README.md`. + - Similarly, all contrib documentation linked from the above page is generated from each contrib's `README.md` file. +- [Components/Default-Commands.md](Components/Default-Commands.md) is generated from the command classes found under `evennia/commands/default/`. +- [Coding/Evennia-Code-Style.md](Coding/Evennia-Code-Style.md) is generated from `evennia/CODING_STYLE.md`. +- [Coding/Changelog.md](Coding/Changelog.md) is generated from `evennia/CHANGELOG.md` +- [Setup/Settings-Default.md](Setup/Settings-Default.md) is generated from the default settings file `evennia/default_settings.py` + +Most auto-generated pages have a warning in the header indicating that it's auto-generated. ## Editing syntax diff --git a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md index 78c3fa29bb..fba93441f7 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md @@ -91,47 +91,34 @@ The `me.cmdset` is the store of all cmdsets stored on us. By giving the path to Now try > echo - Command echo has no defined `func()` - showing on-command variables: - ... - ... + Command "echo" has no defined `func()`. Available properties ... + ...(lots of stuff)... -`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: +`echo` works! You should be getting a long list of outputs. 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 (): YourName - lockhandler (): cmd:all() - caller (): YourName - cmdname (): echo - raw_cmdname (): echo - cmdstring (): echo - args (): - 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 (): Griatch(#1)@1:2:7:.:0:.:0:.:1 - account (): Griatch(account 1) - raw_string (): 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 +``` +Command "echo" has no defined `func()` method. Available properties on this command are: + self.key (): "echo" + self.cmdname (): "echo" + self.raw_cmdname (): "echo" + self.raw_string (): "echo +" + self.aliases (): [] + self.args (): "" + self.caller (): YourName + self.obj (): YourName + self.session (): YourName(#1)@1:2:7:.:0:.:0:.:1 + self.locks (): "cmd:all();" + self.help_category (): "general" + self.cmdset (... a long list of commands ...) +``` 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"`. + - `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"` (including the extra space between `echo` and `foo` that you may want to strip away). - `obj` - this is object on which this Command (and CmdSet) "sits". So you, in this case. + - `raw_string` is not commonly used, but it's the completely unmodified input from the user. It even includes the line break used to send to the command to the server (that's why the end-quotes appear on the next line). 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: @@ -238,7 +225,7 @@ Tweak this file as follows: ```python # in mygame/commands/default_cmdsets.py -# ,.. +# ... from . import mycommands # <------- @@ -297,10 +284,9 @@ And Bob would see Still in `mygame/commands/mycommands.py`, add a new class, between `CmdEcho` and `MyCmdSet`. ```{code-block} python -# in mygame/commands/mycommands.py - :linenos: -:emphasize-lines: 3,4,11,14,15,17,18,19,21 +:emphasize-lines: 5,6,13,16,19,20,21,23 +# in mygame/commands/mycommands.py # ... @@ -324,18 +310,17 @@ class CmdHit(Command): 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 5**: The normal `class` header. We inherit from `Command` which we imported at the top of this file. +- **Lines 6-12**: The docstring and help-entry for the command. You could expand on this as much as you wanted. +- **Line 13**: We want to write `hit` to use this command. +- **Line 16**: 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. ```{sidebar} if-statements - The full form of the if statement is if condition: @@ -346,9 +331,9 @@ The full form of the if statement is ... There can be any number of `elifs` to mark when different branches of the code should run. If `else` is provided, it will run if none of the other conditions were truthy. - ``` -- **Line 15** has our first _conditional_, an `if` statement. This is written on the form `if :` 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": + +- **Line 17** has our first _conditional_, an `if` statement. This is written on the form `if :` 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` @@ -356,12 +341,20 @@ There can be any number of `elifs` to mark when different branches of the code s - 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: + The conditional on **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: +```{sidebar} Errors in your code + +With longer code snippets to try, it gets more and more likely you'll +make an error and get a `traceback` when you reload. This will either appear +directly in-game or in your log (view it with `evennia -l` in a terminal). + +Don't panic - tracebacks are your friends! They are to be read bottom-up and usually describe exactly where your problem is. Refer to [The Python introduction lesson](./Beginner-Tutorial-Python-basic-introduction.md) for more hints. If you get stuck, reach out to the Evennia community for help. +``` - **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. +- **Line 19**: `return` is a reserved Python word that exits `func` immediately. +- **Line 20**: We use `self.caller.search` to look for the target in the current location. +- **Lines 21-22**: 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 23-24**: 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`. @@ -377,14 +370,6 @@ class MyCmdSet(CmdSet): ``` -```{sidebar} Errors in your code - -With longer code snippets to try, it gets more and more likely you'll -make an error and get a `traceback` when you reload. This will either appear -directly in-game or in your log (view it with `evennia -l` in a terminal). -Don't panic; tracebacks are your friends - they are to be read bottom-up and usually describe exactly where your problem is. Refer to [The Python introduction lesson](./Beginner-Tutorial-Python-basic-introduction.md) for more hints. If you get stuck, reach out to the Evennia community for help. -``` - 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. ```python @@ -439,4 +424,4 @@ You won't see the second string. Only Smaug sees that (and is not amused). 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 +get into how we replace and extend Evennia's default Commands. diff --git a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Searching-Things.md b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Searching-Things.md index e0b5dce20f..8f1a58dfe7 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Searching-Things.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Searching-Things.md @@ -149,6 +149,54 @@ If you you really want all matches to the search parameters you specify. In othe There are equivalent search functions for all the main resources. You can find a listing of them [in the Search functions section](../../../Evennia-API.md) of the API front page. +## Understanding object relationships + +It's important to understand how objects relate to one another when searching. + +Let's consider a `chest` with a `coin` inside it. The chest stands in a `dungeon` room. In the dungeon is also a `door` (an exit leading outside). + +``` +┌───────────────────────┐ +│dungeon │ +│ ┌─────────┐ │ +│ │chest │ ┌────┐ │ +│ │ ┌────┐ │ │door│ │ +│ │ │coin│ │ └────┘ │ +│ │ └────┘ │ │ +│ │ │ │ +│ └─────────┘ │ +│ │ +└───────────────────────┘ +``` + +If you have access to any in-game Object, you can find related objects by use if its `.location` and `.contents` properties. + +- `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 `dungeon`. + +- `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 `[]` since it has no exits (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) + ## What can be searched for These are the main database entities one can search for: @@ -162,6 +210,8 @@ 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. +Most search methods are available directly from `evennia`. But there are also a lot of useful search helpers found via `evennia.search`. + So to find an entity, what can be searched for? ### Search by key @@ -206,9 +256,9 @@ However, using `search_object` will find the rose wherever it's located: > py evennia.search_object("rose") -However, if you demand that the room is in the current room, it won't be found: +The `evennia.search_object` method doesn't have a `location` argument. What you do instead is to limit the search by setting its `candidates` keyword to the `.contents` of the current location. This is the same as a location search, since it will only accept matches among those in the room. In this example we'll (correctly) find the rose is not in the room. - > py evennia.search_object("rose", location=here) + > py evennia.search_object("rose", candidate=here.contents) In general, the `Object.search` is a shortcut for doing the very common searches of things in the same location, whereas the `search_object` finds objects anywhere. @@ -267,7 +317,7 @@ For example, let's say our plants have a 'growth state' that updates as it grows Now we can find the things that have a given growth state: - > py evennia.search_object_attribute("growth_state", "withering") + > py evennia.search_object("withering", attribute_name="growth_state") > Searching by Attribute can be very practical. But if you want to group entities or search very often, using Tags and search by Tags is faster and more resource-efficient. @@ -317,53 +367,11 @@ In legacy code bases you may be used to relying a lot on #dbrefs to find and tra ``` -## Finding objects relative each other +## Summary -It's important to understand how objects relate to one another when searching. -Let's consider a `chest` with a `coin` inside it. The chest stands in a room `dungeon`. In the dungeon is also a `door`. This is an exit leading outside. +Knowing how to find things is important and the tools from this section will serve you well. These tools will cover most of your regular needs. -``` -┌───────────────────────┐ -│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 `dungeon`. -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: +Not always though. If we go back to the example of a coin in a chest from before, you _could_ use the following to dynamically figure out if there are any chests in the room with coins inside: ```python from evennia import search_object @@ -372,13 +380,9 @@ from evennia import search_object 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) +coins = search_object("coin", 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. +This would work but is both quite inefficient, fragile and a lot to type. This kind of thing is better done by directly querying the database. -## 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 +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/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md index 115c61faf4..4e35e2b2c4 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md @@ -40,6 +40,8 @@ You can find an AIHandler implemented in `evennia/contrib/tutorials`, in [evadve ``` This is the core logic for managing AI states. Create a new file `evadventure/ai.py`. +> Create a new file `evadventure/ai.py`. + ```{code-block} python :linenos: :emphasize-lines: 10,11-13,16,23 diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Characters.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Characters.md index eb1576452e..e9ebe2826a 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Characters.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Characters.md @@ -1,13 +1,11 @@ # Player Characters -In the [previous lesson about rules and dice rolling](./Beginner-Tutorial-Rules.md) we made some -assumptions about the "Player Character" entity: +In the [previous lesson about rules and dice rolling](./Beginner-Tutorial-Rules.md) 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 +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](../../../Components/Typeclasses.md). The Character class is like a 'character sheet' in a tabletop RPG, it will hold everything relevant to that PC. @@ -16,8 +14,7 @@ RPG, it will hold everything relevant to that PC. 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: +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: ```python from evennia import DefaultCharacter @@ -34,9 +31,7 @@ class EvAdventureMob(EvAdventureNPC): 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: +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: ```python from evennia import DefaultCharacter @@ -60,8 +55,7 @@ Nevertheless, there are some things that _should_ be common for all 'living thin - 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: +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: ```python from evennia import DefaultCharacter @@ -83,10 +77,7 @@ class EvAdventureMob(LivingMixin, EvadventureNPC): In [evennia/contrib/tutorials/evadventure/characters.py](../../../api/evennia.contrib.tutorials.evadventure.characters.md) is an example of a character class structure. ``` -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. +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. ## Living mixin class @@ -178,7 +169,6 @@ Most of these are empty since they will behave differently for characters and np 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. - ## Character class We will now start making the basic Character class, based on what we need from _Knave_. @@ -234,8 +224,7 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter): 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: +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` @@ -249,7 +238,7 @@ We implement the Player Character versions of `at_defeat` and `at_death`. We als ### Funcparser inlines -This piece of code is worth some more explanation: +This piece of code in the `at_defeat` method above is worth some more extra explanation: ```python self.location.msg_contents( @@ -259,8 +248,7 @@ self.location.msg_contents( 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](../../../Components/FuncParser.md). 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. +The `$You() $conj(collapse)` are [FuncParser inlines](../../../Components/FuncParser.md). 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."` @@ -303,10 +291,7 @@ You can easily make yourself an `EvAdventureCharacter` in-game by using the 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`). +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: @@ -327,8 +312,7 @@ instead. > 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. +For testing, we just need to create a new EvAdventure character and check that calling the methods on it doesn't error out. ```python # mygame/evadventure/tests/test_characters.py @@ -368,22 +352,18 @@ class TestCharacters(BaseEvenniaTest): # 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. +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 + evennia test --settings settings.py .evadventure.tests.test_characters ## 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. +_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: +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: ```python # mygame/evadventure/characters.py @@ -399,8 +379,7 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter): 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 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](./Beginner-Tutorial-Rules.md) (and later [character generation](./Beginner-Tutorial-Chargen.md) to check and include what these classes mean. @@ -409,23 +388,16 @@ We'd then need to expand our [rules module](./Beginner-Tutorial-Rules.md) (and l ## Summary -With the `EvAdventureCharacter` class in place, we have a better understanding of how our PCs will look -like under _Knave_. +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 +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 +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. +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. ``` diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.md index 12d15ff8c9..b5611979af 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.md @@ -16,7 +16,7 @@ We will design a base combat system that supports both styles. > Create a new module `evadventure/combat_base.py` ```{sidebar} -In [evennia/contrib/tutorials/evadventure/combat_base.py](evennia.contrib.tutorials.evadventure.combat_base) you'll find a complete implementation of the base combat module. +Under `evennia/contrib/tutorials/evadventure/`, in [combat_base.py](evennia.contrib.tutorials.evadventure.combat_base) you'll find a complete implementation of the base combat module. ``` 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). @@ -718,7 +718,7 @@ We rely on the [Equipment handler](./Beginner-Tutorial-Equipment.md) we created > Create a module `evadventure/tests/test_combat.py`. ```{sidebar} -See [evennia/contrib/tutorials/evadventure/tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) for ready-made combat unit tests. +Look under `evennia/contrib/tutorials/evadventure/`, in [tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) for ready-made combat unit tests. ``` 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](https://docs.python.org/3/library/unittest.mock.html). 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. diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.md index c456cd51d2..9932a5a5a1 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.md @@ -80,7 +80,7 @@ The advantage of using a menu is that you have all possible actions directly ava ## General Principle ```{sidebar} -An example of an implemented Turnbased combat system can be found in [evennia/contrib/tutorials/evadventure/combat_turnbased.py](evennia.contrib.tutorials.evadventure.combat_turnbased). +An example of an implemented Turnbased combat system can be found under `evennia/contrib/tutorials/evadventure/`, in [combat_turnbased.py](evennia.contrib.tutorials.evadventure.combat_turnbased). ``` Here is the general principle of the Turnbased combat handler: diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.md index a1ffb51526..449367eb0f 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.md @@ -92,7 +92,7 @@ class EvAdventureObject(DefaultObject): """The top of the description""" return "" - def get_display_desc(self, looker, **kwargs) + def get_display_desc(self, looker, **kwargs): """The main display - show object stats""" return get_obj_stats(self, owner=looker) @@ -216,7 +216,7 @@ class EvAdventureConsumable(EvAdventureObject): """Called when using the item""" pass - def at_post_use(self. user, *args, **kwargs): + 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 @@ -452,7 +452,7 @@ _BARE_HANDS = None # ... -class WeaponBareHands(EvAdventureWeapon) +class WeaponBareHands(EvAdventureWeapon): obj_type = ObjType.WEAPON inventory_use_slot = WieldLocation.WEAPON_HAND attack_type = Ability.STR diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md index e74f1578ee..e4aee29fe6 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md @@ -1,5 +1,402 @@ # Game Quests -```{warning} -This part of the Beginner tutorial is still being developed. -``` \ No newline at end of file +A _quest_ is a common feature of games. From classic fetch-quests like retrieving 10 flowers to complex quest chains involving drama and intrigue, quests need to be properly tracked in our game. + +A quest follows a specific development: + +1. The quest is _started_. This normally involves the player accepting the quest, from a quest-giver, job board or other source. But the quest could also be thrust on the player ("save the family from the burning house before it collapses!") +2. Once a quest has been accepted and assigned to a character, it is either either `Started` (that is, 'in progress'), `Abandoned`, `Failed` or `Complete`. +3. A quest may consist of one or more 'steps'. Each step has its own set of finish conditions. +4. At suitable times the quest's _progress_ is checked. This could happen on a timer or when trying to 'hand in' the quest. When checking, the current 'step' is checked against its finish conditions. If ok, that step is closed and the next step is checked until it either hits a step that is not yet complete, or there are no more steps, in which case the entire quest is complete. + +```{sidebar} +An example implementation of quests is found under `evennia/contrib/tutorials`, in [evadvanture/quests.py](evennia.contrib.tutorials.evadventure.quests). +``` +To represent quests in code, we need +- A convenient flexible way to code how we check the status and current steps of the quest. We want this scripting to be as flexible as possible. Ideally we want to be able to code the quests's logic in full Python. +- Persistence. The fact that we accepted the quest, as well as its status and other flags must be saved in the database and survive a server reboot. + +We'll accomplish this using two pieces of Python code: +- `EvAdventureQuest`: A Python class with helper methods that we can call to check current quest status, figure if a given quest-step is complete or not. We will create and script new quests by simply inheriting from this base class and implement new methods on it in a standardized way. +- `EvAdventureQuestHandler` will sit 'on' each Character as `character.quests`. It will hold all `EvAdventureQuest`s that the character is or has been involved in. It is also responsible for storing quest state using [Attributes](../../../Components/Attributes.md) on the Character. + +## The Quest Handler + +> Create a new module `evadventure/quests.py`. + +We saw the implementation of an on-object handler back in the [lesson about NPC and monster AI](./Beginner-Tutorial-AI.md#the-aihandler) (the `AIHandler`). + +```{code-block} python +:linenos: +:emphasize-lines: 9,10,11,14-18,21,24-28 +# in evadventure/quests.py + +class EvAdventureQuestHandler: + quest_storage_attribute_key = "_quests" + quest_storage_attribute_category = "evadventure" + + def __init__(self, obj): + self.obj = obj + self.quest_classes = {} + self.quests = {} + self._load() + + def _load(self): + self.quest_classes = self.obj.attributes.get( + self.quest_storage_attribute_key, + category=self.quest_storage_attribute_category, + default={}, + ) + # instantiate all quests + for quest_key, quest_class in self.quest_classes.items(): + self.quests[quest_key] = quest_class(self.obj) + + def _save(self): + self.obj.attributes.add( + self.quest_storage_attribute_key, + self.quest_classes, + category=self.quest_storage_attribute_category, + ) + + def get(self, quest_key): + return self.quests.get(quest_key) + + def all(self): + return list(self.quests.values()) + + def add(self, quest_class): + self.quest_classes[quest_class.key] = quest_class + self.quests[quest_class.key] = quest_class(self.obj) + self._save() + + def remove(self, quest_key): + quest = self.quests.pop(quest_key, None) + self.quest_classes.pop(quest_key, None) + self.quests.pop(quest_key, None) + self._save() + +``` + +```{sidebar} Persistent handler pattern +Persistent handlers are commonly used throughout Evennia. You can read more about them in the [Making a Persistent object Handler](../../Tutorial-Persistent-Handler.md) tutorial. +``` +- **Line 9**: We know that the quests themselves will be Python classes inheriting from `EvAdventureQuest` (which we haven't created yet). We will store those classes in `self.quest_classes` on the handler. Note that there is a difference between a class and an _instance_ of a class! The class cannot hold any _state_ on its own, such as the status of that quest is for this particular character. The class only holds python code. +- **Line 10**: We set aside another property on the handler - `self.quest` This is dictionary that will hold `EvAdventureQuest` _instances_. +- **Line 11**: Note that we call the `self._load()` method here, this loads up data from the database whenever this handler is accessed. +- **Lines 14-18**: We use `self.obj.attributes.get` to fetch an [Attribute](../../../Components/Attributes.md) on the Character named `_quests` and with a category of `evadventure`. If it doesn't exist yet (because we never started any quests), we just return an empty dict. +- **Line 21**: Here we loop over all the classes and instantiate them. We haven't defined how these quest-classes look yet, but by instantiating them with `self.obj` (the Character) we should be covered - from the Character class the quest will be able to get to everything else (this handler itself will be accessible as `obj.quests` from that quest instance after all). +- **Line 24**: Here we do the corresponding save operation. + +The rest of the handler are just access methods for getting, adding and removing quests from the handler. We make one assumption in those code, namely that the quest class has a property `.key` being the unique quest-name. + +This is how it would be used in practice: + +```python +# in some questing code + +from evennia import search_object +from evadventure import quests + +class EvAdventureSuperQuest(quests.EvAdventureQuest): + key = "superquest" + # quest implementation here + +def start_super_quest(character): + character.quests.add(EvAdventureSuperQuest) + +``` +```{sidebar} What can be saved in Attributes? +For more details, see [the Attributes documentation](../../../Components/Attributes.md#what-types-of-data-can-i-save-in-an-attribute) on the matter. +``` +We chose to store classes and not instances of classes above. The reason for this has to do with what can be stored in a database `Attribute` - one limitation of an Attribute is that we can't save a class instance _with other database entities baked inside it_. If we saved quest instances as-is, it's highly likely they'd contain database entities 'hidden' inside them - a reference to the Character, maybe to objects required for the quest to be complete etc. Evennia would fail trying to save that data. +Instead we store only the classes, instantiate those classes with the Character, and let the quest store its state flags separately, like this: + +```python +# in evadventure/quests.py + +class EvAdventureQuestHandler: + + # ... + quest_data_attribute_template = "_quest_data_{quest_key}" + quest_data_attribute_category = "evadventure" + + # ... + + def save_quest_data(self, quest_key): + quest = self.get(quest_key) + if quest: + self.obj.attributes.add( + self.quest_data_attribute_template.format(quest_key=quest_key), + quest.data, + category=self.quest_data_attribute_category, + ) + + def load_quest_data(self, quest_key): + return self.obj.attributes.get( + self.quest_data_attribute_template.format(quest_key=quest_key), + category=self.quest_data_attribute_category, + default={}, + ) + +``` + +This works the same as the `_load` and `_save` methods, except it fetches a property `.data` (this will be a `dict`) on the quest instance and save it. As long as we make sure to call these methods from the quest the quest whenever that `.data` property is changed, all will be well - this is because Attributes know how to properly analyze a `dict` to find and safely serialize any database entities found within. + +Our handler is ready. We created the `EvAdventureCharacter` class back in the [Character lesson](./Beginner-Tutorial-Characters.md) - let's add quest-support to it. + +```python +# in evadventure/characters.py + +# ... + +from evennia.utils import lazy_property +from evadventure.quests import EvAdventureQuestHandler + +class EvAdventureCharacter(LivingMixin, DefaultCharacter): + # ... + + @lazy_property + def quests(self): + return EvAdventureQuestHandler(self) + + # ... + +``` + +We also need a way to represent the quests themselves though! +## The Quest class + + +```{code-block} python +:linenos: +:emphasize-lines: 7,12,13,34-36 +# in evadventure/quests.py + +# ... + +class EvAdventureQuest: + + key = "base-quest" + desc = "Base quest" + start_step = "start" + + def __init__(self, quester): + self.quester = quester + self.data = self.questhandler.load_quest_data(self.key) + self._current_step = self.get_data("current_step") + + if not self.current_step: + self.current_step = self.start_step + + def add_data(self, key, value): + self.data[key] = value + self.questhandler.save_quest_data(self.key) + + def get_data(self, key, default=None): + return self.data.get(key, default) + + def remove_data(self, key): + self.data.pop(key, None) + self.questhandler.save_quest_data(self.key) + + @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.add_data("current_step", step_name) + +``` + +- **Line 7**: Each class must have a `.key` property unquely identifying the quest. We depend on this in the quest-handler. +- **Line 12**: `quester` (the Character) is passed into this class when it is initiated inside `EvAdventureQuestHandler._load()`. +- **Line 13**: We load the quest data into `self.data` directly using the `questhandler.load_quest-data` method (which in turn loads it from an Attribute on the Character). Note that the `.questhandler` property is defined on **lines 34-36** as a shortcut to get to the handler. + +The `add/get/remove_data` methods are convenient wrappers for getting data in and out of the database using the matching methods on the handler. When we implement a quest we should prefer to use `.get_data`, `add_data` and `remove_data` over manipulating `.data` directly, since the former will make sure to save said that to the database automatically. + +The `current_step` tracks the current quest 'step' we are in; what this means is up to each Quest. We set up convenient properties for setting the `current_state` and also make sure to save it in the data dict as "current_step". + +The quest can have a few possible statuses: "started", "completed", "abandoned" and "failed". We create a few properties and methods for easily control that, while saving everything under the hood: + +```python +# in evadventure/quests.py + +# ... + +class EvAdventureQuest: + + # ... + + @property + def status(self): + return self.get_data("status", "started") + + @status.setter + def status(self, value): + self.add_data("status", value) + + @property + def is_completed(self): + return self.status == "completed" + + @property + def is_abandoned(self): + return self.status == "abandoned" + + @property + def is_failed(self): + return self.status == "failed" + + def complete(self): + self.status = "completed" + + def abandon(self): + self.status = "abandoned" + + def fail(self): + self.status = "failed" + + +``` + +So far we have only added convenience functions for checking statuses. How will the actual "quest" aspect of this work? + +What will happen when the system wants to check the progress of the quest, is that it will call a method `.progress()` on this class. Similarly, to get help for the current step, it will call a method `.help()` + +```python + + start_step = "start" + + # help entries for quests (could also be methods) + help_start = "You need to start first" + help_end = "You need to end the quest" + + def progress(self, *args, **kwargs): + getattr(self, f"step_{self.current_step}")(*args, **kwargs) + + def help(self, *args, **kwargs): + if self.status in ("abandoned", "completed", "failed"): + help_resource = getattr(self, f"help_{self.status}", + f"You have {self.status} this quest.") + else: + help_resource = getattr(self, f"help_{self.current_step}", "No help available.") + + if callable(help_resource): + # the help_* methods can be used to dynamically generate help + return help_resource(*args, **kwargs) + else: + # normally it's just a string + return str(help_resource) + +``` + +```{sidebar} What's with the *args, **kwargs? +These are optional, but allow you to pass extra information into your quest-check. This could be very powerful if you want to add extra context to determine if a quest-step is currently complete or not. +``` +Calling the `.progress(*args, **kwargs)` method will call a method named `step_(*args, **kwargs)` on this class. That is, if we are on the _start_ step, the method called will be `self.step_start(*args, **kwargs)`. Where is this method? It has not been implemented yet! In fact, it's up to us to implement methods like this for each quest. By just adding a correctly added method, we will easily be able to add more steps to a quest. + +Similarly, calling `.help(*args, **kwargs)` will try to find a property `help_`. If this is a callable, it will be called as for example `self.help_start(*args, **kwargs)`. If it is given as a string, then the string will be returned as-is and the `*args, **kwargs` will be ignored. + +### Example quest + +```python +# in some quest module, like world/myquests.py + +from evadventure.quests import EvAdventureQuest + +class ShortQuest(EvAdventureQuest): + + key = "simple-quest" + desc = "A very simple quest." + + def step_start(self, *args, **kwargs): + """Example step!""" + self.quester.msg("Quest started!") + self.current_step = "end" + + def step_end(self, *args, **kwargs): + if not self.is_completed: + self.quester.msg("Quest ended!") + self.complete() + +``` + +This is a very simple quest that will resolve on its own after two `.progress()` checks. Here's the full life cycle of this quest: + +```python +# in some module somewhere, using evennia shell or in-game using py + +from evennia import search_object +from world.myquests import ShortQuest + +character = search_object("MyCharacterName")[0] +character.quests.add(ShortQuest) + +# this will echo "Quest started!" to character +character.quests.get("short-quest").progress() +# this will echo "Quest ended!" to character +character.quests.get("short-quest").progress() + +``` + +### A useful Command + +The player must know which quests they have and be able to inspect them. Here's a simple `quests` command to handle this: + +```python +# in evadventure/quests.py + +class CmdQuests(Command): + """ + List all quests and their statuses as well as get info about the status of + a specific quest. + + Usage: + quests + quest + + """ + key = "quests" + aliases = ["quest"] + + def parse(self): + self.quest_name = self.args.strip() + + def func(self): + if self.quest_name: + quest = self.caller.quests.get(self.quest_name) + if not quest: + self.msg(f"Quest {self.quest_name} not found.") + return + self.msg(f"Quest {quest.key}: {quest.status}\n{quest.help()}") + return + + quests = self.caller.quests.all() + if not quests: + self.msg("No quests.") + return + + for quest in quests: + self.msg(f"Quest {quest.key}: {quest.status}") +``` + +Add this to the `CharacterCmdSet` in `mygame/commands/default_cmdsets.py`. Follow the [Adding a command lesson](../Part1/Beginner-Tutorial-Adding-Commands.md#add-the-echo-command-to-the-default-cmdset) if you are unsure how to do this. Reload and if you are playing as an `EvAdventureCharacter` you should be able to use `quests` to view your quests. + +## Testing + +> Create a new folder `evadventure/tests/test_quests.py`. + +```{sidebar} +An example test suite for quests is found in `evennia/contrib/tutorials/evadventure`, as [tests/test_quests.py](evennia.contrib.tutorials.evadventure.tests.test_quests). +``` +Testing of the quests means creating a test character, making a dummy quest, add it to the character's quest handler and making sure all methods work correcly. Create the testing quest so that it will automatically step forward when calling `.progress()`, so you can make sure it works as intended. + +## Conclusions + +What we created here is just the framework for questing. The actual complexity will come when creating the quests themselves (that is, implementing the `step_(*args, **kwargs)` methods), which is something we'll get to later, in [Part 4](../Part4/Beginner-Tutorial-Part4-Overview.md) of this tutorial. \ No newline at end of file diff --git a/docs/source/Setup/Settings-Default.md b/docs/source/Setup/Settings-Default.md index 73f3a6d45b..a49fb8d786 100644 --- a/docs/source/Setup/Settings-Default.md +++ b/docs/source/Setup/Settings-Default.md @@ -31,6 +31,7 @@ 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 diff --git a/docs/source/Setup/Settings.md b/docs/source/Setup/Settings.md index 4d549f7549..766d24281e 100644 --- a/docs/source/Setup/Settings.md +++ b/docs/source/Setup/Settings.md @@ -1,7 +1,6 @@ # 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. +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. @@ -17,13 +16,9 @@ heavily documented and up-to-date, so you should refer to this file directly for 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). +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. +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. @@ -41,45 +36,24 @@ In code, the settings is accessed through 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. +> 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. +- `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 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](../Components/FuncParser.md). -- `inputfuncs.py` - this is where you define custom [Input functions](../Components/Inputfuncs.md) 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](../Components/Locks.md). -- `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. +- `inputfuncs.py` - this is where you define custom [Input functions](../Components/Inputfuncs.md) 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](../Components/Locks.md). +- `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](../Concepts/OOB.md). -- `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](https://code.google.com/p/evennia/wiki/SessionProtocols#Adding_custom_Protocols). -- `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](https://code.google.com/p/evennia/wiki/SessionProtocols#Adding_custom_Protocols). +- `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](https://code.google.com/p/evennia/wiki/SessionProtocols#Adding_custom_Protocols). +- `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](https://code.google.com/p/evennia/wiki/SessionProtocols#Adding_custom_Protocols). -Some other Evennia systems can be customized by plugin modules but has no explicit template in -`conf/`: +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/source/conf.py b/docs/source/conf.py index 193eafcb25..e63efefcd1 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -374,8 +374,12 @@ def setup(app): # build toctree file sys.path.insert(1, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) - from docs.pylib import (auto_link_remapper, contrib_readmes2docs, - update_default_cmd_index, update_dynamic_pages) + from docs.pylib import ( + auto_link_remapper, + contrib_readmes2docs, + update_default_cmd_index, + update_dynamic_pages, + ) _no_autodoc = os.environ.get("NOAUTODOC") update_default_cmd_index.run_update(no_autodoc=_no_autodoc) diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index fcdb2e109f..627a3f43a6 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -4.0.0 +4.1.1 diff --git a/evennia/__init__.py b/evennia/__init__.py index bf5eafa8c6..5e8a53f776 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -16,6 +16,7 @@ to launch such a shell (using python or ipython depending on your install). See www.evennia.com for full documentation. """ + import evennia # docstring header diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 3ac6ca513a..89137ff672 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -10,6 +10,7 @@ character object, so you should customize that instead for most things). """ + import re import time import typing @@ -372,6 +373,18 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): session.protocol_flags.get("SCREENREADER") for session in self.sessions.all() ) + def get_extra_display_name_info(self, looker, **kwargs): + """ + Used in .get_display_name() to provide extra information to the looker. We split this + to be consistent with the Object version of this method. + + This is used e.g. by the `find` command by default. + + """ + if looker and self.locks.check_lockstring(looker, "perm(Admin)"): + return f"(#{self.id})" + return "" + def get_display_name(self, looker, **kwargs): """ This is used by channels and other OOC communications methods to give a @@ -1334,7 +1347,8 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): if isinstance(searchdata, str): # handle wrapping of common terms if searchdata.lower() in ("me", "*me", "self", "*self"): - return self + return [self] if quiet else self + searchdata = self.nicks.nickreplace( searchdata, categories=("account",), include_account=False ) diff --git a/evennia/accounts/models.py b/evennia/accounts/models.py index ea00e24d9d..e8ecabea45 100644 --- a/evennia/accounts/models.py +++ b/evennia/accounts/models.py @@ -15,6 +15,7 @@ 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 diff --git a/evennia/commands/cmdparser.py b/evennia/commands/cmdparser.py index 6ae209c258..98b94638ea 100644 --- a/evennia/commands/cmdparser.py +++ b/evennia/commands/cmdparser.py @@ -6,7 +6,6 @@ same inputs as the default one. """ - import re from django.conf import settings diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py index 618d78cfad..cac54816cb 100644 --- a/evennia/commands/cmdset.py +++ b/evennia/commands/cmdset.py @@ -26,6 +26,7 @@ Set theory. 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 _ diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index 9f7b89e674..19b2f27391 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -64,6 +64,7 @@ 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 diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 1bc0a49764..8dbac1c3a7 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -4,6 +4,7 @@ The base Command class. All commands in Evennia inherit from the 'Command' class in this module. """ + import inspect import math import re @@ -21,7 +22,6 @@ CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES class InterruptCommand(Exception): - """Cleanly interrupt a command.""" pass @@ -487,31 +487,29 @@ class Command(metaclass=CommandMeta): 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__) - ) + output_string = """ +Command \"{cmdname}\" has no defined `func()` method. Available properties on this command are: - self.msg(string) + {variables}""" + variables = [ + " |w{}|n ({}): {}".format(key, type(val), f'"{val}"' if isinstance(val, str) else val) + for key, val in ( + ("self.key", self.key), + ("self.cmdname", self.cmdstring), + ("self.raw_cmdname", self.raw_cmdname), + ("self.raw_string", self.raw_string), + ("self.aliases", self.aliases), + ("self.args", self.args), + ("self.caller", self.caller), + ("self.obj", self.obj), + ("self.session", self.session), + ("self.locks", self.locks), + ("self.help_category", self.help_category), + ("self.cmdset", self.cmdset), + ) + ] + output = output_string.format(cmdname=self.key, variables="\n ".join(variables)) + self.msg(output) def func(self): """ diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 7b966eb7cf..f51b079e17 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -18,6 +18,7 @@ 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 diff --git a/evennia/commands/default/batchprocess.py b/evennia/commands/default/batchprocess.py index 2d1ef758e4..694fd20972 100644 --- a/evennia/commands/default/batchprocess.py +++ b/evennia/commands/default/batchprocess.py @@ -17,6 +17,7 @@ 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 diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index c503990345..558d96b3b5 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1,16 +1,17 @@ """ Building and world design commands """ + import re import typing -import evennia 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 (generate_cmdset_providers, - get_and_merge_cmdsets) +from evennia.commands.cmdhandler import generate_cmdset_providers, get_and_merge_cmdsets from evennia.locks.lockhandler import LockException from evennia.objects.models import ObjectDB from evennia.prototypes import menus as olc_menus @@ -23,10 +24,18 @@ 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) +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) @@ -68,7 +77,7 @@ __all__ = ( ) # used by set -from ast import literal_eval as _LITERAL_EVAL +from ast import literal_eval as _LITERAL_EVAL # noqa LIST_APPEND_CHAR = "+" @@ -218,10 +227,13 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): alias [= [alias[,alias,alias,...]]] alias = alias/category = [alias[,alias,...]: + alias/delete = Switches: category - requires ending input with :category, to store the given aliases with the given category. + delete - deletes all occurrences of the given alias, regardless + of 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 @@ -235,7 +247,7 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): key = "@alias" aliases = "setobjalias" - switch_options = ("category",) + switch_options = ("category", "delete") locks = "cmd:perm(setobjalias) or perm(Builder)" help_category = "Building" @@ -252,12 +264,12 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): return objname = self.lhs - # Find the object to receive aliases + # Find the object to receive/delete aliases obj = caller.search(objname) if not obj: return - if self.rhs is None: - # no =, so we just list aliases on object. + if self.rhs is None and "delete" not in self.switches: + # no =, and not deleting, so we just list aliases on object. aliases = obj.aliases.all(return_key_and_category=True) if aliases: caller.msg( @@ -280,7 +292,9 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): return if not self.rhs: - # we have given an empty =, so delete aliases + # we have given an empty =, so delete aliases. + # as a side-effect, 'alias/delete obj' and 'alias/delete obj=' + # will also be caught here, which is fine old_aliases = obj.aliases.all() if old_aliases: caller.msg( @@ -292,6 +306,19 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): caller.msg("No aliases to clear.") return + if "delete" in self.switches: + # delete all matching keys, regardless of category + existed = False + for key, category in obj.aliases.all(return_key_and_category=True): + if key == self.rhs: + obj.aliases.remove(key=self.rhs, category=category) + existed = True + if existed: + caller.msg("Alias '%s' deleted from %s." % (self.rhs, obj.get_display_name(caller))) + else: + caller.msg("%s has no alias '%s'." % (obj.get_display_name(caller), self.rhs)) + return + category = None if "category" in self.switches: if ":" in self.rhs: @@ -1378,7 +1405,7 @@ class CmdSetHome(CmdLink): obj.home = new_home if old_home: string = ( - f"Home location of {obj} was changed from {old_home}({old_home.dbref} to" + f"Home location of {obj} was changed from {old_home}({old_home.dbref}) to" f" {new_home}({new_home.dbref})." ) else: @@ -2938,9 +2965,9 @@ class CmdExamine(ObjManipCommand): ): 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) + 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) @@ -3255,9 +3282,15 @@ class CmdFind(COMMAND_DEFAULT_CLASS): 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" + string += ( + f"\n|g {result.get_display_name(caller)}" + f"{result.get_extra_display_name_info(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)" + string += ( + f" (|wlocation|n: |g{result.location.get_display_name(caller)}" + f"{result.get_extra_display_name_info(caller)}|n)" + ) else: # Not an account/dbref search but a wider search; build a queryset. # Searches for key and aliases @@ -3397,9 +3430,11 @@ class ScriptEvMore(EvMore): table.add_row( f"#{script.id}", - f"{script.obj.key}({script.obj.dbref})" - if (hasattr(script, "obj") and script.obj) - else "", + ( + f"{script.obj.key}({script.obj.dbref})" + if (hasattr(script, "obj") and script.obj) + else "" + ), script.key, script.interval if script.interval > 0 else "--", nextrep, diff --git a/evennia/commands/default/cmdset_character.py b/evennia/commands/default/cmdset_character.py index f8294dd6b7..df06f3f893 100644 --- a/evennia/commands/default/cmdset_character.py +++ b/evennia/commands/default/cmdset_character.py @@ -4,6 +4,7 @@ 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, diff --git a/evennia/commands/default/cmdset_session.py b/evennia/commands/default/cmdset_session.py index f81e8b9636..eca352c44e 100644 --- a/evennia/commands/default/cmdset_session.py +++ b/evennia/commands/default/cmdset_session.py @@ -1,6 +1,7 @@ """ This module stores session-level commands. """ + from evennia.commands.cmdset import CmdSet from evennia.commands.default import account diff --git a/evennia/commands/default/cmdset_unloggedin.py b/evennia/commands/default/cmdset_unloggedin.py index b668325bd8..1ce4e7749d 100644 --- a/evennia/commands/default/cmdset_unloggedin.py +++ b/evennia/commands/default/cmdset_unloggedin.py @@ -3,6 +3,7 @@ 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 diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index bf9a90b0e3..cac9905a5a 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -8,6 +8,7 @@ Communication commands: """ from django.conf import settings +from django.db.models import Q from evennia.accounts import bots from evennia.accounts.models import AccountDB @@ -1338,8 +1339,24 @@ class CmdPage(COMMAND_DEFAULT_CLASS): # 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 only messages tagged as pages or not tagged at all (legacy pages) + pages_we_sent = pages_we_sent.filter( + Q(db_tags__db_key__iexact="page", db_tags__db_category__iexact="comms") + | Q(db_tags__isnull=True) + ) + # we need to default to True to allow for legacy pages + pages_we_sent = [msg for msg in pages_we_sent if msg.access(caller, "read", default=True)] + # get last messages we've got pages_we_got = Msg.objects.get_messages_by_receiver(caller).order_by("-db_date_created") + pages_we_got = pages_we_got.filter( + Q(db_tags__db_key__iexact="page", db_tags__db_category__iexact="comms") + | Q(db_tags__isnull=True) + ) + # we need to default to True to allow for legacy pages + pages_we_got = [msg for msg in pages_we_got if msg.access(caller, "read", default=True)] + + # get only messages tagged as pages or not tagged at all (legacy pages) targets, message, number = [], None, None if "last" in self.switches: @@ -1360,6 +1377,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS): targets.append(target_obj) message = self.rhs.strip() else: + # no = sign, handler this as well target, *message = self.args.split(" ", 1) if target and target.isnumeric(): # a number to specify a historic page @@ -1395,7 +1413,17 @@ class CmdPage(COMMAND_DEFAULT_CLASS): message = f"{caller.key} {message.strip(':').strip()}" # create the persistent message object - create.create_message(caller, message, receivers=targets) + create.create_message( + caller, + message, + receivers=targets, + locks=( + f"read:id({caller.id}) or perm(Admin);" + f"delete:id({caller.id}) or perm(Admin);" + f"edit:id({caller.id}) or perm(Admin)" + ), + tags=[("page", "comms")], + ) # tell the accounts they got a message. received = [] diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 4a65b63b2d..06aae08f75 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -1,10 +1,12 @@ """ General Character commands usually available to all characters """ + import re -import evennia from django.conf import settings + +import evennia from evennia.typeclasses.attributes import NickTemplateInvalid from evennia.utils import utils @@ -378,20 +380,57 @@ class CmdInventory(COMMAND_DEFAULT_CLASS): self.msg(text=(string, {"type": "inventory"})) -class CmdGet(COMMAND_DEFAULT_CLASS): +class NumberedTargetCommand(COMMAND_DEFAULT_CLASS): + """ + A class that parses out an optional number component from the input string. This + class is intended to be inherited from to provide additional functionality, rather + than used on its own. + """ + + def parse(self): + """ + Parser that extracts a `.number` property from the beginning of the input string. + + For example, if the input string is "3 apples", this parser will set `self.number = 3` and + `self.args = "apples"`. If the input string is "apples", this parser will set + `self.number = 0` and `self.args = "apples"`. + + """ + super().parse() + self.number = 0 + if getattr(self, "lhs", None): + # handle self.lhs but don't require it + count, *args = self.lhs.split(maxsplit=1) + # we only use the first word as a count if it's a number and + # there is more text afterwards + if args and count.isdecimal(): + self.number = int(count) + self.lhs = args[0] + if self.args: + # check for numbering + count, *args = self.args.split(maxsplit=1) + # we only use the first word as a count if it's a number and + # there is more text afterwards + if args and count.isdecimal(): + self.args = args[0] + # we only re-assign self.number if it wasn't already taken from self.lhs + if not self.number: + self.number = int(count) + + +class CmdGet(NumberedTargetCommand): """ pick up something Usage: get - Picks up an object from your location and puts it in - your inventory. + 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)" + locks = "cmd:all()" arg_regex = r"\s|$" def func(self): @@ -400,36 +439,49 @@ class CmdGet(COMMAND_DEFAULT_CLASS): caller = self.caller if not self.args: - caller.msg("Get what?") + self.msg("Get what?") return - obj = caller.search(self.args, location=caller.location) - if not obj: + objs = caller.search(self.args, location=caller.location, stacked=self.number) + if not objs: 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.") + # the 'stacked' search sometimes returns a list, sometimes not, so we make it always a list + # NOTE: this behavior may be a bug, see issue #3432 + objs = utils.make_iter(objs) + + if len(objs) == 1 and caller == objs[0]: + self.msg("You can't get yourself.") return - # calling at_pre_get hook method - if not obj.at_pre_get(caller): - return + # if we aren't allowed to get any of the objects, cancel the get + for obj in objs: + # check the locks + if not obj.access(caller, "get"): + if obj.db.get_err_msg: + self.msg(obj.db.get_err_msg) + else: + self.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.") + moved = [] + # attempt to move all of the objects + for obj in objs: + if obj.move_to(caller, quiet=True, move_type="get"): + moved.append(obj) + # calling at_get hook method + obj.at_get(caller) + + if not moved: + # none of the objects were successfully moved + self.msg("That 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) + obj_name = moved[0].get_numbered_name(len(moved), caller, return_string=True) + caller.location.msg_contents(f"$You() $conj(pick) up {obj_name}.", from_obj=caller) -class CmdDrop(COMMAND_DEFAULT_CLASS): +class CmdDrop(NumberedTargetCommand): """ drop something @@ -454,30 +506,42 @@ class CmdDrop(COMMAND_DEFAULT_CLASS): # Because the DROP command by definition looks for items # in inventory, call the search function using location = caller - obj = caller.search( + objs = 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}:", + stacked=self.number, ) - if not obj: + if not objs: return + # the 'stacked' search sometimes returns a list, sometimes not, so we make it always a list + # NOTE: this behavior may be a bug, see issue #3432 + objs = utils.make_iter(objs) - # Call the object script's at_pre_drop() method. - if not obj.at_pre_drop(caller): - return + # if any objects fail the drop permission check, cancel the drop + for obj in objs: + # Call the object'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.") + # do the actual dropping + moved = [] + for obj in objs: + if obj.move_to(caller.location, quiet=True, move_type="drop"): + moved.append(obj) + # Call the object's at_drop() method. + obj.at_drop(caller) + + if not moved: + # none of the objects were successfully moved + self.msg("That can'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) + obj_name = moved[0].get_numbered_name(len(moved), caller, return_string=True) + caller.location.msg_contents(f"$You() $conj(drop) {obj_name}.", from_obj=caller) -class CmdGive(COMMAND_DEFAULT_CLASS): +class CmdGive(NumberedTargetCommand): """ give away something to someone @@ -500,37 +564,50 @@ class CmdGive(COMMAND_DEFAULT_CLASS): if not self.args or not self.rhs: caller.msg("Usage: give = ") return + # find the thing(s) to give away 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}:", + stacked=self.number, ) + if not to_give: + return + # find the target to give to target = caller.search(self.rhs) - if not (to_give and target): + if not target: return - singular, _ = to_give.get_numbered_name(1, caller) + # the 'stacked' search sometimes returns a list, sometimes not, so we make it always a list + # NOTE: this behavior may be a bug, see issue #3432 + to_give = utils.make_iter(to_give) + + singular, plural = to_give[0].get_numbered_name(len(to_give), 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}.") + caller.msg(f"You keep {plural if len(to_give) > 1 else singular} to yourself.") return - # calling at_pre_give hook method - if not to_give.at_pre_give(caller, target): - return + # if any of the objects aren't allowed to be given, cancel the give + for obj in to_give: + # calling at_pre_give hook method + if not obj.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}.") + # do the actual moving + moved = [] + for obj in to_give: + if obj.move_to(target, quiet=True, move_type="give"): + moved.append(obj) + # Call the object's at_give() method. + obj.at_give(caller, target) + + if not moved: + caller.msg(f"You could not give that to {target.get_display_name(caller)}.") 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) + obj_name = to_give[0].get_numbered_name(len(moved), caller, return_string=True) + caller.msg(f"You give {obj_name} to {target.get_display_name(caller)}.") + target.msg(f"{caller.get_display_name(target)} gives you {obj_name}.") class CmdSetDesc(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index 9956a23c08..a1ec71caf0 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -780,13 +780,14 @@ class CmdSetHelp(CmdHelp): Edit the help database. Usage: - sethelp[/switches] [[;alias;alias][,category[,locks]] [= ] - + sethelp[/switches] [[;alias;alias][,category[,locks]] + [= ] 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. + category - change category of existing help topic. delete - remove help topic. Examples: @@ -794,6 +795,7 @@ class CmdSetHelp(CmdHelp): sethelp/append pickpocketing,Thievery = This steals ... sethelp/replace pickpocketing, ,attr(is_thief) = This steals ... sethelp/edit thievery + sethelp/category thievery = classes 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 @@ -840,7 +842,7 @@ class CmdSetHelp(CmdHelp): key = "sethelp" aliases = [] - switch_options = ("edit", "replace", "append", "extend", "delete") + switch_options = ("edit", "replace", "append", "extend", "category", "delete") locks = "cmd:perm(Helper)" help_category = "Building" arg_regex = None @@ -857,7 +859,7 @@ class CmdSetHelp(CmdHelp): if not self.args: self.msg( - "Usage: sethelp[/switches] [;alias;alias][,category[,locks,..] = " + "Usage: sethelp[/switches] [[;alias;alias][,category[,locks]] [= ]" ) return @@ -953,7 +955,7 @@ class CmdSetHelp(CmdHelp): else: helpentry = create.create_help_entry( topicstr, - self.rhs, + self.rhs if self.rhs is not None else "", category=category, locks=lockstring, aliases=aliases, @@ -986,6 +988,19 @@ class CmdSetHelp(CmdHelp): self.msg(f"Entry updated:\n{old_entry.entrytext}{aliastxt}") return + if "category" in switches: + # set the category + if not old_entry: + self.msg(f"Could not find topic '{topicstr}'{aliastxt}.") + return + if not self.rhs: + self.msg("You must supply a category.") + return + category = self.rhs.lower() + old_entry.help_category = category + self.msg(f"Category for entry '{topicstr}'{aliastxt} changed to '{category}'.") + return + if "delete" in switches or "del" in switches: # delete the help entry if not old_entry: diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 546945367e..d6e2466dc5 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -4,7 +4,6 @@ System commands """ - import code import datetime import os @@ -112,7 +111,6 @@ class CmdReset(COMMAND_DEFAULT_CLASS): class CmdShutdown(COMMAND_DEFAULT_CLASS): - """ stop the server completely @@ -277,7 +275,6 @@ def evennia_local_vars(caller): class EvenniaPythonConsole(code.InteractiveConsole): - """Evennia wrapper around a Python interactive console.""" def __init__(self, caller): diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 8977c890d3..f7fc7f1fd2 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -14,10 +14,13 @@ main test suite started with import datetime from unittest.mock import MagicMock, Mock, patch -import evennia 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, @@ -29,7 +32,14 @@ from evennia import ( 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 ( + 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 @@ -38,8 +48,6 @@ 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 -from parameterized import parameterized -from twisted.internet import task # ------------------------------------------------------------ # Command testing @@ -108,8 +116,12 @@ class TestGeneral(BaseEvenniaCommandTest): self.call(general.CmdNick(), "/list", "Defined Nicks:") 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") + self.call(general.CmdGet(), "Obj", "You pick up an Obj.") + self.call(general.CmdDrop(), "Obj", "You drop an Obj.") + # test stacking + self.obj2.key = "Obj" + self.call(general.CmdGet(), "2 Obj", "You pick up two Objs.") + self.call(general.CmdDrop(), "2 Obj", "You drop two Objs.") def test_give(self): self.call(general.CmdGive(), "Obj to Char2", "You aren't carrying Obj.") @@ -117,6 +129,21 @@ class TestGeneral(BaseEvenniaCommandTest): 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) + # test stacking + self.obj2.key = "Obj" + self.obj2.location = self.char1 + self.call(general.CmdGive(), "2 Obj = Char2", "You give two Objs") + + def test_numbered_target_command(self): + class CmdTest(general.NumberedTargetCommand): + key = "test" + + def func(self): + self.msg(f"Number: {self.number} Args: {self.args}") + + self.call(CmdTest(), "", "Number: 0 Args: ") + self.call(CmdTest(), "obj", "Number: 0 Args: obj") + self.call(CmdTest(), "1 obj", "Number: 1 Args: obj") def test_mux_command(self): class CmdTest(MuxCommand): @@ -197,6 +224,12 @@ class TestHelp(BaseEvenniaCommandTest): cmdset=CharacterCmdSet(), ) self.call(help_module.CmdHelp(), "testhelp", "Help for testhelp", cmdset=CharacterCmdSet()) + self.call( + help_module.CmdSetHelp(), + "/category testhelp = misc", + "Category for entry 'testhelp' changed to 'misc'.", + cmdset=CharacterCmdSet(), + ) @parameterized.expand( [ @@ -784,6 +817,24 @@ class TestBuilding(BaseEvenniaCommandTest): self.call(building.CmdSetObjAlias(), "Obj2 =", "Cleared aliases from Obj2") self.call(building.CmdSetObjAlias(), "Obj2 =", "No aliases to clear.") + self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj: testobj1b") + self.call( + building.CmdSetObjAlias(), + "/category Obj = testobj1b:category1", + "Alias(es) for 'Obj' set to 'testobj1b' (category: 'category1').", + ) + self.call( + building.CmdSetObjAlias(), + "/category Obj = testobj1b:category2", + "Alias(es) for 'Obj' set to 'testobj1b,testobj1b' (category: 'category2').", + ) + self.call( + building.CmdSetObjAlias(), # delete both occurences of alias 'testobj1b' + "/delete Obj = testobj1b", + "Alias 'testobj1b' deleted from Obj.", + ) + self.call(building.CmdSetObjAlias(), "Obj =", "No aliases to clear.") + def test_copy(self): self.call( building.CmdCopy(), @@ -1754,8 +1805,7 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSpawn(), "{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', " - "'key':'goblin', 'location':'%s'}" - % spawnLoc.dbref, + "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin", ) goblin = get_object(self, "goblin") @@ -1803,8 +1853,7 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdSpawn(), "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo'," - " 'location':'%s'}" - % spawnLoc.dbref, + " 'location':'%s'}" % spawnLoc.dbref, "Spawned Ball", ) ball = get_object(self, "Ball") @@ -2048,6 +2097,11 @@ class TestComms(BaseEvenniaCommandTest): ), receiver=self.account, ) + from evennia.comms.models import Msg + + msgs = Msg.objects.filter(db_tags__db_key="page", db_tags__db_category="comms") + self.assertEqual(msgs[0].senders, [self.account]) + self.assertEqual(msgs[0].receivers, [self.account2]) @override_settings(DISCORD_BOT_TOKEN="notarealtoken", DISCORD_ENABLED=True) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 75fff10ccd..a69e20068e 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -2,6 +2,7 @@ Commands that are available from the connect screen. """ + import datetime import re from codecs import lookup as codecs_lookup diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index ea2a512b5c..a32b425df8 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -2,6 +2,7 @@ Base typeclass for in-game Channels. """ + import re from django.contrib.contenttypes.models import ContentType diff --git a/evennia/comms/managers.py b/evennia/comms/managers.py index b55b814009..7cd720a8dd 100644 --- a/evennia/comms/managers.py +++ b/evennia/comms/managers.py @@ -4,7 +4,6 @@ Comm system components. """ - from django.conf import settings from django.db.models import Q diff --git a/evennia/comms/models.py b/evennia/comms/models.py index 0869eaac09..a37cb733bc 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -18,6 +18,7 @@ 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 diff --git a/evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py b/evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py index f8633933ca..1123c66f1a 100644 --- a/evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py +++ b/evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py @@ -221,7 +221,6 @@ def get_available_overwrite_name(name, max_length): @deconstructible class S3Boto3StorageFile(File): - """ The default file object used by the S3Boto3Storage backend. This file implements file streaming using boto's multipart diff --git a/evennia/contrib/base_systems/building_menu/__init__.py b/evennia/contrib/base_systems/building_menu/__init__.py index 5ab8549d2d..0b7e8cbf05 100644 --- a/evennia/contrib/base_systems/building_menu/__init__.py +++ b/evennia/contrib/base_systems/building_menu/__init__.py @@ -2,5 +2,6 @@ Build-menu contrib - vincent-lg 2018 """ + from .building_menu import BuildingMenu # noqa from .building_menu import GenericBuildingCmd # noqa diff --git a/evennia/contrib/base_systems/building_menu/building_menu.py b/evennia/contrib/base_systems/building_menu/building_menu.py index e8bfd291e6..63d52c3211 100644 --- a/evennia/contrib/base_systems/building_menu/building_menu.py +++ b/evennia/contrib/base_systems/building_menu/building_menu.py @@ -331,7 +331,6 @@ def menu_edit(caller, choice, obj): class CmdNoInput(Command): - """No input has been found.""" key = _CMD_NOINPUT @@ -352,7 +351,6 @@ class CmdNoInput(Command): class CmdNoMatch(Command): - """No input has been found.""" key = _CMD_NOMATCH @@ -394,7 +392,6 @@ class CmdNoMatch(Command): class BuildingMenuCmdSet(CmdSet): - """Building menu CmdSet.""" key = "building_menu" @@ -421,7 +418,6 @@ class BuildingMenuCmdSet(CmdSet): class Choice: - """A choice object, created by `add_choice`.""" def __init__( @@ -557,7 +553,6 @@ class Choice: class BuildingMenu: - """ Class allowing to create and set building menus to edit specific objects. @@ -1200,7 +1195,6 @@ class BuildingMenu: # Generic building menu and command 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 @@ -1241,7 +1235,6 @@ class GenericBuildingMenu(BuildingMenu): class GenericBuildingCmd(Command): - """ Generic building command. diff --git a/evennia/contrib/base_systems/components/__init__.py b/evennia/contrib/base_systems/components/__init__.py index 21867e7528..931b156f88 100644 --- a/evennia/contrib/base_systems/components/__init__.py +++ b/evennia/contrib/base_systems/components/__init__.py @@ -7,6 +7,7 @@ This helps writing isolated code and reusing it over multiple objects. See the docs for more information. """ + from . import exceptions # noqa from .component import Component # noqa from .dbfield import DBField, NDBField, TagField # noqa diff --git a/evennia/contrib/base_systems/components/component.py b/evennia/contrib/base_systems/components/component.py index 48c7dfa738..6397b77a07 100644 --- a/evennia/contrib/base_systems/components/component.py +++ b/evennia/contrib/base_systems/components/component.py @@ -155,6 +155,22 @@ class Component(metaclass=BaseComponent): """ return self.host.attributes + @property + def pk(self): + """ + Shortcut property returning the host's primary key. + + Returns: + int: The Host's primary key. + + Notes: + This is requried to allow AttributeProperties to correctly update `_SaverMutable` data + (like lists) in-place (since the DBField sits on the Component which doesn't itself + have a primary key, this save operation would otherwise fail). + + """ + return self.host.pk + @property def nattributes(self): """ diff --git a/evennia/contrib/base_systems/components/dbfield.py b/evennia/contrib/base_systems/components/dbfield.py index c5e0eb2eae..67f812b484 100644 --- a/evennia/contrib/base_systems/components/dbfield.py +++ b/evennia/contrib/base_systems/components/dbfield.py @@ -3,9 +3,11 @@ Components - ChrisLR 2022 This file contains the Descriptors used to set Fields in Components """ + import typing -from evennia.typeclasses.attributes import AttributeProperty, NAttributeProperty +from evennia.typeclasses.attributes import (AttributeProperty, + NAttributeProperty) if typing.TYPE_CHECKING: from .components import Component diff --git a/evennia/contrib/base_systems/components/tests.py b/evennia/contrib/base_systems/components/tests.py index b11ce68937..75e169b825 100644 --- a/evennia/contrib/base_systems/components/tests.py +++ b/evennia/contrib/base_systems/components/tests.py @@ -268,7 +268,7 @@ class TestComponents(EvenniaTest): def test_mutables_are_not_shared_when_autocreate(self): self.char1.test_a.my_list.append(1) - self.assertNotEqual(self.char1.test_a.my_list, self.char2.test_a.my_list) + self.assertNotEqual(id(self.char1.test_a.my_list), id(self.char2.test_a.my_list)) def test_replacing_class_component_slot_with_runtime_component(self): self.char1.components.add_default("replacement_inherited_test_a") diff --git a/evennia/contrib/base_systems/custom_gametime/custom_gametime.py b/evennia/contrib/base_systems/custom_gametime/custom_gametime.py index ca6b26363e..f3f9ad3596 100644 --- a/evennia/contrib/base_systems/custom_gametime/custom_gametime.py +++ b/evennia/contrib/base_systems/custom_gametime/custom_gametime.py @@ -309,7 +309,6 @@ def schedule(callback, repeat=False, **kwargs): class GametimeScript(DefaultScript): - """Gametime-sensitive script.""" def at_script_creation(self): diff --git a/evennia/contrib/base_systems/godotwebsocket/__init__.py b/evennia/contrib/base_systems/godotwebsocket/__init__.py index 8311d5d7da..cb618d85d0 100644 --- a/evennia/contrib/base_systems/godotwebsocket/__init__.py +++ b/evennia/contrib/base_systems/godotwebsocket/__init__.py @@ -10,6 +10,7 @@ You could also pass extra data to this client for advanced functionality. See the docs for more information. """ + from evennia.contrib.base_systems.godotwebsocket.text2bbcode import ( BBCODE_PARSER, parse_to_bbcode, diff --git a/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py b/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py index ac16fb2743..5b94243941 100644 --- a/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py +++ b/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py @@ -3,6 +3,7 @@ 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 diff --git a/evennia/contrib/base_systems/godotwebsocket/webclient.py b/evennia/contrib/base_systems/godotwebsocket/webclient.py index 3e77800abd..bd8576ff17 100644 --- a/evennia/contrib/base_systems/godotwebsocket/webclient.py +++ b/evennia/contrib/base_systems/godotwebsocket/webclient.py @@ -4,6 +4,7 @@ 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 diff --git a/evennia/contrib/base_systems/ingame_python/callbackhandler.py b/evennia/contrib/base_systems/ingame_python/callbackhandler.py index adb44395a0..e0bac0c3b3 100644 --- a/evennia/contrib/base_systems/ingame_python/callbackhandler.py +++ b/evennia/contrib/base_systems/ingame_python/callbackhandler.py @@ -6,7 +6,6 @@ from collections import namedtuple class CallbackHandler(object): - """ The callback handler for a specific object. diff --git a/evennia/contrib/base_systems/ingame_python/commands.py b/evennia/contrib/base_systems/ingame_python/commands.py index a3331648be..1d109aa888 100644 --- a/evennia/contrib/base_systems/ingame_python/commands.py +++ b/evennia/contrib/base_systems/ingame_python/commands.py @@ -70,7 +70,6 @@ Use the /del switch to remove callbacks that should not be connected. class CmdCallback(COMMAND_DEFAULT_CLASS): - """ Command to edit callbacks. """ diff --git a/evennia/contrib/base_systems/ingame_python/scripts.py b/evennia/contrib/base_systems/ingame_python/scripts.py index 63c0477008..047dbb10b7 100644 --- a/evennia/contrib/base_systems/ingame_python/scripts.py +++ b/evennia/contrib/base_systems/ingame_python/scripts.py @@ -27,7 +27,6 @@ RE_LINE_ERROR = re.compile(r'^ File "\", line (\d+)') class EventHandler(DefaultScript): - """ The event handler that contains all events in a global script. @@ -600,7 +599,6 @@ class EventHandler(DefaultScript): # Script to call time-related events class TimeEventScript(DefaultScript): - """Gametime-sensitive script.""" def at_script_creation(self): diff --git a/evennia/contrib/base_systems/ingame_python/tests.py b/evennia/contrib/base_systems/ingame_python/tests.py index b3f7d14a32..a0e420cb67 100644 --- a/evennia/contrib/base_systems/ingame_python/tests.py +++ b/evennia/contrib/base_systems/ingame_python/tests.py @@ -25,7 +25,6 @@ OLD_EVENTS = {} class TestEventHandler(BaseEvenniaTest): - """ Test cases of the event handler to add, edit or delete events. """ @@ -259,7 +258,6 @@ class TestEventHandler(BaseEvenniaTest): class TestCmdCallback(BaseEvenniaCommandTest): - """Test the @callback command.""" def setUp(self): @@ -448,7 +446,6 @@ class TestCmdCallback(BaseEvenniaCommandTest): class TestDefaultCallbacks(BaseEvenniaCommandTest): - """Test the default callbacks.""" def setUp(self): diff --git a/evennia/contrib/base_systems/ingame_python/typeclasses.py b/evennia/contrib/base_systems/ingame_python/typeclasses.py index 3922ffd11d..5f14ee8c6e 100644 --- a/evennia/contrib/base_systems/ingame_python/typeclasses.py +++ b/evennia/contrib/base_systems/ingame_python/typeclasses.py @@ -166,7 +166,6 @@ Variables you can use in this event: @register_events class EventCharacter(DefaultCharacter): - """Typeclass to represent a character and call event types.""" _events = { @@ -625,7 +624,6 @@ Variables you can use in this event: @register_events class EventExit(DefaultExit): - """Modified exit including management of events.""" _events = { @@ -721,7 +719,6 @@ Variables you can use in this event: @register_events class EventObject(DefaultObject): - """Default object with management of events.""" _events = { @@ -892,7 +889,6 @@ Variables you can use in this event: @register_events class EventRoom(DefaultRoom): - """Default room with management of events.""" _events = { diff --git a/evennia/contrib/base_systems/ingame_python/utils.py b/evennia/contrib/base_systems/ingame_python/utils.py index c99041e68c..8635792964 100644 --- a/evennia/contrib/base_systems/ingame_python/utils.py +++ b/evennia/contrib/base_systems/ingame_python/utils.py @@ -251,7 +251,6 @@ def phrase_event(callbacks, parameters): class InterruptEvent(RuntimeError): - """ Interrupt the current event. diff --git a/evennia/contrib/base_systems/menu_login/menu_login.py b/evennia/contrib/base_systems/menu_login/menu_login.py index e2e57a5359..bf9d1609d9 100644 --- a/evennia/contrib/base_systems/menu_login/menu_login.py +++ b/evennia/contrib/base_systems/menu_login/menu_login.py @@ -90,7 +90,7 @@ def node_enter_username(caller, raw_text, **kwargs): else: new_user = False - if new_user and not settings.ACCOUNT_REGISTRATION_ENABLED: + if new_user and not settings.NEW_ACCOUNT_REGISTRATION_ENABLED: caller.msg("Registration is currently disabled.") return None diff --git a/evennia/contrib/base_systems/mux_comms_cmds/mux_comms_cmds.py b/evennia/contrib/base_systems/mux_comms_cmds/mux_comms_cmds.py index 5fadf4117b..ef1a3e0a5a 100644 --- a/evennia/contrib/base_systems/mux_comms_cmds/mux_comms_cmds.py +++ b/evennia/contrib/base_systems/mux_comms_cmds/mux_comms_cmds.py @@ -41,6 +41,7 @@ class CharacterCmdSet(default_cmds.CharacterCmdSet): ``` """ + from django.conf import settings from evennia.commands.cmdset import CmdSet diff --git a/evennia/contrib/base_systems/unixcommand/tests.py b/evennia/contrib/base_systems/unixcommand/tests.py index adb7421352..6a044bdeac 100644 --- a/evennia/contrib/base_systems/unixcommand/tests.py +++ b/evennia/contrib/base_systems/unixcommand/tests.py @@ -9,7 +9,6 @@ from .unixcommand import UnixCommand class CmdDummy(UnixCommand): - """A dummy UnixCommand.""" key = "dummy" diff --git a/evennia/contrib/base_systems/unixcommand/unixcommand.py b/evennia/contrib/base_systems/unixcommand/unixcommand.py index de1c73ab8f..1ad5cbaefe 100644 --- a/evennia/contrib/base_systems/unixcommand/unixcommand.py +++ b/evennia/contrib/base_systems/unixcommand/unixcommand.py @@ -72,14 +72,12 @@ from evennia.utils.ansi import raw class ParseError(Exception): - """An error occurred during parsing.""" pass class UnixCommandParser(argparse.ArgumentParser): - """A modifier command parser for unix commands. This parser is used to replace `argparse.ArgumentParser`. It @@ -183,7 +181,6 @@ class UnixCommandParser(argparse.ArgumentParser): 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 diff --git a/evennia/contrib/full_systems/evscaperoom/menu.py b/evennia/contrib/full_systems/evscaperoom/menu.py index 8e76fb67fe..68f2d910e2 100644 --- a/evennia/contrib/full_systems/evscaperoom/menu.py +++ b/evennia/contrib/full_systems/evscaperoom/menu.py @@ -7,6 +7,7 @@ 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 diff --git a/evennia/contrib/full_systems/evscaperoom/objects.py b/evennia/contrib/full_systems/evscaperoom/objects.py index 627c931472..fceade2bf0 100644 --- a/evennia/contrib/full_systems/evscaperoom/objects.py +++ b/evennia/contrib/full_systems/evscaperoom/objects.py @@ -43,6 +43,7 @@ Available parents: - Positionable (supports sit/lie/knee/climb at once) """ + import inspect import re diff --git a/evennia/contrib/full_systems/evscaperoom/tests.py b/evennia/contrib/full_systems/evscaperoom/tests.py index 1b5cd75e0a..ed599a27d6 100644 --- a/evennia/contrib/full_systems/evscaperoom/tests.py +++ b/evennia/contrib/full_systems/evscaperoom/tests.py @@ -2,6 +2,7 @@ Unit tests for the Evscaperoom """ + import inspect import pkgutil from os import path diff --git a/evennia/contrib/game_systems/clothing/clothing.py b/evennia/contrib/game_systems/clothing/clothing.py index 2c98b17f9f..6fe957eb72 100644 --- a/evennia/contrib/game_systems/clothing/clothing.py +++ b/evennia/contrib/game_systems/clothing/clothing.py @@ -72,9 +72,11 @@ with which to test the system: 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 ( diff --git a/evennia/contrib/game_systems/containers/containers.py b/evennia/contrib/game_systems/containers/containers.py index 66c31b7175..be718d526e 100644 --- a/evennia/contrib/game_systems/containers/containers.py +++ b/evennia/contrib/game_systems/containers/containers.py @@ -30,6 +30,7 @@ or implement the same locks/hooks in your own typeclasses. at_pre_get_from(getter, target, **kwargs) - called with the pre-get hooks at_pre_put_in(putter, target, **kwargs) - called with the pre-put hooks """ + from django.conf import settings from evennia import AttributeProperty, CmdSet, DefaultObject diff --git a/evennia/contrib/game_systems/crafting/crafting.py b/evennia/contrib/game_systems/crafting/crafting.py index 593f1a04b9..254d8aa458 100644 --- a/evennia/contrib/game_systems/crafting/crafting.py +++ b/evennia/contrib/game_systems/crafting/crafting.py @@ -237,7 +237,7 @@ class CraftingRecipeBase: **kwargs: Any optional properties relevant to this send. """ - self.crafter.msg(message, {"type": "crafting"}) + self.crafter.msg(text=(message, {"type": "crafting"})) def pre_craft(self, **kwargs): """ @@ -615,9 +615,11 @@ class CraftingRecipe(CraftingRecipeBase): ) else: self.output_names = [ - prot.get("key", prot.get("typeclass", "unnamed")) - if isinstance(prot, dict) - else str(prot) + ( + prot.get("key", prot.get("typeclass", "unnamed")) + if isinstance(prot, dict) + else str(prot) + ) for prot in self.output_prototypes ] diff --git a/evennia/contrib/game_systems/crafting/tests.py b/evennia/contrib/game_systems/crafting/tests.py index 41ae4412bf..201fa487b6 100644 --- a/evennia/contrib/game_systems/crafting/tests.py +++ b/evennia/contrib/game_systems/crafting/tests.py @@ -78,7 +78,7 @@ class TestCraftingRecipeBase(BaseEvenniaTestCase): """Test messaging to crafter""" self.recipe.msg("message") - self.crafter.msg.assert_called_with("message", {"type": "crafting"}) + self.crafter.msg.assert_called_with(text=("message", {"type": "crafting"})) def test_pre_craft(self): """Test validating hook""" @@ -206,7 +206,7 @@ class TestCraftingRecipe(BaseEvenniaTestCase): 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"} + text=(recipe.success_message.format(outputs="Result1"), {"type": "crafting"}) ) # make sure consumables are gone @@ -235,7 +235,7 @@ class TestCraftingRecipe(BaseEvenniaTestCase): 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"} + text=(recipe.success_message.format(outputs="Result1"), {"type": "crafting"}) ) # make sure consumables are gone @@ -251,8 +251,10 @@ class TestCraftingRecipe(BaseEvenniaTestCase): result = recipe.craft() self.assertFalse(result) self.crafter.msg.assert_called_with( - recipe.error_tool_missing_message.format(outputs="Result1", missing="tool2"), - {"type": "crafting"}, + text=( + recipe.error_tool_missing_message.format(outputs="Result1", missing="tool2"), + {"type": "crafting"}, + ) ) # make sure consumables are still there @@ -269,8 +271,10 @@ class TestCraftingRecipe(BaseEvenniaTestCase): result = recipe.craft() self.assertFalse(result) self.crafter.msg.assert_called_with( - recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"), - {"type": "crafting"}, + text=( + recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"), + {"type": "crafting"}, + ) ) # make sure consumables are still there @@ -293,8 +297,10 @@ class TestCraftingRecipe(BaseEvenniaTestCase): self.assertFalse(result) self.crafter.msg.assert_called_with( - recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"), - {"type": "crafting"}, + text=( + recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"), + {"type": "crafting"}, + ) ) # make sure consumables are deleted even though we failed @@ -317,10 +323,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase): 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"}, + text=( + 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) @@ -342,10 +350,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase): 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"}, + text=( + recipe.error_tool_excess_message.format( + outputs="Result1", excess=tool3.get_display_name(looker=self.crafter) + ), + {"type": "crafting"}, + ) ) # make sure consumables are still there @@ -369,10 +379,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase): 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"}, + text=( + recipe.error_consumable_excess_message.format( + outputs="Result1", excess=cons4.get_display_name(looker=self.crafter) + ), + {"type": "crafting"}, + ) ) # make sure consumables are still there @@ -396,7 +408,7 @@ class TestCraftingRecipe(BaseEvenniaTestCase): result = recipe.craft() self.assertTrue(result) self.crafter.msg.assert_called_with( - recipe.success_message.format(outputs="Result1"), {"type": "crafting"} + text=(recipe.success_message.format(outputs="Result1"), {"type": "crafting"}) ) # make sure consumables are gone @@ -419,7 +431,7 @@ class TestCraftingRecipe(BaseEvenniaTestCase): result = recipe.craft() self.assertTrue(result) self.crafter.msg.assert_called_with( - recipe.success_message.format(outputs="Result1"), {"type": "crafting"} + text=(recipe.success_message.format(outputs="Result1"), {"type": "crafting"}) ) # make sure consumables are gone @@ -439,10 +451,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase): 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"}, + text=( + 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 @@ -462,10 +476,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase): 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"}, + text=( + 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 diff --git a/evennia/contrib/game_systems/gendersub/tests.py b/evennia/contrib/game_systems/gendersub/tests.py index c53a65db36..842f1d1c20 100644 --- a/evennia/contrib/game_systems/gendersub/tests.py +++ b/evennia/contrib/game_systems/gendersub/tests.py @@ -3,7 +3,6 @@ Test gendersub contrib. """ - from mock import patch from evennia.commands.default.tests import BaseEvenniaCommandTest diff --git a/evennia/contrib/game_systems/multidescer/multidescer.py b/evennia/contrib/game_systems/multidescer/multidescer.py index c32e92eddc..177437206a 100644 --- a/evennia/contrib/game_systems/multidescer/multidescer.py +++ b/evennia/contrib/game_systems/multidescer/multidescer.py @@ -25,6 +25,7 @@ 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 diff --git a/evennia/contrib/grid/extended_room/extended_room.py b/evennia/contrib/grid/extended_room/extended_room.py index b23338b709..882b235576 100644 --- a/evennia/contrib/grid/extended_room/extended_room.py +++ b/evennia/contrib/grid/extended_room/extended_room.py @@ -823,7 +823,6 @@ class CmdExtendedRoomDesc(default_cmds.CmdDesc): class CmdExtendedRoomDetail(default_cmds.MuxCommand): - """ sets a detail on a room diff --git a/evennia/contrib/grid/extended_room/tests.py b/evennia/contrib/grid/extended_room/tests.py index 2a8ed62256..a5026a42a6 100644 --- a/evennia/contrib/grid/extended_room/tests.py +++ b/evennia/contrib/grid/extended_room/tests.py @@ -6,11 +6,12 @@ Testing of ExtendedRoom contrib import datetime from django.conf import settings -from evennia import create_object -from evennia.utils.test_resources import BaseEvenniaCommandTest, EvenniaTestCase 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 diff --git a/evennia/contrib/grid/ingame_map_display/ingame_map_display.py b/evennia/contrib/grid/ingame_map_display/ingame_map_display.py index f6369c7c31..3dec9a1d26 100644 --- a/evennia/contrib/grid/ingame_map_display/ingame_map_display.py +++ b/evennia/contrib/grid/ingame_map_display/ingame_map_display.py @@ -56,6 +56,7 @@ 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 diff --git a/evennia/contrib/grid/ingame_map_display/tests.py b/evennia/contrib/grid/ingame_map_display/tests.py index 4af1cdc41c..d047957c1e 100644 --- a/evennia/contrib/grid/ingame_map_display/tests.py +++ b/evennia/contrib/grid/ingame_map_display/tests.py @@ -3,7 +3,6 @@ Tests of ingame_map_display. """ - from typeclasses import exits, rooms from evennia.commands.default.tests import BaseEvenniaCommandTest diff --git a/evennia/contrib/grid/simpledoor/tests.py b/evennia/contrib/grid/simpledoor/tests.py index 9c0d3353c1..a97d31da92 100644 --- a/evennia/contrib/grid/simpledoor/tests.py +++ b/evennia/contrib/grid/simpledoor/tests.py @@ -3,7 +3,6 @@ Tests of simpledoor. """ - from evennia.commands.default.tests import BaseEvenniaCommandTest from . import simpledoor diff --git a/evennia/contrib/grid/xyzgrid/__init__.py b/evennia/contrib/grid/xyzgrid/__init__.py index cc2ad276e1..f086fdaa2b 100644 --- a/evennia/contrib/grid/xyzgrid/__init__.py +++ b/evennia/contrib/grid/xyzgrid/__init__.py @@ -2,6 +2,7 @@ XYZGrid - Griatch 2021 """ + from . import ( example, launchcmd, diff --git a/evennia/contrib/grid/xyzgrid/prototypes.py b/evennia/contrib/grid/xyzgrid/prototypes.py index 2c0341dc24..8f7804dc6f 100644 --- a/evennia/contrib/grid/xyzgrid/prototypes.py +++ b/evennia/contrib/grid/xyzgrid/prototypes.py @@ -14,6 +14,7 @@ and/or {'prototype_parent': 'xyz_exit', ...} """ + from django.conf import settings try: diff --git a/evennia/contrib/grid/xyzgrid/tests.py b/evennia/contrib/grid/xyzgrid/tests.py index a3cd7441d1..602f1bfed0 100644 --- a/evennia/contrib/grid/xyzgrid/tests.py +++ b/evennia/contrib/grid/xyzgrid/tests.py @@ -3,14 +3,15 @@ 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 evennia.utils.test_resources import BaseEvenniaCommandTest, BaseEvenniaTest + from . import commands, xymap, xymap_legend, xyzgrid, xyzroom MAP1 = """ diff --git a/evennia/contrib/grid/xyzgrid/xymap.py b/evennia/contrib/grid/xyzgrid/xymap.py index 41135b52ba..9eb494dac0 100644 --- a/evennia/contrib/grid/xyzgrid/xymap.py +++ b/evennia/contrib/grid/xyzgrid/xymap.py @@ -92,6 +92,7 @@ See `./example.py` for a full grid example. ---- """ + import pickle from collections import defaultdict from os import mkdir @@ -108,6 +109,7 @@ except ImportError as err: "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 @@ -172,6 +174,7 @@ class XYMap: but recommended for readability! """ + mapcorner_symbol = "+" max_pathfinding_length = 500 empty_symbol = " " @@ -475,10 +478,10 @@ class XYMap: 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 + 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: @@ -668,8 +671,7 @@ class XYMap: """ global _XYZROOMCLASS if not _XYZROOMCLASS: - from evennia.contrib.grid.xyzgrid.xyzroom import \ - XYZRoom as _XYZROOMCLASS + from evennia.contrib.grid.xyzgrid.xyzroom import XYZRoom as _XYZROOMCLASS x, y = xy wildcard = "*" spawned = [] diff --git a/evennia/contrib/grid/xyzgrid/xymap_legend.py b/evennia/contrib/grid/xyzgrid/xymap_legend.py index 1a42fc3eb4..973f96a1d5 100644 --- a/evennia/contrib/grid/xyzgrid/xymap_legend.py +++ b/evennia/contrib/grid/xyzgrid/xymap_legend.py @@ -20,11 +20,11 @@ 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) +from .utils import BIGVAL, MAPSCAN, REVERSE_DIRECTIONS, MapError, MapParserError NodeTypeclass = None ExitTypeclass = None @@ -327,6 +327,13 @@ class MapNode: nodeobj, err = Typeclass.create(self.prototype.get("key", "An empty room"), xyz=xyz) if err: raise RuntimeError(err) + except django_exceptions.MultipleObjectsReturned: + raise MapError( + f"Multiple objects found: {NodeTypeclass.objects.filter_xyz(xyz=xyz)}. " + "This may be due to manual creation of XYZRooms at this position. " + "Delete duplicates.", + self, + ) else: self.log(f" updating existing room (if changed) at xyz={xyz}") @@ -844,6 +851,7 @@ class SmartRerouterMapLink(MapLink): /| """ + multilink = True def get_direction(self, start_direction): diff --git a/evennia/contrib/grid/xyzgrid/xyzgrid.py b/evennia/contrib/grid/xyzgrid/xyzgrid.py index 2065eeea12..56626c8cee 100644 --- a/evennia/contrib/grid/xyzgrid/xyzgrid.py +++ b/evennia/contrib/grid/xyzgrid/xyzgrid.py @@ -16,6 +16,7 @@ The grid has three main functions: """ + from evennia.scripts.scripts import DefaultScript from evennia.utils import logger from evennia.utils.utils import variable_from_module diff --git a/evennia/contrib/grid/xyzgrid/xyzroom.py b/evennia/contrib/grid/xyzgrid/xyzroom.py index 1d63a85a6f..21de4aa874 100644 --- a/evennia/contrib/grid/xyzgrid/xyzroom.py +++ b/evennia/contrib/grid/xyzgrid/xyzroom.py @@ -283,7 +283,7 @@ class XYZRoom(DefaultRoom): def __repr__(self): x, y, z = self.xyz - return f"" + return f"<{self.__class__.__name__} '{self.db_key}', XYZ=({x},{y},{z})>" @property def xyz(self): diff --git a/evennia/contrib/rpg/buffs/__init__.py b/evennia/contrib/rpg/buffs/__init__.py index d3e73893db..c08e1d8ceb 100644 --- a/evennia/contrib/rpg/buffs/__init__.py +++ b/evennia/contrib/rpg/buffs/__init__.py @@ -1,4 +1,3 @@ -from .buff import (BaseBuff, BuffableProperty, BuffHandler, CmdBuff, # noqa - Mod, cleanup_buffs, tick_buff) -from .samplebuffs import (Exploit, Exploited, Leeching, Poison, Sated, # noqa - StatBuff) +from .buff import CmdBuff # noqa +from .buff import BaseBuff, BuffableProperty, BuffHandler, Mod, cleanup_buffs, tick_buff +from .samplebuffs import Exploit, Exploited, Leeching, Poison, Sated, StatBuff # noqa diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index e09a070125..d77d47dc9f 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -98,6 +98,7 @@ You can see all the features of the `BaseBuff` class below, or browse `samplebuf many attributes and hook methods you can overload to create complex, interrelated buffs. """ + import time from random import random diff --git a/evennia/contrib/rpg/buffs/tests.py b/evennia/contrib/rpg/buffs/tests.py index 091db9acb6..50dea4da23 100644 --- a/evennia/contrib/rpg/buffs/tests.py +++ b/evennia/contrib/rpg/buffs/tests.py @@ -1,6 +1,7 @@ """ Tests for the buff system contrib """ + from unittest.mock import Mock, call, patch from evennia import DefaultObject, create_object diff --git a/evennia/contrib/rpg/character_creator/character_creator.py b/evennia/contrib/rpg/character_creator/character_creator.py index 8d1db4b6cc..994093b427 100644 --- a/evennia/contrib/rpg/character_creator/character_creator.py +++ b/evennia/contrib/rpg/character_creator/character_creator.py @@ -15,6 +15,7 @@ and examples, including how to allow players to choose and confirm character names from within the menu. """ + import string from random import choices diff --git a/evennia/contrib/rpg/dice/dice.py b/evennia/contrib/rpg/dice/dice.py index 5551ca7f64..5eec8df025 100644 --- a/evennia/contrib/rpg/dice/dice.py +++ b/evennia/contrib/rpg/dice/dice.py @@ -57,6 +57,7 @@ of the roll separately: """ + import re from ast import literal_eval from random import randint diff --git a/evennia/contrib/rpg/rpsystem/rplanguage.py b/evennia/contrib/rpg/rpsystem/rplanguage.py index 4be5782acc..a9a5cddade 100644 --- a/evennia/contrib/rpg/rpsystem/rplanguage.py +++ b/evennia/contrib/rpg/rpsystem/rplanguage.py @@ -137,6 +137,7 @@ 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 diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index 22a3bffa3b..7cb9de81ab 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -148,19 +148,25 @@ Extra Installation Instructions: `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) +from evennia.utils.utils import ( + iter_to_str, + lazy_property, + make_iter, + variable_from_module, +) _INFLECT = inflect.engine() @@ -1277,6 +1283,8 @@ class RPSystemCmdSet(CmdSet): Mix-in for adding rp-commands to default cmdset. """ + key = "rpsystem_cmdset" + def at_cmdset_creation(self): self.add(CmdEmote()) self.add(CmdSay()) diff --git a/evennia/contrib/rpg/rpsystem/tests.py b/evennia/contrib/rpg/rpsystem/tests.py index 155af26672..df40eb367b 100644 --- a/evennia/contrib/rpg/rpsystem/tests.py +++ b/evennia/contrib/rpg/rpsystem/tests.py @@ -2,10 +2,13 @@ Tests for RP system """ + import time from anything import Anything + from evennia import DefaultObject, create_object, default_cmds +from evennia.commands.default import building from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.test_resources import BaseEvenniaTest @@ -413,10 +416,9 @@ class TestRPSystemCommands(BaseEvenniaCommandTest): expected_first_call = [ "More than one match for 'Mushroom' (please narrow target):", - f" Mushroom-1 []", - f" Mushroom-2 []", + f" Mushroom-1", + f" Mushroom-2", ] - self.call(default_cmds.CmdLook(), "Mushroom", "\n".join(expected_first_call)) # PASSES expected_second_call = f"Mushroom(#{mushroom1.id})\nThe first mushroom is brown." @@ -424,3 +426,13 @@ class TestRPSystemCommands(BaseEvenniaCommandTest): expected_third_call = f"Mushroom(#{mushroom2.id})\nThe second mushroom is red." self.call(default_cmds.CmdLook(), "Mushroom-2", expected_third_call) # FAILS + + expected_fourth_call = "Alias(es) for 'Mushroom' set to 'fungus'." + self.call(building.CmdSetObjAlias(), "Mushroom-1 = fungus", expected_fourth_call) # PASSES + + expected_fifth_call = [ + "More than one match for 'Mushroom' (please narrow target):", + f" Mushroom-1 [fungus]", + f" Mushroom-2", + ] + self.call(default_cmds.CmdLook(), "Mushroom", "\n".join(expected_fifth_call)) # PASSES diff --git a/evennia/contrib/rpg/traits/traits.py b/evennia/contrib/rpg/traits/traits.py index 1a2b8b99b7..fbcbeda34a 100644 --- a/evennia/contrib/rpg/traits/traits.py +++ b/evennia/contrib/rpg/traits/traits.py @@ -452,7 +452,6 @@ class Character(DefaultCharacter): """ - from functools import total_ordering from time import time diff --git a/evennia/contrib/tutorials/bodyfunctions/bodyfunctions.py b/evennia/contrib/tutorials/bodyfunctions/bodyfunctions.py index 009928da1f..90afb46f76 100644 --- a/evennia/contrib/tutorials/bodyfunctions/bodyfunctions.py +++ b/evennia/contrib/tutorials/bodyfunctions/bodyfunctions.py @@ -13,6 +13,7 @@ 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 diff --git a/evennia/contrib/tutorials/bodyfunctions/tests.py b/evennia/contrib/tutorials/bodyfunctions/tests.py index 2c7d6c46e6..564f7ab6e4 100644 --- a/evennia/contrib/tutorials/bodyfunctions/tests.py +++ b/evennia/contrib/tutorials/bodyfunctions/tests.py @@ -2,6 +2,7 @@ Tests for the bodyfunctions. """ + from mock import Mock, patch from evennia.utils.test_resources import BaseEvenniaTest diff --git a/evennia/contrib/tutorials/evadventure/ai.py b/evennia/contrib/tutorials/evadventure/ai.py index 90338e968c..6a65943a38 100644 --- a/evennia/contrib/tutorials/evadventure/ai.py +++ b/evennia/contrib/tutorials/evadventure/ai.py @@ -49,9 +49,9 @@ class AIHandler: def __init__(self, obj): self.obj = obj - self.ai_state = obj.attributes.get(self.attribute_name, - category=self.attribute_category, - default="idle") + self.ai_state = obj.attributes.get( + self.attribute_name, category=self.attribute_category, default="idle" + ) def set_state(self, state): self.ai_state = state @@ -122,6 +122,7 @@ class AIMixin: of multiple inheritance. In a real game, you would probably want to use a mixin like this. """ + @lazy_property def ai(self): return AIHandler(self) diff --git a/evennia/contrib/tutorials/evadventure/chargen.py b/evennia/contrib/tutorials/evadventure/chargen.py index f27012e4df..a1b429e3d1 100644 --- a/evennia/contrib/tutorials/evadventure/chargen.py +++ b/evennia/contrib/tutorials/evadventure/chargen.py @@ -2,6 +2,7 @@ EvAdventure character generation. """ + from django.conf import settings from evennia.objects.models import ObjectDB diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 14b7f0bb26..b67c0db028 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -18,7 +18,6 @@ action that takes several vulnerable turns to complete. """ - import random from collections import defaultdict @@ -843,5 +842,7 @@ class TurnCombatCmdSet(CmdSet): CmdSet for the turn-based combat. """ + key = "turncombat_cmdset" + def at_cmdset_creation(self): self.add(CmdTurnAttack()) diff --git a/evennia/contrib/tutorials/evadventure/combat_twitch.py b/evennia/contrib/tutorials/evadventure/combat_twitch.py index 787081fc28..d8a8d38b3f 100644 --- a/evennia/contrib/tutorials/evadventure/combat_twitch.py +++ b/evennia/contrib/tutorials/evadventure/combat_twitch.py @@ -6,6 +6,7 @@ This implements a 'twitch' (aka DIKU or other traditional muds) style of MUD com ---- """ + from evennia import AttributeProperty, CmdSet, default_cmds from evennia.commands.command import Command, InterruptCommand from evennia.utils.utils import ( @@ -560,6 +561,8 @@ class TwitchCombatCmdSet(CmdSet): Add to character, to be able to attack others in a twitch-style way. """ + key = "twitch_combat_cmdset" + def at_cmdset_creation(self): self.add(CmdAttack()) self.add(CmdHold()) @@ -573,5 +576,7 @@ class TwitchLookCmdSet(CmdSet): This will be added/removed dynamically when in combat. """ + key = "twitch_look_cmdset" + def at_cmdset_creation(self): self.add(CmdLook()) diff --git a/evennia/contrib/tutorials/evadventure/dungeon.py b/evennia/contrib/tutorials/evadventure/dungeon.py index d03da74576..6e56bf0562 100644 --- a/evennia/contrib/tutorials/evadventure/dungeon.py +++ b/evennia/contrib/tutorials/evadventure/dungeon.py @@ -105,10 +105,10 @@ class EvAdventureDungeonRoom(EvAdventureRoom): """ self.tags.add("not_clear", category="dungeon_room") + def clear_room(self): self.tags.remove("not_clear", category="dungeon_room") - @property def is_room_clear(self): return not bool(self.tags.get("not_clear", category="dungeon_room")) @@ -146,9 +146,7 @@ class EvAdventureDungeonExit(DefaultExit): dungeon_branch = self.location.db.dungeon_branch if target_location == self.location: # destination points back to us - create a new room - self.destination = target_location = dungeon_branch.new_room( - self - ) + self.destination = target_location = dungeon_branch.new_room(self) dungeon_branch.register_exit_traversed(self) super().at_traverse(traversing_object, target_location, **kwargs) diff --git a/evennia/contrib/tutorials/evadventure/enums.py b/evennia/contrib/tutorials/evadventure/enums.py index 6ba77d82f9..cb7d4636e5 100644 --- a/evennia/contrib/tutorials/evadventure/enums.py +++ b/evennia/contrib/tutorials/evadventure/enums.py @@ -17,6 +17,7 @@ To get the `value` of an enum (must always be hashable, useful for Attribute loo ---- """ + from enum import Enum diff --git a/evennia/contrib/tutorials/evadventure/npcs.py b/evennia/contrib/tutorials/evadventure/npcs.py index 755ce70940..2afd9f73a9 100644 --- a/evennia/contrib/tutorials/evadventure/npcs.py +++ b/evennia/contrib/tutorials/evadventure/npcs.py @@ -2,6 +2,7 @@ EvAdventure NPCs. This includes both friends and enemies, only separated by their AI. """ + from random import choice from evennia import DefaultCharacter @@ -253,6 +254,7 @@ class EvAdventureMob(EvAdventureNPC): Mob (mobile) NPC; this is usually an enemy. """ + # change this to make the mob more or less likely to perform different actions combat_probabilities = { "hold": 0.0, diff --git a/evennia/contrib/tutorials/evadventure/quests.py b/evennia/contrib/tutorials/evadventure/quests.py index be8d0a98cd..5e862ede29 100644 --- a/evennia/contrib/tutorials/evadventure/quests.py +++ b/evennia/contrib/tutorials/evadventure/quests.py @@ -2,21 +2,17 @@ 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. +`.quests` on a Character. Individual Quests are child classes of `EvAdventureQuest` with +methods for each step of the quest. The quest handler can add, remove, and track the progress +by calling the `progress` method on the quest. Persistent changes are stored on the quester +using the `add_data` and `get_data` methods with an Attribute as storage backend. A quest ending can mean a reward or the start of another quest. """ -from copy import copy, deepcopy - -from evennia.utils import dbserialize +from evennia import Command class EvAdventureQuest: @@ -40,15 +36,18 @@ class EvAdventureQuest: start_step = "A" - help_A = "You need a '_quest_A_flag' on yourself to finish this step!" + help_A = "You need a 'A_flag' attribute 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: + if self.get_data("A_flag") == True: self.quester.msg("Completed the first step of the quest.") self.current_step = "end" self.progress() + def step_B(self, *args, **kwargs): + + def step_end(self, *args, **kwargs): if len(self.quester.contents) > 4: self.quester.msg("Quest complete!") @@ -56,32 +55,58 @@ class EvAdventureQuest: ``` """ - key = "basequest" + key = "base quest" 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" - def __init__(self, quester, start_step=None): - if " " in self.key: - raise TypeError("The Quest name must not have spaces in it.") - + def __init__(self, quester): self.quester = quester - self._current_step = start_step or self.start_step - self.is_completed = False - self.is_abandoned = False + self.data = self.questhandler.load_quest_data(self.key) + self._current_step = self.get_data("current_step") - def __serialize_dbobjs__(self): - self.quester = dbserialize.dbserialize(self.quester) + if not self.current_step: + self.current_step = self.start_step - def __deserialize_dbobjs__(self): - if isinstance(self.quester, bytes): - self.quester = dbserialize.dbunserialize(self.quester) + def add_data(self, key, value): + """ + Add data to the quest. This saves it permanently. + + Args: + key (str): The key to store the data under. + value (any): The data to store. + + """ + self.data[key] = value + self.questhandler.save_quest_data(self.key) + + def get_data(self, key, default=None): + """ + Get data from the quest. + + Args: + key (str): The key to get data for. + default (any, optional): The default value to return if key is not found. + + Returns: + any: The data stored under the key. + + """ + return self.data.get(key, default) + + def remove_data(self, key): + """ + Remove data from the quest permanently. + + Args: + key (str): The key to remove. + + """ + self.data.pop(key, None) + self.questhandler.save_quest_data(self.key) @property def questhandler(self): @@ -94,23 +119,48 @@ class EvAdventureQuest: @current_step.setter def current_step(self, step_name): self._current_step = step_name - self.questhandler.do_save = True + self.add_data("current_step", step_name) - def abandon(self): - """ - Call when quest is abandoned. + @property + def status(self): + return self.get_data("status", "started") - """ - self.is_abandoned = True - self.cleanup() + @status.setter + def status(self, value): + self.add_data("status", value) + + @property + def is_completed(self): + return self.status == "completed" + + @property + def is_abandoned(self): + return self.status == "abandoned" + + @property + def is_failed(self): + return self.status == "failed" def complete(self): """ - Call this to end the quest. + Complete the quest. """ - self.is_completed = True - self.cleanup() + self.status = "completed" + + def abandon(self): + """ + Abandon the quest. + + """ + self.status = "abandoned" + + def fail(self): + """ + Fail the quest. + + """ + self.status = "failed" def progress(self, *args, **kwargs): """ @@ -121,31 +171,34 @@ class EvAdventureQuest: 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) + Notes: + `self.quester` is available as the character following the quest. - def help(self): + """ + getattr(self, f"step_{self.current_step}")(*args, **kwargs) + + def help(self, *args, **kwargs): """ This is used to get help (or a reminder) of what needs to be done to complete the current - quest-step. + quest-step. It will look for a `help_` method or string attribute on the quest. + + Args: + *args, **kwargs: Will be passed into any help_* method. 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 + if self.status in ("abandoned", "completed", "failed"): + help_resource = getattr( + self, f"help_{self.status}", f"You have {self.status} this quest." + ) + else: + help_resource = getattr(self, f"help_{self.current_step}", "No help available.") - help_resource = ( - getattr(self, f"help_{self.current_step}", None) - or "You need to {self.current_step} ..." - ) if callable(help_resource): - # the help_ can be a method to call - return help_resource() + # the help_* methods can be used to dynamically generate help + return help_resource(*args, **kwargs) else: # normally it's just a string return str(help_resource) @@ -162,8 +215,9 @@ class EvAdventureQuest: 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. + This is for cleaning up any extra state that were set during the quest (stuff in self.data + is automatically cleaned up) """ pass @@ -185,26 +239,31 @@ class EvAdventureQuestHandler: quest_storage_attribute_key = "_quests" quest_storage_attribute_category = "evadventure" + quest_data_attribute_template = "_quest_data_{quest_key}" + quest_data_attribute_category = "evadventure" + def __init__(self, obj): self.obj = obj - self.do_save = False + self.quests = {} + self.quest_classes = {} self._load() def _load(self): - self.storage = self.obj.attributes.get( + self.quest_classes = self.obj.attributes.get( self.quest_storage_attribute_key, category=self.quest_storage_attribute_category, default={}, ) + # instantiate all quests + for quest_key, quest_class in self.quest_classes.items(): + self.quests[quest_key] = quest_class(self.obj) def _save(self): self.obj.attributes.add( self.quest_storage_attribute_key, - self.storage, + self.quest_classes, category=self.quest_storage_attribute_category, ) - self._load() # important - self.do_save = False def has(self, quest_key): """ @@ -218,7 +277,7 @@ class EvAdventureQuestHandler: bool: If the character is following this quest or not. """ - return bool(self.storage.get(quest_key)) + return bool(self.quests.get(quest_key)) def get(self, quest_key): """ @@ -232,17 +291,28 @@ class EvAdventureQuestHandler: Character is not on this quest. """ - return self.storage.get(quest_key) + return self.quests.get(quest_key) - def add(self, quest): + def all(self): + """ + Get all quests stored on character. + + Returns: + list: All quests stored on character. + + """ + return list(self.quests.values()) + + def add(self, quest_class): """ Add a new quest Args: - quest (EvAdventureQuest): The quest class to start. + quest_class (EvAdventureQuest): The quest class to start. """ - self.storage[quest.key] = quest(self.obj) + self.quest_classes[quest_class.key] = quest_class + self.quests[quest_class.key] = quest_class(self.obj) self._save() def remove(self, quest_key): @@ -253,54 +323,79 @@ class EvAdventureQuestHandler: quest_key (str): The quest to remove. """ - quest = self.storage.pop(quest_key, None) + quest = self.quests.pop(quest_key, None) if not quest.is_completed: # make sure to cleanup quest.abandon() + self.quest_classes.pop(quest_key, None) + self.quests.pop(quest_key, None) self._save() - def get_help(self, quest_key=None): + def save_quest_data(self, quest_key): """ - 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. + Save data for a quest. We store this on the quester as well as updating the quest itself. Args: - quest_key (str, optional): The quest-key. If not given, get help for all - quests in handler. + quest_key (str): The quest to save data for. The data is assumed to be stored on the + quest as `.data` (a dict). + + """ + quest = self.get(quest_key) + if quest: + self.obj.attributes.add( + self.quest_data_attribute_template.format(quest_key=quest_key), + quest.data, + category=self.quest_data_attribute_category, + ) + + def load_quest_data(self, quest_key): + """ + Load data for a quest. + + Args: + quest_key (str): The quest to load data for. Returns: - list: Help texts, one for each quest, or only one if `quest_key` is given. + dict: The data stored for the quest. """ - help_texts = [] - if quest_key in self.storage: - quests = [self.storage[quest_key]] - else: - quests = self.storage.values() + return self.obj.attributes.get( + self.quest_data_attribute_template.format(quest_key=quest_key), + category=self.quest_data_attribute_category, + default={}, + ) + + +class CmdQuests(Command): + """ + List all quests and their statuses as well as get info about the status of + a specific quest. + + Usage: + quests + quest + + """ + + key = "quests" + aliases = ["quest"] + + def parse(self): + self.quest_name = self.args.strip() + + def func(self): + if self.quest_name: + quest = self.caller.quests.get(self.quest_name) + if not quest: + self.msg(f"Quest {self.quest_name} not found.") + return + self.msg(f"Quest {quest.key}: {quest.status}\n{quest.help()}") + return + + quests = self.caller.quests.all() + if not quests: + self.msg("No quests.") + return for quest in quests: - help_texts.append(f"|c{quest.key}|n\n {quest.desc}\n\n - {quest.help()}") - return help_texts - - 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() + self.msg(f"Quest {quest.key}: {quest.status}") diff --git a/evennia/contrib/tutorials/evadventure/rules.py b/evennia/contrib/tutorials/evadventure/rules.py index b0075b8e1f..9ad38f4f93 100644 --- a/evennia/contrib/tutorials/evadventure/rules.py +++ b/evennia/contrib/tutorials/evadventure/rules.py @@ -7,6 +7,7 @@ and determining what the outcome is. ---- """ + from random import randint from .enums import Ability diff --git a/evennia/contrib/tutorials/evadventure/tests/test_ai.py b/evennia/contrib/tutorials/evadventure/tests/test_ai.py index f96b4fb478..16fff27c98 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_ai.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_ai.py @@ -2,6 +2,7 @@ Test the ai module. """ + from unittest.mock import Mock, patch from evennia import create_object diff --git a/evennia/contrib/tutorials/evadventure/tests/test_equipment.py b/evennia/contrib/tutorials/evadventure/tests/test_equipment.py index 1dbe647887..58c53101e9 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_equipment.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_equipment.py @@ -3,7 +3,6 @@ Test the EvAdventure equipment handler. """ - from unittest.mock import MagicMock, patch from parameterized import parameterized diff --git a/evennia/contrib/tutorials/evadventure/tests/test_quests.py b/evennia/contrib/tutorials/evadventure/tests/test_quests.py index 5a65725044..675cf3d498 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_quests.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_quests.py @@ -99,52 +99,50 @@ class EvAdventureQuestTest(EvAdventureMixin, BaseEvenniaTest): 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."]) + quest = self._get_quest() + # get help for a specific quest + help_txt = quest.help() + self.assertEqual(help_txt, "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!"]) + quest.complete() + help_txt = quest.help() + self.assertEqual(help_txt, "You have completed this quest.") 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) + quest = self._get_quest() + # progress quest + quest.progress() # still on step A - self.assertEqual(self._get_quest().current_step, "A") + self.assertEqual(quest.current_step, "A") def test_progress(self): """ - Fulfill the quest steps in sequess + Fulfill the quest steps in sequence. """ + quest = self._get_quest() + # A requires a certain object in inventory self._fulfillA() - self.character.quests.progress() - self.assertEqual(self._get_quest().current_step, "B") + quest.progress() + self.assertEqual(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") + quest.progress() + self.assertEqual(quest.current_step, "B") # should step (kwarg sent) - self.character.quests.progress(complete_quest_B=True) - self.assertEqual(self._get_quest().current_step, "C") + quest.progress(complete_quest_B=True) + self.assertEqual(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) + quest.progress() + self.assertEqual(quest.current_step, "C") # still on last step + self.assertEqual(quest.is_completed, True) diff --git a/evennia/contrib/tutorials/evadventure/utils.py b/evennia/contrib/tutorials/evadventure/utils.py index 6fb12de138..25152c236f 100644 --- a/evennia/contrib/tutorials/evadventure/utils.py +++ b/evennia/contrib/tutorials/evadventure/utils.py @@ -17,7 +17,6 @@ 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. diff --git a/evennia/contrib/tutorials/red_button/red_button.py b/evennia/contrib/tutorials/red_button/red_button.py index 51ee69060f..b4c9f74f3a 100644 --- a/evennia/contrib/tutorials/red_button/red_button.py +++ b/evennia/contrib/tutorials/red_button/red_button.py @@ -31,6 +31,7 @@ Timers are handled by persistent delays on the button. These are examples of such as when closing the lid and un-blinding a character. """ + import random from evennia import CmdSet, Command, DefaultObject diff --git a/evennia/contrib/tutorials/talking_npc/tests.py b/evennia/contrib/tutorials/talking_npc/tests.py index 9a33eccad0..92bbb630b0 100644 --- a/evennia/contrib/tutorials/talking_npc/tests.py +++ b/evennia/contrib/tutorials/talking_npc/tests.py @@ -2,6 +2,7 @@ Tutorial - talking NPC tests. """ + from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object diff --git a/evennia/contrib/tutorials/tutorial_world/rooms.py b/evennia/contrib/tutorials/tutorial_world/rooms.py index 6d27118812..42c4f6fc2d 100644 --- a/evennia/contrib/tutorials/tutorial_world/rooms.py +++ b/evennia/contrib/tutorials/tutorial_world/rooms.py @@ -9,7 +9,6 @@ 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 diff --git a/evennia/contrib/utils/auditing/outputs.py b/evennia/contrib/utils/auditing/outputs.py index d6d4f504ef..cafae2b118 100644 --- a/evennia/contrib/utils/auditing/outputs.py +++ b/evennia/contrib/utils/auditing/outputs.py @@ -13,6 +13,7 @@ the easiest place to do it. Write a method and invoke it via Evennia contribution - Johnny 2017 """ + import json import syslog diff --git a/evennia/contrib/utils/auditing/server.py b/evennia/contrib/utils/auditing/server.py index 6646d07f32..2337b457c9 100644 --- a/evennia/contrib/utils/auditing/server.py +++ b/evennia/contrib/utils/auditing/server.py @@ -5,6 +5,7 @@ user inputs and system outputs. Evennia contribution - Johnny 2017 """ + import os import re import socket diff --git a/evennia/contrib/utils/fieldfill/fieldfill.py b/evennia/contrib/utils/fieldfill/fieldfill.py index 625002611d..b94c8e5499 100644 --- a/evennia/contrib/utils/fieldfill/fieldfill.py +++ b/evennia/contrib/utils/fieldfill/fieldfill.py @@ -138,6 +138,7 @@ Optional: 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 diff --git a/evennia/contrib/utils/random_string_generator/random_string_generator.py b/evennia/contrib/utils/random_string_generator/random_string_generator.py index 21fb68fe60..708064caf8 100644 --- a/evennia/contrib/utils/random_string_generator/random_string_generator.py +++ b/evennia/contrib/utils/random_string_generator/random_string_generator.py @@ -59,7 +59,6 @@ from evennia.utils.create import create_script class RejectedRegex(RuntimeError): - """The provided regular expression has been rejected. More details regarding why this error occurred will be provided in @@ -72,14 +71,12 @@ class RejectedRegex(RuntimeError): class ExhaustedGenerator(RuntimeError): - """The generator hasn't any available strings to generate anymore.""" pass class RandomStringGeneratorScript(DefaultScript): - """ The global script to hold all generators. @@ -99,7 +96,6 @@ class RandomStringGeneratorScript(DefaultScript): class RandomStringGenerator: - """ A generator class to generate pseudo-random strings with a rule. diff --git a/evennia/game_template/typeclasses/characters.py b/evennia/game_template/typeclasses/characters.py index c0cdc30f08..b022c1f293 100644 --- a/evennia/game_template/typeclasses/characters.py +++ b/evennia/game_template/typeclasses/characters.py @@ -7,6 +7,7 @@ is setup to be the "default" character type created by the default creation commands. """ + from evennia.objects.objects import DefaultCharacter from .objects import ObjectParent diff --git a/evennia/game_template/typeclasses/exits.py b/evennia/game_template/typeclasses/exits.py index 8ccd996b8b..3a53753c2e 100644 --- a/evennia/game_template/typeclasses/exits.py +++ b/evennia/game_template/typeclasses/exits.py @@ -6,6 +6,7 @@ set and has a single command defined on itself with the same name as its key, for allowing Characters to traverse the exit to its destination. """ + from evennia.objects.objects import DefaultExit from .objects import ObjectParent diff --git a/evennia/game_template/typeclasses/objects.py b/evennia/game_template/typeclasses/objects.py index b8aab3eeb6..11b7363505 100644 --- a/evennia/game_template/typeclasses/objects.py +++ b/evennia/game_template/typeclasses/objects.py @@ -10,6 +10,7 @@ the other types, you can do so by adding this as a multiple inheritance. """ + from evennia.objects.objects import DefaultObject diff --git a/evennia/game_template/web/admin/urls.py b/evennia/game_template/web/admin/urls.py index 196d574160..c42f8dc16d 100644 --- a/evennia/game_template/web/admin/urls.py +++ b/evennia/game_template/web/admin/urls.py @@ -6,7 +6,6 @@ The main web/urls.py includes these routes for all urls starting with `admin/` """ - from django.urls import path from evennia.web.admin.urls import urlpatterns as evennia_admin_urlpatterns diff --git a/evennia/game_template/web/urls.py b/evennia/game_template/web/urls.py index 673a10bf08..3f306400cd 100644 --- a/evennia/game_template/web/urls.py +++ b/evennia/game_template/web/urls.py @@ -12,6 +12,7 @@ should modify urls.py in those sub directories. Search the Django documentation for "URL dispatcher" for more help. """ + from django.urls import include, path # default evennia patterns diff --git a/evennia/help/filehelp.py b/evennia/help/filehelp.py index 3f8a598249..4c9cf3d9f1 100644 --- a/evennia/help/filehelp.py +++ b/evennia/help/filehelp.py @@ -227,7 +227,7 @@ class FileHelpStorageHandler: for dct in loaded_help_dicts: key = dct.get("key").lower().strip() - category = dct.get("category", _DEFAULT_HELP_CATEGORY).strip() + category = dct.get("category", _DEFAULT_HELP_CATEGORY).lower().strip() aliases = list(dct.get("aliases", [])) entrytext = dct.get("text", "") locks = dct.get("locks", "") diff --git a/evennia/help/manager.py b/evennia/help/manager.py index ff8af1e7c9..58773a5472 100644 --- a/evennia/help/manager.py +++ b/evennia/help/manager.py @@ -1,6 +1,7 @@ """ Custom manager for HelpEntry objects. """ + from django.db import IntegrityError from evennia.server import signals diff --git a/evennia/help/models.py b/evennia/help/models.py index c86cab61a8..e9bdcf9d33 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -9,6 +9,7 @@ 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 diff --git a/evennia/help/tests.py b/evennia/help/tests.py index 6989fddfb3..0f7f31f242 100644 --- a/evennia/help/tests.py +++ b/evennia/help/tests.py @@ -138,5 +138,5 @@ class TestFileHelp(TestCase): for inum, helpentry in enumerate(result): self.assertEqual(HELP_ENTRY_DICTS[inum]["key"], helpentry.key) self.assertEqual(HELP_ENTRY_DICTS[inum].get("aliases", []), helpentry.aliases) - self.assertEqual(HELP_ENTRY_DICTS[inum]["category"], helpentry.help_category) + self.assertEqual(HELP_ENTRY_DICTS[inum]["category"].lower(), helpentry.help_category) self.assertEqual(HELP_ENTRY_DICTS[inum]["text"], helpentry.entrytext) diff --git a/evennia/help/utils.py b/evennia/help/utils.py index 5ef3157ca9..716122accf 100644 --- a/evennia/help/utils.py +++ b/evennia/help/utils.py @@ -5,6 +5,7 @@ sub-categories. This is used primarily by the default `help` command. """ + import re from django.conf import settings diff --git a/evennia/locks/lockfuncs.py b/evennia/locks/lockfuncs.py index d477b5a1ca..4121107b22 100644 --- a/evennia/locks/lockfuncs.py +++ b/evennia/locks/lockfuncs.py @@ -13,7 +13,6 @@ a certain object type. """ - from ast import literal_eval from django.conf import settings diff --git a/evennia/objects/manager.py b/evennia/objects/manager.py index 7b5f51cb75..f962d7e55a 100644 --- a/evennia/objects/manager.py +++ b/evennia/objects/manager.py @@ -1,11 +1,13 @@ """ 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 ( diff --git a/evennia/objects/models.py b/evennia/objects/models.py index a282bafe0f..5d4bfd4acf 100644 --- a/evennia/objects/models.py +++ b/evennia/objects/models.py @@ -13,12 +13,14 @@ 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 diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 97b5849224..150c95f5d1 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -4,17 +4,17 @@ This module defines the basic `DefaultObject` and its children 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 _ + +import evennia from evennia.commands import cmdset from evennia.commands.cmdsethandler import CmdSetHandler from evennia.objects.manager import ObjectManager @@ -24,9 +24,17 @@ 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, compress_whitespace, dbref, - is_iter, iter_to_str, lazy_property, - make_iter, to_str, variable_from_module) +from evennia.utils.utils import ( + class_from_module, + compress_whitespace, + dbref, + is_iter, + iter_to_str, + lazy_property, + make_iter, + to_str, + variable_from_module, +) _INFLECT = inflect.engine() _MULTISESSION_MODE = settings.MULTISESSION_MODE @@ -54,7 +62,7 @@ class ObjectSessionHandler: Initializes the handler. Args: - obj (Object): The object on which the handler is defined. + obj (DefaultObject): The object on which the handler is defined. """ self.obj = obj @@ -81,7 +89,7 @@ class ObjectSessionHandler: sessid (int, optional): A specific session id. Returns: - sessions (list): The sessions connected to this object. If `sessid` is given, + list: The sessions connected to this object. If `sessid` is given, this is a list of one (or zero) elements. Notes: @@ -111,7 +119,7 @@ class ObjectSessionHandler: Alias to get(), returning all sessions. Returns: - sessions (list): All sessions. + list: All sessions. """ return self.get() @@ -146,7 +154,7 @@ class ObjectSessionHandler: Remove session from handler. Args: - session (Session or int): Session or session id to remove. + Session or int: Session or session id to remove. """ try: @@ -174,7 +182,7 @@ class ObjectSessionHandler: Get amount of sessions connected. Returns: - sesslen (int): Number of sessions handled. + int: Number of sessions handled. """ return len(self._sessid_cache) @@ -186,7 +194,7 @@ class ObjectSessionHandler: class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ - This is the root typeclass object, representing all entities that + This is the root Object typeclass, 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. @@ -223,23 +231,27 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): @lazy_property def cmdset(self): + """CmdSetHandler""" return CmdSetHandler(self, True) @lazy_property def scripts(self): + """ScriptHandler""" return ScriptHandler(self) @lazy_property def nicks(self): + """NickHandler""" return NickHandler(self, ModelAttributeBackend) @lazy_property def sessions(self): + """SessionHandler""" return ObjectSessionHandler(self) @property def is_connected(self): - # we get an error for objects subscribed to channels without this + """True if this object is associated with an Account with any connected sessions.""" if self.account: # seems sane to pass on the account return self.account.is_connected else: @@ -247,11 +259,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): @property def has_account(self): - """ - Convenience property for checking if an active account is - currently connected to this object. - - """ + """True is this object has an associated account.""" return self.sessions.count() def get_cmdset_providers(self) -> dict[str, "CmdSetProvider"]: @@ -272,10 +280,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): @property def is_superuser(self): - """ - Check if user has an account, and if so, if it is a superuser. - - """ + """True if this object has an account and that account is a superuser.""" return ( self.db_account and self.db_account.is_superuser @@ -289,23 +294,23 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): This should be publically available. Args: - exclude (Object): Object to exclude from returned + exclude (DefaultObject): 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. + list: List of contents of this Object. Notes: - Also available as the `contents` property, minus exclusion - and filtering. + Also available as the `.contents` property, but that doesn't allow for exclusion and + filtering on content-types. """ return self.contents_cache.get(exclude=exclude, content_type=content_type) def contents_set(self, *args): - "You cannot replace this property" + "Makes sure `.contents` is read-only. Raises `AttributeError` if trying to set it." raise AttributeError( "{}.contents is read-only. Use obj.move_to or " "obj.location to move an object here.".format(self.__class__) @@ -317,7 +322,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): def exits(self): """ Returns all exits from this object, i.e. all objects at this - location having the property destination != `None`. + location having the property .destination != `None`. """ return [exi for exi in self.contents if exi.destination] @@ -353,8 +358,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): **kwargs (any): These are the same as passed to the `search` method. Returns: - tuple: `(should_return, str or Obj)`, where `should_return` is a boolean indicating - the `.search` method should return the result immediately without further + tuple: A tuple `(should_return, str or Obj)`, where `should_return` is a boolean + indicating the `.search` method should return the result immediately without further processing. If `should_return` is `True`, the second element of the tuple is the result that is returned. @@ -369,15 +374,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): def get_search_candidates(self, searchdata, **kwargs): """ - Get the candidates for a search. Also the `candidates` provided to the - search function is included, and could be modified in-place here. + Helper for the `.search` method. Get the candidates for a search. Also the `candidates` + provided to the search function is included, and could be modified in-place here. Args: searchdata (str): The search criterion (could be modified by `get_search_query_replacement`). **kwargs (any): These are the same as passed to the `search` method. Returns: - list: A list of objects to search between. + list: A list of objects possibly relevant for the search. Notes: If `searchdata` is a #dbref, this method should always return `None`. This is because @@ -429,8 +434,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): **kwargs, ): """ - This is a wrapper for actually searching for objects, used by the `search` method. - This is broken out into a separate method to allow for easier overriding in child classes. + Helper for the `.search` method. This is a wrapper for actually searching for objects, used + by the `search` method. This is broken out into a separate method to allow for easier + overriding in child classes. Args: searchdata (str): The search criterion. @@ -441,6 +447,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): use_dbref (bool): Allow dbref search. tags (list): Tags to search for. + Returns: + queryset or iterable: The result of the search. + """ return ObjectDB.objects.search_object( @@ -462,10 +471,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): results (list): The list of results from the search. Returns: - tuple: `(stacked, results)`, where `stacked` is a boolean indicating if the - result is stacked and `results` is the list of results to return. If `stacked` - is True, the ".search" method will return `results` immediately without further - processing (it will not result in a multimatch-error). + tuple: A tuple `(stacked, results)`, where `stacked` is a boolean indicating if the + result is stacked and `results` is the list of results to return. If `stacked` + is True, the ".search" method will return `results` immediately without further + processing (it will not result in a multimatch-error). Notes: The `stacked` keyword argument is an integer that controls the max size of each stack @@ -699,16 +708,17 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + DefaultAccount, None or list: What is returned depends on + the `quiet` setting: + + - `quiet=False`: 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): @@ -741,15 +751,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + 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 @@ -768,28 +778,27 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ Emits something to a session attached to the object. - Args: - text (str or tuple, optional): The message to send. This + Keyword Args: + text (str or tuple): 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 + from_obj (DefaultObject, DefaultAccount, Session, or list): 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 + session (Session or list): 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 + options (dict): 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 + **kwargs (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. + The `at_msg_receive` method will be called on this Object. All extra kwargs will be passed on to the protocol. """ @@ -822,16 +831,6 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): for session in sessions: session.data_out(**kwargs) - def get_contents_unique(self, caller=None): - """ - Get a mapping of contents that are visually unique to the caller, along with - how many of each there are. - - Args: - caller (Object, optional): The object to check visibility from. If not given, - the current object will be used. - """ - def for_contents(self, func, exclude=None, **kwargs): """ Runs a function on every object contained within this one. @@ -908,11 +907,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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"` + 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: :: @@ -944,7 +944,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): you = from_obj or self if "you" not in mapping: - mapping[you] = you + mapping["you"] = you contents = self.contents if exclude: @@ -965,9 +965,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # director-stance replacements outmessage = outmessage.format_map( { - key: obj.get_display_name(looker=receiver) - if hasattr(obj, "get_display_name") - else str(obj) + key: ( + obj.get_display_name(looker=receiver) + if hasattr(obj, "get_display_name") + else str(obj) + ) for key, obj in mapping.items() } ) @@ -989,12 +991,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Moves this object to a new location. Args: - destination (Object): Reference to the object to move to. This + destination (DefaultObject): 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 + emit_to_obj (DefaultObject): 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. @@ -1009,31 +1011,28 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + **kwargs: 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`. + 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: + 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)` + 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)` """ @@ -1206,12 +1205,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Classmethod called during .create() to determine default locks for the object. Args: - account (Account): Account to attribute this object to. + account (DefaultAccount): 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. + 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 @@ -1239,15 +1238,17 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Keyword Args: - account (Account): Account to attribute this object to. + account (DefaultAccount): 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. + tuple: A tuple (Object, errors): A newly created object of the given typeclass. This + will be `None` if there are errors. The second element is then a list of errors that + occurred during creation. If this is empty, it's safe to assume the object was created + successfully. """ errors = [] @@ -1303,7 +1304,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): new_key (string): New key/name of copied object. If new_key is not specified, the copy will be named _copy by default. Returns: - copy (Object): A copy of this object. + Object: A copy of this object. """ @@ -1333,10 +1334,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): covered by .copy(), this can be used to deal with it. Args: - new_obj (Object): The new Copy of this object. + new_obj (DefaultObject): The new Copy of this object. - Returns: - None """ pass @@ -1347,8 +1346,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + bool: Whether or not the delete completed successfully or not. """ global _ScriptDB @@ -1400,14 +1398,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): in whatever way. Args: - accessing_obj (Object): Object trying to access this one. + accessing_obj (DefaultObject): 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. + **kwargs: Passed on to the at_access hook along with the result of the access check. """ result = super().access( @@ -1419,6 +1415,29 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): self.at_access(result, accessing_obj, access_type, **kwargs) return result + def filter_visible(self, obj_list, looker, **kwargs): + """ + Filter a list of objects to only include those that are visible to the looker. + + Args: + obj_list (list): List of objects to filter. + looker (DefaultObject): Object doing the looking. + **kwargs: Arbitrary data for use when overriding. + Returns: + list: The filtered list of visible objects. + Notes: + By default this simply checks the 'view' and 'search' locks on each object in the list. + Override this + method to implement custom visibility mechanics. + + """ + return [ + obj + for obj in obj_list + if obj != looker + and (obj.access(looker, "view") and obj.access(looker, "search", default=True)) + ] + # name and return_appearance hooks def get_display_name(self, looker=None, **kwargs): @@ -1426,7 +1445,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Displays the name of the object in a viewer-aware manner. Args: - looker (TypedObject): The object or account that is looking at or getting information + looker (DefaultObject): The object or account that is looking at or getting information for this object. Returns: @@ -1445,11 +1464,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): object's dbref in parentheses, if the looker has permission to see it. Args: - looker (Object): The object looking at this object. + looker (DefaultObject): The object looking at this object. Returns: str: The dbref of this object, if the looker has permission to see it. Otherwise, an - empty string is returned. + empty string is returned. Notes: By default, this becomes a string (#dbref) attached to the object's name. @@ -1469,22 +1488,28 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Args: count (int): Number of objects of this type - looker (Object): Onlooker. Not used by default. + looker (DefaultObject): Onlooker. Not used by default. Keyword Args: key (str): Optional key to pluralize. If not given, the object's `.get_display_name()` method is used. return_string (bool): If `True`, return only the singular form if count is 0,1 or the plural form otherwise. If `False` (default), return both forms as a tuple. + no_article (bool): If `True`, do not return an article if `count` is 1. Returns: tuple: This is a tuple `(str, str)` with the singular and plural forms of the key - including the count. + including the count. Examples: - :: - obj.get_numbered_name(3, looker, key="foo") -> ("a foo", "three foos") + :: + obj.get_numbered_name(3, looker, key="foo") + -> ("a foo", "three foos") + obj.get_numbered_name(1, looker, key="Foobert", return_string=True) + -> "a Foobert" + obj.get_numbered_name(1, looker, key="Foobert", return_string=True, no_article=True) + -> "Foobert" """ plural_category = "plural_key" key = kwargs.get("key", self.get_display_name(looker)) @@ -1505,8 +1530,13 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # look at 'an egg'. self.aliases.add(singular, category=plural_category) + if kwargs.get("no_article") and count == 1: + if kwargs.get("return_string"): + return key + return key, key + if kwargs.get("return_string"): - return singular if count==1 else plural + return singular if count == 1 else plural return singular, plural @@ -1515,7 +1545,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Get the 'header' component of the object description. Called by `return_appearance`. Args: - looker (Object): Object doing the looking. + looker (DefaultObject): Object doing the looking. **kwargs: Arbitrary data for use when overriding. Returns: str: The header display string. @@ -1528,7 +1558,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Get the 'desc' component of the object description. Called by `return_appearance`. Args: - looker (Object): Object doing the looking. + looker (DefaultObject): Object doing the looking. **kwargs: Arbitrary data for use when overriding. Returns: str: The desc display string. @@ -1541,18 +1571,36 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Get the 'exits' component of the object description. Called by `return_appearance`. Args: - looker (Object): Object doing the looking. + looker (DefaultObject): Object doing the looking. **kwargs: Arbitrary data for use when overriding. + + Keyword Args: + exit_order (iterable of str): The order in which exits should be listed, with + unspecified exits appearing at the end, alphabetically. + Returns: str: The exits display data. + Examples: + :: + + For a room with exits in the order 'portal', 'south', 'north', and 'out': + obj.get_display_name(looker, exit_order=('north', 'south')) + -> "Exits: north, south, out, and portal." (markup not shown here) """ + def _sort_exit_names(names): + exit_order = kwargs.get("exit_order") + if not exit_order: + return names + sort_index = {name: key for key, name in enumerate(exit_order)} + names = sorted(names) + end_pos = len(names) + 1 + names.sort(key=lambda name:sort_index.get(name, end_pos)) + return names - 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) + exits = self.filter_visible(self.contents_get(content_type="exit"), looker, **kwargs) + exit_names = (exi.get_display_name(looker, **kwargs) for exi in exits) + exit_names = iter_to_str(_sort_exit_names(exit_names)) return f"|wExits:|n {exit_names}" if exit_names else "" @@ -1561,17 +1609,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Get the 'characters' component of the object description. Called by `return_appearance`. Args: - looker (Object): Object doing the looking. + looker (DefaultObject): 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")) + characters = self.filter_visible( + self.contents_get(content_type="character"), looker, **kwargs + ) character_names = iter_to_str( char.get_display_name(looker, **kwargs) for char in characters ) @@ -1583,18 +1629,14 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Get the 'things' component of the object description. Called by `return_appearance`. Args: - looker (Object): Object doing the looking. + looker (DefaultObject): 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")) + things = self.filter_visible(self.contents_get(content_type="object"), looker, **kwargs) grouped_things = defaultdict(list) for thing in things: @@ -1614,7 +1656,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Get the 'footer' component of the object description. Called by `return_appearance`. Args: - looker (Object): Object doing the looking. + looker (DefaultObject): Object doing the looking. **kwargs: Arbitrary data for use when overriding. Returns: str: The footer display string. @@ -1628,7 +1670,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Args: appearance (str): The compiled appearance string. - looker (Object): Object doing the looking. + looker (DefaultObject): Object doing the looking. **kwargs: Arbitrary data for use when overriding. Returns: str: The final formatted output. @@ -1641,19 +1683,19 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + '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. + looker (DefaultObject): 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. + the entity's name, description and any contents inside it. Notes: To simply change the layout of how the object displays itself (like @@ -1834,7 +1876,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): have no cmdsets. Keyword Args: - caller (Object, Account or Session): The object requesting the cmdsets. + caller (DefaultObject, DefaultAccount 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. @@ -1847,7 +1889,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Called by the CommandHandler to get a list of cmdsets to merge. Args: - caller (obj): The object requesting the cmdsets. + caller (DefaultObject): The object requesting the cmdsets. current (cmdset): The current merged cmdset. **kwargs: Arbitrary input for overloads. @@ -1862,9 +1904,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): it. Args: - account (Account): This is the connecting account. + account (DefaultAccount): This is the connecting account. session (Session): Session controlling the connection. - **kwargs (dict): Arbitrary, optional arguments for users + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). """ @@ -1876,12 +1918,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Account<->Object links have been established. Args: - **kwargs (dict): Arbitrary, optional arguments for users + **kwargs: 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 + Notes: + 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. """ @@ -1894,9 +1935,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): this Account. Args: - **kwargs (dict): Arbitrary, optional arguments for users + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). - Note: + Notes: 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 @@ -1911,12 +1952,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): this object, severing all connections. Args: - account (Account): The account object that just disconnected + account (DefaultAccount): 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 + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). """ @@ -1951,9 +1992,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + **kwargs: Arbitrary, optional arguments. Unused by default. """ pass @@ -1966,19 +2005,19 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): destination. Return False to abort move. Args: - destination (Object): The object we are moving to + destination (DefaultObject): 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 + **kwargs: 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 + If this method returns `False` or `None`, the move is cancelled before it is even started. """ @@ -1990,14 +2029,16 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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 + leaving_object (DefaultObject): The object that is about to leave. + destination (DefaultObject): Where object is going to. + **kwargs: 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 + Notes: + + If this method returns `False` or `None`, the move is canceled before it even started. """ @@ -2010,16 +2051,18 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): remains where it was. Args: - arriving_object (Object): The object moved into this one - source_location (Object): Where `moved_object` came from. + arriving_object (DefaultObject): The object moved into this one + source_location (DefaultObject): Where `moved_object` came from. Note that this could be `None`. - **kwargs (dict): Arbitrary, optional arguments for users + **kwargs: 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 + Notes: + + If this method returns `False` or `None`, the move is canceled before it even started. """ @@ -2035,23 +2078,26 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): location. Args: - destination (Object): The place we are going to. + destination (DefaultObject): 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 + **kwargs: 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. + 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: + + - `{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: @@ -2087,24 +2133,27 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): we are standing in the new location. Args: - source_location (Object): The place we came from + source_location (DefaultObject): 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 + **kwargs: 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. + + + - `{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. """ @@ -2158,12 +2207,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): now in. Args: - source_location (Object): Where we came from. This may be `None`. + source_location (DefaultObject): 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 + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). """ @@ -2177,13 +2226,13 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + moved_obj (DefaultObject): The object leaving + target_location (DefaultObject): 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 + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). """ @@ -2194,14 +2243,14 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + moved_obj (DefaultObject): The object moved into this one + source_location (DefaultObject): 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 + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). """ @@ -2218,9 +2267,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): called. Args: - traversing_object (Object): Object traversing us. - target_location (Object): Where target is going. - **kwargs (dict): Arbitrary, optional arguments for users + traversing_object (DefaultObject): Object traversing us. + target_location (DefaultObject): Where target is going. + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). """ @@ -2233,8 +2282,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Exit) Args: - traversing_object (Object): The object traversing us. - source_location (Object): Where `traversing_object` came from. + traversing_object (DefaultObject): The object traversing us. + source_location (DefaultObject): Where `traversing_object` came from. **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). @@ -2252,8 +2301,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): some reason. Args: - traversing_object (Object): The object that failed traversing us. - **kwargs (dict): Arbitrary, optional arguments for users + traversing_object (DefaultObject): The object that failed traversing us. + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). Notes: @@ -2280,9 +2329,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + **kwargs: This includes any keywords sent to the `msg` method. Returns: receive (bool): If this message should be received. @@ -2302,9 +2349,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Args: text (str, optional): Text to send. to_obj (any, optional): The object to send to. - - Keyword Args: - Keywords passed from msg() + **kwargs: Keywords passed from msg(). Notes: Since this method is executed by `from_obj`, if no `from_obj` @@ -2318,51 +2363,48 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): def get_visible_contents(self, looker, **kwargs): """ + DEPRECATED 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. + checks the 'view' and 'search' locks and excludes the looker themselves), grouped by type. + Helper method to return_appearance. Args: - looker (Object): The entity looking. - **kwargs (any): Passed from `return_appearance`. Unused by default. + looker (DefaultObject): The entity looking. + **kwargs: 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. + 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) - ] + def _filter_visible(obj_list): + return [obj for obj in self.filter_visible(obj_list, looker, **kwargs) if obj != looker] 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")), + "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")), } def get_content_names(self, looker, **kwargs): """ + DEPRECATED 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 + looker (DefaultObject): The entity looking. + **kwargs: 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. + 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 @@ -2400,17 +2442,16 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): send any data. Args: - target (Object): The target being looked at. This is + target (DefaultObject): 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 + **kwargs: 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. + str: A ready-processed look string potentially ready to return to the looker. """ if not target.access(self, "view"): @@ -2433,7 +2474,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): Args: looker (Object, optional): The object requesting the description. - **kwargs (dict): Arbitrary, optional arguments for users + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). """ @@ -2445,12 +2486,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): picked up. Args: - getter (Object): The object about to get this object. - **kwargs (dict): Arbitrary, optional arguments for users + getter (DefaultObject): The object about to get this object. + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). Returns: - shouldget (bool): If the object should be gotten or not. + bool: If the object should be gotten or not. Notes: If this method returns False/None, the getting is cancelled @@ -2467,8 +2508,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): picked up. Args: - getter (Object): The object getting this object. - **kwargs (dict): Arbitrary, optional arguments for users + getter (DefaultObject): The object getting this object. + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). Notes: @@ -2484,16 +2525,16 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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 + giver (DefaultObject): The object about to give this object. + getter (DefaultObject): The object about to get this object. + **kwargs: 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 + If this method returns `False` or `None`, the giving is cancelled before it is even started. """ @@ -2508,9 +2549,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): given. Args: - giver (Object): The object giving this object. - getter (Object): The object getting this object. - **kwargs (dict): Arbitrary, optional arguments for users + giver (DefaultObject): The object giving this object. + getter (DefaultObject): The object getting this object. + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). Notes: @@ -2526,15 +2567,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): dropped. Args: - dropper (Object): The object which will drop this object. - **kwargs (dict): Arbitrary, optional arguments for users + dropper (DefaultObject): The object which will drop this object. + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). Returns: - shoulddrop (bool): If the object should be dropped or not. + bool: If the object should be dropped or not. Notes: - If this method returns False/None, the dropping is cancelled + If this method returns `False` or `None`, the dropping is cancelled before it is even started. """ @@ -2555,8 +2596,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): dropped. Args: - dropper (Object): The object which just dropped this object. - **kwargs (dict): Arbitrary, optional arguments for users + dropper (DefaultObject): The object which just dropped this object. + **kwargs: Arbitrary, optional arguments for users overriding the call (unused by default). Notes: @@ -2582,11 +2623,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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 + receivers (DefaultObject or iterable): If set, this is the target or targets for the say/whisper. Returns: - message (str): The (possibly modified) text to be spoken. + str: The (possibly modified) text to be spoken. """ return message @@ -2617,7 +2658,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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 + receivers (DefaultObject 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. @@ -2631,20 +2672,22 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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. + + - {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" @@ -2673,9 +2716,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): "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, + "all_receivers": ( + ", ".join(recv.get_display_name(self) for recv in receivers) + if receivers + else None + ), "speech": message, } self_mapping.update(custom_mapping) @@ -2695,9 +2740,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): "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, + "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) @@ -2760,12 +2807,13 @@ class DefaultCharacter(DefaultObject): Classmethod called during .create() to determine default locks for the object. Args: - account (Account): Account to attribute this object to. + account (DefaultAccount): 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. + str: A lockstring to use for this object. + """ pid = f"pid({account.id})" if account else None character = kwargs.get("character", None) @@ -2877,16 +2925,24 @@ class DefaultCharacter(DefaultObject): @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. + Normalize the character name prior to creating. Args: name (str) : The name of the character Returns: - latin_name (str) : A valid name. + str : A valid, latinized name. + + Notes: + + The main purpose of this is to make sure that character names are not created with + special unicode characters that look visually identical to latin charaters. This could + be used to impersonate other characters. + + This method should be refactored to support i18n for non-latin names, 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 default option. + """ from evennia.utils.utils import latinify @@ -2901,10 +2957,10 @@ class DefaultCharacter(DefaultObject): Args: name (str) : The name of the character - Kwargs: + Keyword Args: account (DefaultAccount, optional) : The account creating the character. Returns: - error (str, optional) : A non-empty error message if there is a problem, otherwise False. + str or None: A non-empty error message if there is a problem, otherwise `None`. """ if account and cls.objects.filter_family(db_key__iexact=name): @@ -2949,7 +3005,7 @@ class DefaultCharacter(DefaultObject): """ Return the character from storage in None location in `at_post_unpuppet`. Args: - account (Account): This is the connecting account. + account (DefaultAccount): This is the connecting account. session (Session): Session controlling the connection. """ @@ -2976,7 +3032,8 @@ class DefaultCharacter(DefaultObject): Args: **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). - Note: + Notes: + 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 @@ -3001,15 +3058,16 @@ class DefaultCharacter(DefaultObject): after the account logged off ("headless", so to say). Args: - account (Account): The account object that just disconnected + account (DefaultAccount): 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 + **kwargs: 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. @@ -3095,8 +3153,9 @@ class DefaultRoom(DefaultObject): 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. + tuple: A tuple `(Object, error)` with the newly created Room of the given typeclass, + or `None` if there was an error. If there was an error, `error` will be a list of + error strings. """ errors = [] @@ -3148,8 +3207,7 @@ class DefaultRoom(DefaultObject): def basetype_setup(self): """ - Simple room setup setting locks to make sure the room - cannot be picked up. + Simple room setup setting locks to make sure the room cannot be picked up. """ @@ -3197,13 +3255,13 @@ class ExitCommand(_COMMAND_DEFAULT_CLASS): Shows a bit of information on where the exit leads. Args: - caller (Object): The object (usually a character) that entered an ambiguous command. + caller (DefaultObject): 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. + str: A string with identifying information to disambiguate the command, conventionally + with a preceding space. """ if self.obj.destination: @@ -3245,7 +3303,7 @@ class DefaultExit(DefaultObject): exit's name, triggering the movement between rooms. Args: - exidbobj (Object): The DefaultExit object to base the command on. + exidbobj (DefaultObject): The DefaultExit object to base the command on. """ @@ -3295,15 +3353,16 @@ class DefaultExit(DefaultObject): 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. + account (DefaultAccountDB): Account to associate this Exit with. + caller (DefaultObject): 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. + tuple: A tuple `(Object, errors)`, where the object is the newly + created exit of the given typeclass, or `None` if there was an error. + If there was an error, `errors` will be a list of error strings. """ errors = [] @@ -3357,7 +3416,7 @@ class DefaultExit(DefaultObject): def basetype_setup(self): """ - Setup exit-security + Setup exit-security. You should normally not need to overload this - if you do make sure you include all the functionality in this method. @@ -3391,7 +3450,7 @@ class DefaultExit(DefaultObject): has no cmdsets. Keyword Args: - caller (Object, Account or Session): The object requesting the cmdsets. + caller (DefaultObject, DefaultAccount 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). @@ -3407,6 +3466,7 @@ class DefaultExit(DefaultObject): 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() @@ -3416,8 +3476,8 @@ class DefaultExit(DefaultObject): already been checked (in the Exit command) at this point. Args: - traversing_object (Object): Object traversing us. - target_location (Object): Where target is going. + traversing_object (DefaultObject): Object traversing us. + target_location (DefaultObject): Where target is going. **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). @@ -3438,7 +3498,7 @@ class DefaultExit(DefaultObject): Overloads the default hook to implement a simple default error message. Args: - traversing_object (Object): The object that failed traversing us. + traversing_object (DefaultObject): The object that failed traversing us. **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). @@ -3457,10 +3517,12 @@ class DefaultExit(DefaultObject): Args: return_all (bool): Whether to return available results as a - list or single matching exit. + queryset or single matching exit. Returns: - queryset or exit (Exit): The matching exit(s). + Exit or queryset: The matching exit(s). If `return_all` is `True`, this + will be a queryset of all matching exits. Otherwise, it will be the first Exit matched. + """ query = ObjectDB.objects.filter(db_location=self.destination, db_destination=self.location) if return_all: diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index a77b348d02..5c300865dc 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -3,13 +3,10 @@ from unittest import skip from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom from evennia.objects.models import ObjectDB from evennia.typeclasses.attributes import AttributeProperty -from evennia.typeclasses.tags import ( - AliasProperty, - PermissionProperty, - TagCategoryProperty, - TagProperty, -) +from evennia.typeclasses.tags import (AliasProperty, PermissionProperty, + TagCategoryProperty, TagProperty) from evennia.utils import create, search +from evennia.utils.ansi import strip_ansi from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTestCase @@ -94,6 +91,21 @@ class DefaultObjectTest(BaseEvenniaTest): all_return_exit = ex1.get_return_exit(return_all=True) self.assertEqual(len(all_return_exit), 2) + def test_exit_order(self): + DefaultExit.create("south", self.room1, self.room2, account=self.account) + DefaultExit.create("portal", self.room1, self.room2, account=self.account) + DefaultExit.create("north", self.room1, self.room2, account=self.account) + DefaultExit.create("aperture", self.room1, self.room2, account=self.account) + + # in creation order + exits = strip_ansi(self.room1.get_display_exits(self.char1)) + self.assertEqual(exits, "Exits: out, south, portal, north, and aperture") + + # in specified order with unspecified exits alpbabetically on the end + exit_order = ('north', 'south', 'out') + exits = strip_ansi(self.room1.get_display_exits(self.char1, exit_order=exit_order)) + self.assertEqual(exits, "Exits: north, south, out, aperture, and portal") + def test_urls(self): "Make sure objects are returning URLs" self.assertTrue(self.char1.get_absolute_url()) @@ -185,6 +197,12 @@ class DefaultObjectTest(BaseEvenniaTest): pattern, ) + def test_get_name_without_article(self): + self.assertEqual(self.obj1.get_numbered_name(1, self.char1, return_string=True), "an Obj") + self.assertEqual( + self.obj1.get_numbered_name(1, self.char1, return_string=True, no_article=True), "Obj" + ) + class TestObjectManager(BaseEvenniaTest): "Test object manager methods" @@ -350,6 +368,10 @@ class TestObjectPropertiesClass(DefaultObject): attr2 = AttributeProperty(default="attr2", category="attrcategory") attr3 = AttributeProperty(default="attr3", autocreate=False) attr4 = SubAttributeProperty(default="attr4") + attr5 = AttributeProperty(default=list, autocreate=False) + attr6 = AttributeProperty(default=[None], autocreate=False) + attr7 = AttributeProperty(default=list) + attr8 = AttributeProperty(default=[None]) cusattr = CustomizedProperty(default=5) tag1 = TagProperty() tag2 = TagProperty(category="tagcategory") @@ -535,3 +557,99 @@ class TestProperties(EvenniaTestCase): obj1.delete() obj2.delete() + + def test_not_create_attribute_with_autocreate_false(self): + """ + Test that AttributeProperty with autocreate=False does not create an attribute in the database. + + """ + obj = create.create_object(TestObjectPropertiesClass, key="obj1") + + self.assertEqual(obj.attr3, "attr3") + self.assertEqual(obj.attributes.get("attr3"), None) + + self.assertEqual(obj.attr5, []) + self.assertEqual(obj.attributes.get("attr5"), None) + + obj.delete() + + def test_callable_defaults__autocreate_false(self): + """ + Test https://github.com/evennia/evennia/issues/3488, where a callable default value like `list` + would produce an infinitely empty result even when appended to. + + """ + obj1 = create.create_object(TestObjectPropertiesClass, key="obj1") + obj2 = create.create_object(TestObjectPropertiesClass, key="obj2") + + self.assertEqual(obj1.attr5, []) + obj1.attr5.append(1) + self.assertEqual(obj1.attr5, [1]) + + # check cross-instance sharing + self.assertEqual(obj2.attr5, [], "cross-instance sharing detected") + + + def test_mutable_defaults__autocreate_false(self): + """ + Test https://github.com/evennia/evennia/issues/3488, where a mutable default value (like a + list `[]` or `[None]`) would not be updated in the database when appended to. + + Note that using a mutable default value is not recommended, as the mutable will share the + same memory space across all instances of the class. This means that if one instance modifiesA + the mutable, all instances will be affected. + + """ + obj1 = create.create_object(TestObjectPropertiesClass, key="obj1") + obj2 = create.create_object(TestObjectPropertiesClass, key="obj2") + + self.assertEqual(obj1.attr6, [None]) + obj1.attr6.append(1) + self.assertEqual(obj1.attr6, [None, 1]) + + obj1.attr6[1] = 2 + self.assertEqual(obj1.attr6, [None, 2]) + + # check cross-instance sharing + self.assertEqual(obj2.attr6, [None], "cross-instance sharing detected") + + obj1.delete() + obj2.delete() + + def test_callable_defaults__autocreate_true(self): + """ + Test callables with autocreate=True. + + """ + obj1 = create.create_object(TestObjectPropertiesClass, key="obj1") + obj2 = create.create_object(TestObjectPropertiesClass, key="obj1") + + self.assertEqual(obj1.attr7, []) + obj1.attr7.append(1) + self.assertEqual(obj1.attr7, [1]) + + # check cross-instance sharing + self.assertEqual(obj2.attr7, []) + + + def test_mutable_defaults__autocreate_true(self): + """ + Test mutable defaults with autocreate=True. + + """ + obj1 = create.create_object(TestObjectPropertiesClass, key="obj1") + obj2 = create.create_object(TestObjectPropertiesClass, key="obj2") + + self.assertEqual(obj1.attr8, [None]) + obj1.attr8.append(1) + self.assertEqual(obj1.attr8, [None, 1]) + + obj1.attr8[1] = 2 + self.assertEqual(obj1.attr8, [None, 2]) + + # check cross-instance sharing + self.assertEqual(obj2.attr8, [None]) + + obj1.delete() + obj2.delete() + diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 20a39bf4ac..b77651a6db 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -132,7 +132,6 @@ prototype, override its name with an empty dict. """ - import copy import hashlib import time diff --git a/evennia/scripts/models.py b/evennia/scripts/models.py index 6bff24c571..e2f98dea4c 100644 --- a/evennia/scripts/models.py +++ b/evennia/scripts/models.py @@ -24,6 +24,7 @@ Common examples of uses of Scripts: - 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 diff --git a/evennia/scripts/monitorhandler.py b/evennia/scripts/monitorhandler.py index 78ad71e77d..6789d3b55f 100644 --- a/evennia/scripts/monitorhandler.py +++ b/evennia/scripts/monitorhandler.py @@ -10,6 +10,7 @@ functionality: an action whenever that Attribute *changes* for whatever reason. """ + import inspect from collections import defaultdict diff --git a/evennia/scripts/scripthandler.py b/evennia/scripts/scripthandler.py index 8b8eb4bb45..720dd6e3a6 100644 --- a/evennia/scripts/scripthandler.py +++ b/evennia/scripts/scripthandler.py @@ -5,6 +5,7 @@ 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 diff --git a/evennia/scripts/taskhandler.py b/evennia/scripts/taskhandler.py index b47a7f8166..a6539fb1ad 100644 --- a/evennia/scripts/taskhandler.py +++ b/evennia/scripts/taskhandler.py @@ -205,7 +205,6 @@ class TaskHandlerTask: class TaskHandler: - """A light singleton wrapper allowing to access permanent tasks. When `utils.delay` is called, the task handler is used to create diff --git a/evennia/scripts/tests.py b/evennia/scripts/tests.py index 6d251eed54..8bf6983e21 100644 --- a/evennia/scripts/tests.py +++ b/evennia/scripts/tests.py @@ -6,6 +6,8 @@ Unit tests for the scripts package from collections import defaultdict from unittest import TestCase, mock +from parameterized import parameterized + from evennia import DefaultScript from evennia.objects.objects import DefaultObject from evennia.scripts.manager import ScriptDBManager @@ -17,7 +19,6 @@ from evennia.scripts.tickerhandler import TickerHandler from evennia.utils.create import create_script from evennia.utils.dbserialize import dbserialize from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTest -from parameterized import parameterized class TestScript(BaseEvenniaTest): diff --git a/evennia/scripts/tickerhandler.py b/evennia/scripts/tickerhandler.py index 7dd702cca3..3968cee69a 100644 --- a/evennia/scripts/tickerhandler.py +++ b/evennia/scripts/tickerhandler.py @@ -65,6 +65,7 @@ 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 diff --git a/evennia/server/connection_wizard.py b/evennia/server/connection_wizard.py index 2b17c9786b..440a884153 100644 --- a/evennia/server/connection_wizard.py +++ b/evennia/server/connection_wizard.py @@ -2,6 +2,7 @@ Link Evennia to external resources (wizard plugin for evennia_launcher) """ + import pprint import sys from os import path diff --git a/evennia/server/deprecations.py b/evennia/server/deprecations.py index 643f02afdc..a93f075cd9 100644 --- a/evennia/server/deprecations.py +++ b/evennia/server/deprecations.py @@ -4,6 +4,7 @@ checks for. These all print to the terminal. """ + import os diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 60ed76ab03..7578da9e52 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -642,6 +642,8 @@ def send_instruction(operation, arguments, callback=None, errback=None): """ global AMP_CONNECTION, REACTOR_RUN + # print("launcher: Sending to portal: {} + {}".format(ord(operation), arguments)) + if None in (AMP_HOST, AMP_PORT, AMP_INTERFACE): print(ERROR_AMP_UNCONFIGURED) sys.exit() diff --git a/evennia/server/game_index_client/client.py b/evennia/server/game_index_client/client.py index 02b9241379..c39c336f70 100644 --- a/evennia/server/game_index_client/client.py +++ b/evennia/server/game_index_client/client.py @@ -2,6 +2,7 @@ The client for sending data to the Evennia Game Index """ + import platform import urllib.error import urllib.parse diff --git a/evennia/server/game_index_client/service.py b/evennia/server/game_index_client/service.py index f4a9899c28..ff5edc1bbd 100644 --- a/evennia/server/game_index_client/service.py +++ b/evennia/server/game_index_client/service.py @@ -2,6 +2,7 @@ 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 diff --git a/evennia/server/initial_setup.py b/evennia/server/initial_setup.py index e886718a5e..4785e98f12 100644 --- a/evennia/server/initial_setup.py +++ b/evennia/server/initial_setup.py @@ -6,7 +6,6 @@ Limbo room). It will also hooks, and then perform an initial restart. Everything starts at handle_setup() """ - import time from django.conf import settings diff --git a/evennia/server/manager.py b/evennia/server/manager.py index 237c40df55..7424fa71b1 100644 --- a/evennia/server/manager.py +++ b/evennia/server/manager.py @@ -1,6 +1,7 @@ """ Custom manager for ServerConfig objects. """ + from django.db import models diff --git a/evennia/server/models.py b/evennia/server/models.py index 8a1b8e58b6..f945409ada 100644 --- a/evennia/server/models.py +++ b/evennia/server/models.py @@ -8,6 +8,7 @@ Config values should usually be set through the manager's conf() method. """ + from django.db import models from evennia.server.manager import ServerConfigManager diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index c9953346a1..007bddd7cb 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -4,6 +4,7 @@ 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 @@ -36,7 +37,6 @@ def getenv(): class AMPServerFactory(protocol.ServerFactory): - """ This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the 'Server' process. @@ -197,8 +197,6 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): 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 def wait_for_disconnect(self, callback, *args, **kwargs): @@ -232,11 +230,18 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ if mode == "reload": - self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRELOAD) + self.send_AdminPortal2Server( + amp.DUMMYSESSION, operation=amp.SRELOAD, server_restart_mode=mode + ) elif mode == "reset": - self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRESET) + self.send_AdminPortal2Server( + amp.DUMMYSESSION, operation=amp.SRESET, server_restart_mode=mode + ) elif mode == "shutdown": - self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SSHUTD) + self.send_AdminPortal2Server( + amp.DUMMYSESSION, operation=amp.SSHUTD, server_restart_mode=mode + ) + # store the mode for use once server comes back up again self.factory.portal.server_restart_mode = mode # sending amp data @@ -326,7 +331,6 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): _, 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 @@ -405,11 +409,11 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): 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 + # logger.log_msg(f"Evennia Server->Portal admin data operation {ord(operation)}") + if operation == amp.SLOGIN: # server_session_login # a session has authenticated; sync it. session = portal_sessionhandler.get(sessid) @@ -427,22 +431,28 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason")) elif operation == amp.SRELOAD: # server reload + # set up callback to restart server once it has disconnected self.factory.server_connection.wait_for_disconnect( self.start_server, self.factory.portal.server_twistd_cmd ) + # tell server to reload self.stop_server(mode="reload") elif operation == amp.SRESET: # server reset + # set up callback to restart server once it has disconnected self.factory.server_connection.wait_for_disconnect( self.start_server, self.factory.portal.server_twistd_cmd ) + # tell server to reset 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 + # set up callback to shut down portal once server has disconnected self.factory.server_connection.wait_for_disconnect(self.factory.portal.shutdown) + # tell server to shut down self.stop_server(mode="shutdown") elif operation == amp.PSYNC: # portal sync @@ -451,6 +461,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): 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 + # print("Server has connected. Sending session data to Server ... mode: {}".format(server_restart_mode)) sessdata = evennia.PORTAL_SESSION_HANDLER.get_all_sync_data() self.send_AdminPortal2Server( @@ -461,6 +472,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): portal_start_time=self.factory.portal.start_time, ) evennia.PORTAL_SESSION_HANDLER.at_server_connection() + self.factory.portal.server_restart_mode = None if self.factory.server_connection: # this is an indication the server has successfully connected, so @@ -480,7 +492,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): ) # set a flag in case we are about to shut down soon - self.factory.server_restart_mode = True + self.factory.server_restart_mode = "shutdown" elif operation == amp.SCONN: # server_force_connection (for irc/etc) portal_sessionhandler.server_connect(**kwargs) diff --git a/evennia/server/portal/discord.py b/evennia/server/portal/discord.py index e38c08aea9..0e03ac6481 100644 --- a/evennia/server/portal/discord.py +++ b/evennia/server/portal/discord.py @@ -8,6 +8,7 @@ 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 diff --git a/evennia/server/portal/mccp.py b/evennia/server/portal/mccp.py index 43390b19bc..ba748e0010 100644 --- a/evennia/server/portal/mccp.py +++ b/evennia/server/portal/mccp.py @@ -14,6 +14,7 @@ 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 diff --git a/evennia/server/portal/mssp.py b/evennia/server/portal/mssp.py index 3f57566945..03714cf9da 100644 --- a/evennia/server/portal/mssp.py +++ b/evennia/server/portal/mssp.py @@ -10,6 +10,7 @@ active players and so on. """ + from django.conf import settings from evennia.utils import utils diff --git a/evennia/server/portal/mxp.py b/evennia/server/portal/mxp.py index af3e5d1e0d..a445168c97 100644 --- a/evennia/server/portal/mxp.py +++ b/evennia/server/portal/mxp.py @@ -13,6 +13,7 @@ http://www.mushclient.com/mushclient/mxp.htm http://www.gammon.com.au/mushclient/addingservermxp.htm """ + import re from django.conf import settings diff --git a/evennia/server/portal/naws.py b/evennia/server/portal/naws.py index ab7e892712..b4498e71ea 100644 --- a/evennia/server/portal/naws.py +++ b/evennia/server/portal/naws.py @@ -9,6 +9,7 @@ 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 diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index b1bf532cf1..99a9e904ff 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -7,6 +7,7 @@ sets up all the networking features. (this is done automatically by game/evennia.py). """ + import os import sys diff --git a/evennia/server/portal/portalsessionhandler.py b/evennia/server/portal/portalsessionhandler.py index 8a5ed53ce2..cb118893c1 100644 --- a/evennia/server/portal/portalsessionhandler.py +++ b/evennia/server/portal/portalsessionhandler.py @@ -3,7 +3,6 @@ Sessionhandler for portal sessions. """ - import time from collections import deque, namedtuple diff --git a/evennia/server/portal/rss.py b/evennia/server/portal/rss.py index fdd5b32d2b..1499a89f17 100644 --- a/evennia/server/portal/rss.py +++ b/evennia/server/portal/rss.py @@ -5,6 +5,7 @@ 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 diff --git a/evennia/server/portal/ssh.py b/evennia/server/portal/ssh.py index 0a8e9637e8..294275f198 100644 --- a/evennia/server/portal/ssh.py +++ b/evennia/server/portal/ssh.py @@ -34,9 +34,6 @@ 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 @@ -46,6 +43,10 @@ from twisted.conch.ssh.userauth import SSHUserAuthServer from twisted.internet import defer, protocol from twisted.python import components +from evennia.accounts.models import AccountDB +from evennia.utils import ansi +from evennia.utils.utils import class_from_module, to_str + _RE_N = re.compile(r"\|n$") _RE_SCREENREADER_REGEX = re.compile( r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE diff --git a/evennia/server/portal/ssl.py b/evennia/server/portal/ssl.py index 032f48d401..0fca77cb69 100644 --- a/evennia/server/portal/ssl.py +++ b/evennia/server/portal/ssl.py @@ -3,6 +3,7 @@ This is a simple context factory for auto-creating SSL keys and certificates. """ + import os import sys diff --git a/evennia/server/portal/suppress_ga.py b/evennia/server/portal/suppress_ga.py index 694f09b73a..cadf007fa9 100644 --- a/evennia/server/portal/suppress_ga.py +++ b/evennia/server/portal/suppress_ga.py @@ -39,9 +39,9 @@ class SuppressGA: 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) + 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) diff --git a/evennia/server/portal/telnet_oob.py b/evennia/server/portal/telnet_oob.py index 38012e9170..6fe68ff7a1 100644 --- a/evennia/server/portal/telnet_oob.py +++ b/evennia/server/portal/telnet_oob.py @@ -23,6 +23,7 @@ This implements the following telnet OOB communication protocols: ---- """ + import json import re diff --git a/evennia/server/portal/telnet_ssl.py b/evennia/server/portal/telnet_ssl.py index 46717c0a61..6bf6a0597d 100644 --- a/evennia/server/portal/telnet_ssl.py +++ b/evennia/server/portal/telnet_ssl.py @@ -7,6 +7,7 @@ when starting and will warn if this was not possible. These will appear as files ssl.cert in mygame/server/. """ + import os try: diff --git a/evennia/server/portal/webclient.py b/evennia/server/portal/webclient.py index b74c3eb361..1f35d473ee 100644 --- a/evennia/server/portal/webclient.py +++ b/evennia/server/portal/webclient.py @@ -14,6 +14,7 @@ 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 diff --git a/evennia/server/portal/webclient_ajax.py b/evennia/server/portal/webclient_ajax.py index b3fd54357f..2c43017c57 100644 --- a/evennia/server/portal/webclient_ajax.py +++ b/evennia/server/portal/webclient_ajax.py @@ -17,6 +17,7 @@ http://localhost:4001/webclient.) to sessions connected over the webclient. """ + import html import json import re diff --git a/evennia/server/profiling/dummyrunner.py b/evennia/server/profiling/dummyrunner.py index da7c9f43b2..00d3ea9391 100644 --- a/evennia/server/profiling/dummyrunner.py +++ b/evennia/server/profiling/dummyrunner.py @@ -31,7 +31,6 @@ for instructions on how to define this module. """ - import random import sys import time diff --git a/evennia/server/profiling/dummyrunner_settings.py b/evennia/server/profiling/dummyrunner_settings.py index 1dc63b0410..aa70874f50 100644 --- a/evennia/server/profiling/dummyrunner_settings.py +++ b/evennia/server/profiling/dummyrunner_settings.py @@ -57,6 +57,7 @@ commands (such as creating an account and logging in). ---- """ + import random import string diff --git a/evennia/server/server.py b/evennia/server/server.py index 88adad8a6d..3d22808266 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -7,6 +7,7 @@ the networking features. (this is done automatically by evennia/server/server_runner.py). """ + import os import sys diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 5177f30f5d..e1573b37c0 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -6,6 +6,7 @@ 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 diff --git a/evennia/server/service.py b/evennia/server/service.py index bf4a14d598..c1c57e5d56 100644 --- a/evennia/server/service.py +++ b/evennia/server/service.py @@ -2,6 +2,7 @@ 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 @@ -330,12 +331,13 @@ class EvenniaServerService(MultiService): (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, + 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( diff --git a/evennia/server/session.py b/evennia/server/session.py index c70c75234d..8c1a518796 100644 --- a/evennia/server/session.py +++ b/evennia/server/session.py @@ -3,6 +3,7 @@ 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 diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 697e1aeb56..3d3f1c6e6f 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -12,6 +12,7 @@ There are two similar but separate stores of sessions: handle network communication but holds no game info. """ + import time from codecs import decode as codecs_decode diff --git a/evennia/server/signals.py b/evennia/server/signals.py index 7c3f89221c..170d93b9a1 100644 --- a/evennia/server/signals.py +++ b/evennia/server/signals.py @@ -20,6 +20,7 @@ This is used on top of hooks to make certain features easier to add to contribs without necessitating a full takeover of hooks that may be in high demand. """ + from collections import defaultdict from django.dispatch import Signal diff --git a/evennia/server/tests/test_server.py b/evennia/server/tests/test_server.py index f1625f7d9e..c6518dd438 100644 --- a/evennia/server/tests/test_server.py +++ b/evennia/server/tests/test_server.py @@ -2,6 +2,7 @@ Test the main server component """ + from unittest import TestCase from django.test import override_settings @@ -25,13 +26,15 @@ class TestServer(TestCase): @override_settings(IDMAPPER_CACHE_MAXSIZE=1000) def test__server_maintenance_reset(self): - with patch.object(self.server, "_flush_cache", new=MagicMock()) as mockflush, patch.object( - evennia, "ServerConfig", new=MagicMock() - ) as mockconf, patch.multiple( - "evennia.server.service", - LoopingCall=DEFAULT, - connection=DEFAULT, - ) as mocks: + with ( + patch.object(self.server, "_flush_cache", new=MagicMock()) as mockflush, + patch.object(evennia, "ServerConfig", new=MagicMock()) as mockconf, + patch.multiple( + "evennia.server.service", + LoopingCall=DEFAULT, + connection=DEFAULT, + ) as mocks, + ): self.server.maintenance_count = 0 mocks["connection"].close = MagicMock() @@ -43,15 +46,15 @@ class TestServer(TestCase): @override_settings(IDMAPPER_CACHE_MAXSIZE=1000) def test__server_maintenance_flush(self): - with patch.multiple( - "evennia.server.service", - LoopingCall=DEFAULT, - connection=DEFAULT, - ) as mocks, patch.object( - evennia, "ServerConfig", new=MagicMock() - ) as mockconf, patch.object( - self.server, "_flush_cache", new=MagicMock() - ) as mockflush: + with ( + patch.multiple( + "evennia.server.service", + LoopingCall=DEFAULT, + connection=DEFAULT, + ) as mocks, + patch.object(evennia, "ServerConfig", new=MagicMock()) as mockconf, + patch.object(self.server, "_flush_cache", new=MagicMock()) as mockflush, + ): mocks["connection"].close = MagicMock() mockconf.objects.conf = MagicMock(return_value=100) self.server.maintenance_count = 5 - 1 @@ -61,11 +64,14 @@ class TestServer(TestCase): @override_settings(IDMAPPER_CACHE_MAXSIZE=1000) def test__server_maintenance_close_connection(self): - with patch.multiple( - "evennia.server.service", - LoopingCall=DEFAULT, - connection=DEFAULT, - ) as mocks, patch.object(evennia, "ServerConfig", new=MagicMock()) as mockconf: + with ( + patch.multiple( + "evennia.server.service", + LoopingCall=DEFAULT, + connection=DEFAULT, + ) as mocks, + patch.object(evennia, "ServerConfig", new=MagicMock()) as mockconf, + ): self.server._flush_cache = MagicMock() self.server.maintenance_count = (60 * 7) - 1 self.server._last_server_time_snapshot = 0 @@ -76,16 +82,16 @@ class TestServer(TestCase): @override_settings(IDLE_TIMEOUT=10) def test__server_maintenance_idle_time(self): - with patch.multiple( - "evennia.server.service", - LoopingCall=DEFAULT, - connection=DEFAULT, - time=DEFAULT, - ) as mocks, patch.object( - evennia, "ServerConfig", new=MagicMock() - ) as mockconf, patch.object( - evennia, "SESSION_HANDLER", new=MagicMock() - ) as mocksess: + with ( + patch.multiple( + "evennia.server.service", + LoopingCall=DEFAULT, + connection=DEFAULT, + time=DEFAULT, + ) as mocks, + patch.object(evennia, "ServerConfig", new=MagicMock()) as mockconf, + patch.object(evennia, "SESSION_HANDLER", new=MagicMock()) as mocksess, + ): self.server.maintenance_count = (3600 * 7) - 1 self.server._last_server_time_snapshot = 0 sess1 = MagicMock() @@ -114,13 +120,13 @@ class TestServer(TestCase): mocksess.disconnect.assert_has_calls(calls, any_order=True) def test_update_defaults(self): - with patch.object(evennia, "ObjectDB", new=MagicMock()) as mockobj, patch.object( - evennia, "AccountDB", new=MagicMock() - ) as mockacc, patch.object(evennia, "ScriptDB", new=MagicMock()) as mockscr, patch.object( - evennia, "ChannelDB", new=MagicMock() - ) as mockchan, patch.object( - evennia, "ServerConfig", new=MagicMock() - ) as mockconf: + with ( + patch.object(evennia, "ObjectDB", new=MagicMock()) as mockobj, + patch.object(evennia, "AccountDB", new=MagicMock()) as mockacc, + patch.object(evennia, "ScriptDB", new=MagicMock()) as mockscr, + patch.object(evennia, "ChannelDB", new=MagicMock()) as mockchan, + patch.object(evennia, "ServerConfig", new=MagicMock()) as mockconf, + ): for m in (mockscr, mockobj, mockacc, mockchan): m.objects.filter = MagicMock() @@ -220,9 +226,10 @@ class TestInitHooks(TestCase): @override_settings(TEST_ENVIRONMENT=True) def test_run_init_hooks(self): - with patch.object( - self.server, "at_server_reload_start", new=MagicMock() - ) as reload, patch.object(self.server, "at_server_cold_start", new=MagicMock()) as cold: + with ( + patch.object(self.server, "at_server_reload_start", new=MagicMock()) as reload, + patch.object(self.server, "at_server_cold_start", new=MagicMock()) as cold, + ): self.server.run_init_hooks("reload") self.server.run_init_hooks("reset") self.server.run_init_hooks("shutdown") diff --git a/evennia/server/tests/testrunner.py b/evennia/server/tests/testrunner.py index 9652c5a9d0..31aefb7666 100644 --- a/evennia/server/tests/testrunner.py +++ b/evennia/server/tests/testrunner.py @@ -5,6 +5,7 @@ all over the code base and runs them. Runs as part of the Evennia's test suite with 'evennia test evennia" """ + from django.test.runner import DiscoverRunner diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index 31e05bd0a9..db48e459d4 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -12,6 +12,7 @@ a great example/aid on how to do this.) """ + import urllib.parse from urllib.parse import quote as urlquote diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 2d53d524cc..50a27ce1b5 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -12,6 +12,7 @@ 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 diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index ac274614b0..bdef3bdc33 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -8,14 +8,15 @@ which is a non-db version of Attributes. """ + import fnmatch import re from collections import defaultdict +from copy import copy 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 @@ -165,6 +166,7 @@ class AttributeProperty: """ attrhandler_name = "attributes" + cached_default_name_template = "_property_attribute_default_{key}" def __init__(self, default=None, category=None, strattr=False, lockstring="", autocreate=True): """ @@ -206,21 +208,6 @@ class AttributeProperty: 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 @@ -229,17 +216,35 @@ class AttributeProperty: """ self._key = name + def _get_and_cache_default(self, instance): + """ + Get and cache the default value for this attribute. We make sure to convert any mutables + into _Saver* equivalent classes here and cache the result on the instance's AttributeHandler. + + """ + attrhandler = getattr(instance, self.attrhandler_name) + value = getattr(attrhandler, self.cached_default_name_template.format(key=self._key), None) + if not value: + if callable(self._default): + value = self._default() + else: + value = copy(self._default) + value = from_pickle(value, db_obj=instance) + setattr(attrhandler, self.cached_default_name_template.format(key=self._key), value) + return value + def __get__(self, instance, owner): """ Called when the attrkey is retrieved from the instance. """ - value = self._default + value = self._get_and_cache_default(instance) + try: value = self.at_get( getattr(instance, self.attrhandler_name).get( key=self._key, - default=self._default, + default=value, category=self._category, strattr=self._strattr, raise_exception=self._autocreate, @@ -249,7 +254,7 @@ class AttributeProperty: except AttributeError: if self._autocreate: # attribute didn't exist and autocreate is set - self.__set__(instance, self._default) + self.__set__(instance, value) else: raise return value diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index fee012eaed..7a48e94866 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -4,6 +4,7 @@ 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 diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 9758b1f8ce..8f39c66227 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -25,6 +25,7 @@ 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 diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index a44ed99332..b396419a4b 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -9,6 +9,7 @@ used for storing Aliases and Permissions. This module contains the respective handlers. """ + from collections import defaultdict from django.conf import settings diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 2c07ccd7f1..7bb6643e5f 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -61,6 +61,7 @@ Xterm256 greyscale: ---- """ + import functools import re from collections import OrderedDict diff --git a/evennia/utils/batchprocessors.py b/evennia/utils/batchprocessors.py index b8c0289bdb..d59ce339a3 100644 --- a/evennia/utils/batchprocessors.py +++ b/evennia/utils/batchprocessors.py @@ -167,6 +167,7 @@ made available in the script namespace. script = create.create_script() """ + import codecs import re import sys diff --git a/evennia/utils/containers.py b/evennia/utils/containers.py index 70b7b43f7d..26e4f0ca69 100644 --- a/evennia/utils/containers.py +++ b/evennia/utils/containers.py @@ -10,7 +10,6 @@ evennia.OPTION_CLASSES """ - from pickle import dumps from django.conf import settings diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py index 2d59f9b803..cc79eed89e 100644 --- a/evennia/utils/dbserialize.py +++ b/evennia/utils/dbserialize.py @@ -18,6 +18,7 @@ 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 diff --git a/evennia/utils/eveditor.py b/evennia/utils/eveditor.py index 2df8574368..9101fdcac6 100644 --- a/evennia/utils/eveditor.py +++ b/evennia/utils/eveditor.py @@ -39,6 +39,7 @@ 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 @@ -88,7 +89,7 @@ _HELP_TEXT = _( :y - yank (copy) line(s) to the copy buffer :x - cut line(s) and store it in the copy buffer - :p - put (paste) previously copied line(s) directly after + :p - put (paste) previously copied line(s) directly before :i - insert new text at line . Old line will move down :r - replace line with text :I - insert text at the beginning of line @@ -97,7 +98,7 @@ _HELP_TEXT = _( :s - search/replace word or regex in buffer or on line :j - justify buffer or line . is f, c, l or r. Default f (full) - :f - flood-fill entire buffer or line : Equivalent to :j left + :f - flood-fill entire buffer or line . Equivalent to :j l :fi - indent entire buffer or line :fd - de-indent entire buffer or line @@ -305,12 +306,13 @@ class CmdEditorBase(_COMMAND_DEFAULT_CLASS): 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 + lstart = min(max(1, int(part1)), nlines) - 1 if utils.value_is_integer(part1) else 0 + lend = ( + min(max(lstart + 1, int(part2)), nlines) + if utils.value_is_integer(part2) + else nlines + ) + linerange = True elif arglist and arglist[0].isdigit(): lstart = min(max(0, int(arglist[0]) - 1), nlines) lend = lstart + 1 @@ -349,6 +351,35 @@ class CmdEditorBase(_COMMAND_DEFAULT_CLASS): self.arg1 = arg1 self.arg2 = arg2 + def insert_raw_string_into_buffer(self): + """ + Insert a line into the buffer. Used by both CmdLineInput and CmdEditorGroup. + + """ + 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(line))) + def _load_editor(caller): """ @@ -392,29 +423,7 @@ class CmdLineInput(CmdEditorBase): 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))) + self.insert_raw_string_into_buffer() class CmdEditorGroup(CmdEditorBase): @@ -471,7 +480,8 @@ class CmdEditorGroup(CmdEditorBase): linebuffer = self.linebuffer lstart, lend = self.lstart, self.lend - cmd = self.cmdstring + # preserve the cmdname including case (otherwise uu and UU would be the same) + cmd = self.raw_string[: len(self.cmdstring)] echo_mode = self.editor._echo_mode if cmd == ":": @@ -798,6 +808,9 @@ class CmdEditorGroup(CmdEditorBase): caller.msg(_("Auto-indentation turned off.")) else: caller.msg(_("This command is only available in code editor mode.")) + else: + # no match - insert as line in buffer + self.insert_raw_string_into_buffer() class EvEditorCmdSet(CmdSet): diff --git a/evennia/utils/funcparser.py b/evennia/utils/funcparser.py index 9417767467..2c434badcf 100644 --- a/evennia/utils/funcparser.py +++ b/evennia/utils/funcparser.py @@ -43,6 +43,7 @@ The `FuncParser` also accepts a direct dict mapping of `{'name': callable, ...}` --- """ + import dataclasses import inspect import random diff --git a/evennia/utils/idmapper/manager.py b/evennia/utils/idmapper/manager.py index c60c6d89a7..7e6f0fa31c 100644 --- a/evennia/utils/idmapper/manager.py +++ b/evennia/utils/idmapper/manager.py @@ -1,6 +1,7 @@ """ IDmapper extension to the default manager. """ + from django.db.models.manager import Manager diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index 19b5e1214d..b0b4ab9604 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -13,7 +13,6 @@ log_typemsg(). This is for historical, back-compatible reasons. """ - import os import time from datetime import datetime diff --git a/evennia/utils/test_resources.py b/evennia/utils/test_resources.py index 44802a8325..9aee64842a 100644 --- a/evennia/utils/test_resources.py +++ b/evennia/utils/test_resources.py @@ -22,25 +22,32 @@ Other: 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 mock import MagicMock, Mock, patch +from twisted.internet.defer import Deferred + +import evennia 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.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) diff --git a/evennia/utils/tests/test_eveditor.py b/evennia/utils/tests/test_eveditor.py index b7333d6810..7fc50861c5 100644 --- a/evennia/utils/tests/test_eveditor.py +++ b/evennia/utils/tests/test_eveditor.py @@ -8,19 +8,79 @@ from evennia.utils import eveditor class TestEvEditor(BaseEvenniaCommandTest): + def test_eveditor_ranges(self): + eveditor.EvEditor(self.char1) + self.call( + eveditor.CmdEditorGroup(), + "", + raw_string=":", + msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)", + ) + self.call(eveditor.CmdLineInput(), "line 1", raw_string="line 1", msg="01line 1") + self.call(eveditor.CmdLineInput(), "line 2", raw_string="line 2", msg="02line 2") + self.call(eveditor.CmdLineInput(), "line 3", raw_string="line 3", msg="03line 3") + self.call(eveditor.CmdLineInput(), "line 4", raw_string="line 4", msg="04line 4") + self.call(eveditor.CmdLineInput(), "line 5", raw_string="line 5", msg="05line 5") + self.call( + eveditor.CmdEditorGroup(), + "", # list whole buffer + raw_string=":", + msg="Line Editor []\n01line 1\n02line 2\n" + "03line 3\n04line 4\n05line 5\n" + "[l:05 w:010 c:0034](:h for help)", + ) + self.call( + eveditor.CmdEditorGroup(), + ":", # list empty range + raw_string=":", + msg="Line Editor []\n01line 1\n02line 2\n" + "03line 3\n04line 4\n05line 5\n" + "[l:05 w:010 c:0034](:h for help)", + ) + self.call( + eveditor.CmdEditorGroup(), + ":4", # list from start to line 4 + raw_string=":", + msg="Line Editor []\n01line 1\n02line 2\n" + "03line 3\n04line 4\n" + "[l:04 w:008 c:0027](:h for help)", + ) + self.call( + eveditor.CmdEditorGroup(), + "2:", # list from line 2 to end + raw_string=":", + msg="Line Editor []\n02line 2\n03line 3\n" + "04line 4\n05line 5\n" + "[l:04 w:008 c:0027](:h for help)", + ) + self.call( + eveditor.CmdEditorGroup(), + "-10:10", # try to list invalid range (too large) + raw_string=":", + msg="Line Editor []\n01line 1\n02line 2\n" + "03line 3\n04line 4\n05line 5\n" + "[l:05 w:010 c:0034](:h for help)", + ) + self.call( + eveditor.CmdEditorGroup(), + "3:1", # try to list invalid range (reversed) + raw_string=":", + msg="Line Editor []\n03line 3\n" "[l:01 w:002 c:0006](:h for help)", + ) + def test_eveditor_view_cmd(self): eveditor.EvEditor(self.char1) self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":h", + raw_string=":h", msg=" - any non-command is appended to the end of the buffer.", ) # empty buffer self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)", ) # input a string @@ -41,49 +101,49 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", # view buffer + raw_string=":", # view buffer msg="Line Editor []\n01First test line\n" "02Second test line\n[l:02 w:006 c:0032](:h for help)", ) self.call( eveditor.CmdEditorGroup(), "", - cmdstring="::", # view buffer, no linenums + raw_string="::", # view buffer, no linenums msg="Line Editor []\nFirst test line\n" "Second test line\n[l:02 w:006 c:0032](:h for help)", ) self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":::", # add single : alone on row + raw_string=":::", # add single : alone on row msg="Single ':' added to buffer.", ) self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01First test line\n" "02Second test line\n03:\n[l:03 w:007 c:0034](:h for help)", ) self.call( - eveditor.CmdEditorGroup(), "", cmdstring=":dd", msg="Deleted line 3." # delete line + eveditor.CmdEditorGroup(), "", raw_string=":dd", msg="Deleted line 3." # delete line ) self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "First test line\nSecond test line") - self.call(eveditor.CmdEditorGroup(), "", cmdstring=":u", msg="Undid one step.") # undo + self.call(eveditor.CmdEditorGroup(), "", raw_string=":u", msg="Undid one step.") # undo self.assertEqual( self.char1.ndb._eveditor.get_buffer(), "First test line\nSecond test line\n:" ) - self.call(eveditor.CmdEditorGroup(), "", cmdstring=":uu", msg="Redid one step.") # redo + self.call(eveditor.CmdEditorGroup(), "", raw_string=":uu", msg="Redid one step.") # redo self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "First test line\nSecond test line") - self.call(eveditor.CmdEditorGroup(), "", cmdstring=":u", msg="Undid one step.") # undo + self.call(eveditor.CmdEditorGroup(), "", raw_string=":u", msg="Undid one step.") # undo self.assertEqual( self.char1.ndb._eveditor.get_buffer(), "First test line\nSecond test line\n:" ) self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01First test line\n" "02Second test line\n03:\n[l:03 w:007 c:0034](:h for help)", ) @@ -91,31 +151,31 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "Second", - cmdstring=":dw", # delete by word + raw_string=":dw", # delete by word msg="Removed Second for lines 1-4.", ) - self.call(eveditor.CmdEditorGroup(), "", cmdstring=":u", msg="Undid one step.") # undo + self.call(eveditor.CmdEditorGroup(), "", raw_string=":u", msg="Undid one step.") # undo self.call( eveditor.CmdEditorGroup(), "2 Second", - cmdstring=":dw", # delete by word/line + raw_string=":dw", # delete by word/line msg="Removed Second for line 2.", ) self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "First test line\n test line\n:") self.call( - eveditor.CmdEditorGroup(), "2", cmdstring=":p", msg="Copy buffer is empty." # paste + eveditor.CmdEditorGroup(), "2", raw_string=":p", msg="Copy buffer is empty." # paste ) self.call( eveditor.CmdEditorGroup(), "2", - cmdstring=":y", # yank + raw_string=":y", # yank msg="Line 2, [' test line'] yanked.", ) self.call( eveditor.CmdEditorGroup(), "2", - cmdstring=":p", # paste + raw_string=":p", # paste msg="Pasted buffer [' test line'] to line 2.", ) self.assertEqual( @@ -123,31 +183,34 @@ class TestEvEditor(BaseEvenniaCommandTest): ) self.call( - eveditor.CmdEditorGroup(), "3", cmdstring=":x", msg="Line 3, [' test line'] cut." # cut + eveditor.CmdEditorGroup(), + "3", + raw_string=":x", + msg="Line 3, [' test line'] cut.", # cut ) self.call( eveditor.CmdEditorGroup(), "2 New Second line", - cmdstring=":i", # insert + raw_string=":i", # insert msg="Inserted 1 new line(s) at line 2.", ) self.call( eveditor.CmdEditorGroup(), "2 New Replaced Second line", # replace - cmdstring=":r", + raw_string=":r", msg="Replaced 1 line(s) at line 2.", ) self.call( eveditor.CmdEditorGroup(), "2 Inserted-", # insert beginning line - cmdstring=":I", + raw_string=":I", msg="Inserted text at beginning of line 2.", ) self.call( eveditor.CmdEditorGroup(), "2 -End", # append end line - cmdstring=":A", + raw_string=":A", msg="Appended text to end of line 2.", ) @@ -156,25 +219,32 @@ class TestEvEditor(BaseEvenniaCommandTest): "First test line\nInserted-New Replaced Second line-End\n test line\n:", ) + self.call( + eveditor.CmdLineInput(), + " Whitespace echo test line.", + raw_string=" Whitespace echo test line.", + msg="05 Whitespace echo test line.", + ) + def test_eveditor_COLON_UU(self): eveditor.EvEditor(self.char1) self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)", ) self.call( eveditor.CmdLineInput(), 'First test "line".', raw_string='First test "line".', - msg='01First test "line" .', + msg='01First test "line".', ) self.call( eveditor.CmdLineInput(), "Second 'line'.", raw_string="Second 'line'.", - msg="02Second 'line' .", + msg="02Second 'line'.", ) self.assertEqual( self.char1.ndb._eveditor.get_buffer(), "First test \"line\".\nSecond 'line'." @@ -182,7 +252,7 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":UU", + raw_string=":UU", msg="Reverted all changes to the buffer back to original state.", ) self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "") @@ -192,7 +262,7 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)", ) self.call(eveditor.CmdLineInput(), "line 1.", raw_string="line 1.", msg="01line 1.") @@ -201,20 +271,20 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "2:3", - cmdstring=":", + raw_string=":", msg="Line Editor []\n02line 2.\n03line 3.\n[l:02 w:004 c:0015](:h for help)", ) self.call( eveditor.CmdEditorGroup(), "1:2 line LINE", - cmdstring=":s", + raw_string=":s", msg="Search-replaced line -> LINE for lines 1-2.", ) self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "LINE 1.\nLINE 2.\nline 3.") self.call( eveditor.CmdEditorGroup(), "line MINE", - cmdstring=":s", + raw_string=":s", msg="Search-replaced line -> MINE for lines 1-3.", ) self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "LINE 1.\nLINE 2.\nMINE 3.") @@ -224,14 +294,14 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)", ) self.call(eveditor.CmdLineInput(), "line 1.", raw_string="line 1.", msg="01line 1.") self.call(eveditor.CmdLineInput(), "line 2.", raw_string="line 2.", msg="02line 2.") self.call(eveditor.CmdLineInput(), "line 3.", raw_string="line 3.", msg="03line 3.") self.call( - eveditor.CmdEditorGroup(), "", cmdstring=":DD", msg="Cleared 3 lines from buffer." + eveditor.CmdEditorGroup(), "", raw_string=":DD", msg="Cleared 3 lines from buffer." ) self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "") @@ -240,11 +310,11 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)", ) self.call(eveditor.CmdLineInput(), "line 1", raw_string="line 1", msg="01line 1") - self.call(eveditor.CmdEditorGroup(), "1:2", cmdstring=":f", msg="Flood filled lines 1-2.") + self.call(eveditor.CmdEditorGroup(), "1:2", raw_string=":f", msg="Flood filled line 1.") self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "line 1") def test_eveditor_COLON_J(self): @@ -252,16 +322,16 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)", ) self.call(eveditor.CmdLineInput(), "line 1", raw_string="line 1", msg="01line 1") self.call(eveditor.CmdLineInput(), "l 2", raw_string="l 2", msg="02l 2") self.call(eveditor.CmdLineInput(), "l 3", raw_string="l 3", msg="03l 3") self.call(eveditor.CmdLineInput(), "l 4", raw_string="l 4", msg="04l 4") - self.call(eveditor.CmdEditorGroup(), "2 r", cmdstring=":j", msg="Right-justified line 2.") - self.call(eveditor.CmdEditorGroup(), "3 c", cmdstring=":j", msg="Center-justified line 3.") - self.call(eveditor.CmdEditorGroup(), "4 f", cmdstring=":j", msg="Full-justified line 4.") + self.call(eveditor.CmdEditorGroup(), "2 r", raw_string=":j", msg="Right-justified line 2.") + self.call(eveditor.CmdEditorGroup(), "3 c", raw_string=":j", msg="Center-justified line 3.") + self.call(eveditor.CmdEditorGroup(), "4 f", raw_string=":j", msg="Full-justified line 4.") l1, l2, l3, l4 = tuple(self.char1.ndb._eveditor.get_buffer().split("\n")) self.assertEqual(l1, "line 1") self.assertEqual(l2, " " * 75 + "l 2") @@ -273,44 +343,44 @@ class TestEvEditor(BaseEvenniaCommandTest): self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":", + raw_string=":", msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)", ) self.call(eveditor.CmdLineInput(), "line 1.", raw_string="line 1.", msg="01line 1.") self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":dw", + raw_string=":dw", msg="You must give a search word to delete.", ) # self.call( # eveditor.CmdEditorGroup(), # raw_string="", - # cmdstring=":i", + # raw_string=":i", # msg="You need to enter a new line and where to insert it.", # ) # self.call( # eveditor.CmdEditorGroup(), # "", - # cmdstring=":I", + # raw_string=":I", # msg="You need to enter text to insert.", # ) # self.call( # eveditor.CmdEditorGroup(), # "", - # cmdstring=":r", + # raw_string=":r", # msg="You need to enter a replacement string.", # ) self.call( eveditor.CmdEditorGroup(), "", - cmdstring=":s", + raw_string=":s", msg="You must give a search word and something to replace it with.", ) # self.call( # eveditor.CmdEditorGroup(), # "", - # cmdstring=":f", + # raw_string=":f", # msg="Valid justifications are [f]ull (default), [c]enter, [r]right or [l]eft" # ) self.assertEqual(self.char1.ndb._eveditor.get_buffer(), "line 1.") diff --git a/evennia/utils/tests/test_evform.py b/evennia/utils/tests/test_evform.py index 4d879cfafb..1d60508eb6 100644 --- a/evennia/utils/tests/test_evform.py +++ b/evennia/utils/tests/test_evform.py @@ -2,6 +2,7 @@ Unit tests for the EvForm text form generator """ + from unittest import skip from django.test import TestCase diff --git a/evennia/utils/tests/test_tagparsing.py b/evennia/utils/tests/test_tagparsing.py index ac1fa3069a..fc062b1c43 100644 --- a/evennia/utils/tests/test_tagparsing.py +++ b/evennia/utils/tests/test_tagparsing.py @@ -2,6 +2,7 @@ Unit tests for all sorts of inline text-tag parsing, like ANSI, html conversion, inlinefuncs etc """ + import re from django.test import TestCase, override_settings diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index 17f6c4144a..9852802e72 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -11,11 +11,12 @@ from datetime import datetime, timedelta import mock from django.test import TestCase +from parameterized import parameterized +from twisted.internet import task + from evennia.utils import utils from evennia.utils.ansi import ANSIString from evennia.utils.test_resources import BaseEvenniaTest -from parameterized import parameterized -from twisted.internet import task class TestIsIter(TestCase): @@ -58,15 +59,26 @@ class TestCompressWhitespace(TestCase): # No text, return no text self.assertEqual("", utils.compress_whitespace("")) # If no whitespace is exceeded, should return the same - self.assertEqual("One line\nTwo spaces", utils.compress_whitespace("One line\nTwo spaces")) + self.assertEqual( + "One line\nTwo spaces", utils.compress_whitespace("One line\nTwo spaces") + ) # Extra newlines are removed - self.assertEqual("First line\nSecond line", utils.compress_whitespace("First line\n\nSecond line")) + self.assertEqual( + "First line\nSecond line", utils.compress_whitespace("First line\n\nSecond line") + ) # Extra spaces are removed self.assertEqual("Too many spaces", utils.compress_whitespace("Too many spaces")) # "Invisible" extra lines with whitespace are removed - self.assertEqual("First line\nSecond line", utils.compress_whitespace("First line\n \n \nSecond line")) + self.assertEqual( + "First line\nSecond line", utils.compress_whitespace("First line\n \n \nSecond line") + ) # Max kwargs are respected - self.assertEqual("First line\n\nSecond line", utils.compress_whitespace("First line\n\nSecond line", max_spacing=1, max_linebreaks=2)) + self.assertEqual( + "First line\n\nSecond line", + utils.compress_whitespace( + "First line\n\nSecond line", max_spacing=1, max_linebreaks=2 + ), + ) def test_preserve_indents(self): """Ensure that indentation spacing is preserved.""" @@ -78,6 +90,7 @@ Hanging Indents # since there is no doubled-up spacing besides indents, input should equal output self.assertEqual(indented, utils.compress_whitespace(indented)) + class TestListToString(TestCase): """ Default function header from time.py: diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index a3e3633a94..377d31958d 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -28,7 +28,6 @@ from os.path import join as osjoin from string import punctuation from unicodedata import east_asian_width -import evennia from django.apps import apps from django.conf import settings from django.core.exceptions import ValidationError as DjangoValidationError @@ -36,12 +35,14 @@ 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 evennia.utils import logger 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 @@ -474,6 +475,7 @@ iter_to_string = iter_to_str re_empty = re.compile("\n\s*\n") + def compress_whitespace(text, max_linebreaks=1, max_spacing=2): """ Removes extra sequential whitespace in a block of text. This will also remove any trailing @@ -492,9 +494,9 @@ def compress_whitespace(text, max_linebreaks=1, max_spacing=2): # this allows the blank-line compression to eliminate them if needed text = re_empty.sub("\n\n", text) # replace groups of extra spaces with the maximum number of spaces - text = re.sub(f"(?<=\S) {{{max_spacing},}}", " "*max_spacing, text) + text = re.sub(f"(?<=\S) {{{max_spacing},}}", " " * max_spacing, text) # replace groups of extra newlines with the maximum number of newlines - text = re.sub(f"\n{{{max_linebreaks},}}", "\n"*max_linebreaks, text) + text = re.sub(f"\n{{{max_linebreaks},}}", "\n" * max_linebreaks, text) return text @@ -2070,7 +2072,7 @@ def format_grid(elements, width=78, sep=" ", verbatim_elements=None, line_prefi else: row += " " * max(0, width - lrow) rows.append(row) - row = "" + row = element ic = 0 else: # add a new slot @@ -2402,21 +2404,19 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): # 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",) - ] + aliases = [alias.db_key for alias in aliases if alias.db_category != "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 ""), + 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 @@ -3077,3 +3077,21 @@ def ip_from_request(request, exclude=None) -> str: logger.log_warn("ip_from_request: No valid IP address found in request. Using remote_addr.") return remote_addr + + +def value_is_integer(value): + """ + Determines if a value can be type-cast to an integer. + + Args: + value (any): The value to check. + + Returns: + result (bool): Whether it can be type-cast to an integer or not. + """ + try: + int(value) + except ValueError: + return False + + return True diff --git a/evennia/utils/verb_conjugation/pronouns.py b/evennia/utils/verb_conjugation/pronouns.py index f7a293fda1..c3316e15a9 100644 --- a/evennia/utils/verb_conjugation/pronouns.py +++ b/evennia/utils/verb_conjugation/pronouns.py @@ -25,6 +25,7 @@ viewpoint/pronouns Subject Object Possessive Possessive Reflexive 3rd person plural they them their theirs themselves ==================== ======= ======== ========== ========== =========== """ + from evennia.utils.utils import copy_word_case, is_iter DEFAULT_PRONOUN_TYPE = "subject pronoun" diff --git a/evennia/web/admin/help.py b/evennia/web/admin/help.py index 4417142107..9c64630fca 100644 --- a/evennia/web/admin/help.py +++ b/evennia/web/admin/help.py @@ -1,6 +1,7 @@ """ This defines how to edit help entries in Admin. """ + from django import forms from django.contrib import admin diff --git a/evennia/web/admin/tags.py b/evennia/web/admin/tags.py index ebfe2cff56..fd7395f686 100644 --- a/evennia/web/admin/tags.py +++ b/evennia/web/admin/tags.py @@ -3,7 +3,6 @@ Tag admin """ - import traceback from datetime import datetime diff --git a/evennia/web/admin/urls.py b/evennia/web/admin/urls.py index 0a59fcf752..a6dfcf64f6 100644 --- a/evennia/web/admin/urls.py +++ b/evennia/web/admin/urls.py @@ -4,6 +4,7 @@ Rerouting admin frontpage to evennia version. These patterns are all under the admin/* namespace. """ + from django.conf import settings from django.contrib import admin from django.urls import include, path diff --git a/evennia/web/api/filters.py b/evennia/web/api/filters.py index 29ce027dc0..dedb874ad1 100644 --- a/evennia/web/api/filters.py +++ b/evennia/web/api/filters.py @@ -7,6 +7,7 @@ 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 @@ -138,7 +139,6 @@ class ScriptDBFilterSet(BaseTypeclassFilterSet): class HelpFilterSet(FilterSet): - """ Filter for help entries diff --git a/evennia/web/api/permissions.py b/evennia/web/api/permissions.py index 536cb9a552..901f485c55 100644 --- a/evennia/web/api/permissions.py +++ b/evennia/web/api/permissions.py @@ -3,7 +3,6 @@ Sets up an api-access permission check using the in-game permission hierarchy. """ - from django.conf import settings from rest_framework import permissions diff --git a/evennia/web/api/tests.py b/evennia/web/api/tests.py index 4af37b2603..94006dd641 100644 --- a/evennia/web/api/tests.py +++ b/evennia/web/api/tests.py @@ -2,6 +2,7 @@ Tests for the REST API. """ + from collections import namedtuple from django.core.exceptions import ObjectDoesNotExist diff --git a/evennia/web/api/views.py b/evennia/web/api/views.py index eb28073d97..51de2f0321 100644 --- a/evennia/web/api/views.py +++ b/evennia/web/api/views.py @@ -4,6 +4,7 @@ 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 diff --git a/evennia/web/utils/general_context.py b/evennia/web/utils/general_context.py index f8731a633d..57abb1843f 100644 --- a/evennia/web/utils/general_context.py +++ b/evennia/web/utils/general_context.py @@ -7,10 +7,10 @@ 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 diff --git a/evennia/web/webclient/urls.py b/evennia/web/webclient/urls.py index 364a72feb5..39a7edfdbf 100644 --- a/evennia/web/webclient/urls.py +++ b/evennia/web/webclient/urls.py @@ -2,6 +2,7 @@ This structures the (simple) structure of the webpage 'application'. """ + from django.urls import path from . import views diff --git a/evennia/web/website/urls.py b/evennia/web/website/urls.py index 2777424eeb..92c01e2474 100644 --- a/evennia/web/website/urls.py +++ b/evennia/web/website/urls.py @@ -2,6 +2,7 @@ This redirects to website sub-pages. """ + from django.conf import settings from django.contrib import admin from django.urls import include, path diff --git a/evennia/web/website/views/accounts.py b/evennia/web/website/views/accounts.py index 5ecb5128ee..8cfd33a550 100644 --- a/evennia/web/website/views/accounts.py +++ b/evennia/web/website/views/accounts.py @@ -3,7 +3,6 @@ Views for managing accounts. """ - from django.conf import settings from django.contrib import messages from django.http import HttpResponseRedirect diff --git a/evennia/web/website/views/characters.py b/evennia/web/website/views/characters.py index f9e4142b1d..113710f2a5 100644 --- a/evennia/web/website/views/characters.py +++ b/evennia/web/website/views/characters.py @@ -14,12 +14,17 @@ from django.utils.encoding import iri_to_uri from django.utils.http import url_has_allowed_host_and_scheme 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) +from .objects import ( + ObjectCreateView, + ObjectDeleteView, + ObjectDetailView, + ObjectUpdateView, +) class CharacterMixin(TypeclassMixin): @@ -124,9 +129,11 @@ class CharacterPuppetView(LoginRequiredMixin, CharacterMixin, RedirectView, Obje # since next_page is untrusted input from the user, we need to check it's safe to next_page = iri_to_uri(next_page) - if not url_has_allowed_host_and_scheme(url=next_page, - allowed_hosts={self.request.get_host()}, - require_https=self.request.is_secure()): + if not url_has_allowed_host_and_scheme( + url=next_page, + allowed_hosts={self.request.get_host()}, + require_https=self.request.is_secure(), + ): next_page = self.success_url if char: @@ -140,7 +147,6 @@ class CharacterPuppetView(LoginRequiredMixin, CharacterMixin, RedirectView, Obje self.request.session["puppet"] = None messages.error(self.request, "You cannot become '%s'." % char) - return next_page diff --git a/evennia/web/website/views/help.py b/evennia/web/website/views/help.py index 98ef5cbcb3..c84feb20b1 100644 --- a/evennia/web/website/views/help.py +++ b/evennia/web/website/views/help.py @@ -4,6 +4,7 @@ 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 diff --git a/evennia/web/website/views/mixins.py b/evennia/web/website/views/mixins.py index 8cecd5b241..496ebab64e 100644 --- a/evennia/web/website/views/mixins.py +++ b/evennia/web/website/views/mixins.py @@ -2,6 +2,7 @@ These are mixins for class-based views, granting functionality. """ + from django.views.generic import DetailView from django.views.generic.edit import CreateView, DeleteView, UpdateView diff --git a/pyproject.toml b/pyproject.toml index 74b950053a..7c507aada5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "evennia" -version = "4.0.0" +version = "4.1.1" maintainers = [{ name = "Griatch", email = "griatch@gmail.com" }] description = "A full-featured toolkit and server for text-based multiplayer games (MUDs, MU*, etc)." requires-python = ">=3.10"