diff --git a/.github/actions/setup-database/action.yml b/.github/actions/setup-database/action.yml index d5491525de..47a0020d64 100644 --- a/.github/actions/setup-database/action.yml +++ b/.github/actions/setup-database/action.yml @@ -23,7 +23,7 @@ runs: if: ${{ inputs.database == 'postgresql' }} uses: harmon758/postgresql-action@v1 with: - postgresql version: "12" + postgresql version: "13" postgresql db: "evennia" postgresql user: "evennia" postgresql password: "password" diff --git a/.github/workflows/github_action_build_docs.yml b/.github/workflows/github_action_build_docs.yml index 8b31597007..e4ec0acb3d 100644 --- a/.github/workflows/github_action_build_docs.yml +++ b/.github/workflows/github_action_build_docs.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: - python-version: ['3.10'] + python-version: ['3.11'] steps: - name: Checkout ${{ github.ref }} branch diff --git a/.github/workflows/github_action_issue_to_project.yml b/.github/workflows/github_action_issue_to_project.yml new file mode 100644 index 0000000000..4ecf9b6ea9 --- /dev/null +++ b/.github/workflows/github_action_issue_to_project.yml @@ -0,0 +1,19 @@ +name: Automatically add issue to project view + +on: + issues: + types: + - opened + - reopened + +jobs: + add-to-project: + runs-on: ubuntu-latest + steps: + - name: Add To GitHub projects + uses: actions/add-to-project@v1.0.2 + with: + # URL of the project to add issues to + project-url: https://github.com/orgs/evennia/projects/1 + # A GitHub personal access token with write access to the project + github-token: ${{ secrets.EVENNIA_TICKET_TO_PROJECT }} diff --git a/.github/workflows/github_action_test_suite.yml b/.github/workflows/github_action_test_suite.yml index 2bcb6c0153..edbaec5da8 100644 --- a/.github/workflows/github_action_test_suite.yml +++ b/.github/workflows/github_action_test_suite.yml @@ -19,10 +19,10 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] - TESTING_DB: ["sqlite3", "postgresql", "mysql"] + python-version: ["3.11", "3.12", "3.13"] + TESTING_DB: ["sqlite3", "mysql"] include: - - python-version: "3.10" + - python-version: "3.11" TESTING_DB: "sqlite3" coverage-test: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 524a15185e..11096d437a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,30 +2,239 @@ ## Main branch +Updated dependencies: Django >5.1 (<5,2), Twisted >24 (<25). +Python versions: 3.11, 3.12, 3.13. + +This upgrade requires running `evennia migrate` on your existing database +(ignore any prompts to run `evennia makemigrations`). + +- Feat (backwards incompatible): RUN MIGRATIONS (`evennia migrate`): Now requiring Django 5.1 (Griatch) +- Feat (backwards incompatible): Drop support and testing for Python 3.10 (Griatch) +- [Feat][pull3719]: Support Python 3.13. (0xDEADFED5) +- [Feat][pull3633]: Default object's default descs are now taken from a `default_description` + class variable instead of the `desc` Attribute always being set (count-infinity) +- [Feat][pull3718]: Remove twistd.bat creation for Windows, should not be needed anymore (0xDEADFED5) +- [Feat][pull3756]: Updated German translation (JohnFi) +- [Feat][pull3757]: Add more i18n strings to `DefaultObject` for easier translation (JohnFi) +- [Feat][pull3783]: Support users of `ruff` linter by adding compatible config in `pyproject.toml` (jaborsh) +- [Fix][pull3677]: Make sure that `DefaultAccount.create` normalizes to empty + strings instead of `None` if no name is provided, also enforce string type (InspectorCaracal) +- [Fix][pull3682]: Allow in-game help searching for commands natively starting + with `*` (which is the Lunr search wildcard) (count-infinity) +- [Fix][pull3684]: Web client stopped auto-focusing the input box after opening settings (count-infinity) +- [Fix][pull3689]: Partial matching fix in default search, makes sure e.g. `b sw` uniquely + finds `big sword` even if another type of sword is around (InspectorCaracal) +- [Fix][pull3690]: In searches, allow special 'here' and 'me' keywords only be valid queries + unless current location and/or caller is in valid search candidates respectively (InspectorCaracal) +- [Fix][pull3694]: Funcparser swallowing rest of line after a `\`-escape (count-infinity) +- [Fix][pull3705]: Properly serialize `IntFlag` enum types (0xDEADFED5) +- [Fix][pull3707]: Correct links in `about` command (0xDEADFED5) +- [Fix][pull3710]: Clean reduntant session clearin in `at_server_cold_start` (InspectorCaracal) +- [Fix][pull3711]: Usability improvements in the Discord integration (InspectorCaracal) +- [Fix][pull3721]: Avoid loading cmdsets that don't need to be checked, avoiding + a performance hit for loading cmdsets in rooms with a lot of objects (InspectorCaracal) +- [Fix][issue3688]: Made TutorialWorld possible to build cleanly without being a superuser (Griatch) +- [Fix][issue3687]: Fixed batchcommand/interactive with developer perms (Griatch) +- [Fix][pull3723]: Bug in `ingame-map-display` contrib when using ordinal alises (aMiss-aWry) +- [Fix][pull3726]: Fix Twisted v25 issue with returnValue() +- [Fix][pull3729]: Godot client text2bbcode mxp link conversion error (ChrisLR) +- [Fix][pull3737]: The `evennia --gamedir` command didn't properly set the alt gamedir (Russel-Jones) +- [Fix][pull3739]: Fixing f-string in account.py for i18n (JohnFi) +- [Fix][pull3744]: Fix for format strings not getting picked up in i18n (JohnFi) +- [Fix][pull3743]: Log full stack trace on failed object creation (aMiss-aWry) +- [Fix][pull3747]: TutorialWorld bridge-room didn't correctly randomize weather effects (SpyrosRoum) +- [Fix][pull3765]: Storing TickerHandler `store_key` in a db attribute would not + work correctly (0xDEADFED5) +- [Fix][pull3753]: Make sure `AttributeProperty`s are initialized with default values also in parent class (JohnFi) +- [Fix][pull3751]: The `access` and `inventory` commands would traceback if run on a character without an Account (EliasWatson) +- [Fix][pull3768]: Make sure the `CmdCopy` command copies object categories, + since otherwise plurals were lost (jaborsh) +- [Fix][issue3788]: `GLOBAL_SCRIPTS.all()` raised error (Griatch) +- Fix: `options` setting `NOPROMPTGOAHEAD` was not possible to set (Griatch) +- Fix: Make `\\` properly preserve one backlash in funcparser (Griatch) +- Fix: The testing 'echo' inputfunc didn't work correctly; now returns both args/kwargs (Griatch) +- Fix: When an object was used as an On-Demand Task's category, and that object was then deleted, + it caused an OnDemandHandler save error on reload. Will now clean up on save. (Griatch) + used as the task's category (Griatch) +- Fix: Correct aws contrib's use of legacy django string utils (Griatch) +- [Docs]: Fixes from InspectorCaracal, Griatch, ChrisLR, JohnFi, 0xDEADFED5, jaborsh, Problematic, BlaneWins + +[pull3633]: https://github.com/evennia/evennia/pull/3633 +[pull3677]: https://github.com/evennia/evennia/pull/3677 +[pull3682]: https://github.com/evennia/evennia/pull/3682 +[pull3684]: https://github.com/evennia/evennia/pull/3684 +[pull3689]: https://github.com/evennia/evennia/pull/3689 +[pull3690]: https://github.com/evennia/evennia/pull/3690 +[pull3705]: https://github.com/evennia/evennia/pull/3705 +[pull3707]: https://github.com/evennia/evennia/pull/3707 +[pull3710]: https://github.com/evennia/evennia/pull/3710 +[pull3711]: https://github.com/evennia/evennia/pull/3711 +[pull3718]: https://github.com/evennia/evennia/pull/3718 +[pull3719]: https://github.com/evennia/evennia/pull/3719 +[pull3721]: https://github.com/evennia/evennia/pull/3721 +[pull3723]: https://github.com/evennia/evennia/pull/3723 +[pull3726]: https://github.com/evennia/evennia/pull/3726 +[pull3729]: https://github.com/evennia/evennia/pull/3729 +[pull3737]: https://github.com/evennia/evennia/pull/3737 +[pull3739]: https://github.com/evennia/evennia/pull/3739 +[pull3743]: https://github.com/evennia/evennia/pull/3743 +[pull3744]: https://github.com/evennia/evennia/pull/3744 +[pull3747]: https://github.com/evennia/evennia/pull/3747 +[pull3765]: https://github.com/evennia/evennia/pull/3765 +[pull3753]: https://github.com/evennia/evennia/pull/3753 +[pull3751]: https://github.com/evennia/evennia/pull/3751 +[pull3756]: https://github.com/evennia/evennia/pull/3756 +[pull3757]: https://github.com/evennia/evennia/pull/3757 +[pull3768]: https://github.com/evennia/evennia/pull/3768 +[pull3783]: https://github.com/evennia/evennia/pull/3783 +[issue3688]: https://github.com/evennia/evennia/issues/3688 +[issue3687]: https://github.com/evennia/evennia/issues/3687 +[issue3788]: https://github.com/evennia/evennia/issues/3788 + + + +## Evennia 4.5.0 + +Nov 12, 2024 + +- [Feat][pull3634]: New contrib for in-game `storage` of items in rooms (aMiss-aWry) +- [Feat][pull3636]: Make `cpattr` command also support Attribute categories (aMiss-aWry) +- [Feat][pull3653]: Updated Chinese translation (Pridell). +- [Fix][pull3635]: Fix memory leak in Portal Telnet connections, force weak + references to Telnet negotiations, stop LoopingCall on disconnect (a-rodian-jedi) +- [Fix][pull3626]: Typo in `defense_type` in evadventure tutorial (feyrkh) +- [Fix][pull3632]: Made fallback permissions on be set correctly (InspectorCaracal) +- [Fix][pull3639]: Fix `system` command when environment uses a language with + commas for decimal points (aMiss-aWry) +- [Fix][pull3645]: Correct `character_creator` contrib's error return (InspectorCaracal) +- [Fix][pull3640]: Typo fixes for conjugate verbs (aMiss-aWry) +- [Fix][pull3647]: Contents cache didn't reset internal typecache on use of `init` hook (InspectorCaracal) +- [Fix][issue3627]: Traceback from contrib `in-game reports` `help manage` command (Griatch) +- [Fix][issue3643]: Fix for Commands metaclass interpreting e.g. `usercmd:false()` locks as + a `cmd:` type lock for the purposes of default access fallbacks (Griatch). +- [Fix][pull3651]: EvEditor `:j` defaulted to 'full' justify instead of 'left' as + was documented (willmofield) +- [Fix][pull3657]: Fix error in `do_search` that caused `FileHelpEntries` to + traceback (a-rodian-jedi) +- [Fix][pull3660]: Numbered aliases didn't refresh after a object rename unless + the endpoint hook was re-called; now triggers the call autiomatically (count-infinity) +- [Fix][pull3664]: The `Account.last_login` field was updated also when user + disconnected, which is not useful (InspectorCaracal) +- [Fix][pull3665]: Remove faulty verb conjugation exceptions for 'offer', + 'hinder' and 'alter' in automatic verb-conjugation engine (aMiss-aWry) +- [Fix][pull3669]: The `page` command tracebacked for some input combinations (InspectorCaracal) +- [Fix][pull3642]: Give friendlier error if EvMore object is not available + neither on Object, nor on account fallback. (InspectorCaracal) +- [Docs][pull3655]: Fixed many erroneously created links on `file.py` names in + the docs (marado) +- [Docs][pull3576]: Rework doc for [Pycharm howto][doc-pycharm] +- Docs updates: feykrh, Griatch, marado, jaborsh + +[pull3626]: https://github.com/evennia/evennia/pull/3626 +[pull3676]: https://github.com/evennia/evennia/pull/3676 +[pull3634]: https://github.com/evennia/evennia/pull/3634 +[pull3632]: https://github.com/evennia/evennia/pull/3632 +[pull3636]: https://github.com/evennia/evennia/pull/3636 +[pull3639]: https://github.com/evennia/evennia/pull/3639 +[pull3645]: https://github.com/evennia/evennia/pull/3645 +[pull3640]: https://github.com/evennia/evennia/pull/3640 +[pull3647]: https://github.com/evennia/evennia/pull/3647 +[pull3635]: https://github.com/evennia/evennia/pull/3635 +[pull3651]: https://github.com/evennia/evennia/pull/3651 +[pull3655]: https://github.com/evennia/evennia/pull/3655 +[pull3657]: https://github.com/evennia/evennia/pull/3657 +[pull3653]: https://github.com/evennia/evennia/pull/3653 +[pull3660]: https://github.com/evennia/evennia/pull/3660 +[pull3664]: https://github.com/evennia/evennia/pull/3664 +[pull3665]: https://github.com/evennia/evennia/pull/3665 +[pull3669]: https://github.com/evennia/evennia/pull/3669 +[pull3642]: https://github.com/evennia/evennia/pull/3642 +[pull3576]: https://github.com/evennia/evennia/pull/3576 +[issue3627]: https://github.com/evennia/evennia/issues/3627 +[issue3643]: https://github.com/evennia/evennia/issues/3643 +[doc-pycharm]: https://www.evennia.com/docs/latest/Coding/Setting-up-PyCharm.html + +## Evennia 4.4.1 + +Oct 1, 2024 + +- [Fix][issue3629]: Reverting change of default Sqlite3 PRAGMA settings, changing them for + existing database caused corruption of db. For empty db, can still change in + `SQLITE3_PRAGMAS` settings. (Griatch) + +[issue3629]: https://github.com/evennia/evennia/issues/3629 + + +## Evennia 4.4.0 + +Sep 29, 2024 + +> WARNING: Due to a bug in the default Sqlite3 PRAGMA settings, it is +> recommended to not upgrade to this version if you are using Sqlite3. +> Use `4.4.1` or higher instead. + - Feat: Support `scripts key:typeclass` to create global scripts with dynamic keys (rather than just relying on typeclass' key) (Griatch) - [Feat][pull3595]: Tweak Sqlite3 PRAGMAs for better performance (0xDEADFED5) - Feat: Make Sqlite3 PRAGMAs configurable via settings (Griatch) -- [Fix][pull3494]: Update/clean some Evennia dependencies (0xDEADFED5) +- [Feat][pull3592]: Revised German locationlization ('Du' instead of 'Sie', + cleanup) (Drakon72) +- [Feat][pull3541]: Rework main Object searching to respect partial matches, empty + search now partial matching all candidates, overall cleanup (InspectorCaracal) +- [Feat][pull3588]: New `DefaultObject` hooks: `at_object_post_creation`, called once after + first creation but after any prototypes have been applied, and +`at_object_post_spawn(prototype)`, called only after creation/update with a prototype (InspectorCaracal) +- [Fix][pull3594]: Update/clean some Evennia dependencies (0xDEADFED5) - [Fix][issue3556]: Better error if trying to treat ObjectDB as a typeclass (Griatch) - [Fix][issue3590]: Make `examine` command properly show `strattr` type Attribute values (Griatch) - [Fix][issue3519]: `GLOBAL_SCRIPTS` container didn't list global scripts not -defined explicitly to be restarted/recrated in settings.py (Griatch) +defined explicitly to be restarted/recrated in `settings.py` (Griatch) - Fix: Passing an already instantiated Script to `obj.scripts.add` (`ScriptHandler.add`) did not add it to the handler's object (Griatch) -- [Fix](pull3533): Fix Lunr search issues preventing finding help entries with similar +- [Fix][pull3533]: Fix Lunr search issues preventing finding help entries with similar names (chiizyjin) -[Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch) -- Docs: Tutorial fixes (Griatch) +- [Fix][pull3603]: Fix of client header for LLM contrib for remote APIs (InspectorCaracal) +- [Fix][pull3605]: Correctly pass node kwargs through `@list_node` decorated evmenu nodes + (InspectorCaracal) +- [Fix][pull3597]: Address timing issue for testing `new_task_waiting_input `on + Windows (0xDEADFED5) +- [Fix][pull3611]: Fix and update for Reports contrib (InspectorCaracal) +- [Fix][pull3625]: Lycanthropy tutorial page had some issues (feyrkh) +- [Fix][pull3622]: Fix for examine command tracebacking with strvalue error + (aMiss-aWry) +- [Fix][issue3612]: Make sure help entries' `subtopic_separator_char` is + respected (Griatch) +- [Fix][issue3624]: Setting tags with integer names caused errors on postgres (Griatch) +- [Fix][issue3615]: Using `print()` in `py` caused an infinite loop (Griatch) +- [Fix][issue3620]: Better handle TaskHandler running against an attribute that + was removed since last reload (Griatch) +- [Fix][issue3616]: The `color ansi` command output was broken (Griatch) +- Fix: Extended the `color truecolor` display with usage examples. Also updated docs (Griatch) +- [Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch) +- Docs: Tutorial fixes (Griatch, aMiss-aWry, feyrkh) [issue3591]: https://github.com/evennia/evennia/issues/3591 [issue3590]: https://github.com/evennia/evennia/issues/3590 [issue3556]: https://github.com/evennia/evennia/issues/3556 [issue3519]: https://github.com/evennia/evennia/issues/3519 +[issue3612]: https://github.com/evennia/evennia/issues/3612 +[issue3624]: https://github.com/evennia/evennia/issues/3624 +[issue3615]: https://github.com/evennia/evennia/issues/3615 +[issue3620]: https://github.com/evennia/evennia/issues/3620 +[issue3616]: https://github.com/evennia/evennia/issues/3616 [pull3595]: https://github.com/evennia/evennia/pull/3595 [pull3533]: https://github.com/evennia/evennia/pull/3533 [pull3594]: https://github.com/evennia/evennia/pull/3594 +[pull3592]: https://github.com/evennia/evennia/pull/3592 +[pull3603]: https://github.com/evennia/evennia/pull/3603 +[pull3605]: https://github.com/evennia/evennia/pull/3605 +[pull3597]: https://github.com/evennia/evennia/pull/3597 +[pull3611]: https://github.com/evennia/evennia/pull/3611 +[pull3541]: https://github.com/evennia/evennia/pull/3541 +[pull3588]: https://github.com/evennia/evennia/pull/3588 +[pull3625]: https://github.com/evennia/evennia/pull/3625 +[pull3622]: https://github.com/evennia/evennia/pull/3622 ## Evennia 4.3.0 @@ -47,15 +256,12 @@ underline reset, italic/reset and strikethrough/reset (0xDEADFED5) of local search on multimatch (InspectorCaracal) - [Fix][pull3585]: `TagCmd.switch_options` was misnamed (erratic-pattern) - [Fix][pull3580]: Fix typo that made `find/loc` show the wrong dbref in result (erratic-pattern) -- [Fix][pull3571]: Issue disambiguating between certain partial multimatches - (InspectorCaracal) -- [Fix][pull3589]: Fix regex escaping in utils.py for future Python versions (hhsiao) +- [Fix][pull3589]: Fix regex escaping in `utils.py` for future Python versions (hhsiao) - [Docs]: Add True-color description for Colors documentation (0xDEADFED5) - [Docs]: Doc fixes (Griatch, InspectorCaracal, 0xDEADFED5) [pull3585]: https://github.com/evennia/evennia/pull/3585 [pull3580]: https://github.com/evennia/evennia/pull/3580 -[pull3571]: https://github.com/evennia/evennia/pull/3571 [pull3586]: https://github.com/evennia/evennia/pull/3586 [pull3550]: https://github.com/evennia/evennia/pull/3550 [pull3531]: https://github.com/evennia/evennia/pull/3531 @@ -1152,7 +1358,7 @@ without arguments starts a full interactive Python console. - `VALIDATOR_FUNC_MODULES` - (general) text validator functions, for verifying an input is on a specific form. -### Utils +### Utilities - `evennia` launcher now fully handles all django-admin commands, like running tests in parallel. - `evennia.utils.create.account` now also takes `tags` and `attrs` keywords. @@ -1175,7 +1381,7 @@ without arguments starts a full interactive Python console. - Option Classes added to make storing user-options easier and smoother. - `evennia.VALIDATOR_CONTAINER` and `evennia.OPTION_CONTAINER` added to load these. -### Contribs +### New Contribs - Evscaperoom - a full puzzle engine for making multiplayer escape rooms in Evennia. Used to make the entry for the MUD-Coder's Guild's 2019 Game Jam with the theme "One Room", where it ranked #1. @@ -1299,7 +1505,7 @@ without arguments starts a full interactive Python console. - Removed the enforcing of `MAX_NR_CHARACTERS=1` for `MULTISESSION_MODE` `0` and `1` by default. - Add `evennia.utils.logger.log_sec` for logging security-related messages (marked SS in log). -### Contribs +### More Contribs - `Auditing` (Johnny): Log and filter server input/output for security purposes - `Build Menu` (vincent-lg): New @edit command to edit object properties in a menu. @@ -1403,7 +1609,7 @@ base-modules where removed from game/gamesrc. Instead admins are encouraged to explicitly create new modules under game/gamesrc/ when they want to implement their game - gamesrc/ is empty by default except for the example folders that contain template files to use for -this purpose. We also added the ev.py file, implementing a new, flat +this purpose. We also added the `ev.py` file, implementing a new, flat API. Work is ongoing to add support for mud-specific telnet extensions, notably the MSDP and GMCP out-of-band extensions. On the community side, evennia's dev blog was started and linked on planet diff --git a/docs/requirements.txt b/docs/requirements.txt index 9b371f313a..cddfd1b970 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,7 +5,7 @@ myst-parser==0.15.2 myst-parser[linkify]==0.15.2 Jinja2 < 3.1 -# pinned to allow for sphinx 3.x to still work, latest requried 5+ +# pinned to allow for sphinx 3.x to still work, latest required 5+ alabaster==0.7.13 sphinxcontrib-applehelp<1.0.7 sphinxcontrib-devhelp<1.0.6 diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index 3d160d8efb..4ee241282c 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -2,28 +2,206 @@ ## Main branch +Updated dependencies: Twisted >24 (<25). Python 3.10, 3.11, 3.12, 3.13. Will +drop 3.10 support as part of next major release. + +- [Feat][pull3719]: Support Python 3.13. (0xDEADFED5) +- [Feat][pull3633]: Default object's default descs are now taken from a `default_description` + class variable instead of the `desc` Attribute always being set (count-infinity) +- [Feat][pull3718]: Remove twistd.bat creation for Windows, should not be needed anymore (0xDEADFED5) +- [Fix][pull3677]: Make sure that `DefaultAccount.create` normalizes to empty + strings instead of `None` if no name is provided, also enforce string type (InspectorCaracal) +- [Fix][pull3682]: Allow in-game help searching for commands natively starting + with `*` (which is the Lunr search wildcard) (count-infinity) +- [Fix][pull3684]: Web client stopped auto-focusing the input box after opening + settings (count-infinity) +- [Fix][pull3689]: Partial matching fix in default search, makes sure e.g. `b sw` uniquely + finds `big sword` even if another type of sword is around (InspectorCaracal) +- [Fix][pull3690]: In searches, allow special 'here' and 'me' keywords only be valid queries + unless current location and/or caller is in valid search candidates respectively (InspectorCaracal) +- [Fix][pull3694]: Funcparser swallowing rest of line after a `\`-escape (count-infinity) +- [Fix][pull3705]: Properly serialize `IntFlag` enum types (0xDEADFED5) +- [Fix][pull3707]: Correct links in `about` command (0xDEADFED5) +- [Fix][pull3710]: Clean reduntant session clearin in `at_server_cold_start` (InspectorCaracal) +- [Fix][pull3711]: Usability improvements in the Discord integration (InspectorCaracal) +- [Fix][pull3721]: Avoid loading cmdsets that don't need to be checked, avoiding + a performance hit for loading cmdsets in rooms with a lot of objects (InspectorCaracal) +- [Fix][issue3688]: Made TutorialWorld possible to build cleanly without being a superuser (Griatch) +- [Fix][issue3687]: Fixed batchcommand/interactive with developer perms (Griatch) +- [Fix][issue3723]: Bug in `ingame-map-display` contrib when using ordinal alises (aMiss-aWry) +- [Fix][issue3726]: Fix Twisted v25 issue with returnValue() +- [Fix][issue3729]: Godot client text2bbcode mxp link conversion error (ChrisLR) +- Fix: Make `\\` properly preserve one backlash in funcparser (Griatch) +- Fix: When an object was used as an On-Demand Task's category, and that object was then deleted, + it caused an OnDemandHandler save error on reload. Will now clean up on save. (Griatch) + used as the task's category (Griatch) +- [Docs]: Fixes from InspectorCaracal, Griatch, ChrisLR + + +[pull3633]: https://github.com/evennia/evennia/pull/3633 +[pull3677]: https://github.com/evennia/evennia/pull/3677 +[pull3682]: https://github.com/evennia/evennia/pull/3682 +[pull3684]: https://github.com/evennia/evennia/pull/3684 +[pull3689]: https://github.com/evennia/evennia/pull/3689 +[pull3690]: https://github.com/evennia/evennia/pull/3690 +[pull3705]: https://github.com/evennia/evennia/pull/3705 +[pull3707]: https://github.com/evennia/evennia/pull/3707 +[pull3710]: https://github.com/evennia/evennia/pull/3710 +[pull3711]: https://github.com/evennia/evennia/pull/3711 +[pull3718]: https://github.com/evennia/evennia/pull/3718 +[pull3719]: https://github.com/evennia/evennia/pull/3719 +[pull3721]: https://github.com/evennia/evennia/pull/3721 +[pull3723]: https://github.com/evennia/evennia/pull/3723 +[pull3726]: https://github.com/evennia/evennia/pull/3726 +[pull3729]: https://github.com/evennia/evennia/pull/3729 +[issue3688]: https://github.com/evennia/evennia/issues/3688 +[issue3688]: https://github.com/evennia/evennia/issues/3687 + + + +## Evennia 4.5.0 + +Nov 12, 2024 + +- [Feat][pull3634]: New contrib for in-game `storage` of items in rooms (aMiss-aWry) +- [Feat][pull3636]: Make `cpattr` command also support Attribute categories (aMiss-aWry) +- [Feat][pull3653]: Updated Chinese translation (Pridell). +- [Fix][pull3635]: Fix memory leak in Portal Telnet connections, force weak + references to Telnet negotiations, stop LoopingCall on disconnect (a-rodian-jedi) +- [Fix][pull3626]: Typo in `defense_type` in evadventure tutorial (feyrkh) +- [Fix][pull3632]: Made fallback permissions on be set correctly (InspectorCaracal) +- [Fix][pull3639]: Fix `system` command when environment uses a language with + commas for decimal points (aMiss-aWry) +- [Fix][pull3645]: Correct `character_creator` contrib's error return (InspectorCaracal) +- [Fix][pull3640]: Typo fixes for conjugate verbs (aMiss-aWry) +- [Fix][pull3647]: Contents cache didn't reset internal typecache on use of `init` hook (InspectorCaracal) +- [Fix][issue3627]: Traceback from contrib `in-game reports` `help manage` command (Griatch) +- [Fix][issue3643]: Fix for Commands metaclass interpreting e.g. `usercmd:false()` locks as + a `cmd:` type lock for the purposes of default access fallbacks (Griatch). +- [Fix][pull3651]: EvEditor `:j` defaulted to 'full' justify instead of 'left' as + was documented (willmofield) +- [Fix][pull3657]: Fix error in `do_search` that caused `FileHelpEntries` to + traceback (a-rodian-jedi) +- [Fix][pull3660]: Numbered aliases didn't refresh after a object rename unless + the endpoint hook was re-called; now triggers the call autiomatically (count-infinity) +- [Fix][pull3664]: The `Account.last_login` field was updated also when user + disconnected, which is not useful (InspectorCaracal) +- [Fix][pull3665]: Remove faulty verb conjugation exceptions for 'offer', + 'hinder' and 'alter' in automatic verb-conjugation engine (aMiss-aWry) +- [Fix][pull3669]: The `page` command tracebacked for some input combinations (InspectorCaracal) +- [Fix][pull3642]: Give friendlier error if EvMore object is not available + neither on Object, nor on account fallback. (InspectorCaracal) +- [Docs][pull3655]: Fixed many erroneously created links on `file.py` names in + the docs (marado) +- [Docs][pull3576]: Rework doc for [Pycharm howto][doc-pycharm] +- Docs updates: feykrh, Griatch, marado, jaborsh + +[pull3626]: https://github.com/evennia/evennia/pull/3626 +[pull3676]: https://github.com/evennia/evennia/pull/3676 +[pull3634]: https://github.com/evennia/evennia/pull/3634 +[pull3632]: https://github.com/evennia/evennia/pull/3632 +[pull3636]: https://github.com/evennia/evennia/pull/3636 +[pull3639]: https://github.com/evennia/evennia/pull/3639 +[pull3645]: https://github.com/evennia/evennia/pull/3645 +[pull3640]: https://github.com/evennia/evennia/pull/3640 +[pull3647]: https://github.com/evennia/evennia/pull/3647 +[pull3635]: https://github.com/evennia/evennia/pull/3635 +[pull3651]: https://github.com/evennia/evennia/pull/3651 +[pull3655]: https://github.com/evennia/evennia/pull/3655 +[pull3657]: https://github.com/evennia/evennia/pull/3657 +[pull3653]: https://github.com/evennia/evennia/pull/3653 +[pull3660]: https://github.com/evennia/evennia/pull/3660 +[pull3664]: https://github.com/evennia/evennia/pull/3664 +[pull3665]: https://github.com/evennia/evennia/pull/3665 +[pull3669]: https://github.com/evennia/evennia/pull/3669 +[pull3642]: https://github.com/evennia/evennia/pull/3642 +[pull3576]: https://github.com/evennia/evennia/pull/3576 +[issue3627]: https://github.com/evennia/evennia/issues/3627 +[issue3643]: https://github.com/evennia/evennia/issues/3643 +[doc-pycharm]: https://www.evennia.com/docs/latest/Coding/Setting-up-PyCharm.html + +## Evennia 4.4.1 + +Oct 1, 2024 + +- [Fix][issue3629]: Reverting change of default Sqlite3 PRAGMA settings, changing them for + existing database caused corruption of db. For empty db, can still change in + `SQLITE3_PRAGMAS` settings. (Griatch) + +[issue3629]: https://github.com/evennia/evennia/issues/3629 + + +## Evennia 4.4.0 + +Sep 29, 2024 + +> WARNING: Due to a bug in the default Sqlite3 PRAGMA settings, it is +> recommended to not upgrade to this version if you are using Sqlite3. +> Use `4.4.1` or higher instead. + - Feat: Support `scripts key:typeclass` to create global scripts with dynamic keys (rather than just relying on typeclass' key) (Griatch) - [Feat][pull3595]: Tweak Sqlite3 PRAGMAs for better performance (0xDEADFED5) - Feat: Make Sqlite3 PRAGMAs configurable via settings (Griatch) +- [Feat][pull3592]: Revised German locationlization ('Du' instead of 'Sie', + cleanup) (Drakon72) +- [Feat][pull3541]: Rework main Object searching to respect partial matches, empty + search now partial matching all candidates, overall cleanup (InspectorCaracal) +- [Feat][pull3588]: New `DefaultObject` hooks: `at_object_post_creation`, called once after + first creation but after any prototypes have been applied, and +`at_object_post_spawn(prototype)`, called only after creation/update with a prototype (InspectorCaracal) +- [Fix][pull3594]: Update/clean some Evennia dependencies (0xDEADFED5) - [Fix][issue3556]: Better error if trying to treat ObjectDB as a typeclass (Griatch) - [Fix][issue3590]: Make `examine` command properly show `strattr` type Attribute values (Griatch) - [Fix][issue3519]: `GLOBAL_SCRIPTS` container didn't list global scripts not -defined explicitly to be restarted/recrated in settings.py (Griatch) +defined explicitly to be restarted/recrated in `settings.py` (Griatch) - Fix: Passing an already instantiated Script to `obj.scripts.add` (`ScriptHandler.add`) did not add it to the handler's object (Griatch) -- [Fix](pull3533): Fix Lunr search issues preventing finding help entries with similar +- [Fix][pull3533]: Fix Lunr search issues preventing finding help entries with similar names (chiizyjin) -[Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch) -- Docs: Tutorial fixes (Griatch) +- [Fix][pull3603]: Fix of client header for LLM contrib for remote APIs (InspectorCaracal) +- [Fix][pull3605]: Correctly pass node kwargs through `@list_node` decorated evmenu nodes + (InspectorCaracal) +- [Fix][pull3597]: Address timing issue for testing `new_task_waiting_input `on + Windows (0xDEADFED5) +- [Fix][pull3611]: Fix and update for Reports contrib (InspectorCaracal) +- [Fix][pull3625]: Lycanthropy tutorial page had some issues (feyrkh) +- [Fix][pull3622]: Fix for examine command tracebacking with strvalue error + (aMiss-aWry) +- [Fix][issue3612]: Make sure help entries' `subtopic_separator_char` is + respected (Griatch) +- [Fix][issue3624]: Setting tags with integer names caused errors on postgres (Griatch) +- [Fix][issue3615]: Using `print()` in `py` caused an infinite loop (Griatch) +- [Fix][issue3620]: Better handle TaskHandler running against an attribute that + was removed since last reload (Griatch) +- [Fix][issue3616]: The `color ansi` command output was broken (Griatch) +- Fix: Extended the `color truecolor` display with usage examples. Also updated docs (Griatch) +- [Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch) +- Docs: Tutorial fixes (Griatch, aMiss-aWry, feyrkh) [issue3591]: https://github.com/evennia/evennia/issues/3591 [issue3590]: https://github.com/evennia/evennia/issues/3590 [issue3556]: https://github.com/evennia/evennia/issues/3556 [issue3519]: https://github.com/evennia/evennia/issues/3519 +[issue3612]: https://github.com/evennia/evennia/issues/3612 +[issue3624]: https://github.com/evennia/evennia/issues/3624 +[issue3615]: https://github.com/evennia/evennia/issues/3615 +[issue3620]: https://github.com/evennia/evennia/issues/3620 +[issue3616]: https://github.com/evennia/evennia/issues/3616 +[pull3595]: https://github.com/evennia/evennia/pull/3595 [pull3595]: https://github.com/evennia/evennia/pull/3595 [pull3533]: https://github.com/evennia/evennia/pull/3533 +[pull3594]: https://github.com/evennia/evennia/pull/3594 +[pull3592]: https://github.com/evennia/evennia/pull/3592 +[pull3603]: https://github.com/evennia/evennia/pull/3603 +[pull3605]: https://github.com/evennia/evennia/pull/3605 +[pull3597]: https://github.com/evennia/evennia/pull/3597 +[pull3611]: https://github.com/evennia/evennia/pull/3611 +[pull3541]: https://github.com/evennia/evennia/pull/3541 +[pull3588]: https://github.com/evennia/evennia/pull/3588 +[pull3625]: https://github.com/evennia/evennia/pull/3625 +[pull3622]: https://github.com/evennia/evennia/pull/3622 ## Evennia 4.3.0 @@ -47,7 +225,7 @@ underline reset, italic/reset and strikethrough/reset (0xDEADFED5) - [Fix][pull3580]: Fix typo that made `find/loc` show the wrong dbref in result (erratic-pattern) - [Fix][pull3571]: Issue disambiguating between certain partial multimatches (InspectorCaracal) -- [Fix][pull3589]: Fix regex escaping in utils.py for future Python versions (hhsiao) +- [Fix][pull3589]: Fix regex escaping in `utils.py` for future Python versions (hhsiao) - [Docs]: Add True-color description for Colors documentation (0xDEADFED5) - [Docs]: Doc fixes (Griatch, InspectorCaracal, 0xDEADFED5) @@ -1401,7 +1579,7 @@ base-modules where removed from game/gamesrc. Instead admins are encouraged to explicitly create new modules under game/gamesrc/ when they want to implement their game - gamesrc/ is empty by default except for the example folders that contain template files to use for -this purpose. We also added the ev.py file, implementing a new, flat +this purpose. We also added the `ev.py` file, implementing a new, flat API. Work is ongoing to add support for mud-specific telnet extensions, notably the MSDP and GMCP out-of-band extensions. On the community side, evennia's dev blog was started and linked on planet diff --git a/docs/source/Coding/Setting-up-PyCharm.md b/docs/source/Coding/Setting-up-PyCharm.md index 262b62b78c..952bf9812a 100644 --- a/docs/source/Coding/Setting-up-PyCharm.md +++ b/docs/source/Coding/Setting-up-PyCharm.md @@ -1,84 +1,125 @@ # Setting up PyCharm with Evennia -[PyCharm](https://www.jetbrains.com/pycharm/) is a Python developer's IDE from Jetbrains available for Windows, Mac and Linux. It is a commercial product but offer free trials, a scaled-down community edition and also generous licenses for OSS projects like Evennia. +[PyCharm](https://www.jetbrains.com/pycharm/) is a Python developer's IDE from Jetbrains available for Windows, Mac and Linux. +It is a commercial product but offer free trials, a scaled-down community edition and also generous licenses for OSS projects like Evennia. -> This page was originally tested on Windows (so use Windows-style path examples), but should work the same for all platforms. +First, download and install the IDE edition of your choosing. +The community edition should have everything you need, +but the professional edition has integrated support for Django which can help. -First, install Evennia on your local machine with [[Getting Started]]. If you're new to PyCharm, loading your project is as easy as selecting the `Open` option when PyCharm starts, and browsing to your game folder (the one created with `evennia --init`). We refer to it as `mygame` here. +## From an existing project -If you want to be able to examine evennia's core code or the scripts inside your virtualenv, you'll -need to add them to your project too: +Use this if you want to use PyCharm with an already existing Evennia game. +First, ensure you have completed the steps outlined [here](https://www.evennia.com/docs/latest/Setup/Installation.html#requirements). +Especially the virtualenv part, this will make setting the IDE up much easier. -1. Go to `File > Open...` -1. Select the folder (i.e. the `evennia` root) -1. Select "Open in current window" and "Add to currently opened projects" +1. Open Pycharm and click on the open button, open your root folder corresponding to `mygame/`. +2. Click on File -> Settings -> Project -> Python Interpreter -> Add Interpreter -> Add Local Interpreter +![Example](https://imgur.com/QRo8O1C.png) +3. Click on VirtualEnv -> Existing Interpreter -> Select your existing virtualenv folder, + should be `evenv` if you followed the default installation. -It's a good idea to set up the interpreter this before attempting anything further. The rest of this page assumes your project is already configured in PyCharm. +![Example](https://imgur.com/XDmgjTw.png) -1. Go to `File > Settings... > Project: \ > Project Interpreter` -1. Click the Gear symbol `> Add local` -1. Navigate to your `evenv/scripts directory`, and select Python.exe +## From a new project + +Use this if you are starting from scratch or want to make a new Evennia game. +1. Click on the new project button. +2. Select the location for your project. +You should create two new folders, one for the root of your project and one +for the evennia game directly. It should look like `/location/projectfolder/gamefolder` +3. Select the `Custom environment` interpreter type, using `Generate New` of type `Virtual env` using a +compatible base python version as recommended in https://www.evennia.com/docs/latest/Setup/Installation.html#requirements +Then choose a folder for your virtual environment as a sub folder of your project folder. + +![Example new project configuration](https://imgur.com/R5Yr9I4.png) + +Click on the create button and it will take you inside your new project with a bare bones virtual environment. +To install Evennia, you can then either clone evennia in your project folder or install it via pip. +The simplest way is to use pip. + +Click on the `terminal` button + +![Terminal Button](https://i.imgur.com/fDr4nhv.png) + +1. Type in `pip install evennia` +2. Close the IDE and navigate to the project folder +3. Rename the game folder to a temporary name and create a new empty folder with the previous name +4. Open your OS terminal, navigate to your project folder and activate your virtualenv. +On linux, `source .evenv/bin/activate` +On windows, `evenv\Scripts\activate` +5. Type in `evennia --init mygame` +6. Move the files from your temporary folder, which should contain the `.idea/` folder into +the folder you have created at step 3 and delete the now empty temporary folder. +7. In the terminal, Move into the folder and type in `evennia migrate` +8. Start evennia to ensure that it works with `evennia start` and stop it with `evennia stop` + +At this point, you can reopen your IDE and it should be functional. +[Look here for additional information](https://www.evennia.com/docs/latest/Setup/Installation.html) -Enjoy seeing all your imports checked properly, setting breakpoints, and live variable watching! ## Debug Evennia from inside PyCharm -1. Launch Evennia in your preferred way (usually from a console/terminal) -1. Open your project in PyCharm -1. In the PyCharm menu, select `Run > Attach to Local Process...` -1. From the list, pick the `twistd` process with the `server.py` parameter (Example: `twistd.exe --nodaemon --logfile=\\server\logs\server.log --python=\\evennia\server\server.py`) +### Attaching to the process +1. Launch Evennia in the pycharm terminal +2. Attempt to start it twice, this will give you the process ID of the server +3. In the PyCharm menu, select `Run > Attach to Process...` +4. From the list, pick the corresponding process id, it should be the `twistd` process with the `server.py` parameter (Example: `twistd.exe --nodaemon --logfile=\\server\logs\server.log --python=\\evennia\server\server.py`) -Of course you can attach to the `portal` process as well. If you want to debug the Evennia launcher +You can attach to the `portal` process as well, if you want to debug the Evennia launcher or runner for some reason (or just learn how they work!), see Run Configuration below. > NOTE: Whenever you reload Evennia, the old Server process will die and a new one start. So when you restart you have to detach from the old and then reattach to the new process that was created. -> To make the process less tedious you can apply a filter in settings to show only the server.py process in the list. To do that navigate to: `Settings/Preferences | Build, Execution, Deployment | Python Debugger` and then in `Attach to process` field put in: `twistd.exe" --nodaemon`. This is an example for windows, I don't have a working mac/linux box. -![Example process filter configuration](https://i.imgur.com/vkSheR8.png) +### Run Evennia with a Run/Debug Configuration -## Run Evennia from inside PyCharm +This configuration allows you to launch Evennia from inside PyCharm. +Besides convenience, it also allows suspending and debugging the evennia_launcher or evennia_runner +at points earlier than you could by running them externally and attaching. +In fact by the time the server and/or portal are running the launcher will have exited already. -This configuration allows you to launch Evennia from inside PyCharm. Besides convenience, it also allows suspending and debugging the evennia_launcher or evennia_runner at points earlier than you could by running them externally and attaching. In fact by the time the server and/or portal are running the launcher will have exited already. +#### On Windows +1. Go to `Run > Edit Configutations...` +2. Click the plus-symbol to add a new configuration and choose Python +3. Add the script: `\\.evenv\Scripts\evennia_launcher.py` (substitute your virtualenv if it's not named `evenv`) +4. Set script parameters to: `start -l` (-l enables console logging) +5. Ensure the chosen interpreter is your virtualenv +6. Set Working directory to your `mygame` folder (not your project folder nor evennia) +7. You can refer to the PyCharm documentation for general info, but you'll want to set at least a config name (like "MyMUD start" or similar). + +A dropdown box holding your new configurations should appear next to your PyCharm run button. +Select it start and press the debug icon to begin debugging. + +#### On Linux +1. Go to `Run > Edit Configutations...` +2. Click the plus-symbol to add a new configuration and choose Python +3. Add the script: `//.evenv/bin/twistd` (substitute your virtualenv if it's not named `evenv`) +4. Set script parameters to: `--python=//.evenv/lib/python3.11/site-packages/evennia/server/server.py --logger=evennia.utils.logger.GetServerLogObserver --pidfile=///server/server.pid --nodaemon` +5. Add an environment variable `DJANGO_SETTINGS_MODULE=server.conf.settings` +6. Ensure the chosen interpreter is your virtualenv +7. Set Working directory to your game folder (not your project folder nor evennia) +8. You can refer to the PyCharm documentation for general info, but you'll want to set at least a config name (like "MyMUD Server" or similar). + +A dropdown box holding your new configurations should appear next to your PyCharm run button. +Select it start and press the debug icon to begin debugging. +Note that this only starts the server process, you can either start the portal manually or set up +the configuration for the portal. The steps are very similar to the ones above. 1. Go to `Run > Edit Configutations...` -1. Click the plus-symbol to add a new configuration and choose Python -1. Add the script: `\\evenv\Scripts\evennia_launcher.py` (substitute your virtualenv if it's not named `evenv`) -1. Set script parameters to: `start -l` (-l enables console logging) -1. Ensure the chosen interpreter is from your virtualenv -1. Set Working directory to your `mygame` folder (not evenv nor evennia) -1. You can refer to the PyCharm documentation for general info, but you'll want to set at least a config name (like "MyMUD start" or similar). +2. Click the plus-symbol to add a new configuration and choose Python +3. Add the script: `//.evenv/bin/twistd` (substitute your virtualenv if it's not named `evenv`) +4. Set script parameters to: `--python=//.evenv/lib/python3.11/site-packages/evennia/server/portal/portal.py --logger=evennia.utils.logger.GetServerLogObserver --pidfile=///server/portal.pid --nodaemon` +5. Add an environment variable `DJANGO_SETTINGS_MODULE=server.conf.settings` +6. Ensure the chosen interpreter is your virtualenv +7. Set Working directory to your game folder (not your project folder nor evennia) +8. You can refer to the PyCharm documentation for general info, but you'll want to set at least a config name (like "MyMUD Portal" or similar). -Now set up a "stop" configuration by following the same steps as above, but set your Script parameters to: stop (and name the configuration appropriately). +You should now be able to start both modes and get full debugging. +If you want to go one step further, you can add another config to automatically start both. -A dropdown box holding your new configurations should appear next to your PyCharm run button. Select MyMUD start and press the debug icon to begin debugging. Depending on how far you let the program run, you may need to run your "MyMUD stop" config to actually stop the server, before you'll be able start it again. +1. Go to `Run > Edit Configutations...` +2. Click the plus-symbol to add a new configuration and choose Compound +3. Add your two previous configurations, name it appropriately and press Ok. -### Alternative config - utilizing logfiles as source of data - -This configuration takes a bit different approach as instead of focusing on getting the data back through logfiles. Reason for that is this way you can easily separate data streams, for example you rarely want to follow both server and portal at the same time, and this will allow it. This will also make sure to stop the evennia before starting it, essentially working as reload command (it will also include instructions how to disable that part of functionality). We will start by defining a configuration that will stop evennia. This assumes that `upfire` is your pycharm project name, and also the game name, hence the `upfire/upfire` path. - -1. Go to `Run > Edit Configutations...`\ -1. Click the plus-symbol to add a new configuration and choose the python interpreter to use (should be project default) -1. Name the configuration as "stop evennia" and fill rest of the fields accordingly to the image: -![Stop run configuration](https://i.imgur.com/gbkXhlG.png) -1. Press `Apply` - -Now we will define the start/reload command that will make sure that evennia is not running already, and then start the server in one go. -1. Go to `Run > Edit Configutations...`\ -1. Click the plus-symbol to add a new configuration and choose the python interpreter to use (should be project default) -1. Name the configuration as "start evennia" and fill rest of the fields accordingly to the image: -![Start run configuration](https://i.imgur.com/5YEjeHq.png) -1. Navigate to the `Logs` tab and add the log files you would like to follow. The picture shows -adding `portal.log` which will show itself in `portal` tab when running: -![Configuring logs following](https://i.imgur.com/gWYuOWl.png) -1. Skip the following steps if you don't want the launcher to stop evennia before starting. -1. Head back to `Configuration` tab and press the `+` sign at the bottom, under `Before launch....` -and select `Run another configuration` from the submenu that will pop up. -1. Click `stop evennia` and make sure that it's added to the list like on the image above. -1. Click `Apply` and close the run configuration window. - -You are now ready to go, and if you will fire up `start evennia` configuration you should see -following in the bottom panel: -![Example of running alternative configuration](https://i.imgur.com/nTfpC04.png) -and you can click through the tabs to check appropriate logs, or even the console output as it is -still running in interactive mode. \ No newline at end of file +You can now start your game with one click with full debugging active. diff --git a/docs/source/Coding/Unit-Testing.md b/docs/source/Coding/Unit-Testing.md index 1ff629ffbb..6587dfa580 100644 --- a/docs/source/Coding/Unit-Testing.md +++ b/docs/source/Coding/Unit-Testing.md @@ -5,63 +5,45 @@ A typical unit test set calls some function or method with a given input, looks at the result and makes sure that this result looks as expected. Rather than having lots of stand-alone test programs, Evennia makes use of a central *test runner*. This is a program that gathers all available tests all over the Evennia source code (called *test suites*) and runs them all in one go. Errors and tracebacks are reported. By default Evennia only tests itself. But you can also add your own tests to your game code and have Evennia run those for you. - ## Running the Evennia test suite To run the full Evennia test suite, go to your game folder and issue the command evennia test evennia -This will run all the evennia tests using the default settings. You could also run only a subset of -all tests by specifying a subpackage of the library: +This will run all the evennia tests using the default settings. You could also run only a subset of all tests by specifying a subpackage of the library: evennia test evennia.commands.default -A temporary database will be instantiated to manage the tests. If everything works out you will see -how many tests were run and how long it took. If something went wrong you will get error messages. -If you contribute to Evennia, this is a useful sanity check to see you haven't introduced an -unexpected bug. +A temporary database will be instantiated to manage the tests. If everything works out you will see how many tests were run and how long it took. If something went wrong you will get error messages. If you contribute to Evennia, this is a useful sanity check to see you haven't introduced an unexpected bug. ## Running custom game-dir unit tests -If you have implemented your own tests for your game you can run them from your game dir -with +If you have implemented your own tests for your game you can run them from your game dir with evennia test --settings settings.py . -The period (`.`) means to run all tests found in the current directory and all subdirectories. You -could also specify, say, `typeclasses` or `world` if you wanted to just run tests in those subdirs. +The period (`.`) means to run all tests found in the current directory and all subdirectories. You could also specify, say, `typeclasses` or `world` if you wanted to just run tests in those subdirs. -An important thing to note is that those tests will all be run using the _default Evennia settings_. -To run the tests with your own settings file you must use the `--settings` option: +An important thing to note is that those tests will all be run using the _default Evennia settings_. To run the tests with your own settings file you must use the `--settings` option: evennia test --settings settings.py . -The `--settings` option of Evennia takes a file name in the `mygame/server/conf` folder. It is -normally used to swap settings files for testing and development. In combination with `test`, it -forces Evennia to use this settings file over the default one. +The `--settings` option of Evennia takes a file name in the `mygame/server/conf` folder. It is normally used to swap settings files for testing and development. In combination with `test`, it forces Evennia to use this settings file over the default one. You can also test specific things by giving their path evennia test --settings settings.py world.tests.YourTest - ## Writing new unit tests -Evennia's test suite makes use of Django unit test system, which in turn relies on Python's -*unittest* module. +Evennia's test suite makes use of Django unit test system, which in turn relies on Python's *unittest* module. -To make the test runner find the tests, they must be put in a module named `test*.py` (so `test.py`, -`tests.py` etc). Such a test module will be found wherever it is in the package. It can be a good -idea to look at some of Evennia's `tests.py` modules to see how they look. +To make the test runner find the tests, they must be put in a module named `test*.py` (so `test.py`, `tests.py` etc). Such a test module will be found wherever it is in the package. It can be a good idea to look at some of Evennia's `tests.py` modules to see how they look. -Inside the module you need to put a class inheriting (at any distance) from `unittest.TestCase`. Each -method on that class that starts with `test_` will be run separately as a unit test. There -are two special, optional methods `setUp` and `tearDown` that will (if you define them) run before -_every_ test. This can be useful for setting up and deleting things. +Inside the module you need to put a class inheriting (at any distance) from `unittest.TestCase`. Each method on that class that starts with `test_` will be run separately as a unit test. There are two special, optional methods `setUp` and `tearDown` that will (if you define them) respectively run before and after _every_ test. This can be useful for creating, configuring and cleaning up things that every test in the class needs. -To actually test things, you use special `assert...` methods on the class. Most common on is -`assertEqual`, which makes sure a result is what you expect it to be. +To actually test things, you use special `assert...` methods on the class. Most common on is `assertEqual`, which makes sure a result is what you expect it to be. Here's an example of the principle. Let's assume you put this in `mygame/world/tests.py` and want to test a function in `mygame/world/myfunctions.py` @@ -76,7 +58,7 @@ and want to test a function in `mygame/world/myfunctions.py` class TestObj(unittest.TestCase): - "This tests a function myfunc." + """This tests a function myfunc.""" def setUp(self): """done before every of the test_ * methods below""" @@ -120,11 +102,11 @@ You might also want to read the [Python documentation for the unittest module](h ### Using the Evennia testing classes -Evennia offers many custom testing classes that helps with testing Evennia features. -They are all found in [evennia.utils.test_resources](evennia.utils.test_resources). Note that -these classes implement the `setUp` and `tearDown` already, so if you want to add stuff in them -yourself you should remember to use e.g. `super().setUp()` in your code. +Evennia offers many custom testing classes that helps with testing Evennia features. They are all found in [evennia.utils.test_resources](evennia.utils.test_resources). +```{important} +Note that these base classes implement the `setUp` and `tearDown` already, so if you want to add stuff in them yourself you should remember to use e.g. `super().setUp()` in your code. +``` #### Classes for testing your game dir These all use whatever setting you pass to them and works well for testing code in your game dir. @@ -147,10 +129,7 @@ These all use whatever setting you pass to them and works well for testing code without a timing component. - `.session` - A fake [Session](evennia.server.serversession.ServerSession) that mimics a player connecting to the game. It is used by `.account1` and has a sessid of 1. -- `EvenniaCommandTest` - has the same environment like `EvenniaTest` but also adds a special - [.call()](evennia.utils.test_resources.EvenniaCommandTestMixin.call) method specifically for - testing Evennia [Commands](../Components/Commands.md). It allows you to compare what the command _actually_ - returns to the player with what you expect. Read the `call` api doc for more info. +- `EvenniaCommandTest` - has the same environment like `EvenniaTest` but also adds a special [.call()](evennia.utils.test_resources.EvenniaCommandTestMixin.call) method specifically for testing Evennia [Commands](../Components/Commands.md). It allows you to compare what the command _actually_ returns to the player with what you expect. Read the `call` api doc for more info. - `EvenniaTestCase` - This is identical to the regular Python `TestCase` class, it's just there for naming symmetry with `BaseEvenniaTestCase` below. @@ -181,29 +160,24 @@ from commands import command as mycommand class TestSet(EvenniaCommandTest): - "tests the look command by simple call, using Char2 as a target" + """Tests the look command by simple call, using Char2 as a target""" def test_mycmd_char(self): self.call(mycommand.CmdMyLook(), "Char2", "Char2(#7)") def test_mycmd_room(self): - "tests the look command by simple call, with target as room" + """Tests the look command by simple call, with target as room""" self.call(mycommand.CmdMyLook(), "Room", "Room(#1)\nroom_desc\nExits: out(#3)\n" "You see: Obj(#4), Obj2(#5), Char2(#7)") ``` -When using `.call`, you don't need to specify the entire string; you can just give the beginning -of it and if it matches, that's enough. Use `\n` to denote line breaks and (this is a special for -the `.call` helper), `||` to indicate multiple uses of `.msg()` in the Command. The `.call` helper -has a lot of arguments for mimicing different ways of calling a Command, so make sure to -[read the API docs for .call()](evennia.utils.test_resources.EvenniaCommandTestMixin.call). +When using `.call`, you don't need to specify the entire string; you can just give the beginning of it and if it matches, that's enough. Use `\n` to denote line breaks and (this is a special for the `.call` helper), `||` to indicate multiple uses of `.msg()` in the Command. The `.call` helper has a lot of arguments for mimicing different ways of calling a Command, so make sure to [read the API docs for .call()](evennia.utils.test_resources.EvenniaCommandTestMixin.call). #### Classes for testing Evennia core These are used for testing Evennia itself. They provide the same resources as the classes -above but enforce Evennias default settings found in `evennia/settings_default.py`, ignoring -any settings changes in your game dir. +above but enforce Evennias default settings found in `evennia/settings_default.py`, ignoring any settings changes in your game dir. - `BaseEvenniaTest` - all the default objects above but with enforced default settings - `BaseEvenniaCommandTest` - for testing Commands, but with enforced default settings @@ -216,19 +190,12 @@ be useful if you want to mix your own testing classes: - `EvenniaCommandMixin` - A class mixin that adds the `.call()` Command-tester helper. If you want to help out writing unittests for Evennia, take a look at Evennia's [coveralls.io -page](https://coveralls.io/github/evennia/evennia). There you see which modules have any form of -test coverage and which does not. All help is appreciated! +page](https://coveralls.io/github/evennia/evennia). There you see which modules have any form of test coverage and which does not. All help is appreciated! ### Unit testing contribs with custom models -A special case is if you were to create a contribution to go to the `evennia/contrib` folder that -uses its [own database models](../Concepts/Models.md). The problem with this is that Evennia (and Django) will -only recognize models in `settings.INSTALLED_APPS`. If a user wants to use your contrib, they will -be required to add your models to their settings file. But since contribs are optional you cannot -add the model to Evennia's central `settings_default.py` file - this would always create your -optional models regardless of if the user wants them. But at the same time a contribution is a part -of the Evennia distribution and its unit tests should be run with all other Evennia tests using -`evennia test evennia`. +A special case is if you were to create a contribution to go to the `evennia/contrib` folder that uses its [own database models](../Concepts/Models.md). The problem with this is that Evennia (and Django) will +only recognize models in `settings.INSTALLED_APPS`. If a user wants to use your contrib, they will be required to add your models to their settings file. But since contribs are optional you cannot add the model to Evennia's central `settings_default.py` file - this would always create your optional models regardless of if the user wants them. But at the same time a contribution is a part of the Evennia distribution and its unit tests should be run with all other Evennia tests using `evennia test evennia`. The way to do this is to only temporarily add your models to the `INSTALLED_APPS` directory when the test runs. here is an example of how to do it. @@ -284,8 +251,7 @@ class TestMyModel(BaseEvenniaTest): ### A note on making the test runner faster -If you have custom models with a large number of migrations, creating the test database can take a very long time. If you don't require migrations to run for your tests, you can disable them with the -django-test-without-migrations package. To install it, simply: +If you have custom models with a large number of migrations, creating the test database can take a very long time. If you don't require migrations to run for your tests, you can disable them with the django-test-without-migrations package. To install it, simply: ``` $ pip install django-test-without-migrations diff --git a/docs/source/Components/Batch-Command-Processor.md b/docs/source/Components/Batch-Command-Processor.md index dda41e610c..2718d3a40b 100644 --- a/docs/source/Components/Batch-Command-Processor.md +++ b/docs/source/Components/Batch-Command-Processor.md @@ -111,18 +111,25 @@ Use `nn` and `bb` (next and back) to step through the file; e.g. `nn 12` will ju ## Limitations and Caveats -The batch-command processor is great for automating smaller builds or for testing new commands and objects repeatedly without having to write so much. There are several caveats you have to be aware of when using the batch-command processor for building larger, complex worlds though. +The main issue with batch-command builds is that when you run a batch-command script you (*you*, as in your character) are actually moving around in the game creating and building rooms in sequence, just as if you had been entering those commands manually, one by one. -The main issue is that when you run a batch-command script you (*you*, as in your superuser -character) are actually moving around in the game creating and building rooms in sequence, just as if you had been entering those commands manually, one by one. You have to take this into account when creating the file, so that you can 'walk' (or teleport) to the right places in order. +You have to take this into account when creating the file, so that you can 'walk' (or teleport) to the right places in order. It also means that you may be affected by the things you create, such as mobs attacking you or traps immediately hurting you. -This also means there are several pitfalls when designing and adding certain types of objects. Here are some examples: +If you know that your rooms and objects are going to be deployed via a batch-command script, you can plan for this beforehand. To help with this, you can use the fact that the non-persistent Attribute `batch_batchmode` is _only_ set while the batch-processor is running. Here's an example of how to use it: -- *Rooms that change your [Command Set](./Command-Sets.md)*: Imagine that you build a 'dark' room, which severely limits the cmdsets of those entering it (maybe you have to find the light switch to proceed). In your batch script you would create this room, then teleport to it - and promptly be shifted into the dark state where none of your normal build commands work ... -- *Auto-teleportation*: Rooms that automatically teleport those that enter them to another place (like a trap room, for example). You would be teleported away too. -- *Mobiles*: If you add aggressive mobs, they might attack you, drawing you into combat. If they have AI they might even follow you around when building - or they might move away from you before you've had time to finish describing and equipping them! +```python +class HorribleTrapRoom(Room): + # ... + def at_object_receive(self, received_obj, source_location, **kwargs): + """Apply the horrible traps the moment the room is entered!""" + if received_obj.ndb.batch_batchmode: + # skip if we are currently building the room + return + # commence horrible trap code +``` +So we skip the hook if we are currently building the room. This can work for anything, including making sure mobs don't start attacking you while you are creating them. -The solution to all these is to plan ahead. Make sure that superusers are never affected by whatever effects are in play. Add an on/off switch to objects and make sure it's always set to *off* upon creation. It's all doable, one just needs to keep it in mind. +There are other strategies, such as adding an on/off switch to actiive objects and make sure it's always set to *off* upon creation. ## Editor highlighting for .ev files diff --git a/docs/source/Components/EvMenu.md b/docs/source/Components/EvMenu.md index 8a1342c7ae..01159d0cd8 100644 --- a/docs/source/Components/EvMenu.md +++ b/docs/source/Components/EvMenu.md @@ -501,7 +501,7 @@ See `evennia/utils/evmenu.py` for the details of their default implementations. ## EvMenu templating language -In evmenu.py are two helper functions `parse_menu_template` and `template2menu` that is used to parse a _menu template_ string into an EvMenu: +In `evmenu.py` are two helper functions `parse_menu_template` and `template2menu` that is used to parse a _menu template_ string into an EvMenu: evmenu.template2menu(caller, menu_template, goto_callables) diff --git a/docs/source/Components/FuncParser.md b/docs/source/Components/FuncParser.md index 458bbeb3dc..35abebb65e 100644 --- a/docs/source/Components/FuncParser.md +++ b/docs/source/Components/FuncParser.md @@ -20,7 +20,7 @@ To escape the inlinefunc (e.g. to explain to someone how it works, use `$$`) You say "To get a random value from 1 to 5, use $randint(1,5)." ``` -While `randint` may look and work just like `random.randint` from the standard Python library, it is _not_. Instead it's a `inlinefunc` named `randint` made available to Evennia (which in turn uses the standard library function). For security reasons, only functions explicitly assigned to be used as inlinefuncs are viable. +While `randint` may look and work just like `random.randint` from the standard Python library, it is _not_. Instead it's an `inlinefunc` named `randint` made available to Evennia (which in turn uses the standard library function). For security reasons, only functions explicitly assigned to be used as inlinefuncs are viable. You can apply the `FuncParser` manually. The parser is initialized with the inlinefunc(s) it's supposed to recognize in that string. Below is an example of a parser only understanding a single `$pow` inlinefunc: @@ -84,7 +84,7 @@ parser = FuncParser(["game.myfuncparser_callables", "game.more_funcparser_callab Here, `callables` points to a collection of normal Python functions (see next section) for you to make available to the parser as you parse strings with it. It can either be -- A `dict` of `{"functionname": callable, ...}`. This allows you do pick and choose exactly which callables +- A `dict` of `{"functionname": callable, ...}`. This allows you to pick and choose exactly which callables to include and how they should be named. Do you want a callable to be available under more than one name? Just add it multiple times to the dict, with a different key. - A `module` or (more commonly) a `python-path` to a module. This module can define a dict @@ -235,7 +235,7 @@ everything but the outermost double quotes. Since you don't know in which order users may use your callables, they should always check the types of its inputs and convert to the type the callable needs. -Note also that when converting from strings, there are limits what inputs you +Note also that when converting from strings, there are limits on what inputs you can support. This is because FunctionParser strings can be used by non-developer players/builders and some things (such as complex classes/callables etc) are just not safe/possible to convert from string diff --git a/docs/source/Components/Locks.md b/docs/source/Components/Locks.md index 1683997158..ab414b4be6 100644 --- a/docs/source/Components/Locks.md +++ b/docs/source/Components/Locks.md @@ -182,6 +182,9 @@ Some useful default lockfuncs (see `src/locks/lockfuncs.py` for more): - `attr(attrname, value)` - checks so an attribute exists on accessing_object *and* has the given value. - `attr_gt(attrname, value)` - checks so accessing_object has a value larger (`>`) than the given value. - `attr_ge, attr_lt, attr_le, attr_ne` - corresponding for `>=`, `<`, `<=` and `!=`. +- `tag(tagkey[, category])` - checks if the accessing_object has the specified tag and optional category. +- `objtag(tagkey[, category])` - checks if the *accessed_object* has the specified tag and optional category. +- `objloctag(tagkey[, category])` - checks if the *accessed_obj*'s location has the specified tag and optional category. - `holds(objid)` - checks so the accessing objects contains an object of given name or dbref. - `inside()` - checks so the accessing object is inside the accessed object (the inverse of `holds()`). - `pperm(perm)`, `pid(num)/pdbref(num)` - same as `perm`, `id/dbref` but always looks for permissions and dbrefs of *Accounts*, not on Characters. diff --git a/docs/source/Components/Prototypes.md b/docs/source/Components/Prototypes.md index 901859a2f1..c99e49061f 100644 --- a/docs/source/Components/Prototypes.md +++ b/docs/source/Components/Prototypes.md @@ -39,7 +39,7 @@ In dictionary form, a prototype can look something like this: ``` If you wanted to load it into the spawner in-game you could just put all on one line: - spawn {"prototype_key="house", "key": "Large house", ...} + spawn {"prototype_key"="house", "key": "Large house", ...} > Note that the prototype dict as given on the command line must be a valid Python structure - so you need to put quotes around strings etc. For security reasons, a dict inserted from-in game cannot have any other advanced Python functionality, such as executable code, `lambda` etc. If builders are supposed to be able to use such features, you need to offer them through [$protfuncs](Spawner-and- Prototypes#protfuncs), embedded runnable functions that you have full control to check and vet before running. diff --git a/docs/source/Components/Tags.md b/docs/source/Components/Tags.md index 42aaa92455..9e8643113a 100644 --- a/docs/source/Components/Tags.md +++ b/docs/source/Components/Tags.md @@ -1,5 +1,7 @@ # Tags +_Tags_ are short text lables one can 'attach' to objects in order to organize, group and quickly find out their properties, similarly to how you attach labels to your luggage. + ```{code-block} :caption: In game > tag obj = tagname @@ -28,7 +30,7 @@ class Sword(DefaultObject): ``` -In-game, tags are controlled `tag` command: +In-game, tags are controlled by the default `tag` command: > tag Chair = furniture > tag Chair = furniture @@ -37,12 +39,13 @@ In-game, tags are controlled `tag` command: > tag/search furniture Chair, Sofa, Table -_Tags_ are short text lables one can 'hang' on objects in order to organize, group and quickly find out their properties. An Evennia entity can be tagged by any number of tags. They are more efficient than [Attributes](./Attributes.md) since on the database-side, Tags are _shared_ between all objects with that particular tag. A tag does not carry a value in itself; it either sits on the entity -You manage Tags using the `TagHandler` (`.tags`) on typeclassed entities. You can also assign Tags on the class level through the `TagProperty` (one tag, one category per line) or the `TagCategoryProperty` (one category, multiple tags per line). Both of these use the `TagHandler` under the hood, they are just convenient ways to add tags already when you define your class. +An Evennia entity can be tagged by any number of tags. Tags are more efficient than [Attributes](./Attributes.md) since on the database-side, Tags are _shared_ between all objects with that particular tag. A tag does not carry a value in itself; rather the existence of the tag itself is what is checked - a given object either has a given tag or not. + +In code, you manage Tags using the `TagHandler` (`.tags`) on typeclassed entities. You can also assign Tags on the class level through the `TagProperty` (one tag, one category per line) or the `TagCategoryProperty` (one category, multiple tags per line). Both of these use the `TagHandler` under the hood, they are just convenient ways to add tags already when you define your class. Above, the tags inform us that the `Sword` is both sharp and can be wielded. If that's all they do, they could just be a normal Python flag. When tags become important is if there are a lot of objects with different combinations of tags. Maybe you have a magical spell that dulls _all_ sharp-edged objects in the castle - whether sword, dagger, spear or kitchen knife! You can then just grab all objects with the `has_sharp_edge` tag. -Another example would be a weather script affecting all rooms tagged as `outdoors` or finding all characters tagged with `belongs_to_fighter_guild`. +Another example would be a weather script affecting all rooms tagged as `outdoors` or finding all characters tagged with the `belongs_to_fighter_guild` tag. In Evennia, Tags are technically also used to implement `Aliases` (alternative names for objects) and `Permissions` (simple strings for [Locks](./Locks.md) to check for). diff --git a/docs/source/Components/Typeclasses.md b/docs/source/Components/Typeclasses.md index 19e221ac23..f48ffb5d31 100644 --- a/docs/source/Components/Typeclasses.md +++ b/docs/source/Components/Typeclasses.md @@ -244,18 +244,18 @@ The arguments to this method are described [in the API docs here](github:evennia Technically, typeclasses are [Django proxy models](https://docs.djangoproject.com/en/4.1/topics/db/models/#proxy-models). The only database models that are "real" in the typeclass system (that is, are represented by actual tables in the database) are `AccountDB`, `ObjectDB`, `ScriptDB` and `ChannelDB` (there are also [Attributes](./Attributes.md) and [Tags](./Tags.md) but they are not typeclasses themselves). All the subclasses of them are "proxies", extending them with Python code without actually modifying the database layout. -Evennia modifies Django's proxy model in various ways to allow them to work without any boiler plate (for example you don't need to set the Django "proxy" property in the model `Meta` subclass, Evennia handles this for you using metaclasses). Evennia also makes sure you can query subclasses as well as patches django to allow multiple inheritance from the same base class. +Evennia modifies Django's proxy model in various ways to allow them to work without any boiler plate (for example you don't need to set the Django "proxy" property in the model `Meta` subclass, Evennia handles this for you using metaclasses). Evennia also makes sure you can query subclasses as well as patches Django to allow multiple inheritance from the same base class. ### Caveats -Evennia uses the *idmapper* to cache its typeclasses (Django proxy models) in memory. The idmapper allows things like on-object handlers and properties to be stored on typeclass instances and to not get lost as long as the server is running (they will only be cleared on a Server reload). Django does not work like this by default; by default every time you search for an object in the database you'll get a *different* instance of that object back and anything you stored on it that was not in the database would be lost. The bottom line is that Evennia's Typeclass instances subside in memory a lot longer than vanilla Django model instance do. +Evennia uses the *idmapper* to cache its typeclasses (Django proxy models) in memory. The idmapper allows things like on-object handlers and properties to be stored on typeclass instances and to not get lost as long as the server is running (they will only be cleared on a Server reload). Django does not work like this by default; by default every time you search for an object in the database you'll get a *different* instance of that object back and anything you stored on it that was not in the database would be lost. The bottom line is that Evennia's Typeclass instances subsist in memory a lot longer than vanilla Django model instances do. There is one caveat to consider with this, and that relates to [making your own models](New- Models): Foreign relationships to typeclasses are cached by Django and that means that if you were to change an object in a foreign relationship via some other means than via that relationship, the object seeing the relationship may not reliably update but will still see its old cached version. Due to typeclasses staying so long in memory, stale caches of such relationships could be more visible than common in Django. See the [closed issue #1098 and its comments](https://github.com/evennia/evennia/issues/1098) for examples and solutions. ## Will I run out of dbrefs? -Evennia does not re-use its `#dbrefs`. This means new objects get an ever-increasing `#dbref`, also if you delete older objects. There are technical and safety reasons for this. But you may wonder if this means you have to worry about a big game 'running out' of dbref integers eventually. +Evennia does not re-use its `#dbrefs`. This means new objects get an ever-increasing `#dbref`, even if you delete older objects. There are technical and safety reasons for this. But you may wonder if this means you have to worry about a big game 'running out' of dbref integers eventually. The answer is simply **no**. diff --git a/docs/source/Components/Web-API.md b/docs/source/Components/Web-API.md index da4b70e31c..0a11445d0c 100644 --- a/docs/source/Components/Web-API.md +++ b/docs/source/Components/Web-API.md @@ -102,7 +102,7 @@ and static files. - The api code is located in `evennia/web/api/` - the `url.py` file here is responsible for collecting all view-classes. -Contrary to other web components, there is no pre-made urls.py set up for +Contrary to other web components, there is no pre-made `urls.py` set up for `mygame/web/api/`. This is because the registration of models with the api is strongly integrated with the REST api functionality. Easiest is probably to copy over `evennia/web/api/urls.py` and modify it in place. diff --git a/docs/source/Components/Website.md b/docs/source/Components/Website.md index c4dddad948..de784b58cc 100644 --- a/docs/source/Components/Website.md +++ b/docs/source/Components/Website.md @@ -104,7 +104,7 @@ This is the layout of the `mygame/web/` folder relevant for the website: Game folders created with older versions of Evennia will lack most of this convenient `mygame/web/` layout. If you use a game dir from an older version, you should copy over the missing `evennia/game_template/web/` folders from - there, as well as the main urls.py file. + there, as well as the main `urls.py` file. ``` diff --git a/docs/source/Concepts/Colors.md b/docs/source/Concepts/Colors.md index d50eda3aea..c59d3099f5 100644 --- a/docs/source/Concepts/Colors.md +++ b/docs/source/Concepts/Colors.md @@ -5,27 +5,21 @@ Color can be a very useful tool for your game. It can be used to increase readability and make your game more appealing visually. -Remember however that, with the exception of the webclient, you generally don't control the client -used to connect to the game. There is, for example, one special tag meaning "yellow". But exactly -*which* hue of yellow is actually displayed on the user's screen depends on the settings of their -particular mud client. They could even swap the colours around or turn them off altogether if so -desired. Some clients don't even support color - text games are also played with special reading -equipment by people who are blind or have otherwise diminished eyesight. +Remember however that, with the exception of the webclient, you generally don't control the client used to connect to the game. There is, for example, one special tag meaning "yellow". But exactly *which* hue of yellow is actually displayed on the user's screen depends on the settings of their particular mud client. They could even swap the colours around or turn them off altogether if so desired. Some clients don't even support color - text games are also played with special reading equipment by people who are blind or have otherwise diminished eyesight. So a good rule of thumb is to use colour to enhance your game but don't *rely* on it to display -critical information. If you are coding the game, you can add functionality to let users disable -colours as they please, as described [here](../Howtos/Manually-Configuring-Color.md). +critical information. The default `screenreader` command will automatically turn off all color for a user (as well as clean up many line decorations etc). Make sure your game is still playable and understandable with this active. Evennia supports two color standards: - `ANSI` - 16 foreground colors + 8 background colors. Widely supported. -- `Xterm256` - 128 RGB colors, 32 greyscales. Not always supported in old clients. +- `Xterm256` - 128 RGB colors, 32 greyscales. Not always supported in old clients. Falls back to ANSI. +- `Truecolor` - 24B RGB colors using hex notation. Not supported by many clients. Falls back to `XTerm256`. To see which colours your client support, use the default `color` command. This will list all -available colours for ANSI and Xterm256 along with the codes you use for them. The -central ansi/xterm256 parser is located in [evennia/utils/ansi.py](evennia.utils.ansi). +available colours for ANSI and Xterm256, as well as a selection of True color codes along with the codes you use for them. The central ansi/xterm256 parser is located in [evennia/utils/ansi.py](evennia.utils.ansi), the true color one in [evennia/utils/true/hex_colors.py](evennia.utils.hex_colors). -## ANSI colours +## ANSI colours and symbols Evennia supports the `ANSI` standard for text. This is by far the most supported MUD-color standard, available in all but the most ancient mud clients. @@ -35,39 +29,39 @@ will see the text in the specified colour, otherwise the tags will be stripped ( For the webclient, Evennia will translate the codes to CSS tags. -| Tag | Effect | -| ---- | ----- | -| \|n | end all color formatting, including background colors. | -|\|r | bright red foreground color | -|\|g | bright green foreground color | -|\|y | bright yellow foreground color | -|\|b | bright blue foreground color | -|\|m | bright magentaforeground color | -|\|c | bright cyan foreground color | -|\|w | bright white foreground color | -|\|x | bright black (dark grey) foreground color | -|\|R | normal red foreground color | -|\|G | normal green foreground color | -|\|Y | normal yellow foreground color | -|\|B | normal blue foreground color | -|\|M | normal magentaforeground color | -|\|C | normal cyan foreground color | -|\|W | normal white (light grey) foreground color | -|\|X | normal black foreground color | -| \|\[# | background colours, e.g. \|\[c for bright cyan background and \|\[C a normal cyan background. | -| \|!# | foreground color that inherits brightness from previous tags. Always uppcase, like \|!R | -| \|h | make any following foreground ANSI colors bright (for Xterm256/true color makes the font bold if client supports it). Use with \|!#. Technically, \|h\|G == \|g. | -| \|H | negates the effects of \|h | -| \|u | underline font if client supports it | -| \|U | negates the effects of \|u | -| \|i | italic font if client supports it | -| \|I | negates the effects of \|i | -| \|s | strikethrough font if client supports it | -| \|S | negates the effects of \|s | -| \|/ | line break. Use instead of Python \\n when adding strings from in-game. | -| \|- | tab character when adding strings in-game. Can vay per client, so usually better with spaces. | -| \|_ | a space. Only needed to avoid auto-cropping at the end of a in-game input | -| \|* | invert the current text/background colours, like a marker. See note below. | +| Tag | Effect | | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | +| \|n | end all color formatting, including background colors. | | +| \|r | bright red foreground color | | +| \|g | bright green foreground color | | +| \|y | bright yellow foreground color | | +| \|b | bright blue foreground color | | +| \|m | bright magentaforeground color | | +| \|c | bright cyan foreground color | | +| \|w | bright white foreground color | | +| \|x | bright black (dark grey) foreground color | | +| \|R | normal red foreground color | | +| \|G | normal green foreground color | | +| \|Y | normal yellow foreground color | | +| \|B | normal blue foreground color | | +| \|M | normal magentaforeground color | | +| \|C | normal cyan foreground color | | +| \|W | normal white (light grey) foreground color | | +| \|X | normal black foreground color | | +| \|\[# | background colours, e.g. \|\[c for bright cyan background and \|\[C a normal cyan background. | | +| \|!# | foreground color that inherits brightness from previous tags. Always uppcase, like \|!R | | +| \|h | make any following foreground ANSI colors bright (for Xterm256/true color makes the font bold if client supports it). Use with \|!#. Technically, \|h\|G == \|g. | | +| \|H | negates the effects of \|h | | +| \|u | underline font (not supported in Evennia webclient) | | +| \|U | negates the effects of \|u | | +| \|i | italic font (not supported in Evennia webclient) | | +| \|I | negates the effects of \|i | | +| \|s | strikethrough font (not supported in Evennia webclient) | | +| \|S | negates the effects of \|s | | +| \|/ | line break. Use instead of Python \\n when adding strings from in-game. | | +| \|- | tab character when adding strings in-game. Can vay per client, so usually better with spaces. | | +| \|_ | a space. Only needed to avoid auto-cropping at the end of a in-game input | | +| \|* | invert the current text/background colours, like a marker. See note below. | | Here is an example of the tags in action: @@ -187,11 +181,9 @@ See the [Wikipedia entry on web colors](https://en.wikipedia.org/wiki/Web_colors ``` Some clients support 24-bit colors. This is also called [true color](https://en.wikipedia.org/wiki/Color_depth#True_color_(24-bit)). -Not all clients support true color, they will instead see the closest equivalent. It's important to bear in mind that things may look quite -different from what you intended if you use subtle gradations in true color and it's viewed with a client that doesn't support true color. +Not all clients support true color, they will instead see the closest equivalent. It's important to bear in mind that things may look quite different from what you intended if you use subtle gradations in true color and it's viewed with a client that doesn't support true color. The hexadecimal color codes used here are the same ones used in web design. - | Tag | Effect | | -------- | ---- | | \|#$$$$$$ | foreground RGB (red/green/blue), 6-digit hexadecimal format, where $ = 0-F | diff --git a/docs/source/Concepts/Internationalization.md b/docs/source/Concepts/Internationalization.md index 31c57c5407..0850770651 100644 --- a/docs/source/Concepts/Internationalization.md +++ b/docs/source/Concepts/Internationalization.md @@ -15,7 +15,7 @@ updated after Sept 2022 will be missing some translations. +---------------+----------------------+--------------+ | Language Code | Language | Last updated | +===============+======================+==============+ -| de | German | Dec 2022 | +| de | German | Aug 2024 | +---------------+----------------------+--------------+ | es | Spanish | Aug 2019 | +---------------+----------------------+--------------+ @@ -35,7 +35,7 @@ updated after Sept 2022 will be missing some translations. +---------------+----------------------+--------------+ | sv | Swedish | Sep 2022 | +---------------+----------------------+--------------+ -| zh | Chinese (simplified) | May 2019 | +| zh | Chinese (simplified) | Oct 2024 | +---------------+----------------------+--------------+ ``` @@ -117,21 +117,21 @@ find there. You can edit this with a normal text editor but it is easiest if you use a special po-file editor from the web (search the web for "po editor" for many free alternatives), for example: - - [gtranslator](https://wiki.gnome.org/Apps/Gtranslator) - - [poeditor](https://poeditor.com/) +- [gtranslator](https://wiki.gnome.org/Apps/Gtranslator) +- [poeditor](https://poeditor.com/) The concept of translating is simple, it's just a matter of taking the english -strings you find in the `**.po` file and add your language's translation best +strings you find in the `django.po` file and add your language's translation best you can. Once you are done, run - `evennia compilemessages` + evennia compilemessages This will compile all languages. Check your language and also check back to your `.po` file in case the process updated it - you may need to fill in some missing header fields and should usually note who did the translation. When you are done, make sure that everyone can benefit from your translation! -Make a PR against Evennia with the updated `**.po` file. Less ideally (if git is +Make a PR against Evennia with the updated `django.po` file. Less ideally (if git is not your thing) you can also attach it to a new post in our forums. ### Hints on translation @@ -161,3 +161,31 @@ English anyway. \n(Traceback was logged {timestamp})" Swedish: "Fel medan cmdset laddades: Ingen cmdset-klass med namn '{classname}' i {path}. \n(Traceback loggades {timestamp})" + +## Marking Strings in Code for Translation + +If you modify the Python module code, you can mark strings for translation by passing them to the `gettext()` method. In Evennia, this is usually imported as `_()` for convenience: + +```python +from django.utils.translation import gettext as _ +string = _("Text to translate") +``` + +### Formatting Considerations + +When using formatted strings, ensure that you pass the "raw" string to `gettext` for translation first and then format the output. Otherwise, placeholders will be replaced before translation occurs, preventing the correct string from being found in the `.po` file. It's also recommended to use named placeholders (e.g., `{char}`) instead of positional ones (e.g., `{}`) for better readability and maintainability. + +```python +# incorrect: +string2 = _("Hello {char}!".format(char=caller.name)) + +# correct: +string2 = _("Hello {char}!").format(char=caller.name) +``` + +This is also why f-strings don't work with `gettext`: + +```python +# will not work +string = _(f"Hello {char}!") +``` diff --git a/docs/source/Concepts/Messagepath.md b/docs/source/Concepts/Messagepath.md index 73dc7567fa..49047b9ae7 100644 --- a/docs/source/Concepts/Messagepath.md +++ b/docs/source/Concepts/Messagepath.md @@ -43,7 +43,7 @@ For the `look`-command (and anything else written by the player), the `text` `co ### Inputfuncs -On the Evennia server side, a list of [inputfucs](Inputuncs) are registered. You can add your own by extending `settings.INPUT_FUNC_MODULES`. +On the Evennia server side, a list of [inputfuncs](Inputfuncs) are registered. You can add your own by extending `settings.INPUT_FUNC_MODULES`. ```python inputfunc_commandname(session, *args, **kwargs) diff --git a/docs/source/Contribs/Contrib-Clothing.md b/docs/source/Contribs/Contrib-Clothing.md index 3a5dcf6502..219fee2dcc 100644 --- a/docs/source/Contribs/Contrib-Clothing.md +++ b/docs/source/Contribs/Contrib-Clothing.md @@ -21,7 +21,7 @@ Would result in this added description: ## Installation To install, import this module and have your default character -inherit from ClothedCharacter in your game's characters.py file: +inherit from ClothedCharacter in your game's `characters.py` file: ```python diff --git a/docs/source/Contribs/Contrib-Godotwebsocket.md b/docs/source/Contribs/Contrib-Godotwebsocket.md index ffe10e1d68..1169ccdef8 100644 --- a/docs/source/Contribs/Contrib-Godotwebsocket.md +++ b/docs/source/Contribs/Contrib-Godotwebsocket.md @@ -9,7 +9,7 @@ You can use Godot to provide advanced functionality with proper Evennia support. ## Installation -You need to add the following settings in your settings.py and restart evennia. +You need to add the following settings in your `settings.py` and restart evennia. ```python PORTAL_SERVICES_PLUGIN_MODULES.append('evennia.contrib.base_systems.godotwebsocket.webclient') @@ -64,7 +64,7 @@ This will connect when the Scene is ready, poll and print the data when we recei extends Node # The URL we will connect to. -var websocket_url = "ws://localhost:4008" +var websocket_url = "ws://127.0.0.1:4008" var socket := WebSocketPeer.new() func _ready(): @@ -144,7 +144,7 @@ func _on_button_pressed(): extends Node # The URL we will connect to. -var websocket_url = "ws://localhost:4008" +var websocket_url = "ws://127.0.0.1:4008" var socket := WebSocketPeer.new() @onready var output_label = $"../Panel/VBoxContainer/RichTextLabel" @@ -193,6 +193,7 @@ func _exit_tree(): ``` + ---- This document page is generated from `evennia/contrib/base_systems/godotwebsocket/README.md`. Changes to this diff --git a/docs/source/Contribs/Contrib-Ingame-Reports.md b/docs/source/Contribs/Contrib-Ingame-Reports.md index 20157af3f9..e7958e773e 100644 --- a/docs/source/Contribs/Contrib-Ingame-Reports.md +++ b/docs/source/Contribs/Contrib-Ingame-Reports.md @@ -77,7 +77,7 @@ The contrib is designed to make adding new types of reports to the system as sim #### Update your settings -The contrib optionally references `INGAME_REPORT_TYPES` in your settings.py to see which types of reports can be managed. If you want to change the available report types, you'll need to define this setting. +The contrib optionally references `INGAME_REPORT_TYPES` in your `settings.py` to see which types of reports can be managed. If you want to change the available report types, you'll need to define this setting. ```python # in server/conf/settings.py diff --git a/docs/source/Contribs/Contrib-Llm.md b/docs/source/Contribs/Contrib-Llm.md index ffae9c1a84..85d33bfbb0 100644 --- a/docs/source/Contribs/Contrib-Llm.md +++ b/docs/source/Contribs/Contrib-Llm.md @@ -66,7 +66,8 @@ LLM_PATH = "/api/v1/generate" # if you wanted to authenticated to some external service, you could # add an Authenticate header here with a token -LLM_HEADERS = {"Content-Type": "application/json"} +# note that the content of each header must be an iterable +LLM_HEADERS = {"Content-Type": ["application/json"]} # this key will be inserted in the request, with your user-input LLM_PROMPT_KEYNAME = "prompt" @@ -77,7 +78,7 @@ LLM_REQUEST_BODY = { "temperature": 0.7, # 0-2. higher=more random, lower=predictable } # helps guide the NPC AI. See the LLNPC section. -LLM_PROMPT_PREFIx = ( +LLM_PROMPT_PREFIX = ( "You are roleplaying as {name}, a {desc} existing in {location}. " "Answer with short sentences. Only respond as {name} would. " "From here on, the conversation between {name} and {character} begins." @@ -148,8 +149,8 @@ Here is an untested example of the Evennia setting for calling [OpenAI's v1/comp ```python LLM_HOST = "https://api.openai.com" LLM_PATH = "/v1/completions" -LLM_HEADERS = {"Content-Type": "application/json", - "Authorization": "Bearer YOUR_OPENAI_API_KEY"} +LLM_HEADERS = {"Content-Type": ["application/json"], + "Authorization": ["Bearer YOUR_OPENAI_API_KEY"]} LLM_PROMPT_KEYNAME = "prompt" LLM_REQUEST_BODY = { "model": "gpt-3.5-turbo", diff --git a/docs/source/Contribs/Contrib-Mapbuilder.md b/docs/source/Contribs/Contrib-Mapbuilder.md index ae18378f24..c2d365f865 100644 --- a/docs/source/Contribs/Contrib-Mapbuilder.md +++ b/docs/source/Contribs/Contrib-Mapbuilder.md @@ -86,7 +86,7 @@ For example: Below are two examples showcasing the use of automatic exit generation and custom exit generation. Whilst located, and can be used, from this module for -convenience The below example code should be in mymap.py in mygame/world. +convenience The below example code should be in `mymap.py` in mygame/world. ### Example One diff --git a/docs/source/Contribs/Contrib-Storage.md b/docs/source/Contribs/Contrib-Storage.md new file mode 100644 index 0000000000..fa720ece20 --- /dev/null +++ b/docs/source/Contribs/Contrib-Storage.md @@ -0,0 +1,42 @@ +# Item Storage + +Contribution by helpme (2024) + +This module allows certain rooms to be marked as storage locations. + +In those rooms, players can `list`, `store`, and `retrieve` items. Storages can be shared or individual. + +## Installation + +This utility adds the storage-related commands. Import the module into your commands and add it to your command set to make it available. + +Specifically, in `mygame/commands/default_cmdsets.py`: + +```python +... +from evennia.contrib.game_systems.storage import StorageCmdSet # <--- + +class CharacterCmdset(default_cmds.Character_CmdSet): + ... + def at_cmdset_creation(self): + ... + self.add(StorageCmdSet) # <--- + +``` + +Then `reload` to make the `list`, `retrieve`, `store`, and `storage` commands available. + +## Usage + +To mark a location as having item storage, use the `storage` command. By default this is a builder-level command. Storage can be shared, which means everyone using the storage can access all items stored there, or individual, which means only the person who stores an item can retrieve it. See `help storage` for further details. + +## Technical info + +This is a tag-based system. Rooms set as storage rooms are tagged with an identifier marking them as shared or not. Items stored in those rooms are tagged with the storage room identifier and, if the storage room is not shared, the character identifier, and then they are removed from the grid i.e. their location is set to `None`. Upon retrieval, items are untagged and moved back to character inventories. + +When a room is unmarked as storage with the `storage` command, all stored objects are untagged and dropped to the room. You should use the `storage` command to create and remove storages, as otherwise stored objects may become lost. + +---- + +This document page is generated from `evennia/contrib/game_systems/storage/README.md`. Changes to this +file will be overwritten, so edit that file rather than this one. diff --git a/docs/source/Contribs/Contribs-Overview.md b/docs/source/Contribs/Contribs-Overview.md index f1d7308eaf..232284616e 100644 --- a/docs/source/Contribs/Contribs-Overview.md +++ b/docs/source/Contribs/Contribs-Overview.md @@ -7,7 +7,7 @@ in the [Community Contribs & Snippets][forum] forum. _Contribs_ are optional code snippets and systems contributed by the Evennia community. They vary in size and complexity and may be more specific about game types and styles than 'core' Evennia. -This page is auto-generated and summarizes all **51** contribs currently included +This page is auto-generated and summarizes all **52** contribs currently included with the Evennia distribution. All contrib categories are imported from `evennia.contrib`, such as @@ -37,9 +37,9 @@ If you want to add a contrib, see [the contrib guidelines](./Contribs-Guidelines | [health_bar](#health_bar) | [ingame_map_display](#ingame_map_display) | [ingame_python](#ingame_python) | [ingame_reports](#ingame_reports) | [llm](#llm) | | [mail](#mail) | [mapbuilder](#mapbuilder) | [menu_login](#menu_login) | [mirror](#mirror) | [multidescer](#multidescer) | | [mux_comms_cmds](#mux_comms_cmds) | [name_generator](#name_generator) | [puzzles](#puzzles) | [random_string_generator](#random_string_generator) | [red_button](#red_button) | -| [rpsystem](#rpsystem) | [simpledoor](#simpledoor) | [slow_exit](#slow_exit) | [talking_npc](#talking_npc) | [traits](#traits) | -| [tree_select](#tree_select) | [turnbattle](#turnbattle) | [tutorial_world](#tutorial_world) | [unixcommand](#unixcommand) | [wilderness](#wilderness) | -| [xyzgrid](#xyzgrid) | +| [rpsystem](#rpsystem) | [simpledoor](#simpledoor) | [slow_exit](#slow_exit) | [storage](#storage) | [talking_npc](#talking_npc) | +| [traits](#traits) | [tree_select](#tree_select) | [turnbattle](#turnbattle) | [tutorial_world](#tutorial_world) | [unixcommand](#unixcommand) | +| [wilderness](#wilderness) | [xyzgrid](#xyzgrid) | @@ -288,6 +288,7 @@ Contrib-Gendersub.md Contrib-Mail.md Contrib-Multidescer.md Contrib-Puzzles.md +Contrib-Storage.md Contrib-Turnbattle.md ``` @@ -420,6 +421,16 @@ the puzzle entirely from in-game. +### `storage` + +_Contribution by helpme (2024)_ + +This module allows certain rooms to be marked as storage locations. + +[Read the documentation](./Contrib-Storage.md) - [Browse the Code](evennia.contrib.game_systems.storage) + + + ### `turnbattle` _Contribution by Tim Ashley Jenkins, 2017_ diff --git a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Creating-Things.md b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Creating-Things.md index f93d8188ec..3e9bea12c2 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Creating-Things.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Creating-Things.md @@ -80,7 +80,7 @@ The `east` exit has a `key` of `east`, a `location` of `Meadow` and a `destinati Meadow -> east -> Forest Forest -> west -> Meadow -In-game you do this with `tunnel` and `dig` commands, bit if you want to ever set up these links in code, you can do it like this: +In-game you do this with `tunnel` and `dig` commands, but if you want to set up these links in code, you can do it like this: ```python from evennia import create_object diff --git a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Django-queries.md b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Django-queries.md index d13c631cc8..9e6093a2fb 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Django-queries.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Django-queries.md @@ -14,7 +14,7 @@ But sometimes you need to be more specific: - You want to find all `Characters` ... - ... who are in Rooms tagged as `moonlit` ... -- ... _and_ who has the Attribute `lycantrophy` with a level higher than 2 ... +- ... _and_ who has the Attribute `lycanthropy` with a level equal to 2 ... - ... because they should immediately transform to werewolves! In principle you could achieve this with the existing search functions combined with a lot of loops @@ -159,7 +159,7 @@ of this lesson. Firstly, we make ourselves and our current location match the criteria, so we can test: > py here.tags.add("moonlit") - > py me.db.lycantrophy = 3 + > py me.db.lycanthropy = 2 This is an example of a more complex query. We'll consider it an example of what is possible. @@ -174,14 +174,16 @@ will_transform = ( Character.objects .filter( db_location__db_tags__db_key__iexact="moonlit", - db_attributes__db_key="lycantrophy", - db_attributes__db_value__eq=2 + db_attributes__db_key__iexact="lycanthropy", + db_attributes__db_value=2 ) ) ``` ```{sidebar} Attributes vs database fields -Don't confuse database fields with [Attributes](../../../Components/Attributes.md) you set via `obj.db.attr = 'foo'` or `obj.attributes.add()`. Attributes are custom database entities *linked* to an object. They are not separate fields *on* that object like `db_key` or `db_location` are. +Don't confuse database fields with [Attributes](../../../Components/Attributes.md) you set via `obj.db.attr = 'foo'` or `obj.attributes.add()`. Attributes are custom database entities *linked* to an object. They are not separate fields *on* that object like `db_key` or `db_location` are. + +While an Attribute's `db_key` is just a normal string, theor `db_value` is in fact a serialized piece of data. This means that cannot query this with additional operators. So if you use e.g. `db_attributes__db_value__iexact=2`, you'll get an error. While Attributes are very flexible, this is their drawback - their stored value is not possible to directly query with advanced query methods beyond finding the exact match. ``` - **Line 4** We want to find `Character`s, so we access `.objects` on the `Character` typeclass. - We start to filter ... @@ -189,19 +191,19 @@ Don't confuse database fields with [Attributes](../../../Components/Attributes.m - ... and on that location, we get the value of `db_tags` (this is a _many-to-many_ database field that we can treat like an object for this purpose; it references all Tags on the location) - ... and from those `Tags`, we looking for `Tags` whose `db_key` is "monlit" (non-case sensitive). - - **Line 7**: ... We also want only Characters with `Attributes` whose `db_key` is exactly `"lycantrophy"` - - **Line 8** :... at the same time as the `Attribute`'s `db_value` is exactly 2. + - **Line 7**: ... We also want only Characters with `Attributes` whose `db_key` is exactly `"lycanthropy"` + - **Line 8** :... at the same time as the `Attribute`'s `db_value` is 2. -Running this query makes our newly lycantrophic Character appear in `will_transform` so we know to transform it. Success! +Running this query makes our newly lycanthropic Character appear in `will_transform` so we know to transform it. Success! ```{important} -You can't query for an Attribute `db_value` quite as freely as other data-types. This is because Attributes can store any Python entity and is actually stored as _strings_ on the database side. So while you can use `__eq=2` in the above example, you will not be able to `__gt=2` or `__lt=2` because these operations don't make sense for strings. See [Attributes](../../../Components/Attributes.md#querying-by-attribute) for more information on dealing with Attributes. +You can't query for an Attribute `db_value` quite as freely as other data-types. This is because Attributes can store any Python entity and is actually stored as _strings_ on the database side. So while you can use `db_value=2` in the above example, you will not be able to use `dbvalue__eq=2` or `__lt=2`. See [Attributes](../../../Components/Attributes.md#querying-by-attribute) for more information on dealing with Attributes. ``` ## Queries with OR or NOT All examples so far used `AND` relations. The arguments to `.filter` are added together with `AND` -("we want tag room to be "monlit" _and_ lycantrhopy be > 2"). +("we want tag room to be "monlit" _and_ lycanthropy be > 2"). For queries using `OR` and `NOT` we need Django's [Q object](https://docs.djangoproject.com/en/4.1/topics/db/queries/#complex-lookups-with-q-objects). It is imported from Django directly: @@ -228,7 +230,7 @@ works like `NOT`. Would get all Characters that are either named "Dalton" _or_ which is _not_ in prison. The result is a mix of Daltons and non-prisoners. -Let us expand our original werewolf query. Not only do we want to find all Characters in a moonlit room with a certain level of `lycanthrophy` - we decide that if they have been _newly bitten_, they should also turn, _regardless_ of their lycantrophy level (more dramatic that way!). +Let us expand our original werewolf query. Not only do we want to find all Characters in a moonlit room with a certain level of `lycanthropy` - we decide that if they have been _newly bitten_, they should also turn, _regardless_ of their lycanthropy level (more dramatic that way!). Let's say that getting bitten means that you'll get assigned a Tag `recently_bitten`. @@ -242,8 +244,8 @@ will_transform = ( .filter( Q(db_location__db_tags__db_key__iexact="moonlit") & ( - Q(db_attributes__db_key="lycantrophy", - db_attributes__db_value__eq=2) + Q(db_attributes__db_key="lycanthropy", + db_attributes__db_value=2) | Q(db_tags__db_key__iexact="recently_bitten") )) .distinct() @@ -256,12 +258,12 @@ That's quite compact. It may be easier to see what's going on if written this wa from django.db.models import Q q_moonlit = Q(db_location__db_tags__db_key__iexact="moonlit") -q_lycantropic = Q(db_attributes__db_key="lycantrophy", db_attributes__db_value__eq=2) +q_lycanthropic = Q(db_attributes__db_key="lycanthropy", db_attributes__db_value=2) q_recently_bitten = Q(db_tags__db_key__iexact="recently_bitten") will_transform = ( Character.objects - .filter(q_moonlit & (q_lycantropic | q_recently_bitten)) + .filter(q_moonlit & (q_lycanthropic | q_recently_bitten)) .distinct() ) ``` @@ -276,7 +278,7 @@ the same object with different relations. ``` This reads as "Find all Characters in a moonlit room that either has the -Attribute `lycantrophy` higher than two, _or_ which has the Tag +Attribute `lycanthropy` equal to two, _or_ which has the Tag `recently_bitten`". With an OR-query like this it's possible to find the same Character via different paths, so we add `.distinct()` at the end. This makes sure that there is only one instance of each Character in the result. @@ -406,4 +408,4 @@ in a format like the following: ## Conclusions -We have covered a lot of ground in this lesson and covered several more complex topics. Knowing how to query using Django is a powerful skill to have. \ No newline at end of file +We have covered a lot of ground in this lesson and covered several more complex topics. Knowing how to query using Django is a powerful skill to have. diff --git a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Gamedir-Overview.md b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Gamedir-Overview.md index 6096b2c548..af281f6a7e 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Gamedir-Overview.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Gamedir-Overview.md @@ -81,7 +81,7 @@ Common for the settings is that you generally will never import them directly vi - `at_server_startstop.py` - This allows to inject code to execute every time the server starts, stops or reloads in different ways. - `connection_screens.py` - This allows for changing the connection screen you see when you first connect to your game. - `inlinefuncs.py` - [Inlinefuncs](../../../Concepts/Inline-Functions.md) are optional and limited 'functions' that can be embedded in any strings being sent to a player. They are written as `$funcname(args)` and are used to customize the output depending on the user receiving it. For example sending people the text `"Let's meet at $realtime(13:00, GMT)!` would show every player seeing that string the time given in their own time zone. The functions added to this module will become new inlinefuncs in the game. See also the [FuncParser](../../../Components/FuncParser.md). -- `inputfucs.py` - When a command like `look` is received by the server, it is handled by an [Inputfunc](InputFuncs) that redirects it to the cmdhandler system. But there could be other inputs coming from the clients, like button-presses or the request to update a health-bar. While most common cases are already covered, this is where one adds new functions to process new types of input. +- `inputfuncs.py` - When a command like `look` is received by the server, it is handled by an [Inputfunc](InputFuncs) that redirects it to the cmdhandler system. But there could be other inputs coming from the clients, like button-presses or the request to update a health-bar. While most common cases are already covered, this is where one adds new functions to process new types of input. - `lockfuncs.py` - [Locks](../../../Components/Locks.md) and their component _LockFuncs_ restrict access to things in-game. Lock funcs are used in a mini-language to defined more complex locks. For example you could have a lockfunc that checks if the user is carrying a given item, is bleeding or has a certain skill value. New functions added in this modules will become available for use in lock definitions. - `mssp.py` - Mud Server Status Protocol is a way for online MUD archives/listings (which you usually have to sign up for) to track which MUDs are currently online, how many players they have etc. While Evennia handles the dynamic information automatically, this is where you set up the meta-info about your game, such as its theme, if player-killing is allowed and so on. This is a more generic form of the Evennia Game directory. - `portal_services_plugins.py` - If you want to add new external connection protocols to Evennia, this is the place to add them. diff --git a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Making-A-Sittable-Object.md b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Making-A-Sittable-Object.md index d2266364e7..d952965d8c 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Making-A-Sittable-Object.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Making-A-Sittable-Object.md @@ -132,7 +132,7 @@ It's fine to sit 'on' a chair. But what if our Sittable is an armchair? You sit on armchair. ``` -This is not grammatically correct, you actually sit "in" an armchair rather than "on" it. It's also possible to both sit 'in' or 'on' a chair depending on the type of chair (English is weird). We want to be able to control this. +This is not grammatically correct, you actually sit "in" an armchair rather than "on" it. The type of chair matters (English is weird). We want to be able to control this. We _could_ make a child class of `Sittable` named `SittableIn` that makes this change, but that feels excessive. Instead we will modify what we have: @@ -154,19 +154,19 @@ class Sittable(Object): sitter (Object): The one trying to sit down. """ - adjective = self.db.adjective or "on" + preposition = self.db.preposition or "on" current = self.db.sitter if current: if current == sitter: - sitter.msg(f"You are already sitting {adjective} {self.key}.") + sitter.msg(f"You are already sitting {preposition} {self.key}.") else: sitter.msg( - f"You can't sit {adjective} {self.key} " + f"You can't sit {preposition} {self.key} " f"- {current.key} is already sitting there!") return self.db.sitter = sitter sitter.db.is_sitting = self - sitter.msg(f"You sit {adjective} {self.key}") + sitter.msg(f"You sit {preposition} {self.key}") def do_stand(self, stander): """ @@ -178,20 +178,20 @@ class Sittable(Object): """ current = self.db.sitter if not stander == current: - stander.msg(f"You are not sitting {self.db.adjective} {self.key}.") + stander.msg(f"You are not sitting {self.db.preposition} {self.key}.") else: self.db.sitter = None del stander.db.is_sitting stander.msg(f"You stand up from {self.key}.") ``` -- **Line 15**: We grab the `adjective` Attribute. Using `self.db.adjective or "on"` here means that if the Attribute is not set (is `None`/falsy) the default "on" string will be assumed. -- **Lines 19,22,27,39, and 43**: We use this adjective to modify the return text we see. +- **Line 15**: We grab the `preposition` Attribute. Using `self.db.preposition or "on"` here means that if the Attribute is not set (is `None`/falsy) the default "on" string will be assumed. This is because the `or` relation will return the first true condition. A more explicit way to write this would be to use a [ternary operator](https://www.dataquest.io/blog/python-ternary-operator/) `self.db.preposition if self.db.preposition else "on"`. +- **Lines 19,22,27,39, and 43**: We use this preposition to modify the return text we see. `reload` the server. An advantage of using Attributes like this is that they can be modified on the fly, in-game. Let's look at how a builder could use this with normal building commands (no need for `py`): ``` -> set armchair/adjective = in +> set armchair/preposition = in ``` Since we haven't added the `sit` command yet, we must still use `py` to test: @@ -386,7 +386,7 @@ We don't need a new CmdSet for this, instead we will add this to the default Cha # ... from commands import sittables -class CharacterCmdSet(CmdSet): +class CharacterCmdSet(default_cmds.CharacterCmdSet): """ (docstring) """ diff --git a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-classes-and-objects.md b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-classes-and-objects.md index f664cdcedb..668db4e283 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-classes-and-objects.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Python-classes-and-objects.md @@ -377,7 +377,6 @@ class ObjectParent: """ class docstring """ - pass class Object(ObjectParent, DefaultObject): """ 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 72a9709c75..3be62f420e 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 @@ -382,10 +382,8 @@ from evennia import search_object # we assume only one match of each dungeons = search_object("dungeon", typeclass="typeclasses.rooms.Room") chests = search_object("chest", location=dungeons[0]) -# find if there are any skulls in the chest +# find out how much coin are in the chest coins = search_object("coin", candidates=chests[0].contents) ``` -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. - -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 +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*. We will get to this in the next lesson. There we will dive into more complex searching using Django database queries and querysets. \ 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 4e35e2b2c4..58661a1ddd 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md @@ -172,7 +172,7 @@ In game: Or in code: exit_obj.locks.add( - "traverse:attr(is_ic, True)") + "traverse:attr(is_pc, True)") See [Locks](../../../Components/Locks.md) for a lot more information about Evennia locks. ``` diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Equipment.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Equipment.md index cb81de616c..08dfd144c1 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Equipment.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Equipment.md @@ -402,18 +402,19 @@ class EquipmentHandler: to_backpack = [obj] else: # for others (body, head), just replace whatever's there - replaced = [obj] + to_backpack = [slots[use_slot]] slots[use_slot] = obj for to_backpack_obj in to_backpack: # put stuff in backpack - slots[use_slot].append(to_backpack_obj) + if to_backpack_obj: + slots[WieldLocation.BACKPACK].append(to_backpack_obj) # store new state self._save() ``` -Here we remember that every `EvAdventureObject` has an `inventory_use_slot` property that tells us where it goes. So we just need to move the object to that slot, replacing whatever is in that place from before. Anything we replace goes back to the backpack. +Here we remember that every `EvAdventureObject` has an `inventory_use_slot` property that tells us where it goes. So we just need to move the object to that slot, replacing whatever is in that place from before. Anything we replace goes back to the backpack, as long as it's actually an item and not `None`, in the case where we are moving an item into an empty slot. ## Get everything @@ -612,4 +613,4 @@ _Handlers_ are useful for grouping functionality together. Now that we spent our We also learned to use _hooks_ to tie _Knave_'s custom equipment handling into Evennia. -With `Characters`, `Objects` and now `Equipment` in place, we should be able to move on to character generation - where players get to make their own character! \ No newline at end of file +With `Characters`, `Objects` and now `Equipment` in place, we should be able to move on to character generation - where players get to make their own character! diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.md index bba390a9cd..b4a2be590f 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.md @@ -27,7 +27,7 @@ from evennia import DefaultCharacter, AttributeProperty from .characters import LivingMixin from .enums import Ability - +from .objects import get_bare_hands class EvAdventureNPC(LivingMixin, DefaultCharacter): """Base class for NPCs""" @@ -40,7 +40,7 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter): morale = AttributeProperty(default=9, autocreate=False) allegiance = AttributeProperty(default=Ability.ALLEGIANCE_HOSTILE, autocreate=False) - weapon = AttributeProperty(default=BARE_HANDS, autocreate=False) # instead of inventory + weapon = AttributeProperty(default=get_bare_hands, autocreate=False) # instead of inventory coins = AttributeProperty(default=1, autocreate=False) # coin loot is_idle = AttributeProperty(default=False, autocreate=False) 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 449367eb0f..98330c17f1 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Objects.md @@ -248,7 +248,7 @@ class EvAdventureWeapon(EvAdventureObject): quality = AttributeProperty(3, autocreate=False) attack_type = AttributeProperty(Ability.STR, autocreate=False) - defend_type = AttributeProperty(Ability.ARMOR, autocreate=False) + defense_type = AttributeProperty(Ability.ARMOR, autocreate=False) damage_roll = AttributeProperty("1d6", autocreate=False) @@ -387,7 +387,7 @@ class EvAdventureRuneStone(EvAdventureWeapon, EvAdventureConsumable): quality = AttributeProperty(3, autocreate=False) attack_type = AttributeProperty(Ability.INT, autocreate=False) - defend_type = AttributeProperty(Ability.DEX, autocreate=False) + defense_type = AttributeProperty(Ability.DEX, autocreate=False) damage_roll = AttributeProperty("1d8", autocreate=False) @@ -488,4 +488,4 @@ Well, we just figured out all we need! You can go back and update `get_obj_stats When you change this function you must also update the related unit test - so your existing test becomes a nice way to test your new Objects as well! Add more tests showing the output of feeding different object-types to `get_obj_stats`. -Try it out yourself. If you need help, a finished utility example is found in [evennia/contrib/tutorials/evadventure/utils.py](get_obj_stats). \ No newline at end of file +Try it out yourself. If you need help, a finished utility example is found in [evennia/contrib/tutorials/evadventure/utils.py](get_obj_stats). 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 e4aee29fe6..91ff51f7ce 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,9 @@ # Game Quests +```{warning} +This tutorial lesson is not yet complete, and has some serious bugs in its implementation. So use this as a reference, but the code is not yet ready to use directly. +``` + 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: diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Utilities.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Utilities.md index b715cfb559..d493ce0db2 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Utilities.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Utilities.md @@ -135,9 +135,9 @@ ABILITY_REVERSE_MAP = { Above, the `Ability` class holds some basic properties of a character sheet. -The `ABILITY_REVERSE_MAP` is a convenient map to go the other way — if in some command we were to enter the string 'cha', we could use this mapping to directly convert your input to the correct `Ability`. For example: +The `ABILITY_REVERSE_MAP` is a convenient map to convert a string to an Enum. The most common use of this would be in a Command; the Player don't know anything about Enums, they can only send strings. So we'd only get the string "cha". Using this `ABILITY_REVERSE_MAP` we can conveniently convert this input to an `Ability.CHA` Enum you can then pass around in code - ability = ABILITY_REVERSE_MAP.get(your_input) + ability = ABILITY_REVERSE_MAP.get(user_input) ## Utility Module @@ -151,22 +151,7 @@ An example of the utility module is found in The utility module is used to contain general functions we may need to call repeatedly from various other modules. In this tutorial example, we only crate one utility: a function that produces a pretty display of any object we pass to it. -Below is an example of the string we want to see: - -``` -Chipped Sword -Value: ~10 coins [wielded in Weapon hand] - -A simple sword used by mercenaries all over -the world. - -Slots: 1, Used from: weapon hand -Quality: 3, Uses: None -Attacks using strength against armor. -Damage roll: 1d6 -``` - -And, here's the start of how the function might look: +Here's how it could look: ```python # in mygame/evadventure/utils.py @@ -210,11 +195,33 @@ def get_obj_stats(obj, owner=None): damage_roll="1d6" ) ``` -In our new `get_obj_stats` function above, we set up a string template with place holders for where every element of stats information should go. Study this string so that you understand what it does. The `|c`, `|y`, `|w` and `|n` markers are [Evennia color markup](../../../Concepts/Colors.md) for making the text cyan, yellow, white and neutral-color, respectively. + +Previously throughout these tutorial lessons, we have seen the `""" ... """` multi-line string used mainly for function help strings, but a triple-quoted string in Python is used for any multi-line string. + +Above, we set up a string template (`_OBJ_STATS`) with place holders (`{...}`) for where every element of stats information should go. In the `_OBJ_STATS.format(...)` call, we then dynamically fill those place holders with data from the object we pass into `get_obj_stats`. + +Here's what you'd get back if you were to pass a 'chipped sword' to `get_obj_stats` (note that these docs don't show the text colors): + +``` +Chipped Sword +Value: ~10 coins [wielded in Weapon hand] + +A simple sword used by mercenaries all over +the world. + +Slots: 1, Used from: weapon hand +Quality: 3, Uses: None +Attacks using strength against armor. +Damage roll: 1d6 +``` + +We will later use this to let the player inspect any object without us having to make a new utility for every object type. + +Study the `_OBJ_STATS` template string so that you understand what it does. The `|c`, `|y`, `|w` and `|n` markers are [Evennia color markup](../../../Concepts/Colors.md) for making the text cyan, yellow, white and neutral-color, respectively. Some stats elements are easy to identify in the above code. For instance, `obj.key` is the name of an object and `obj.db.desc` will hold an object's description — this is also how default Evennia works. -So far, here in our tutorial, we have not yet established how to get any of the other properties like `size` or `attack_type`. For our current purposes, we will just set them to dummy values and we'll need to revisit them later when we have more code in place! +So far, here in our tutorial, we have not yet established how to get any of the other properties like `size`, `damage_roll` or `attack_type_name`. For our current purposes, we will just set them to fixed dummy values so they work. We'll need to revisit them later when we have more code in place! ## Testing @@ -259,7 +266,7 @@ class TestUtils(EvenniaTest): result, """ |ctestobj|n -Value: ~|y10|n coins[not carried] +Value: ~|y10|n coins[Not carried] A test object diff --git a/docs/source/Howtos/Tutorial-Persistent-Handler.md b/docs/source/Howtos/Tutorial-Persistent-Handler.md index 826e894e4a..925b772aa9 100644 --- a/docs/source/Howtos/Tutorial-Persistent-Handler.md +++ b/docs/source/Howtos/Tutorial-Persistent-Handler.md @@ -22,7 +22,7 @@ class NameChanger: self.obj = obj def add_to_key(self, suffix): - self.obj.key = f"self.obj.key_{suffix}" + self.obj.key = f"{self.obj.key}_{suffix}" # make a test object class MyObject(DefaultObject): diff --git a/docs/source/Howtos/Web-Character-Generation.md b/docs/source/Howtos/Web-Character-Generation.md index e7ef7c823b..98190f6cb8 100644 --- a/docs/source/Howtos/Web-Character-Generation.md +++ b/docs/source/Howtos/Web-Character-Generation.md @@ -56,7 +56,7 @@ After this, we will get into defining our *models* (the description of the datab ### Installing - Checkpoint: * you should have a folder named `chargen` or whatever you chose in your mygame/web/ directory -* you should have your application name added to your INSTALLED_APPS in settings.py +* you should have your application name added to your INSTALLED_APPS in `settings.py` ## Create Models @@ -350,7 +350,7 @@ urlpatterns = [ ### URLs - Checkpoint: -* You’ve created a urls.py file in the `mygame/web/chargen` directory +* You’ve created a `urls.py` file in the `mygame/web/chargen` directory * You have edited the main `mygame/web/urls.py` file to include urls to the `chargen` directory. ## HTML Templates @@ -416,7 +416,7 @@ This page should show a detailed character sheet of their application. This will ### create.html -Our create HTML template will use the Django form we defined back in views.py/forms.py to drive the majority of the application process. There will be a form input for every field we defined in forms.py, which is handy. We have used POST as our method because we are sending information to the server that will update the database. As an alternative, GET would be much less secure. You can read up on documentation elsewhere on the web for GET vs. POST. +Our create HTML template will use the Django form we defined back in views.py/forms.py to drive the majority of the application process. There will be a form input for every field we defined in `forms.py`, which is handy. We have used POST as our method because we are sending information to the server that will update the database. As an alternative, GET would be much less secure. You can read up on documentation elsewhere on the web for GET vs. POST. ```html @@ -546,4 +546,4 @@ And you should put it at the bottom of the page. Just before the closing body w {% endblock %} ``` -Reload and open [http://localhost:4001/chargen/create](http://localhost:4001/chargen/create/) and you should see your beautiful CAPCHA just before the "submit" button. Try not to check the checkbox to see what happens. And do the same while checking the checkbox! \ No newline at end of file +Reload and open [http://localhost:4001/chargen/create](http://localhost:4001/chargen/create/) and you should see your beautiful CAPCHA just before the "submit" button. Try not to check the checkbox to see what happens. And do the same while checking the checkbox! diff --git a/docs/source/Howtos/Web-Help-System-Tutorial.md b/docs/source/Howtos/Web-Help-System-Tutorial.md index 37788f963d..116bb35bf5 100644 --- a/docs/source/Howtos/Web-Help-System-Tutorial.md +++ b/docs/source/Howtos/Web-Help-System-Tutorial.md @@ -62,10 +62,10 @@ At this point, our new *app* contains mostly empty files that you can explore. ### Create a view -A *view* in Django is a simple Python function placed in the "views.py" file in your app. It will +A *view* in Django is a simple Python function placed in the `views.py` file in your app. It will handle the behavior that is triggered when a user asks for this information by entering a *URL* (the connection between *views* and *URLs* will be discussed later). -So let's create our view. You can open the "web/help_system/views.py" file and paste the following lines: +So let's create our view. You can open the `web/help_system/views.py` file and paste the following lines: ```python from django.shortcuts import render @@ -108,7 +108,7 @@ Here's a little explanation line by line of what this template does: ### Create a new URL -Last step to add our page: we need to add a *URL* leading to it... otherwise users won't be able to access it. The URLs of our apps are stored in the app's directory "urls.py" file. +Last step to add our page: we need to add a *URL* leading to it... otherwise users won't be able to access it. The URLs of our apps are stored in the app's directory `urls.py` file. Open the `web/help_system/urls.py` file (you might have to create it) and make it look like this: diff --git a/docs/source/Setup/Choosing-a-Database.md b/docs/source/Setup/Choosing-a-Database.md index 630a176cde..65db157449 100644 --- a/docs/source/Setup/Choosing-a-Database.md +++ b/docs/source/Setup/Choosing-a-Database.md @@ -94,7 +94,7 @@ We create a database user 'evennia' and a new database named `evennia` (you can ### Evennia PostgreSQL configuration -Edit `mygame/server/conf/secret_settings.py and add the following section: +Edit `mygame/server/conf/secret_settings.py` and add the following section: ```python # diff --git a/docs/source/Setup/Installation-Troubleshooting.md b/docs/source/Setup/Installation-Troubleshooting.md index 7eb9d77a31..f862437e27 100644 --- a/docs/source/Setup/Installation-Troubleshooting.md +++ b/docs/source/Setup/Installation-Troubleshooting.md @@ -12,14 +12,13 @@ Any system that supports Python3.10+ should work. - Windows (Win7, Win8, Win10, Win11) - Mac OSX (>10.5 recommended) -- [Python](https://www.python.org) (3.10 and 3.11 are tested. 3.11 is recommended) -- [Twisted](https://twistedmatrix.com) (v22.3+) +- [Python](https://www.python.org) (3.10, 3.11 and 3.12 are tested. 3.12 is recommended) +- [Twisted](https://twistedmatrix.com) (v23.10+) - [ZopeInterface](https://www.zope.org/Products/ZopeInterface) (v3.0+) - usually included in Twisted packages - Linux/Mac users may need the `gcc` and `python-dev` packages or equivalent. - Windows users need [MS Visual C++](https://aka.ms/vs/16/release/vs_buildtools.exe) and *maybe* [pypiwin32](https://pypi.python.org/pypi/pypiwin32). - [Django](https://www.djangoproject.com) (v4.2+), be warned that latest dev version is usually untested with Evennia. -- [GIT](https://git-scm.com/) - version control software used if you want to install the sources - (but also useful to track your own code) +- [GIT](https://git-scm.com/) - version control software used if you want to install the sources (but also useful to track your own code) - Mac users can use the [git-osx-installer](https://code.google.com/p/git-osx-installer/) or the [MacPorts version](https://git-scm.com/book/en/Getting-Started-Installing-Git#Installing-on-Mac). ## Confusion of location (GIT installation) @@ -33,27 +32,26 @@ muddev/ mygame/ ``` -The evennia code itself is found inside `evennia/evennia/` (so two levels down). Your settings file -is `mygame/server/conf/settings.py` and the _parent_ setting file is `evennia/evennia/settings_default.py`. +The evennia library code itself is found inside `evennia/evennia/` (so two levels down). You shouldn't change this; do all work in `mygame/`. Your settings file is `mygame/server/conf/settings.py` and the _parent_ setting file is `evennia/evennia/settings_default.py`. ## Virtualenv setup fails -When doing the `python3.11 -m venv evenv` step, some users report getting an error; something like: +When doing the `python3.x -m venv evenv` (where x is the python3 version) step, some users report getting an error; something like: Error: Command '['evenv', '-Im', 'ensurepip', '--upgrade', '--default-pip']' returned non-zero exit status 1 -You can solve this by installing the `python3.11-venv` package or equivalent for your OS. Alternatively you can bootstrap it in this way: +You can solve this by installing the `python3.11-venv` (or later) package (or equivalent for your OS). Alternatively you can bootstrap it in this way: - python3.11 -m --without-pip evenv + python3.x -m --without-pip evenv -This should set up the virtualenv without `pip`. Activate the new virtualenv and then install pip from within it: +This should set up the virtualenv without `pip`. Activate the new virtualenv and then install pip from within it (you don't need to specify the python version once virtualenv is active): python -m ensurepip --upgrade If that fails, a worse alternative to try is - curl https://bootstrap.pypa.io/get-pip.py | python3.10 (linux/unix/WSL only) + curl https://bootstrap.pypa.io/get-pip.py | python3.x (linux/unix/WSL only) Either way, you should now be able to continue with the installation. @@ -72,29 +70,19 @@ If `localhost` doesn't work when trying to connect to your local game, try `127. - Users of Fedora (notably Fedora 24) has reported a `gcc` error saying the directory `/usr/lib/rpm/redhat/redhat-hardened-cc1` is missing, despite `gcc` itself being installed. [The confirmed work-around](https://gist.github.com/yograterol/99c8e123afecc828cb8c) seems to be to install the `redhat-rpm-config` package with e.g. `sudo dnf install redhat-rpm-config`. -- Some users trying to set up a virtualenv on an NTFS filesystem find that it fails due to issues - with symlinks not being supported. Answer is to not use NTFS (seriously, why would you do that to yourself?) +- Some users trying to set up a virtualenv on an NTFS filesystem find that it fails due to issues with symlinks not being supported. Answer is to not use NTFS (seriously, why would you do that to yourself?) ## Mac Troubleshooting - Some Mac users have reported not being able to connect to `localhost` (i.e. your own computer). If so, try to connect to `127.0.0.1` instead, which is the same thing. Use port 4000 from mud clients and port 4001 from the web browser as usual. -- If you get a `MemoryError` when starting Evennia, or when looking at the log, this may be due to an sqlite versioning issue. [A user in our forums](https://github.com/evennia/evennia/discussions/2637) found a working solution for this. [Here](https://github.com/evennia/evennia/issues/2854) is another variation to solve it. +- If you get a `MemoryError` when starting Evennia, or when looking at the log, this may be due to an sqlite versioning issue. [A user in our forums](https://github.com/evennia/evennia/discussions/2637) found a working solution for this. [Here](https://github.com/evennia/evennia/issues/2854) is another variation to solve it. [Another user](https://github.com/evennia/evennia/issues/3704) also wrote an extensive summary of the issue, along with troubleshooting instructions. ## Windows Troubleshooting - If you install with `pip install evennia` and find that the `evennia` command is not available, run `py -m evennia` once. This should add the evennia binary to your environment. If this fails, make sure you are using a [virtualenv](./Installation-Git.md#virtualenv). Worst case, you can keep using `py -m evennia` in the places where the `evennia` command is used. -- Install Python [from the Python homepage](https://www.python.org/downloads/windows/). You will need to be a Windows Administrator to install packages. -- When installing Python, make sure to check-mark *all* install options, especially the one about making Python available on the path (you may have to scroll to see it). This allows you to - just write `python` in any console without first finding where the `python` program actually sits on your hard drive. -- If you get a `command not found` when trying to run the `evennia` program after installation, try closing the Console and starting it again (remember to re-activate the virtualenv if you use one!). Sometimes Windows is not updating its environment properly and `evennia` will be available only in the new console. -- If you installed Python but the `python` command is not available (even in a new console), then - you might have missed installing Python on the path. In the Windows Python installer you get a list of options for what to install. Most or all options are pre-checked except this one, and you may even have to scroll down to see it. Reinstall Python and make sure it's checked. -- If your MUD client cannot connect to `localhost:4000`, try the equivalent `127.0.0.1:4000` - instead. Some MUD clients on Windows does not appear to understand the alias `localhost`. -- Some Windows users get an error installing the Twisted 'wheel'. A wheel is a pre-compiled binary - package for Python. A common reason for this error is that you are using a 32-bit version of Python, but Twisted has not yet uploaded the latest 32-bit wheel. Easiest way to fix this is to install a slightly older Twisted version. So if, say, version `22.1` failed, install `22.0` manually with `pip install twisted==22.0`. Alternatively you could check that you are using the 64-bit version of Python and uninstall any 32bit one. If so, you must then `deactivate` the virtualenv, delete the `evenv` folder and recreate it anew with your new Python. +- - If you get a `command not found` when trying to run the `evennia` program directly after installation, try closing the Windows Console and starting it again (remember to re-activate the virtualenv if you use one!). Sometimes Windows is not updating its environment properly and `evennia` will be available only in the new console. +- If you installed Python but the `python` command is not available (even in a new console), then you might have missed installing Python on the path. In the Windows Python installer you get a list of options for what to install. Most or all options are pre-checked except this one, and you may even have to scroll down to see it. Reinstall Python and make sure it's checked. Install Python [from the Python homepage](https://www.python.org/downloads/windows/). You will need to be a Windows Administrator to install packages. +- If your MUD client cannot connect to `localhost:4000`, try the equivalent `127.0.0.1:4000` instead. Some MUD clients on Windows does not appear to understand the alias `localhost`. +- Some Windows users get an error installing the Twisted 'wheel'. A wheel is a pre-compiled binary package for Python. A common reason for this error is that you are using a 32-bit version of Python, but Twisted has not yet uploaded the latest 32-bit wheel. Easiest way to fix this is to install a slightly older Twisted version. So if, say, version `22.1` failed, install `22.0` manually with `pip install twisted==22.0`. Alternatively you could check that you are using the 64-bit version of Python and uninstall any 32bit one. If so, you must then `deactivate` the virtualenv, delete the `evenv` folder and recreate it anew with your new Python. - If you've done a git installation, and your server won't start with an error message like `AttributeError: module 'evennia' has no attribute '_init'`, it may be a python path issue. In a terminal, cd to `(your python directory)\site-packages` and run the command `echo "C:\absolute\path\to\evennia" > local-vendors.pth`. Open the created file in your favorite IDE and make sure it is saved with *UTF-8* encoding and not *UTF-8 with BOM*. -- If your server won't start, with no error messages (and no log files at all when starting from - scratch), try to start with `evennia ipstart` instead. If you then see an error about `system cannot find the path specified`, it may be that the file `evennia\evennia\server\twistd.bat` has the wrong path to the `twistd` executable. This file is auto-generated, so try to delete it and then run `evennia start` to rebuild it and see if it works. If it still doesn't work you need to open it in a text editor like Notepad. It's just one line containing the path to the `twistd.exe` executable as determined by Evennia. If you installed Twisted in a non-standard location this might be wrong and you should update the line to the real location. -- Some users have reported issues with Windows WSL and anti-virus software during Evennia - development. Timeout errors and the inability to run `evennia connections` may be due to your anti-virus software interfering. Try disabling or changing your anti-virus software settings. +- Some users have reported issues with Windows WSL and anti-virus software during Evennia development. Timeout errors and the inability to run `evennia connections` may be due to your anti-virus software interfering. Try disabling or changing your anti-virus software settings. \ No newline at end of file diff --git a/docs/source/Setup/Settings-Default.md b/docs/source/Setup/Settings-Default.md index 494bbd614e..f7f0dc29c1 100644 --- a/docs/source/Setup/Settings-Default.md +++ b/docs/source/Setup/Settings-Default.md @@ -320,13 +320,13 @@ DATABASES = { } } # PRAGMA (directives) for the default Sqlite3 database operations. This can be used to tweak -# performance for your setup. Don't change this unless you know what # you are doing. +# performance for your setup. Don't change this unless you know what you are doing. Also +# be careful to change for an already populated database. SQLITE3_PRAGMAS = ( "PRAGMA cache_size=10000", - "PRAGMA synchronous=1", + "PRAGMA synchronous=OFF", "PRAGMA count_changes=OFF", "PRAGMA temp_store=2", - "PRAGMA journal_mode=WAL", ) # How long the django-database connection should be kept open, in seconds. diff --git a/docs/source/Setup/Settings.md b/docs/source/Setup/Settings.md index 766d24281e..afe4abbbfb 100644 --- a/docs/source/Setup/Settings.md +++ b/docs/source/Setup/Settings.md @@ -55,5 +55,5 @@ Apart from the main `settings.py` file, 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 +- `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). diff --git a/docs/source/api/evennia.contrib.game_systems.md b/docs/source/api/evennia.contrib.game_systems.md index 07ddfa33e1..de3da5f352 100644 --- a/docs/source/api/evennia.contrib.game_systems.md +++ b/docs/source/api/evennia.contrib.game_systems.md @@ -21,6 +21,7 @@ evennia.contrib.game\_systems evennia.contrib.game_systems.mail evennia.contrib.game_systems.multidescer evennia.contrib.game_systems.puzzles + evennia.contrib.game_systems.storage evennia.contrib.game_systems.turnbattle ``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.game_systems.storage.md b/docs/source/api/evennia.contrib.game_systems.storage.md new file mode 100644 index 0000000000..82abd8d66a --- /dev/null +++ b/docs/source/api/evennia.contrib.game_systems.storage.md @@ -0,0 +1,18 @@ +```{eval-rst} +evennia.contrib.game\_systems.storage +============================================= + +.. automodule:: evennia.contrib.game_systems.storage + :members: + :undoc-members: + :show-inheritance: + + + +.. toctree:: + :maxdepth: 6 + + evennia.contrib.game_systems.storage.storage + evennia.contrib.game_systems.storage.tests + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.game_systems.storage.storage.md b/docs/source/api/evennia.contrib.game_systems.storage.storage.md new file mode 100644 index 0000000000..882eaf6984 --- /dev/null +++ b/docs/source/api/evennia.contrib.game_systems.storage.storage.md @@ -0,0 +1,10 @@ +```{eval-rst} +evennia.contrib.game\_systems.storage.storage +==================================================== + +.. automodule:: evennia.contrib.game_systems.storage.storage + :members: + :undoc-members: + :show-inheritance: + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.game_systems.storage.tests.md b/docs/source/api/evennia.contrib.game_systems.storage.tests.md new file mode 100644 index 0000000000..fccdd5fe58 --- /dev/null +++ b/docs/source/api/evennia.contrib.game_systems.storage.tests.md @@ -0,0 +1,10 @@ +```{eval-rst} +evennia.contrib.game\_systems.storage.tests +================================================== + +.. automodule:: evennia.contrib.game_systems.storage.tests + :members: + :undoc-members: + :show-inheritance: + +``` \ No newline at end of file diff --git a/docs/source/index.md b/docs/source/index.md index 99a7b20c0e..cb4faf4117 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,6 +1,6 @@ # Evennia Documentation -This is the manual of [Evennia](https://www.evennia.com), the open source Python `MU*` creation system. Use the Search bar on the left to find or discover interesting articles. This manual was last updated August 25, 2024, see the [Evennia Changelog](Coding/Changelog.md). Latest released Evennia version is 4.3.0. +This is the manual of [Evennia](https://www.evennia.com), the open source Python `MU*` creation system. Use the Search bar on the left to find or discover interesting articles. This manual was last updated outubro 26, 2024, see the [Evennia Changelog](Coding/Changelog.md). Latest released Evennia version is 4.5.0. - [Introduction](./Evennia-Introduction.md) - what is this Evennia thing? - [Evennia in Pictures](./Evennia-In-Pictures.md) - a visual overview of Evennia diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index 80895903a1..a84947d6ff 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -4.3.0 +4.5.0 diff --git a/evennia/VERSION_REQS.txt b/evennia/VERSION_REQS.txt index e2a6dec300..a767c6b243 100644 --- a/evennia/VERSION_REQS.txt +++ b/evennia/VERSION_REQS.txt @@ -3,8 +3,8 @@ # when people upgrade outside regular channels). This file only supports lines of # `value = number` and only specific names supported by the handler. -PYTHON_MIN = 3.10 -PYTHON_MAX_TESTED = 3.12.100 -TWISTED_MIN = 23.10 +PYTHON_MIN = 3.11 +PYTHON_MAX_TESTED = 3.13.100 +TWISTED_MIN = 24.11 DJANGO_MIN = 4.0.2 -DJANGO_MAX_TESTED = 4.2.100 +DJANGO_MAX_TESTED = 5.1.100 diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index e92c245fb6..9266f3d4a9 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -478,11 +478,11 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): raise RuntimeError("Session not found") if self.get_puppet(session) == obj: # already puppeting this object - self.msg("You are already puppeting this object.") + self.msg(_("You are already puppeting this object.")) return if not obj.access(self, "puppet"): # no access - self.msg(f"You don't have permission to puppet '{obj.key}'.") + self.msg(_("You don't have permission to puppet '{key}'.").format(key=obj.key)) return if obj.account: # object already puppeted @@ -491,13 +491,21 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): # we may take over another of our sessions # output messages to the affected sessions if _MULTISESSION_MODE in (1, 3): - txt1 = f"Sharing |c{obj.name}|n with another of your sessions." - txt2 = f"|c{obj.name}|n|G is now shared from another of your sessions.|n" + txt1 = _("Sharing |c{name}|n with another of your sessions.").format( + name=obj.name + ) + txt2 = _( + "|c{name}|n|G is now shared from another of your sessions.|n" + ).format(name=obj.name) self.msg(txt1, session=session) self.msg(txt2, session=obj.sessions.all()) else: - txt1 = f"Taking over |c{obj.name}|n from another of your sessions." - txt2 = f"|c{obj.name}|n|R is now acted from another of your sessions.|n" + txt1 = _("Taking over |c{name}|n from another of your sessions.").format( + name=obj.name + ) + txt2 = _( + "|c{name}|n|R is now acted from another of your sessions.|n" + ).format(name=obj.name) self.msg(txt1, session=session) self.msg(txt2, session=obj.sessions.all()) self.unpuppet_object(obj.sessions.get()) @@ -523,7 +531,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): and len(self.get_all_puppets()) >= _MAX_NR_SIMULTANEOUS_PUPPETS ): self.msg( - _(f"You cannot control any more puppets (max {_MAX_NR_SIMULTANEOUS_PUPPETS})") + _("You cannot control any more puppets (max {max_puppets})").format( + max_puppets=_MAX_NR_SIMULTANEOUS_PUPPETS + ) ) return @@ -778,6 +788,9 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): In this case we're simply piggybacking on this feature to apply additional normalization per Evennia's standards. """ + if not isinstance(username, str): + username = str(username) + username = super(DefaultAccount, cls).normalize_username(username) # strip excessive spaces in accountname @@ -939,7 +952,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): # parse inputs character_key = kwargs.pop("key", self.key) character_ip = kwargs.pop("ip", self.db.creator_ip) - character_permissions = kwargs.pop("permissions", self.permissions) + character_permissions = kwargs.pop("permissions", self.permissions.all()) # Load the appropriate Character class character_typeclass = kwargs.pop("typeclass", self.default_character_typeclass) @@ -1010,8 +1023,8 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): account = None errors = [] - username = kwargs.get("username") - password = kwargs.get("password") + username = kwargs.get("username", "") + password = kwargs.get("password", "") email = kwargs.get("email", "").strip() guest = kwargs.get("guest", False) diff --git a/evennia/accounts/bots.py b/evennia/accounts/bots.py index 82bd3e2d57..f4315257d8 100644 --- a/evennia/accounts/bots.py +++ b/evennia/accounts/bots.py @@ -556,24 +556,28 @@ class DiscordBot(Bot): factory_path = "evennia.server.portal.discord.DiscordWebsocketServerFactory" - def at_init(self): - """ - Load required channels back into memory - - """ + def _load_channels(self): + self.ndb.ev_channels = {} if channel_links := self.db.channels: # this attribute contains a list of evennia<->discord links in the form # of ("evennia_channel", "discord_chan_id") # grab Evennia channels, cache and connect channel_set = {evchan for evchan, dcid in channel_links} - self.ndb.ev_channels = {} for channel_name in list(channel_set): channel = search.search_channel(channel_name) if not channel: - raise RuntimeError(f"Evennia Channel {channel_name} not found.") + logger.log_err(f"Evennia Channel {channel_name} not found; skipping.") + continue channel = channel[0] self.ndb.ev_channels[channel_name] = channel + def at_init(self): + """ + Load required channels back into memory + + """ + self._load_channels() + def start(self): """ Tell the Discord protocol to connect. @@ -583,27 +587,20 @@ class DiscordBot(Bot): self.delete() return - if self.ndb.ev_channels: - for channel in self.ndb.ev_channels.values(): - channel.connect(self) + if not self.ndb.ev_channels: + self._load_channels() - elif channel_links := self.db.channels: - # this attribute contains a list of evennia<->discord links in the form - # of ("evennia_channel", "discord_chan_id") - # grab Evennia channels, cache and connect - channel_set = {evchan for evchan, dcid in channel_links} - self.ndb.ev_channels = {} - for channel_name in list(channel_set): - channel = search.search_channel(channel_name) - if not channel: - raise RuntimeError(f"Evennia Channel {channel_name} not found.") - channel = channel[0] - self.ndb.ev_channels[channel_name] = channel - channel.connect(self) + for channel in self.ndb.ev_channels.values(): + if not channel.connect(self): + logger.log_warn(f"{self} could not connect to Evennia channel {channel}.") + if not channel.access(self, "send"): + logger.log_warn( + f"{self} doesn't have permission to send messages to Evennia channel {channel}." + ) - # connect # these will be made available as properties on the protocol factory configdict = {"uid": self.dbid} + # finally, connect evennia.SESSION_HANDLER.start_bot_session(self.factory_path, configdict) def at_pre_channel_msg(self, message, channel, senders=None, **kwargs): diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index e8bfe6c4c5..8b93d6689d 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -37,7 +37,7 @@ from weakref import WeakValueDictionary from django.conf import settings from django.utils.translation import gettext as _ from twisted.internet import reactor -from twisted.internet.defer import inlineCallbacks, returnValue +from twisted.internet.defer import inlineCallbacks from twisted.internet.task import deferLater from evennia.commands.cmdset import CmdSet @@ -261,6 +261,7 @@ def _progressive_cmd_run(cmd, generator, response=None): class NoCmdSets(Exception): "No cmdsets found. Critical error." + pass @@ -361,7 +362,12 @@ def get_and_merge_cmdsets( local_objlist = yield ( location.contents_get(exclude=obj) + obj.contents_get() + [location] ) - local_objlist = [o for o in local_objlist if not o._is_deleted] + local_objlist = [ + o + for o in local_objlist + if not o._is_deleted + and o.access(caller, access_type="call", no_superuser_bypass=True) + ] for lobj in local_objlist: try: # call hook in case we need to do dynamic changing to cmdset @@ -375,12 +381,7 @@ def get_and_merge_cmdsets( chain.from_iterable( lobj.cmdset.cmdset_stack for lobj in local_objlist - if ( - lobj.cmdset.current - and lobj.access( - caller, access_type="call", no_superuser_bypass=True - ) - ) + if lobj.cmdset.current ) ) for cset in local_obj_cmdsets: @@ -390,7 +391,7 @@ def get_and_merge_cmdsets( # explicitly. cset.old_duplicates = cset.duplicates cset.duplicates = True if cset.duplicates is None else cset.duplicates - returnValue(local_obj_cmdsets) + return local_obj_cmdsets except Exception: _msg_err(caller, _ERROR_CMDSETS) raise ErrorReported(raw_string) @@ -408,9 +409,9 @@ def get_and_merge_cmdsets( _msg_err(caller, _ERROR_CMDSETS) raise ErrorReported(raw_string) try: - returnValue(obj.get_cmdsets(caller=caller, current=current)) + return obj.get_cmdsets(caller=caller, current=current) except AttributeError: - returnValue((CmdSet(), [])) + return (CmdSet(), []) local_obj_cmdsets = [] @@ -486,7 +487,7 @@ def get_and_merge_cmdsets( # if cmdset: # caller.cmdset.current = cmdset - returnValue(cmdset) + return cmdset except ErrorReported: raise except Exception: @@ -600,7 +601,7 @@ def cmdhandler( if _testing: # only return the command instance - returnValue(cmd) + return cmd # assign custom kwargs to found cmd object for key, val in kwargs.items(): @@ -619,7 +620,7 @@ def cmdhandler( abort = yield cmd.at_pre_cmd() if abort: # abort sequence - returnValue(abort) + return abort # Parse and execute yield cmd.parse() @@ -630,14 +631,12 @@ def cmdhandler( if isinstance(ret, types.GeneratorType): # cmd.func() is a generator, execute progressively _progressive_cmd_run(cmd, ret) - ret = yield ret # note that the _progressive_cmd_run will itself run # the at_post_cmd etc as it finishes; this is a bit of # code duplication but there seems to be no way to # catch the StopIteration here (it's not in the same # frame since this is in a deferred chain) else: - ret = yield ret # post-command hook yield cmd.at_post_cmd() @@ -648,9 +647,6 @@ def cmdhandler( else: caller.ndb.last_cmd = None - # return result to the deferred - returnValue(ret) - except InterruptCommand: # Do nothing, clean exit pass @@ -759,10 +755,9 @@ def cmdhandler( cmd = copy(cmd) # A normal command. - ret = yield _run_command( + yield _run_command( cmd, cmdname, args, raw_cmdname, cmdset, session, account, cmdset_providers ) - returnValue(ret) except ErrorReported as exc: # this error was already reported, so we @@ -776,7 +771,7 @@ def cmdhandler( sysarg = exc.sysarg if syscmd: - ret = yield _run_command( + yield _run_command( syscmd, syscmd.key, sysarg, @@ -786,7 +781,7 @@ def cmdhandler( account, cmdset_providers, ) - returnValue(ret) + return elif sysarg: # return system arg error_to.msg(err_helper(exc.sysarg, cmdid=cmdid)) diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 8dbac1c3a7..8931442bb4 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -19,6 +19,7 @@ from evennia.utils.evtable import EvTable from evennia.utils.utils import fill, is_iter, lazy_property, make_iter CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES +_RE_CMD_LOCKFUNC_IN_LOCKSTRING = re.compile(r"(^|;|\s)cmd\:\w+", re.DOTALL) class InterruptCommand(Exception): @@ -74,7 +75,7 @@ def _init_command(cls, **kwargs): if not hasattr(cls, "locks"): # default if one forgets to define completely cls.locks = "cmd:all()" - if "cmd:" not in cls.locks: + if not _RE_CMD_LOCKFUNC_IN_LOCKSTRING.search(cls.locks): cls.locks = "cmd:all();" + cls.locks for lockstring in cls.locks.split(";"): if lockstring and ":" not in lockstring: @@ -575,7 +576,7 @@ Command \"{cmdname}\" has no defined `func()` method. Available properties on th ex. :: - url(r'characters/(?P[\w\d\-]+)/(?P[0-9]+)/$', + url(r'characters/(?P[\\w\\d\\-]+)/(?P[0-9]+)/$', CharDetailView.as_view(), name='character-detail') If no View has been created and defined in urls.py, returns an @@ -623,6 +624,22 @@ Command \"{cmdname}\" has no defined `func()` method. Available properties on th )[0] return settings.CLIENT_DEFAULT_WIDTH + def _get_account_option(self, option): + """ + Retrieve the value of a specified account option. + + Args: + option (str): The name of the option to retrieve. + + Returns: + The value of the specified account option if the account exists, + otherwise the default value from settings.OPTIONS_ACCOUNT_DEFAULT. + + """ + if self.account: + return self.account.options.get(option) + return settings.OPTIONS_ACCOUNT_DEFAULT.get(option) + def styled_table(self, *args, **kwargs): """ Create an EvTable styled by on user preferences. @@ -638,8 +655,8 @@ Command \"{cmdname}\" has no defined `func()` method. Available properties on th or incomplete and ready for use with `.add_row` or `.add_collumn`. """ - border_color = self.account.options.get("border_color") - column_color = self.account.options.get("column_names_color") + border_color = self._get_account_option("border_color") + column_color = self._get_account_option("column_names_color") colornames = ["|%s%s|n" % (column_color, col) for col in args] @@ -699,9 +716,9 @@ Command \"{cmdname}\" has no defined `func()` method. Available properties on th """ colors = dict() - colors["border"] = self.account.options.get("border_color") - colors["headertext"] = self.account.options.get("%s_text_color" % mode) - colors["headerstar"] = self.account.options.get("%s_star_color" % mode) + colors["border"] = self._get_account_option("border_color") + colors["headertext"] = self._get_account_option("%s_text_color" % mode) + colors["headerstar"] = self._get_account_option("%s_star_color" % mode) width = width or self.client_width() if edge_character: @@ -722,7 +739,7 @@ Command \"{cmdname}\" has no defined `func()` method. Available properties on th else: center_string = "" - fill_character = self.account.options.get("%s_fill" % mode) + fill_character = self._get_account_option("%s_fill" % mode) remain_fill = width - len(center_string) if remain_fill % 2 == 0: diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index 602f2a0413..8f7b558584 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -212,7 +212,7 @@ class CmdBan(COMMAND_DEFAULT_CLASS): typ = "IP" ban = ipban[0] # replace * with regex form and compile it - ipregex = ban.replace(".", "\.") + ipregex = ban.replace(".", r"\.") ipregex = ipregex.replace("*", "[0-9]{1,3}") ipregex = re.compile(r"%s" % ipregex) bantup = ("", ban, ipregex, now, reason) diff --git a/evennia/commands/default/batchprocess.py b/evennia/commands/default/batchprocess.py index 694fd20972..a1e36304e8 100644 --- a/evennia/commands/default/batchprocess.py +++ b/evennia/commands/default/batchprocess.py @@ -21,7 +21,6 @@ therefore always be limited to superusers only. import re from django.conf import settings - from evennia.commands.cmdset import CmdSet from evennia.utils import logger, utils from evennia.utils.batchprocessors import BATCHCMD, BATCHCODE @@ -394,7 +393,7 @@ class CmdStateAbort(_COMMAND_DEFAULT_CLASS): key = "abort" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): """Exit back to default.""" @@ -412,7 +411,7 @@ class CmdStateLL(_COMMAND_DEFAULT_CLASS): key = "ll" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): show_curr(self.caller, showall=True) @@ -427,7 +426,7 @@ class CmdStatePP(_COMMAND_DEFAULT_CLASS): key = "pp" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): """ @@ -450,7 +449,7 @@ class CmdStateRR(_COMMAND_DEFAULT_CLASS): key = "rr" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -473,7 +472,7 @@ class CmdStateRRR(_COMMAND_DEFAULT_CLASS): key = "rrr" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -495,7 +494,7 @@ class CmdStateNN(_COMMAND_DEFAULT_CLASS): key = "nn" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -518,7 +517,7 @@ class CmdStateNL(_COMMAND_DEFAULT_CLASS): key = "nl" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -541,7 +540,7 @@ class CmdStateBB(_COMMAND_DEFAULT_CLASS): key = "bb" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -564,7 +563,7 @@ class CmdStateBL(_COMMAND_DEFAULT_CLASS): key = "bl" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -588,7 +587,7 @@ class CmdStateSS(_COMMAND_DEFAULT_CLASS): key = "ss" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -618,7 +617,7 @@ class CmdStateSL(_COMMAND_DEFAULT_CLASS): key = "sl" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -647,7 +646,7 @@ class CmdStateCC(_COMMAND_DEFAULT_CLASS): key = "cc" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -676,7 +675,7 @@ class CmdStateJJ(_COMMAND_DEFAULT_CLASS): key = "jj" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -701,7 +700,7 @@ class CmdStateJL(_COMMAND_DEFAULT_CLASS): key = "jl" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): caller = self.caller @@ -726,7 +725,7 @@ class CmdStateQQ(_COMMAND_DEFAULT_CLASS): key = "qq" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): purge_processor(self.caller) @@ -738,7 +737,7 @@ class CmdStateHH(_COMMAND_DEFAULT_CLASS): key = "hh" help_category = "BatchProcess" - locks = "cmd:perm(batchcommands)" + locks = "cmd:perm(batchcommands) or perm(Developer)" def func(self): string = """ diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index cffd59a6a6..40f1b60371 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -5,10 +5,11 @@ 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.locks.lockhandler import LockException @@ -167,7 +168,7 @@ class ObjManipCommand(COMMAND_DEFAULT_CLASS): attrs = _attrs # store data obj_defs[iside].append({"name": objdef, "option": option, "aliases": aliases}) - obj_attrs[iside].append({"name": objdef, "attrs": attrs}) + obj_attrs[iside].append({"name": objdef, "attrs": attrs, "category": option}) # store for future access self.lhs_objs = obj_defs[0] @@ -397,7 +398,10 @@ class CmdCopy(ObjManipCommand): if not from_obj: return to_obj_name = "%s_copy" % from_obj_name - to_obj_aliases = ["%s_copy" % alias for alias in from_obj.aliases.all()] + to_obj_aliases = [ + (f"{alias}_copy", category) + for alias, category in from_obj.aliases.all(return_key_and_category=True) + ] copiedobj = ObjectDB.objects.copy_object( from_obj, new_key=to_obj_name, new_aliases=to_obj_aliases ) @@ -447,7 +451,7 @@ class CmdCpAttr(ObjManipCommand): Usage: cpattr[/switch] / = / [,/,/,...] cpattr[/switch] / = [,,,...] - cpattr[/switch] = / [,/,/,...] + cpattr[/switch] [:category] = /[:category] [,/,/,...] cpattr[/switch] = [,,,...] Switches: @@ -459,6 +463,11 @@ class CmdCpAttr(ObjManipCommand): copies the coolness attribute (defined on yourself), to attributes on Anna and Tom. + cpattr box/width:dimension = tube/width:dimension + -> + copies the box's width attribute in the dimension category, to be the + tube's width attribute in the dimension category + Copy the attribute one object to one or more attributes on another object. If you don't supply a source object, yourself is used. """ @@ -468,7 +477,7 @@ class CmdCpAttr(ObjManipCommand): locks = "cmd:perm(cpattr) or perm(Builder)" help_category = "Building" - def check_from_attr(self, obj, attr, clear=False): + def check_from_attr(self, obj, attr, category=None, clear=False): """ Hook for overriding on subclassed commands. Checks to make sure a caller can copy the attr from the object in question. If not, return a @@ -479,7 +488,7 @@ class CmdCpAttr(ObjManipCommand): """ return True - def check_to_attr(self, obj, attr): + def check_to_attr(self, obj, attr, category=None): """ Hook for overriding on subclassed commands. Checks to make sure a caller can write to the specified attribute on the specified object. @@ -488,22 +497,24 @@ class CmdCpAttr(ObjManipCommand): """ return True - def check_has_attr(self, obj, attr): + def check_has_attr(self, obj, attr, category=None): """ Hook for overriding on subclassed commands. Do any preprocessing required and verify an object has an attribute. """ - if not obj.attributes.has(attr): - self.msg(f"{obj.name} doesn't have an attribute {attr}.") + if not obj.attributes.has(attr, category=category): + self.msg( + f"{obj.name} doesn't have an attribute {attr}{f'[{category}]' if category else ''}." + ) return False return True - def get_attr(self, obj, attr): + def get_attr(self, obj, attr, category=None): """ Hook for overriding on subclassed commands. Do any preprocessing required and get the attribute from the object. """ - return obj.attributes.get(attr) + return obj.attributes.get(attr, category=category) def func(self): """ @@ -513,7 +524,7 @@ class CmdCpAttr(ObjManipCommand): if not self.rhs: string = """Usage: - cpattr[/switch] / = / [,/,/,...] + cpattr[/switch] /[:category] = / [,/,/,...] cpattr[/switch] / = [,,,...] cpattr[/switch] = / [,/,/,...] cpattr[/switch] = [,,,...]""" @@ -524,6 +535,8 @@ class CmdCpAttr(ObjManipCommand): to_objs = self.rhs_objattr from_obj_name = lhs_objattr[0]["name"] from_obj_attrs = lhs_objattr[0]["attrs"] + from_obj_category = lhs_objattr[0]["category"] # None if unset + from_obj_category_str = f"[{from_obj_category}]" if from_obj_category else "" if not from_obj_attrs: # this means the from_obj_name is actually an attribute @@ -540,11 +553,13 @@ class CmdCpAttr(ObjManipCommand): clear = True else: clear = False - if not self.check_from_attr(from_obj, from_obj_attrs[0], clear=clear): + if not self.check_from_attr( + from_obj, from_obj_attrs[0], clear=clear, category=from_obj_category + ): return for attr in from_obj_attrs: - if not self.check_has_attr(from_obj, attr): + if not self.check_has_attr(from_obj, attr, category=from_obj_category): return if (len(from_obj_attrs) != len(set(from_obj_attrs))) and clear: @@ -552,10 +567,11 @@ class CmdCpAttr(ObjManipCommand): return result = [] - for to_obj in to_objs: to_obj_name = to_obj["name"] to_obj_attrs = to_obj["attrs"] + to_obj_category = to_obj.get("category") + to_obj_category_str = f"[{to_obj_category}]" if to_obj_category else "" to_obj = caller.search(to_obj_name) if not to_obj: result.append(f"\nCould not find object '{to_obj_name}'") @@ -567,19 +583,19 @@ class CmdCpAttr(ObjManipCommand): # if there are too few attributes given # on the to_obj, we copy the original name instead. to_attr = from_attr - if not self.check_to_attr(to_obj, to_attr): + if not self.check_to_attr(to_obj, to_attr, to_obj_category): continue - value = self.get_attr(from_obj, from_attr) - to_obj.attributes.add(to_attr, value) + value = self.get_attr(from_obj, from_attr, from_obj_category) + to_obj.attributes.add(to_attr, value, category=to_obj_category) if clear and not (from_obj == to_obj and from_attr == to_attr): - from_obj.attributes.remove(from_attr) + from_obj.attributes.remove(from_attr, category=from_obj_category) result.append( - f"\nMoved {from_obj.name}.{from_attr} -> {to_obj_name}.{to_attr}. (value:" + f"\nMoved {from_obj.name}.{from_attr}{from_obj_category_str} -> {to_obj_name}.{to_attr}{to_obj_category_str}. (value:" f" {repr(value)})" ) else: result.append( - f"\nCopied {from_obj.name}.{from_attr} -> {to_obj.name}.{to_attr}. (value:" + f"\nCopied {from_obj.name}.{from_attr}{from_obj_category_str} -> {to_obj.name}.{to_attr}{to_obj_category_str}. (value:" f" {repr(value)})" ) caller.msg("".join(result)) @@ -693,6 +709,7 @@ class CmdCreate(ObjManipCommand): ) if errors: self.msg(errors) + if not obj: continue @@ -702,9 +719,7 @@ class CmdCreate(ObjManipCommand): ) else: string = f"You create a new {obj.typename}: {obj.name}." - # set a default desc - if not obj.db.desc: - obj.db.desc = "You see nothing special." + if "drop" in self.switches: if caller.location: obj.home = caller.location @@ -1473,7 +1488,7 @@ class CmdName(ObjManipCommand): obj = None if self.lhs_objs: objname = self.lhs_objs[0]["name"] - if objname.startswith("*"): + if objname.startswith("*") and caller.account: # account mode obj = caller.account.search(objname.lstrip("*")) if obj: @@ -3238,7 +3253,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): try: # Check that rhs is either a valid dbref or dbref range bounds = tuple( - sorted(dbref(x, False) for x in re.split("[-\s]+", self.rhs.strip())) + sorted(dbref(x, False) for x in re.split(r"[-\s]+", self.rhs.strip())) ) # dbref() will return either a valid int or None diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 5c5df209b2..a5d1da3e19 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -1382,18 +1382,18 @@ class CmdPage(COMMAND_DEFAULT_CLASS): if target and target.isnumeric(): # a number to specify a historic page number = int(target) - elif target: + elif message: target_obj = self.caller.search(target, quiet=True) if target_obj: # a proper target targets = [target_obj[0]] message = message[0].strip() else: - # a message with a space in it - put it back together - message = target + " " + (message[0] if message else "") + # a message with a space in it - use the original args + message = self.args.strip() else: - # a single-word message - message = message[0].strip() + # a single-word message - use the original args + message = self.args.strip() pages = list(pages_we_sent) + list(pages_we_got) pages = sorted(pages, key=lambda page: page.date_created) @@ -2030,7 +2030,7 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): # show all connections if channel_list := discord_bot.db.channels: table = self.styled_table( - "|wLink ID|n", + "|wLink Index|n", "|wEvennia|n", "|wDiscord|n", border="cells", @@ -2076,7 +2076,7 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): # show all discord channels linked to self.lhs if channel_list := discord_bot.db.channels: table = self.styled_table( - "|wLink ID|n", + "|wLink Index|n", "|wEvennia|n", "|wDiscord|n", border="cells", diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 06aae08f75..c5af73af79 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -6,7 +6,7 @@ import re from django.conf import settings -import evennia +from evennia.objects.objects import DefaultObject from evennia.typeclasses.attributes import NickTemplateInvalid from evennia.utils import utils @@ -118,7 +118,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): nick build $1 $2 = create/drop $1;$2 nick tell $1 $2=page $1=$2 nick tm?$1=page tallman=$1 - nick tm\=$1=page tallman=$1 + nick tm\\\\=$1=page tallman=$1 A 'nick' is a personal string replacement. Use $1, $2, ... to catch arguments. Put the last $-marker without an ending space to catch all remaining text. You @@ -128,7 +128,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): ? - matches 0 or 1 single characters [abcd] - matches these chars in any order [!abcd] - matches everything not among these chars - \= - escape literal '=' you want in your + \\\\= - escape literal '=' you want in your Note that no objects are actually renamed or changed by this command - your nicks are only available to you. If you want to permanently add keywords to an object @@ -143,7 +143,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): def parse(self): """ - Support escaping of = with \= + Support escaping of = with \\= """ super().parse() args = (self.lhs or "") + (" = %s" % self.rhs if self.rhs else "") @@ -153,7 +153,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): self.lhs = parts[0].strip() else: self.lhs, self.rhs = [part.strip() for part in parts] - self.lhs = self.lhs.replace("\=", "=") + self.lhs = self.lhs.replace("\\=", "=") def func(self): """Create the nickname""" @@ -190,7 +190,8 @@ class CmdNick(COMMAND_DEFAULT_CLASS): if "clearall" in switches: caller.nicks.clear() - caller.account.nicks.clear() + if caller.account: + caller.account.nicks.clear() caller.msg("Cleared all nicks.") return @@ -790,15 +791,18 @@ class CmdAccess(COMMAND_DEFAULT_CLASS): hierarchy_full = settings.PERMISSION_HIERARCHY string = "\n|wPermission Hierarchy|n (climbing):\n %s" % ", ".join(hierarchy_full) - if self.caller.account.is_superuser: + if caller.account and caller.account.is_superuser: cperms = "" pperms = "" else: cperms = ", ".join(caller.permissions.all()) - pperms = ", ".join(caller.account.permissions.all()) + if caller.account: + pperms = ", ".join(caller.account.permissions.all()) + else: + pperms = "" string += "\n|wYour access|n:" string += f"\nCharacter |c{caller.key}|n: {cperms}" - if utils.inherits_from(caller, evennia.DefaultObject): + if utils.inherits_from(caller, DefaultObject) and caller.account: string += f"\nAccount |c{caller.account.key}|n: {pperms}" caller.msg(string) diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index 4e9b06bdde..dbdfe65374 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -477,6 +477,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): tuple: A tuple (match, suggestions). """ + def strip_prefix(query): if query and query[0] in settings.CMD_IGNORE_PREFIXES: return query[1:] @@ -742,7 +743,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): if not fuzzy_match: # no match found - give up - checked_topic = topic + f"/{subtopic_query}" + checked_topic = topic + f"{self.subtopic_separator_char}{subtopic_query}" output = self.format_help_entry( topic=topic, help_text=f"No help entry found for '{checked_topic}'", @@ -757,7 +758,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS): subtopic_map = subtopic_map.pop(subtopic_query) subtopic_index = [subtopic for subtopic in subtopic_map if subtopic is not None] # keep stepping down into the tree, append path to show position - topic = topic + f"/{subtopic_query}" + topic = topic + f"{self.subtopic_separator_char}{subtopic_query}" # we reached the bottom of the topic tree help_text = subtopic_map[None] diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index d6e2466dc5..b09aa8cb19 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -7,6 +7,7 @@ System commands import code import datetime import os +import subprocess import sys import time import traceback @@ -48,6 +49,11 @@ __all__ = ( ) +class PrintRecursionError(RecursionError): + # custom error for recursion in print + pass + + class CmdReload(COMMAND_DEFAULT_CLASS): """ reload the server @@ -203,7 +209,14 @@ def _run_code_snippet( self.caller = caller def write(self, string): - self.caller.msg(text=(string.rstrip("\n"), {"type": "py_output"})) + try: + self.caller.msg(text=(string.rstrip("\n"), {"type": "py_output"})) + except RecursionError: + tb = traceback.extract_tb(sys.exc_info()[2]) + if any("print(" in frame.line for frame in tb): + # We are in a print loop (likely because msg() contains a print), + # exit the stdout reroute prematurely + raise PrintRecursionError fake_std = FakeStd(caller) sys.stdout = fake_std @@ -225,6 +238,12 @@ def _run_code_snippet( else: ret = eval(pycode_compiled, {}, available_vars) + except PrintRecursionError: + ret = ( + "<<< Error: Recursive print() found (probably in custom msg()). " + "Since `py` reroutes `print` to `msg()`, this causes a loop. Remove `print()` " + "from msg-related code to resolve." + ) except Exception: errlist = traceback.format_exc().split("\n") if len(errlist) > 4: @@ -679,8 +698,7 @@ class CmdAbout(COMMAND_DEFAULT_CLASS): |wHomepage|n https://evennia.com |wCode|n https://github.com/evennia/evennia - |wDemo|n https://demo.evennia.com - |wGame listing|n https://games.evennia.com + |wGame listing|n http://games.evennia.com |wChat|n https://discord.gg/AJJpcRUhtF |wForum|n https://github.com/evennia/evennia/discussions |wLicence|n https://opensource.org/licenses/BSD-3-Clause @@ -845,16 +863,26 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): if not _RESOURCE: import resource as _RESOURCE + env = os.environ.copy() + env["LC_NUMERIC"] = "C" # use default locale instead of system locale loadavg = os.getloadavg()[0] - rmem = ( - float(os.popen("ps -p %d -o %s | tail -1" % (pid, "rss")).read()) / 1000.0 - ) # resident memory - vmem = ( - float(os.popen("ps -p %d -o %s | tail -1" % (pid, "vsz")).read()) / 1000.0 - ) # virtual memory - pmem = float( - os.popen("ps -p %d -o %s | tail -1" % (pid, "%mem")).read() - ) # % of resident memory to total + + # Helper function to run the ps command with a modified environment + def run_ps_command(command): + result = subprocess.run( + command, shell=True, env=env, stdout=subprocess.PIPE, text=True + ) + return result.stdout.strip() + + # Resident memory + rmem = float(run_ps_command(f"ps -p {pid} -o rss | tail -1")) / 1000.0 + + # Virtual memory + vmem = float(run_ps_command(f"ps -p {pid} -o vsz | tail -1")) / 1000.0 + + # Percentage of resident memory to total + pmem = float(run_ps_command(f"ps -p {pid} -o %mem | tail -1")) + rusage = _RESOURCE.getrusage(_RESOURCE.RUSAGE_SELF) if "mem" in self.switches: @@ -930,7 +958,7 @@ class CmdTickers(COMMAND_DEFAULT_CLASS): locks = "cmd:perm(tickers) or perm(Builder)" def func(self): - from evennia import TICKER_HANDLER + from evennia.scripts.tickerhandler import TICKER_HANDLER all_subs = TICKER_HANDLER.all_display() if not all_subs: diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index a204749a47..852e4c6b2f 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -14,32 +14,39 @@ 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 evennia import ( - DefaultCharacter, - DefaultExit, - DefaultObject, - DefaultRoom, - ObjectDB, - search_object, -) +from parameterized import parameterized +from twisted.internet import task + +import evennia 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 from evennia.commands.default.muxcommand import MuxCommand +from evennia.objects.models import ObjectDB +from evennia.objects.objects import ( + DefaultCharacter, + DefaultExit, + DefaultObject, + DefaultRoom, +) from evennia.prototypes import prototypes as protlib from evennia.utils import create, gametime, utils +from evennia.utils.search import search_object 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 @@ -543,7 +550,9 @@ class TestCmdTasks(BaseEvenniaCommandTest): self.call(system.CmdTasks(), f"/cancel {self.task.get_id()}") self.task_handler.clock.advance(self.timedelay + 1) self.assertFalse(self.task.exists()) - self.task = self.task_handler.add(self.timedelay, func_test_cmd_tasks) + # the +1 time delay is to fix a timing issue with the test on Windows + # (see https://github.com/evennia/evennia/issues/3596) + self.task = self.task_handler.add(self.timedelay + 1, func_test_cmd_tasks) self.assertTrue(self.task.get_id(), 1) self.char1.msg = Mock() self.char1.execute_cmd("y") @@ -1265,6 +1274,15 @@ class TestBuilding(BaseEvenniaCommandTest): ) self.call(building.CmdName(), "Obj4=", "No names or aliases defined!") + def test_name_clears_plural(self): + box, _ = DefaultObject.create("Opened Box", location=self.char1) + + # Force update of plural aliases (set in get_numbered_name) + self.char1.execute_cmd("inventory") + self.assertIn("one opened box", box.aliases.get(category=box.plural_category)) + self.char1.execute_cmd("@name box=closed box") + self.assertIsNone(box.aliases.get(category=box.plural_category)) + def test_desc(self): oid = self.obj2.id self.call(building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2.") diff --git a/evennia/commands/tests.py b/evennia/commands/tests.py index 10eb5143a7..d1b8f4eb47 100644 --- a/evennia/commands/tests.py +++ b/evennia/commands/tests.py @@ -1307,3 +1307,24 @@ class TestIssue3090(BaseEvenniaTest): self.assertEqual(result[3], 8) self.assertEqual(result[4], 1.0) self.assertEqual(result[5], "smile at") + + +class _TestCmd1(Command): + key = "testcmd" + locks = "usecmd:false()" + + def func(): + pass + + +class TestIssue3643(BaseEvenniaTest): + """ + Commands with a 'cmd:' anywhere in its string, even `funccmd:` is assumed to + be a cmd: type lock, meaning it will not auto-insert `cmd:all()` into the + lockstring as intended. + + """ + + def test_issue_3643(self): + cmd = _TestCmd1() + self.assertEqual(cmd.locks, "cmd:all();usecmd:false()") diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index 5b1a0bc028..343c7b9e40 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -9,9 +9,9 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.text import slugify -import evennia from evennia.comms.managers import ChannelManager from evennia.comms.models import ChannelDB +from evennia.objects.objects import DefaultObject from evennia.typeclasses.models import TypeclassBase from evennia.utils import create, logger from evennia.utils.utils import inherits_from, make_iter @@ -231,7 +231,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): """ has_sub = self.subscriptions.has(subscriber) - if not has_sub and inherits_from(subscriber, evennia.DefaultObject): + if not has_sub and inherits_from(subscriber, DefaultObject): # it's common to send an Object when we # by default only allow Accounts to subscribe. has_sub = self.subscriptions.has(subscriber.account) @@ -814,7 +814,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): return "#" def web_get_detail_url(self): - """ + r""" Returns the URI path for a View that allows users to view details for this object. @@ -850,7 +850,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): return "#" def web_get_update_url(self): - """ + r""" Returns the URI path for a View that allows users to update this object. @@ -886,7 +886,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): return "#" def web_get_delete_url(self): - """ + r""" Returns the URI path for a View that allows users to delete this object. ex. Oscar (Character) = '/characters/oscar/1/delete/' diff --git a/evennia/comms/models.py b/evennia/comms/models.py index efab6ee4d3..b8053015e2 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -184,6 +184,7 @@ class Msg(SharedMemoryModel): class Meta(object): "Define Django meta options" + verbose_name = "Msg" @lazy_property @@ -712,6 +713,7 @@ class ChannelDB(TypedObject): class Meta: "Define Django meta options" + verbose_name = "Channel" verbose_name_plural = "Channels" diff --git a/evennia/comms/tests.py b/evennia/comms/tests.py index b037730e49..96ac1a38db 100644 --- a/evennia/comms/tests.py +++ b/evennia/comms/tests.py @@ -1,7 +1,7 @@ from django.test import SimpleTestCase -from evennia import DefaultChannel from evennia.commands.default.comms import CmdChannel +from evennia.comms.comms import DefaultChannel from evennia.utils.create import create_message from evennia.utils.test_resources import BaseEvenniaTest diff --git a/evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py b/evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py index 1123c66f1a..1d7494ab92 100644 --- a/evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py +++ b/evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py @@ -76,7 +76,7 @@ from tempfile import SpooledTemporaryFile from django.core.files.base import File from django.core.files.storage import Storage from django.utils.deconstruct import deconstructible -from django.utils.encoding import filepath_to_uri, force_bytes, force_text, smart_text +from django.utils.encoding import filepath_to_uri, force_bytes, force_str, smart_str from django.utils.timezone import is_naive, make_naive try: @@ -127,9 +127,9 @@ def safe_join(base, *paths): final_path (str): A joined path, base + filepath """ - base_path = force_text(base) + base_path = force_str(base) base_path = base_path.rstrip("/") - paths = [force_text(p) for p in paths] + paths = [force_str(p) for p in paths] final_path = base_path + "/" for path in paths: @@ -252,7 +252,7 @@ class S3Boto3StorageFile(File): self._storage = storage self.name = name[len(self._storage.location) :].lstrip("/") self._mode = mode - self._force_mode = (lambda b: b) if "b" in mode else force_text + self._force_mode = (lambda b: b) if "b" in mode else force_str self.obj = storage.bucket.Object(storage._encode_name(name)) if "w" not in mode: # Force early RAII-style exception if object does not exist @@ -632,10 +632,10 @@ class S3Boto3Storage(Storage): raise SuspiciousOperation("Attempted access to '%s' denied." % name) def _encode_name(self, name): - return smart_text(name, encoding=self.file_name_charset) + return smart_str(name, encoding=self.file_name_charset) def _decode_name(self, name): - return force_text(name, encoding=self.file_name_charset) + return force_str(name, encoding=self.file_name_charset) def _compress_content(self, content): """Gzip a given string content.""" diff --git a/evennia/contrib/base_systems/awsstorage/tests.py b/evennia/contrib/base_systems/awsstorage/tests.py index 37736fcc59..7ea4f82832 100644 --- a/evennia/contrib/base_systems/awsstorage/tests.py +++ b/evennia/contrib/base_systems/awsstorage/tests.py @@ -8,7 +8,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.files.base import ContentFile from django.test import TestCase, override_settings -from django.utils.timezone import is_aware, utc +from django.utils.timezone import is_aware _SKIP = False try: @@ -533,7 +533,7 @@ class S3Boto3StorageTests(S3Boto3TestCase): def _test_storage_mtime(self, use_tz): obj = self.storage.bucket.Object.return_value - obj.last_modified = datetime.datetime.now(utc) + obj.last_modified = datetime.datetime.now(datetime.timezone.utc) name = "file.txt" self.assertFalse( diff --git a/evennia/contrib/base_systems/godotwebsocket/README.md b/evennia/contrib/base_systems/godotwebsocket/README.md index b80068024c..466eebea4f 100644 --- a/evennia/contrib/base_systems/godotwebsocket/README.md +++ b/evennia/contrib/base_systems/godotwebsocket/README.md @@ -9,7 +9,7 @@ You can use Godot to provide advanced functionality with proper Evennia support. ## Installation -You need to add the following settings in your settings.py and restart evennia. +You need to add the following settings in your `settings.py` and restart evennia. ```python PORTAL_SERVICES_PLUGIN_MODULES.append('evennia.contrib.base_systems.godotwebsocket.webclient') @@ -64,7 +64,7 @@ This will connect when the Scene is ready, poll and print the data when we recei extends Node # The URL we will connect to. -var websocket_url = "ws://localhost:4008" +var websocket_url = "ws://127.0.0.1:4008" var socket := WebSocketPeer.new() func _ready(): @@ -145,7 +145,7 @@ func _on_button_pressed(): extends Node # The URL we will connect to. -var websocket_url = "ws://localhost:4008" +var websocket_url = "ws://127.0.0.1:4008" var socket := WebSocketPeer.new() @onready var output_label = $"../Panel/VBoxContainer/RichTextLabel" @@ -193,4 +193,4 @@ func _on_button_pressed(): func _exit_tree(): socket.close() -``` \ No newline at end of file +``` diff --git a/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.py b/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.py index cddcd8ef41..783e78aecd 100644 --- a/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.py +++ b/evennia/contrib/base_systems/godotwebsocket/test_text2bbcode.py @@ -1,4 +1,4 @@ -"""Tests for text2bbcode """ +"""Tests for text2bbcode""" import mock from django.test import TestCase @@ -58,7 +58,7 @@ class TestText2Bbcode(TestCase): mocked_match = mock.Mock() mocked_match.groups.return_value = ["cmd", "text"] - self.assertEqual("[mxp=send cmd=cmd]text[/mxp]", parser.sub_mxp_links(mocked_match)) + self.assertEqual("[url=send cmd=cmd]text[/url]", parser.sub_mxp_links(mocked_match)) def test_sub_text(self): parser = text2bbcode.BBCODE_PARSER diff --git a/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py b/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py index 5b94243941..c77bdb8553 100644 --- a/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py +++ b/evennia/contrib/base_systems/godotwebsocket/text2bbcode.py @@ -705,7 +705,7 @@ class TextToBBCODEparser(TextToHTMLparser): """ cmd, text = [grp.replace('"', "\\"") for grp in match.groups()] - val = f"[mxp=send cmd={cmd}]{text}[/mxp]" + val = f"[url=send cmd={cmd}]{text}[/url]" return val diff --git a/evennia/contrib/base_systems/godotwebsocket/webclient.py b/evennia/contrib/base_systems/godotwebsocket/webclient.py index bd8576ff17..0706781ba6 100644 --- a/evennia/contrib/base_systems/godotwebsocket/webclient.py +++ b/evennia/contrib/base_systems/godotwebsocket/webclient.py @@ -65,6 +65,7 @@ class GodotWebSocketClient(webclient.WebSocketClient): def start_plugin_services(portal): class GodotWebsocket(WebSocketServerFactory): "Only here for better naming in logs" + pass factory = GodotWebsocket() diff --git a/evennia/contrib/base_systems/ingame_python/typeclasses.py b/evennia/contrib/base_systems/ingame_python/typeclasses.py index 5f14ee8c6e..1e0abac220 100644 --- a/evennia/contrib/base_systems/ingame_python/typeclasses.py +++ b/evennia/contrib/base_systems/ingame_python/typeclasses.py @@ -7,14 +7,19 @@ default ones in evennia core. """ -from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom, ScriptDB from evennia.contrib.base_systems.ingame_python.callbackhandler import CallbackHandler from evennia.contrib.base_systems.ingame_python.utils import ( phrase_event, register_events, time_event, ) -from evennia.utils.utils import delay, inherits_from, lazy_property +from evennia.objects.objects import ( + DefaultCharacter, + DefaultExit, + DefaultObject, + DefaultRoom, +) +from evennia.utils.utils import inherits_from, lazy_property # Character help CHARACTER_CAN_DELETE = """ diff --git a/evennia/contrib/base_systems/ingame_python/utils.py b/evennia/contrib/base_systems/ingame_python/utils.py index 8635792964..d332e39815 100644 --- a/evennia/contrib/base_systems/ingame_python/utils.py +++ b/evennia/contrib/base_systems/ingame_python/utils.py @@ -7,11 +7,15 @@ These functions are to be used by developers to customize events and callbacks. from django.conf import settings -from evennia import ScriptDB, logger -from evennia.contrib.base_systems.custom_gametime import UNITS, gametime_to_realtime +from evennia.contrib.base_systems.custom_gametime import ( + UNITS, + gametime_to_realtime, +) from evennia.contrib.base_systems.custom_gametime import ( real_seconds_until as custom_rsu, ) +from evennia.scripts.models import ScriptDB +from evennia.utils import logger from evennia.utils.create import create_script from evennia.utils.gametime import real_seconds_until as standard_rsu from evennia.utils.utils import class_from_module diff --git a/evennia/contrib/base_systems/ingame_reports/README.md b/evennia/contrib/base_systems/ingame_reports/README.md index 00c1cbf905..7376390325 100644 --- a/evennia/contrib/base_systems/ingame_reports/README.md +++ b/evennia/contrib/base_systems/ingame_reports/README.md @@ -77,7 +77,7 @@ The contrib is designed to make adding new types of reports to the system as sim #### Update your settings -The contrib optionally references `INGAME_REPORT_TYPES` in your settings.py to see which types of reports can be managed. If you want to change the available report types, you'll need to define this setting. +The contrib optionally references `INGAME_REPORT_TYPES` in your `settings.py` to see which types of reports can be managed. If you want to change the available report types, you'll need to define this setting. ```python # in server/conf/settings.py diff --git a/evennia/contrib/base_systems/ingame_reports/menu.py b/evennia/contrib/base_systems/ingame_reports/menu.py index a7a4c98a2e..7759f4992b 100644 --- a/evennia/contrib/base_systems/ingame_reports/menu.py +++ b/evennia/contrib/base_systems/ingame_reports/menu.py @@ -24,11 +24,6 @@ if hasattr(settings, "INGAME_REPORT_STATUS_TAGS"): def menunode_list_reports(caller, raw_string, **kwargs): """Paginates and lists out reports for the provided hub""" hub = caller.ndb._evmenu.hub - - page = kwargs.get("page", 0) - start = page * _REPORTS_PER_PAGE - end = start + _REPORTS_PER_PAGE - report_slice = report_list[start:end] hub_name = " ".join(hub.key.split("_")).title() text = f"Managing {hub_name}" @@ -54,6 +49,11 @@ def menunode_list_reports(caller, raw_string, **kwargs): if not report_list: return "There is nothing there for you to manage.", {} + page = kwargs.get("page", 0) + start = page * _REPORTS_PER_PAGE + end = start + _REPORTS_PER_PAGE + report_slice = report_list[start:end] + options = [ { "desc": f"{datetime_format(report.date_created)} - {crop(report.message, 50)}", diff --git a/evennia/contrib/base_systems/ingame_reports/reports.py b/evennia/contrib/base_systems/ingame_reports/reports.py index 62d470676a..73b348e1f1 100644 --- a/evennia/contrib/base_systems/ingame_reports/reports.py +++ b/evennia/contrib/base_systems/ingame_reports/reports.py @@ -23,7 +23,7 @@ To install, just add the provided cmdset to your default AccountCmdSet: The contrib provides three commands by default and their associated report types: `CmdBug`, `CmdIdea`, and `CmdReport` (which is for reporting other players). - + The `ReportCmdBase` class holds most of the functionality for creating new reports, providing a convenient parent class for adding your own categories of reports. @@ -34,10 +34,10 @@ The contrib can be further configured through two settings, `INGAME_REPORT_TYPES from django.conf import settings from evennia import CmdSet -from evennia.utils import create, evmenu, logger, search -from evennia.utils.utils import class_from_module, datetime_format, is_iter, iter_to_str from evennia.commands.default.muxcommand import MuxCommand from evennia.comms.models import Msg +from evennia.utils import create, evmenu, logger, search +from evennia.utils.utils import class_from_module, datetime_format, is_iter, iter_to_str from . import menu @@ -67,8 +67,9 @@ def _get_report_hub(report_type): Note: If no matching valid script exists, this function will attempt to create it. """ hub_key = f"{report_type}_reports" - # NOTE: due to a regression in GLOBAL_SCRIPTS, we use search_script instead of the container - if not (hub := search.search_script(hub_key)): + from evennia import GLOBAL_SCRIPTS + + if not (hub := GLOBAL_SCRIPTS.get(hub_key)): hub = create.create_script(key=hub_key) return hub or None @@ -92,7 +93,7 @@ class CmdManageReports(_DEFAULT_COMMAND_CLASS): aliases = tuple(f"manage {report_type}" for report_type in _REPORT_TYPES) locks = "cmd:pperm(Admin)" - def get_help(self): + def get_help(self, caller, cmdset): """Returns a help string containing the configured available report types""" report_types = iter_to_str("\n ".join(_REPORT_TYPES)) @@ -102,7 +103,7 @@ manage the various reports Usage: manage [report type] - + Available report types: {report_types} @@ -157,7 +158,7 @@ class ReportCmdBase(_DEFAULT_COMMAND_CLASS): def parse(self): """ Parse the target and message out of the arguments. - + Override if you want different syntax, but make sure to assign `report_message` and `target_str`. """ # do the base MuxCommand parsing first @@ -212,7 +213,11 @@ class ReportCmdBase(_DEFAULT_COMMAND_CLASS): receivers.append(target) if self.create_report( - self.account, self.report_message, receivers=receivers, locks=self.report_locks, tags=["report"] + self.account, + self.report_message, + receivers=receivers, + locks=self.report_locks, + tags=["report"], ): # the report Msg was successfully created self.msg(self.success_msg) diff --git a/evennia/contrib/base_systems/ingame_reports/tests.py b/evennia/contrib/base_systems/ingame_reports/tests.py index 4556c250a4..3769390504 100644 --- a/evennia/contrib/base_systems/ingame_reports/tests.py +++ b/evennia/contrib/base_systems/ingame_reports/tests.py @@ -1,6 +1,7 @@ -from unittest.mock import Mock, patch, MagicMock -from evennia.utils import create +from unittest.mock import MagicMock, Mock, patch + from evennia.comms.models import TempMsg +from evennia.utils import create from evennia.utils.test_resources import EvenniaCommandTest from . import menu, reports diff --git a/evennia/contrib/base_systems/menu_login/menu_login.py b/evennia/contrib/base_systems/menu_login/menu_login.py index bf9d1609d9..2e081e6408 100644 --- a/evennia/contrib/base_systems/menu_login/menu_login.py +++ b/evennia/contrib/base_systems/menu_login/menu_login.py @@ -232,6 +232,7 @@ class MenuLoginEvMenu(EvMenu): class UnloggedinCmdSet(CmdSet): "Cmdset for the unloggedin state" + key = "DefaultUnloggedin" priority = 0 diff --git a/evennia/contrib/full_systems/evscaperoom/commands.py b/evennia/contrib/full_systems/evscaperoom/commands.py index e573e1c8ba..dba5f1e9be 100644 --- a/evennia/contrib/full_systems/evscaperoom/commands.py +++ b/evennia/contrib/full_systems/evscaperoom/commands.py @@ -31,14 +31,10 @@ import re from django.conf import settings -from evennia import ( - SESSION_HANDLER, - CmdSet, - Command, - InterruptCommand, - default_cmds, - syscmdkeys, -) +import evennia +from evennia import default_cmds, syscmdkeys +from evennia.commands.cmdset import CmdSet +from evennia.commands.command import Command, InterruptCommand from evennia.utils import variable_from_module from .utils import create_evscaperoom_object @@ -300,7 +296,7 @@ class CmdWho(CmdEvscapeRoom, default_cmds.CmdWho): if self.args == "all": table = self.style_table("|wName", "|wRoom") - sessions = SESSION_HANDLER.get_sessions() + sessions = evennia.SESSION_HANDLER.get_sessions() for session in sessions: puppet = session.get_puppet() if puppet: diff --git a/evennia/contrib/game_systems/achievements/achievements.py b/evennia/contrib/game_systems/achievements/achievements.py index 82eed2a9e5..13ad3b7e95 100644 --- a/evennia/contrib/game_systems/achievements/achievements.py +++ b/evennia/contrib/game_systems/achievements/achievements.py @@ -23,7 +23,7 @@ The recognized fields for an achievement are: - name (str): The name of the achievement. This is not the key and does not need to be unique. - desc (str): The longer description of the achievement. Common uses for this would be flavor text or hints on how to complete it. -- category (str): The category of conditions which this achievement tracks. It will most likely be +- category (str): The category of conditions which this achievement tracks. It will most likely be an action and you will most likely specify it based on where you're checking from. e.g. killing 10 rats might have a category of "defeat", which you'd then check from your code that runs when a player defeats something. diff --git a/evennia/contrib/game_systems/clothing/README.md b/evennia/contrib/game_systems/clothing/README.md index d84a259f01..d92bdd7b72 100644 --- a/evennia/contrib/game_systems/clothing/README.md +++ b/evennia/contrib/game_systems/clothing/README.md @@ -21,7 +21,7 @@ Would result in this added description: ## Installation To install, import this module and have your default character -inherit from ClothedCharacter in your game's characters.py file: +inherit from ClothedCharacter in your game's `characters.py` file: ```python diff --git a/evennia/contrib/game_systems/clothing/clothing.py b/evennia/contrib/game_systems/clothing/clothing.py index 6fe957eb72..11ba05e4b3 100644 --- a/evennia/contrib/game_systems/clothing/clothing.py +++ b/evennia/contrib/game_systems/clothing/clothing.py @@ -527,6 +527,9 @@ class CmdRemove(MuxCommand): help_category = "clothing" def func(self): + if not self.args: + self.caller.msg("Usage: remove ") + return clothing = self.caller.search(self.args, candidates=self.caller.contents) if not clothing: self.caller.msg("You don't have anything like that.") diff --git a/evennia/contrib/game_systems/clothing/tests.py b/evennia/contrib/game_systems/clothing/tests.py index 8a2711c3d9..b3bf623757 100644 --- a/evennia/contrib/game_systems/clothing/tests.py +++ b/evennia/contrib/game_systems/clothing/tests.py @@ -85,7 +85,9 @@ class TestClothingCmd(BaseEvenniaCommandTest): ) # Test remove command. - self.call(clothing.CmdRemove(), "", "Could not find ''.", caller=self.wearer) + self.call( + clothing.CmdRemove(), "", "Usage: remove ", caller=self.wearer + ) self.call( clothing.CmdRemove(), "hat", diff --git a/evennia/contrib/game_systems/containers/containers.py b/evennia/contrib/game_systems/containers/containers.py index be718d526e..42d37148c6 100644 --- a/evennia/contrib/game_systems/containers/containers.py +++ b/evennia/contrib/game_systems/containers/containers.py @@ -11,7 +11,7 @@ To install, import and add the `ContainerCmdSet` to `CharacterCmdSet` in your `d class CharacterCmdSet(default_cmds.CharacterCmdSet): # ... - + def at_cmdset_creation(self): # ... self.add(ContainerCmdSet) diff --git a/evennia/contrib/game_systems/multidescer/multidescer.py b/evennia/contrib/game_systems/multidescer/multidescer.py index 177437206a..55cba97c30 100644 --- a/evennia/contrib/game_systems/multidescer/multidescer.py +++ b/evennia/contrib/game_systems/multidescer/multidescer.py @@ -41,6 +41,7 @@ _RE_KEYS = re.compile(r"([\w\s]+)(?:\+*?)", re.U + re.I) class DescValidateError(ValueError): "Used for tracebacks from desc systems" + pass diff --git a/evennia/contrib/game_systems/storage/README.md b/evennia/contrib/game_systems/storage/README.md new file mode 100644 index 0000000000..5b573e2678 --- /dev/null +++ b/evennia/contrib/game_systems/storage/README.md @@ -0,0 +1,37 @@ +# Item Storage + +Contribution by helpme (2024) + +This module allows certain rooms to be marked as storage locations. + +In those rooms, players can `list`, `store`, and `retrieve` items. Storages can be shared or individual. + +## Installation + +This utility adds the storage-related commands. Import the module into your commands and add it to your command set to make it available. + +Specifically, in `mygame/commands/default_cmdsets.py`: + +```python +... +from evennia.contrib.game_systems.storage import StorageCmdSet # <--- + +class CharacterCmdset(default_cmds.Character_CmdSet): + ... + def at_cmdset_creation(self): + ... + self.add(StorageCmdSet) # <--- + +``` + +Then `reload` to make the `list`, `retrieve`, `store`, and `storage` commands available. + +## Usage + +To mark a location as having item storage, use the `storage` command. By default this is a builder-level command. Storage can be shared, which means everyone using the storage can access all items stored there, or individual, which means only the person who stores an item can retrieve it. See `help storage` for further details. + +## Technical info + +This is a tag-based system. Rooms set as storage rooms are tagged with an identifier marking them as shared or not. Items stored in those rooms are tagged with the storage room identifier and, if the storage room is not shared, the character identifier, and then they are removed from the grid i.e. their location is set to `None`. Upon retrieval, items are untagged and moved back to character inventories. + +When a room is unmarked as storage with the `storage` command, all stored objects are untagged and dropped to the room. You should use the `storage` command to create and remove storages, as otherwise stored objects may become lost. \ No newline at end of file diff --git a/evennia/contrib/game_systems/storage/__init__.py b/evennia/contrib/game_systems/storage/__init__.py new file mode 100644 index 0000000000..335ddff65a --- /dev/null +++ b/evennia/contrib/game_systems/storage/__init__.py @@ -0,0 +1,5 @@ +""" +Item storage integration - helpme 2024 +""" + +from .storage import StorageCmdSet # noqa diff --git a/evennia/contrib/game_systems/storage/storage.py b/evennia/contrib/game_systems/storage/storage.py new file mode 100644 index 0000000000..184b6e449e --- /dev/null +++ b/evennia/contrib/game_systems/storage/storage.py @@ -0,0 +1,190 @@ +from evennia import CmdSet +from evennia.commands.default.muxcommand import MuxCommand +from evennia.utils import list_to_string +from evennia.utils.search import search_object_by_tag + +SHARED_TAG_PREFIX = "shared" + + +class StorageCommand(MuxCommand): + """ + Shared functionality for storage-related commands + """ + + def at_pre_cmd(self): + """ + Check if the current location is tagged as a storage location + Every stored object is tagged on storage, and untagged on retrieval + + Returns: + bool: True if the command is to be stopped here + """ + if super().at_pre_cmd(): + return True + + self.storage_location_id = self.caller.location.tags.get(category="storage_location") + if not self.storage_location_id: + self.caller.msg(f"You cannot {self.cmdstring} anything here.") + return True + + self.object_tag = ( + SHARED_TAG_PREFIX + if self.storage_location_id.startswith(SHARED_TAG_PREFIX) + else self.caller.pk + ) + self.currently_stored = search_object_by_tag( + self.object_tag, category=self.storage_location_id + ) + + +class CmdStore(StorageCommand): + """ + Store something in a storage location. + + Usage: + store + """ + + key = "store" + locks = "cmd:all()" + help_category = "Storage" + + def func(self): + """ + Find the item in question to store, then store it + """ + caller = self.caller + if not self.args: + self.caller.msg("Store what?") + return + obj = caller.search(self.args.strip(), candidates=caller.contents) + if not obj: + return + + """ + We first check at_pre_move before setting the location to None, in case + anything should stymie its movement. + """ + if obj.at_pre_move(caller.location): + obj.tags.add(self.object_tag, self.storage_location_id) + obj.location = None + caller.msg(f"You store {obj.get_display_name(caller)} here.") + else: + caller.msg(f"You fail to store {obj.get_display_name(caller)} here.") + + +class CmdRetrieve(StorageCommand): + """ + Retrieve something from a storage location. + + Usage: + retrieve + """ + + key = "retrieve" + locks = "cmd:all()" + help_category = "Storage" + + def func(self): + """ + Retrieve the item in question if possible + """ + caller = self.caller + + if not self.args: + self.caller.msg("Retrieve what?") + return + + obj = caller.search(self.args.strip(), candidates=self.currently_stored) + if not obj: + return + + if obj.at_pre_move(caller): + obj.tags.remove(self.object_tag, self.storage_location_id) + caller.msg(f"You retrieve {obj.get_display_name(caller)}.") + else: + caller.msg(f"You fail to retrieve {obj.get_display_name(caller)}.") + + +class CmdList(StorageCommand): + """ + List items in the storage location. + + Usage: + list + """ + + key = "list" + locks = "cmd:all()" + help_category = "Storage" + + def func(self): + """ + List items in the storage + """ + caller = self.caller + if not self.currently_stored: + caller.msg("You find nothing stored here.") + return + caller.msg(f"Stored here:\n{list_to_string(self.currently_stored)}") + + +class CmdStorage(MuxCommand): + """ + Make the current location a storage room, or delete it as a storage and move all stored objects into the room contents. + + Shared storage locations can be used by all interchangeably. + + The default storage identifier will be its primary key in the database, but you can supply a new one in case you want linked storages. + + Usage: + storage [= [storage identifier]] + storage/shared [= [storage identifier]] + storage/delete + """ + + key = "@storage" + locks = "cmd:perm(Builder)" + + def func(self): + """Set the storage location.""" + + caller = self.caller + location = caller.location + current_storage_id = location.tags.get(category="storage_location") + storage_id = self.lhs or location.pk + + if "delete" in self.switches: + if not current_storage_id: + caller.msg("This is not tagged as a storage location.") + return + # Move the stored objects, if any, into the room + currently_stored_here = search_object_by_tag(category=current_storage_id) + for obj in currently_stored_here: + obj.tags.remove(category=current_storage_id) + obj.location = location + caller.msg("You remove the storage capabilities of the room.") + location.tags.remove(current_storage_id, category="storage_location") + return + + if current_storage_id: + caller.msg("This is already a storage location: |wstorage/delete|n to remove the tag.") + return + + new_storage_id = ( + f"{SHARED_TAG_PREFIX if SHARED_TAG_PREFIX in self.switches else ''}{storage_id}" + ) + location.tags.add(new_storage_id, category="storage_location") + caller.msg(f"This is now a storage location with id: {new_storage_id}.") + + +class StorageCmdSet(CmdSet): + """ + CmdSet for all storage-related commands + """ + + def at_cmdset_creation(self): + self.add(CmdStore) + self.add(CmdRetrieve) + self.add(CmdList) + self.add(CmdStorage) diff --git a/evennia/contrib/game_systems/storage/tests.py b/evennia/contrib/game_systems/storage/tests.py new file mode 100644 index 0000000000..bb794007d7 --- /dev/null +++ b/evennia/contrib/game_systems/storage/tests.py @@ -0,0 +1,121 @@ +from evennia.commands.default.tests import BaseEvenniaCommandTest +from evennia.utils.create import create_object + +from . import storage + + +class TestStorage(BaseEvenniaCommandTest): + def setUp(self): + super().setUp() + self.obj1.location = self.char1 + self.room1.tags.add("storage_1", "storage_location") + self.room2.tags.add("shared_storage_2", "storage_location") + + def test_store_and_retrieve(self): + self.call( + storage.CmdStore(), + "", + "Store what?", + caller=self.char1, + ) + self.call( + storage.CmdStore(), + "obj", + f"You store {self.obj1.get_display_name(self.char1)} here.", + caller=self.char1, + ) + self.call( + storage.CmdList(), + "", + f"Stored here:\n{self.obj1.get_display_name(self.char1)}", + caller=self.char1, + ) + self.call( + storage.CmdRetrieve(), + "obj2", + "Could not find 'obj2'.", + caller=self.char1, + ) + self.call( + storage.CmdRetrieve(), + "obj", + f"You retrieve {self.obj1.get_display_name(self.char1)}.", + caller=self.char1, + ) + + def test_store_retrieve_while_not_in_storeroom(self): + self.char2.location = self.char1 + self.call(storage.CmdStore(), "obj", "You cannot store anything here.", caller=self.char2) + self.call( + storage.CmdRetrieve(), "obj", "You cannot retrieve anything here.", caller=self.char2 + ) + + def test_store_retrieve_nonexistent_obj(self): + self.call(storage.CmdStore(), "asdasd", "Could not find 'asdasd'.", caller=self.char1) + self.call(storage.CmdRetrieve(), "asdasd", "Could not find 'asdasd'.", caller=self.char1) + + def test_list_nothing_stored(self): + self.call( + storage.CmdList(), + "", + "You find nothing stored here.", + caller=self.char1, + ) + + def test_shared_storage(self): + self.char1.location = self.room2 + self.char2.location = self.room2 + + self.call( + storage.CmdStore(), + "obj", + f"You store {self.obj1.get_display_name(self.char1)} here.", + caller=self.char1, + ) + self.call( + storage.CmdRetrieve(), + "obj", + f"You retrieve {self.obj1.get_display_name(self.char1)}.", + caller=self.char2, + ) + + def test_remove_add_storage(self): + self.char1.permissions.add("builder") + self.call( + storage.CmdStorage(), + "", + "This is already a storage location: storage/delete to remove the tag.", + caller=self.char1, + ) + self.call( + storage.CmdStore(), + "obj", + f"You store {self.obj1.get_display_name(self.char1)} here.", + caller=self.char1, + ) + self.assertEqual(self.obj1.location, None) + self.call( + storage.CmdStorage(), + "/delete", + "You remove the storage capabilities of the room.", + caller=self.char1, + ) + self.assertEqual(self.obj1.location, self.room1) + self.call( + storage.CmdStorage(), + "", + f"This is now a storage location with id: {self.room1.id}.", + caller=self.char1, + ) + self.call( + storage.CmdStorage(), + "/delete", + "You remove the storage capabilities of the room.", + caller=self.char1, + ) + self.call( + storage.CmdStorage(), + "/shared", + f"This is now a storage location with id: shared{self.room1.id}.", + caller=self.char1, + ) 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 3dec9a1d26..66515bbc12 100644 --- a/evennia/contrib/grid/ingame_map_display/ingame_map_display.py +++ b/evennia/contrib/grid/ingame_map_display/ingame_map_display.py @@ -125,16 +125,11 @@ class Map(object): Returns: string: The exit name as a compass direction or an empty string. """ - exit_name = ex.name - if exit_name not in _COMPASS_DIRECTIONS: - compass_aliases = [ - direction in ex.aliases.all() for direction in _COMPASS_DIRECTIONS.keys() - ] - if compass_aliases[0]: - exit_name = compass_aliases[0] - if exit_name not in _COMPASS_DIRECTIONS: - return "" - return exit_name + return ( + ex.name + if ex.name in _COMPASS_DIRECTIONS + else next((alias for alias in ex.aliases.all() if alias in _COMPASS_DIRECTIONS), "") + ) def update_pos(self, room, exit_name): """ diff --git a/evennia/contrib/grid/ingame_map_display/tests.py b/evennia/contrib/grid/ingame_map_display/tests.py index d047957c1e..d3baf45a8f 100644 --- a/evennia/contrib/grid/ingame_map_display/tests.py +++ b/evennia/contrib/grid/ingame_map_display/tests.py @@ -32,8 +32,8 @@ class TestIngameMap(BaseEvenniaCommandTest): ) create_object( exits.Exit, - key="west", - aliases=["w"], + key="shopfront", + aliases=["w", "west"], location=self.east_room, destination=self.west_room, ) diff --git a/evennia/contrib/grid/mapbuilder/README.md b/evennia/contrib/grid/mapbuilder/README.md index 8a249c9cd6..f2750350b0 100644 --- a/evennia/contrib/grid/mapbuilder/README.md +++ b/evennia/contrib/grid/mapbuilder/README.md @@ -86,7 +86,7 @@ For example: Below are two examples showcasing the use of automatic exit generation and custom exit generation. Whilst located, and can be used, from this module for -convenience The below example code should be in mymap.py in mygame/world. +convenience The below example code should be in `mymap.py` in mygame/world. ### Example One diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index d77d47dc9f..7afb20d69c 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -1,7 +1,7 @@ """ Buffs - Tegiminis 2022 -A buff is a timed object, attached to a game entity, that modifies values, triggers +A buff is a timed object, attached to a game entity, that modifies values, triggers code, or both. It is a common design pattern in RPGs, particularly action games. This contrib gives you a buff handler to apply to your objects, a buff class to extend them, @@ -25,7 +25,7 @@ To make use of the handler, you will need: ### Applying a Buff -Call the handler `add(BuffClass)` method. This requires a class reference, and also contains a number of +Call the handler `add(BuffClass)` method. This requires a class reference, and also contains a number of optional arguments to customize the buff's duration, stacks, and so on. ```python @@ -36,8 +36,8 @@ self.buffs.add(ReflectBuff, to_cache={'reflect': 0.5}) # A single stack of Refl ### Modify -Call the handler `check(value, stat)` method wherever you want to see the modified value. -This will return the value, modified by and relevant buffs on the handler's owner (identified by +Call the handler `check(value, stat)` method wherever you want to see the modified value. +This will return the value, modified by and relevant buffs on the handler's owner (identified by the `stat` string). For example: ```python @@ -49,7 +49,7 @@ def take_damage(self, source, damage): ### Trigger -Call the handler `trigger(triggerstring)` method wherever you want an event call. This +Call the handler `trigger(triggerstring)` method wherever you want an event call. This will call the `at_trigger` hook method on all buffs with the relevant trigger. ```python diff --git a/evennia/contrib/rpg/character_creator/character_creator.py b/evennia/contrib/rpg/character_creator/character_creator.py index 694393b75a..35f1ce4a41 100644 --- a/evennia/contrib/rpg/character_creator/character_creator.py +++ b/evennia/contrib/rpg/character_creator/character_creator.py @@ -82,7 +82,7 @@ class ContribCmdCharCreate(MuxAccountCommand): ) if errors: - self.msg(errors) + self.msg("\n".join(errors)) if not new_character: return # initalize the new character to the beginning of the chargen menu diff --git a/evennia/contrib/rpg/llm/README.md b/evennia/contrib/rpg/llm/README.md index 762895b96f..2960a72b56 100644 --- a/evennia/contrib/rpg/llm/README.md +++ b/evennia/contrib/rpg/llm/README.md @@ -66,7 +66,8 @@ LLM_PATH = "/api/v1/generate" # if you wanted to authenticated to some external service, you could # add an Authenticate header here with a token -LLM_HEADERS = {"Content-Type": "application/json"} +# note that the content of each header must be an iterable +LLM_HEADERS = {"Content-Type": ["application/json"]} # this key will be inserted in the request, with your user-input LLM_PROMPT_KEYNAME = "prompt" @@ -77,7 +78,7 @@ LLM_REQUEST_BODY = { "temperature": 0.7, # 0-2. higher=more random, lower=predictable } # helps guide the NPC AI. See the LLNPC section. -LLM_PROMPT_PREFIx = ( +LLM_PROMPT_PREFIX = ( "You are roleplaying as {name}, a {desc} existing in {location}. " "Answer with short sentences. Only respond as {name} would. " "From here on, the conversation between {name} and {character} begins." @@ -148,8 +149,8 @@ Here is an untested example of the Evennia setting for calling [OpenAI's v1/comp ```python LLM_HOST = "https://api.openai.com" LLM_PATH = "/v1/completions" -LLM_HEADERS = {"Content-Type": "application/json", - "Authorization": "Bearer YOUR_OPENAI_API_KEY"} +LLM_HEADERS = {"Content-Type": ["application/json"], + "Authorization": ["Bearer YOUR_OPENAI_API_KEY"]} LLM_PROMPT_KEYNAME = "prompt" LLM_REQUEST_BODY = { "model": "gpt-3.5-turbo", diff --git a/evennia/contrib/rpg/llm/llm_client.py b/evennia/contrib/rpg/llm/llm_client.py index 51fa4ea2c1..91641de4c0 100644 --- a/evennia/contrib/rpg/llm/llm_client.py +++ b/evennia/contrib/rpg/llm/llm_client.py @@ -37,7 +37,7 @@ from evennia.utils.utils import make_iter DEFAULT_LLM_HOST = "http://127.0.0.1:5000" DEFAULT_LLM_PATH = "/api/v1/generate" -DEFAULT_LLM_HEADERS = {"Content-Type": "application/json"} +DEFAULT_LLM_HEADERS = {"Content-Type": ["application/json"]} DEFAULT_LLM_PROMPT_KEYNAME = "prompt" DEFAULT_LLM_API_TYPE = "" # or openai DEFAULT_LLM_REQUEST_BODY = { diff --git a/evennia/contrib/rpg/traits/tests.py b/evennia/contrib/rpg/traits/tests.py index 607dab88f9..8e0f406bdc 100644 --- a/evennia/contrib/rpg/traits/tests.py +++ b/evennia/contrib/rpg/traits/tests.py @@ -9,9 +9,10 @@ Unit test module for Trait classes. from copy import copy from anything import Something +from mock import MagicMock, patch + from evennia.objects.objects import DefaultCharacter from evennia.utils.test_resources import BaseEvenniaTestCase, EvenniaTest -from mock import MagicMock, patch from . import traits diff --git a/evennia/contrib/rpg/traits/traits.py b/evennia/contrib/rpg/traits/traits.py index 4c21e6c4b2..c38e92a0fa 100644 --- a/evennia/contrib/rpg/traits/traits.py +++ b/evennia/contrib/rpg/traits/traits.py @@ -456,9 +456,15 @@ from functools import total_ordering from time import time from django.conf import settings + from evennia.utils import logger from evennia.utils.dbserialize import _SaverDict -from evennia.utils.utils import class_from_module, inherits_from, list_to_string, percent +from evennia.utils.utils import ( + class_from_module, + inherits_from, + list_to_string, + percent, +) # Available Trait classes. # This way the user can easily supply their own. Each diff --git a/evennia/contrib/tutorials/evadventure/tests/test_rooms.py b/evennia/contrib/tutorials/evadventure/tests/test_rooms.py index d661390785..94fa338520 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_rooms.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_rooms.py @@ -43,7 +43,7 @@ class EvAdventureRoomTest(EvenniaTestCase): /|\ o o o room_center -You see nothing special. +This is a room. Exits: north, northeast, east, southeast, south, southwest, west, and northwest""" result = "\n".join(part.rstrip() for part in strip_ansi(desc).split("\n")) diff --git a/evennia/contrib/tutorials/evadventure/utils.py b/evennia/contrib/tutorials/evadventure/utils.py index 25152c236f..34db91b11b 100644 --- a/evennia/contrib/tutorials/evadventure/utils.py +++ b/evennia/contrib/tutorials/evadventure/utils.py @@ -37,7 +37,7 @@ def get_obj_stats(obj, owner=None): carried = f", Worn: [{carried.value}]" if carried else "" attack_type = getattr(obj, "attack_type", None) - defense_type = getattr(obj, "attack_type", None) + defense_type = getattr(obj, "defense_type", None) return _OBJ_STATS.format( key=obj.key, diff --git a/evennia/contrib/tutorials/talking_npc/talking_npc.py b/evennia/contrib/tutorials/talking_npc/talking_npc.py index 3e9aa0cb42..ca19feb100 100644 --- a/evennia/contrib/tutorials/talking_npc/talking_npc.py +++ b/evennia/contrib/tutorials/talking_npc/talking_npc.py @@ -123,6 +123,7 @@ class CmdTalk(default_cmds.MuxCommand): class TalkingCmdSet(CmdSet): "Stores the talk command." + key = "talkingcmdset" def at_cmdset_creation(self): diff --git a/evennia/contrib/tutorials/tutorial_world/rooms.py b/evennia/contrib/tutorials/tutorial_world/rooms.py index 42c4f6fc2d..346ffa7d33 100644 --- a/evennia/contrib/tutorials/tutorial_world/rooms.py +++ b/evennia/contrib/tutorials/tutorial_world/rooms.py @@ -280,7 +280,11 @@ class TutorialRoom(DefaultRoom): source_location (Object): the previous location of new_arrival. """ - if new_arrival.has_account and not new_arrival.is_superuser: + if new_arrival.ndb.batch_batchmode: + # currently running batchcommand + return + + if new_arrival.has_account and not new_arrival.ndb.batch_batchmode: # this is a character for obj in self.contents_get(exclude=new_arrival): if hasattr(obj, "at_new_arrival"): @@ -465,6 +469,10 @@ class IntroRoom(TutorialRoom): Assign properties on characters """ + if character.ndb.batch_batchmode: + # currently running batchcommand + return + # setup character for the tutorial health = self.db.char_health or 20 @@ -476,8 +484,8 @@ class IntroRoom(TutorialRoom): string = "-" * 78 + SUPERUSER_WARNING + "-" * 78 character.msg("|r%s|n" % string.format(name=character.key, quell="|wquell|r")) else: - # quell user - if character.account: + # quell user if they have account and is not currently running the batch processor + if character.account and not character.ndb.batch_batchmode: character.account.execute_cmd("quell") character.msg("(Auto-quelling while in tutorial-world)") @@ -775,7 +783,7 @@ class BridgeRoom(WeatherRoom): This is called at irregular intervals and makes the passage over the bridge a little more interesting. """ - if random.random() < 80: + if random.random() < 0.8: # send a message most of the time self.msg_contents("|w%s|n" % random.choice(BRIDGE_WEATHER)) @@ -784,6 +792,10 @@ class BridgeRoom(WeatherRoom): This hook is called by the engine whenever the player is moved into this room. """ + if character.ndb.batch_batchmode: + # currently running batchcommand + return + if character.has_account: # we only run this if the entered object is indeed a player object. # check so our east/west exits are correctly defined. @@ -1007,6 +1019,7 @@ class DarkRoom(TutorialRoom): """ return ( obj.is_superuser + or obj.ndb.batch_batchmode or obj.db.is_giving_light or any(o for o in obj.contents if o.db.is_giving_light) ) @@ -1051,6 +1064,10 @@ class DarkRoom(TutorialRoom): """ Called when an object enters the room. """ + if obj.ndb.batch_batchmode: + # currently running batchcommand + self.check_light_state() # this should remove the DarkCmdSet + if obj.has_account: # a puppeted object, that is, a Character self._heal(obj) @@ -1117,9 +1134,10 @@ class TeleportRoom(TutorialRoom): This hook is called by the engine whenever the player is moved into this room. """ - if not character.has_account: - # only act on player characters. + if not character.has_account or character.ndb.batch_batchmode: + # only act on player characters or when not building. return + # determine if the puzzle is a success or not is_success = str(character.db.puzzle_clue) == str(self.db.puzzle_value) teleport_to = self.db.success_teleport_to if is_success else self.db.failure_teleport_to @@ -1180,6 +1198,10 @@ class OutroRoom(TutorialRoom): """ Do cleanup. """ + if character.ndb.batch_batchmode: + # currently running batchcommand + return + if character.has_account: del character.db.health_max del character.db.health diff --git a/evennia/game_template/server/logs/README.md b/evennia/game_template/server/logs/README.md index 35ad999cd5..16a4af70d7 100644 --- a/evennia/game_template/server/logs/README.md +++ b/evennia/game_template/server/logs/README.md @@ -1,15 +1,15 @@ This directory contains Evennia's log files. The existence of this README.md file is also necessary to correctly include the log directory in git (since log files are ignored by git and you can't -commit an empty directory). +commit an empty directory). -- `server.log` - log file from the game Server. -- `portal.log` - log file from Portal proxy (internet facing) +`server.log` - log file from the game Server. +`portal.log` - log file from Portal proxy (internet facing) Usually these logs are viewed together with `evennia -l`. They are also rotated every week so as not -to be too big. Older log names will have a name appended by `_month_date`. - -- `lockwarnings.log` - warnings from the lock system. -- `http_requests.log` - this will generally be empty unless turning on debugging inside the server. +to be too big. Older log names will have a name appended by `_month_date`. -- `channel_.log` - these are channel logs for the in-game channels They are also used - by the `/history` flag in-game to get the latest message history. +`lockwarnings.log` - warnings from the lock system. +`http_requests.log` - this will generally be empty unless turning on debugging inside the server. + +`channel_.log` - these are channel logs for the in-game channels They are also used +by the `/history` flag in-game to get the latest message history. diff --git a/evennia/help/filehelp.py b/evennia/help/filehelp.py index 4c9cf3d9f1..8e1bb1e946 100644 --- a/evennia/help/filehelp.py +++ b/evennia/help/filehelp.py @@ -120,12 +120,15 @@ class FileHelpEntry: def __repr__(self): return f"" + def __hash__(self): + return hash(self.key) + @lazy_property def locks(self): return LockHandler(self) def web_get_detail_url(self): - """ + r""" Returns the URI path for a View that allows users to view details for this object. diff --git a/evennia/help/models.py b/evennia/help/models.py index 37565256f7..09834128de 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -109,6 +109,7 @@ class HelpEntry(SharedMemoryModel): class Meta: "Define Django meta options" + verbose_name = "Help Entry" verbose_name_plural = "Help Entries" @@ -206,7 +207,7 @@ class HelpEntry(SharedMemoryModel): return "#" def web_get_detail_url(self): - """ + r""" Returns the URI path for a View that allows users to view details for this object. @@ -242,7 +243,7 @@ class HelpEntry(SharedMemoryModel): return "#" def web_get_update_url(self): - """ + r""" Returns the URI path for a View that allows users to update this object. @@ -278,7 +279,7 @@ class HelpEntry(SharedMemoryModel): return "#" def web_get_delete_url(self): - """ + r""" Returns the URI path for a View that allows users to delete this object. ex. Oscar (Character) = '/characters/oscar/1/delete/' diff --git a/evennia/help/tests.py b/evennia/help/tests.py index 0f7f31f242..02e4ccb59e 100644 --- a/evennia/help/tests.py +++ b/evennia/help/tests.py @@ -6,6 +6,8 @@ command test-suite). from unittest import mock +from parameterized import parameterized + from evennia.help import filehelp from evennia.help import utils as help_utils from evennia.utils.test_resources import TestCase @@ -140,3 +142,56 @@ class TestFileHelp(TestCase): self.assertEqual(HELP_ENTRY_DICTS[inum].get("aliases", []), helpentry.aliases) self.assertEqual(HELP_ENTRY_DICTS[inum]["category"].lower(), helpentry.help_category) self.assertEqual(HELP_ENTRY_DICTS[inum]["text"], helpentry.entrytext) + + +class HelpUtils(TestCase): + + def setUp(self): + self.candidate_entries = [ + filehelp.FileHelpEntry( + key="*examine", + aliases=["*exam", "*ex", "@examine"], + help_category="building", + entrytext="Lorem ipsum examine", + lock_storage="", + ), + filehelp.FileHelpEntry( + key="inventory", + aliases=[], + help_category="general", + entrytext="A character's inventory", + lock_storage="", + ), + filehelp.FileHelpEntry( + key="userpassword", + aliases=[], + help_category="admin", + entrytext="change the password of an account", + lock_storage="", + ), + ] + + @parameterized.expand( + [ + ("*examine", "*examine", "Leading wildcard should return exact matches."), + ("@examine", "*examine", "Aliases should return an entry."), + ("inventory", "inventory", "It should return exact matches."), + ("inv*", "inventory", "Trailing wildcard search should return an entry."), + ("userpaZZword~2", "userpassword", "Fuzzy matching should return an entry."), + ( + "*word", + "userpassword", + "Leading wildcard should return an entry when no exact match.", + ), + ] + ) + def test_help_search_with_index(self, search_term, expected_entry_key, error_msg): + """Test search terms return correct entries""" + + expected_entry = [ + entry for entry in self.candidate_entries if entry.key == expected_entry_key + ] + + entries, _ = help_utils.help_search_with_index(search_term, self.candidate_entries) + + self.assertEqual(entries, expected_entry, error_msg) diff --git a/evennia/help/utils.py b/evennia/help/utils.py index 716122accf..537cbca973 100644 --- a/evennia/help/utils.py +++ b/evennia/help/utils.py @@ -9,26 +9,7 @@ This is used primarily by the default `help` command. import re from django.conf import settings - -# these are words that Lunr normally ignores but which we want to find -# since we use them (e.g. as command names). -# Lunr's default ignore-word list is found here: -# https://github.com/yeraydiazdiaz/lunr.py/blob/master/lunr/stop_word_filter.py -_LUNR_STOP_WORD_FILTER_EXCEPTIONS = [ - "about", - "might", - "get", - "who", - "say", - "where", -] + settings.LUNR_STOP_WORD_FILTER_EXCEPTIONS - - -_LUNR = None -_LUNR_EXCEPTION = None - -_LUNR_GET_BUILDER = None -_LUNR_BUILDER_PIPELINE = None +from lunr.stemmer import stemmer _RE_HELP_SUBTOPICS_START = re.compile(r"^\s*?#\s*?subtopics\s*?$", re.I + re.M) _RE_HELP_SUBTOPIC_SPLIT = re.compile(r"^\s*?(\#{2,6}\s*?\w+?[a-z0-9 \-\?!,\.]*?)$", re.M + re.I) @@ -37,6 +18,121 @@ _RE_HELP_SUBTOPIC_PARSE = re.compile(r"^(?P\#{2,6})\s*?(?P.*?)$", MAX_SUBTOPIC_NESTING = 5 +def wildcard_stemmer(token, i, tokens): + """ + Custom LUNR stemmer that returns both the original and stemmed token + if the token contains a leading wildcard (*). + + Args: + token (str): The input token to be stemmed + i (int): Index of current token. Unused here but required by LUNR. + tokens (list): List of tokens being processed. Unused here but required by LUNR. + + Returns: + list: A list containing the stemmed tokens and original token if it has leading '*'. + """ + + original_token = token.clone() + # Then apply the standard Lunr stemmer + stemmed_token = stemmer(token) + + if original_token.string.startswith("*"): + # Return both tokens + return [original_token, stemmed_token] + return stemmed_token + + +class LunrSearch: + """ + Singleton class for managing Lunr search index configuration and initialization. + """ + + # these are words that Lunr normally ignores but which we want to find + # since we use them (e.g. as command names). + # Lunr's default ignore-word list is found here: + # https://github.com/yeraydiazdiaz/lunr.py/blob/master/lunr/stop_word_filter.py + _LUNR_STOP_WORD_FILTER_EXCEPTIONS = [ + "about", + "might", + "get", + "who", + "say", + "where", + ] + settings.LUNR_STOP_WORD_FILTER_EXCEPTIONS + + _instance = None + + def __new__(cls): + """ + Ensure only one instance of the class is created (Singleton) + """ + if not cls._instance: + cls._instance = super(LunrSearch, cls).__new__(cls) + cls._instance._initialize() + return cls._instance + + def _initialize(self): + """ + Lazy load Lunr libraries and set up custom configuration + + we have to delay-load lunr because it messes with logging if it's imported + before twisted's logging has been set up + """ + # Lunr-related imports + from lunr import get_default_builder, lunr, stop_word_filter + from lunr.exceptions import QueryParseError + from lunr.pipeline import Pipeline + from lunr.stemmer import stemmer + + # Store imported modules as instance attributes + self.get_default_builder = get_default_builder + self.lunr = lunr + self.stop_word_filter = stop_word_filter + self.QueryParseError = QueryParseError + self.default_stemmer = stemmer + + self._setup_stop_words_filter() + self.custom_builder_pipeline = (self.custom_stop_words_filter, wildcard_stemmer) + + # Register custom stemmer if we want to serialize. + Pipeline.register_function(wildcard_stemmer, "wildcard_stemmer") + + def _setup_stop_words_filter(self): + """ + Create a custom stop words filter, removing specified exceptions + """ + stop_words = self.stop_word_filter.WORDS.copy() + + for ignore_word in self._LUNR_STOP_WORD_FILTER_EXCEPTIONS: + try: + stop_words.remove(ignore_word) + except ValueError: + pass + + self.custom_stop_words_filter = self.stop_word_filter.generate_stop_word_filter(stop_words) + + def index(self, ref, fields, documents): + """ + Creates a Lunr searchable index. + + Args: + ref (str): Unique identifier field within a document + fields (list): A list of Lunr field mappings + ``{"field_name": str, "boost": int}``. See the Lunr documentation + for more details. + documents (list[dict]): This is the body of possible entities to search. + Each dict should have all keys in the `fields` arg. + Returns: A lunr.Index object + """ + + # Create and configure builder + builder = self.get_default_builder() + builder.pipeline.reset() + builder.pipeline.add(*self.custom_builder_pipeline) + + return self.lunr(ref, fields, documents, builder=builder) + + def help_search_with_index(query, candidate_entries, suggestion_maxnum=5, fields=None): """ Lunr-powered fast index search and suggestion wrapper. See https://lunrjs.com/. @@ -57,31 +153,7 @@ def help_search_with_index(query, candidate_entries, suggestion_maxnum=5, fields how many suggestions are included. """ - global _LUNR, _LUNR_EXCEPTION, _LUNR_BUILDER_PIPELINE, _LUNR_GET_BUILDER - if not _LUNR: - # we have to delay-load lunr because it messes with logging if it's imported - # before twisted's logging has been set up - from lunr import get_default_builder as _LUNR_GET_BUILDER - from lunr import lunr as _LUNR - from lunr import stop_word_filter - from lunr.exceptions import QueryParseError as _LUNR_EXCEPTION - from lunr.stemmer import stemmer - - # from lunr.trimmer import trimmer - # pre-create a lunr index-builder pipeline where we've removed some of - # the stop-words from the default in lunr. - - stop_words = stop_word_filter.WORDS - - for ignore_word in _LUNR_STOP_WORD_FILTER_EXCEPTIONS: - try: - stop_words.remove(ignore_word) - except ValueError: - pass - - custom_stop_words_filter = stop_word_filter.generate_stop_word_filter(stop_words) - # _LUNR_BUILDER_PIPELINE = (trimmer, custom_stop_words_filter, stemmer) - _LUNR_BUILDER_PIPELINE = (custom_stop_words_filter, stemmer) + from lunr.exceptions import QueryParseError indx = [cnd.search_index_entry for cnd in candidate_entries] mapping = {indx[ix]["key"]: cand for ix, cand in enumerate(candidate_entries)} @@ -94,16 +166,13 @@ def help_search_with_index(query, candidate_entries, suggestion_maxnum=5, fields {"field_name": "tags", "boost": 5}, ] - # build the search index - builder = _LUNR_GET_BUILDER() - builder.pipeline.reset() - builder.pipeline.add(*_LUNR_BUILDER_PIPELINE) + lunr_search = LunrSearch() - search_index = _LUNR(ref="key", fields=fields, documents=indx, builder=builder) + search_index = lunr_search.index(ref="key", fields=fields, documents=indx) try: matches = search_index.search(query)[:suggestion_maxnum] - except _LUNR_EXCEPTION: + except QueryParseError: # this is a user-input problem matches = [] diff --git a/evennia/locale/de/LC_MESSAGES/django.mo b/evennia/locale/de/LC_MESSAGES/django.mo index bf09c991c5..2778006df1 100644 Binary files a/evennia/locale/de/LC_MESSAGES/django.mo and b/evennia/locale/de/LC_MESSAGES/django.mo differ diff --git a/evennia/locale/de/LC_MESSAGES/django.po b/evennia/locale/de/LC_MESSAGES/django.po index 8d909c7d95..3896b24afb 100644 --- a/evennia/locale/de/LC_MESSAGES/django.po +++ b/evennia/locale/de/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-28 21:18+0100\n" -"PO-Revision-Date: 2022-11-29 20:06+0100\n" +"POT-Creation-Date: 2025-03-28 08:12+0000\n" +"PO-Revision-Date: 2025-03-28 10:21+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: de\n" @@ -16,49 +16,78 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 3.2\n" +"X-Generator: Poedit 3.4.4\n" -#: accounts/accounts.py:278 +#: accounts/accounts.py:474 msgid "You are already puppeting this object." -msgstr "Sie steuern dieses Objekt bereits." +msgstr "Du steuerst dieses Objekt bereits." -#: accounts/accounts.py:282 +#: accounts/accounts.py:478 #, python-brace-format msgid "You don't have permission to puppet '{key}'." -msgstr "Sie haben nicht die Berechtigung ‚{key}‘ zu steuern." +msgstr "Du hast nicht die Berechtigung '{key}' zu steuern." -#: accounts/accounts.py:303 +#: accounts/accounts.py:487 +#, python-brace-format +msgid "Sharing |c{name}|n with another of your sessions." +msgstr "Teile |c{name}|n mit einer deiner anderen Sitzungen." + +#: accounts/accounts.py:491 +#, python-brace-format +msgid "|c{name}|n|G is now shared from another of your sessions.|n" +msgstr "|c{name}|n|G wird nun von einer deiner anderen Sitzungen gesteuert.|n" + +#: accounts/accounts.py:496 +#, python-brace-format +msgid "Taking over |c{name}|n from another of your sessions." +msgstr "Übernehme |c{name}|n von einer deiner anderen Sitzungen." + +#: accounts/accounts.py:500 +#, python-brace-format +msgid "|c{name}|n|R is now acted from another of your sessions.|n" +msgstr "|c{name}|n|R wird nun von einer deiner anderen Sitzungen gesteuert.|n" + +#: accounts/accounts.py:507 #, python-brace-format msgid "|c{key}|R is already puppeted by another Account." msgstr "|c{key}|R wird schon von einem anderen Account gesteuert." -#: accounts/accounts.py:499 +#: accounts/accounts.py:527 +#, python-brace-format +msgid "You cannot control any more puppets (max {max_puppets})" +msgstr "Du kannst nicht noch mehr 'Puppen' steuern (max {max_puppets})" + +#: accounts/accounts.py:722 msgid "Too many login failures; please try again in a few minutes." msgstr "" -"Zu viele falsche Loginversuche. Bitte versuchen Sie es in ein paar Minuten " -"erneut." +"Zu viele fehlgeschlagene Loginversuche. Bitte versuche es in ein paar " +"Minuten erneut." -#: accounts/accounts.py:512 accounts/accounts.py:772 +#: accounts/accounts.py:735 accounts/accounts.py:1062 msgid "" "|rYou have been banned and cannot continue from here.\n" "If you feel this ban is in error, please email an admin.|x" msgstr "" -"|Du wurdest gebannt und kannst von hier aus nicht weitermachen.\n" -"Wenn Sie der Meinung sind, dass diese Sperre zu Unrecht erfolgt ist, senden " -"Sie bitte eine E-Mail an einen Administrator.|x" +"|rDu wurdest gebannt und kannst hier nicht weiter.\n" +"Wenn du der Meinung bist, dass diese Sperre zu Unrecht erfolgt ist, sende " +"bitte eine E-Mail an einen Administrator.|x" -#: accounts/accounts.py:524 +#: accounts/accounts.py:747 msgid "Username and/or password is incorrect." msgstr "Benutzername und/oder Passwort ist falsch." -#: accounts/accounts.py:743 +#: accounts/accounts.py:754 +msgid "Too many authentication failures." +msgstr "Zu viele fehlgeschlagene Authentifizierungen." + +#: accounts/accounts.py:1033 msgid "" "You are creating too many accounts. Please log into an existing account." msgstr "" "Sie erstellen zu viele Konten. Bitte melden Sie sich bei einem bestehenden " "Konto an." -#: accounts/accounts.py:789 +#: accounts/accounts.py:1079 msgid "" "There was an error creating the Account. If this problem persists, contact " "an admin." @@ -66,94 +95,170 @@ msgstr "" "Beim Erstellen des Kontos ist ein Fehler aufgetreten. Wenn dieses Problem " "weiterhin besteht, wenden Sie sich an einen Administrator." -#: accounts/accounts.py:824 accounts/accounts.py:1639 +#: accounts/accounts.py:1123 accounts/accounts.py:2048 msgid "An error occurred. Please e-mail an admin if the problem persists." msgstr "" "Es ist ein Fehler aufgetreten. Bitte senden Sie eine E-Mail an einen " "Administrator, wenn das Problem weiterhin besteht." -#: accounts/accounts.py:851 +#: accounts/accounts.py:1156 msgid "Account being deleted." msgstr "Account wird gelöscht." -#: accounts/accounts.py:1307 accounts/accounts.py:1656 +#: accounts/accounts.py:1727 accounts/accounts.py:2066 #, python-brace-format msgid "|G{key} connected|n" msgstr "|G{key} verbunden|n" -#: accounts/accounts.py:1314 accounts/accounts.py:1321 +#: accounts/accounts.py:1733 msgid "The Character does not exist." msgstr "Der Charakter existiert nicht." -#: accounts/accounts.py:1360 +#: accounts/accounts.py:1767 #, python-brace-format msgid "|R{key} disconnected{reason}|n" -msgstr "|R{key} getrennt{reason}|n" +msgstr "|R{key} getrennt {reason}|n" -#: accounts/accounts.py:1467 -#, python-brace-format -msgid "{target} has no in-game appearance." -msgstr "{target} Hat kein Aussehen im Spiel." - -#: accounts/accounts.py:1511 -msgid "" -"\n" -"\n" -" You don't have any characters yet. See |whelp @charcreate|n for creating " -"one." -msgstr "" -"\n" -"\n" -"Du hast noch keine Charaktere. Siehe |whelp @charcreate|n um einen zu " -"erstellen." - -#: accounts/accounts.py:1592 +#: accounts/accounts.py:2001 msgid "Guest accounts are not enabled on this server." msgstr "Gastkonten sind auf diesem Server nicht aktiviert." -#: accounts/accounts.py:1602 +#: accounts/accounts.py:2011 msgid "All guest accounts are in use. Please try again later." msgstr "" -"Alle Gastaccounts sind bereits in Benutzung. Bitte versuchen Sie es später " -"nocheinmal." +"Alle Gastaccounts sind bereits in Benutzung. Bitte versuche es später " +"nochmal." -#: accounts/bots.py:333 +#: commands/cmdhandler.py:86 +msgid "" +"\n" +"An untrapped error occurred.\n" +msgstr "" +"\n" +"Ein nicht abgefangener Fehler ist aufgetreten\n" + +#: commands/cmdhandler.py:91 +msgid "" +"\n" +"An untrapped error occurred. Please file a bug report detailing the steps to " +"reproduce.\n" +msgstr "" +"\n" +"Ein nicht abgefangener Fehler ist aufgetreten. Bitte reiche einen " +"Fehlerbericht mit detaillierten Schritten zur Reproduktion ein.\n" + +#: commands/cmdhandler.py:99 +msgid "" +"\n" +"A cmdset merger-error occurred. This is often due to a syntax\n" +"error in one of the cmdsets to merge.\n" +msgstr "" +"\n" +"Ein 'cmdset merger-error' ist aufgetreten. \n" +"Dies liegt häufig an einem Syntaxfehler in einem der zusammenzuführenden " +"Cmdsets.\n" + +#: commands/cmdhandler.py:105 +msgid "" +"\n" +"A cmdset merger-error occurred. Please file a bug report detailing the\n" +"steps to reproduce.\n" +msgstr "" +"\n" +"Ein 'cmdset merger-error' ist aufgetreten. \n" +"Bitte reiche einen Fehlerbericht mit den erforderlichen Schritten zur " +"Reproduktion ein.\n" + +#: commands/cmdhandler.py:114 +msgid "" +"\n" +"No command sets found! This is a critical bug that can have\n" +"multiple causes.\n" +msgstr "" +"\n" +"Keine 'command sets' gefunden! \n" +"Dies ist ein kritischer Fehler, der mehrere Ursachen haben kann.\n" + +#: commands/cmdhandler.py:120 +msgid "" +"\n" +"No command sets found! This is a sign of a critical bug. If\n" +"disconnecting/reconnecting doesn't\" solve the problem, try to contact\n" +"the server admin through\" some other means for assistance.\n" +msgstr "" +"\n" +"Keine 'command sets' gefunden! Dies deutet auf einen kritischen Fehler " +"hin. \n" +"Falls das Trennen und erneute Verbinden das Problem nicht löst, \n" +"versuche den Serveradministrator auf anderem Wege zu kontaktieren.\n" + +#: commands/cmdhandler.py:130 +msgid "" +"\n" +"A command handler bug occurred. If this is not due to a local change,\n" +"please file a bug report with the Evennia project, including the\n" +"traceback and steps to reproduce.\n" +msgstr "" +"\n" +"Ein Fehler im 'command handler' ist aufgetreten. Falls dies nicht auf eine " +"lokale Änderung zurückzuführen ist, sende bitte einen Fehlerbericht an das " +"Evennia-Projekt. Gib dabei den Traceback und die Schritte zur Reproduktion " +"an.\n" + +#: commands/cmdhandler.py:137 +msgid "" +"\n" +"A command handler bug occurred. Please notify staff - they should\n" +"likely file a bug report with the Evennia project.\n" +msgstr "" +"\n" +"Ein Fehler im 'command handler' ist aufgetreten. Bitte benachrichtigen Sie " +"die Mitarbeiter – sie sollten wahrscheinlich einen Fehlerbericht an das " +"Evennia-Projekt senden.\n" + +#: commands/cmdhandler.py:145 #, python-brace-format msgid "" -"Nicks at {chstr}:\n" -" {nicklist}" +"Command recursion limit ({recursion_limit}) reached for '{raw_cmdname}' " +"({cmdclass})." msgstr "" -"Nicks: {chstr}:\n" -" {nicklist}" +"Befehlsrekursionslimit ({recursion_limit}) für '{raw_cmdname}' ({cmdclass}) " +"erreicht." -#: accounts/bots.py:344 +#: commands/cmdhandler.py:171 #, python-brace-format -msgid "IRC ping return from {chstr} took {time}s." -msgstr "Die IRC Ping Antwort von {chstr}benötigte {time}s." +msgid "" +"{traceback}\n" +"{errmsg}\n" +"(Traceback was logged {timestamp})." +msgstr "" +"{traceback}\n" +"{errmsg}\n" +"(Traceback wurde protokolliert {timestamp})" -#: commands/cmdhandler.py:738 +#: commands/cmdhandler.py:708 msgid "There were multiple matches." msgstr "Es gab mehrere Treffer." -#: commands/cmdhandler.py:763 +#: commands/cmdhandler.py:733 #, python-brace-format msgid "Command '{command}' is not available." -msgstr "Der Befehlt‚{command}‘ ist nicht verfügbar." +msgstr "Der Befehl '{command}' ist nicht verfügbar." -#: commands/cmdhandler.py:773 +#: commands/cmdhandler.py:743 #, python-brace-format msgid " Maybe you meant {command}?" -msgstr "Meinten Sie {command}?" +msgstr " Meintest du vielleicht {command}?" -#: commands/cmdhandler.py:774 +#: commands/cmdhandler.py:745 msgid "or" -msgstr "Oder" +msgstr "oder" -#: commands/cmdhandler.py:777 +#: commands/cmdhandler.py:749 msgid " Type \"help\" for help." -msgstr " Geben Sie \"help\" für Hilfe ein." +msgstr " Gib \"help\" für Hilfe ein." -#: commands/cmdsethandler.py:88 +#: commands/cmdsethandler.py:91 #, python-brace-format msgid "" "{traceback}\n" @@ -161,20 +266,20 @@ msgid "" "(Traceback was logged {timestamp})" msgstr "" "{traceback}\n" -"Fehler beim laden von cmdset ‚{path}‘\n" +"Fehler beim Laden von cmdset '{path}'\n" "(Traceback wurde protokolliert {timestamp})" -#: commands/cmdsethandler.py:94 +#: commands/cmdsethandler.py:97 #, python-brace-format msgid "" "Error loading cmdset: No cmdset class '{classname}' in '{path}'.\n" "(Traceback was logged {timestamp})" msgstr "" -"Fehler beim laden von cmdset: Keine cmdset Klasse‚{classname}‘ in " -"‚{path}‘.\n" +"Fehler beim Laden von cmdset: Keine cmdset Klasse '{classname}' in " +"'{path}'.\n" "(Traceback wurde protokolliert {timestamp})" -#: commands/cmdsethandler.py:99 +#: commands/cmdsethandler.py:102 #, python-brace-format msgid "" "{traceback}\n" @@ -182,21 +287,21 @@ msgid "" "(Traceback was logged {timestamp})" msgstr "" "{traceback}\n" -"SyntaxError beim laden von cmdset ‚{path}‘.\n" +"Syntaxfehler beim laden von cmdset '{path}'.\n" "(Traceback wurde protokolliert {timestamp})" -#: commands/cmdsethandler.py:105 +#: commands/cmdsethandler.py:108 #, python-brace-format msgid "" "{traceback}\n" -"Compile/Run error when loading cmdset '{path}'.\",\n" +"Compile/Run error when loading cmdset '{path}'.\n" "(Traceback was logged {timestamp})" msgstr "" "{traceback}\n" -"Kompilierung/Laufzeit Fehler beim laden von cmdset ‚{path}‘.“,\n" -"(Traceback wurde protokolliert{timestamp})" +"Kompilierungs-/Laufzeitfehler beim laden von cmdset '{path}'.\n" +"(Traceback wurde protokolliert {timestamp})" -#: commands/cmdsethandler.py:111 +#: commands/cmdsethandler.py:114 #, python-brace-format msgid "" "\n" @@ -204,233 +309,369 @@ msgid "" "Replacing with fallback '{fallback_path}'.\n" msgstr "" "\n" -"Fehler im cmdset bei Pfad ‚{path}‘.\n" -"Ersetze mit fallback Pfad‚{fallback_path}‘.\n" +"Fehler im cmdset bei Pfad '{path}'.\n" +"Ersetze mit Ersatzpfad '{fallback_path}'.\n" -#: commands/cmdsethandler.py:117 +#: commands/cmdsethandler.py:120 #, python-brace-format msgid "Fallback path '{fallback_path}' failed to generate a cmdset." -msgstr "Fallback Pfad ‚{fallback_path}‘ konnte kein cmdset generieren." +msgstr "Ersatzpfad '{fallback_path}' konnte kein cmdset generieren." -#: commands/cmdsethandler.py:187 commands/cmdsethandler.py:199 +#: commands/cmdsethandler.py:189 commands/cmdsethandler.py:201 #, python-brace-format msgid "" "\n" "(Unsuccessfully tried '{path}')." msgstr "" "\n" -"(Unerfolgreich bei Pfad {path}’)." +"(Pfad '{path}' nicht gefunden.)" -#: commands/cmdsethandler.py:329 +#: commands/cmdsethandler.py:332 #, python-brace-format msgid "custom {mergetype} on cmdset '{cmdset}'" -msgstr "Selbsterstellter {mergetype} in cmdset ‚{cmdset}‘" +msgstr "Selbsterstellter {mergetype} in cmdset '{cmdset}'" -#: commands/cmdsethandler.py:451 +#: commands/cmdsethandler.py:458 msgid "Only CmdSets can be added to the cmdsethandler!" msgstr "Es können nur CmdSets zum cmdsethandler hinzugefügt werden!" -#: comms/channelhandler.py:103 -msgid "Say what?" -msgstr "Wie bitte?" - -#: comms/channelhandler.py:108 -#, python-format -msgid "Channel '%s' not found." -msgstr "Kanal ‚%s’ nicht gefunden." - -#: comms/channelhandler.py:111 -#, python-format -msgid "You are not connected to channel '%s'." -msgstr "Sie sind nicht mit dem Channel ‚%s‘. Verbunden." - -#: comms/channelhandler.py:115 -#, python-format -msgid "You are not permitted to send to channel '%s'." -msgstr "Es ist Ihnen nicht gestattet, an den Kanal ‚%s’ zu senden." - -#: comms/channelhandler.py:122 -#, python-format -msgid "You start listening to %s." -msgstr "Du fängst an %s zuzuhören." - -#: comms/channelhandler.py:124 -#, python-format -msgid "You were already listening to %s." -msgstr "Du hörst bereits %s zu." - -#: comms/channelhandler.py:130 -#, python-format -msgid "You stop listening to %s." -msgstr "Du hörst auf %s zuzuhören." - -#: comms/channelhandler.py:132 -#, python-format -msgid "You were already not listening to %s." -msgstr "Du hörst bereits %s NICHT zu." - -#: comms/channelhandler.py:147 -#, python-format -msgid "You currently have %s muted." -msgstr "Du hast zurzeit %s stumm geschaltet." - -#: comms/channelhandler.py:161 -msgid " (channel)" -msgstr " (Kanal)" - -#: help/manager.py:134 +#: locks/lockhandler.py:242 #, python-brace-format -msgid "Help database moved to category {default_category}" -msgstr "Hilfe Datenbank in Kategorie verschoben: {default_category}" - -#: locks/lockhandler.py:236 -#, python-format -msgid "Lock: lock-function '%s' is not available." -msgstr "Sperre: Sperrfunktion ‚%s‘ ist nicht verfügbar." - -#: locks/lockhandler.py:256 -#, python-brace-format -msgid "Lock: definition '{lock_string}' has syntax errors." -msgstr "Sperre: Definition ’{lock_string}’ hat syntax Fehler." +msgid "Lock: lock-function '{lockfunc}' is not available." +msgstr "Sperre: Sperrfunktion '{lockfunc}' ist nicht verfügbar." #: locks/lockhandler.py:265 -#, python-format -msgid "" -"LockHandler on %(obj)s: access type '%(access_type)s' changed from " -"'%(source)s' to '%(goal)s' " -msgstr "" -"LockHandler in %(obj)s: Zugriffstyp‚%(access_type)s‘ wechselte von " -"‚%(source)s‘ zu ‚%(goal)s‘ " +#, python-brace-format +msgid "Lock: definition '{lock_string}' has syntax errors." +msgstr "Sperre: Definition '{lock_string}' hat Syntaxfehler." -#: locks/lockhandler.py:339 +#: locks/lockhandler.py:274 +#, python-brace-format +msgid "" +"LockHandler on {obj}: access type '{access_type}' changed from '{source}' to " +"'{goal}' " +msgstr "" +"LockHandler in {obj}: Zugriffstyp '{access_type}' wechselte von '{source}' " +"zu '{goal}' " + +#: locks/lockhandler.py:357 #, python-brace-format msgid "Lock: '{lockdef}' contains no colon (:)." -msgstr "Sperre: ‚{lockdef}‘ Enthält keinen Doppelpunkt(:)." +msgstr "Sperre: '{lockdef}' enthält keinen Doppelpunkt (:)." -#: locks/lockhandler.py:348 +#: locks/lockhandler.py:366 #, python-brace-format msgid "Lock: '{lockdef}' has no access_type (left-side of colon is empty)." msgstr "" -"Sperre: ‚{lockdef}‘ Hat keinen access_type (die linke Seite des " -"Doppelpunkts ist leer)." +"Sperre: '{lockdef}' hat keinen access_type (die linke Seite vom Doppelpunkt " +"ist leer)." -#: locks/lockhandler.py:356 +#: locks/lockhandler.py:374 #, python-brace-format msgid "Lock: '{lockdef}' has mismatched parentheses." -msgstr "Sperre: ’{lockdef}’ Hat nicht übereinstimmende Klammern." +msgstr "Sperre: '{lockdef}' hat nicht übereinstimmende Klammern." -#: locks/lockhandler.py:363 +#: locks/lockhandler.py:381 #, python-brace-format msgid "Lock: '{lockdef}' has no valid lock functions." -msgstr "Sperre: ‚{lockdef}‘ Hat keine gültigen Sperrfunktionen." +msgstr "Sperre: '{lockdef}' hat keine gültigen Sperrfunktionen." -#: objects/objects.py:804 -#, python-format -msgid "Couldn't perform move ('%s'). Contact an admin." +#: objects/objects.py:403 +msgid "You see nothing special." +msgstr "Du siehst nichts Besonderes." + +#: objects/objects.py:1239 +#, python-brace-format +msgid "Couldn't perform move ({err}). Contact an admin." msgstr "" -"Bewegung konnte nicht ausgeführt werden (%s). Kontaktieren Sie einen " +"Bewegung konnte nicht ausgeführt werden ({err}). Kontaktiere einen " "Administrator." -#: objects/objects.py:814 +#: objects/objects.py:1249 msgid "The destination doesn't exist." msgstr "Das Ziel existiert nicht." -#: objects/objects.py:905 -#, python-format -msgid "Could not find default home '(#%d)'." -msgstr "Standard-Zuhause konnte nicht gefunden werden‚(#%d)‘." +#: objects/objects.py:1361 +#, python-brace-format +msgid "Could not find default home '(#{dbid})'." +msgstr "Standard-Zuhause konnte nicht gefunden werden '(#{dbid})'." -#: objects/objects.py:921 +#: objects/objects.py:1375 msgid "Something went wrong! You are dumped into nowhere. Contact an admin." msgstr "" -"Etwas ist schief gelaufen! Sie werden ins Nirgendwo abgeworfen. " -"Kontaktieren Sie einen Administrator." +"Etwas ist schief gelaufen! Du bist im Nirgendwo gelandet. Wende dich an " +"einen Administrator." -#: objects/objects.py:1070 +#: objects/objects.py:1557 #, python-brace-format msgid "Your character {key} has been destroyed." -msgstr "Dein Charakter {key} wurde zerstört." +msgstr "Dein Charakter {key} wurde entfernt." -#: scripts/scripthandler.py:52 -#, python-format +#: objects/objects.py:2378 +#, python-brace-format +msgid "You now have {name} in your possession." +msgstr "Du hast nun {name} in deinem Besitz." + +#: objects/objects.py:2388 +#, python-brace-format +msgid "{object} arrives to {destination} from {origin}." +msgstr "{object} kommt aus {origin} in {destination} an." + +#: objects/objects.py:2390 +#, python-brace-format +msgid "{object} arrives to {destination}." +msgstr "{object} kommt in {destination} an." + +#: objects/objects.py:3031 +msgid "This is a character." +msgstr "Dies ist ein Charakter." + +#: objects/objects.py:3255 +#, python-brace-format +msgid "|r{obj} has no location and no home is set.|n" +msgstr "|r{obj} hat keinen Standort und es ist kein Zuhause gesetzt.|n" + +#: objects/objects.py:3274 +#, python-brace-format msgid "" "\n" -" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s" +"You become |c{name}|n.\n" msgstr "" "\n" -" ‚%(key)s‘ (%(next_repeat)s/%(interval)s, %(repeats)s Wiederholungen): " -"%(desc)s" +"Du wirst zu |c{name}|n.\n" -#: scripts/scripts.py:198 -#, python-format -msgid "" -"Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error '%(err)s'." +#: objects/objects.py:3279 +#, python-brace-format +msgid "{name} has entered the game." +msgstr "{name} hat das Spiel betreten." + +#: objects/objects.py:3309 +#, python-brace-format +msgid "{name} has left the game{reason}." +msgstr "{name} hat das Spiel verlassen{reason}." + +#: objects/objects.py:3360 +msgid "This is a room." +msgstr "Dies ist ein Raum." + +#: objects/objects.py:3528 +msgid "This is an exit." +msgstr "Dies ist ein Ausgang." + +#: objects/objects.py:3748 +msgid "You cannot go there." +msgstr "Du kannst da nicht hingehen." + +#: prototypes/prototypes.py:55 +msgid "Error" +msgstr "Fehler" + +#: prototypes/prototypes.py:56 +msgid "Warning" +msgstr "Warnung" + +#: prototypes/prototypes.py:422 +msgid "Prototype requires a prototype_key" +msgstr "Prototype erfordert einen prototype_key" + +#: prototypes/prototypes.py:430 prototypes/prototypes.py:500 +#: prototypes/prototypes.py:1162 +#, python-brace-format +msgid "{protkey} is a read-only prototype (defined as code in {module})." msgstr "" -"Skript %(key)s(#%(dbid)s) vom Typen ‚%(cname)s‘: at_repeat() Fehler " -"‚%(err)s‘." +"{protkey} ist ein schreibgeschützter Prototyp (definiert als Code in " +"{module})." -#: server/initial_setup.py:29 +#: prototypes/prototypes.py:432 prototypes/prototypes.py:502 +#: prototypes/prototypes.py:1164 +#, python-brace-format +msgid "{protkey} is a read-only prototype (passed directly as a dict)." +msgstr "" +"{protkey} ist ein schreibgeschützter Prototyp (direkt als dict übergeben)" + +#: prototypes/prototypes.py:509 +#, python-brace-format +msgid "Prototype {prototype_key} was not found." +msgstr "Prototyp {prototype_key} wurde nicht gefunden." + +#: prototypes/prototypes.py:517 +#, python-brace-format +msgid "" +"{caller} needs explicit 'edit' permissions to delete prototype " +"{prototype_key}." +msgstr "" +"{caller} benötigt explizite 'edit'-Berechtigungen, um den Prototyp " +"{prototype_key} zu löschen." + +#: prototypes/prototypes.py:670 +#, python-brace-format +msgid "Found {num} matching prototypes." +msgstr "{num} passende Prototypen gefunden." + +#: prototypes/prototypes.py:827 +msgid "No prototypes found." +msgstr "Keine Prototypen gefunden." + +#: prototypes/prototypes.py:874 +msgid "Prototype lacks a 'prototype_key'." +msgstr "Dem Prototyp fehlt ein 'prototype_key'." + +#: prototypes/prototypes.py:883 +#, python-brace-format +msgid "Prototype {protkey} requires `typeclass` or 'prototype_parent'." +msgstr "" +"Prototyp {protkey} erfordert eine `typeclass` oder ein 'prototype_parent'." + +#: prototypes/prototypes.py:890 +#, python-brace-format +msgid "" +"Prototype {protkey} can only be used as a mixin since it lacks 'typeclass' " +"or 'prototype_parent' keys." +msgstr "" +"Prototyp {protkey} kann nur als Mixin verwendet werden, da er keine " +"'typeclass' oder 'prototype_parent' hat." + +#: prototypes/prototypes.py:901 +#, python-brace-format +msgid "" +"{err}: Prototype {protkey} is based on typeclass {typeclass}, which could " +"not be imported!" +msgstr "" +"{err}: Prototyp {protkey} basiert auf typeclass {typeclass}, die nicht " +"importiert werden konnte!" + +#: prototypes/prototypes.py:920 +#, python-brace-format +msgid "Prototype {protkey} tries to parent itself." +msgstr "Prototyp {protkey} versucht von sich selbst zu erben." + +#: prototypes/prototypes.py:932 +#, python-brace-format +msgid "" +"Prototype {protkey}'s `prototype_parent` (named '{parent}') was not found." +msgstr "" +"Der `prototype_parent` (namens '{parent}') des Prototyps {protkey} wurde " +"nicht gefunden." + +#: prototypes/prototypes.py:940 +#, python-brace-format +msgid "{protkey} has infinite nesting of prototypes." +msgstr "{protkey} hat unendlich Verschachtelte Prototypen." + +#: prototypes/prototypes.py:969 +#, python-brace-format +msgid "" +"Prototype {protkey} has no `typeclass` defined anywhere in its parent\n" +" chain. Add `typeclass`, or a `prototype_parent` pointing to a prototype " +"with a typeclass." +msgstr "" +"Für den Prototyp {protkey} ist in seinen übergeordneten Elementen keine " +"`typeclass` definiert. \n" +"Füge eine `typeclass` oder einen `prototype_parent` hinzu, der auf einen " +"Prototyp mit einer Typklasse verweist." + +#: prototypes/spawner.py:498 +#, python-brace-format +msgid "" +"Diff contains non-dicts that are not on the form (old, new, action_to_take): " +"{diffpart}" +msgstr "" +"Diff enthält non-dicts, die nicht in der Form (old, new, action_to_take) " +"sind: {diffpart}" + +#: scripts/scripthandler.py:50 +#, python-brace-format msgid "" "\n" -"Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if " +" '{key}' ({next_repeat}/{interval}, {repeats} repeats): {desc}" +msgstr "" +"\n" +" '{key}' ({next_repeat}/{interval}, {repeats} Wiederholungen): {desc}" + +#: scripts/scripts.py:348 +#, python-brace-format +msgid "Script {key}(#{dbid}) of type '{name}': at_repeat() error '{err}'." +msgstr "Script {key}(#{dbid}) vom Typ '{name}': at_repeat() Fehler '{err}'." + +#: server/initial_setup.py:30 +msgid "" +"\n" +"Welcome to your new |wEvennia|n-based game! Visit https://www.evennia.com if " "you need\n" "help, want to contribute, report issues or just join the community.\n" -"As Account #1 you can create a demo/tutorial area with '|wbatchcommand " -"tutorial_world.build|n'.\n" -" " +"\n" +"As a privileged user, write |wbatchcommand tutorial_world.build|n to build\n" +"tutorial content. Once built, try |wintro|n for starting help and |wtutorial|" +"n to\n" +"play the demo game.\n" msgstr "" "\n" -"Willkommen bei deinem neuen Spiel auf Evennia-Basis! Besuchen Sie http://" -"www.evennia.com, wenn Sie\n" -"hilfe brauchst, etwas beitragen, Probleme melden oder einfach der Community " -"beitreten willst.\n" -"Als Account #1 kannst du einen Demo/Tutorial-Bereich mit '|wbatchcommand " -"tutorial_world.build|n' erstellen.\n" -" " +"Willkommen bei deinem neuen Spiel auf |wEvennia|n-Basis! Besuche http://www." +"evennia.com, wenn \n" +"du Hilfe brauchst, etwas beitragen, Probleme melden oder einfach der " +"Community beitreten willst.\n" +"\n" +"Als privilegierter Benutzer kannst du '|wbatchcommand tutorial_world.build|" +"n'eingeben, um \n" +"Tutorial-Inhalte zu erstellen. Sobald erstellt, probiere |wintro|n \n" -#: server/initial_setup.py:94 +#: server/initial_setup.py:104 msgid "This is User #1." msgstr "Dies ist Benutzer #1." -#: server/initial_setup.py:110 +#: server/initial_setup.py:123 msgid "Limbo" msgstr "Limbus" -#: server/server.py:159 -msgid "idle timeout exceeded" -msgstr "Leerlauf-Timeout überschritten" - -#: server/sessionhandler.py:402 -msgid " ... Server restarted." -msgstr " … Server wurde neugestartet." - -#: server/sessionhandler.py:627 -msgid "Logged in from elsewhere. Disconnecting." -msgstr "Von anderswo aus eingeloggt. Getrennt." - -#: server/sessionhandler.py:655 -msgid "Idle timeout exceeded, disconnecting." -msgstr "Leerlauf-Timeout überschritten, Trenne Verbindung." - -#: server/validators.py:31 -msgid "Sorry, that username is reserved." -msgstr "Entschuldigung, dieser Benutzername ist reserviert." - -#: server/validators.py:38 -msgid "Sorry, that username is already taken." -msgstr "Endschuldigung, dieser Benutzername ist bereits vergeben." - -#: server/validators.py:88 -#, python-format +#: server/portal/portalsessionhandler.py:43 +#, python-brace-format msgid "" -"%s From a terminal client, you can also use a phrase of multiple words if " -"you enclose the password in double quotes." +"{servername} DoS protection is active.You are queued to connect in {num} " +"seconds ..." msgstr "" -"%s Von einem Terminal-Client aus können Sie auch eine Phrase aus mehreren " -"Wörtern verwenden, wenn Sie das Passwort in doppelte Anführungszeichen " -"setzen." +"{servername}-DoS-Schutz ist aktiv. Du stehst in der Warteschlange, um in " +"{num} Sekunden eine Verbindung herzustellen ..." -#: utils/evmenu.py:292 +#: server/service.py:121 +msgid " (connection lost)" +msgstr " (Verbindung verloren)" + +#: server/service.py:131 +msgid "idle timeout exceeded" +msgstr "Idle-Timeout überschritten" + +#: server/sessionhandler.py:50 +msgid "Your client sent an incorrect UTF-8 sequence." +msgstr "Ihr Client hat eine falsche UTF-8-Sequenz gesendet." + +#: server/sessionhandler.py:418 +msgid " ... Server restarted." +msgstr " … Server wurde neu gestartet." + +#: server/sessionhandler.py:648 +msgid "Logged in from elsewhere. Disconnecting." +msgstr "Von woanders eingeloggt. Verbindung getrennt." + +#: server/sessionhandler.py:676 +msgid "Idle timeout exceeded, disconnecting." +msgstr "Idle-Timeout überschritten, Verbindung wird getrennt." + +#: server/throttle.py:23 +msgid "" +"Too many failed attempts; you must wait a few minutes before trying again." +msgstr "" +"Zu viele Fehlversuche. Du musst einige Minuten warten, bevor du es erneut " +"versuchen kannst." + +#: server/validators.py:32 +msgid "Sorry, that username is reserved." +msgstr "Dieser Benutzername ist reserviert." + +#: server/validators.py:39 +msgid "Sorry, that username is already taken." +msgstr "Dieser Benutzername ist bereits vergeben." + +#: utils/evmenu.py:310 #, python-brace-format msgid "" "Menu node '{nodename}' is either not implemented or caused an error. Make " @@ -438,53 +679,202 @@ msgid "" msgstr "" "Der Menüknoten '{nodename}' ist entweder nicht implementiert oder hat einen " "Fehler verursacht. \n" -"Treffen Sie eine andere Wahl oder versuchen Sie 'q', um abzubrechen." +"Triff eine andere Wahl oder wähle 'q', um abzubrechen." -#: utils/evmenu.py:295 +#: utils/evmenu.py:313 #, python-brace-format msgid "Error in menu node '{nodename}'." msgstr "Fehler im Menüknoten '{nodename}'." -#: utils/evmenu.py:296 +#: utils/evmenu.py:314 msgid "No description." msgstr "Keine Beschreibung." -#: utils/evmenu.py:297 +#: utils/evmenu.py:315 msgid "Commands: , help, quit" -msgstr "Befehle: , Hilfe, Beenden" +msgstr "Befehle: , help, quit" -#: utils/evmenu.py:298 +#: utils/evmenu.py:316 msgid "Commands: , help" -msgstr "Befehle: , Hilfe" +msgstr "Befehle: , help" -#: utils/evmenu.py:299 +#: utils/evmenu.py:317 msgid "Commands: help, quit" -msgstr "Befehle: Hilfe, Beenden" +msgstr "Befehle: help, quit" -#: utils/evmenu.py:300 +#: utils/evmenu.py:318 msgid "Commands: help" -msgstr "Befehle: Hilfe" +msgstr "Befehle: help" -#: utils/evmenu.py:301 utils/evmenu.py:1665 +#: utils/evmenu.py:319 utils/evmenu.py:1828 msgid "Choose an option or try 'help'." -msgstr "Wähle eine Option oder versuche ‚Hilfe‘." +msgstr "Wähle eine Option oder versuche 'help‘." -#: utils/utils.py:1923 -#, python-format -msgid "Could not find '%s'." -msgstr "Kann ‚%s‘. Nicht finden." +#: utils/evmenu.py:1352 +msgid "|rInvalid choice.|n" +msgstr "|rUngültige Wahl.|n" -#: utils/utils.py:1930 +#: utils/evmenu.py:1415 +msgid "|Wcurrent|n" +msgstr "|Waktuell|n" + +#: utils/evmenu.py:1423 +msgid "|wp|Wrevious page|n" +msgstr "|wp|Wrevious (vorherige) Seite|n" + +#: utils/evmenu.py:1430 +msgid "|wn|Wext page|n" +msgstr "|wn|Wächste Seite|n" + +#: utils/evmenu.py:1667 +msgid "Aborted." +msgstr "Abgebrochen." + +#: utils/evmenu.py:1690 +msgid "|rError in ask_yes_no. Choice not confirmed (report to admin)|n" +msgstr "" +"|rFehler in ask_yes_no. Auswahl nicht bestätigt (bitte an Administrator " +"melden)|n" + +#: utils/evmore.py:244 +msgid "|xExited pager.|n" +msgstr "|xPager verlassen.|n" + +#: utils/optionhandler.py:139 utils/optionhandler.py:164 +msgid "Option not found!" +msgstr "Option nicht gefunden!" + +#: utils/optionhandler.py:161 +msgid "Option field blank!" +msgstr "Option-Feld leer!" + +#: utils/optionhandler.py:167 +msgid "Multiple matches:" +msgstr "Es gab mehrere Treffer:" + +#: utils/optionhandler.py:167 +msgid "Please be more specific." +msgstr "Bitte sei spezifischer." + +#: utils/utils.py:2227 +#, python-brace-format +msgid "" +"{obj}.{handlername} is a handler and can't be set directly. To add values, " +"use `{obj}.{handlername}.add()` instead." +msgstr "" +"{obj}.{handlername} ist ein 'handler' und kann nicht direkt gesetzt werden. " +"Um Werte hinzuzufügen, verwende stattdessen `{obj}.{handlername}.add()`." + +#: utils/utils.py:2237 +#, python-brace-format +msgid "" +"{obj}.{handlername} is a handler and can't be deleted directly. To remove " +"values, use `{obj}.{handlername}.remove()` instead." +msgstr "" +"{obj}.{handlername} ist ein 'handler' und kann nicht direkt gelöscht werden. " +"Um Werte zu entfernen, verwenden Sie stattdessen `{obj}.{handlername}." +"remove()`." + +#: utils/utils.py:2389 +#, python-brace-format +msgid "Could not find '{query}'." +msgstr "Kann '{query}' nicht finden." + +#: utils/utils.py:2396 #, python-brace-format msgid "More than one match for '{query}' (please narrow target):\n" msgstr "Mehr als ein Treffer für ‚{query}‘ (Bitte das Ziel präzisieren):\n" -#: utils/validatorfuncs.py:62 -#, python-brace-format -msgid "No {option_key} entered!" -msgstr "Kein {option_key} eingegeben!" +#: web/templates/admin/app_list.html:19 +msgid "Add" +msgstr "Hinzufügen" + +#: web/templates/admin/app_list.html:26 +msgid "View" +msgstr "Anschauen" + +#: web/templates/admin/app_list.html:28 +msgid "Change" +msgstr "Ändern" + +#: web/templates/admin/app_list.html:39 +msgid "You don’t have permission to view or edit anything." +msgstr "Du hast nicht die Berechtigung etwas anzuschauen oder zu ändern." -#: utils/validatorfuncs.py:71 #, python-brace-format -msgid "Timezone string '{acct_tz}' is not a valid timezone ({err})" -msgstr "Zeitzonen string ‚{acct_tz}‘ ist keine Gültige Zeitzone({err})" +#~ msgid "{target} has no in-game appearance." +#~ msgstr "{target} hat im Spiel keine Präsenz." + +#~ msgid "" +#~ "\n" +#~ "\n" +#~ " You don't have any characters yet. See |whelp @charcreate|n for creating " +#~ "one." +#~ msgstr "" +#~ "\n" +#~ "\n" +#~ "Du hast noch keine Charaktere. Siehe |whelp @charcreate|n um einen zu " +#~ "erstellen." + +#, python-brace-format +#~ msgid "" +#~ "Nicks at {chstr}:\n" +#~ " {nicklist}" +#~ msgstr "" +#~ "Nicks: {chstr}:\n" +#~ " {nicklist}" + +#, python-brace-format +#~ msgid "IRC ping return from {chstr} took {time}s." +#~ msgstr "Der IRC Ping von {chstr} benötigte {time}s." + +#~ msgid "Say what?" +#~ msgstr "Wie bitte?" + +#, python-format +#~ msgid "Channel '%s' not found." +#~ msgstr "Kanal '%s' nicht gefunden." + +#, python-format +#~ msgid "You are not connected to channel '%s'." +#~ msgstr "Du bist nicht mit dem Kanal '%s' verbunden." + +#, python-format +#~ msgid "You are not permitted to send to channel '%s'." +#~ msgstr "Du darfst nichts an den Kanal '%s' senden." + +#, python-format +#~ msgid "You start listening to %s." +#~ msgstr "Du fängst an %s zuzuhören." + +#, python-format +#~ msgid "You were already listening to %s." +#~ msgstr "Du hörst %s bereits zu." + +#, python-format +#~ msgid "You stop listening to %s." +#~ msgstr "Du hörst %s nicht mehr zu." + +#, python-format +#~ msgid "You were already not listening to %s." +#~ msgstr "Du hörst bereits %s nicht zu." + +#, python-format +#~ msgid "You currently have %s muted." +#~ msgstr "Du hast %s stumm geschaltet." + +#~ msgid " (channel)" +#~ msgstr " (Kanal)" + +#, python-brace-format +#~ msgid "Help database moved to category {default_category}" +#~ msgstr "Hilfe-Datenbank in Kategorie {default_category} verschoben" + +#, python-format +#~ msgid "" +#~ "%s From a terminal client, you can also use a phrase of multiple words if " +#~ "you enclose the password in double quotes." +#~ msgstr "" +#~ "%s Von einem Terminal-Client aus kannst du eine Phrase aus mehreren " +#~ "Wörtern verwenden, wenn du das Passwort in doppelte Anführungszeichen " +#~ "setzen." diff --git a/evennia/locale/zh/LC_MESSAGES/django.po b/evennia/locale/zh/LC_MESSAGES/django.po index 0b30ba017b..1bacd24476 100644 --- a/evennia/locale/zh/LC_MESSAGES/django.po +++ b/evennia/locale/zh/LC_MESSAGES/django.po @@ -1,105 +1,89 @@ -# The Simplified Chinese translation for the Evennia server. -# Copyright (C) 2019 MaxAlex -# This file is distributed under the same license as the Evennia package. -# FIRST AUTHOR: MaxAlex , 2018- -# msgid "" msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-10-29 18:53+0000\n" -"PO-Revision-Date: 2019-05-03 17:04+0800\n" -"Last-Translator: \n" -"Language-Team: \n" -"Language: zh-hans\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"X-Generator: POEditor.com\n" +"Project-Id-Version: Evennia\n" +"Language: zh-Hans\n" #: accounts/accounts.py:341 -#, python-brace-format msgid "|c{key}|R is already puppeted by another Account." -msgstr "" +msgstr "|c{key}|R 已被另一个帐户使用。" #: accounts/accounts.py:361 -#, python-brace-format -msgid "" -"You cannot control any more puppets (max {_MAX_NR_SIMULTANEOUS_PUPPETS})" -msgstr "" +msgid "You cannot control any more puppets (max {_MAX_NR_SIMULTANEOUS_PUPPETS})" +msgstr "您无法再操作更多实体(最多{_MAX_NR_SIMULTANEOUS_PUPPETS}个)" #: accounts/accounts.py:555 msgid "Too many login failures; please try again in a few minutes." -msgstr "" +msgstr "登录失败次数过多;请稍后再试。" #: accounts/accounts.py:568 accounts/accounts.py:832 -msgid "" -"|rYou have been banned and cannot continue from here.\n" +msgid "|rYou have been banned and cannot continue from here.\n" "If you feel this ban is in error, please email an admin.|x" -msgstr "" +msgstr "|r您已被封禁。\n" +"如有疑问或想要解禁,请向管理员发送电子邮件说明。|x" #: accounts/accounts.py:580 msgid "Username and/or password is incorrect." -msgstr "" +msgstr "用户名或密码不正确。" #: accounts/accounts.py:587 msgid "Too many authentication failures." -msgstr "" +msgstr "身份验证失败次数过多。" #: accounts/accounts.py:803 -msgid "" -"You are creating too many accounts. Please log into an existing account." -msgstr "" +msgid "You are creating too many accounts. Please log into an existing account." +msgstr "您创建的账户过多,请先登录至现有账户。" #: accounts/accounts.py:849 -msgid "" -"There was an error creating the Account. If this problem persists, contact " -"an admin." -msgstr "" +msgid "There was an error creating the Account. If this problem persists, contact an admin." +msgstr "创建帐户时出错:如果您发现此问题重复出现,请汇报至管理员。" #: accounts/accounts.py:885 accounts/accounts.py:1801 msgid "An error occurred. Please e-mail an admin if the problem persists." -msgstr "" +msgstr "发生错误:如果问题仍然存在,请向管理员发送电子邮件。" #: accounts/accounts.py:918 msgid "Account being deleted." msgstr "用户已删除。" #: accounts/accounts.py:1475 accounts/accounts.py:1819 -#, python-brace-format msgid "|G{key} connected|n" -msgstr "" +msgstr "|G{key} 已连接|n" #: accounts/accounts.py:1481 -#, fuzzy -#| msgid "The destination doesn't exist." msgid "The Character does not exist." -msgstr "目的地不存在。" +msgstr "该角色不存在。" #: accounts/accounts.py:1520 -#, python-brace-format msgid "|R{key} disconnected{reason}|n" -msgstr "" +msgstr "|R{key} 断开连接{reason}|n" #: accounts/accounts.py:1754 msgid "Guest accounts are not enabled on this server." -msgstr "" +msgstr "服务器未启用访客帐户。" #: accounts/accounts.py:1764 msgid "All guest accounts are in use. Please try again later." -msgstr "" +msgstr "所有访客帐户均在使用中。请稍后重试。" #: commands/cmdhandler.py:84 msgid "" "\n" "An untrapped error occurred.\n" msgstr "" +"\n" +"发生了未捕获的错误。\n" #: commands/cmdhandler.py:89 msgid "" "\n" -"An untrapped error occurred. Please file a bug report detailing the steps to " -"reproduce.\n" +"An untrapped error occurred. Please file a bug report detailing the steps to reproduce.\n" msgstr "" +"\n" +"发生未捕获的错误。请提交错误报告并详细说明重现步骤。\n" #: commands/cmdhandler.py:97 msgid "" @@ -107,6 +91,8 @@ msgid "" "A cmdset merger-error occurred. This is often due to a syntax\n" "error in one of the cmdsets to merge.\n" msgstr "" +"\n" +"发生 cmaset 合并错误。这通常是由于要合并的某个 cmdset 中存在语法错误。\n" #: commands/cmdhandler.py:103 msgid "" @@ -114,6 +100,9 @@ msgid "" "A cmdset merger-error occurred. Please file a bug report detailing the\n" "steps to reproduce.\n" msgstr "" +"\n" +"发生 cmdset 合并错误。\n" +"请提交错误报告,详细说明重现步骤。\n" #: commands/cmdhandler.py:112 msgid "" @@ -121,6 +110,9 @@ msgid "" "No command sets found! This is a critical bug that can have\n" "multiple causes.\n" msgstr "" +"\n" +"未找到命令集!\n" +"这是一个严重的错误,可能由多种原因造成。\n" #: commands/cmdhandler.py:118 msgid "" @@ -129,6 +121,10 @@ msgid "" "disconnecting/reconnecting doesn't\" solve the problem, try to contact\n" "the server admin through\" some other means for assistance.\n" msgstr "" +"\n" +"未找到命令集!\n" +"这意味着出现严重错误。\n" +"如果断开/重新连接无法解决问题,请尝试通过其他方式联系服务器管理员寻求帮助。\n" #: commands/cmdhandler.py:128 msgid "" @@ -137,6 +133,10 @@ msgid "" "please file a bug report with the Evennia project, including the\n" "traceback and steps to reproduce.\n" msgstr "" +"\n" +"发生命令处理程序错误。\n" +"如果这不是由于本地更改造成的, 请向 Evennia官方提交错误报告,\n" +"包括回溯和重现步骤。\n" #: commands/cmdhandler.py:135 msgid "" @@ -144,44 +144,33 @@ msgid "" "A command handler bug occurred. Please notify staff - they should\n" "likely file a bug report with the Evennia project.\n" msgstr "" +"\n" +"出现命令处理程序错误。请通知工作人员 \n" +"---他们应该 向 Evennia 项目提交错误报告。\n" #: commands/cmdhandler.py:143 -#, python-brace-format -msgid "" -"Command recursion limit ({recursion_limit}) reached for " -"'{raw_cmdname}' ({cmdclass})." -msgstr "" +msgid "Command recursion limit ({recursion_limit}) reached for '{raw_cmdname}' ({cmdclass})." +msgstr "‘{raw_cmdname}’ ({cmdclass}) 的命令递归已达到限制 ({recursion_limit}) 。" #: commands/cmdhandler.py:165 -#, fuzzy, python-brace-format -#| msgid "" -#| "{traceback}\n" -#| "Error loading cmdset '{path}'\n" -#| "(Traceback was logged {timestamp})" -msgid "" -"{traceback}\n" +msgid "{traceback}\n" "{errmsg}\n" "(Traceback was logged {timestamp})." -msgstr "" -"{traceback}\n" -"读取CmdSet '{path}' 时发生错误 \n" -"(已记录 Traceback {timestamp})" +msgstr "{traceback}\n" +"读取CmdSet '{errmsg}' 时发生错误 \n" +"(已记录回溯 {timestamp})。" #: commands/cmdhandler.py:715 msgid "There were multiple matches." msgstr "发现多个匹配项。" #: commands/cmdhandler.py:740 -#, fuzzy, python-brace-format -#| msgid "Command '%s' is not available." msgid "Command '{command}' is not available." -msgstr "命令 '%s' 不可用。" +msgstr "命令 '{command}' 不可用。" #: commands/cmdhandler.py:750 -#, fuzzy, python-brace-format -#| msgid " Maybe you meant %s?" msgid " Maybe you meant {command}?" -msgstr " 您指的是 %s 吗?" +msgstr " 您指的是{command}吗?" #: commands/cmdhandler.py:751 msgid "or" @@ -189,56 +178,39 @@ msgstr "或" #: commands/cmdhandler.py:754 msgid " Type \"help\" for help." -msgstr " 键入 \"help\" 获得帮助。" +msgstr " 输入 \"help\" 获得帮助。" #: commands/cmdsethandler.py:89 -#, python-brace-format -msgid "" -"{traceback}\n" +msgid "{traceback}\n" "Error loading cmdset '{path}'\n" "(Traceback was logged {timestamp})" -msgstr "" -"{traceback}\n" +msgstr "{traceback}\n" "读取CmdSet '{path}' 时发生错误 \n" "(已记录 Traceback {timestamp})" #: commands/cmdsethandler.py:95 -#, python-brace-format -msgid "" -"Error loading cmdset: No cmdset class '{classname}' in '{path}'.\n" +msgid "Error loading cmdset: No cmdset class '{classname}' in '{path}'.\n" "(Traceback was logged {timestamp})" -msgstr "" -"读取 CmdSet 时发生错误:在 '{path}' 处未找到 CmdSet '{classname}' 。\n" +msgstr "读取 CmdSet 时发生错误:在 '{path}' 处未找到 CmdSet '{classname}' 。\n" "(已记录 Traceback {timestamp})" #: commands/cmdsethandler.py:100 -#, python-brace-format -msgid "" -"{traceback}\n" +msgid "{traceback}\n" "SyntaxError encountered when loading cmdset '{path}'.\n" "(Traceback was logged {timestamp})" -msgstr "" -"{traceback}\n" +msgstr "{traceback}\n" "读取在 '{path}' 处的 CmdSet 时发生 语法 错误。\n" "(已记录 Traceback {timestamp})" #: commands/cmdsethandler.py:106 -#, fuzzy, python-brace-format -#| msgid "" -#| "{traceback}\n" -#| "Compile/Run error when loading cmdset '{path}'.\",\n" -#| "(Traceback was logged {timestamp})" -msgid "" -"{traceback}\n" +msgid "{traceback}\n" "Compile/Run error when loading cmdset '{path}'.\n" "(Traceback was logged {timestamp})" -msgstr "" -"{traceback}\n" -"读取在 '{path}' 处的 CmdSet 时发生 编译/运行 错误。\",\n" +msgstr "{traceback}\n" +"读取在 '{path}' 处的 CmdSet 时发生 编译/运行 错误。\n" "(已记录 Traceback {timestamp})" #: commands/cmdsethandler.py:112 -#, python-brace-format msgid "" "\n" "Error encountered for cmdset at path '{path}'.\n" @@ -249,24 +221,18 @@ msgstr "" "使用备选路径 '{fallback_path}' 。\n" #: commands/cmdsethandler.py:118 -#, python-brace-format msgid "Fallback path '{fallback_path}' failed to generate a cmdset." msgstr "在备选路径 '{fallback_path}' 处创建 CmdSet 失败。" #: commands/cmdsethandler.py:188 commands/cmdsethandler.py:200 -#, fuzzy, python-brace-format -#| msgid "" -#| "\n" -#| "(Unsuccessfully tried '%s')." msgid "" "\n" "(Unsuccessfully tried '{path}')." msgstr "" "\n" -"(尝试 '%s' 失败)。" +"(尝试‘{path}’未成功)。" #: commands/cmdsethandler.py:331 -#, python-brace-format msgid "custom {mergetype} on cmdset '{cmdset}'" msgstr "CmdSet '{cmdset}' 的自定义 {mergetype}" @@ -275,305 +241,226 @@ msgid "Only CmdSets can be added to the cmdsethandler!" msgstr "只有 CmdSet 可以被添加给 cmdsethandler!" #: locks/lockhandler.py:239 -#, fuzzy, python-brace-format -#| msgid "Lock: lock-function '%s' is not available." msgid "Lock: lock-function '{lockfunc}' is not available." -msgstr "Lock:Lock函数 '%s' 不可用。" +msgstr "Lock:Lock函数'{lockfunc}'不可用。" #: locks/lockhandler.py:262 -#, fuzzy, python-brace-format -#| msgid "Lock: definition '%s' has syntax errors." msgid "Lock: definition '{lock_string}' has syntax errors." -msgstr "Lock:定义 '%s' 发生语法错误。" +msgstr "Lock:定义 '{lock_string}' 发现语法错误。" #: locks/lockhandler.py:271 -#, fuzzy, python-brace-format -#| msgid "" -#| "LockHandler on %(obj)s: access type '%(access_type)s' changed from " -#| "'%(source)s' to '%(goal)s' " -msgid "" -"LockHandler on {obj}: access type '{access_type}' changed from '{source}' to " -"'{goal}' " -msgstr "" -"%(obj)s 上的 LockHandler: 访问类型 '%(access_type)s' 由 '%(source)s' 改变为 " -"'%(goal)s' " +msgid "LockHandler on {obj}: access type '{access_type}' changed from '{source}' to '{goal}' " +msgstr "{obj} 上的 LockHandler:访问类型'{access_type}'从'{source}'更改为'{goal}' " #: locks/lockhandler.py:347 -#, python-brace-format msgid "Lock: '{lockdef}' contains no colon (:)." msgstr "Lock:'{lockdef}' 缺少英文冒号 (:) 。" #: locks/lockhandler.py:356 -#, python-brace-format msgid "Lock: '{lockdef}' has no access_type (left-side of colon is empty)." msgstr "Lock: '{lockdef}' 无访问类型(冒号左侧缺少数据)。" #: locks/lockhandler.py:364 -#, python-brace-format msgid "Lock: '{lockdef}' has mismatched parentheses." msgstr "Lock: '{lockdef}' 英文括号不匹配。" #: locks/lockhandler.py:371 -#, python-brace-format msgid "Lock: '{lockdef}' has no valid lock functions." msgstr "Lock: '{lockdef}' 缺少合法Lock函数。" #: objects/objects.py:856 -#, fuzzy, python-brace-format -#| msgid "Couldn't perform move ('%s'). Contact an admin." msgid "Couldn't perform move ({err}). Contact an admin." -msgstr "无法做出行动 ('%s')。请联系管理员。" +msgstr "无法执行操作({err})。请联系管理员。" #: objects/objects.py:866 msgid "The destination doesn't exist." msgstr "目的地不存在。" #: objects/objects.py:978 -#, fuzzy, python-brace-format -#| msgid "Could not find default home '(#%d)'." msgid "Could not find default home '(#{dbid})'." -msgstr "无法定位默认寓所 '(#%d)' 。" +msgstr "未找到默认位置‘(#{dbid})’。" #: objects/objects.py:992 msgid "Something went wrong! You are dumped into nowhere. Contact an admin." -msgstr "出现错误!您进入了错误的地点。请联系管理员。" +msgstr "发生了意外!您目前处于不恰当的地点。请联系管理员处理。" #: objects/objects.py:1145 -#, fuzzy, python-brace-format -#| msgid "Your character %s has been destroyed." msgid "Your character {key} has been destroyed." -msgstr "您的角色 %s 被摧毁了。" +msgstr "你的角色{key}已被删除。" #: objects/objects.py:1853 -#, python-brace-format msgid "You now have {name} in your possession." -msgstr "" +msgstr "您得到了{name}。" #: objects/objects.py:1863 -#, python-brace-format msgid "{object} arrives to {destination} from {origin}." -msgstr "" +msgstr "{object}从{origin}来到了{destination}。" #: objects/objects.py:1865 -#, python-brace-format msgid "{object} arrives to {destination}." -msgstr "" +msgstr "{object}来到了{destination}。" #: objects/objects.py:2530 msgid "Invalid character name." -msgstr "" +msgstr "无效的角色名称。" #: objects/objects.py:2549 msgid "There are too many characters associated with this account." -msgstr "" +msgstr "与此帐户关联的角色过多。" #: objects/objects.py:2575 -#, fuzzy -#| msgid "This is User #1." msgid "This is a character." -msgstr "这是管理员。" +msgstr "这是一个角色。" #: objects/objects.py:2664 -#, python-brace-format msgid "|r{obj} has no location and no home is set.|n" -msgstr "" +msgstr "|r{obj} 目前没有位置信息且没有设置默认归属地。|n" #: objects/objects.py:2682 -#, python-brace-format msgid "" "\n" "You become |c{name}|n.\n" msgstr "" +"\n" +"你化身为|c{name}|n。\n" #: objects/objects.py:2687 -#, python-brace-format msgid "{name} has entered the game." -msgstr "" +msgstr "{name}已加入游戏。" #: objects/objects.py:2716 -#, python-brace-format msgid "{name} has left the game{reason}." -msgstr "" +msgstr "{name}已离开游戏{reason}。" #: objects/objects.py:2838 -#, fuzzy -#| msgid "This is User #1." msgid "This is a room." -msgstr "这是管理员。" +msgstr "这是一个房间。" #: objects/objects.py:3045 -#, fuzzy -#| msgid "This is User #1." msgid "This is an exit." -msgstr "这是管理员。" +msgstr "这是一个出口。" #: objects/objects.py:3142 msgid "You cannot go there." -msgstr "" +msgstr "你不能到那里去。" #: prototypes/prototypes.py:55 msgid "Error" -msgstr "" +msgstr "错误" #: prototypes/prototypes.py:56 msgid "Warning" -msgstr "" +msgstr "警告" #: prototypes/prototypes.py:389 msgid "Prototype requires a prototype_key" -msgstr "" +msgstr "原型需要一个'prototype_key'" #: prototypes/prototypes.py:397 prototypes/prototypes.py:466 #: prototypes/prototypes.py:1092 -#, python-brace-format msgid "{protkey} is a read-only prototype (defined as code in {module})." -msgstr "" +msgstr "{protkey} 是一个只读原型(在 {module} 中定义为代码)。" #: prototypes/prototypes.py:399 prototypes/prototypes.py:468 #: prototypes/prototypes.py:1094 -#, python-brace-format msgid "{protkey} is a read-only prototype (passed directly as a dict)." -msgstr "" +msgstr "{protkey} 是一个只读原型(直接作为dict传递)。" #: prototypes/prototypes.py:475 -#, python-brace-format msgid "Prototype {prototype_key} was not found." -msgstr "" +msgstr "未找到原型 {prototype_key}。" #: prototypes/prototypes.py:483 -#, python-brace-format -msgid "" -"{caller} needs explicit 'edit' permissions to delete prototype " -"{prototype_key}." -msgstr "" +msgid "{caller} needs explicit 'edit' permissions to delete prototype {prototype_key}." +msgstr "{caller} 需要明确的'编辑'权限才能删除原型 {prototype_key}。" #: prototypes/prototypes.py:605 -#, python-brace-format msgid "Found {num} matching prototypes among {module_prototypes}." -msgstr "" +msgstr "在 {module_prototypes} 中找到 {num} 个匹配的原型。" #: prototypes/prototypes.py:765 msgid "No prototypes found." -msgstr "" +msgstr "未找到原型。" #: prototypes/prototypes.py:816 msgid "Prototype lacks a 'prototype_key'." -msgstr "" +msgstr "原型缺少'prototype_key'。" #: prototypes/prototypes.py:825 -#, python-brace-format msgid "Prototype {protkey} requires `typeclass` or 'prototype_parent'." -msgstr "" +msgstr "原型 {protkey} 需要'typeclass'或'prototype_parent'。" #: prototypes/prototypes.py:832 -#, python-brace-format -msgid "" -"Prototype {protkey} can only be used as a mixin since it lacks 'typeclass' " -"or 'prototype_parent' keys." -msgstr "" +msgid "Prototype {protkey} can only be used as a mixin since it lacks 'typeclass' or 'prototype_parent' keys." +msgstr "原型 {protkey} 由于缺少 'typeclass'或 'prototype_parent'键,只能作为混合元素(mixin)使用。" #: prototypes/prototypes.py:843 -#, python-brace-format -msgid "" -"{err}: Prototype {protkey} is based on typeclass {typeclass}, which could " -"not be imported!" -msgstr "" +msgid "{err}: Prototype {protkey} is based on typeclass {typeclass}, which could not be imported!" +msgstr "{err}原型: {protkey} 基于类型类 {typeclass},无法导入!" #: prototypes/prototypes.py:862 -#, python-brace-format msgid "Prototype {protkey} tries to parent itself." -msgstr "" +msgstr "原型 {protkey} 尝试将自己作为父节点。" #: prototypes/prototypes.py:868 -#, python-brace-format -msgid "" -"Prototype {protkey}'s `prototype_parent` (named '{parent}') was not found." -msgstr "" +msgid "Prototype {protkey}'s `prototype_parent` (named '{parent}') was not found." +msgstr "未找到原型 {protkey} 的 `prototype_parent` (名为 '{parent}')。" #: prototypes/prototypes.py:875 -#, python-brace-format msgid "{protkey} has infinite nesting of prototypes." -msgstr "" +msgstr "{protkey} 具有无限的原型嵌套。" #: prototypes/prototypes.py:900 -#, python-brace-format -msgid "" -"Prototype {protkey} has no `typeclass` defined anywhere in its parent\n" -" chain. Add `typeclass`, or a `prototype_parent` pointing to a prototype " -"with a typeclass." -msgstr "" +msgid "Prototype {protkey} has no `typeclass` defined anywhere in its parent\n" +" chain. Add `typeclass`, or a `prototype_parent` pointing to a prototype with a typeclass." +msgstr "原型 {protkey} 在其父链的任何地方均未定义 `typeclass`。\n" +"添加 `typeclass`,或指向具有 typeclass 的原型的 `protype_parent`。" #: prototypes/spawner.py:495 -#, python-brace-format -msgid "" -"Diff contains non-dicts that are not on the form (old, new, action_to_take): " -"{diffpart}" -msgstr "" +msgid "Diff contains non-dicts that are not on the form (old, new, action_to_take): {diffpart}" +msgstr "Diff 包含不属于 (old, new, action_to_take) 形式的非字典:{diffpart}" #: scripts/scripthandler.py:51 -#, fuzzy, python-brace-format -#| msgid "" -#| "\n" -#| " '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s" msgid "" "\n" " '{key}' ({next_repeat}/{interval}, {repeats} repeats): {desc}" msgstr "" "\n" -" '%(key)s' (%(next_repeat)s/%(interval)s, %(repeats)s repeats): %(desc)s" +"'{key}'({next_repeat}/{interval},{repeats} 重复):{desc}" #: scripts/scripts.py:344 -#, fuzzy, python-brace-format -#| msgid "" -#| "Script %(key)s(#%(dbid)s) of type '%(cname)s': at_repeat() error " -#| "'%(err)s'." msgid "Script {key}(#{dbid}) of type '{name}': at_repeat() error '{err}'." -msgstr "" -"'%(cname)s' 的脚本 %(key)s(#%(dbid)s): at_repeat() 出现 '%(err)s' 错误。" +msgstr "类型为‘{name}’的脚本 {key}(#{dbid}):at_repeat() 错误‘{err}’。" #: server/initial_setup.py:29 -#, fuzzy -#| msgid "" -#| "\n" -#| "Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com " -#| "if you need\n" -#| "help, want to contribute, report issues or just join the community.\n" -#| "As Account #1 you can create a demo/tutorial area with |w@batchcommand " -#| "tutorial_world.build|n.\n" -#| " " msgid "" "\n" -"Welcome to your new |wEvennia|n-based game! Visit https://www.evennia.com if " -"you need\n" +"Welcome to your new |wEvennia|n-based game! Visit https://www.evennia.com if you need\n" "help, want to contribute, report issues or just join the community.\n" "\n" "As a privileged user, write |wbatchcommand tutorial_world.build|n to build\n" -"tutorial content. Once built, try |wintro|n for starting help and |wtutorial|" -"n to\n" +"tutorial content. Once built, try |wintro|n for starting help and |wtutorial|n to\n" "play the demo game.\n" msgstr "" "\n" -"欢迎进入您的基于 |wEvennia|n 的游戏! 如果需要帮助、想要做些贡献、报告错误的" -"话,请访问 http://www.evennia.com 。\n" +"--欢迎来到基于|wEvennia|n的新游戏!\n" +"如果您需要帮助,或是想贡献自己的力量、报告问题或只是想加入社区,\n" +"请访问 https://www.evennia.com\n" "\n" -"作为管理员,你可以使用 |w@batchcommand tutorial_world.build|n 来创建一个演示/" -"教程区域。\n" -" " +"作为超级用户,输入|wbatchcommand tutorial_world.build|n来构建教程内容。\n" +"构建完成后,请尝试使用 |wintro|n 启动帮助,随后使用 |wtutorial|n 玩演示游戏。\n" #: server/initial_setup.py:108 msgid "This is User #1." -msgstr "这是管理员。" +msgstr "这是首个用户。" #: server/initial_setup.py:128 msgid "Limbo" -msgstr "边境" +msgstr "Limbo" #: server/portal/portalsessionhandler.py:41 -#, python-brace-format -msgid "" -"{servername} DoS protection is active.You are queued to connect in {num} " -"seconds ..." -msgstr "" +msgid "{servername} DoS protection is active.You are queued to connect in {num} seconds ..." +msgstr "{servername} DoS 保护已激活。您将在 {num} 秒内排队连接..." #: server/server.py:156 msgid "idle timeout exceeded" @@ -581,48 +468,39 @@ msgstr "连接超时" #: server/server.py:177 msgid " (connection lost)" -msgstr "" +msgstr " (连接丢失)" #: server/sessionhandler.py:41 msgid "Your client sent an incorrect UTF-8 sequence." -msgstr "" +msgstr "您的客户端发送了一个不正确的 UTF-8 序列。" #: server/sessionhandler.py:410 msgid " ... Server restarted." -msgstr " ... 服务器已启动。" +msgstr " ... 服务器已重启。" #: server/sessionhandler.py:634 msgid "Logged in from elsewhere. Disconnecting." -msgstr "异地登录。已断线。" +msgstr "已从其他地方登录。正在断开连接。" #: server/sessionhandler.py:662 msgid "Idle timeout exceeded, disconnecting." -msgstr "连接超时。已断线。" +msgstr "连接超时,正在断开连接。" #: server/throttle.py:21 -msgid "" -"Too many failed attempts; you must wait a few minutes before trying again." -msgstr "" +msgid "Too many failed attempts; you must wait a few minutes before trying again." +msgstr "失败尝试次数过多;您必须等待几分钟才能重试。" #: server/validators.py:31 msgid "Sorry, that username is reserved." -msgstr "" +msgstr "抱歉,该用户名已被保留。" #: server/validators.py:38 msgid "Sorry, that username is already taken." -msgstr "" +msgstr "抱歉,该用户名已被使用。" #: server/validators.py:88 -#, fuzzy, python-brace-format -#| msgid "" -#| "%s From a terminal client, you can also use a phrase of multiple words if " -#| "you enclose the password in double quotes." -msgid "" -"{policy} From a terminal client, you can also use a phrase of multiple words " -"if you enclose the password in double quotes." -msgstr "" -"(%s) 在命令行客户端中,您可以使用英文引号将输入内容扩起,来使用包含空格的词" -"组。" +msgid "{policy} From a terminal client, you can also use a phrase of multiple words if you enclose the password in double quotes." +msgstr "{policy} 在命令行客户端中,如果用双引号括起密码,也可以使用由多个单词组成的短语。" #: utils/eveditor.py:68 msgid "" @@ -649,23 +527,56 @@ msgid "" " :y - yank (copy) line(s) to the copy buffer\n" " :x - cut line(s) and store it in the copy buffer\n" " :p - put (paste) previously copied line(s) directly after \n" -" :i - insert new text at line . Old line will move " -"down\n" +" :i - insert new text at line . Old line will move down\n" " :r - replace line with text \n" " :I - insert text at the beginning of line \n" " :A - append text after the end of line \n" "\n" -" :s - search/replace word or regex in buffer or on line " -"\n" +" :s - search/replace word or regex in buffer or on line \n" "\n" -" :j - justify buffer or line . is f, c, l or r. Default f " -"(full)\n" +" :j - justify buffer or line . is f, c, l or r. Default f (full)\n" " :f - flood-fill entire buffer or line : Equivalent to :j left\n" " :fi - indent entire buffer or line \n" " :fd - de-indent entire buffer or line \n" "\n" " :echo - turn echoing of the input on/off (helpful for some clients)\n" msgstr "" +"\n" +" - 任何非命令都会附加到缓冲区的末尾。\n" +" : - 查看缓冲区或仅查看行\n" +" :: - 原始视图缓冲区或仅行数 \n" +" ::: - escape - 输入': '作为该行的唯一字符。\n" +" :h - 此帮助。\n" +"\n" +" :w - 保存缓冲区(不退出)\n" +" :wq - 保存缓冲区并退出\n" +" :q - 退出(如果缓冲区已更改,将要求保存)\n" +" :q! - 不保存退出,不问任何问题\n" +"\n" +" :u - (撤销)在撤销历史中后退一步\n" +" :uu - (重做)在撤销历史中向前移动一步\n" +" :UU - 将所有更改重置回初始状态\n" +"\n" +" :dd - 删除最后一行或几行 \n" +" :dw - 删除整个缓冲区或行 中的单词或 regex \n" +" :DD - 清除整个缓冲区\n" +"\n" +" :y - 将行拖入(复制)到复制缓冲区\n" +" :x - 剪切行 ,并将其存储在复制缓冲区中\n" +" :p - 将先前复制的行直接粘贴到 之后\n" +" :i - 在 行插入新文本 。旧行将下移\n" +" :r - 用文本 替换 行\n" +" :I - 在 行开头插入文本\n" +" :A - 在 行结束后附加文本\n" +"\n" +" :s - 在缓冲区或行 中搜索/替换单词或 regex \n" +"\n" +" :j - 对齐缓冲区或 行。 可以是 f、c、l 或 r。默认为 f(全选)\n" +" :f - 填充整个缓冲区或行 :等同于 :j left\n" +" :fi - 缩进整个缓冲区或行 \n" +" :fd - 取消整个缓冲区或行 的缩进\n" +"\n" +" :echo - 打开/关闭输入的回显(对某些客户端有帮助)\n" #: utils/eveditor.py:108 msgid "" @@ -675,6 +586,11 @@ msgid "" " - a single word, or multiple words with quotes around them.\n" " - longer string, usually not needing quotes.\n" msgstr "" +"\n" +"图例: \n" +" - 行号,如 '5' 或范围,如 '3:7'。 \n" +" - 单个单词,或多个单词,用引号括起来。 \n" +" - 较长的字符串,通常不需要引号。\n" #: utils/eveditor.py:117 msgid "" @@ -684,48 +600,61 @@ msgid "" " :> - Increase the level of automatic indentation for the next lines\n" " := - Switch automatic indentation on/off\n" msgstr "" +"\n" +":! - 执行代码缓冲区而不保存 \n" +":< - 降低下一行的自动缩进级别 \n" +":> - 增加下一行的自动缩进级别 \n" +":= - 打开/关闭自动缩进\n" #: utils/eveditor.py:128 -#, python-brace-format msgid "" "\n" "{error}\n" "\n" "|rBuffer load function error. Could not load initial data.|n\n" msgstr "" +"\n" +"{error} \n" +"\n" +"|rBuffer 加载函数错误。无法加载初始数据。|n\n" #: utils/eveditor.py:136 -#, python-brace-format msgid "" "\n" "{error}\n" "\n" "|rSave function returned an error. Buffer not saved.|n\n" msgstr "" +"\n" +"{error} \n" +"\n" +"|rSave 函数返回错误。缓冲区未保存。|n\n" #: utils/eveditor.py:143 msgid "|rNo save function defined. Buffer cannot be saved.|n" -msgstr "" +msgstr "|r未定义保存函数。无法保存至缓冲区。|n" #: utils/eveditor.py:145 msgid "No changes need saving" -msgstr "" +msgstr "无需保存任何更改" #: utils/eveditor.py:146 msgid "Exited editor." -msgstr "" +msgstr "退出编辑器。" #: utils/eveditor.py:149 -#, python-brace-format msgid "" "\n" "{error}\n" "\n" "|rQuit function gave an error. Skipping.|n\n" msgstr "" +"\n" +"{error} \n" +"\n" +"|r退出函数出错。已跳过。|n\n" #: utils/eveditor.py:157 -#, python-brace-format msgid "" "\n" "{error}\n" @@ -734,238 +663,209 @@ msgid "" "to non-persistent mode (which means the editor session won't survive\n" "an eventual server reload - so save often!)|n\n" msgstr "" +"\n" +"{error}\n" +"\n" +"|r编辑器状态无法保存为持久模式,切换到非持久模式。\n" +"(这意味着编辑器会话将无法在最终的服务器重载后继续存在\n" +"--所以要经常保存!)|n\n" #: utils/eveditor.py:167 -msgid "" -"EvEditor persistent-mode error. Commonly, this is because one or more of the " -"EvEditor callbacks could not be pickled, for example because it's a class " -"method or is defined inside another function." -msgstr "" +msgid "EvEditor persistent-mode error. Commonly, this is because one or more of the EvEditor callbacks could not be pickled, for example because it's a class method or is defined inside another function." +msgstr "EvEditor 持久模式错误。通常情况下,这是因为一个或多个 EvEditor 回调无法被拾取,例如因为它是一个类方法或定义在另一个函数内。" #: utils/eveditor.py:173 msgid "Nothing to undo." -msgstr "" +msgstr "沒有需要撤消的操作。" #: utils/eveditor.py:174 msgid "Nothing to redo." -msgstr "" +msgstr "沒有需要重做的操作。" #: utils/eveditor.py:175 msgid "Undid one step." -msgstr "" +msgstr "撤消了一个步骤。" #: utils/eveditor.py:176 msgid "Redid one step." -msgstr "" +msgstr "重做了一个步骤。" #: utils/eveditor.py:494 msgid "Single ':' added to buffer." -msgstr "" +msgstr "单个‘:’已添加到缓冲区。" #: utils/eveditor.py:509 msgid "Save before quitting?" -msgstr "" +msgstr "退出前要保存吗?" #: utils/eveditor.py:524 msgid "Reverted all changes to the buffer back to original state." -msgstr "" +msgstr "将缓冲区的所有更改恢复为原始状态。" #: utils/eveditor.py:529 -#, python-brace-format msgid "Deleted {string}." -msgstr "" +msgstr "已删除 {string}。" #: utils/eveditor.py:534 msgid "You must give a search word to delete." -msgstr "" +msgstr "您必须输入要删除的搜索词。" #: utils/eveditor.py:540 -#, python-brace-format msgid "Removed {arg1} for lines {l1}-{l2}." -msgstr "" +msgstr "删除了第 {l1}-{l2} 行的 {arg1}。" #: utils/eveditor.py:546 -#, python-brace-format msgid "Removed {arg1} for {line}." -msgstr "" +msgstr "已删除 {line} 的 {arg1}。" #: utils/eveditor.py:562 -#, python-brace-format msgid "Cleared {nlines} lines from buffer." -msgstr "" +msgstr "从缓冲区清除了 {nlines} 行。" #: utils/eveditor.py:567 -#, python-brace-format msgid "{line}, {cbuf} yanked." -msgstr "" +msgstr "{line}, {cbuf} 被删除。" #: utils/eveditor.py:574 -#, python-brace-format msgid "{line}, {cbuf} cut." -msgstr "" +msgstr "{line},{cbuf} 被剪切。" #: utils/eveditor.py:578 msgid "Copy buffer is empty." -msgstr "" +msgstr "复制缓冲区为空。" #: utils/eveditor.py:583 -#, python-brace-format msgid "Pasted buffer {cbuf} to {line}." -msgstr "" +msgstr "已将缓冲区 {cbuf} 粘贴至 {line}。" #: utils/eveditor.py:591 msgid "You need to enter a new line and where to insert it." -msgstr "" +msgstr "您需要输入新行以及插入位置。" #: utils/eveditor.py:596 -#, python-brace-format msgid "Inserted {num} new line(s) at {line}." -msgstr "" +msgstr "在 {line} 处插入 {num} 个新行。" #: utils/eveditor.py:604 msgid "You need to enter a replacement string." -msgstr "" +msgstr "您需要输入替换字符串。" #: utils/eveditor.py:609 -#, python-brace-format msgid "Replaced {num} line(s) at {line}." -msgstr "" +msgstr "替换了第 {line} 行中的 {num} 行。" #: utils/eveditor.py:616 msgid "You need to enter text to insert." -msgstr "" +msgstr "您需要输入要插入的文本。" #: utils/eveditor.py:624 -#, python-brace-format msgid "Inserted text at beginning of {line}." -msgstr "" +msgstr "在{line}开头插入文本。" #: utils/eveditor.py:628 msgid "You need to enter text to append." -msgstr "" +msgstr "您需要输入要附加的文本。" #: utils/eveditor.py:636 -#, python-brace-format msgid "Appended text to end of {line}." -msgstr "" +msgstr "将文本附加至{line}的末尾。" #: utils/eveditor.py:641 msgid "You must give a search word and something to replace it with." -msgstr "" +msgstr "您必须给出一个搜索词以及要替换它的词。" #: utils/eveditor.py:647 -#, python-brace-format msgid "Search-replaced {arg1} -> {arg2} for lines {l1}-{l2}." -msgstr "" +msgstr "搜索替换行 {l1}-{l2} 的 {arg1} -> {arg2}。" #: utils/eveditor.py:653 -#, python-brace-format msgid "Search-replaced {arg1} -> {arg2} for {line}." -msgstr "" +msgstr "搜索替换 {arg1} -> {arg2} 中的 {line}。" #: utils/eveditor.py:677 -#, python-brace-format msgid "Flood filled lines {l1}-{l2}." -msgstr "" +msgstr "填充行 {l1}-{l2}。" #: utils/eveditor.py:679 -#, python-brace-format msgid "Flood filled {line}." -msgstr "" +msgstr "填充了{line}。" #: utils/eveditor.py:701 msgid "Valid justifications are" -msgstr "" +msgstr "有效的理由如下" #: utils/eveditor.py:710 -#, python-brace-format msgid "{align}-justified lines {l1}-{l2}." -msgstr "" +msgstr "{align}-对齐行 {l1}-{l2}。" #: utils/eveditor.py:716 -#, python-brace-format msgid "{align}-justified {line}." -msgstr "" +msgstr "{align}-对齐{line}。" #: utils/eveditor.py:728 -#, python-brace-format msgid "Indented lines {l1}-{l2}." -msgstr "" +msgstr "缩进行 {l1}-{l2}。" #: utils/eveditor.py:730 -#, python-brace-format msgid "Indented {line}." -msgstr "" +msgstr "缩进{line}。" #: utils/eveditor.py:740 -#, python-brace-format msgid "Removed left margin (dedented) lines {l1}-{l2}." -msgstr "" +msgstr "删除行 {l1}-{l2}的左侧边距(dedented)。" #: utils/eveditor.py:745 -#, python-brace-format msgid "Removed left margin (dedented) {line}." -msgstr "" +msgstr "删除了左侧边距(dedented){line}。" #: utils/eveditor.py:753 -#, python-brace-format msgid "Echo mode set to {mode}" -msgstr "" +msgstr "回显模式设置为 {mode}" #: utils/eveditor.py:758 utils/eveditor.py:773 utils/eveditor.py:788 #: utils/eveditor.py:799 msgid "This command is only available in code editor mode." -msgstr "" +msgstr "此命令仅在代码编辑器模式下可用。" #: utils/eveditor.py:766 -#, python-brace-format msgid "Decreased indentation: new indentation is {indent}." -msgstr "" +msgstr "减少缩进:新缩进为{indent}。" #: utils/eveditor.py:771 utils/eveditor.py:786 msgid "|rManual indentation is OFF.|n Use := to turn it on." -msgstr "" +msgstr "|r手动缩进已关闭。|n 使用 := 将其打开。" #: utils/eveditor.py:781 -#, python-brace-format msgid "Increased indentation: new indentation is {indent}." -msgstr "" +msgstr "增加缩进:新缩进为 {indent}。" #: utils/eveditor.py:795 msgid "Auto-indentation turned on." -msgstr "" +msgstr "自动缩进已打开。" #: utils/eveditor.py:797 msgid "Auto-indentation turned off." -msgstr "" +msgstr "自动缩进已关闭。" #: utils/eveditor.py:1093 -#, python-brace-format msgid "Line Editor [{name}]" -msgstr "" +msgstr "行编辑器 [{name}]" #: utils/eveditor.py:1101 msgid "(:h for help)" -msgstr "" +msgstr "(:h 获取帮助)" #: utils/evmenu.py:302 -#, fuzzy, python-brace-format -#| msgid "" -#| "Menu node '{nodename}' is either not implemented or caused an error. Make " -#| "another choice." -msgid "" -"Menu node '{nodename}' is either not implemented or caused an error. Make " -"another choice or try 'q' to abort." -msgstr "菜单节点 '{nodename}' 未实现或发生错误。请尝试其他选项。" +msgid "Menu node '{nodename}' is either not implemented or caused an error. Make another choice or try 'q' to abort." +msgstr "菜单节点’{nodename}‘未实现或导致错误。请做出其他选择或尝试’q‘中止。" #: utils/evmenu.py:305 -#, python-brace-format msgid "Error in menu node '{nodename}'." msgstr "菜单节点 '{nodename}' 发生错误。" #: utils/evmenu.py:306 msgid "No description." -msgstr "无描述。" +msgstr "没有描述。" #: utils/evmenu.py:307 msgid "Commands: , help, quit" @@ -985,219 +885,168 @@ msgstr "命令: help" #: utils/evmenu.py:311 utils/evmenu.py:1850 msgid "Choose an option or try 'help'." -msgstr "" +msgstr "选择一个选项或尝试“帮助”。" #: utils/evmenu.py:1375 msgid "|rInvalid choice.|n" -msgstr "" +msgstr "|r无效的选择。|n" #: utils/evmenu.py:1439 msgid "|Wcurrent|n" -msgstr "" +msgstr "|W当前|n" #: utils/evmenu.py:1447 msgid "|wp|Wrevious page|n" -msgstr "" +msgstr "|wp|W上一页|n" #: utils/evmenu.py:1454 msgid "|wn|Wext page|n" -msgstr "" +msgstr "|wn|下一页|n" #: utils/evmenu.py:1689 msgid "Aborted." -msgstr "" +msgstr "已中止。" #: utils/evmenu.py:1712 msgid "|rError in ask_yes_no. Choice not confirmed (report to admin)|n" -msgstr "" +msgstr "|rask_yes_no 错误。选择尚未确认(请向管理员汇报)|n" #: utils/evmore.py:235 msgid "|xExited pager.|n" -msgstr "" +msgstr "|x退出呼叫器。|n" #: utils/optionhandler.py:138 utils/optionhandler.py:162 msgid "Option not found!" -msgstr "" +msgstr "未找到选项!" #: utils/optionhandler.py:159 msgid "Option field blank!" -msgstr "" +msgstr "选项字段空白!" #: utils/optionhandler.py:165 #, fuzzy -#| msgid "There were multiple matches." msgid "Multiple matches:" msgstr "发现多个匹配项。" #: utils/optionhandler.py:165 msgid "Please be more specific." -msgstr "" +msgstr "请输入更有指向性的名称。" #: utils/utils.py:2127 -#, python-brace-format -msgid "" -"{obj}.{handlername} is a handler and can't be set directly. To add values, " -"use `{obj}.{handlername}.add()` instead." -msgstr "" +msgid "{obj}.{handlername} is a handler and can't be set directly. To add values, use `{obj}.{handlername}.add()` instead." +msgstr "{obj}.{handlername} 是一个处理程序,不能直接设置。要添加值,请使用 `{obj}.{handlername}.add()`。" #: utils/utils.py:2137 -#, python-brace-format -msgid "" -"{obj}.{handlername} is a handler and can't be deleted directly. To remove " -"values, use `{obj}.{handlername}.remove()` instead." -msgstr "" +msgid "{obj}.{handlername} is a handler and can't be deleted directly. To remove values, use `{obj}.{handlername}.remove()` instead." +msgstr "{obj}.{handlername} 是一个处理程序,无法直接删除。要删除值,请使用 `{obj}.{handlername}.remove()`。" #: utils/utils.py:2278 -#, fuzzy, python-brace-format -#| msgid "Could not find '%s'." +#, fuzzy msgid "Could not find '{query}'." -msgstr "无法找到 '%s'" +msgstr "无法找到“{query}”。" #: utils/utils.py:2285 -#, fuzzy, python-brace-format -#| msgid "More than one match for '%s' (please narrow target):\n" +#, fuzzy msgid "More than one match for '{query}' (please narrow target):\n" -msgstr "发现多个符合 '%s' 的匹配项 (请缩小范围):\n" +msgstr "‘{query}’ 有多个匹配项(请缩小目标):\n" #: utils/validatorfuncs.py:25 -#, python-brace-format msgid "Input could not be converted to text ({err})" -msgstr "" +msgstr "输入无法转换为文本({err})" #: utils/validatorfuncs.py:34 -#, python-brace-format msgid "Nothing entered for a {option_key}!" -msgstr "" +msgstr "{option_key} 未输入任何内容!" #: utils/validatorfuncs.py:38 -#, python-brace-format msgid "'{entry}' is not a valid {option_key}." -msgstr "" +msgstr "'{entry}' 不是有效的 {option_key}。" #: utils/validatorfuncs.py:63 utils/validatorfuncs.py:236 -#, python-brace-format msgid "No {option_key} entered!" -msgstr "" +msgstr "未输入{option_key}!" #: utils/validatorfuncs.py:72 -#, python-brace-format msgid "Timezone string '{acct_tz}' is not a valid timezone ({err})" -msgstr "" +msgstr "时区字符串“{acct_tz}”不是有效的时区({err})" #: utils/validatorfuncs.py:89 utils/validatorfuncs.py:97 -#, python-brace-format msgid "{option_key} must be entered in a 24-hour format such as: {timeformat}" -msgstr "" +msgstr "{option_key} 必须采用 24 小时格式输入,例如:{timeformat}" #: utils/validatorfuncs.py:141 -#, python-brace-format msgid "Could not convert section '{interval}' to a {option_key}." -msgstr "" +msgstr "无法将’{interval}‘部分转换为{option_key}。" #: utils/validatorfuncs.py:153 -#, python-brace-format msgid "That {option_key} is in the past! Must give a Future datetime!" -msgstr "" +msgstr "{option_key}是过去时!必须给出一个未来日期时间!" #: utils/validatorfuncs.py:163 -#, python-brace-format msgid "Must enter a whole number for {option_key}!" -msgstr "" +msgstr "必须为{option_key}输入一个整数!" #: utils/validatorfuncs.py:169 -#, python-brace-format msgid "Could not convert '{entry}' to a whole number for {option_key}!" -msgstr "" +msgstr "无法将“{entry}”转换为{option_key}的整数!" #: utils/validatorfuncs.py:180 -#, python-brace-format msgid "Must enter a whole number greater than 0 for {option_key}!" -msgstr "" +msgstr "必须为 {option_key} 输入一个大于 0 的整数!" #: utils/validatorfuncs.py:191 -#, python-brace-format msgid "{option_key} must be a whole number greater than or equal to 0!" -msgstr "" +msgstr "{option_key} 必须是大于或等于 0 的整数!" #: utils/validatorfuncs.py:210 -#, python-brace-format msgid "Must enter a true/false input for {option_key}. Accepts {alternatives}." -msgstr "" +msgstr "必须为 {option_key} 输入真/假值。接受 {alternatives}。" #: utils/validatorfuncs.py:240 -#, python-brace-format msgid "That matched: {matches}. Please be more specific!" -msgstr "" +msgstr "匹配项:{matches}。请更具体一些!" #: utils/validatorfuncs.py:247 -#, python-brace-format msgid "Could not find timezone '{entry}' for {option_key}!" -msgstr "" +msgstr "无法找到 {option_key} 的时区“{entry}”!" #: utils/validatorfuncs.py:255 msgid "Email address field empty!" -msgstr "" +msgstr "电子邮件地址字段为空!" #: utils/validatorfuncs.py:258 -#, python-brace-format msgid "That isn't a valid {option_key}!" -msgstr "" +msgstr "这不是一个有效的{option_key}!" #: utils/validatorfuncs.py:265 -#, python-brace-format msgid "No {option_key} entered to set!" -msgstr "" +msgstr "未输入要设置的{option_key}!" #: utils/validatorfuncs.py:269 msgid "Must enter an access type!" -msgstr "" +msgstr "必须输入访问类型!" #: utils/validatorfuncs.py:273 -#, python-brace-format msgid "Access type must be one of: {alternatives}" -msgstr "" +msgstr "访问类型必须是其中之一:{alternatives}" #: utils/validatorfuncs.py:278 msgid "Lock func not entered." -msgstr "" +msgstr "未输入锁定函数。" #: web/templates/admin/app_list.html:19 msgid "Add" -msgstr "" +msgstr "添加" #: web/templates/admin/app_list.html:26 msgid "View" -msgstr "" +msgstr "视图" #: web/templates/admin/app_list.html:28 msgid "Change" -msgstr "" +msgstr "更改" #: web/templates/admin/app_list.html:39 msgid "You don’t have permission to view or edit anything." -msgstr "" +msgstr "您无权查看或编辑任何内容。" -#~ msgid " : {current}" -#~ msgstr "<合并 {mergelist} {mergetype},优先级 {prio}>: {current}" - -#~ msgid "" -#~ " <{key} ({mergetype}, prio {prio}, {permstring})>:\n" -#~ " {keylist}" -#~ msgstr "" -#~ " <{key} ({mergetype}, 优先级 {prio}, {permstring})>:\n" -#~ " {keylist}" - -#~ msgid "Say what?" -#~ msgstr "您想说?" - -#~ msgid "Channel '%s' not found." -#~ msgstr "未找到频道 '%s' 。" - -#~ msgid "You are not connected to channel '%s'." -#~ msgstr "未连接至频道 '%s' 。" - -#~ msgid "You are not permitted to send to channel '%s'." -#~ msgstr "您未被允许在频道 '%s' 发送信息。" - -#~ msgid " (channel)" -#~ msgstr " (频道)" diff --git a/evennia/objects/manager.py b/evennia/objects/manager.py index f962d7e55a..2982daa1d7 100644 --- a/evennia/objects/manager.py +++ b/evennia/objects/manager.py @@ -281,8 +281,7 @@ class ObjectDBManager(TypedObjectManager): Args: ostring (str): A search criterion. exact (bool, optional): Require exact match of ostring - (still case-insensitive). If `False`, will do fuzzy matching - using `evennia.utils.utils.string_partial_matching` algorithm. + (still case-insensitive). If `False`, will do fuzzy matching with a regex filter. candidates (list): Only match among these candidates. typeclasses (list): Only match objects with typeclasses having thess path strings. @@ -305,7 +304,7 @@ class ObjectDBManager(TypedObjectManager): cand_restriction = candidates is not None and Q(pk__in=candidates_id) or Q() type_restriction = typeclasses and Q(db_typeclass_path__in=make_iter(typeclasses)) or Q() if exact: - # exact match - do direct search + # exact matches only return ( ( self.filter( @@ -321,50 +320,26 @@ class ObjectDBManager(TypedObjectManager): .distinct() .order_by("id") ) - elif candidates: - # fuzzy with candidates - search_candidates = ( - self.filter(cand_restriction & type_restriction).distinct().order_by("id") - ) - else: - # fuzzy without supplied candidates - we select our own candidates - search_candidates = ( - self.filter( - type_restriction - & (Q(db_key__icontains=ostring) | Q(db_tags__db_key__icontains=ostring)) - ) - .distinct() - .order_by("id") - ) - # fuzzy matching - key_strings = search_candidates.values_list("db_key", flat=True).order_by("id") - match_ids = [] - index_matches = string_partial_matching(key_strings, ostring, ret_index=True) - if index_matches: - # a match by key - match_ids = [ - obj.id for ind, obj in enumerate(search_candidates) if ind in index_matches - ] - else: - # match by alias rather than by key - search_candidates = search_candidates.filter( - db_tags__db_tagtype__iexact="alias", db_tags__db_key__icontains=ostring - ).distinct() - alias_strings = [] - alias_candidates = [] - # TODO create the alias_strings and alias_candidates lists more efficiently? - for candidate in search_candidates: - for alias in candidate.aliases.all(): - alias_strings.append(alias) - alias_candidates.append(candidate) - index_matches = string_partial_matching(alias_strings, ostring, ret_index=True) - if index_matches: - # it's possible to have multiple matches to the same Object, we must weed those out - match_ids = [alias_candidates[ind].id for ind in index_matches] - # TODO - not ideal to have to do a second lookup here, but we want to return a queryset - # rather than a list ... maybe the above queries can be improved. - return self.filter(id__in=match_ids) + # convert search term to partial-match regex + search_regex = r".* ".join(r"\b" + re.escape(word) for word in ostring.split()) + r".*" + + # do the fuzzy search and return whatever it matches + return ( + ( + self.filter( + cand_restriction + & type_restriction + & ( + Q(db_key__iregex=search_regex) + | Q(db_tags__db_key__iregex=search_regex) + & Q(db_tags__db_tagtype__iexact="alias") + ) + ) + ) + .distinct() + .order_by("id") + ) # main search methods and helper functions @@ -380,14 +355,13 @@ class ObjectDBManager(TypedObjectManager): ): """ Search as an object globally or in a list of candidates and - return results. The result is always an Object. Always returns - a list. + return results. Always returns a QuerySet of Objects. Args: searchdata (str or Object): The entity to match for. This is usually a key string but may also be an object itself. By default (if no `attribute_name` is set), this will - search `object.key` and `object.aliases` in order. + search `object.key` and `object.aliases`. Can also be on the form #dbref, which will (if `exact=True`) be matched against primary key. attribute_name (str): Use this named Attribute to @@ -417,63 +391,43 @@ class ObjectDBManager(TypedObjectManager): a match. Returns: - matches (list): Matching objects + matches (QuerySet): Matching objects """ def _searcher(searchdata, candidates, typeclass, exact=False): """ - Helper method for searching objects. `typeclass` is only used - for global searching (no candidates) + Helper method for searching objects. """ if attribute_name: # attribute/property search (always exact). matches = self.get_objs_with_db_property_value( attribute_name, searchdata, candidates=candidates, typeclasses=typeclass ) - if matches: - return matches - return self.get_objs_with_attr_value( - attribute_name, searchdata, candidates=candidates, typeclasses=typeclass - ) + if not matches: + matches = self.get_objs_with_attr_value( + attribute_name, searchdata, candidates=candidates, typeclasses=typeclass + ) else: # normal key/alias search - return self.get_objs_with_key_or_alias( + matches = self.get_objs_with_key_or_alias( searchdata, exact=exact, candidates=candidates, typeclasses=typeclass ) + if matches and tags: + # additionally filter matches by tags + for tagkey, tagcategory in tags: + matches = matches.filter( + db_tags__db_key=tagkey, db_tags__db_category=tagcategory + ) - def _search_by_tag(query, taglist): - for tagkey, tagcategory in taglist: - query = query.filter(db_tags__db_key=tagkey, db_tags__db_category=tagcategory) - - return query - - if not searchdata and searchdata != 0: - if tags: - return _search_by_tag(self.all(), make_iter(tags)) - - return self.none() - - if typeclass: - # typeclass may also be a list - typeclasses = make_iter(typeclass) - for i, typeclass in enumerate(make_iter(typeclasses)): - if callable(typeclass): - typeclasses[i] = "%s.%s" % (typeclass.__module__, typeclass.__name__) - else: - typeclasses[i] = "%s" % typeclass - typeclass = typeclasses + return matches if candidates is not None: if not candidates: - # candidates is the empty list. This should mean no matches can ever be acquired. - return [] + # candidates is an empty list. This should mean no matches can ever be acquired. + return self.none() # Convenience check to make sure candidates are really dbobjs candidates = [cand for cand in make_iter(candidates) if cand] - if typeclass: - candidates = [ - cand for cand in candidates if _GA(cand, "db_typeclass_path") in typeclass - ] dbref = not attribute_name and exact and use_dbref and self.dbref(searchdata) if dbref: @@ -486,51 +440,54 @@ class ObjectDBManager(TypedObjectManager): else: return self.none() + if typeclass: + # typeclass may be a string, a typeclass, or a list + typeclasses = make_iter(typeclass) + for i, typeclass in enumerate(make_iter(typeclasses)): + if callable(typeclass): + typeclasses[i] = "%s.%s" % (typeclass.__module__, typeclass.__name__) + else: + typeclasses[i] = "%s" % typeclass + typeclass = typeclasses + # Search through all possibilities. match_number = None # always run first check exact - we don't want partial matches # if on the form of 1-keyword etc. matches = _searcher(searchdata, candidates, typeclass, exact=True) + stripped_searchdata = searchdata if not matches: # no matches found - check if we are dealing with N-keyword # query - if so, strip it. - match = _MULTIMATCH_REGEX.match(str(searchdata)) + match_data = _MULTIMATCH_REGEX.match(str(searchdata)) match_number = None - stripped_searchdata = searchdata - if match: + if match_data: # strips the number - match_number, stripped_searchdata = match.group("number"), match.group("name") + match_number, stripped_searchdata = match_data.group("number"), match_data.group( + "name" + ) match_number = int(match_number) - 1 if match_number is not None: # run search against the stripped data matches = _searcher(stripped_searchdata, candidates, typeclass, exact=True) - if not matches: - # final chance to get a looser match against the number-strippped query - matches = _searcher(stripped_searchdata, candidates, typeclass, exact=False) - elif not exact: - matches = _searcher(searchdata, candidates, typeclass, exact=False) - if tags: - matches = _search_by_tag(matches, make_iter(tags)) + # at this point, if there are no matches, we give it a chance to find fuzzy matches + if not exact and not matches: + # we use stripped_searchdata in case a match number was included + matches = _searcher(stripped_searchdata, candidates, typeclass, exact=False) # deal with result - if len(matches) == 1 and match_number is not None and match_number != 0: - # this indicates trying to get a single match with a match-number - # targeting some higher-number match (like 2-box when there is only - # one box in the room). This leads to a no-match. - matches = self.none() - elif len(matches) > 1 and match_number is not None: - # multiple matches, but a number was given to separate them + if match_number is not None: if 0 <= match_number < len(matches): # limit to one match (we still want a queryset back) - # TODO: Can we do this some other way and avoid a second lookup? + # NOTE: still haven't found a way to avoid a second lookup matches = self.filter(id=matches[match_number].id) else: # a number was given outside of range. This means a no-match. matches = self.none() - # return a list (possibly empty) + # return a QuerySet (possibly empty) return matches # alias for backwards compatibility diff --git a/evennia/objects/models.py b/evennia/objects/models.py index 544f33fd2d..24286de0c0 100644 --- a/evennia/objects/models.py +++ b/evennia/objects/models.py @@ -20,6 +20,7 @@ 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 @@ -68,6 +69,7 @@ class ContentsHandler: """ objects = self.load() + self._typecache = defaultdict(dict) self._pkcache = {obj.pk: True for obj in objects} for obj in objects: try: diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 82e087bb4a..7643fec2b2 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -10,10 +10,11 @@ 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 @@ -231,6 +232,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): has_account (bool, read-only) - True is this object has an associated account. is_superuser (bool, read-only): True if this object has an account and that account is a superuser. + plural_category (string) - Alias category for the plural strings of this object * Handlers available @@ -304,11 +306,14 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): at_object_creation() - only called once, when object is first created. Object customizations go here. + at_object_post_creation() - only called once, when object is first created. + Additional setup involving e.g. prototype-set attributes can go here. at_object_delete() - called just before deleting an object. If returning False, deletion is aborted. Note that all objects inside a deleted object are automatically moved to their , they don't need to be removed here. - + at_object_post_spawn() - called when object is spawned from a prototype or updated + by the spawner to apply prototype changes. at_init() - called whenever typeclass is cached from memory, at least once every server restart/reload at_first_save() @@ -379,6 +384,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): at_look(target, **kwargs) at_desc(looker=None) + at_rename(oldname, newname) """ @@ -394,6 +400,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): objects = ObjectManager() + # Used by get_display_desc when self.db.desc is None + default_description = _("You see nothing special.") + # populated by `return_appearance` appearance_template = """ {header} @@ -404,6 +413,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): {things} {footer} """ + + plural_category = "plural_key" # on-object properties @lazy_property @@ -542,11 +553,13 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ if isinstance(searchdata, str): + candidates = kwargs.get("candidates") or [] + global_search = kwargs.get("global_search", False) match searchdata.lower(): case "me" | "self": - return True, self + return global_search or self in candidates, self case "here": - return True, self.location + return global_search or self.location in candidates, self.location return False, searchdata def get_search_candidates(self, searchdata, **kwargs): @@ -826,8 +839,14 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # replace incoming searchdata string with a potentially modified version searchdata = self.get_search_query_replacement(searchdata, **input_kwargs) + # get candidates + candidates = self.get_search_candidates(searchdata, **input_kwargs) + # handle special input strings, like "me" or "here". - should_return, searchdata = self.get_search_direct_match(searchdata, **input_kwargs) + # we also want to include the identified candidates here instead of input, to account for defaults + should_return, searchdata = self.get_search_direct_match( + searchdata, **(input_kwargs | {"candidates": candidates}) + ) if should_return: # we got an actual result, return it immediately return [searchdata] if quiet else searchdata @@ -847,9 +866,6 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # always use exact match for dbref/global searches exact = True if global_search or dbref(searchdata) else exact - # get candidates - candidates = self.get_search_candidates(searchdata, **input_kwargs) - # do the actual search results = self.get_search_result( searchdata, @@ -1367,13 +1383,13 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): if obj.has_account: if home: - string = "Your current location has ceased to exist," - string += " moving you to (#{dbid})." - obj.msg(_(string).format(dbid=home.dbid)) + string = _( + "Your current location has ceased to exist, moving you to (#{dbid})." + ) + obj.msg(string.format(dbid=home.dbid)) else: # Famous last words: The account should never see this. - string = "This place should not exist ... contact an admin." - obj.msg(_(string)) + obj.msg(_("This place should not exist ... contact an admin.")) obj.move_to(home, move_type="teleport") @classmethod @@ -1461,14 +1477,13 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): if account: obj.db.creator_id = account.id - # Set description if there is none, or update it if provided - if description or not obj.db.desc: - desc = description if description else "You see nothing special." - obj.db.desc = desc + # Set description if provided + if description: + obj.db.desc = description except Exception as e: - errors.append(f"An error occurred while creating this '{key}' object: {e}") - logger.log_err(e) + errors.append(f"An error occurred while creating '{key}' object: {e}") + logger.log_trace() return obj, errors @@ -1690,7 +1705,6 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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)) raw_key = self.name key = ansi.ANSIString(key) # this is needed to allow inflection of colored names @@ -1701,13 +1715,13 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # this is raised by inflect if the input is not a proper noun plural = key singular = _INFLECT.an(key) - if not self.aliases.get(plural, category=plural_category): + if not self.aliases.get(plural, category=self.plural_category): # we need to wipe any old plurals/an/a in case key changed in the interrim - self.aliases.clear(category=plural_category) - self.aliases.add(plural, category=plural_category) + self.aliases.clear(category=self.plural_category) + self.aliases.add(plural, category=self.plural_category) # save the singular form as an alias here too so we can display "an egg" and also # look at 'an egg'. - self.aliases.add(singular, category=plural_category) + self.aliases.add(singular, category=self.plural_category) if kwargs.get("no_article") and count == 1: if kwargs.get("return_string"): @@ -1743,7 +1757,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): str: The desc display string. """ - return self.db.desc or "You see nothing special." + return self.db.desc or self.default_description def get_display_exits(self, looker, **kwargs): """ @@ -1780,9 +1794,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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 "" + exit_names = iter_to_str(_sort_exit_names(exit_names), endsep=_(", and")) + e = _("Exits") + return f"|w{e}:|n {exit_names}" if exit_names else "" def get_display_characters(self, looker, **kwargs): """ @@ -1799,10 +1813,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): self.contents_get(content_type="character"), looker, **kwargs ) character_names = iter_to_str( - char.get_display_name(looker, **kwargs) for char in characters + (char.get_display_name(looker, **kwargs) for char in characters), endsep=_(", and") ) - - return f"|wCharacters:|n {character_names}" if character_names else "" + c = _("Characters") + return f"|w{c}:|n {character_names}" if character_names else "" def get_display_things(self, looker, **kwargs): """ @@ -1828,8 +1842,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): thing = thinglist[0] singular, plural = thing.get_numbered_name(nthings, looker, key=thingname) thing_names.append(singular if nthings == 1 else plural) - thing_names = iter_to_str(thing_names) - return f"|wYou see:|n {thing_names}" if thing_names else "" + thing_names = iter_to_str(thing_names, endsep=_(", and")) + s = _("You see") + return f"|w{s}:|n {thing_names}" if thing_names else "" def get_display_footer(self, looker, **kwargs): """ @@ -1924,9 +1939,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): self.init_evennia_properties() if hasattr(self, "_createdict"): - # this will only be set if the utils.create function - # was used to create the object. We want the create - # call's kwargs to override the values set by hooks. + # this will be set if the object was created by the utils.create function + # or the spawner. We want these kwargs to override the values set by + # the initial hooks. cdict = self._createdict updates = [] if not cdict.get("key"): @@ -1970,6 +1985,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): del self._createdict + # run the post-setup hook + self.at_object_post_creation() + self.basetype_posthook_setup() # hooks called by the game engine # @@ -2027,6 +2045,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ pass + def at_object_post_creation(self): + """ + Called once, when this object is first created and after any attributes, tags, etc. + that were passed to the `create_object` function or defined in a prototype have been + applied. + + """ + pass + def at_object_delete(self): """ Called just before the database object is persistently @@ -2036,6 +2063,16 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ return True + def at_object_post_spawn(self, prototype=None): + """ + Called when this object is spawned or updated from a prototype, after all other + hooks have been run. + + Keyword Args: + prototype (dict): The prototype that was used to spawn or update this object. + """ + pass + def at_init(self): """ This is always called whenever this object is initiated -- @@ -2106,7 +2143,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): puppeting this Object. """ - self.msg(f"You become |w{self.key}|n.") + self.msg(_("You become |w{key}|n.").format(key=self.key)) self.account.db._last_puppet = self def at_pre_unpuppet(self, **kwargs): @@ -2285,7 +2322,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): if msg: string = msg else: - string = "{object} is leaving {origin}, heading for {destination}." + string = _("{object} is leaving {origin}, heading for {destination}.") location = self.location exits = [ @@ -2297,9 +2334,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): mapping.update( { "object": self, - "exit": exits[0] if exits else "somewhere", - "origin": location or "nowhere", - "destination": destination or "nowhere", + "exit": exits[0] if exits else _("somewhere"), + "origin": location or _("nowhere"), + "destination": destination or _("nowhere"), } ) @@ -2370,9 +2407,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): mapping.update( { "object": self, - "exit": exits[0] if exits else "somewhere", - "origin": origin or "nowhere", - "destination": destination or "nowhere", + "exit": exits[0] if exits else _("somewhere"), + "origin": origin or _("nowhere"), + "destination": destination or _("nowhere"), } ) @@ -2636,9 +2673,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ if not target.access(self, "view"): try: - return "Could not view '%s'." % target.get_display_name(self, **kwargs) + return _("Could not view '{target_name}'.").format( + target_name=target.get_display_name(self, **kwargs) + ) except AttributeError: - return "Could not view '%s'." % target.key + return _("Could not view '{target_name}'.").format(target_name=target.key) description = target.return_appearance(self, **kwargs) @@ -2763,7 +2802,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # TODO: This if-statment will be removed in Evennia 1.0 return True if not self.access(dropper, "drop", default=False): - dropper.msg(f"You cannot drop {self.get_display_name(dropper)}") + dropper.msg(_("You cannot drop {obj}").format(obj=self.get_display_name(dropper))) return False return True @@ -2875,15 +2914,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # whisper mode msg_type = "whisper" msg_self = ( - '{self} whisper to {all_receivers}, "|n{speech}|n"' + _('{self} whisper to {all_receivers}, "|n{speech}|n"') if msg_self is True else msg_self ) - msg_receivers = msg_receivers or '{object} whispers: "|n{speech}|n"' + msg_receivers = msg_receivers or _('{object} whispers: "|n{speech}|n"') msg_location = None else: - msg_self = '{self} say, "|n{speech}|n"' if msg_self is True else msg_self - msg_location = msg_location or '{object} says, "{speech}"' + msg_self = _('{self} say, "|n{speech}|n"') if msg_self is True else msg_self + msg_location = msg_location or _('{object} says, "{speech}"') msg_receivers = msg_receivers or message custom_mapping = kwargs.get("mapping", {}) @@ -2892,7 +2931,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): if msg_self: self_mapping = { - "self": "You", + "self": _("You"), "object": self.get_display_name(self), "location": location.get_display_name(self) if location else None, "receiver": None, @@ -2908,7 +2947,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): if receivers and msg_receivers: receiver_mapping = { - "self": "You", + "self": _("You"), "object": None, "location": None, "receiver": None, @@ -2935,7 +2974,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): if self.location and msg_location: location_mapping = { - "self": "You", + "self": _("You"), "object": self, "location": location, "all_receivers": ", ".join(str(recv) for recv in receivers) if receivers else None, @@ -2955,6 +2994,19 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): mapping=location_mapping, ) + def at_rename(self, oldname, newname): + """ + This Hook is called by @name on a successful rename. + + Args: + oldname (str): The instance's original name. + newname (str): The new name for the instance. + + """ + + # Clear plural aliases set by DefaultObject.get_numbered_name + self.aliases.clear(category=self.plural_category) + # # Base Character object @@ -2979,6 +3031,9 @@ class DefaultCharacter(DefaultObject): "edit:pid({account_id}) or perm(Admin)" ) + # Used by get_display_desc when self.db.desc is None + default_description = _("This is a character.") + @classmethod def get_default_lockstring( cls, account: "DefaultAccount" = None, caller: "DefaultObject" = None, **kwargs @@ -3092,13 +3147,13 @@ class DefaultCharacter(DefaultObject): if locks: obj.locks.add(locks) - # If no description is set, set a default description - if description or not obj.db.desc: - obj.db.desc = description if description else _("This is a character.") + # Set description if provided + if description: + obj.db.desc = description except Exception as e: - errors.append(f"An error occurred while creating object '{key} object: {e}") - logger.log_err(e) + errors.append(f"An error occurred while creating '{key}' object: {e}") + logger.log_trace() return obj, errors @@ -3144,7 +3199,7 @@ class DefaultCharacter(DefaultObject): """ if account and cls.objects.filter_family(db_key__iexact=name): - return f"|rA character named '|w{name}|r' already exists.|n" + return _("|rA character named '|w{name}|r' already exists.|n").format(name=name) def basetype_setup(self): """ @@ -3305,6 +3360,9 @@ class DefaultRoom(DefaultObject): # Generally, a room isn't expected to HAVE a location, but maybe in some games? _content_types = ("room",) + # Used by get_display_desc when self.db.desc is None + default_description = _("This is a room.") + @classmethod def create( cls, @@ -3375,13 +3433,13 @@ class DefaultRoom(DefaultObject): if account: obj.db.creator_id = account.id - # If no description is set, set a default description - if description or not obj.db.desc: - obj.db.desc = description if description else _("This is a room.") + # Set description if provided + if description: + obj.db.desc = description except Exception as e: - errors.append(f"An error occurred while creating this '{key}' object: {e}") - logger.log_err(e) + errors.append(f"An error occurred while creating '{key}' object: {e}") + logger.log_trace() return obj, errors @@ -3445,7 +3503,9 @@ class ExitCommand(_COMMAND_DEFAULT_CLASS): """ if self.obj.destination: - return " (exit to %s)" % self.obj.destination.get_display_name(caller, **kwargs) + return _(" (exit to {destination})").format( + destination=self.obj.destination.get_display_name(caller, **kwargs) + ) else: return " (%s)" % self.obj.get_display_name(caller, **kwargs) @@ -3470,6 +3530,9 @@ class DefaultExit(DefaultObject): exit_command = ExitCommand priority = 101 + # Used by get_display_desc when self.db.desc is None + default_description = _("This is an exit.") + # Helper classes and methods to implement the Exit. These need not # be overloaded unless one want to change the foundation for how # Exits work. See the end of the class for hook methods to overload. @@ -3584,9 +3647,9 @@ class DefaultExit(DefaultObject): if account: obj.db.creator_id = account.id - # If no description is set, set a default description - if description or not obj.db.desc: - obj.db.desc = description if description else _("This is an exit.") + # Set description if provided + if description: + obj.db.desc = description except Exception as e: errors.append(f"An error occurred while creating this '{key}' object: {e}") diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index df00189d50..3d5997127d 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -1,7 +1,12 @@ from unittest import skip -from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom from evennia.objects.models import ObjectDB +from evennia.objects.objects import ( + DefaultCharacter, + DefaultExit, + DefaultObject, + DefaultRoom, +) from evennia.typeclasses.attributes import AttributeProperty from evennia.typeclasses.tags import ( AliasProperty, @@ -30,6 +35,13 @@ class DefaultObjectTest(BaseEvenniaTest): self.assertEqual(obj.db.creator_ip, self.ip) self.assertEqual(obj.db_home, self.room1) + def test_object_default_description(self): + obj, errors = DefaultObject.create("void") + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertIsNone(obj.db.desc) + self.assertEqual(obj.default_description, obj.get_display_desc(obj)) + def test_character_create(self): description = "A furry green monster, reeking of garbage." home = self.room1.dbref @@ -57,6 +69,13 @@ class DefaultObjectTest(BaseEvenniaTest): self.assertFalse(errors, errors) self.assertEqual(obj.name, "SigurXurXorarinsson") + def test_character_default_description(self): + obj, errors = DefaultCharacter.create("dementor") + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertIsNone(obj.db.desc) + self.assertEqual(obj.default_description, obj.get_display_desc(obj)) + def test_room_create(self): description = "A dimly-lit alley behind the local Chinese restaurant." obj, errors = DefaultRoom.create("alley", self.account, description=description, ip=self.ip) @@ -65,6 +84,13 @@ class DefaultObjectTest(BaseEvenniaTest): self.assertEqual(description, obj.db.desc) self.assertEqual(obj.db.creator_ip, self.ip) + def test_room_default_description(self): + obj, errors = DefaultRoom.create("black hole") + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertIsNone(obj.db.desc) + self.assertEqual(obj.default_description, obj.get_display_desc(obj)) + def test_exit_create(self): description = ( "The steaming depths of the dumpster, ripe with refuse in various states of" @@ -78,6 +104,13 @@ class DefaultObjectTest(BaseEvenniaTest): self.assertEqual(description, obj.db.desc) self.assertEqual(obj.db.creator_ip, self.ip) + def test_exit_default_description(self): + obj, errors = DefaultExit.create("the nothing") + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertIsNone(obj.db.desc) + self.assertEqual(obj.default_description, obj.get_display_desc(obj)) + def test_exit_get_return_exit(self): ex1, _ = DefaultExit.create("north", self.room1, self.room2, account=self.account) single_return_exit = ex1.get_return_exit() @@ -247,6 +280,74 @@ class TestObjectManager(BaseEvenniaTest): ) self.assertEqual(list(query), [self.char1]) + def test_get_objs_with_key_or_alias(self): + query = ObjectDB.objects.get_objs_with_key_or_alias("Char") + self.assertEqual(list(query), [self.char1]) + query = ObjectDB.objects.get_objs_with_key_or_alias( + "Char", typeclasses="evennia.objects.objects.DefaultObject" + ) + self.assertEqual(list(query), []) + query = ObjectDB.objects.get_objs_with_key_or_alias( + "Char", candidates=[self.char1, self.char2] + ) + self.assertEqual(list(query), [self.char1]) + + self.char1.aliases.add("test alias") + query = ObjectDB.objects.get_objs_with_key_or_alias("test alias") + self.assertEqual(list(query), [self.char1]) + + query = ObjectDB.objects.get_objs_with_key_or_alias("") + self.assertFalse(query) + query = ObjectDB.objects.get_objs_with_key_or_alias("", exact=False) + self.assertEqual(list(query), list(ObjectDB.objects.all().order_by("id"))) + + query = ObjectDB.objects.get_objs_with_key_or_alias( + "", exact=False, typeclasses="evennia.objects.objects.DefaultCharacter" + ) + self.assertEqual(list(query), [self.char1, self.char2]) + + def test_key_alias_search_partial_match(self): + """ + verify that get_objs_with_key_or_alias will partial match the first part of + any words in the name, when given in the correct order + """ + self.obj1.key = "big sword" + self.obj2.key = "shiny sword" + + # beginning of "sword", should match both + query = ObjectDB.objects.get_objs_with_key_or_alias("sw", exact=False) + self.assertEqual(list(query), [self.obj1, self.obj2]) + + # middle of "sword", should NOT match + query = ObjectDB.objects.get_objs_with_key_or_alias("wor", exact=False) + self.assertEqual(list(query), []) + + # beginning of "big" then "sword", should match obj1 + query = ObjectDB.objects.get_objs_with_key_or_alias("b sw", exact=False) + self.assertEqual(list(query), [self.obj1]) + + # beginning of "sword" then "big", should NOT match + query = ObjectDB.objects.get_objs_with_key_or_alias("sw b", exact=False) + self.assertEqual(list(query), []) + + def test_search_object(self): + self.char1.tags.add("test tag") + self.obj1.tags.add("test tag") + + query = ObjectDB.objects.search_object("", exact=False, tags=[("test tag", None)]) + self.assertEqual(list(query), [self.obj1, self.char1]) + + query = ObjectDB.objects.search_object("Char", tags=[("invalid tag", None)]) + self.assertFalse(query) + + query = ObjectDB.objects.search_object( + "", + exact=False, + tags=[("test tag", None)], + typeclass="evennia.objects.objects.DefaultCharacter", + ) + self.assertEqual(list(query), [self.char1]) + def test_get_objs_with_attr(self): self.obj1.db.testattr = "testval1" query = ObjectDB.objects.get_objs_with_attr("testattr") diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 569179817f..c369e4fa89 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -12,6 +12,7 @@ from django.conf import settings from django.core.paginator import Paginator from django.db.models import Q from django.utils.translation import gettext as _ + from evennia.locks.lockhandler import check_lockstring, validate_lockstring from evennia.objects.models import ObjectDB from evennia.scripts.scripts import DefaultScript diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 3dfad73b31..5e8897b6dd 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -803,6 +803,8 @@ def batch_update_objects_with_prototype( if do_save: changed += 1 obj.save() + if spawn_hook := getattr(obj, "at_object_post_spawn", None): + spawn_hook(prototype=prototype) return changed @@ -869,6 +871,9 @@ def batch_create_object(*objparams): for code in objparam[7]: if code: exec(code, {}, {"evennia": evennia, "obj": obj}) + # run the spawned hook + if spawn_hook := getattr(obj, "at_object_post_spawn", None): + spawn_hook() objs.append(obj) return objs diff --git a/evennia/scripts/models.py b/evennia/scripts/models.py index e2f98dea4c..592df28d28 100644 --- a/evennia/scripts/models.py +++ b/evennia/scripts/models.py @@ -126,6 +126,7 @@ class ScriptDB(TypedObject): class Meta(object): "Define Django meta options" + verbose_name = "Script" # diff --git a/evennia/scripts/ondemandhandler.py b/evennia/scripts/ondemandhandler.py index a86eb91016..c7191d501f 100644 --- a/evennia/scripts/ondemandhandler.py +++ b/evennia/scripts/ondemandhandler.py @@ -398,6 +398,10 @@ class OnDemandHandler: Save the on-demand timers to ServerConfig storage. Should be called when Evennia shuts down. """ + for key, category in list(self.tasks.keys()): + # in case an object was used for categories, and were since deleted, drop the task + if hasattr(category, "id") and category.id is None: + self.tasks.pop((key, category)) ServerConfig.objects.conf(ONDEMAND_HANDLER_SAVE_NAME, self.tasks) def _build_key(self, key, category): diff --git a/evennia/scripts/scripthandler.py b/evennia/scripts/scripthandler.py index 35971669bf..3e6268dc61 100644 --- a/evennia/scripts/scripthandler.py +++ b/evennia/scripts/scripthandler.py @@ -7,6 +7,7 @@ added to all game objects. You access it through the property """ from django.utils.translation import gettext as _ + from evennia.scripts.models import ScriptDB from evennia.utils import create, logger diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 1f2da85513..5d1b45f365 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -6,12 +6,13 @@ ability to run timers. """ from django.utils.translation import gettext as _ +from twisted.internet.defer import Deferred, maybeDeferred +from twisted.internet.task import LoopingCall + from evennia.scripts.manager import ScriptManager from evennia.scripts.models import ScriptDB from evennia.typeclasses.models import TypeclassBase from evennia.utils import create, logger -from twisted.internet.defer import Deferred, maybeDeferred -from twisted.internet.task import LoopingCall __all__ = ["DefaultScript", "DoNothing", "Store"] diff --git a/evennia/scripts/taskhandler.py b/evennia/scripts/taskhandler.py index a6539fb1ad..4493ca57fd 100644 --- a/evennia/scripts/taskhandler.py +++ b/evennia/scripts/taskhandler.py @@ -251,7 +251,12 @@ class TaskHandler: to_save = True continue - callback = getattr(obj, method) + try: + callback = getattr(obj, method) + except Exception as e: + log_err(f"TaskHandler: Unable to load task {task_id} (disabling it): {e}") + to_save = True + continue self.tasks[task_id] = (date, callback, args, kwargs, True, None) if self.stale_timeout > 0: # cleanup stale tasks. diff --git a/evennia/scripts/tests.py b/evennia/scripts/tests.py index e1277b8584..b62de636de 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): @@ -45,6 +46,20 @@ class TestTickerHandler(TestCase): th = TickerHandler() th.remove(callback=1) + def test_removing_ticker_using_store_key_in_attribute(self): + """ + Test adding a ticker, storing the store_key in an attribute, and then removing it + using that same store_key. + + https://github.com/evennia/evennia/pull/3765 + """ + obj = DefaultObject.create("test_object")[0] + th = TickerHandler() + obj.db.ticker = th.add(60, obj.msg, idstring="ticker_test", persistent=True) + self.assertTrue(len(th.all()), 1) + th.remove(store_key=obj.db.ticker) + self.assertTrue(len(th.all()), 0) + class TestScriptDBManager(TestCase): """Test the ScriptDBManger class""" diff --git a/evennia/scripts/tickerhandler.py b/evennia/scripts/tickerhandler.py index 3968cee69a..cd0e3825cd 100644 --- a/evennia/scripts/tickerhandler.py +++ b/evennia/scripts/tickerhandler.py @@ -564,6 +564,14 @@ class TickerHandler(object): if not store_key: obj, path, callfunc = self._get_callback(callback) store_key = self._store_key(obj, path, interval, callfunc, idstring, persistent) + else: + if isinstance(store_key, tuple) and not isinstance(store_key[0], tuple): + # this means the store-key was deserialized, which means we need to + # re-build the key anew (since the obj would already be unpacked otherwise) + obj, path, callfunc = self._get_callback(getattr(store_key[0], store_key[1])) + store_key = self._store_key( + obj, path, store_key[3], callfunc, store_key[4], store_key[5] + ) to_remove = self.ticker_storage.pop(store_key, None) if to_remove: self.ticker_pool.remove(store_key) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 7578da9e52..75cdcdcd99 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -248,7 +248,7 @@ RECREATED_MISSING = """ ERROR_DATABASE = """ ERROR: Your database does not exist or is not set up correctly. - (error was '{traceback}') + Missing tables: {missing_tables} If you think your database should work, make sure you are running your commands from inside your game directory. If this error persists, run @@ -258,29 +258,6 @@ ERROR_DATABASE = """ to initialize/update the database according to your settings. """ -ERROR_WINDOWS_WIN32API = """ - ERROR: Unable to import win32api, which Twisted requires to run. - You may download it with pip in your Python environment: - - pip install --upgrade pywin32 - - """ - -INFO_WINDOWS_BATFILE = """ - INFO: Since you are running Windows, a file 'twistd.bat' was - created for you. This is a simple batch file that tries to call - the twisted executable. Evennia determined this to be: - - {twistd_path} - - If you run into errors at startup you might need to edit - twistd.bat to point to the actual location of the Twisted - executable (usually called twistd.py) on your machine. - - This procedure is only done once. Run `evennia` again when you - are ready to start the server. - """ - CMDLINE_HELP = """Starts, initializes, manages and operates the Evennia MU* server. Most standard django management commands are also accepted.""" @@ -843,7 +820,7 @@ def start_evennia(pprofiler=False, sprofiler=False): if response: _, _, _, _, pinfo, sinfo = response _print_info(pinfo, sinfo) - _reactor_stop() + _reactor_stop() def _portal_started(*args): print( @@ -1483,9 +1460,15 @@ def create_game_directory(dirname): def create_superuser(): """ - Create the superuser account + Auto-create the superuser account. Returns `True` if superuser was created. """ + from evennia.accounts.models import AccountDB + + if AccountDB.objects.filter(is_superuser=True).exists(): + # if superuser already exists, do nothing here + return False + print( "\nCreate a superuser below. The superuser is Account #1, the 'owner' " "account of the server. Email is optional and can be empty.\n" @@ -1497,79 +1480,95 @@ def create_superuser(): password = environ.get("EVENNIA_SUPERUSER_PASSWORD") if (username is not None) and (password is not None) and len(password) > 0: - from evennia.accounts.models import AccountDB superuser = AccountDB.objects.create_superuser(username, email, password) superuser.save() else: django.core.management.call_command("createsuperuser", interactive=True) + return True + def check_database(always_return=False): """ - Check so the database exists. + Check if the database exists and has basic tables. This is only run by the launcher. Args: - always_return (bool, optional): If set, will always return True/False - also on critical errors. No output will be printed. + always_return (bool, optional): If True, will not raise exceptions on errors. + Returns: - exists (bool): `True` if the database exists, otherwise `False`. - - + exists (bool): `True` if database exists and seems set up, `False` otherwise. + If `always_return` is `False`, this will raise exceptions instead of + returning `False`. """ - # Check so a database exists and is accessible + # Check if database exists + from django.conf import settings from django.db import connection - tables = connection.introspection.get_table_list(connection.cursor()) - if not tables or not isinstance(tables[0], str): # django 1.8+ - tables = [tableinfo.name for tableinfo in tables] - if tables and "accounts_accountdb" in tables: - # database exists and seems set up. Initialize evennia. - evennia._init() - # Try to get Account#1 - from evennia.accounts.models import AccountDB + tables_to_check = [ + "accounts_accountdb", # base account table + "objects_objectdb", # base object table + "scripts_scriptdb", # base script table + "typeclasses_tag", # base tag table + ] try: - AccountDB.objects.get(id=1) - except (django.db.utils.OperationalError, ProgrammingError) as e: - if always_return: - return False - print(ERROR_DATABASE.format(traceback=e)) - sys.exit() - except AccountDB.DoesNotExist: - # no superuser yet. We need to create it. + with connection.cursor() as cursor: + # Get all table names in the database + if connection.vendor == "postgresql": + cursor.execute( + """ + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + """ + ) + elif connection.vendor == "mysql": + cursor.execute( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = %s + """, + [settings.DATABASES["default"]["NAME"]], + ) + elif connection.vendor == "sqlite": + cursor.execute( + """ + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + """ + ) + else: + if not always_return: + raise Exception( + f"Unsupported database: {connection.vendor}" + "Evennia supports PostgreSQL, MySQL, and SQLite only." + ) + return False - other_superuser = AccountDB.objects.filter(is_superuser=True) - if other_superuser: - # Another superuser was found, but not with id=1. This may - # happen if using flush (the auto-id starts at a higher - # value). Wwe copy this superuser into id=1. To do - # this we must deepcopy it, delete it then save the copy - # with the new id. This allows us to avoid the UNIQUE - # constraint on usernames. - other = other_superuser[0] - other_id = other.id - other_key = other.username - print(WARNING_MOVING_SUPERUSER.format(other_key=other_key, other_id=other_id)) - res = "" - while res.upper() != "Y": - # ask for permission - res = eval(input("Continue [Y]/N: ")) - if res.upper() == "N": - sys.exit() - elif not res: - break - # continue with the - from copy import deepcopy + existing_tables = {row[0].lower() for row in cursor.fetchall()} - new = deepcopy(other) - other.delete() - new.id = 1 - new.save() - else: - create_superuser() - check_database(always_return=always_return) - return True + # Check if essential tables exist + missing_tables = [table for table in tables_to_check if table not in existing_tables] + + if missing_tables: + if always_return: + return False + raise Exception( + f"Database tables missing: {', '.join(missing_tables)}. " + "\nDid you remember to run migrations?" + ) + else: + create_superuser() + + return True + + except Exception as exc: + if not always_return: + raise + import traceback + + traceback.print_exc() + return False def getenv(): @@ -1791,8 +1790,11 @@ def init_game_directory(path, check_db=True, need_gamedir=True): be run in a valid game directory. """ - # set the GAMEDIR path - if need_gamedir: + global GAMEDIR + # Set the GAMEDIR path if not set already + ## Declaring it global doesn't set the variable + ## This check is needed for evennia --gamedir to work + if need_gamedir and "GAMEDIR" not in globals(): set_gamedir(path) # Add gamedir to python path @@ -1808,11 +1810,12 @@ def init_game_directory(path, check_db=True, need_gamedir=True): else: os.environ["DJANGO_SETTINGS_MODULE"] = SETTINGS_DOTPATH - # required since django1.7 - django.setup() - # test existence of the settings module try: + + # required since django1.7 + django.setup() + from django.conf import settings except Exception as ex: if not str(ex).startswith("No module named"): @@ -1825,6 +1828,7 @@ def init_game_directory(path, check_db=True, need_gamedir=True): # this will both check the database and initialize the evennia dir. if check_db: check_database() + evennia._init() # if we don't have to check the game directory, return right away if not need_gamedir: @@ -1870,50 +1874,10 @@ def init_game_directory(path, check_db=True, need_gamedir=True): sys.exit() if _is_windows(): - # We need to handle Windows twisted separately. We create a - # batchfile in game/server, linking to the actual binary - global TWISTED_BINARY - # Windows requires us to use the absolute path for the bat file. - server_path = os.path.dirname(os.path.abspath(__file__)) - TWISTED_BINARY = os.path.join(server_path, "twistd.bat") - - # add path so system can find the batfile - sys.path.insert(1, os.path.join(GAMEDIR, SERVERDIR)) - - try: - importlib.import_module("win32api") - except ImportError: - print(ERROR_WINDOWS_WIN32API) - sys.exit() - - batpath = os.path.join(EVENNIA_SERVER, TWISTED_BINARY) - if not os.path.exists(batpath): - # Test for executable twisted batch file. This calls the - # twistd.py executable that is usually not found on the - # path in Windows. It's not enough to locate - # scripts.twistd, what we want is the executable script - # C:\PythonXX/Scripts/twistd.py. Alas we cannot hardcode - # this location since we don't know if user has Python in - # a non-standard location. So we try to figure it out. - twistd = importlib.import_module("twisted.scripts.twistd") - twistd_dir = os.path.dirname(twistd.__file__) - - # note that we hope the twistd package won't change here, since we - # try to get to the executable by relative path. - # Update: In 2016, it seems Twisted 16 has changed the name of - # of its executable from 'twistd.py' to 'twistd.exe'. - twistd_path = os.path.abspath( - os.path.join( - twistd_dir, os.pardir, os.pardir, os.pardir, os.pardir, "scripts", "twistd.exe" - ) - ) - - with open(batpath, "w") as bat_file: - # build a custom bat file for windows - bat_file.write('@"%s" %%*' % twistd_path) - - print(INFO_WINDOWS_BATFILE.format(twistd_path=twistd_path)) + TWISTED_BINARY = os.path.join(os.path.dirname(sys.executable), "twistd.exe") + if not os.path.exists(TWISTED_BINARY): # venv isn't being used + TWISTED_BINARY = os.path.join(os.path.dirname(sys.executable), "Scripts\\twistd.exe") def run_dummyrunner(number_of_dummies): diff --git a/evennia/server/models.py b/evennia/server/models.py index f945409ada..f7313407e2 100644 --- a/evennia/server/models.py +++ b/evennia/server/models.py @@ -110,6 +110,7 @@ class ServerConfig(WeakSharedMemoryModel): class Meta: "Define Django meta options" + verbose_name = "Server Config value" verbose_name_plural = "Server Config values" diff --git a/evennia/server/portal/mccp.py b/evennia/server/portal/mccp.py index ba748e0010..8f7aa00de5 100644 --- a/evennia/server/portal/mccp.py +++ b/evennia/server/portal/mccp.py @@ -15,6 +15,7 @@ This protocol is implemented by the telnet protocol importing mccp_compress and calling it from its write methods. """ +import weakref import zlib # negotiations for v1 and v2 of the protocol @@ -57,10 +58,10 @@ class Mccp: """ - self.protocol = protocol - self.protocol.protocol_flags["MCCP"] = False + self.protocol = weakref.ref(protocol) + self.protocol().protocol_flags["MCCP"] = False # ask if client will mccp, connect callbacks to handle answer - self.protocol.will(MCCP).addCallbacks(self.do_mccp, self.no_mccp) + self.protocol().will(MCCP).addCallbacks(self.do_mccp, self.no_mccp) def no_mccp(self, option): """ @@ -70,10 +71,10 @@ class Mccp: option (Option): Option dict (not used). """ - if hasattr(self.protocol, "zlib"): - del self.protocol.zlib - self.protocol.protocol_flags["MCCP"] = False - self.protocol.handshake_done() + if hasattr(self.protocol(), "zlib"): + del self.protocol().zlib + self.protocol().protocol_flags["MCCP"] = False + self.protocol().handshake_done() def do_mccp(self, option): """ @@ -84,7 +85,7 @@ class Mccp: option (Option): Option dict (not used). """ - self.protocol.protocol_flags["MCCP"] = True - self.protocol.requestNegotiation(MCCP, b"") - self.protocol.zlib = zlib.compressobj(9) - self.protocol.handshake_done() + self.protocol().protocol_flags["MCCP"] = True + self.protocol().requestNegotiation(MCCP, b"") + self.protocol().zlib = zlib.compressobj(9) + self.protocol().handshake_done() diff --git a/evennia/server/portal/mssp.py b/evennia/server/portal/mssp.py index 03714cf9da..b32c0dd861 100644 --- a/evennia/server/portal/mssp.py +++ b/evennia/server/portal/mssp.py @@ -11,6 +11,8 @@ active players and so on. """ +import weakref + from django.conf import settings from evennia.utils import utils @@ -39,8 +41,8 @@ class Mssp: protocol (Protocol): The active protocol instance. """ - self.protocol = protocol - self.protocol.will(MSSP).addCallbacks(self.do_mssp, self.no_mssp) + self.protocol = weakref.ref(protocol) + self.protocol().will(MSSP).addCallbacks(self.do_mssp, self.no_mssp) def get_player_count(self): """ @@ -50,7 +52,7 @@ class Mssp: count (int): The number of players in the MUD. """ - return str(self.protocol.sessionhandler.count_loggedin()) + return str(self.protocol().sessionhandler.count_loggedin()) def get_uptime(self): """ @@ -60,7 +62,7 @@ class Mssp: uptime (int): Number of seconds of uptime. """ - return str(self.protocol.sessionhandler.uptime) + return str(self.protocol().sessionhandler.uptime) def no_mssp(self, option): """ @@ -71,7 +73,7 @@ class Mssp: option (Option): Not used. """ - self.protocol.handshake_done() + self.protocol().handshake_done() def do_mssp(self, option): """ @@ -132,5 +134,5 @@ class Mssp: ) # send to crawler by subnegotiation - self.protocol.requestNegotiation(MSSP, varlist) - self.protocol.handshake_done() + self.protocol().requestNegotiation(MSSP, varlist) + self.protocol().handshake_done() diff --git a/evennia/server/portal/mxp.py b/evennia/server/portal/mxp.py index a445168c97..260cd97089 100644 --- a/evennia/server/portal/mxp.py +++ b/evennia/server/portal/mxp.py @@ -15,6 +15,7 @@ http://www.gammon.com.au/mushclient/addingservermxp.htm """ import re +import weakref from django.conf import settings @@ -24,7 +25,7 @@ URL_SUB = re.compile(r"\|lu(.*?)\|lt(.*?)\|le", re.DOTALL) # MXP Telnet option MXP = bytes([91]) # b"\x5b" -MXP_TEMPSECURE = "\x1B[4z" +MXP_TEMPSECURE = "\x1b[4z" MXP_SEND = MXP_TEMPSECURE + '' + "\\2" + MXP_TEMPSECURE + "" MXP_URL = MXP_TEMPSECURE + '' + "\\2" + MXP_TEMPSECURE + "" @@ -61,10 +62,10 @@ class Mxp: protocol (Protocol): The active protocol instance. """ - self.protocol = protocol - self.protocol.protocol_flags["MXP"] = False + self.protocol = weakref.ref(protocol) + self.protocol().protocol_flags["MXP"] = False if settings.MXP_ENABLED: - self.protocol.will(MXP).addCallbacks(self.do_mxp, self.no_mxp) + self.protocol().will(MXP).addCallbacks(self.do_mxp, self.no_mxp) def no_mxp(self, option): """ @@ -74,8 +75,8 @@ class Mxp: option (Option): Not used. """ - self.protocol.protocol_flags["MXP"] = False - self.protocol.handshake_done() + self.protocol().protocol_flags["MXP"] = False + self.protocol().handshake_done() def do_mxp(self, option): """ @@ -86,8 +87,8 @@ class Mxp: """ if settings.MXP_ENABLED: - self.protocol.protocol_flags["MXP"] = True - self.protocol.requestNegotiation(MXP, b"") + self.protocol().protocol_flags["MXP"] = True + self.protocol().requestNegotiation(MXP, b"") else: - self.protocol.wont(MXP) - self.protocol.handshake_done() + self.protocol().wont(MXP) + self.protocol().handshake_done() diff --git a/evennia/server/portal/naws.py b/evennia/server/portal/naws.py index 71d3a75352..e720210338 100644 --- a/evennia/server/portal/naws.py +++ b/evennia/server/portal/naws.py @@ -10,6 +10,7 @@ client and update it when the size changes """ +import weakref from codecs import encode as codecs_encode from django.conf import settings @@ -41,13 +42,13 @@ class Naws: """ self.naws_step = 0 - self.protocol = protocol - self.protocol.protocol_flags["SCREENWIDTH"] = { + self.protocol = weakref.ref(protocol) + self.protocol().protocol_flags["SCREENWIDTH"] = { 0: DEFAULT_WIDTH } # windowID (0 is root):width - self.protocol.protocol_flags["SCREENHEIGHT"] = {0: DEFAULT_HEIGHT} # windowID:width - self.protocol.negotiationMap[NAWS] = self.negotiate_sizes - self.protocol.do(NAWS).addCallbacks(self.do_naws, self.no_naws) + self.protocol().protocol_flags["SCREENHEIGHT"] = {0: DEFAULT_HEIGHT} # windowID:width + self.protocol().negotiationMap[NAWS] = self.negotiate_sizes + self.protocol().do(NAWS).addCallbacks(self.do_naws, self.no_naws) def no_naws(self, option): """ @@ -58,8 +59,8 @@ class Naws: option (Option): Not used. """ - self.protocol.protocol_flags["AUTORESIZE"] = False - self.protocol.handshake_done() + self.protocol().protocol_flags["AUTORESIZE"] = False + self.protocol().handshake_done() def do_naws(self, option): """ @@ -69,8 +70,8 @@ class Naws: option (Option): Not used. """ - self.protocol.protocol_flags["AUTORESIZE"] = True - self.protocol.handshake_done() + self.protocol().protocol_flags["AUTORESIZE"] = True + self.protocol().handshake_done() def negotiate_sizes(self, options): """ @@ -83,6 +84,8 @@ class Naws: if len(options) == 4: # NAWS is negotiated with 16bit words width = options[0] + options[1] - self.protocol.protocol_flags["SCREENWIDTH"][0] = int(codecs_encode(width, "hex"), 16) + self.protocol().protocol_flags["SCREENWIDTH"][0] = int(codecs_encode(width, "hex"), 16) height = options[2] + options[3] - self.protocol.protocol_flags["SCREENHEIGHT"][0] = int(codecs_encode(height, "hex"), 16) + self.protocol().protocol_flags["SCREENHEIGHT"][0] = int( + codecs_encode(height, "hex"), 16 + ) diff --git a/evennia/server/portal/service.py b/evennia/server/portal/service.py index 048ad8ea17..66bf388037 100644 --- a/evennia/server/portal/service.py +++ b/evennia/server/portal/service.py @@ -223,6 +223,7 @@ class EvenniaPortalService(MultiService): class Websocket(WebSocketServerFactory): "Only here for better naming in logs" + pass factory = Websocket() diff --git a/evennia/server/portal/suppress_ga.py b/evennia/server/portal/suppress_ga.py index cadf007fa9..ddba5ed15a 100644 --- a/evennia/server/portal/suppress_ga.py +++ b/evennia/server/portal/suppress_ga.py @@ -14,6 +14,8 @@ http://www.faqs.org/rfcs/rfc858.html """ +import weakref + SUPPRESS_GA = bytes([3]) # b"\x03" # default taken from telnet specification @@ -36,14 +38,14 @@ class SuppressGA: protocol (Protocol): The active protocol instance. """ - self.protocol = protocol + self.protocol = weakref.ref(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["NOGOAHEAD"] = True + self.protocol().protocol_flags[ + "NOPROMPTGOAHEAD" + ] = True # Used to send a GA after a prompt line only, set in TTYPE (per client) # tell the client that we prefer to suppress GA ... - self.protocol.will(SUPPRESS_GA).addCallbacks(self.will_suppress_ga, self.wont_suppress_ga) + self.protocol().will(SUPPRESS_GA).addCallbacks(self.will_suppress_ga, self.wont_suppress_ga) def wont_suppress_ga(self, option): """ @@ -53,8 +55,8 @@ class SuppressGA: option (Option): Not used. """ - self.protocol.protocol_flags["NOGOAHEAD"] = False - self.protocol.handshake_done() + self.protocol().protocol_flags["NOGOAHEAD"] = False + self.protocol().handshake_done() def will_suppress_ga(self, option): """ @@ -64,5 +66,5 @@ class SuppressGA: option (Option): Not used. """ - self.protocol.protocol_flags["NOGOAHEAD"] = True - self.protocol.handshake_done() + self.protocol().protocol_flags["NOGOAHEAD"] = True + self.protocol().handshake_done() diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 46d1de0baa..e561953f18 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -306,6 +306,8 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS): """ self.sessionhandler.disconnect(self) + if self.nop_keep_alive and self.nop_keep_alive.running: + self.toggle_nop_keepalive() self.transport.loseConnection() def applicationDataReceived(self, data): diff --git a/evennia/server/portal/telnet_oob.py b/evennia/server/portal/telnet_oob.py index ffc2283067..c8c3365ab0 100644 --- a/evennia/server/portal/telnet_oob.py +++ b/evennia/server/portal/telnet_oob.py @@ -26,6 +26,7 @@ This implements the following telnet OOB communication protocols: import json import re +import weakref # General Telnet from twisted.conch.telnet import IAC, SB, SE @@ -84,16 +85,16 @@ class TelnetOOB: protocol (Protocol): The active protocol. """ - self.protocol = protocol - self.protocol.protocol_flags["OOB"] = False + self.protocol = weakref.ref(protocol) + self.protocol().protocol_flags["OOB"] = False self.MSDP = False self.GMCP = False # ask for the available protocols and assign decoders # (note that handshake_done() will be called twice!) - self.protocol.negotiationMap[MSDP] = self.decode_msdp - self.protocol.negotiationMap[GMCP] = self.decode_gmcp - self.protocol.will(MSDP).addCallbacks(self.do_msdp, self.no_msdp) - self.protocol.will(GMCP).addCallbacks(self.do_gmcp, self.no_gmcp) + self.protocol().negotiationMap[MSDP] = self.decode_msdp + self.protocol().negotiationMap[GMCP] = self.decode_gmcp + self.protocol().will(MSDP).addCallbacks(self.do_msdp, self.no_msdp) + self.protocol().will(GMCP).addCallbacks(self.do_gmcp, self.no_gmcp) self.oob_reported = {} def no_msdp(self, option): @@ -105,7 +106,7 @@ class TelnetOOB: """ # no msdp, check GMCP - self.protocol.handshake_done() + self.protocol().handshake_done() def do_msdp(self, option): """ @@ -116,8 +117,8 @@ class TelnetOOB: """ self.MSDP = True - self.protocol.protocol_flags["OOB"] = True - self.protocol.handshake_done() + self.protocol().protocol_flags["OOB"] = True + self.protocol().handshake_done() def no_gmcp(self, option): """ @@ -128,7 +129,7 @@ class TelnetOOB: option (Option): Not used. """ - self.protocol.handshake_done() + self.protocol().handshake_done() def do_gmcp(self, option): """ @@ -139,8 +140,8 @@ class TelnetOOB: """ self.GMCP = True - self.protocol.protocol_flags["OOB"] = True - self.protocol.handshake_done() + self.protocol().protocol_flags["OOB"] = True + self.protocol().handshake_done() # encoders @@ -375,7 +376,7 @@ class TelnetOOB: cmds["msdp_{}".format(remap)] = cmds.pop(lower_case[remap]) # print("msdp data in:", cmds) # DEBUG - self.protocol.data_in(**cmds) + self.protocol().data_in(**cmds) def decode_gmcp(self, data): """ @@ -424,7 +425,7 @@ class TelnetOOB: if cmdname.lower().startswith(b"core_"): # if Core.cmdname, then use cmdname cmdname = cmdname[5:] - self.protocol.data_in(**{cmdname.lower().decode(): [args, kwargs]}) + self.protocol().data_in(**{cmdname.lower().decode(): [args, kwargs]}) # access methods @@ -441,8 +442,8 @@ class TelnetOOB: if self.MSDP: encoded_oob = self.encode_msdp(cmdname, *args, **kwargs) - self.protocol._write(IAC + SB + MSDP + encoded_oob + IAC + SE) + self.protocol()._write(IAC + SB + MSDP + encoded_oob + IAC + SE) if self.GMCP: encoded_oob = self.encode_gmcp(cmdname, *args, **kwargs) - self.protocol._write(IAC + SB + GMCP + encoded_oob + IAC + SE) + self.protocol()._write(IAC + SB + GMCP + encoded_oob + IAC + SE) diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py index d1b4c738b2..5427cd40e8 100644 --- a/evennia/server/portal/ttype.py +++ b/evennia/server/portal/ttype.py @@ -12,6 +12,8 @@ under the 'TTYPE' key. """ +import weakref + # telnet option codes TTYPE = bytes([24]) # b"\x18" IS = bytes([0]) # b"\x00" @@ -55,16 +57,16 @@ class Ttype: """ self.ttype_step = 0 - self.protocol = protocol + self.protocol = weakref.ref(protocol) # we set FORCEDENDLINE for clients not supporting ttype - self.protocol.protocol_flags["FORCEDENDLINE"] = True - self.protocol.protocol_flags["TTYPE"] = False + self.protocol().protocol_flags["FORCEDENDLINE"] = True + self.protocol().protocol_flags["TTYPE"] = False # is it a safe bet to assume ANSI is always supported? - self.protocol.protocol_flags["ANSI"] = True + self.protocol().protocol_flags["ANSI"] = True # setup protocol to handle ttype initialization and negotiation - self.protocol.negotiationMap[TTYPE] = self.will_ttype + self.protocol().negotiationMap[TTYPE] = self.will_ttype # ask if client will ttype, connect callback if it does. - self.protocol.do(TTYPE).addCallbacks(self.will_ttype, self.wont_ttype) + self.protocol().do(TTYPE).addCallbacks(self.will_ttype, self.wont_ttype) def wont_ttype(self, option): """ @@ -74,8 +76,8 @@ class Ttype: option (Option): Not used. """ - self.protocol.protocol_flags["TTYPE"] = False - self.protocol.handshake_done() + self.protocol().protocol_flags["TTYPE"] = False + self.protocol().handshake_done() def will_ttype(self, option): """ @@ -91,7 +93,7 @@ class Ttype: stored on protocol.protocol_flags under the TTYPE key. """ - options = self.protocol.protocol_flags + options = self.protocol().protocol_flags if options and options.get("TTYPE", False) or self.ttype_step > 3: return @@ -104,7 +106,7 @@ class Ttype: if self.ttype_step == 0: # just start the request chain - self.protocol.requestNegotiation(TTYPE, SEND) + self.protocol().requestNegotiation(TTYPE, SEND) elif self.ttype_step == 1: # this is supposed to be the name of the client/terminal. @@ -125,9 +127,9 @@ class Ttype: xterm256 = clientname.split("MUDLET", 1)[1].strip() >= "1.1" # Mudlet likes GA's on a prompt line for the prompt trigger to # match, if it's not wanting NOGOAHEAD. - if not self.protocol.protocol_flags["NOGOAHEAD"]: - self.protocol.protocol_flags["NOGOAHEAD"] = True - self.protocol.protocol_flags["NOPROMPTGOAHEAD"] = False + if not self.protocol().protocol_flags["NOGOAHEAD"]: + self.protocol().protocol_flags["NOGOAHEAD"] = True + self.protocol().protocol_flags["NOPROMPTGOAHEAD"] = False if ( clientname.startswith("XTERM") @@ -153,11 +155,11 @@ class Ttype: truecolor = True # all clients supporting TTYPE at all seem to support ANSI - self.protocol.protocol_flags["ANSI"] = True - self.protocol.protocol_flags["XTERM256"] = xterm256 - self.protocol.protocol_flags["TRUECOLOR"] = truecolor - self.protocol.protocol_flags["CLIENTNAME"] = clientname - self.protocol.requestNegotiation(TTYPE, SEND) + self.protocol().protocol_flags["ANSI"] = True + self.protocol().protocol_flags["XTERM256"] = xterm256 + self.protocol().protocol_flags["TRUECOLOR"] = truecolor + self.protocol().protocol_flags["CLIENTNAME"] = clientname + self.protocol().requestNegotiation(TTYPE, SEND) elif self.ttype_step == 2: # this is a term capabilities flag @@ -170,11 +172,11 @@ class Ttype: and not tupper.endswith("-COLOR") # old Tintin, Putty ) if xterm256: - self.protocol.protocol_flags["ANSI"] = True - self.protocol.protocol_flags["XTERM256"] = xterm256 - self.protocol.protocol_flags["TERM"] = term + self.protocol().protocol_flags["ANSI"] = True + self.protocol().protocol_flags["XTERM256"] = xterm256 + self.protocol().protocol_flags["TERM"] = term # request next information - self.protocol.requestNegotiation(TTYPE, SEND) + self.protocol().requestNegotiation(TTYPE, SEND) elif self.ttype_step == 3: # the MTTS bitstring identifying term capabilities @@ -186,12 +188,12 @@ class Ttype: support = dict( (capability, True) for bitval, capability in MTTS if option & bitval > 0 ) - self.protocol.protocol_flags.update(support) + self.protocol().protocol_flags.update(support) else: # some clients send erroneous MTTS as a string. Add directly. - self.protocol.protocol_flags[option.upper()] = True + self.protocol().protocol_flags[option.upper()] = True - self.protocol.protocol_flags["TTYPE"] = True + self.protocol().protocol_flags["TTYPE"] = True # we must sync ttype once it'd done - self.protocol.handshake_done() + self.protocol().handshake_done() self.ttype_step += 1 diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 4f76960592..165769b7f2 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -161,9 +161,6 @@ class ServerSession(_BASE_SESSION_CLASS): account = self.account if self.puppet: account.unpuppet_object(self) - uaccount = account - uaccount.last_login = timezone.now() - uaccount.save() # calling account hook account.at_disconnect(reason) self.logged_in = False diff --git a/evennia/server/service.py b/evennia/server/service.py index 80eba12bb0..ee640e84f9 100644 --- a/evennia/server/service.py +++ b/evennia/server/service.py @@ -8,19 +8,20 @@ import time import traceback import django -import evennia from django.conf import settings from django.db import connection from django.db.utils import OperationalError from django.utils.translation import gettext as _ -from evennia.utils import logger -from evennia.utils.utils import get_evennia_version, make_iter, mod_import from twisted.application import internet from twisted.application.service import MultiService from twisted.internet import defer, reactor from twisted.internet.defer import Deferred from twisted.internet.task import LoopingCall +import evennia +from evennia.utils import logger +from evennia.utils.utils import get_evennia_version, make_iter, mod_import + _SA = object.__setattr__ @@ -673,12 +674,6 @@ class EvenniaServerService(MultiService): shutdown or a reset. """ - # We need to do this just in case the server was killed in a way where - # the normal cleanup operations did not have time to run. - from evennia.objects.models import ObjectDB - - ObjectDB.objects.clear_all_sessids() - # Remove non-persistent scripts from evennia.scripts.models import ScriptDB diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 79e11c8ebc..ce5bf0e214 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -301,13 +301,13 @@ DATABASES = { } } # PRAGMA (directives) for the default Sqlite3 database operations. This can be used to tweak -# performance for your setup. Don't change this unless you know what # you are doing. +# performance for your setup. Don't change this unless you know what you are doing. Also +# be careful to change for an already populated database. SQLITE3_PRAGMAS = ( "PRAGMA cache_size=10000", - "PRAGMA synchronous=1", + "PRAGMA synchronous=OFF", "PRAGMA count_changes=OFF", "PRAGMA temp_store=2", - "PRAGMA journal_mode=WAL", ) # How long the django-database connection should be kept open, in seconds. diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 174b9ab056..5d8d7aca8c 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -17,6 +17,7 @@ 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 @@ -62,7 +63,7 @@ class IAttribute: return LockHandler(self) key = property(lambda self: self.db_key) - strvalue = property(lambda self: self.db_strvalue) + strvalue = property(lambda self: getattr(self, "db_strvalue", None)) category = property(lambda self: self.db_category) model = property(lambda self: self.db_model) attrtype = property(lambda self: self.db_attrtype) @@ -411,6 +412,7 @@ class Attribute(IAttribute, SharedMemoryModel): class Meta: "Define Django meta options" + verbose_name = "Attribute" # Wrapper properties to easily set database fields. These are @@ -1471,7 +1473,7 @@ class DbHolder: # Nick templating # -""" +r""" This supports the use of replacement templates in nicks: This happens in two steps: diff --git a/evennia/typeclasses/migrations/0017_use_index_instead_of_index_together_in_tags.py b/evennia/typeclasses/migrations/0017_use_index_instead_of_index_together_in_tags.py new file mode 100644 index 0000000000..e192a66f8d --- /dev/null +++ b/evennia/typeclasses/migrations/0017_use_index_instead_of_index_together_in_tags.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.6 on 2025-03-01 21:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("typeclasses", "0016_alter_attribute_id_alter_tag_id"), + ] + + def get_operations(self, app_labels, schema_editor): + """Return database-specific operations""" + if schema_editor.connection.vendor == "sqlite": + # For SQLite, we know the two-step process works + return [ + migrations.AddIndex( + model_name="tag", + index=models.Index( + fields=["db_key", "db_category", "db_tagtype", "db_model"], + name="typeclasses_tag_db_key_db_category_db_tagtype_db_model_idx", + ), + ), + migrations.RenameIndex( + model_name="tag", + new_name="typeclasses_db_key_be0c81_idx", + old_fields=("db_key", "db_category", "db_tagtype", "db_model"), + ), + ] + else: + # For other databases, create the index directly with its final name + return [ + migrations.AddIndex( + model_name="tag", + index=models.Index( + fields=["db_key", "db_category", "db_tagtype", "db_model"], + name="typeclasses_db_key_be0c81_idx", + ), + ), + ] + + operations = [] # Will be populated at runtime by get_operations() diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 476e11b045..67440f061c 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -36,6 +36,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.encoding import smart_str from django.utils.text import slugify +from django.utils.translation import gettext as _ import evennia from evennia.locks.lockhandler import LockHandler @@ -347,12 +348,21 @@ class TypedObject(SharedMemoryModel): Called by creation methods; makes sure to initialize Attribute/TagProperties by fetching them once. """ - for propkey, prop in self.__class__.__dict__.items(): - if isinstance(prop, (AttributeProperty, TagProperty, TagCategoryProperty)): - try: - getattr(self, propkey) - except Exception: - log_trace() + evennia_properties = set() + for base in type(self).__mro__: + evennia_properties.update( + { + propkey + for propkey, prop in vars(base).items() + if isinstance(prop, (AttributeProperty, TagProperty, TagCategoryProperty)) + } + ) + + for propkey in evennia_properties: + try: + getattr(self, propkey) + except Exception: + log_trace() # initialize all handlers in a lazy fashion @lazy_property @@ -883,7 +893,7 @@ class TypedObject(SharedMemoryModel): """ if self.location == looker: - return " (carried)" + return _(" (carried)") return "" def at_rename(self, oldname, newname): @@ -948,7 +958,7 @@ class TypedObject(SharedMemoryModel): return "#" def web_get_detail_url(self): - """ + r""" Returns the URI path for a View that allows users to view details for this object. @@ -988,7 +998,7 @@ class TypedObject(SharedMemoryModel): return "#" def web_get_puppet_url(self): - """ + r""" Returns the URI path for a View that allows users to puppet a specific object. @@ -1026,7 +1036,7 @@ class TypedObject(SharedMemoryModel): return "#" def web_get_update_url(self): - """ + r""" Returns the URI path for a View that allows users to update this object. @@ -1065,7 +1075,7 @@ class TypedObject(SharedMemoryModel): return "#" def web_get_delete_url(self): - """ + r""" Returns the URI path for a View that allows users to delete this object. Returns: diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index b396419a4b..017cf5bc73 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -79,9 +79,10 @@ class Tag(models.Model): class Meta: "Define Django meta options" + verbose_name = "Tag" unique_together = (("db_key", "db_category", "db_tagtype", "db_model"),) - index_together = (("db_key", "db_category", "db_tagtype", "db_model"),) + indexes = [models.Index(fields=["db_key", "db_category", "db_tagtype", "db_model"])] def __lt__(self, other): return str(self) < str(other) diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 06e2438251..38bea3fcb1 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -67,6 +67,7 @@ import re from collections import OrderedDict from django.conf import settings + from evennia.utils import logger, utils from evennia.utils.hex_colors import HexColors from evennia.utils.utils import to_str diff --git a/evennia/utils/containers.py b/evennia/utils/containers.py index 53496e5a0c..5dd143d8c8 100644 --- a/evennia/utils/containers.py +++ b/evennia/utils/containers.py @@ -249,7 +249,7 @@ class GlobalScriptContainer(Container): """ if not self.loaded: self.load_data() - managed_scripts = list(self.loaded_data.values()) + managed_scripts = [self._load_script(key) for key in self.typeclass_storage.keys()] unmanaged_scripts = list( ScriptDB.objects.filter(db_obj__isnull=True).exclude( id__in=[scr.id for scr in managed_scripts] diff --git a/evennia/utils/create.py b/evennia/utils/create.py index e90df490ad..326d1bab90 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -14,8 +14,7 @@ objects already existing in the database. """ -from django.contrib.contenttypes.models import ContentType -from django.db.utils import OperationalError, ProgrammingError +from django.utils.functional import SimpleLazyObject # limit symbol import from API __all__ = ( @@ -29,232 +28,254 @@ __all__ = ( _GA = object.__getattribute__ -# import objects this way to avoid circular import problems -try: - ObjectDB = ContentType.objects.get(app_label="objects", model="objectdb").model_class() - ScriptDB = ContentType.objects.get(app_label="scripts", model="scriptdb").model_class() - AccountDB = ContentType.objects.get(app_label="accounts", model="accountdb").model_class() - Msg = ContentType.objects.get(app_label="comms", model="msg").model_class() - ChannelDB = ContentType.objects.get(app_label="comms", model="channeldb").model_class() - HelpEntry = ContentType.objects.get(app_label="help", model="helpentry").model_class() - Tag = ContentType.objects.get(app_label="typeclasses", model="tag").model_class() -except (OperationalError, ProgrammingError): - # this is a fallback used during tests/doc building - print("Database not available yet - using temporary fallback for create managers.") - from evennia.accounts.models import AccountDB - from evennia.comms.models import ChannelDB, Msg - from evennia.help.models import HelpEntry - from evennia.objects.models import ObjectDB - from evennia.scripts.models import ScriptDB - from evennia.typeclasses.tags import Tag # noqa -# -# Game Object creation -# -# Create a new in-game object. -# -# Keyword Args: -# typeclass (class or str): Class or python path to a typeclass. -# key (str): Name of the new object. If not set, a name of -# `#dbref` will be set. -# location (Object or str): Obj or #dbref to use as the location of the new object. -# home (Object or str): Obj or #dbref to use as the object's home location. -# permissions (list): A list of permission strings or tuples (permstring, category). -# locks (str): one or more lockstrings, separated by semicolons. -# aliases (list): A list of alternative keys or tuples (aliasstring, category). -# tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). -# destination (Object or str): Obj or #dbref to use as an Exit's target. -# report_to (Object): The object to return error messages to. -# nohome (bool): This allows the creation of objects without a -# default home location; only used when creating the default -# location itself or during unittests. -# attributes (list): Tuples on the form (key, value) or (key, value, category), -# (key, value, lockstring) or (key, value, lockstring, default_access). -# to set as Attributes on the new object. -# nattributes (list): Non-persistent tuples on the form (key, value). Note that -# adding this rarely makes sense since this data will not survive a reload. -# -# Returns: -# object (Object): A newly created object of the given typeclass. -# -# Raises: -# ObjectDB.DoesNotExist: If trying to create an Object with -# `location` or `home` that can't be found. -# +# Lazy-loaded model classes +def _get_objectdb(): + from django.contrib.contenttypes.models import ContentType -create_object = ObjectDB.objects.create_object -# alias for create_object + return ContentType.objects.get(app_label="objects", model="objectdb").model_class() + + +def _get_scriptdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="scripts", model="scriptdb").model_class() + + +def _get_accountdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="accounts", model="accountdb").model_class() + + +def _get_msg(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="comms", model="msg").model_class() + + +def _get_channeldb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="comms", model="channeldb").model_class() + + +def _get_helpentry(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="help", model="helpentry").model_class() + + +def _get_tag(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="typeclasses", model="tag").model_class() + + +# Lazy model instances +ObjectDB = SimpleLazyObject(_get_objectdb) +ScriptDB = SimpleLazyObject(_get_scriptdb) +AccountDB = SimpleLazyObject(_get_accountdb) +Msg = SimpleLazyObject(_get_msg) +ChannelDB = SimpleLazyObject(_get_channeldb) +HelpEntry = SimpleLazyObject(_get_helpentry) +Tag = SimpleLazyObject(_get_tag) + + +def create_object(*args, **kwargs): + """ + Create a new in-game object. + + Keyword Args: + typeclass (class or str): Class or python path to a typeclass. + key (str): Name of the new object. If not set, a name of + `#dbref` will be set. + location (Object or str): Obj or #dbref to use as the location of the new object. + home (Object or str): Obj or #dbref to use as the object's home location. + permissions (list): A list of permission strings or tuples (permstring, category). + locks (str): one or more lockstrings, separated by semicolons. + aliases (list): A list of alternative keys or tuples (aliasstring, category). + tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). + destination (Object or str): Obj or #dbref to use as an Exit's target. + report_to (Object): The object to return error messages to. + nohome (bool): This allows the creation of objects without a + default home location; only used when creating the default + location itself or during unittests. + attributes (list): Tuples on the form (key, value) or (key, value, category), + (key, value, lockstring) or (key, value, lockstring, default_access). + to set as Attributes on the new object. + nattributes (list): Non-persistent tuples on the form (key, value). Note that + adding this rarely makes sense since this data will not survive a reload. + + Returns: + object (Object): A newly created object of the given typeclass. + + Raises: + ObjectDB.DoesNotExist: If trying to create an Object with + `location` or `home` that can't be found. + """ + return ObjectDB.objects.create_object(*args, **kwargs) + + +def create_script(*args, **kwargs): + """ + Create a new script. All scripts are a combination of a database + object that communicates with the database, and an typeclass that + 'decorates' the database object into being different types of + scripts. It's behaviour is similar to the game objects except + scripts has a time component and are more limited in scope. + + Keyword Args: + typeclass (class or str): Class or python path to a typeclass. + key (str): Name of the new object. If not set, a name of + #dbref will be set. + obj (Object): The entity on which this Script sits. If this + is `None`, we are creating a "global" script. + account (Account): The account on which this Script sits. It is + exclusiv to `obj`. + locks (str): one or more lockstrings, separated by semicolons. + interval (int): The triggering interval for this Script, in + seconds. If unset, the Script will not have a timing + component. + start_delay (bool): If `True`, will wait `interval` seconds + before triggering the first time. + repeats (int): The number of times to trigger before stopping. + If unset, will repeat indefinitely. + persistent (bool): If this Script survives a server shutdown + or not (all Scripts will survive a reload). + autostart (bool): If this Script will start immediately when + created or if the `start` method must be called explicitly. + report_to (Object): The object to return error messages to. + desc (str): Optional description of script + tags (list): List of tags or tuples (tag, category). + attributes (list): List if tuples (key, value) or (key, value, category) + (key, value, lockstring) or (key, value, lockstring, default_access). + + Returns: + script (obj): An instance of the script created + + See evennia.scripts.manager for methods to manipulate existing + scripts in the database. + """ + return ScriptDB.objects.create_script(*args, **kwargs) + + +def create_help_entry(*args, **kwargs): + """ + Create a static help entry in the help database. Note that Command + help entries are dynamic and directly taken from the __doc__ + entries of the command. The database-stored help entries are + intended for more general help on the game, more extensive info, + in-game setting information and so on. + + Args: + key (str): The name of the help entry. + entrytext (str): The body of te help entry + category (str, optional): The help category of the entry. + locks (str, optional): A lockstring to restrict access. + aliases (list of str, optional): List of alternative (likely shorter) keynames. + tags (lst, optional): List of tags or tuples `(tag, category)`. + + Returns: + help (HelpEntry): A newly created help entry. + """ + return HelpEntry.objects.create_help(*args, **kwargs) + + +def create_message(*args, **kwargs): + """ + Create a new communication Msg. Msgs represent a unit of + database-persistent communication between entites. + + Args: + senderobj (Object, Account, Script, str or list): The entity (or + entities) sending the Msg. If a `str`, this is the id-string + for an external sender type. + message (str): Text with the message. Eventual headers, titles + etc should all be included in this text string. Formatting + will be retained. + receivers (Object, Account, Script, str or list): An Account/Object to send + to, or a list of them. If a string, it's an identifier for an external + receiver. + locks (str): Lock definition string. + tags (list): A list of tags or tuples `(tag, category)`. + header (str): Mime-type or other optional information for the message + + Notes: + The Comm system is created to be very open-ended, so it's fully + possible to let a message both go several receivers at the same time, + it's up to the command definitions to limit this as desired. + """ + return Msg.objects.create_message(*args, **kwargs) + + +def create_channel(*args, **kwargs): + """ + Create A communication Channel. A Channel serves as a central hub + for distributing Msgs to groups of people without specifying the + receivers explicitly. Instead accounts may 'connect' to the channel + and follow the flow of messages. By default the channel allows + access to all old messages, but this can be turned off with the + keep_log switch. + + Args: + key (str): This must be unique. + + Keyword Args: + aliases (list of str): List of alternative (likely shorter) keynames. + desc (str): A description of the channel, for use in listings. + locks (str): Lockstring. + keep_log (bool): Log channel throughput. + typeclass (str or class): The typeclass of the Channel (not + often used). + tags (list): A list of tags or tuples `(tag[,category[,data]])`. + attrs (list): List of attributes on form `(name, value[,category[,lockstring]])` + + Returns: + channel (Channel): A newly created channel. + """ + return ChannelDB.objects.create_channel(*args, **kwargs) + + +def create_account(*args, **kwargs): + """ + This creates a new account. + + Args: + key (str): The account's name. This should be unique. + email (str or None): Email on valid addr@addr.domain form. If + the empty string, will be set to None. + password (str): Password in cleartext. + + Keyword Args: + typeclass (str): The typeclass to use for the account. + is_superuser (bool): Whether or not this account is to be a superuser + locks (str): Lockstring. + permission (list): List of permission strings. + tags (list): List of Tags on form `(key, category[, data])` + attributes (list): List of Attributes on form + `(key, value [, category, [,lockstring [, default_pass]]])` + report_to (Object): An object with a msg() method to report + errors to. If not given, errors will be logged. + + Returns: + Account: The newly created Account. + Raises: + ValueError: If `key` already exists in database. + + Notes: + Usually only the server admin should need to be superuser, all + other access levels can be handled with more fine-grained + permissions or groups. A superuser bypasses all lock checking + operations and is thus not suitable for play-testing the game. + """ + return AccountDB.objects.create_account(*args, **kwargs) + + +# Aliases for the creation functions object = create_object - - -# -# Script creation - -# Create a new script. All scripts are a combination of a database -# object that communicates with the database, and an typeclass that -# 'decorates' the database object into being different types of -# scripts. It's behaviour is similar to the game objects except -# scripts has a time component and are more limited in scope. -# -# Keyword Args: -# typeclass (class or str): Class or python path to a typeclass. -# key (str): Name of the new object. If not set, a name of -# #dbref will be set. -# obj (Object): The entity on which this Script sits. If this -# is `None`, we are creating a "global" script. -# account (Account): The account on which this Script sits. It is -# exclusiv to `obj`. -# locks (str): one or more lockstrings, separated by semicolons. -# interval (int): The triggering interval for this Script, in -# seconds. If unset, the Script will not have a timing -# component. -# start_delay (bool): If `True`, will wait `interval` seconds -# before triggering the first time. -# repeats (int): The number of times to trigger before stopping. -# If unset, will repeat indefinitely. -# persistent (bool): If this Script survives a server shutdown -# or not (all Scripts will survive a reload). -# autostart (bool): If this Script will start immediately when -# created or if the `start` method must be called explicitly. -# report_to (Object): The object to return error messages to. -# desc (str): Optional description of script -# tags (list): List of tags or tuples (tag, category). -# attributes (list): List if tuples (key, value) or (key, value, category) -# (key, value, lockstring) or (key, value, lockstring, default_access). -# -# Returns: -# script (obj): An instance of the script created -# -# See evennia.scripts.manager for methods to manipulate existing -# scripts in the database. - -create_script = ScriptDB.objects.create_script -# alias script = create_script - - -# -# Help entry creation -# - -# """ -# Create a static help entry in the help database. Note that Command -# help entries are dynamic and directly taken from the __doc__ -# entries of the command. The database-stored help entries are -# intended for more general help on the game, more extensive info, -# in-game setting information and so on. -# -# Args: -# key (str): The name of the help entry. -# entrytext (str): The body of te help entry -# category (str, optional): The help category of the entry. -# locks (str, optional): A lockstring to restrict access. -# aliases (list of str, optional): List of alternative (likely shorter) keynames. -# tags (lst, optional): List of tags or tuples `(tag, category)`. -# -# Returns: -# help (HelpEntry): A newly created help entry. -# - -create_help_entry = HelpEntry.objects.create_help -# alias help_entry = create_help_entry - - -# -# Comm system methods - -# -# Create a new communication Msg. Msgs represent a unit of -# database-persistent communication between entites. -# -# Args: -# senderobj (Object, Account, Script, str or list): The entity (or -# entities) sending the Msg. If a `str`, this is the id-string -# for an external sender type. -# message (str): Text with the message. Eventual headers, titles -# etc should all be included in this text string. Formatting -# will be retained. -# receivers (Object, Account, Script, str or list): An Account/Object to send -# to, or a list of them. If a string, it's an identifier for an external -# receiver. -# locks (str): Lock definition string. -# tags (list): A list of tags or tuples `(tag, category)`. -# header (str): Mime-type or other optional information for the message -# -# Notes: -# The Comm system is created to be very open-ended, so it's fully -# possible to let a message both go several receivers at the same time, -# it's up to the command definitions to limit this as desired. -# - -create_message = Msg.objects.create_message message = create_message -create_msg = create_message - - -# Create A communication Channel. A Channel serves as a central hub -# for distributing Msgs to groups of people without specifying the -# receivers explicitly. Instead accounts may 'connect' to the channel -# and follow the flow of messages. By default the channel allows -# access to all old messages, but this can be turned off with the -# keep_log switch. -# -# Args: -# key (str): This must be unique. -# -# Keyword Args: -# aliases (list of str): List of alternative (likely shorter) keynames. -# desc (str): A description of the channel, for use in listings. -# locks (str): Lockstring. -# keep_log (bool): Log channel throughput. -# typeclass (str or class): The typeclass of the Channel (not -# often used). -# tags (list): A list of tags or tuples `(tag, category)`. -# -# Returns: -# channel (Channel): A newly created channel. -# - -create_channel = ChannelDB.objects.create_channel channel = create_channel - - -# -# Account creation methods -# - -# This creates a new account. -# -# Args: -# key (str): The account's name. This should be unique. -# email (str or None): Email on valid addr@addr.domain form. If -# the empty string, will be set to None. -# password (str): Password in cleartext. -# -# Keyword Args: -# typeclass (str): The typeclass to use for the account. -# is_superuser (bool): Wether or not this account is to be a superuser -# locks (str): Lockstring. -# permission (list): List of permission strings. -# tags (list): List of Tags on form `(key, category[, data])` -# attributes (list): List of Attributes on form -# `(key, value [, category, [,lockstring [, default_pass]]])` -# report_to (Object): An object with a msg() method to report -# errors to. If not given, errors will be logged. -# -# Returns: -# Account: The newly created Account. -# Raises: -# ValueError: If `key` already exists in database. -# -# -# Notes: -# Usually only the server admin should need to be superuser, all -# other access levels can be handled with more fine-grained -# permissions or groups. A superuser bypasses all lock checking -# operations and is thus not suitable for play-testing the game. - -create_account = AccountDB.objects.create_account -# alias account = create_account diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py index cc79eed89e..73d00e7cc2 100644 --- a/evennia/utils/dbserialize.py +++ b/evennia/utils/dbserialize.py @@ -28,6 +28,8 @@ try: except ImportError: from pickle import dumps, loads +from enum import IntFlag + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.utils.safestring import SafeString @@ -672,6 +674,8 @@ def to_pickle(data): if dtype in (str, int, float, bool, bytes, SafeString): return item + elif isinstance(item, IntFlag): + return item.value elif dtype == tuple: return tuple(process_item(val) for val in item) elif dtype in (list, _SaverList): diff --git a/evennia/utils/eveditor.py b/evennia/utils/eveditor.py index 2657d61393..debd63c7de 100644 --- a/evennia/utils/eveditor.py +++ b/evennia/utils/eveditor.py @@ -731,7 +731,7 @@ class CmdEditorGroup(CmdEditorBase): + " [f]ull (default), [c]enter, [r]right or [l]eft" ) return - align = align_map[self.arg1.lower()] if self.arg1 else "f" + align = align_map[self.arg1.lower()] if self.arg1 else "l" width = _DEFAULT_WIDTH if self.arg2: value = self.arg2.lstrip("=") diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 6bf4361827..106e31ecde 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1414,21 +1414,27 @@ def list_node(option_generator, select=None, pagesize=10): { "key": (_("|Wcurrent|n"), "c"), "desc": "|W({}/{})|n".format(page_index + 1, npages), - "goto": (lambda caller: None, {"optionpage_index": page_index}), + "goto": (lambda caller: None, kwargs | {"optionpage_index": page_index}), } ) if page_index > 0: options.append( { "key": (_("|wp|Wrevious page|n"), "p"), - "goto": (lambda caller: None, {"optionpage_index": page_index - 1}), + "goto": ( + lambda caller: None, + kwargs | {"optionpage_index": page_index - 1}, + ), } ) if page_index < npages - 1: options.append( { "key": (_("|wn|Wext page|n"), "n"), - "goto": (lambda caller: None, {"optionpage_index": page_index + 1}), + "goto": ( + lambda caller: None, + kwargs | {"optionpage_index": page_index + 1}, + ), } ) diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 57192e2e90..3bbd42d425 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -79,7 +79,7 @@ class CmdMore(Command): Implement the command """ more = self.caller.ndb._more - if not more and inherits_from(self.caller, evennia.DefaultObject): + if not more and hasattr(self.caller, "account") and self.caller.account: more = self.caller.account.ndb._more if not more: self.caller.msg("Error in loading the pager. Contact an admin.") @@ -111,9 +111,13 @@ class CmdMoreExit(Command): def func(self): """ Exit pager and re-fire the failed command. - """ more = self.caller.ndb._more + if not more and hasattr(self.caller, "account") and self.caller.account: + more = self.caller.account.ndb._more + if not more: + self.caller.msg("Error in exiting the pager. Contact an admin.") + return more.page_quit() # re-fire the command (in new cmdset) diff --git a/evennia/utils/funcparser.py b/evennia/utils/funcparser.py index c0de83838a..2ed3243004 100644 --- a/evennia/utils/funcparser.py +++ b/evennia/utils/funcparser.py @@ -334,18 +334,20 @@ class FuncParser: infuncstr = "" # string parts inside the current level of $funcdef (including $) literal_infuncstr = False - for char in string: + for ichar, char in enumerate(string): if escaped: # always store escaped characters verbatim if curr_func: infuncstr += char + curr_func.rawstr += char else: fullstr += char escaped = False continue - if char == escape_char: - # don't store the escape-char itself + if char == escape_char and string[ichar + 1 : ichar + 2] != escape_char: + # don't store the escape-char itself, but keep one escape-char, + # if it's followed by another escape-char escaped = True continue @@ -372,7 +374,8 @@ class FuncParser: curr_func.open_lsquare = open_lsquare curr_func.open_lcurly = open_lcurly # we must strip the remaining funcstr so it's not counted twice - curr_func.rawstr = curr_func.rawstr[: -len(infuncstr)] + if len(infuncstr) > 0: + curr_func.rawstr = curr_func.rawstr[: -len(infuncstr)] current_kwarg = "" infuncstr = "" double_quoted = -1 diff --git a/evennia/utils/gametime.py b/evennia/utils/gametime.py index 297badde51..9cd2b49d46 100644 --- a/evennia/utils/gametime.py +++ b/evennia/utils/gametime.py @@ -14,7 +14,7 @@ from django.conf import settings from django.db.utils import OperationalError import evennia -from evennia import DefaultScript +from evennia.scripts.scripts import DefaultScript from evennia.server.models import ServerConfig from evennia.utils.create import create_script diff --git a/evennia/utils/hex_colors.py b/evennia/utils/hex_colors.py index 3ca921bc3b..dedfadc515 100644 --- a/evennia/utils/hex_colors.py +++ b/evennia/utils/hex_colors.py @@ -1,3 +1,8 @@ +""" +Truecolor 24bit hex color support, on the form `|#00FF00`, `|[00FF00` or `|#0F0 or `|[#0F0` + +""" + import re diff --git a/evennia/utils/search.py b/evennia/utils/search.py index 7f94ed6378..26f4a384b1 100644 --- a/evennia/utils/search.py +++ b/evennia/utils/search.py @@ -21,13 +21,9 @@ Example: To reach the search method 'get_object_with_account' > from evennia.objects.models import ObjectDB > match = Object.objects.get_object_with_account(...) - """ -# Import the manager methods to be wrapped - -from django.contrib.contenttypes.models import ContentType -from django.db.utils import OperationalError, ProgrammingError +from django.utils.functional import SimpleLazyObject # limit symbol import from API __all__ = ( @@ -45,180 +41,265 @@ __all__ = ( ) -# import objects this way to avoid circular import problems -try: - ObjectDB = ContentType.objects.get(app_label="objects", model="objectdb").model_class() - AccountDB = ContentType.objects.get(app_label="accounts", model="accountdb").model_class() - ScriptDB = ContentType.objects.get(app_label="scripts", model="scriptdb").model_class() - Msg = ContentType.objects.get(app_label="comms", model="msg").model_class() - ChannelDB = ContentType.objects.get(app_label="comms", model="channeldb").model_class() - HelpEntry = ContentType.objects.get(app_label="help", model="helpentry").model_class() - Tag = ContentType.objects.get(app_label="typeclasses", model="tag").model_class() -except (OperationalError, ProgrammingError): - # this is a fallback used during tests/doc building - print("Database not available yet - using temporary fallback for search managers.") - from evennia.accounts.models import AccountDB - from evennia.comms.models import ChannelDB, Msg - from evennia.help.models import HelpEntry - from evennia.objects.models import ObjectDB - from evennia.scripts.models import ScriptDB - from evennia.typeclasses.tags import Tag # noqa +# Lazy-loaded model classes +def _get_objectdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="objects", model="objectdb").model_class() + + +def _get_accountdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="accounts", model="accountdb").model_class() + + +def _get_scriptdb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="scripts", model="scriptdb").model_class() + + +def _get_msg(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="comms", model="msg").model_class() + + +def _get_channeldb(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="comms", model="channeldb").model_class() + + +def _get_helpentry(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="help", model="helpentry").model_class() + + +def _get_tag(): + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get(app_label="typeclasses", model="tag").model_class() + + +# Lazy model instances +ObjectDB = SimpleLazyObject(_get_objectdb) +AccountDB = SimpleLazyObject(_get_accountdb) +ScriptDB = SimpleLazyObject(_get_scriptdb) +Msg = SimpleLazyObject(_get_msg) +ChannelDB = SimpleLazyObject(_get_channeldb) +HelpEntry = SimpleLazyObject(_get_helpentry) +Tag = SimpleLazyObject(_get_tag) + # ------------------------------------------------------------------- # Search manager-wrappers # ------------------------------------------------------------------- -# -# Search objects as a character -# -# NOTE: A more powerful wrapper of this method -# is reachable from within each command class -# by using self.caller.search()! -# -# def object_search(self, ostring=None, -# attribute_name=None, -# typeclass=None, -# candidates=None, -# exact=True): -# -# Search globally or in a list of candidates and return results. -# The result is always a list of Objects (or the empty list) -# -# Arguments: -# ostring: (str) The string to compare names against. By default (if -# not attribute_name is set), this will search object.key -# and object.aliases in order. Can also be on the form #dbref, -# which will, if exact=True be matched against primary key. -# attribute_name: (str): Use this named ObjectAttribute to match ostring -# against, instead of the defaults. -# typeclass (str or TypeClass): restrict matches to objects having -# this typeclass. This will help speed up global searches. -# candidates (list obj ObjectDBs): If supplied, search will only be -# performed among the candidates in this list. A common list -# of candidates is the contents of the current location. -# exact (bool): Match names/aliases exactly or partially. Partial -# matching matches the beginning of words in the names/aliases, -# using a matching routine to separate multiple matches in -# names with multiple components (so "bi sw" will match -# "Big sword"). Since this is more expensive than exact -# matching, it is recommended to be used together with -# the objlist keyword to limit the number of possibilities. -# This keyword has no meaning if attribute_name is set. -# -# Returns: -# A list of matching objects (or a list with one unique match) -# def object_search(self, ostring, caller=None, -# candidates=None, -# attribute_name=None): -# -search_object = ObjectDB.objects.search_object + +def search_object(*args, **kwargs): + """ + Search for objects in the database. + + Args: + key (str or int): Object key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all objects. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + candidates (list): Only search among these object candidates, + if given. Default is to search all objects. + attribute_name (str): If set, search by objects with this attribute_name + defined on them, with the value specified by `attribute_value`. + attribute_value (any): What value the given attribute_name must have. + location (Object): Filter by objects at this location. + typeclass (str or TypeClass): Filter by objects having this typeclass. + This can also be a list of typeclasses. + tags (str or list): Filter by objects having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + nofetch (bool): Don't fetch typeclass and perms data from db. + This is faster but gives less info. + + Returns: + matches (list): List of Objects matching the search criteria. + """ + return ObjectDB.objects.search_object(*args, **kwargs) + + search_objects = search_object object_search = search_object objects = search_objects -# -# Search for accounts -# -# account_search(self, ostring) -# Searches for a particular account by name or -# database id. -# -# ostring = a string or database id. -# +def search_account(*args, **kwargs): + """ + Search for accounts in the database. + + Args: + key (str or int): Account key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all accounts. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + candidates (list): Only search among these account candidates, + if given. Default is to search all accounts. + attribute_name (str): If set, search by accounts with this attribute_name + defined on them, with the value specified by `attribute_value`. + attribute_value (any): What value the given attribute_name must have. + tags (str or list): Filter by accounts having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + nofetch (bool): Don't fetch typeclass and perms data from db. + This is faster but gives less info. + + Returns: + matches (list): List of Accounts matching the search criteria. + """ + return AccountDB.objects.search_account(*args, **kwargs) + -search_account = AccountDB.objects.search_account search_accounts = search_account account_search = search_account accounts = search_accounts -# -# Searching for scripts -# -# script_search(self, ostring, obj=None, only_timed=False) -# -# Search for a particular script. -# -# ostring - search criterion - a script ID or key -# obj - limit search to scripts defined on this object -# only_timed - limit search only to scripts that run -# on a timer. -# -search_script = ScriptDB.objects.search_script +def search_script(*args, **kwargs): + """ + Search for scripts in the database. + + Args: + key (str or int): Script key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all scripts. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + candidates (list): Only search among these script candidates, + if given. Default is to search all scripts. + attribute_name (str): If set, search by scripts with this attribute_name + defined on them, with the value specified by `attribute_value`. + attribute_value (any): What value the given attribute_name must have. + obj (Object): Filter by scripts defined on this object. + account (Account): Filter by scripts defined on this account. + typeclass (str or TypeClass): Filter by scripts having this typeclass. + This can also be a list of typeclasses. + tags (str or list): Filter by scripts having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + nofetch (bool): Don't fetch typeclass and perms data from db. + This is faster but gives less info. + + Returns: + matches (list): List of Scripts matching the search criteria. + """ + return ScriptDB.objects.search_script(*args, **kwargs) + + search_scripts = search_script script_search = search_script scripts = search_scripts -# -# Searching for communication messages -# -# -# message_search(self, sender=None, receiver=None, channel=None, freetext=None) -# -# Search the message database for particular messages. At least one -# of the arguments must be given to do a search. -# -# sender - get messages sent by a particular account -# receiver - get messages received by a certain account -# channel - get messages sent to a particular channel -# freetext - Search for a text string in a message. -# NOTE: This can potentially be slow, so make sure to supply -# one of the other arguments to limit the search. -# -search_message = Msg.objects.search_message + +def search_message(*args, **kwargs): + """ + Search for messages in the database. + + Args: + sender (Object, Account or str): Filter by messages sent by this entity. + If a string, this is an external sender name. + receiver (Object, Account or str): Filter by messages received by this entity. + If a string, this is an external receiver name. + channel (Channel): Filter by messages sent to this channel. + date (datetime): Filter by messages sent on this date. + type (str): Filter by messages of this type. + tags (str or list): Filter by messages having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + exclude_tags (str or list): Exclude messages with these tags. + search_text (str): Search for text in message content. + exact (bool): If True, require exact text match. Default False. + + Returns: + matches (list): List of Messages matching the search criteria. + """ + return Msg.objects.search_message(*args, **kwargs) + + search_messages = search_message message_search = search_message messages = search_messages -# -# Search for Communication Channels -# -# channel_search(self, ostring) -# -# Search the channel database for a particular channel. -# -# ostring - the key or database id of the channel. -# exact - requires an exact ostring match (not case sensitive) -# -search_channel = ChannelDB.objects.search_channel +def search_channel(*args, **kwargs): + """ + Search for channels in the database. + + Args: + key (str or int): Channel key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all channels. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + candidates (list): Only search among these channel candidates, + if given. Default is to search all channels. + attribute_name (str): If set, search by channels with this attribute_name + defined on them, with the value specified by `attribute_value`. + attribute_value (any): What value the given attribute_name must have. + typeclass (str or TypeClass): Filter by channels having this typeclass. + This can also be a list of typeclasses. + tags (str or list): Filter by channels having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + nofetch (bool): Don't fetch typeclass and perms data from db. + This is faster but gives less info. + + Returns: + matches (list): List of Channels matching the search criteria. + """ + return ChannelDB.objects.search_channel(*args, **kwargs) + + search_channels = search_channel channel_search = search_channel channels = search_channels -# -# Find help entry objects. -# -# search_help(self, ostring, help_category=None) -# -# Retrieve a search entry object. -# -# ostring - the help topic to look for -# category - limit the search to a particular help topic -# -search_help = HelpEntry.objects.search_help +def search_help(*args, **kwargs): + """ + Search for help entries in the database. + + Args: + key (str or int): Help entry key or dbref to search for. This can also + be a list of keys/dbrefs. `None` (default) returns all help entries. + exact (bool): Only valid for string keys. If True, requires exact + key match, otherwise also match key with case-insensitive and + partial matching. Default is True. + category (str): Filter by help entries in this category. + tags (str or list): Filter by help entries having one or more Tags. + This can be a single tag key, a list of tag keys, or a list of + tuples (tag_key, tag_category). + locks (str): Filter by help entries with these locks. + + Returns: + matches (list): List of HelpEntries matching the search criteria. + """ + return HelpEntry.objects.search_help(*args, **kwargs) + + search_help_entry = search_help search_help_entries = search_help help_entry_search = search_help help_entries = search_help -# Locate Attributes - -# search_object_attribute(key, category, value, strvalue) (also search_attribute works) -# search_account_attribute(key, category, value, strvalue) (also search_attribute works) -# search_script_attribute(key, category, value, strvalue) (also search_attribute works) -# search_channel_attribute(key, category, value, strvalue) (also search_attribute works) - -# Note that these return the object attached to the Attribute, -# not the attribute object itself (this is usually what you want) - - def search_object_attribute( key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs ): + """ + Search for objects by their attributes. + """ return ObjectDB.objects.get_by_attribute( key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) @@ -227,6 +308,9 @@ def search_object_attribute( def search_account_attribute( key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs ): + """ + Search for accounts by their attributes. + """ return AccountDB.objects.get_by_attribute( key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) @@ -235,6 +319,9 @@ def search_account_attribute( def search_script_attribute( key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs ): + """ + Search for scripts by their attributes. + """ return ScriptDB.objects.get_by_attribute( key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) @@ -243,23 +330,20 @@ def search_script_attribute( def search_channel_attribute( key=None, category=None, value=None, strvalue=None, attrtype=None, **kwargs ): + """ + Search for channels by their attributes. + """ return ChannelDB.objects.get_by_attribute( key=key, category=category, value=value, strvalue=strvalue, attrtype=attrtype, **kwargs ) -# search for attribute objects -search_attribute_object = ObjectDB.objects.get_attribute - -# Locate Tags - -# search_object_tag(key=None, category=None) (also search_tag works) -# search_account_tag(key=None, category=None) -# search_script_tag(key=None, category=None) -# search_channel_tag(key=None, category=None) - -# Note that this returns the object attached to the tag, not the tag -# object itself (this is usually what you want) +# Replace direct assignments with functions +def search_attribute_object(*args, **kwargs): + """ + Search for attribute objects. + """ + return ObjectDB.objects.get_attribute(*args, **kwargs) def search_object_by_tag(key=None, category=None, tagtype=None, **kwargs): @@ -281,7 +365,6 @@ def search_object_by_tag(key=None, category=None, tagtype=None, **kwargs): matches (list): List of Objects with tags matching the search criteria, or an empty list if no matches were found. - """ return ObjectDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) @@ -292,23 +375,6 @@ search_tag = search_object_by_tag # this is the most common case def search_account_tag(key=None, category=None, tagtype=None, **kwargs): """ Find account based on tag or category. - - Args: - key (str, optional): The tag key to search for. - category (str, optional): The category of tag - to search for. If not set, uncategorized - tags will be searched. - tagtype (str, optional): 'type' of Tag, by default - this is either `None` (a normal Tag), `alias` or - `permission`. This always apply to all queried tags. - kwargs (any): Other optional parameter that may be supported - by the manager method. - - Returns: - matches (list): List of Accounts with tags matching - the search criteria, or an empty list if no - matches were found. - """ return AccountDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) @@ -316,23 +382,6 @@ def search_account_tag(key=None, category=None, tagtype=None, **kwargs): def search_script_tag(key=None, category=None, tagtype=None, **kwargs): """ Find script based on tag or category. - - Args: - key (str, optional): The tag key to search for. - category (str, optional): The category of tag - to search for. If not set, uncategorized - tags will be searched. - tagtype (str, optional): 'type' of Tag, by default - this is either `None` (a normal Tag), `alias` or - `permission`. This always apply to all queried tags. - kwargs (any): Other optional parameter that may be supported - by the manager method. - - Returns: - matches (list): List of Scripts with tags matching - the search criteria, or an empty list if no - matches were found. - """ return ScriptDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) @@ -340,35 +389,16 @@ def search_script_tag(key=None, category=None, tagtype=None, **kwargs): def search_channel_tag(key=None, category=None, tagtype=None, **kwargs): """ Find channel based on tag or category. - - Args: - key (str, optional): The tag key to search for. - category (str, optional): The category of tag - to search for. If not set, uncategorized - tags will be searched. - tagtype (str, optional): 'type' of Tag, by default - this is either `None` (a normal Tag), `alias` or - `permission`. This always apply to all queried tags. - kwargs (any): Other optional parameter that may be supported - by the manager method. - - Returns: - matches (list): List of Channels with tags matching - the search criteria, or an empty list if no - matches were found. - """ return ChannelDB.objects.get_by_tag(key=key, category=category, tagtype=tagtype, **kwargs) -# search for tag objects (not the objects they are attached to -search_tag_object = ObjectDB.objects.get_tag - - -# Locate Objects by Typeclass - -# search_objects_by_typeclass(typeclass="", include_children=False, include_parents=False) (also search_typeclass works) -# This returns the objects of the given typeclass +# Replace direct assignment with function +def search_tag_object(*args, **kwargs): + """ + Search for tag objects. + """ + return ObjectDB.objects.get_tag(*args, **kwargs) def search_objects_by_typeclass(typeclass, include_children=False, include_parents=False): diff --git a/evennia/utils/tests/test_batchprocessors.py b/evennia/utils/tests/test_batchprocessors.py index 61374e6711..a55a2949b8 100644 --- a/evennia/utils/tests/test_batchprocessors.py +++ b/evennia/utils/tests/test_batchprocessors.py @@ -1,4 +1,4 @@ -"""Tests for batchprocessors """ +"""Tests for batchprocessors""" import codecs import textwrap diff --git a/evennia/utils/tests/test_containers.py b/evennia/utils/tests/test_containers.py index ee0f4d2d4a..5236c3f947 100644 --- a/evennia/utils/tests/test_containers.py +++ b/evennia/utils/tests/test_containers.py @@ -2,7 +2,6 @@ import unittest from django.conf import settings from django.test import override_settings - from evennia import DefaultScript from evennia.utils import containers from evennia.utils.utils import class_from_module @@ -10,15 +9,16 @@ from evennia.utils.utils import class_from_module _BASE_TYPECLASS = class_from_module(settings.BASE_SCRIPT_TYPECLASS) -class GoodScript(DefaultScript): +class UnittestGoodScript(DefaultScript): pass -class InvalidScript: +class UnittestInvalidScript: pass class TestGlobalScriptContainer(unittest.TestCase): + def test_init_with_no_scripts(self): gsc = containers.GlobalScriptContainer() @@ -60,7 +60,7 @@ class TestGlobalScriptContainer(unittest.TestCase): @override_settings( GLOBAL_SCRIPTS={ - "script_name": {"typeclass": "evennia.utils.tests.test_containers.GoodScript"} + "script_name": {"typeclass": "evennia.utils.tests.test_containers.UnittestGoodScript"} } ) def test_start_with_valid_script(self): @@ -70,11 +70,13 @@ class TestGlobalScriptContainer(unittest.TestCase): self.assertEqual(len(gsc.typeclass_storage), 1) self.assertIn("script_name", gsc.typeclass_storage) - self.assertEqual(gsc.typeclass_storage["script_name"], GoodScript) + self.assertEqual(gsc.typeclass_storage["script_name"], UnittestGoodScript) @override_settings( GLOBAL_SCRIPTS={ - "script_name": {"typeclass": "evennia.utils.tests.test_containers.InvalidScript"} + "script_name": { + "typeclass": "evennia.utils.tests.test_containers.UnittestInvalidScript" + } } ) def test_start_with_invalid_script(self): @@ -85,7 +87,7 @@ class TestGlobalScriptContainer(unittest.TestCase): gsc.start() # check for general attribute failure on the invalid class to preserve against future code-rder changes self.assertTrue( - str(err.exception).startswith("type object 'InvalidScript' has no attribute"), + str(err.exception).startswith("type object 'UnittestInvalidScript' has no attribute"), err.exception, ) @@ -105,3 +107,24 @@ class TestGlobalScriptContainer(unittest.TestCase): str(err.exception).startswith("cannot import name 'nonexistent_module' from 'evennia'"), err.exception, ) + + @override_settings( + GLOBAL_SCRIPTS={ + "script_name": {"typeclass": "evennia.utils.tests.test_containers.UnittestGoodScript"} + } + ) + def test_using_global_script__all(self): + """ + Using the GlobalScriptContainer.all() to get all scripts + + Tests https://github.com/evennia/evennia/issues/3788 + + """ + from evennia.scripts.models import ScriptDB + + ScriptDB.objects.all().delete() # clean up any old scripts + + gsc = containers.GlobalScriptContainer() + script = gsc.get("script_name") + result = gsc.all() + self.assertEqual(result, [script]) diff --git a/evennia/utils/tests/test_dbserialize.py b/evennia/utils/tests/test_dbserialize.py index f70bf702a0..2533afd99a 100644 --- a/evennia/utils/tests/test_dbserialize.py +++ b/evennia/utils/tests/test_dbserialize.py @@ -3,6 +3,7 @@ Tests for dbserialize module """ from collections import defaultdict, deque +from enum import IntFlag, auto from django.test import TestCase from parameterized import parameterized @@ -20,6 +21,14 @@ class TestDbSerialize(TestCase): self.obj = DefaultObject(db_key="Tester") self.obj.save() + def test_intflag(self): + class TestFlag(IntFlag): + foo = auto() + + self.obj.db.test = TestFlag.foo + self.assertEqual(self.obj.db.test, TestFlag.foo) + self.obj.save() + def test_constants(self): self.obj.db.test = 1 self.obj.db.test += 1 diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 9a83b440df..4f97d684f6 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -29,6 +29,7 @@ from evennia.utils.test_resources import BaseEvenniaTest class TestEvMenu(TestCase): "Run the EvMenu testing." + menutree = {} # can also be the path to the menu tree startnode = "start" cmdset_mergetype = "Replace" diff --git a/evennia/utils/tests/test_evtable.py b/evennia/utils/tests/test_evtable.py index 54869e8da9..34fb3d40fc 100644 --- a/evennia/utils/tests/test_evtable.py +++ b/evennia/utils/tests/test_evtable.py @@ -404,3 +404,48 @@ class TestEvTable(EvenniaTestCase): self.assertEqual(table1b, table1a) self.assertEqual(table2b, table2a) + + @skip("Needs to be further invstigated") + def test_formatting_with_carriage_return_marker_3693_a(self): + """ + Testing of issue https://github.com/evennia/evennia/issues/3693 + + Adding a |/ marker causes a misalignment of the side border. + + """ + data = "This is a test |/on a separate line" + table = evtable.EvTable("", table=[[data]], width=20, border="cols") + + expected = """ +| | ++~~~~~~~~~~~~~~~~~~+ +| This is a test | +| on a separate | +| line | +""" + self._validate(expected, str(table)) + + @skip("Needs to be further invstigated") + def test_formatting_with_carriage_return_marker_3693_b(self): + """ + Testing of issue https://github.com/evennia/evennia/issues/3693 + + Adding a |/ marker causes a misalignment of the side border. + + """ + data = "This is a test |/on a separate line" + data = "Welcome to your new Evennia-based game! Visit https://www.evennia.com if you need help, want to contribute, report issues or just join the community. |/|/As a privileged user, write batchcommand tutorial_world.build to build tutorial content. Once built, try intro for starting help and tutorial to play the demo game." # noqa + + table = evtable.EvTable("", table=[[data]], width=80, border="cols") + + expected = """ +| | ++~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+ +| Welcome to your new Evennia-based game! Visit https://www.evennia.com if | +| you need help, want to contribute, report issues or just join the community. | +| | +| As a privileged user, write batchcommand tutorial_world.build to build | +| tutorial content. Once built, try intro for starting help and tutorial to | +| play the demo game. | +""" + self._validate(expected, str(table)) diff --git a/evennia/utils/tests/test_funcparser.py b/evennia/utils/tests/test_funcparser.py index 7756a95999..589219163e 100644 --- a/evennia/utils/tests/test_funcparser.py +++ b/evennia/utils/tests/test_funcparser.py @@ -231,6 +231,9 @@ class TestFuncParser(TestCase): ("Test literal3 $typ($lit(1)aaa)", "Test literal3 "), ("Test literal4 $typ(aaa$lit(1))", "Test literal4 "), ("Test spider's thread", "Test spider's thread"), + ("Test escape syntax $a=$b", "Test escape syntax $a=$b"), + (r"Test escape syntax $a\= b", "Test escape syntax $a= b"), + (r"Test escape syntax $a\\= $b", r"Test escape syntax $a\= $b"), ] ) def test_parse(self, string, expected): diff --git a/evennia/utils/tests/test_text2html.py b/evennia/utils/tests/test_text2html.py index f8aec0c1d4..b5e48b8531 100644 --- a/evennia/utils/tests/test_text2html.py +++ b/evennia/utils/tests/test_text2html.py @@ -1,4 +1,4 @@ -"""Tests for text2html """ +"""Tests for text2html""" import unittest diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index 44540338f1..0c55c4ad67 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -821,13 +821,13 @@ class TestAtSearchResult(TestCase): class MockObject: def __init__(self, key): self.key = key - self.aliases = '' + self.aliases = "" def get_display_name(self, looker, **kwargs): return self.key - + def get_extra_info(self, looker, **kwargs): - return '' + return "" def __repr__(self): return f"MockObject({self.key})" @@ -846,7 +846,7 @@ class TestAtSearchResult(TestCase): def test_basic_multimatch(self): """multiple matches with the same name should return a message with incrementing indices""" - matches = [ self.MockObject("obj1") for _ in range(3) ] + matches = [self.MockObject("obj1") for _ in range(3)] caller = mock.MagicMock() self.assertIsNone(utils.at_search_result(matches, caller, "obj1")) multimatch_msg = """\ @@ -858,7 +858,9 @@ More than one match for 'obj1' (please narrow target): def test_partial_multimatch(self): """multiple partial matches with different names should increment index by unique name""" - matches = [ self.MockObject("obj1") for _ in range(3) ] + [ self.MockObject("obj2") for _ in range(2) ] + matches = [self.MockObject("obj1") for _ in range(3)] + [ + self.MockObject("obj2") for _ in range(2) + ] caller = mock.MagicMock() self.assertIsNone(utils.at_search_result(matches, caller, "obj")) multimatch_msg = """\ diff --git a/evennia/utils/tests/test_validatorfuncs.py b/evennia/utils/tests/test_validatorfuncs.py index f691d92836..30a6160861 100644 --- a/evennia/utils/tests/test_validatorfuncs.py +++ b/evennia/utils/tests/test_validatorfuncs.py @@ -1,4 +1,4 @@ -"""Tests for validatorfuncs """ +"""Tests for validatorfuncs""" import datetime diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 5c6264fc5a..bcea65b805 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -27,6 +27,8 @@ from inspect import getmembers, getmodule, getmro, ismodule, trace from os.path import join as osjoin from string import punctuation from unicodedata import east_asian_width +from collections.abc import Callable +from typing import Generic, TypeVar, overload from django.apps import apps from django.conf import settings @@ -494,7 +496,7 @@ 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(fr"(?<=\S) {{{max_spacing},}}", " " * max_spacing, text) + text = re.sub(rf"(?<=\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) return text @@ -2180,8 +2182,9 @@ def deepsize(obj, max_depth=4): # lazy load handler _missing = object() +TProp = TypeVar("TProp") -class lazy_property: +class lazy_property(Generic[TProp]): """ Delays loading of property until first access. Credit goes to the Implementation in the werkzeug suite: @@ -2202,18 +2205,24 @@ class lazy_property: """ - def __init__(self, func, name=None, doc=None): + def __init__(self, func: Callable[..., TProp], name=None, doc=None): """Store all properties for now""" self.__name__ = name or func.__name__ self.__module__ = func.__module__ self.__doc__ = doc or func.__doc__ self.func = func - def __get__(self, obj, type=None): + @overload + def __get__(self, obj: None, type=None) -> 'lazy_property': ... + + @overload + def __get__(self, obj, type=None) -> TProp: ... + + def __get__(self, obj, type=None) -> TProp | 'lazy_property': """Triggers initialization""" if obj is None: return self - value = obj.__dict__.get(self.__name__, _missing) + value: TProp = obj.__dict__.get(self.__name__, _missing) if value is _missing: value = self.func(obj) obj.__dict__[self.__name__] = value @@ -2401,10 +2410,8 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): grouped_matches = defaultdict(list) for item in matches: group_key = ( - item.get_display_name(caller) - if hasattr(item, "get_display_name") - else query - ) + item.get_display_name(caller) if hasattr(item, "get_display_name") else query + ) grouped_matches[group_key].append(item) for key, match_list in grouped_matches.items(): @@ -2415,7 +2422,9 @@ 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.db_key for alias in aliases if alias.db_category != "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 diff --git a/evennia/utils/verb_conjugation/verbs.txt b/evennia/utils/verb_conjugation/verbs.txt index 9cfae2cc47..ae297860fd 100644 --- a/evennia/utils/verb_conjugation/verbs.txt +++ b/evennia/utils/verb_conjugation/verbs.txt @@ -2004,7 +2004,6 @@ nidify,,,nidifies,,nidifying,,,,,nidified,nidified,,,,,,,,,,,, expand,,,expands,,expanding,,,,,expanded,expanded,,,,,,,,,,,, audit,,,audits,,auditing,,,,,audited,audited,,,,,,,,,,,, dislocate,,,dislocates,,dislocating,,,,,dislocated,dislocated,,,,,,,,,,,, -offer,,,,,,,,,,,,,,,,,,,,,,, fascinate,,,fascinates,,fascinating,,,,,fascinated,fascinated,,,,,,,,,,,, trudge,,,trudges,,trudging,,,,,trudged,trudged,,,,,,,,,,,, shotgun,,,shotguns,,shotgunning,,,,,shotgunned,shotgunned,,,,,,,,,,,, @@ -4660,7 +4659,7 @@ recuperate,,,recuperates,,recuperating,,,,,recuperated,recuperated,,,,,,,,,,,, womanize,,,womanizes,,womanizing,,,,,womanized,womanized,,,,,,,,,,,, remount,,,remounts,,remounting,,,,,remounted,remounted,,,,,,,,,,,, jess,,,jesses,,jessing,,,,,jessed,jessed,,,,,,,,,,,, -canter,,,cants,,canting,,,,,canted,canted,,,,,,,,,,,, +cant,,,cants,,canting,,,,,canted,canted,,,,,,,,,,,, lyophilize,,,lyophilizes,,lyophilizing,,,,,lyophilized,lyophilized,,,,,,,,,,,, jest,,,jests,,jesting,,,,,jested,jested,,,,,,,,,,,, mouse,,,mouses,,mousing,,,,,moused,moused,,,,,,,,,,,, @@ -6559,7 +6558,7 @@ micturate,,,micturates,,micturating,,,,,micturated,micturated,,,,,,,,,,,, outgain,,,outgains,,outgaining,,,,,outgained,outgained,,,,,,,,,,,, declassify,,,declassifies,,declassifying,,,,,declassified,declassified,,,,,,,,,,,, tissue,,,tissues,,tissuing,,,,,tissued,tissued,,,,,,,,,,,, -install,,,instals,,installing,,,,,installed,installed,,,,,,,,,,,, +install,,,installs,,installing,,,,,installed,installed,,,,,,,,,,,, salvage,,,salvages,,salvaging,,,,,salvaged,salvaged,,,,,,,,,,,, aggrandize,,,aggrandizes,,aggrandizing,,,,,aggrandized,aggrandized,,,,,,,,,,,, quarrel,,,quarrels,,quarrelling,,,,,quarrelled,quarrelled,,,,,,,,,,,, @@ -7405,7 +7404,6 @@ hint,,,hints,,hinting,,,,,hinted,hinted,,,,,,,,,,,, except,,,excepts,,excepting,,,,,excepted,excepted,,,,,,,,,,,, enfilade,,,enfilades,,enfilading,,,,,enfiladed,enfiladed,,,,,,,,,,,, blob,,,blobs,,blobbing,,,,,blobbed,blobbed,,,,,,,,,,,, -hinder,,,,,,,,,,,,,,,,,,,,,,, backbite,,,backbites,,backbiting,,,,,backbit,backbitten,,,,,,,,,,,, disrupt,,,disrupts,,disrupting,,,,,disrupted,disrupted,,,,,,,,,,,, impound,,,impounds,,impounding,,,,,impounded,impounded,,,,,,,,,,,, @@ -7529,7 +7527,6 @@ disc,,,discs,,discing,,,,,disced,disced,,,,,,,,,,,, antagonize,,,antagonizes,,antagonizing,,,,,antagonized,antagonized,,,,,,,,,,,, dish,,,dishes,,dishing,,,,,dished,dished,,,,,,,,,,,, follow,,,follows,,following,,,,,followed,followed,,,,,,,,,,,, -alter,,,,,,,,,,,,,,,,,,,,,,, glimpse,,,glimpses,,glimpsing,,,,,glimpsed,glimpsed,,,,,,,,,,,, depressurize,,,depressurizes,,depressurizing,,,,,depressurized,depressurized,,,,,,,,,,,, homage,,,homages,,homaging,,,,,homaged,homaged,,,,,,,,,,,, diff --git a/evennia/web/admin/help.py b/evennia/web/admin/help.py index 9c64630fca..77dc5d5da2 100644 --- a/evennia/web/admin/help.py +++ b/evennia/web/admin/help.py @@ -39,6 +39,7 @@ class HelpEntryForm(forms.ModelForm): @admin.register(HelpEntry) class HelpEntryAdmin(admin.ModelAdmin): "Sets up the admin manaager for help entries" + inlines = [HelpTagInline] list_display = ("id", "db_key", "db_help_category", "db_lock_storage", "db_date_created") list_display_links = ("id", "db_key") diff --git a/evennia/web/static/webclient/js/plugins/goldenlayout.js b/evennia/web/static/webclient/js/plugins/goldenlayout.js index 68ea5710bd..aca6f8b836 100644 --- a/evennia/web/static/webclient/js/plugins/goldenlayout.js +++ b/evennia/web/static/webclient/js/plugins/goldenlayout.js @@ -300,6 +300,14 @@ let goldenlayout = (function () { let typelist = document.getElementById("typelist"); let updatelist = document.getElementById("updatelist"); + if(tab?.componentName !== 'options') + { + window.plugins["default_in"].setKeydownFocus(true); + } + else { + window.plugins["default_in"].setKeydownFocus(false); + } + if( renamebox ) { closeRenameDropdown(); } diff --git a/evennia/web/static/webclient/js/plugins/options2.js b/evennia/web/static/webclient/js/plugins/options2.js index 24acd4b314..ef62d62a54 100644 --- a/evennia/web/static/webclient/js/plugins/options2.js +++ b/evennia/web/static/webclient/js/plugins/options2.js @@ -75,14 +75,12 @@ let options2 = (function () { .click( function () { optionsContainer = null; tab.contentItem.remove(); - window.plugins["default_in"].setKeydownFocus(true); }); optionsContainer = tab.contentItem; } }); main.parent.addChild( optionsComponent ); - window.plugins["default_in"].setKeydownFocus(false); } else { optionsContainer.remove(); optionsContainer = null; @@ -151,7 +149,6 @@ let options2 = (function () { // don't claim this Prompt as completed. return false; } - // // var init = function() { diff --git a/evennia/web/templates/website/_menu.html b/evennia/web/templates/website/_menu.html index 6bd1e0f704..f55837c018 100644 --- a/evennia/web/templates/website/_menu.html +++ b/evennia/web/templates/website/_menu.html @@ -5,86 +5,98 @@ folder and edit it to add/remove links to the menu. {% endcomment %} {% load static %} diff --git a/evennia/web/website/tests.py b/evennia/web/website/tests.py index 40e453c173..3725fb3da2 100644 --- a/evennia/web/website/tests.py +++ b/evennia/web/website/tests.py @@ -97,6 +97,20 @@ class LoginTest(EvenniaWebTest): class LogoutTest(EvenniaWebTest): url_name = "logout" + def test_get(self): + """Since Django 5.0, logout is no longer supported with GET requests""" + pass + + def test_post(self): + """Do the logout test with a POST request""" + response = self.client.post(reverse(self.url_name), follow=True) + self.assertEqual(response.status_code, 200) + + def test_get_authenticated(self): + """Do the logout test with a POST instead of GET""" + response = self.client.post(reverse(self.url_name), follow=True) + self.assertEqual(response.status_code, 200) + class PasswordResetTest(EvenniaWebTest): url_name = "password_change" diff --git a/pyproject.toml b/pyproject.toml index 0c40f9cd7b..df920c912b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "evennia" -version = "4.3.0" +version = "4.5.0" 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" +requires-python = ">=3.11" readme = { file = "README.md", content-type = "text/markdown" } license = { text = "BSD" } keywords = [ @@ -38,8 +38,9 @@ keywords = [ ] classifiers = [ "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: JavaScript", "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: BSD License", @@ -63,8 +64,9 @@ classifiers = [ ] dependencies = [ # core dependencies - "django >= 4.2, < 4.3", - "twisted >= 23.10, < 24", + "legacy-cgi;python_version >= '3.13'", + "django >= 5.1, < 5.2", + "twisted >= 24.11.0, < 25", "pytz >= 2022.6", "djangorestframework >= 3.14, < 3.15", "pyyaml >= 6.0", @@ -110,7 +112,7 @@ extra = [ "django-extensions >= 3.1.0", # xyzroom contrib - "scipy == 1.12.0", + "scipy == 1.15.1", # Git contrib "gitpython >= 3.1.27", @@ -168,3 +170,20 @@ omit = [ "*.pyc", "*.service", ] + +[tool.ruff] +exclude = [ + "/.eggs/", + "/.git/", + "/.hg/", + "/.mypy_cache/", + "/.tox/", + "/.venv/", + "/_build/", + "/buck-out/", + "/build/", + "/dist/", + "migrations", + "docs", +] +line-length = 100 \ No newline at end of file