diff --git a/.github/workflows/github_action_test_suite.yml b/.github/workflows/github_action_test_suite.yml index c05faa95a5..2bcb6c0153 100644 --- a/.github/workflows/github_action_test_suite.yml +++ b/.github/workflows/github_action_test_suite.yml @@ -33,7 +33,7 @@ jobs: COVERAGE_TEST_SETTINGS: "--settings=settings --timing" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up database (${{ matrix.TESTING_DB }}) uses: ./.github/actions/setup-database diff --git a/CHANGELOG.md b/CHANGELOG.md index a358bbcdc1..d67950ca16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,67 @@ ## Main branch +- 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][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) +- Fix: Passing an already instantiated Script to `obj.scripts.add` (`ScriptHandler.add`) +did not add it to the handler's object (Griatch) +[Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch) +- Docs: Tutorial fixes (Griatch) + +[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 +[pull3595]: https://github.com/evennia/evennia/pull/3595 + + +## Evennia 4.3.0 + +Aug 11, 2024 + +- [Feat][pull3531]: New contrib; `in-game reports` for handling user reports, + bugs etc in-game (InspectorCaracal) +- [Feat][pull3586]: Add ANSI color support `|U`, `|I`, `|i`, `|s`, `|S` for +underline reset, italic/reset and strikethrough/reset (0xDEADFED5) +- Feat: Add `Trait.traithandler` back-reference so custom Traits from the Traits + contrib can find and reference other Traits. (Griatch) +- [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (0xDEADFED5) +- [Fix][pull3571]: Better visual display of partial multimatch search results + (InspectorCaracal) +- [Fix][issue3378]: Prototype 'alias' key was not properly homogenized to a list + (Griatch) +- [Fix][pull3550]: Issue where rpsystem contrib search would do a global instead + 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) +- [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 +[pull3571]: https://github.com/evennia/evennia/pull/3571 +[pull3582]: https://github.com/evennia/evennia/pull/3582 +[pull3589]: https://github.com/evennia/evennia/pull/3589 +[issue3378]: https://github.com/evennia/evennia/issues/3578 + +## Evennia 4.2.0 + +June 27, 2024 + - [Feature][pull3470]: New `exit_order` kwarg to `DefaultObject.get_display_exits` to easier customize the order in which standard exits are displayed in a room (chiizujin) @@ -48,6 +109,10 @@ template properly (InspectorCaracal) - [Fix][pull3545]: Fix fallback issue in cmdhandler for local-object cmdsets (InspectorCaracal) - [Fix][pull3554]: Fix/readd custom `ic` command to the `character_creator` contrib (InspectorCaracal) +- [Fix][pull3566]: Make sure the `website/base.html` website base is targeted + explicitly so it doesn't get overridden by same file name elsewhere in app (InspectorCaracal) +- [fix][issue3387]: Update all game template doc strings to be more up-to-date + (Griatch) - [Docs]: Doc fixes (Griatch, chiizujin, InspectorCaracal, iLPDev) [pull3470]: https://github.com/evennia/evennia/pull/3470 @@ -79,7 +144,9 @@ [pull3549]: https://github.com/evennia/evennia/pull/3549 [pull3554]: https://github.com/evennia/evennia/pull/3554 [pull3523]: https://github.com/evennia/evennia/pull/3523 +[pull3566]: https://github.com/evennia/evennia/pull/3566 [issue3522]: https://github.com/evennia/evennia/issue/3522 +[issue3387]: https://github.com/evennia/evennia/issue/3387 ## Evennia 4.1.1 diff --git a/docs/pylib/update_dynamic_pages.py b/docs/pylib/update_dynamic_pages.py index bafa945f23..4e3a032d53 100644 --- a/docs/pylib/update_dynamic_pages.py +++ b/docs/pylib/update_dynamic_pages.py @@ -3,8 +3,10 @@ Update dynamically generated doc pages based on github sources. """ +from datetime import datetime from os.path import abspath, dirname from os.path import join as pathjoin +from re import sub as re_sub ROOTDIR = dirname(dirname(dirname(abspath(__file__)))) DOCDIR = pathjoin(ROOTDIR, "docs") @@ -86,12 +88,45 @@ if settings.SERVERNAME == "Evennia": print(" -- Updated Settings-Default.md") +def update_index(): + """ + Read the index.md file and inject the latest version number and updated time. + + """ + indexfile = pathjoin(DOCSRCDIR, "index.md") + versionfile = pathjoin(EVENNIADIR, "VERSION.txt") + + with open(indexfile) as f: + srcdata = f.read() + + # replace the version number + with open(versionfile) as f: + version = f.read().strip() + + pattern = r"Evennia version is \d+\.\d+\.\d+\." + replacement = f"Evennia version is {version}." + + srcdata = re_sub(pattern, replacement, srcdata) + + # replace the last-updated time + now = datetime.now().strftime("%B %d, %Y") + + pattern = r"This manual was last updated [A-Z][a-z]+ \d{1,2}, \d{4}" + replacement = f"This manual was last updated {now}" + + srcdata = re_sub(pattern, replacement, srcdata) + + with open(indexfile, "w") as f: + f.write(srcdata) + + print(" -- Updated index.md") def update_dynamic_pages(): """ Run the various updaters """ + update_index() update_changelog() update_default_settings() update_code_style() diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index a358bbcdc1..533de8ca14 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -2,6 +2,64 @@ ## Main branch +Feat: Support `scripts key:typeclass` form to create global scripts +with dynamic keys (rather than just relying on typeclass' key). Support +searching using the same syntax (Griatch) +[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) +Fix: Passing an already instantiated Script to `obj.scripts.add` (`ScriptHandler.add`) +did not add it to the handler's object (Griatch) +[Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch) + +[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 + + +## Evennia 4.3.0 + +Aug 11, 2024 + +- [Feat][pull3531]: New contrib; `in-game reports` for handling user reports, + bugs etc in-game (InspectorCaracal) +- [Feat][pull3586]: Add ANSI color support `|U`, `|I`, `|i`, `|s`, `|S` for +underline reset, italic/reset and strikethrough/reset (0xDEADFED5) +- Feat: Add `Trait.traithandler` back-reference so custom Traits from the Traits + contrib can find and reference other Traits. (Griatch) +- [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (0xDEADFED5) +- [Fix][pull3571]: Better visual display of partial multimatch search results + (InspectorCaracal) +- [Fix][issue3378]: Prototype 'alias' key was not properly homogenized to a list + (Griatch) +- [Fix][pull3550]: Issue where rpsystem contrib search would do a global instead + 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) +- [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 +[pull3571]: https://github.com/evennia/evennia/pull/3571 +[pull3582]: https://github.com/evennia/evennia/pull/3582 +[pull3589]: https://github.com/evennia/evennia/pull/3589 +[issue3378]: https://github.com/evennia/evennia/issues/3578 + +## Evennia 4.2.0 + +June 27, 2024 + - [Feature][pull3470]: New `exit_order` kwarg to `DefaultObject.get_display_exits` to easier customize the order in which standard exits are displayed in a room (chiizujin) @@ -48,6 +106,10 @@ template properly (InspectorCaracal) - [Fix][pull3545]: Fix fallback issue in cmdhandler for local-object cmdsets (InspectorCaracal) - [Fix][pull3554]: Fix/readd custom `ic` command to the `character_creator` contrib (InspectorCaracal) +- [Fix][pull3566]: Make sure the `website/base.html` website base is targeted + explicitly so it doesn't get overridden by same file name elsewhere in app (InspectorCaracal) +- [fix][issue3387]: Update all game template doc strings to be more up-to-date + (Griatch) - [Docs]: Doc fixes (Griatch, chiizujin, InspectorCaracal, iLPDev) [pull3470]: https://github.com/evennia/evennia/pull/3470 @@ -79,7 +141,9 @@ [pull3549]: https://github.com/evennia/evennia/pull/3549 [pull3554]: https://github.com/evennia/evennia/pull/3554 [pull3523]: https://github.com/evennia/evennia/pull/3523 +[pull3566]: https://github.com/evennia/evennia/pull/3566 [issue3522]: https://github.com/evennia/evennia/issue/3522 +[issue3387]: https://github.com/evennia/evennia/issue/3387 ## Evennia 4.1.1 diff --git a/docs/source/Components/Locks.md b/docs/source/Components/Locks.md index 3d07b9b583..1683997158 100644 --- a/docs/source/Components/Locks.md +++ b/docs/source/Components/Locks.md @@ -99,7 +99,8 @@ Below are the access_types checked by the default commandset. - `send` - who may send to the channel. - `listen` - who may subscribe and listen to the channel. - [HelpEntry](./Help-System.md): - - `examine` - who may view this help entry (usually everyone) + - `view` - if the help entry header should show up in the help index + - `read` - who may view this help entry (usually everyone) - `edit` - who may edit this help entry. So to take an example, whenever an exit is to be traversed, a lock of the type *traverse* will be checked. Defining a suitable lock type for an exit object would thus involve a lockstring `traverse: `. diff --git a/docs/source/Concepts/Colors.md b/docs/source/Concepts/Colors.md index 1b0a9b9c24..d50eda3aea 100644 --- a/docs/source/Concepts/Colors.md +++ b/docs/source/Concepts/Colors.md @@ -56,8 +56,14 @@ For the webclient, Evennia will translate the codes to CSS tags. |\|X | normal black foreground color | | \|\[# | background colours, e.g. \|\[c for bright cyan background and \|\[C a normal cyan background. | | \|!# | foreground color that inherits brightness from previous tags. Always uppcase, like \|!R | -| \|h | make any following foreground ANSI colors bright (no effect on Xterm colors). Use with \|!#. Technically, \|h\|G == \|g. | -| \|H | negates the effects of \|h, return foreground to normal (no effect on Xterm colors) | +| \|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 | @@ -126,14 +132,14 @@ actually change the background color instead of the foreground: ``` |*reversed text |!R now BG is red. ``` -For a detailed explanation of these caveats, see the [Understanding Color Tags](Understanding-Color- -Tags) tutorial. But most of the time you might be better off to simply avoid `|*` and mark your text +For a detailed explanation of these caveats, see the [Understanding Color Tags](../Howtos/Tutorial\-Understanding\-Color\-Tags.md) +tutorial. But most of the time you might be better off to simply avoid `|*` and mark your text manually instead. ## Xterm256 Colours ```{sidebar} -See the [Understanding Color Tags](../Howtos/Tutorial-Understanding-Color-Tags.md) tutorial, for more on the use of ANSI color tags and the pitfalls of mixing ANSI and Xterms256 color tags in the same context. +See the [Understanding Color Tags](../Howtos/Tutorial\-Understanding\-Color\-Tags.md) tutorial, for more on the use of ANSI color tags and the pitfalls of mixing ANSI and Xterms256 color tags in the same context. ``` The _Xterm256_ standard is a colour scheme that supports 256 colours for text and/or background. It can be combined freely with ANSI colors (above), but some ANSI tags don't affect Xterm256 tags. @@ -173,3 +179,40 @@ If you have a client that supports Xterm256, you can use to get a table of all the 256 colours and the codes that produce them. If the table looks broken up into a few blocks of colors, it means Xterm256 is not supported and ANSI are used as a replacement. You can use the `options` command to see if xterm256 is active for you. This depends on if your client told Evennia what it supports - if not, and you know what your client supports, you may have to activate some features manually. + +## 24-bit Colors (True color) + +```{sidebar} +See the [Wikipedia entry on web colors](https://en.wikipedia.org/wiki/Web_colors) for more detailed information on this color format. +``` + +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. +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 | +| \|\[#$$$$$$ | background RGB | +| \|#$$$ | foreground RGB (red/green/blue), 3-digit hexadecimal format. | +| \|\[#$$$ | background RGB | + +Some 6-digit examples: + +| Tag | Effect | +| -------- | ---- | +| \|#ff0000 | bright red foreground| +| \|#00ff00 | bright green foreground| +| \|#0000ff | bright blue foreground| +| \|#\[ff0000 | bright red background| + +Some 3-digit examples: + +| Tag | Effect | +| ---- | ---- | +| \|#f00 | bright red foreground| +| \|#0f0 | bright green foreground| +| \|#00f | bright blue foreground| +| \|\[#f00 | bright red background| \ No newline at end of file diff --git a/docs/source/Contribs/Contrib-Achievements.md b/docs/source/Contribs/Contrib-Achievements.md index 3e4dfa0857..351f5ca170 100644 --- a/docs/source/Contribs/Contrib-Achievements.md +++ b/docs/source/Contribs/Contrib-Achievements.md @@ -31,7 +31,7 @@ class CharacterCmdSet(default_cmds.CharacterCmdSet): self.add(CmdAchieve) ``` -**Optional** - The achievements contrib stores individual progress data on the `achievements` attribute by default, visible via `obj.db.attributes`. You can change this by assigning an attribute (key, category) tuple to the setting `ACHIEVEMENT_CONTRIB_ATTRIBUTE` +**Optional** - The achievements contrib stores individual progress data on the `achievements` attribute by default, visible via `obj.db.achievements`. You can change this by assigning an attribute (key, category) tuple to the setting `ACHIEVEMENT_CONTRIB_ATTRIBUTE` Example: ```py diff --git a/docs/source/Contribs/Contrib-Ingame-Reports.md b/docs/source/Contribs/Contrib-Ingame-Reports.md new file mode 100644 index 0000000000..20157af3f9 --- /dev/null +++ b/docs/source/Contribs/Contrib-Ingame-Reports.md @@ -0,0 +1,136 @@ +# In-Game Reporting System + +Contrib by InspectorCaracal, 2024 + +This contrib provides an in-game reports system, handling bug reports, player reports, and idea submissions by default. It also supports adding your own types of reports, or removing any of the default report types. + +Each type of report has its own command for submitting new reports, and an admin command is also provided for managing the reports through a menu. + +## Installation + +To install the reports contrib, just add the provided cmdset to your default AccountCmdSet: + +```python +# in commands/default_cmdset.py + +from evennia.contrib.base_systems.ingame_reports import ReportsCmdSet + +class AccountCmdSet(default_cmds.AccountCmdSet): + # ... + + def at_cmdset_creation(self): + # ... + self.add(ReportsCmdSet) +``` + +The contrib also has two optional settings: `INGAME_REPORT_TYPES` and `INGAME_REPORT_STATUS_TAGS`. + +The `INGAME_REPORT_TYPES` setting is covered in detail in the section "Adding new types of reports". + +The `INGAME_REPORT_STATUS_TAGS` setting is covered in the section "Managing reports". + +## Usage + +By default, the following report types are available: + +* Bugs: Report bugs encountered during gameplay. +* Ideas: Submit suggestions for game improvement. +* Players: Report inappropriate player behavior. + +Players can submit new reports through the command for each report type, and staff are given access to a report-management command and menu. + +### Submitting reports + +Players can submit reports using the following commands: + +* `bug ` - Files a bug report. An optional target can be included - `bug = ` - making it easier for devs/builders to track down issues. +* `report = ` - Reports a player for inappropriate or rule-breaking behavior. *Requires* a target to be provided - it searches among accounts by default. +* `idea ` - Submits a general suggestion, with no target. It also has an alias of `ideas` which allows you to view all of your submitted ideas. + +### Managing reports + +The `manage reports` command allows staff to review and manage the various types of reports by launching a management menu. + +This command will dynamically add aliases to itself based on the types of reports available, with each command string launching a menu for that particular report type. The aliases are built on the pattern `manage s` - by default, this means it makes `manage bugs`, `manage players`, and `manage ideas` available along with the default `manage reports`, and that e.g. `manage bugs` will launch the management menu for `bug`-type reports. + +Aside from reading over existing reports, the menu allows you to change the status of any given report. By default, the contrib includes two different status tags: `in progress` and `closed`. + +> Note: A report is created with no status tags, which is considered "open" + +If you want a different set of statuses for your reports, you can define the `INGAME_REPORT_STATUS_TAGS` to your list of statuses. + +**Example** + +```python +# in server/conf/settings.py + +# this will allow for the statuses of 'in progress', 'rejected', and 'completed', without the contrib-default of 'closed' +INGAME_REPORT_STATUS_TAGS = ('in progress', 'rejected', 'completed') +``` + +### Adding new types of reports + +The contrib is designed to make adding new types of reports to the system as simple as possible, requiring only two steps: + +1. Update your settings file to include an `INGAME_REPORT_TYPES` setting. +2. Create and add a new `ReportCmd` to your command set. + +#### 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. + +```python +# in server/conf/settings.py + +# this will include the contrib's report types as well as a custom 'complaint' report type +INGAME_REPORT_TYPES = ('bugs', 'ideas', 'players', 'complaints') +``` + +You can also use this setting to remove any of the contrib's report types - the contrib will respect this setting when building its cmdset with no additional steps. + +```python +# in server/conf/settings.py + +# this redefines the setting to not include 'ideas', so the ideas command and reports won't be available +INGAME_REPORT_TYPES = ('bugs', 'players') +``` + +#### Create a new ReportCmd + +`ReportCmdBase` is a parent command class which comes with the main functionality for submitting reports. Creating a new reporting command is as simple as inheriting from this class and defining a couple of class attributes. + +* `key` - This is the same as for any other command, setting the command's usable key. It also acts as the report type if that isn't explicitly set. +* `report_type` - The type of report this command is for (e.g. `player`). You only need to set it if you want a different string from the key. +* `report_locks` - The locks you want applied to the created reports. Defaults to `"read:pperm(Admin)"` +* `success_msg` - The string which is sent to players after submitting a report of this type. Defaults to `"Your report has been filed."` +* `require_target`: Set to `True` if your report type requires a target (e.g. player reports). + +> Note: The contrib's own commands - `CmdBug`, `CmdIdea`, and `CmdReport` - are implemented the same way, so you can review them as examples. + +Example: + +```python +from evennia.contrib.base_systems.ingame_reports.reports import ReportCmdBase + +class CmdCustomReport(ReportCmdBase): + """ + file a custom report + + Usage: + customreport + + This is a custom report type. + """ + + key = "customreport" + report_type = "custom" + success_message = "You have successfully filed a custom report." +``` + +Add this new command to your default cmdset to enable filing your new report type. + + +---- + +This document page is generated from `evennia/contrib/base_systems/ingame_reports/README.md`. Changes to this +file will be overwritten, so edit that file rather than this one. diff --git a/docs/source/Contribs/Contrib-RPSystem.md b/docs/source/Contribs/Contrib-RPSystem.md index 0be85deeb4..3502f298e0 100644 --- a/docs/source/Contribs/Contrib-RPSystem.md +++ b/docs/source/Contribs/Contrib-RPSystem.md @@ -73,8 +73,14 @@ class Room(ContribRPRoom): # ... ``` +You need to set up Evennia to use the RPsystem's form to separate +between sdescs (`3-tall`) to make it compatible with how the rest of Evennia +separates between other multi-matches of searches/commands: -You will then need to reload the server and potentially force-reload + SEARCH_MULTIMATCH_REGEX = r"(?P[0-9]+)-(?P[^-]*)(?P.*)" + SEARCH_MULTIMATCH_TEMPLATE = " {number}-{name}{aliases}{info}\n" + +Finally, you will then need to reload the server and potentially force-reload your objects, if you originally created them without this. Example for your character: diff --git a/docs/source/Contribs/Contrib-Traits.md b/docs/source/Contribs/Contrib-Traits.md index 901f42ea4c..9eac6bcd90 100644 --- a/docs/source/Contribs/Contrib-Traits.md +++ b/docs/source/Contribs/Contrib-Traits.md @@ -4,7 +4,7 @@ Contribution by Griatch 2020, based on code by Whitenoise and Ainneve contribs, A `Trait` represents a modifiable property on (usually) a Character. They can be used to represent everything from attributes (str, agi etc) to skills -(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc. +(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc. Traits differ from normal Attributes in that they track their changes and limit themselves to particular value-ranges. One can add/subtract from them easily and they can even change dynamically at a particular rate (like you being poisoned or @@ -50,8 +50,6 @@ class Character(DefaultCharacter): self.traits.add("hp", "Health", trait_type="gauge", min=0, max=100) self.traits.add("hunting", "Hunting Skill", trait_type="counter", base=10, mod=1, min=0, max=100) - - ``` When adding the trait, you supply the name of the property (`hunting`) along with a more human-friendly name ("Hunting Skill"). The latter will show if you @@ -78,7 +76,6 @@ class Object(DefaultObject): strength = TraitProperty("Strength", trait_type="static", base=10, mod=2) health = TraitProperty("Health", trait_type="gauge", min=0, base=100, mod=2) hunting = TraitProperty("Hunting Skill", trait_type="counter", base=10, mod=1, min=0, max=100) - ``` > Note that the property-name will become the name of the trait and you don't supply `trait_key` @@ -92,7 +89,7 @@ class Object(DefaultObject): ## Using traits -A trait is added to the traithandler (if you use `TraitProperty` the handler is just created under +A trait are entities added to the traithandler (if you use `TraitProperty` the handler is just created under the hood) after which one can access it as a property on the handler (similarly to how you can do .db.attrname for Attributes in Evennia). @@ -137,9 +134,30 @@ obj.traits.strength.value > obj.strength.value += 5 > obj.strength.value 17 - ``` +### Relating traits to one another + +From a trait you can access its own Traithandler as `.traithandler`. You can +also find another trait on the same handler by using the +`Trait.get_trait("traitname")` method. + +```python +> obj.strength.get_trait("hp").value +100 +``` + +This is not too useful for the default trait types - they are all operating +independently from one another. But if you create your own trait classes, you +can use this to make traits that depend on each other. + +For example, you could picture making a Trait that is the sum of the values of +two other traits and capped by the value of a third trait. Such complex +interactions are common in RPG rule systems but are by definition game specific. + +See an example in the section about [making your own Trait classes](#expanding-with-your-own-traits). + + ## Trait types All default traits have a read-only `.value` property that shows the relevant or @@ -158,7 +176,6 @@ compatible type. > trait1 + 2 > trait1.value 5 - ``` Two numerical traits can also be compared (bigger-than etc), which is useful in @@ -168,7 +185,6 @@ all sorts of rule-resolution. if trait1 > trait2: # do stuff - ``` ### Trait @@ -193,7 +209,6 @@ like a glorified Attribute. > obj.traits.mytrait.value = "stringvalue" > obj.traits.mytrait.value "stringvalue" - ``` ### Static trait @@ -217,7 +232,6 @@ that varies slowly or not at all, and which may be modified in-place. > obj.traits.mytrait.mod = 0 > obj.traits.mytrait.value 12 - ``` ### Counter @@ -253,8 +267,6 @@ remove it. A suggested use for a Counter Trait would be to track skill values. # for TraitProperties, pass the args/kwargs of traits.add() to the # TraitProperty constructor instead. - - ``` Counters have some extra properties: @@ -286,7 +298,6 @@ By calling `.desc()` on the Counter, you will get the text matching the current > obj.traits.hunting.desc() "expert" - ``` #### .rate @@ -327,12 +338,10 @@ a previous value. 71 # we have stopped at the ratetarget > obj.traits.hunting.rate = 0 # disable auto-change - - ``` Note that when retrieving the `current`, the result will always be of the same type as the `.base` even `rate` is a non-integer value. So if `base` is an `int` -(default)`, the `current` value will also be rounded the closest full integer. +(default), the `current` value will also be rounded the closest full integer. If you want to see the exact `current` value, set `base` to a float - you will then need to use `round()` yourself on the result if you want integers. @@ -347,7 +356,6 @@ return the value as a percentage. > obj.traits.hunting.percent(formatting=None) 71.0 - ``` ### Gauge @@ -379,7 +387,6 @@ stamina and the like. > obj.traits.hp.current -= 30 > obj.traits.hp.value 80 - ``` The Gauge trait is subclass of the Counter, so you have access to the same @@ -412,8 +419,6 @@ class RageTrait(StaticTrait): def sedate(self): self.mod = 0 - - ``` Above is an example custom-trait-class "rage" that stores a property "rage" on @@ -432,12 +437,24 @@ Reload the server and you should now be able to use your trait: > obj.traits.add("mood", "A dark mood", rage=30, trait_type='rage') > obj.traits.mood.rage 30 +``` + +Remember that you can use `.get_trait("name")` to access other traits on the +same handler. Let's say that the rage modifier is actually limited by +the characters's current STR value times 3, with a max of 100: + +```python +class RageTrait(StaticTrait): + #... + def berserk(self): + self.mod = min(100, self.get_trait("STR").value * 3) +``` # as TraitProperty +``` class Character(DefaultCharacter): rage = TraitProperty("A dark mood", rage=30, trait_type='rage') - ``` ## Adding additional TraitHandlers @@ -459,7 +476,7 @@ class Character(DefaultCharacter): def traits(self): # this adds the handler as .traits return TraitHandler(self) - + @lazy_property def stats(self): # this adds the handler as .stats @@ -479,6 +496,9 @@ class Character(DefaultCharacter): base=10, mod=1, min=0, max=100) ``` +> Rememebr that the `.get_traits()` method only works for accessing Traits within the +_same_ TraitHandler. + ---- diff --git a/docs/source/Contribs/Contribs-Overview.md b/docs/source/Contribs/Contribs-Overview.md index 49f0e4f431..f1d7308eaf 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 **50** contribs currently included +This page is auto-generated and summarizes all **51** contribs currently included with the Evennia distribution. All contrib categories are imported from `evennia.contrib`, such as @@ -34,11 +34,12 @@ If you want to add a contrib, see [the contrib guidelines](./Contribs-Guidelines | [color_markups](#color_markups) | [components](#components) | [containers](#containers) | [cooldowns](#cooldowns) | [crafting](#crafting) | | [custom_gametime](#custom_gametime) | [dice](#dice) | [email_login](#email_login) | [evadventure](#evadventure) | [evscaperoom](#evscaperoom) | | [extended_room](#extended_room) | [fieldfill](#fieldfill) | [gendersub](#gendersub) | [git_integration](#git_integration) | [godotwebsocket](#godotwebsocket) | -| [health_bar](#health_bar) | [ingame_map_display](#ingame_map_display) | [ingame_python](#ingame_python) | [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) | +| [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) | @@ -64,6 +65,7 @@ Contrib-Custom-Gametime.md Contrib-Email-Login.md Contrib-Godotwebsocket.md Contrib-Ingame-Python.md +Contrib-Ingame-Reports.md Contrib-Menu-Login.md Contrib-Mux-Comms-Cmds.md Contrib-Unixcommand.md @@ -173,6 +175,16 @@ this module carefully before continuing. +### `ingame_reports` + +_Contrib by InspectorCaracal, 2024_ + +This contrib provides an in-game reports system, handling bug reports, player reports, and idea submissions by default. It also supports adding your own types of reports, or removing any of the default report types. + +[Read the documentation](./Contrib-Ingame-Reports.md) - [Browse the Code](evennia.contrib.base_systems.ingame_reports) + + + ### `menu_login` _Contribution by Vincent-lg 2016. Reworked for modern EvMenu by Griatch, 2019._ @@ -642,7 +654,7 @@ _Contribution by Griatch 2020, based on code by Whitenoise and Ainneve contribs, A `Trait` represents a modifiable property on (usually) a Character. They can be used to represent everything from attributes (str, agi etc) to skills -(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc. +(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc. Traits differ from normal Attributes in that they track their changes and limit themselves to particular value-ranges. One can add/subtract from them easily and they can even change dynamically at a particular rate (like you being poisoned or diff --git a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md index fba93441f7..e2ed6f805a 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part1/Beginner-Tutorial-Adding-Commands.md @@ -3,8 +3,7 @@ In this lesson we'll learn how to create our own Evennia [Commands](../../../Components/Commands.md) If you are new to Python you'll also learn some more basics about how to manipulate strings and get information out of Evennia. A Command is something that handles the input from a user and causes a result to happen. -An example is `look`, which examines your current location and tells you what it looks like and -what is in it. +An example is `look`, which examines your current location and tells you what it looks like and what is in it. ```{sidebar} Commands are not typeclassed @@ -20,7 +19,7 @@ Command-Sets are then associated with objects, for example with your Character. ## Creating a custom command -Open `mygame/commands/command.py`: +Open `mygame/commands/command.py`. This file already has stuff filled in for you. ```python """ 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 8f1a58dfe7..72a9709c75 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 @@ -23,12 +23,15 @@ On the `DefaultObject` is a `.search` method which we have already tried out whe - It will always return exactly one match. If it found zero or more than one match, the return is `None`. This is different from `evennia.search` (see below), which always returns a list. - On a no-match or multimatch, `.search` will automatically send an error message to `obj`. So you don't have to worry about reporting messages if the result is `None`. -In other words, this method handles error messaging for you. A very common way to use it is in commands: +In other words, this method handles error messaging for you. A very common way to use it is in commands. You can put your command anywhere, but let's try the pre-filled-in `mygame/commands/command.py`. ```python # in for example mygame/commands/command.py -from evennia import Command +from evennia import Command as BaseCommand + +class Command(BaseCommand): + # ... class CmdQuickFind(Command): """ @@ -132,9 +135,9 @@ Above we find first the rose and then an Account. You can try both using `py`: > py evennia.search_account("YourName")[0] -In the example above we used `[0]` to only get the first match of the queryset, which in this case gives us the rose and your Account respectively. Note that if you don't find any matches, using `[0]` like this leads to an error, so it's mostly useful for debugging. +The `search_object/account` returns all matches. We use `[0]` to only get the first match of the queryset, which in this case gives us the rose and your Account respectively. Note that if you don't find any matches, using `[0]` like this leads to an error, so it's mostly useful for debugging. -If you you really want all matches to the search parameters you specify. In other situations, having zero or more than one match is a sign of a problem and you need to handle this case yourself. This is too detailed for testing out just with `py`, but good to know if you want to make your own search methods: +In other situations, having zero or more than one match is a sign of a problem and you need to handle this case yourself. This is too detailed for testing out just with `py`, but good to know if you want to make your own search methods: ```python the_one_ring = evennia.search_object("The one Ring") diff --git a/docs/source/Howtos/Howto-Command-Prompt.md b/docs/source/Howtos/Howto-Command-Prompt.md index 443c0c8e1b..6f5e0d22e0 100644 --- a/docs/source/Howtos/Howto-Command-Prompt.md +++ b/docs/source/Howtos/Howto-Command-Prompt.md @@ -48,7 +48,7 @@ Here is a simple example of the prompt sent/updated from a command class: if not self.args: target = self.caller else: - target = self.search(self.args) + target = self.caller.search(self.args) if not target: return # try to get health, mana and stamina diff --git a/docs/source/Howtos/Tutorial-NPC-Reacting.md b/docs/source/Howtos/Tutorial-NPC-Reacting.md index 0e4fc20716..cb94b5088e 100644 --- a/docs/source/Howtos/Tutorial-NPC-Reacting.md +++ b/docs/source/Howtos/Tutorial-NPC-Reacting.md @@ -27,7 +27,7 @@ class NPC(Character): """ A NPC typeclass which extends the character class. """ - def at_char_entered(self, character): + def at_char_entered(self, character, **kwargs): """ A simple is_aggressive check. Can be expanded upon later. @@ -38,7 +38,13 @@ class NPC(Character): self.execute_cmd(f"say Greetings, {character}!") ``` -Here we make a simple method on the `NPC`˙. We expect it to be called when a (player-)character enters the room. We don't actually set the `is_aggressive` [Attribute](../Components/Attributes.md) beforehand; if it's not set, the NPC is simply non-hostile. +```{sidebar} Passing extra information +Note that we don't use the `**kwargs` property here. This can be used to pass extra information into hooks in your game and would be used when you make custom move commands. For example, if you `run` into the room, you could inform all hooks by doing `obj.move_to(..., running=True)`. Maybe your librarian NPC should have a separate reaction for people running into their library! + +We make sure to pass the `**kwargs` from the standard `at_object_receive` hook below. +``` + +Here we make a simple method on the `NPC`˙ called `at_char_entered`. We expect it to be called when a (player-)character enters the room. We don't actually set the `is_aggressive` [Attribute](../Components/Attributes.md) beforehand; we leave this up for the admin to activate in-game. if it's not set, the NPC is simply non-hostile. Whenever _something_ enters the `Room`, its [at_object_receive](DefaultObject.at_object_receive) hook will be called. So we should override it. @@ -54,18 +60,18 @@ class Room(ObjectParent, DefaultRoom): # ... - def at_object_receive(self, arriving_obj, source_location): + def at_object_receive(self, arriving_obj, source_location, **kwargs): if arriving_obj.account: # this has an active acccount - a player character for item in self.contents: # get all npcs in the room and inform them - if utils.inherits_from(item, "typeclasses.npcs.NPC"): - self.at_char_entered(arriving_obj) + if utils.inherits_from(item, "typeclasses.npcs.NPC"): + item.at_char_entered(arriving_obj, **kwargs) ``` ```{sidebar} Universal Object methods -Remember that Rooms are `Objects`. So the same `at_object_receive` hook will fire for you when you pick something up (making you 'receive' it). Or for a box when putting something inside it. +Remember that Rooms are `Objects`, and other Objects have these same hooks. So an `at_object_receive` hook will fire for you when you pick something up (making you 'receive' it). Or for a box when putting something inside it, for example. ``` A currently puppeted Character will have an `.account` attached to it. We use that to know that the thing arriving is a Character. We then use Evennia's [utils.inherits_from](evennia.utils.utils.inherits_from) helper utility to get every NPC in the room can each of their newly created `at_char_entered` method. diff --git a/docs/source/api/evennia.contrib.base_systems.ingame_reports.md b/docs/source/api/evennia.contrib.base_systems.ingame_reports.md new file mode 100644 index 0000000000..c7d130792c --- /dev/null +++ b/docs/source/api/evennia.contrib.base_systems.ingame_reports.md @@ -0,0 +1,19 @@ +```{eval-rst} +evennia.contrib.base\_systems.ingame\_reports +===================================================== + +.. automodule:: evennia.contrib.base_systems.ingame_reports + :members: + :undoc-members: + :show-inheritance: + + + +.. toctree:: + :maxdepth: 6 + + evennia.contrib.base_systems.ingame_reports.menu + evennia.contrib.base_systems.ingame_reports.reports + evennia.contrib.base_systems.ingame_reports.tests + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.base_systems.ingame_reports.menu.md b/docs/source/api/evennia.contrib.base_systems.ingame_reports.menu.md new file mode 100644 index 0000000000..e253b05d69 --- /dev/null +++ b/docs/source/api/evennia.contrib.base_systems.ingame_reports.menu.md @@ -0,0 +1,10 @@ +```{eval-rst} +evennia.contrib.base\_systems.ingame\_reports.menu +========================================================= + +.. automodule:: evennia.contrib.base_systems.ingame_reports.menu + :members: + :undoc-members: + :show-inheritance: + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.base_systems.ingame_reports.reports.md b/docs/source/api/evennia.contrib.base_systems.ingame_reports.reports.md new file mode 100644 index 0000000000..acdcee38f5 --- /dev/null +++ b/docs/source/api/evennia.contrib.base_systems.ingame_reports.reports.md @@ -0,0 +1,10 @@ +```{eval-rst} +evennia.contrib.base\_systems.ingame\_reports.reports +============================================================ + +.. automodule:: evennia.contrib.base_systems.ingame_reports.reports + :members: + :undoc-members: + :show-inheritance: + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.base_systems.ingame_reports.tests.md b/docs/source/api/evennia.contrib.base_systems.ingame_reports.tests.md new file mode 100644 index 0000000000..3cb42bee82 --- /dev/null +++ b/docs/source/api/evennia.contrib.base_systems.ingame_reports.tests.md @@ -0,0 +1,10 @@ +```{eval-rst} +evennia.contrib.base\_systems.ingame\_reports.tests +========================================================== + +.. automodule:: evennia.contrib.base_systems.ingame_reports.tests + :members: + :undoc-members: + :show-inheritance: + +``` \ No newline at end of file diff --git a/docs/source/api/evennia.contrib.base_systems.md b/docs/source/api/evennia.contrib.base_systems.md index 3b8409adcf..781ff3f24b 100644 --- a/docs/source/api/evennia.contrib.base_systems.md +++ b/docs/source/api/evennia.contrib.base_systems.md @@ -19,6 +19,7 @@ evennia.contrib.base\_systems evennia.contrib.base_systems.email_login evennia.contrib.base_systems.godotwebsocket evennia.contrib.base_systems.ingame_python + evennia.contrib.base_systems.ingame_reports evennia.contrib.base_systems.menu_login evennia.contrib.base_systems.mux_comms_cmds evennia.contrib.base_systems.unixcommand diff --git a/docs/source/index.md b/docs/source/index.md index b0beec4e1c..d043858fd5 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,8 +1,8 @@ # 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 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 11, 2024, see the [Evennia Changelog](Coding/Changelog.md). Latest released Evennia version is 4.3.0. -- [Introduction](./Evennia-Introduction.md) - what is this Evennia thing? +- [Introduction](./Evennia-Introduction.md) - what is this Evennia thing? - [Evennia in Pictures](./Evennia-In-Pictures.md) - a visual overview of Evennia - [Contributing and Getting help](./Contributing.md) - when you get stuck or want to chip in @@ -10,7 +10,7 @@ This is the manual of [Evennia](https://www.evennia.com), the open source Python - [Installation](Setup/Setup-Overview.md#installation-and-running) - getting started - [Running the Game](Setup/Running-Evennia.md) - how to start, stop and reload Evennia -- [Updating the Server](Setup/Updating-Evennia.md) - how to update Evennia +- [Updating the Server](Setup/Updating-Evennia.md) - how to update Evennia - [Configuration](Setup/Setup-Overview.md#configuration) - how to set up Evennia the way you like it - [Going Online](Setup/Setup-Overview.md#going-online) - bringing your game online diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index 627a3f43a6..80895903a1 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -4.1.1 +4.3.0 diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 89137ff672..e92c245fb6 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -226,7 +226,6 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): - user (User, read-only) - django User authorization object - obj (Object) - game object controlled by account. 'character' can also be used. - - sessions (list of Sessions) - sessions connected to this account - is_superuser (bool, read-only) - if the connected user is a superuser * Handlers @@ -239,18 +238,47 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): - scripts - script-handler. Add new scripts to object with scripts.add() - cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object - nicks - nick-handler. New nicks with nicks.add(). + - sessions - session-handler. Use session.get() to see all sessions connected, if any + - options - option-handler. Defaults are taken from settings.OPTIONS_ACCOUNT_DEFAULT + - characters - handler for listing the account's playable characters - * Helper methods + * Helper methods (check autodocs for full updated listing) - msg(text=None, from_obj=None, session=None, options=None, **kwargs) - execute_cmd(raw_string) - - search(ostring, global_search=False, attribute_name=None, - use_nicks=False, location=None, - ignore_errors=False, account=False) + - search(searchdata, return_puppet=False, search_object=False, typeclass=None, + nofound_string=None, multimatch_string=None, use_nicks=True, + quiet=False, **kwargs) - is_typeclass(typeclass, exact=False) - swap_typeclass(new_typeclass, clean_attributes=False, no_default=True) - - access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False) + - access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False, **kwargs) - check_permstring(permstring) + - get_cmdsets(caller, current, **kwargs) + - get_cmdset_providers() + - uses_screenreader(session=None) + - get_display_name(looker, **kwargs) + - get_extra_display_name_info(looker, **kwargs) + - disconnect_session_from_account() + - puppet_object(session, obj) + - unpuppet_object(session) + - unpuppet_all() + - get_puppet(session) + - get_all_puppets() + - is_banned(**kwargs) + - get_username_validators(validator_config=settings.AUTH_USERNAME_VALIDATORS) + - authenticate(username, password, ip="", **kwargs) + - normalize_username(username) + - validate_username(username) + - validate_password(password, account=None) + - set_password(password, **kwargs) + - get_character_slots() + - get_available_character_slots() + - create_character(*args, **kwargs) + - create(*args, **kwargs) + - delete(*args, **kwargs) + - channel_msg(message, channel, senders=None, **kwargs) + - idle_time() + - connection_time() * Hook methods @@ -261,15 +289,26 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): usually handled on the character level: - at_init() + - at_first_save() - at_access() - at_cmdset_get(**kwargs) + - at_password_change(**kwargs) - at_first_login() + - at_pre_login() - at_post_login(session=None) - - at_disconnect() + - at_failed_login(session, **kwargs) + - at_disconnect(reason=None, **kwargs) + - at_post_disconnect(**kwargs) - at_message_receive() - at_message_send() - at_server_reload() - at_server_shutdown() + - at_look(target=None, session=None, **kwargs) + - at_post_create_character(character, **kwargs) + - at_post_add_character(char) + - at_post_remove_character(char) + - at_pre_channel_msg(message, channel, senders=None, **kwargs) + - at_post_chnnel_msg(message, channel, senders=None, **kwargs) """ diff --git a/evennia/accounts/migrations/0004_auto_20150403_2339.py b/evennia/accounts/migrations/0004_auto_20150403_2339.py index 5191eaf51b..8b8e642c5e 100644 --- a/evennia/accounts/migrations/0004_auto_20150403_2339.py +++ b/evennia/accounts/migrations/0004_auto_20150403_2339.py @@ -2,9 +2,10 @@ import django.core.validators -import evennia.accounts.manager from django.db import migrations, models +import evennia.accounts.manager + class Migration(migrations.Migration): diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 87bca0eea6..d6268b3162 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -3,14 +3,18 @@ from random import randint from unittest import TestCase -import evennia from django.test import override_settings -from evennia.accounts.accounts import (AccountSessionHandler, DefaultAccount, - DefaultGuest) +from mock import MagicMock, Mock, patch + +import evennia +from evennia.accounts.accounts import ( + AccountSessionHandler, + DefaultAccount, + DefaultGuest, +) from evennia.utils import create from evennia.utils.test_resources import BaseEvenniaTest from evennia.utils.utils import uses_database -from mock import MagicMock, Mock, patch class TestAccountSessionHandler(TestCase): diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 53b3c6ba94..cffd59a6a6 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -5,11 +5,10 @@ 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 @@ -2831,8 +2830,12 @@ class CmdExamine(ObjManipCommand): _FUNCPARSER = funcparser.FuncParser(settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES) key, category, value = attr.db_key, attr.db_category, attr.value + valuetype = "" + if value is None and attr.strvalue is not None: + value = attr.strvalue + valuetype = " |B[strvalue]|n" typ = self._get_attribute_value_type(value) - typ = f" |B[type: {typ}]|n" if typ else "" + typ = f" |B[type:{typ}]|n{valuetype}" if typ else f"{valuetype}" value = utils.to_str(value) value = _FUNCPARSER.parse(ansi_raw(value), escape=True) return ( @@ -2846,8 +2849,12 @@ class CmdExamine(ObjManipCommand): _FUNCPARSER = funcparser.FuncParser(settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES) key, category, value = attr.db_key, attr.db_category, attr.value + valuetype = "" + if value is None and attr.strvalue is not None: + value = attr.strvalue + valuetype = " |B[strvalue]|n" typ = self._get_attribute_value_type(value) - typ = f" |B[type: {typ}]|n" if typ else "" + typ = f" |B[type: {typ}]|n{valuetype}" if typ else f"{valuetype}" value = utils.to_str(value) value = _FUNCPARSER.parse(ansi_raw(value), escape=True) value = utils.crop(value) @@ -3293,7 +3300,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): if "loc" in self.switches and not is_account and result.location: string += ( f" (|wlocation|n: |g{result.location.get_display_name(caller)}" - f"{result.get_extra_display_name_info(caller)}|n)" + f"{result.location.get_extra_display_name_info(caller)}|n)" ) else: # Not an account/dbref search but a wider search; build a queryset. @@ -3439,7 +3446,7 @@ class ScriptEvMore(EvMore): if (hasattr(script, "obj") and script.obj) else "" ), - script.key, + script.db_key, script.interval if script.interval > 0 else "--", nextrep, rept, @@ -3460,17 +3467,20 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): script[/start||stop] = [] Switches: - start - start/unpause an existing script's timer. - stop - stops an existing script's timer - pause - pause a script's timer + start - start/unpause an existing script's timer. + stop - stops an existing script's timer + pause - pause a script's timer delete - deletes script. This will also stop the timer as needed Examples: - script - list all scripts - script foo.bar.Script - create a new global Script - script/pause foo.bar.Script - pause global script - script scriptname|#dbref - examine named existing global script - script/delete #dbref[-#dbref] - delete script or range by #dbref + script - list all scripts + script key:foo.bar.Script - create a new global Script with typeclass + and key 'key' + script foo.bar.Script - create a new global Script with typeclass + (key taken from typeclass or auto-generated) + script/pause foo.bar.Script - pause global script + script typeclass|name|#dbref - examine named existing global script + script/delete #dbref[-#dbref] - delete script or range by #dbref script myobj = - list all scripts on object script myobj = foo.bar.Script - create and assign script to object @@ -3495,14 +3505,13 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): key = "@scripts" aliases = ["@script"] - switch_options = ("create", "start", "stop", "pause", "delete") + switch_options = ("start", "stop", "pause", "delete") locks = "cmd:perm(scripts) or perm(Builder)" help_category = "System" excluded_typeclass_paths = ["evennia.prototypes.prototypes.DbPrototype"] switch_mapping = { - "create": "|gCreated|n", "start": "|gStarted|n", "stop": "|RStopped|n", "pause": "|Paused|n", @@ -3511,21 +3520,32 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): # never show these script types hide_script_paths = ("evennia.prototypes.prototypes.DbPrototype",) - def _search_script(self, args): - # test first if this is a script match - scripts = ScriptDB.objects.get_all_scripts(key=args).exclude( - db_typeclass_path__in=self.hide_script_paths - ) - if scripts: - return scripts - # try typeclass path + def _search_script(self): + + # see if a dbref was provided + if dbref(self.typeclass_query): + scripts = ScriptDB.objects.get_all_scripts(self.typeclass_query) + if scripts: + return scripts + self.caller.msg(f"No script found with dbref {self.typeclass_query}") + raise InterruptCommand + + # if we provided a key, we must find an exact match, otherwise we're creating that anew + if self.key_query: + return ScriptDB.objects.filter( + db_key__iexact=self.key_query, db_typeclass_path__iendswith=self.typeclass_query + ).exclude(db_typeclass_path__in=self.hide_script_paths) + + # the more general case - try typeclass path scripts = ( - ScriptDB.objects.filter(db_typeclass_path__iendswith=args) + ScriptDB.objects.filter(db_typeclass_path__iendswith=self.typeclass_query) .exclude(db_typeclass_path__in=self.hide_script_paths) .order_by("id") ) if scripts: return scripts + + args = self.typeclass_query if "-" in args: # may be a dbref-range val1, val2 = (dbref(part.strip()) for part in args.split("-", 1)) @@ -3538,6 +3558,29 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): if scripts: return scripts + def parse(self): + super().parse() + + if not self.args: + return + + def _separate_key_typeclass(part): + part1, *part2 = part.split(":", 1) + return (part1, part2[0]) if part2 else (None, part1) + + if self.rhs: + # arg with "=" + self.obj_query = self.lhs + self.key_query, self.typeclass_query = _separate_key_typeclass(self.rhs) + elif self.rhs is not None: + # an empty "=" + self.obj_query = self.lhs + self.key_query, self.typeclass_query = None, None + else: + # arg without "=" + self.obj_query = None + self.key_query, self.typeclass_query = _separate_key_typeclass(self.args) + def func(self): """implement method""" @@ -3553,20 +3596,8 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): return # find script or object to operate on - scripts, obj = None, None - if self.rhs: - obj_query = self.lhs - script_query = self.rhs - elif self.rhs is not None: - # an empty "=" - obj_query = self.lhs - script_query = None - else: - obj_query = None - script_query = self.args - - scripts = self._search_script(script_query) if script_query else None - objects = caller.search(obj_query, quiet=True) if obj_query else None + scripts = self._search_script() if self.typeclass_query else None + objects = caller.search(self.obj_query, quiet=True) if self.obj_query else None obj = objects[0] if objects else None if not self.switches: @@ -3575,7 +3606,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): # we have an object if self.rhs: # creation mode - if obj.scripts.add(self.rhs, autostart=True): + if obj.scripts.add(self.typeclass_query, key=self.key_query, autostart=True): caller.msg( f"Script |w{self.rhs}|n successfully added and " f"started on {obj.get_display_name(caller)}." @@ -3603,7 +3634,9 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): else: # create global script try: - new_script = create.create_script(self.args) + new_script = create.create_script( + typeclass=self.typeclass_query, key=self.key_query + ) except ImportError: logger.log_trace() new_script = None @@ -3922,7 +3955,7 @@ class CmdTag(COMMAND_DEFAULT_CLASS): key = "@tag" aliases = ["@tags"] - options = ("search", "del") + switch_options = ("search", "del") locks = "cmd:perm(tag) or perm(Builder)" help_category = "Building" arg_regex = r"(/\w+?(\s|$))|\s|$" diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index c75d837407..5c5df209b2 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -9,6 +9,7 @@ Communication commands: from django.conf import settings from django.db.models import Q + from evennia.accounts import bots from evennia.accounts.models import AccountDB from evennia.comms.comms import DefaultChannel @@ -1412,9 +1413,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS): message = f"{caller.key} {message.strip(':').strip()}" # create the persistent message object - target_perms = " or ".join( - [f"id({target.id})" for target in targets + [caller]] - ) + target_perms = " or ".join([f"id({target.id})" for target in targets + [caller]]) create.create_message( caller, message, diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index f7fc7f1fd2..a204749a47 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -14,13 +14,10 @@ main test suite started with import datetime from unittest.mock import MagicMock, Mock, patch +import evennia from anything import Anything from django.conf import settings from django.test import override_settings -from parameterized import parameterized -from twisted.internet import task - -import evennia from evennia import ( DefaultCharacter, DefaultExit, @@ -32,14 +29,7 @@ from evennia import ( from evennia.commands import cmdparser from evennia.commands.cmdset import CmdSet from evennia.commands.command import Command, InterruptCommand -from evennia.commands.default import ( - account, - admin, - batchprocess, - building, - comms, - general, -) +from evennia.commands.default import account, admin, batchprocess, building, comms, general from evennia.commands.default import help as help_module from evennia.commands.default import syscommands, system, unloggedin from evennia.commands.default.cmdset_character import CharacterCmdSet @@ -48,6 +38,8 @@ from evennia.prototypes import prototypes as protlib from evennia.utils import create, gametime, utils from evennia.utils.test_resources import BaseEvenniaCommandTest # noqa from evennia.utils.test_resources import BaseEvenniaTest, EvenniaCommandTest +from parameterized import parameterized +from twisted.internet import task # ------------------------------------------------------------ # Command testing @@ -446,7 +438,7 @@ class TestCmdTasks(BaseEvenniaCommandTest): args = f"/pause {self.task.get_id()}" wanted_msg = "Pause task 1 with completion date" cmd_result = self.call(system.CmdTasks(), args, wanted_msg) - self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ") self.char1.execute_cmd("y") self.assertTrue(self.task.paused) self.task_handler.clock.advance(self.timedelay + 1) @@ -455,7 +447,7 @@ class TestCmdTasks(BaseEvenniaCommandTest): self.assertTrue(self.task.exists()) wanted_msg = "Unpause task 1 with completion date" cmd_result = self.call(system.CmdTasks(), args, wanted_msg) - self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ") self.char1.execute_cmd("y") # verify task continues after unpause self.task_handler.clock.advance(1) @@ -465,7 +457,7 @@ class TestCmdTasks(BaseEvenniaCommandTest): args = f"/do_task {self.task.get_id()}" wanted_msg = "Do_task task 1 with completion date" cmd_result = self.call(system.CmdTasks(), args, wanted_msg) - self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ") self.char1.execute_cmd("y") self.assertFalse(self.task.exists()) @@ -473,7 +465,7 @@ class TestCmdTasks(BaseEvenniaCommandTest): args = f"/remove {self.task.get_id()}" wanted_msg = "Remove task 1 with completion date" cmd_result = self.call(system.CmdTasks(), args, wanted_msg) - self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ") self.char1.execute_cmd("y") self.assertFalse(self.task.exists()) @@ -481,7 +473,7 @@ class TestCmdTasks(BaseEvenniaCommandTest): args = f"/call {self.task.get_id()}" wanted_msg = "Call task 1 with completion date" cmd_result = self.call(system.CmdTasks(), args, wanted_msg) - self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ") self.char1.execute_cmd("y") # make certain the task is still active self.assertTrue(self.task.active()) @@ -493,7 +485,7 @@ class TestCmdTasks(BaseEvenniaCommandTest): args = f"/cancel {self.task.get_id()}" wanted_msg = "Cancel task 1 with completion date" cmd_result = self.call(system.CmdTasks(), args, wanted_msg) - self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ") + self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ") self.char1.execute_cmd("y") self.assertTrue(self.task.exists()) self.assertFalse(self.task.active()) @@ -797,7 +789,7 @@ class TestBuilding(BaseEvenniaCommandTest): self.call( building.CmdExamine(), "self/test2", - "Attribute Char/test2 [category=None]:\n\nthis is a \$random() value.", + "Attribute Char/test2 [category=None]:\n\nthis is a \\$random() value.", ) self.room1.scripts.add(self.script.__class__) @@ -805,6 +797,13 @@ class TestBuilding(BaseEvenniaCommandTest): self.account.scripts.add(self.script.__class__) self.call(building.CmdExamine(), "*TestAccount") + self.char1.attributes.add("strattr", "testval", strattr=True) + self.call( + building.CmdExamine(), + "self/strattr", + "Attribute Char/strattr [category=None] [strvalue]:\n\ntestval", + ) + def test_set_obj_alias(self): self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj") self.call( @@ -1654,17 +1653,17 @@ class TestBuilding(BaseEvenniaCommandTest): ) def test_script_multi_delete(self): - script1 = create.create_script() - script2 = create.create_script() - script3 = create.create_script() + script1 = create.create_script(key="script1") + script2 = create.create_script(key="script2") + script3 = create.create_script(key="script3") self.call( building.CmdScripts(), "/delete #{}-#{}".format(script1.id, script3.id), ( - f"Global Script Deleted - #{script1.id} (evennia.scripts.scripts.DefaultScript)|" - f"Global Script Deleted - #{script2.id} (evennia.scripts.scripts.DefaultScript)|" - f"Global Script Deleted - #{script3.id} (evennia.scripts.scripts.DefaultScript)" + f"Global Script Deleted - script1 (evennia.scripts.scripts.DefaultScript)|" + f"Global Script Deleted - script2 (evennia.scripts.scripts.DefaultScript)|" + f"Global Script Deleted - script3 (evennia.scripts.scripts.DefaultScript)" ), inputs=["y"], ) diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index d6666a2bd4..5b1a0bc028 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -5,10 +5,11 @@ Base typeclass for in-game Channels. import re -import evennia from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.text import slugify + +import evennia from evennia.comms.managers import ChannelManager from evennia.comms.models import ChannelDB from evennia.typeclasses.models import TypeclassBase @@ -50,6 +51,70 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): the account-level `channel` command is used. If you were to rename that command you must tweak the output to something like `yourchannelcommandname {channelname} = $1`. + * Properties: + mutelist + banlist + wholist + + * Working methods: + get_log_filename() + set_log_filename(filename) + has_connection(account) - check if the given account listens to this channel + connect(account) - connect account to this channel + disconnect(account) - disconnect account from channel + access(access_obj, access_type='listen', default=False) - check the + access on this channel (default access_type is listen) + create(key, creator=None, *args, **kwargs) + delete() - delete this channel + message_transform(msg, emit=False, prefix=True, + sender_strings=None, external=False) - called by + the comm system and triggers the hooks below + msg(msgobj, header=None, senders=None, sender_strings=None, + persistent=None, online=False, emit=False, external=False) - main + send method, builds and sends a new message to channel. + tempmsg(msg, header=None, senders=None) - wrapper for sending non-persistent + messages. + distribute_message(msg, online=False) - send a message to all + connected accounts on channel, optionally sending only + to accounts that are currently online (optimized for very large sends) + mute(subscriber, **kwargs) + unmute(subscriber, **kwargs) + ban(target, **kwargs) + unban(target, **kwargs) + add_user_channel_alias(user, alias, **kwargs) + remove_user_channel_alias(user, alias, **kwargs) + + + Useful hooks: + at_channel_creation() - called once, when the channel is created + basetype_setup() + at_init() + at_first_save() + channel_prefix() - how the channel should be + prefixed when returning to user. Returns a string + format_senders(senders) - should return how to display multiple + senders to a channel + pose_transform(msg, sender_string) - should detect if the + sender is posing, and if so, modify the string + format_external(msg, senders, emit=False) - format messages sent + from outside the game, like from IRC + format_message(msg, emit=False) - format the message body before + displaying it to the user. 'emit' generally means that the + message should not be displayed with the sender's name. + channel_prefix() + + pre_join_channel(joiner) - if returning False, abort join + post_join_channel(joiner) - called right after successful join + pre_leave_channel(leaver) - if returning False, abort leave + post_leave_channel(leaver) - called right after successful leave + at_pre_msg(message, **kwargs) + at_post_msg(message, **kwargs) + web_get_admin_url() + web_get_create_url() + web_get_detail_url() + web_get_update_url() + web_get_delete_url() + """ objects = ChannelManager() @@ -856,7 +921,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): # Used by Django Sites/Admin get_absolute_url = web_get_detail_url - # TODO Evennia 1.0+ removed hooks. Remove in 1.1. + # TODO Evennia 1.0+ removed hooks. Remove in 5.0 def message_transform(self, *args, **kwargs): raise RuntimeError( "Channel.message_transform is no longer used in 1.0+. " diff --git a/evennia/comms/models.py b/evennia/comms/models.py index d323770858..efab6ee4d3 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -22,6 +22,7 @@ necessary to easily be able to delete connections on the fly). from django.conf import settings from django.db import models from django.utils import timezone + from evennia.comms import managers from evennia.locks.lockhandler import LockHandler from evennia.typeclasses.models import TypedObject diff --git a/evennia/contrib/base_systems/components/component.py b/evennia/contrib/base_systems/components/component.py index edcf21de78..2754373a3c 100644 --- a/evennia/contrib/base_systems/components/component.py +++ b/evennia/contrib/base_systems/components/component.py @@ -15,16 +15,17 @@ class BaseComponent(type): This is the metaclass for components, responsible for registering components to the listing. """ + def __new__(cls, name, parents, attrs): """ Every class that uses this metaclass will be registered as a component in the Component Listing using its name. All of them require a unique name. """ - attrs_name = attrs.get('name') + attrs_name = attrs.get("name") if attrs_name and not COMPONENT_LISTING.get(attrs_name): new_fields = {} - attrs['_fields'] = new_fields + attrs["_fields"] = new_fields for parent in parents: _parent_fields = getattr(parent, "_fields") if _parent_fields: diff --git a/evennia/contrib/base_systems/components/tests.py b/evennia/contrib/base_systems/components/tests.py index 6087a4d6f6..a40b239eb1 100644 --- a/evennia/contrib/base_systems/components/tests.py +++ b/evennia/contrib/base_systems/components/tests.py @@ -87,23 +87,23 @@ class TestComponents(EvenniaTest): def test_character_components_set_fields_properly(self): test_a_fields = self.char1.test_a._fields - self.assertIn('my_int', test_a_fields) - self.assertIn('my_list', test_a_fields) + self.assertIn("my_int", test_a_fields) + self.assertIn("my_list", test_a_fields) self.assertEqual(len(test_a_fields), 2) test_b_fields = self.char1.test_b._fields - self.assertIn('my_int', test_b_fields) - self.assertIn('my_list', test_b_fields) - self.assertIn('default_tag', test_b_fields) - self.assertIn('single_tag', test_b_fields) - self.assertIn('multiple_tags', test_b_fields) - self.assertIn('default_single_tag', test_b_fields) + self.assertIn("my_int", test_b_fields) + self.assertIn("my_list", test_b_fields) + self.assertIn("default_tag", test_b_fields) + self.assertIn("single_tag", test_b_fields) + self.assertIn("multiple_tags", test_b_fields) + self.assertIn("default_single_tag", test_b_fields) self.assertEqual(len(test_b_fields), 6) test_ic_a_fields = self.char1.ic_a._fields - self.assertIn('my_int', test_ic_a_fields) - self.assertIn('my_list', test_ic_a_fields) - self.assertIn('my_other_int', test_ic_a_fields) + self.assertIn("my_int", test_ic_a_fields) + self.assertIn("my_list", test_ic_a_fields) + self.assertIn("my_other_int", test_ic_a_fields) self.assertEqual(len(test_ic_a_fields), 3) def test_inherited_typeclass_does_not_include_child_class_components(self): diff --git a/evennia/contrib/base_systems/ingame_reports/README.md b/evennia/contrib/base_systems/ingame_reports/README.md new file mode 100644 index 0000000000..00c1cbf905 --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/README.md @@ -0,0 +1,130 @@ +# In-Game Reporting System + +Contrib by InspectorCaracal, 2024 + +This contrib provides an in-game reports system, handling bug reports, player reports, and idea submissions by default. It also supports adding your own types of reports, or removing any of the default report types. + +Each type of report has its own command for submitting new reports, and an admin command is also provided for managing the reports through a menu. + +## Installation + +To install the reports contrib, just add the provided cmdset to your default AccountCmdSet: + +```python +# in commands/default_cmdset.py + +from evennia.contrib.base_systems.ingame_reports import ReportsCmdSet + +class AccountCmdSet(default_cmds.AccountCmdSet): + # ... + + def at_cmdset_creation(self): + # ... + self.add(ReportsCmdSet) +``` + +The contrib also has two optional settings: `INGAME_REPORT_TYPES` and `INGAME_REPORT_STATUS_TAGS`. + +The `INGAME_REPORT_TYPES` setting is covered in detail in the section "Adding new types of reports". + +The `INGAME_REPORT_STATUS_TAGS` setting is covered in the section "Managing reports". + +## Usage + +By default, the following report types are available: + +* Bugs: Report bugs encountered during gameplay. +* Ideas: Submit suggestions for game improvement. +* Players: Report inappropriate player behavior. + +Players can submit new reports through the command for each report type, and staff are given access to a report-management command and menu. + +### Submitting reports + +Players can submit reports using the following commands: + +* `bug ` - Files a bug report. An optional target can be included - `bug = ` - making it easier for devs/builders to track down issues. +* `report = ` - Reports a player for inappropriate or rule-breaking behavior. *Requires* a target to be provided - it searches among accounts by default. +* `idea ` - Submits a general suggestion, with no target. It also has an alias of `ideas` which allows you to view all of your submitted ideas. + +### Managing reports + +The `manage reports` command allows staff to review and manage the various types of reports by launching a management menu. + +This command will dynamically add aliases to itself based on the types of reports available, with each command string launching a menu for that particular report type. The aliases are built on the pattern `manage s` - by default, this means it makes `manage bugs`, `manage players`, and `manage ideas` available along with the default `manage reports`, and that e.g. `manage bugs` will launch the management menu for `bug`-type reports. + +Aside from reading over existing reports, the menu allows you to change the status of any given report. By default, the contrib includes two different status tags: `in progress` and `closed`. + +> Note: A report is created with no status tags, which is considered "open" + +If you want a different set of statuses for your reports, you can define the `INGAME_REPORT_STATUS_TAGS` to your list of statuses. + +**Example** + +```python +# in server/conf/settings.py + +# this will allow for the statuses of 'in progress', 'rejected', and 'completed', without the contrib-default of 'closed' +INGAME_REPORT_STATUS_TAGS = ('in progress', 'rejected', 'completed') +``` + +### Adding new types of reports + +The contrib is designed to make adding new types of reports to the system as simple as possible, requiring only two steps: + +1. Update your settings file to include an `INGAME_REPORT_TYPES` setting. +2. Create and add a new `ReportCmd` to your command set. + +#### 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. + +```python +# in server/conf/settings.py + +# this will include the contrib's report types as well as a custom 'complaint' report type +INGAME_REPORT_TYPES = ('bugs', 'ideas', 'players', 'complaints') +``` + +You can also use this setting to remove any of the contrib's report types - the contrib will respect this setting when building its cmdset with no additional steps. + +```python +# in server/conf/settings.py + +# this redefines the setting to not include 'ideas', so the ideas command and reports won't be available +INGAME_REPORT_TYPES = ('bugs', 'players') +``` + +#### Create a new ReportCmd + +`ReportCmdBase` is a parent command class which comes with the main functionality for submitting reports. Creating a new reporting command is as simple as inheriting from this class and defining a couple of class attributes. + +* `key` - This is the same as for any other command, setting the command's usable key. It also acts as the report type if that isn't explicitly set. +* `report_type` - The type of report this command is for (e.g. `player`). You only need to set it if you want a different string from the key. +* `report_locks` - The locks you want applied to the created reports. Defaults to `"read:pperm(Admin)"` +* `success_msg` - The string which is sent to players after submitting a report of this type. Defaults to `"Your report has been filed."` +* `require_target`: Set to `True` if your report type requires a target (e.g. player reports). + +> Note: The contrib's own commands - `CmdBug`, `CmdIdea`, and `CmdReport` - are implemented the same way, so you can review them as examples. + +Example: + +```python +from evennia.contrib.base_systems.ingame_reports.reports import ReportCmdBase + +class CmdCustomReport(ReportCmdBase): + """ + file a custom report + + Usage: + customreport + + This is a custom report type. + """ + + key = "customreport" + report_type = "custom" + success_message = "You have successfully filed a custom report." +``` + +Add this new command to your default cmdset to enable filing your new report type. diff --git a/evennia/contrib/base_systems/ingame_reports/__init__.py b/evennia/contrib/base_systems/ingame_reports/__init__.py new file mode 100644 index 0000000000..d4ad3e32aa --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/__init__.py @@ -0,0 +1 @@ +from .reports import ReportsCmdSet diff --git a/evennia/contrib/base_systems/ingame_reports/menu.py b/evennia/contrib/base_systems/ingame_reports/menu.py new file mode 100644 index 0000000000..a7a4c98a2e --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/menu.py @@ -0,0 +1,134 @@ +""" +The report-management menu module. +""" + +from django.conf import settings + +from evennia.comms.models import Msg +from evennia.utils import logger +from evennia.utils.utils import crop, datetime_format, is_iter, iter_to_str + +# the number of reports displayed on each page +_REPORTS_PER_PAGE = 10 + +_REPORT_STATUS_TAGS = ("closed", "in progress") +if hasattr(settings, "INGAME_REPORT_STATUS_TAGS"): + if is_iter(settings.INGAME_REPORT_STATUS_TAGS): + _REPORT_STATUS_TAGS = settings.INGAME_REPORT_STATUS_TAGS + else: + logger.log_warn( + "The 'INGAME_REPORT_STATUS_TAGS' setting must be an iterable of strings; falling back to defaults." + ) + + +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}" + + if not (report_list := getattr(caller.ndb._evmenu, "report_list", None)): + report_list = Msg.objects.search_message(receiver=hub).order_by("db_date_created") + caller.ndb._evmenu.report_list = report_list + # allow the menu to filter print-outs by status + if kwargs.get("status"): + new_report_list = report_list.filter(db_tags__db_key=kwargs["status"]) + # we don't filter reports if there are no reports under that filter + if not new_report_list: + text = f"(No {kwargs['status']} reports)\n{text}" + else: + report_list = new_report_list + text = f"Managing {kwargs['status']} {hub_name}" + else: + report_list = report_list.exclude(db_tags__db_key="closed") + + # filter by lock access + report_list = [msg for msg in report_list if msg.access(caller, "read")] + + # this will catch both no reports filed and no permissions + if not report_list: + return "There is nothing there for you to manage.", {} + + options = [ + { + "desc": f"{datetime_format(report.date_created)} - {crop(report.message, 50)}", + "goto": ("menunode_manage_report", {"report": report}), + } + for report in report_slice + ] + options.append( + { + "key": ("|uF|nilter by status", "filter", "status", "f"), + "goto": "menunode_choose_filter", + } + ) + if start > 0: + options.append( + { + "key": (f"|uP|nrevious {_REPORTS_PER_PAGE}", "previous", "prev", "p"), + "goto": ( + "menunode_list_reports", + {"page": max(start - _REPORTS_PER_PAGE, 0) // _REPORTS_PER_PAGE}, + ), + } + ) + if end < len(report_list): + options.append( + { + "key": (f"|uN|next {_REPORTS_PER_PAGE}", "next", "n"), + "goto": ( + "menunode_list_reports", + {"page": (start + _REPORTS_PER_PAGE) // _REPORTS_PER_PAGE}, + ), + } + ) + return text, options + + +def menunode_choose_filter(caller, raw_string, **kwargs): + """apply or clear a status filter to the main report view""" + text = "View which reports?" + # options for all the possible statuses + options = [ + {"desc": status, "goto": ("menunode_list_reports", {"status": status})} + for status in _REPORT_STATUS_TAGS + ] + # no filter + options.append({"desc": "All open reports", "goto": "menunode_list_reports"}) + return text, options + + +def _report_toggle_tag(caller, raw_string, report, tag, **kwargs): + """goto callable to toggle a status tag on or off""" + if tag in report.tags.all(): + report.tags.remove(tag) + else: + report.tags.add(tag) + return ("menunode_manage_report", {"report": report}) + + +def menunode_manage_report(caller, raw_string, report, **kwargs): + """ + Read out the full report text and targets, and allow for changing the report's status. + """ + receivers = [r for r in report.receivers if r != caller.ndb._evmenu.hub] + text = f"""\ +{report.message} +{datetime_format(report.date_created)} by {iter_to_str(report.senders)}{' about '+iter_to_str(r.get_display_name(caller) for r in receivers) if receivers else ''} +{iter_to_str(report.tags.all())}""" + + options = [] + for tag in _REPORT_STATUS_TAGS: + options.append( + { + "desc": f"{'Unmark' if tag in report.tags.all() else 'Mark' } as {tag}", + "goto": (_report_toggle_tag, {"report": report, "tag": tag}), + } + ) + options.append({"desc": f"Manage another report", "goto": "menunode_list_reports"}) + return text, options diff --git a/evennia/contrib/base_systems/ingame_reports/reports.py b/evennia/contrib/base_systems/ingame_reports/reports.py new file mode 100644 index 0000000000..62d470676a --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/reports.py @@ -0,0 +1,315 @@ +""" +In-Game Reporting System + +This contrib provides an in-game reporting system, with player-facing commands and a staff +management interface. + +# Installation + +To install, just add the provided cmdset to your default AccountCmdSet: + + # in commands/default_cmdset.py + + from evennia.contrib.base_systems.ingame_reports import ReportsCmdSet + + class AccountCmdSet(default_cmds.AccountCmdSet): + # ... + + def at_cmdset_creation(self): + # ... + self.add(ReportsCmdSet) + +# Features + +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. + +The contrib can be further configured through two settings, `INGAME_REPORT_TYPES` and `INGAME_REPORT_STATUS_TAGS` + +""" + +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 . import menu + +_DEFAULT_COMMAND_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) + +# the default report types +_REPORT_TYPES = ("bugs", "ideas", "players") +if hasattr(settings, "INGAME_REPORT_TYPES"): + if is_iter(settings.INGAME_REPORT_TYPES): + _REPORT_TYPES = settings.INGAME_REPORT_TYPES + else: + logger.log_warn( + "The 'INGAME_REPORT_TYPES' setting must be an iterable of strings; falling back to defaults." + ) + + +def _get_report_hub(report_type): + """ + A helper function to retrieve the global script which acts as the hub for a given report type. + + Args: + report_type (str): The category of reports to retrieve the script for. + + Returns: + Script or None: The global script, or None if it couldn't be retrieved or created + + 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)): + hub = create.create_script(key=hub_key) + return hub or None + + +class CmdManageReports(_DEFAULT_COMMAND_CLASS): + """ + manage the various reports + + Usage: + manage [report type] + + Available report types: + bugs + ideas + players + + Initializes a menu for reviewing and changing the status of current reports. + """ + + key = "manage reports" + aliases = tuple(f"manage {report_type}" for report_type in _REPORT_TYPES) + locks = "cmd:pperm(Admin)" + + def get_help(self): + """Returns a help string containing the configured available report types""" + + report_types = iter_to_str("\n ".join(_REPORT_TYPES)) + + helptext = f"""\ +manage the various reports + +Usage: + manage [report type] + +Available report types: + {report_types} + +Initializes a menu for reviewing and changing the status of current reports. +""" + + return helptext + + def func(self): + report_type = self.cmdstring.split()[-1] + if report_type == "reports": + report_type = "players" + if report_type not in _REPORT_TYPES: + self.msg(f"'{report_type}' is not a valid report category.") + return + # remove the trailing s, just so everything reads nicer + report_type = report_type[:-1] + hub = _get_report_hub(report_type) + if not hub: + self.msg("You cannot manage that.") + + evmenu.EvMenu( + self.account, menu, startnode="menunode_list_reports", hub=hub, persistent=True + ) + + +class ReportCmdBase(_DEFAULT_COMMAND_CLASS): + """ + A parent class for creating report commands. This help text may be displayed if + your command's help text is not properly configured. + """ + + help_category = "reports" + # defines what locks the reports generated by this command will have set + report_locks = "read:pperm(Admin)" + # determines if the report can be filed without a target + require_target = False + # the message sent to the reporter after the report has been created + success_msg = "Your report has been filed." + # the report type for this command, if different from the key + report_type = None + + def at_pre_cmd(self): + """validate that the needed hub script exists - if not, cancel the command""" + hub = _get_report_hub(self.report_type or self.key) + if not hub: + # a return value of True from `at_pre_cmd` cancels the command + return True + self.hub = hub + return super().at_pre_cmd() + + 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 + super().parse() + # split out the report message and target strings + if self.rhs: + self.report_message = self.rhs + self.target_str = self.lhs + else: + self.report_message = self.lhs + self.target_str = "" + + def target_search(self, searchterm, **kwargs): + """ + Search for a target that matches the given search term. By default, does a normal search via the + caller - a local object search for a Character, or an account search for an Account. + + Args: + searchterm (str) - The string to search for + + Returns: + result (Object, Account, or None) - the result of the search + """ + return self.caller.search(searchterm) + + def create_report(self, *args, **kwargs): + """ + Creates the report. By default, this creates a Msg with any provided args and kwargs. + + Returns: + success (bool) - True if the report was created successfully, or False if there was an issue. + """ + return create.create_message(*args, **kwargs) + + def func(self): + hub = self.hub + if not self.args: + self.msg("You must provide a message.") + return + + target = None + if self.target_str: + target = self.target_search(self.target_str) + if not target: + return + elif self.require_target: + self.msg("You must include a target.") + return + + receivers = [hub] + if target: + receivers.append(target) + + if self.create_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) + else: + # something went wrong + self.msg( + "Something went wrong creating your report. Please try again later or contact staff directly." + ) + + +# The commands below are the usable reporting commands + + +class CmdBug(ReportCmdBase): + """ + file a bug + + Usage: + bug [ =] + + Note: If a specific object, location or character is bugged, please target it for the report. + + Examples: + bug hammer = This doesn't work as a crafting tool but it should + bug every time I go through a door I get the message twice + """ + + key = "bug" + report_locks = "read:pperm(Developer)" + + +class CmdReport(ReportCmdBase): + """ + report a player + + Usage: + report = + + All player reports will be reviewed. + """ + + key = "report" + report_type = "player" + require_target = True + account_caller = True + + +class CmdIdea(ReportCmdBase): + """ + submit a suggestion + + Usage: + ideas + idea + + Example: + idea wouldn't it be cool if we had horses we could ride + """ + + key = "idea" + aliases = ("ideas",) + report_locks = "read:pperm(Builder)" + success_msg = "Thank you for your suggestion!" + + def func(self): + # we add an extra feature to this command, allowing you to see all your submitted ideas + if self.cmdstring == "ideas": + # list your ideas + if ( + ideas := Msg.objects.search_message(sender=self.account, receiver=self.hub) + .order_by("-db_date_created") + .exclude(db_tags__db_key="closed") + ): + # todo: use a paginated menu + self.msg( + "Ideas you've submitted:\n " + + "\n ".join( + f"|w{item.message}|n (submitted {datetime_format(item.date_created)})" + for item in ideas + ) + ) + else: + self.msg("You have no open suggestions.") + return + # proceed to do the normal report-command functionality + super().func() + + +class ReportsCmdSet(CmdSet): + key = "Reports CmdSet" + + def at_cmdset_creation(self): + super().at_cmdset_creation() + if "bugs" in _REPORT_TYPES: + self.add(CmdBug) + if "ideas" in _REPORT_TYPES: + self.add(CmdIdea) + if "players" in _REPORT_TYPES: + self.add(CmdReport) + self.add(CmdManageReports) diff --git a/evennia/contrib/base_systems/ingame_reports/tests.py b/evennia/contrib/base_systems/ingame_reports/tests.py new file mode 100644 index 0000000000..4556c250a4 --- /dev/null +++ b/evennia/contrib/base_systems/ingame_reports/tests.py @@ -0,0 +1,86 @@ +from unittest.mock import Mock, patch, MagicMock +from evennia.utils import create +from evennia.comms.models import TempMsg +from evennia.utils.test_resources import EvenniaCommandTest + +from . import menu, reports + + +class _MockQuerySet(list): + def order_by(self, *args, **kwargs): + return self + + def exclude(self, *args, **kwargs): + return self + + def filter(self, *args, **kwargs): + return self + + +def _mock_pre(cmdobj): + """helper to mock at_pre_cmd""" + cmdobj.hub = Mock() + + +class TestReportCommands(EvenniaCommandTest): + @patch.object(create, "create_message", new=MagicMock()) + def test_report_cmd_base(self): + """verify that the base command functionality works""" + cmd = reports.ReportCmdBase + + # avoid test side-effects + with patch.object(cmd, "at_pre_cmd", new=_mock_pre) as _: + # no arguments + self.call(cmd(), "", "You must provide a message.") + # arguments, no target, no target required + self.call(cmd(), "test", "Your report has been filed.") + # arguments, custom success message + custom_success = "custom success message" + cmd.success_msg = custom_success + self.call(cmd(), "test", custom_success) + # arguments, no target, target required + cmd.require_target = True + self.call(cmd(), "test", "You must include a target.") + + @patch.object(create, "create_message", new=MagicMock()) + @patch.object(reports, "datetime_format", return_value="now") + def test_ideas_list(self, mock_datetime_format): + cmd = reports.CmdIdea + + fake_ideas = _MockQuerySet([TempMsg(message=f"idea {i+1}") for i in range(3)]) + expected = """\ +Ideas you've submitted: + idea 1 (submitted now) + idea 2 (submitted now) + idea 3 (submitted now) +""" + + with patch.object(cmd, "at_pre_cmd", new=_mock_pre) as _: + # submitting an idea + self.call(cmd(), "", "You must provide a message.") + # arguments, no target, no target required + self.call(cmd(), "test", "Thank you for your suggestion!") + + # viewing your submitted ideas + with patch.object(reports.Msg.objects, "search_message", return_value=fake_ideas): + self.call(cmd(), "", cmdstring="ideas", msg=expected) + + @patch.object(reports.evmenu, "EvMenu") + def test_cmd_manage_reports(self, evmenu_mock): + cmd = reports.CmdManageReports + hub = Mock() + + with patch.object(reports, "_get_report_hub", return_value=hub) as _: + # invalid report type fails + self.call( + cmd(), "", cmdstring="manage custom", msg="'custom' is not a valid report category." + ) + # verify valid type triggers evmenu + self.call(cmd(), "", cmdstring="manage bugs") + evmenu_mock.assert_called_once_with( + self.account, + menu, + startnode="menunode_list_reports", + hub=hub, + persistent=True, + ) diff --git a/evennia/contrib/game_systems/achievements/README.md b/evennia/contrib/game_systems/achievements/README.md index 217b9e54e0..7a6f9a73af 100644 --- a/evennia/contrib/game_systems/achievements/README.md +++ b/evennia/contrib/game_systems/achievements/README.md @@ -31,7 +31,7 @@ class CharacterCmdSet(default_cmds.CharacterCmdSet): self.add(CmdAchieve) ``` -**Optional** - The achievements contrib stores individual progress data on the `achievements` attribute by default, visible via `obj.db.attributes`. You can change this by assigning an attribute (key, category) tuple to the setting `ACHIEVEMENT_CONTRIB_ATTRIBUTE` +**Optional** - The achievements contrib stores individual progress data on the `achievements` attribute by default, visible via `obj.db.achievements`. You can change this by assigning an attribute (key, category) tuple to the setting `ACHIEVEMENT_CONTRIB_ATTRIBUTE` Example: ```py diff --git a/evennia/contrib/game_systems/achievements/__init__.py b/evennia/contrib/game_systems/achievements/__init__.py index 15d57e7025..76553e0b47 100644 --- a/evennia/contrib/game_systems/achievements/__init__.py +++ b/evennia/contrib/game_systems/achievements/__init__.py @@ -1,8 +1,8 @@ from .achievements import ( - get_achievement, - search_achievement, - all_achievements, - track_achievements, - get_achievement_progress, CmdAchieve, + all_achievements, + get_achievement, + get_achievement_progress, + search_achievement, + track_achievements, ) diff --git a/evennia/contrib/game_systems/achievements/achievements.py b/evennia/contrib/game_systems/achievements/achievements.py index f0cd189e12..82eed2a9e5 100644 --- a/evennia/contrib/game_systems/achievements/achievements.py +++ b/evennia/contrib/game_systems/achievements/achievements.py @@ -50,11 +50,18 @@ Example: """ from collections import Counter + from django.conf import settings -from evennia.utils import logger -from evennia.utils.utils import all_from_module, is_iter, make_iter, string_partial_matching -from evennia.utils.evmore import EvMore + from evennia.commands.default.muxcommand import MuxCommand +from evennia.utils import logger +from evennia.utils.evmore import EvMore +from evennia.utils.utils import ( + all_from_module, + is_iter, + make_iter, + string_partial_matching, +) # this is either a string of the attribute name, or a tuple of strings of the attribute name and category _ACHIEVEMENT_ATTR = make_iter(getattr(settings, "ACHIEVEMENT_CONTRIB_ATTRIBUTE", "achievements")) @@ -322,12 +329,12 @@ class CmdAchieve(MuxCommand): elif not achievement_data.get("progress"): status = "|yNot Started|n" else: - count = achievement_data.get("count",1) + count = achievement_data.get("count", 1) # is this achievement tracking items separately? if is_iter(achievement_data["progress"]): # we'll display progress as how many items have been completed completed = Counter(val >= count for val in achievement_data["progress"])[True] - pct = (completed * 100) // len(achievement_data['progress']) + pct = (completed * 100) // len(achievement_data["progress"]) else: # we display progress as the percent of the total count pct = (achievement_data["progress"] * 100) // count @@ -379,8 +386,7 @@ class CmdAchieve(MuxCommand): elif "all" in self.switches: # we merge our progress data into the full dict of achievements achievement_data = { - key: data | progress_data.get(key, {}) - for key, data in achievements.items() + key: data | progress_data.get(key, {}) for key, data in achievements.items() } # we show all of the currently available achievements regardless of progress status diff --git a/evennia/contrib/game_systems/achievements/tests.py b/evennia/contrib/game_systems/achievements/tests.py index b552972af3..74975fe329 100644 --- a/evennia/contrib/game_systems/achievements/tests.py +++ b/evennia/contrib/game_systems/achievements/tests.py @@ -1,5 +1,7 @@ -from evennia.utils.test_resources import BaseEvenniaTest, BaseEvenniaCommandTest from mock import patch + +from evennia.utils.test_resources import BaseEvenniaCommandTest, BaseEvenniaTest + from . import achievements _dummy_achievements = { diff --git a/evennia/contrib/grid/extended_room/extended_room.py b/evennia/contrib/grid/extended_room/extended_room.py index 1cb2661d83..a313a8fc17 100644 --- a/evennia/contrib/grid/extended_room/extended_room.py +++ b/evennia/contrib/grid/extended_room/extended_room.py @@ -677,7 +677,7 @@ class CmdExtendedRoomDesc(default_cmds.CmdDesc): edit - Open up a line editor for more advanced editing. del - Delete the description of an object. If another state is given, its description will be deleted. - spring|summer|autumn|winter - room description to use in respective in-game season + spring||summer||autumn||winter - room description to use in respective in-game season - room description to use with an arbitrary room state. Sets the description an object. If an object is not given, diff --git a/evennia/contrib/rpg/character_creator/character_creator.py b/evennia/contrib/rpg/character_creator/character_creator.py index 05b88b201b..694393b75a 100644 --- a/evennia/contrib/rpg/character_creator/character_creator.py +++ b/evennia/contrib/rpg/character_creator/character_creator.py @@ -22,9 +22,9 @@ from random import choices from django.conf import settings from evennia import DefaultAccount -from evennia.commands.default.muxcommand import MuxAccountCommand -from evennia.commands.default.account import CmdIC from evennia.commands.cmdset import CmdSet +from evennia.commands.default.account import CmdIC +from evennia.commands.default.muxcommand import MuxAccountCommand from evennia.objects.models import ObjectDB from evennia.utils.evmenu import EvMenu from evennia.utils.utils import is_iter, string_partial_matching diff --git a/evennia/contrib/rpg/rpsystem/README.md b/evennia/contrib/rpg/rpsystem/README.md index 8fa3df8f18..0df7407863 100644 --- a/evennia/contrib/rpg/rpsystem/README.md +++ b/evennia/contrib/rpg/rpsystem/README.md @@ -73,8 +73,14 @@ class Room(ContribRPRoom): # ... ``` +You need to set up Evennia to use the RPsystem's form to separate +between sdescs (`3-tall`) to make it compatible with how the rest of Evennia +separates between other multi-matches of searches/commands: -You will then need to reload the server and potentially force-reload + SEARCH_MULTIMATCH_REGEX = r"(?P[0-9]+)-(?P[^-]*)(?P.*)" + SEARCH_MULTIMATCH_TEMPLATE = " {number}-{name}{aliases}{info}\n" + +Finally, you will then need to reload the server and potentially force-reload your objects, if you originally created them without this. Example for your character: diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index 7cb9de81ab..b4938e8d3a 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -392,7 +392,7 @@ def parse_sdescs_and_recogs( # if no sdesc, include key plus aliases instead else: candidate_map.append((obj, obj.key)) - candidate_map.extend([(obj, alias) for alias in obj.aliases.all()]) + candidate_map.extend([(obj, alias) for alias in obj.aliases.all()]) # escape mapping syntax on the form {#id} if it exists already in emote, # if so it is replaced with just "id". @@ -422,7 +422,10 @@ def parse_sdescs_and_recogs( # first see if there is a number given (e.g. 1-tall) num_identifier, _ = marker_match.groups("") # return "" if no match, rather than None + # get the beginning of the actual text, minus the numeric identifier match_index = marker_match.start() + if num_identifier: + match_index += len(num_identifier) + 1 # split the emote string at the reference marker, to process everything after it head = string[:match_index] tail = string[match_index + 1 :] @@ -439,7 +442,7 @@ def parse_sdescs_and_recogs( (re.search(rquery, text, _RE_FLAGS), obj, text) for obj, text in candidate_map ) # filter out any non-matching candidates - bestmatches = [(obj, match.group()) for match, obj, text in matches if match] + bestmatches = [(obj, mtch.group()) for mtch, obj, text in matches if mtch] else: # to find the longest match, we start from the marker and lengthen the @@ -1333,30 +1336,30 @@ class ContribRPObject(DefaultObject): """ # we also want to use the default search method search_obj = super().get_search_result - is_builder = self.locks.check_lockstring(self, "perm(Builder)") + is_builder = self.permissions.check("Builder") + results = [] - if candidates: - candidates = parse_sdescs_and_recogs( + if candidates is not None: + searched_results = parse_sdescs_and_recogs( self, candidates, _PREFIX + searchdata, search_mode=True ) - results = [] - for candidate in candidates: - # we search by candidate keys here; this allows full error - # management and use of all kwargs - we will use searchdata - # in eventual error reporting later (not their keys). Doing - # it like this e.g. allows for use of the typeclass kwarg - # limiter. - results.extend( - [obj for obj in search_obj(candidate.key, **kwargs) if obj not in results] - ) - - if not results and is_builder: - # builders get to do a global search by key+alias - results = search_obj(searchdata, **kwargs) + if not searched_results and is_builder: + # builders get to do a search by key + results = search_obj(searchdata, candidates=candidates, **kwargs) + else: + # we do a default search on each result by key, here, to apply extra filtering kwargs + for searched_obj in searched_results: + results.extend( + [ + obj + for obj in search_obj( + searched_obj.key, candidates=[searched_obj], **kwargs + ) + if obj not in results + ] + ) else: - # global searches with #drefs end up here. Global searches are - # only done in code, so is controlled, #dbrefs are turned off - # for non-Builders. + # no candidates means it's a global search, so we pass it back to the default results = search_obj(searchdata, **kwargs) return results diff --git a/evennia/contrib/rpg/rpsystem/tests.py b/evennia/contrib/rpg/rpsystem/tests.py index df40eb367b..7c01ae9ca1 100644 --- a/evennia/contrib/rpg/rpsystem/tests.py +++ b/evennia/contrib/rpg/rpsystem/tests.py @@ -346,6 +346,44 @@ class TestRPSystem(BaseEvenniaTest): self.assertEqual(self.speaker.search("receiver of emotes"), self.receiver1) self.assertEqual(self.speaker.search("colliding"), self.receiver2) + def test_get_search_result(self): + self.obj1 = create_object(rpsystem.ContribRPObject, key="Obj1", location=self.room) + self.obj1.sdesc.add("something") + self.obj2 = create_object(rpsystem.ContribRPCharacter, key="Obj2", location=self.room) + self.obj2.sdesc.add("something") + candidates = [self.obj1, self.obj2] + + # search candidates by sdesc: both objects should be found + result = self.speaker.get_search_result("something", candidates) + self.assertEqual(list(result), candidates) + + # search by sdesc with 2-disambiguator: only second object should be found + result = self.speaker.get_search_result("2-something", candidates) + self.assertEqual(list(result), [self.obj2]) + + # search empty candidates: no objects should be found + result = self.speaker.get_search_result("something", candidates=[]) + self.assertEqual(list(result), []) + + # typeclass was given: only matching object should be found + result = self.speaker.get_search_result( + "something", candidates=candidates, typeclass=rpsystem.ContribRPCharacter + ) + self.assertEqual(list(result), [self.obj2]) + + # search by key with player permissions: no objects should be found + result = self.speaker.get_search_result("obj1", candidates) + self.assertEqual(list(result), []) + + # search by key with builder permissions: object should be found + self.speaker.permissions.add("builder") + result = self.speaker.get_search_result("obj1", candidates) + self.assertEqual(list(result), [self.obj1]) + + # search by key with builder permissions when NOT IN candidates: object should NOT be found + result = self.speaker.get_search_result("obj1", [self.obj2]) + self.assertEqual(list(result), []) + class TestRPSystemCommands(BaseEvenniaCommandTest): def setUp(self): diff --git a/evennia/contrib/rpg/traits/README.md b/evennia/contrib/rpg/traits/README.md index 511233f15d..217c2d1fcc 100644 --- a/evennia/contrib/rpg/traits/README.md +++ b/evennia/contrib/rpg/traits/README.md @@ -4,7 +4,7 @@ Contribution by Griatch 2020, based on code by Whitenoise and Ainneve contribs, A `Trait` represents a modifiable property on (usually) a Character. They can be used to represent everything from attributes (str, agi etc) to skills -(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc. +(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc. Traits differ from normal Attributes in that they track their changes and limit themselves to particular value-ranges. One can add/subtract from them easily and they can even change dynamically at a particular rate (like you being poisoned or @@ -50,8 +50,6 @@ class Character(DefaultCharacter): self.traits.add("hp", "Health", trait_type="gauge", min=0, max=100) self.traits.add("hunting", "Hunting Skill", trait_type="counter", base=10, mod=1, min=0, max=100) - - ``` When adding the trait, you supply the name of the property (`hunting`) along with a more human-friendly name ("Hunting Skill"). The latter will show if you @@ -78,7 +76,6 @@ class Object(DefaultObject): strength = TraitProperty("Strength", trait_type="static", base=10, mod=2) health = TraitProperty("Health", trait_type="gauge", min=0, base=100, mod=2) hunting = TraitProperty("Hunting Skill", trait_type="counter", base=10, mod=1, min=0, max=100) - ``` > Note that the property-name will become the name of the trait and you don't supply `trait_key` @@ -92,7 +89,7 @@ class Object(DefaultObject): ## Using traits -A trait is added to the traithandler (if you use `TraitProperty` the handler is just created under +A trait are entities added to the traithandler (if you use `TraitProperty` the handler is just created under the hood) after which one can access it as a property on the handler (similarly to how you can do .db.attrname for Attributes in Evennia). @@ -137,9 +134,30 @@ obj.traits.strength.value > obj.strength.value += 5 > obj.strength.value 17 - ``` +### Relating traits to one another + +From a trait you can access its own Traithandler as `.traithandler`. You can +also find another trait on the same handler by using the +`Trait.get_trait("traitname")` method. + +```python +> obj.strength.get_trait("hp").value +100 +``` + +This is not too useful for the default trait types - they are all operating +independently from one another. But if you create your own trait classes, you +can use this to make traits that depend on each other. + +For example, you could picture making a Trait that is the sum of the values of +two other traits and capped by the value of a third trait. Such complex +interactions are common in RPG rule systems but are by definition game specific. + +See an example in the section about [making your own Trait classes](#expanding-with-your-own-traits). + + ## Trait types All default traits have a read-only `.value` property that shows the relevant or @@ -158,7 +176,6 @@ compatible type. > trait1 + 2 > trait1.value 5 - ``` Two numerical traits can also be compared (bigger-than etc), which is useful in @@ -168,7 +185,6 @@ all sorts of rule-resolution. if trait1 > trait2: # do stuff - ``` ### Trait @@ -193,7 +209,6 @@ like a glorified Attribute. > obj.traits.mytrait.value = "stringvalue" > obj.traits.mytrait.value "stringvalue" - ``` ### Static trait @@ -217,7 +232,6 @@ that varies slowly or not at all, and which may be modified in-place. > obj.traits.mytrait.mod = 0 > obj.traits.mytrait.value 12 - ``` ### Counter @@ -253,8 +267,6 @@ remove it. A suggested use for a Counter Trait would be to track skill values. # for TraitProperties, pass the args/kwargs of traits.add() to the # TraitProperty constructor instead. - - ``` Counters have some extra properties: @@ -286,7 +298,6 @@ By calling `.desc()` on the Counter, you will get the text matching the current > obj.traits.hunting.desc() "expert" - ``` #### .rate @@ -327,12 +338,10 @@ a previous value. 71 # we have stopped at the ratetarget > obj.traits.hunting.rate = 0 # disable auto-change - - ``` Note that when retrieving the `current`, the result will always be of the same type as the `.base` even `rate` is a non-integer value. So if `base` is an `int` -(default)`, the `current` value will also be rounded the closest full integer. +(default), the `current` value will also be rounded the closest full integer. If you want to see the exact `current` value, set `base` to a float - you will then need to use `round()` yourself on the result if you want integers. @@ -347,7 +356,6 @@ return the value as a percentage. > obj.traits.hunting.percent(formatting=None) 71.0 - ``` ### Gauge @@ -379,7 +387,6 @@ stamina and the like. > obj.traits.hp.current -= 30 > obj.traits.hp.value 80 - ``` The Gauge trait is subclass of the Counter, so you have access to the same @@ -412,8 +419,6 @@ class RageTrait(StaticTrait): def sedate(self): self.mod = 0 - - ``` Above is an example custom-trait-class "rage" that stores a property "rage" on @@ -432,12 +437,24 @@ Reload the server and you should now be able to use your trait: > obj.traits.add("mood", "A dark mood", rage=30, trait_type='rage') > obj.traits.mood.rage 30 +``` + +Remember that you can use `.get_trait("name")` to access other traits on the +same handler. Let's say that the rage modifier is actually limited by +the characters's current STR value times 3, with a max of 100: + +```python +class RageTrait(StaticTrait): + #... + def berserk(self): + self.mod = min(100, self.get_trait("STR").value * 3) +``` # as TraitProperty +``` class Character(DefaultCharacter): rage = TraitProperty("A dark mood", rage=30, trait_type='rage') - ``` ## Adding additional TraitHandlers @@ -459,7 +476,7 @@ class Character(DefaultCharacter): def traits(self): # this adds the handler as .traits return TraitHandler(self) - + @lazy_property def stats(self): # this adds the handler as .stats @@ -478,3 +495,6 @@ class Character(DefaultCharacter): self.skills.add("hunting", "Hunting Skill", trait_type="counter", base=10, mod=1, min=0, max=100) ``` + +> Rememebr that the `.get_traits()` method only works for accessing Traits within the +_same_ TraitHandler. diff --git a/evennia/contrib/rpg/traits/tests.py b/evennia/contrib/rpg/traits/tests.py index 41998b8490..607dab88f9 100644 --- a/evennia/contrib/rpg/traits/tests.py +++ b/evennia/contrib/rpg/traits/tests.py @@ -9,10 +9,9 @@ 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 @@ -156,6 +155,16 @@ class TraitHandlerTest(_TraitHandlerBase): self.obj.attributes.get("traits", category="traits")["test1"]["value"], None ) + def test_related_traits(self): + """Test traits related to each other via Trait.get_trait()""" + + trait1 = self.traithandler.test1 + trait2 = self.traithandler.test2 + + self.assertEqual(trait1.traithandler, self.traithandler) + self.assertEqual(trait1.get_trait("test1"), trait1) + self.assertEqual(trait1.get_trait("test2"), trait2) + class TestTrait(_TraitHandlerBase): """ diff --git a/evennia/contrib/rpg/traits/traits.py b/evennia/contrib/rpg/traits/traits.py index 59daf7d70f..4c21e6c4b2 100644 --- a/evennia/contrib/rpg/traits/traits.py +++ b/evennia/contrib/rpg/traits/traits.py @@ -456,15 +456,9 @@ 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 @@ -657,7 +651,9 @@ class TraitHandler: if trait is None and trait_key in self.trait_data: trait_type = self.trait_data[trait_key]["trait_type"] trait_cls = self._get_trait_class(trait_type) - trait = self._cache[trait_key] = trait_cls(_GA(self, "trait_data")[trait_key]) + trait = self._cache[trait_key] = trait_cls( + _GA(self, "trait_data")[trait_key], handler=self + ) return trait def add( @@ -856,7 +852,7 @@ class Trait: # and have them treated like data to store. allow_extra_properties = True - def __init__(self, trait_data): + def __init__(self, trait_data, handler=None): """ This both initializes and validates the Trait on creation. It must raise exception if validation fails. The TraitHandler will call this @@ -869,12 +865,15 @@ class Trait: value in cls.data_default_values. Any extra kwargs will be made available as extra properties on the Trait, assuming the class variable `allow_extra_properties` is set. + handler (TraitHandler): The handler that this Trait is connected to. + This is for referencing other traits. Raises: TraitException: If input-validation failed. """ self._data = self.__class__.validate_input(self.__class__, trait_data) + self.traithandler = handler if not isinstance(trait_data, _SaverDict): logger.log_warn( @@ -955,6 +954,7 @@ class Trait: "data_default", "trait_type", "allow_extra_properties", + "traithandler", ): return _GA(self, key) try: @@ -970,10 +970,9 @@ class Trait: """Set extra parameters as attributes. Arbitrary attributes set on a Trait object will be - stored in the 'extra' key of the `_data` attribute. + stored as extra keys in the Trait's data. - This behavior is enabled by setting the instance - variable `_locked` to True. + This behavior is enabled by setting the instance variable `allow_extra_properties`. """ propobj = getattr(self.__class__, key, None) @@ -984,7 +983,7 @@ class Trait: return else: # this is some other value - if key in ("_data",): + if key in ("_data", "traithandler"): _SA(self, key, value) return if _GA(self, "allow_extra_properties"): @@ -1053,6 +1052,11 @@ class Trait: """Display name for the trait.""" return self._data["name"] + def get_trait(self, trait_key): + """Get another Trait from the handler. Not used by default, but can be used + for custom traits that are affected by other traits on the same handler.""" + return self.traithandler.get(trait_key) + key = name # Numeric operations diff --git a/evennia/game_template/typeclasses/accounts.py b/evennia/game_template/typeclasses/accounts.py index 89d5e41295..12650e2cb8 100644 --- a/evennia/game_template/typeclasses/accounts.py +++ b/evennia/game_template/typeclasses/accounts.py @@ -27,68 +27,112 @@ from evennia.accounts.accounts import DefaultAccount, DefaultGuest class Account(DefaultAccount): """ - This class describes the actual OOC account (i.e. the user connecting - to the MUD). It does NOT have visual appearance in the game world (that - is handled by the character which is connected to this). Comm channels - are attended/joined using this object. + An Account is the actual OOC player entity. It doesn't exist in the game, + but puppets characters. - It can be useful e.g. for storing configuration options for your game, but - should generally not hold any character-related info (that's best handled - on the character level). + This is the base Typeclass for all Accounts. Accounts represent + the person playing the game and tracks account info, password + etc. They are OOC entities without presence in-game. An Account + can connect to a Character Object in order to "enter" the + game. - Can be set using BASE_ACCOUNT_TYPECLASS. + Account Typeclass API: + * Available properties (only available on initiated typeclass objects) - * available properties - - key (string) - name of account - name (string)- wrapper for user.username - aliases (list of strings) - aliases to the object. Will be saved to database as AliasDB entries but returned as strings. - dbref (int, read-only) - unique #id-number. Also "id" can be used. - date_created (string) - time stamp of object creation - permissions (list of strings) - list of permission strings - - user (User, read-only) - django User authorization object - obj (Object) - game object controlled by account. 'character' can also be used. - sessions (list of Sessions) - sessions connected to this account - is_superuser (bool, read-only) - if the connected user is a superuser + - key (string) - name of account + - name (string)- wrapper for user.username + - aliases (list of strings) - aliases to the object. Will be saved to + database as AliasDB entries but returned as strings. + - dbref (int, read-only) - unique #id-number. Also "id" can be used. + - date_created (string) - time stamp of object creation + - permissions (list of strings) - list of permission strings + - user (User, read-only) - django User authorization object + - obj (Object) - game object controlled by account. 'character' can also + be used. + - is_superuser (bool, read-only) - if the connected user is a superuser * Handlers - locks - lock-handler: use locks.add() to add new lock strings - db - attribute-handler: store/retrieve database attributes on this self.db.myattr=val, val=self.db.myattr - ndb - non-persistent attribute handler: same as db but does not create a database entry when storing data - scripts - script-handler. Add new scripts to object with scripts.add() - cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object - nicks - nick-handler. New nicks with nicks.add(). + - locks - lock-handler: use locks.add() to add new lock strings + - db - attribute-handler: store/retrieve database attributes on this + self.db.myattr=val, val=self.db.myattr + - ndb - non-persistent attribute handler: same as db but does not + create a database entry when storing data + - scripts - script-handler. Add new scripts to object with scripts.add() + - cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object + - nicks - nick-handler. New nicks with nicks.add(). + - sessions - session-handler. Use session.get() to see all sessions connected, if any + - options - option-handler. Defaults are taken from settings.OPTIONS_ACCOUNT_DEFAULT + - characters - handler for listing the account's playable characters - * Helper methods + * Helper methods (check autodocs for full updated listing) - msg(text=None, **kwargs) - execute_cmd(raw_string, session=None) - search(ostring, global_search=False, attribute_name=None, use_nicks=False, location=None, ignore_errors=False, account=False) - is_typeclass(typeclass, exact=False) - swap_typeclass(new_typeclass, clean_attributes=False, no_default=True) - access(accessing_obj, access_type='read', default=False) - check_permstring(permstring) + - msg(text=None, from_obj=None, session=None, options=None, **kwargs) + - execute_cmd(raw_string) + - search(searchdata, return_puppet=False, search_object=False, typeclass=None, + nofound_string=None, multimatch_string=None, use_nicks=True, + quiet=False, **kwargs) + - is_typeclass(typeclass, exact=False) + - swap_typeclass(new_typeclass, clean_attributes=False, no_default=True) + - access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False, **kwargs) + - check_permstring(permstring) + - get_cmdsets(caller, current, **kwargs) + - get_cmdset_providers() + - uses_screenreader(session=None) + - get_display_name(looker, **kwargs) + - get_extra_display_name_info(looker, **kwargs) + - disconnect_session_from_account() + - puppet_object(session, obj) + - unpuppet_object(session) + - unpuppet_all() + - get_puppet(session) + - get_all_puppets() + - is_banned(**kwargs) + - get_username_validators(validator_config=settings.AUTH_USERNAME_VALIDATORS) + - authenticate(username, password, ip="", **kwargs) + - normalize_username(username) + - validate_username(username) + - validate_password(password, account=None) + - set_password(password, **kwargs) + - get_character_slots() + - get_available_character_slots() + - create_character(*args, **kwargs) + - create(*args, **kwargs) + - delete(*args, **kwargs) + - channel_msg(message, channel, senders=None, **kwargs) + - idle_time() + - connection_time() - * Hook methods (when re-implementation, remember methods need to have self as first arg) + * Hook methods basetype_setup() at_account_creation() - - note that the following hooks are also found on Objects and are + > note that the following hooks are also found on Objects and are usually handled on the character level: - at_init() - at_cmdset_get(**kwargs) - at_first_login() - at_post_login(session=None) - at_disconnect() - at_message_receive() - at_message_send() - at_server_reload() - at_server_shutdown() + - at_init() + - at_first_save() + - at_access() + - at_cmdset_get(**kwargs) + - at_password_change(**kwargs) + - at_first_login() + - at_pre_login() + - at_post_login(session=None) + - at_failed_login(session, **kwargs) + - at_disconnect(reason=None, **kwargs) + - at_post_disconnect(**kwargs) + - at_message_receive() + - at_message_send() + - at_server_reload() + - at_server_shutdown() + - at_look(target=None, session=None, **kwargs) + - at_post_create_character(character, **kwargs) + - at_post_add_character(char) + - at_post_remove_character(char) + - at_pre_channel_msg(message, channel, senders=None, **kwargs) + - at_post_chnnel_msg(message, channel, senders=None, **kwargs) """ diff --git a/evennia/game_template/typeclasses/channels.py b/evennia/game_template/typeclasses/channels.py index f16e8897dc..39cc7885c4 100644 --- a/evennia/game_template/typeclasses/channels.py +++ b/evennia/game_template/typeclasses/channels.py @@ -16,14 +16,53 @@ from evennia.comms.comms import DefaultChannel class Channel(DefaultChannel): - """ - Working methods: - at_channel_creation() - called once, when the channel is created + r""" + This is the base class for all Channel Comms. Inherit from this to + create different types of communication channels. + + Class-level variables: + - `send_to_online_only` (bool, default True) - if set, will only try to + send to subscribers that are actually active. This is a useful optimization. + - `log_file` (str, default `"channel_{channelname}.log"`). This is the + log file to which the channel history will be saved. The `{channelname}` tag + will be replaced by the key of the Channel. If an Attribute 'log_file' + is set, this will be used instead. If this is None and no Attribute is found, + no history will be saved. + - `channel_prefix_string` (str, default `"[{channelname} ]"`) - this is used + as a simple template to get the channel prefix with `.channel_prefix()`. It is used + in front of every channel message; use `{channelmessage}` token to insert the + name of the current channel. Set to `None` if you want no prefix (or want to + handle it in a hook during message generation instead. + - `channel_msg_nick_pattern`(str, default `"{alias}\s*?|{alias}\s+?(?P.+?)") - + this is what used when a channel subscriber gets a channel nick assigned to this + channel. The nickhandler uses the pattern to pick out this channel's name from user + input. The `{alias}` token will get both the channel's key and any set/custom aliases + per subscriber. You need to allow for an `` regex group to catch any message + that should be send to the channel. You usually don't need to change this pattern + unless you are changing channel command-style entirely. + - `channel_msg_nick_replacement` (str, default `"channel {channelname} = $1"` - this + is used by the nickhandler to generate a replacement string once the nickhandler (using + the `channel_msg_nick_pattern`) identifies that the channel should be addressed + to send a message to it. The `` regex pattern match from `channel_msg_nick_pattern` + will end up at the `$1` position in the replacement. Together, this allows you do e.g. + 'public Hello' and have that become a mapping to `channel public = Hello`. By default, + the account-level `channel` command is used. If you were to rename that command you must + tweak the output to something like `yourchannelcommandname {channelname} = $1`. + + * Properties: + mutelist + banlist + wholist + + * Working methods: + get_log_filename() + set_log_filename(filename) has_connection(account) - check if the given account listens to this channel connect(account) - connect account to this channel disconnect(account) - disconnect account from channel access(access_obj, access_type='listen', default=False) - check the access on this channel (default access_type is listen) + create(key, creator=None, *args, **kwargs) delete() - delete this channel message_transform(msg, emit=False, prefix=True, sender_strings=None, external=False) - called by @@ -36,8 +75,19 @@ class Channel(DefaultChannel): distribute_message(msg, online=False) - send a message to all connected accounts on channel, optionally sending only to accounts that are currently online (optimized for very large sends) + mute(subscriber, **kwargs) + unmute(subscriber, **kwargs) + ban(target, **kwargs) + unban(target, **kwargs) + add_user_channel_alias(user, alias, **kwargs) + remove_user_channel_alias(user, alias, **kwargs) + Useful hooks: + at_channel_creation() - called once, when the channel is created + basetype_setup() + at_init() + at_first_save() channel_prefix() - how the channel should be prefixed when returning to user. Returns a string format_senders(senders) - should return how to display multiple @@ -49,13 +99,19 @@ class Channel(DefaultChannel): format_message(msg, emit=False) - format the message body before displaying it to the user. 'emit' generally means that the message should not be displayed with the sender's name. + channel_prefix() pre_join_channel(joiner) - if returning False, abort join post_join_channel(joiner) - called right after successful join pre_leave_channel(leaver) - if returning False, abort leave post_leave_channel(leaver) - called right after successful leave - pre_send_message(msg) - runs just before a message is sent to channel - post_send_message(msg) - called just after message was sent to channel + at_pre_msg(message, **kwargs) + at_post_msg(message, **kwargs) + web_get_admin_url() + web_get_create_url() + web_get_detail_url() + web_get_update_url() + web_get_delete_url() """ diff --git a/evennia/game_template/typeclasses/characters.py b/evennia/game_template/typeclasses/characters.py index b022c1f293..eeb1b2d737 100644 --- a/evennia/game_template/typeclasses/characters.py +++ b/evennia/game_template/typeclasses/characters.py @@ -15,22 +15,11 @@ from .objects import ObjectParent class Character(ObjectParent, DefaultCharacter): """ - The Character defaults to reimplementing some of base Object's hook methods with the - following functionality: + The Character just re-implements some of the Object's methods and hooks + to represent a Character entity in-game. - at_basetype_setup - always assigns the DefaultCmdSet to this object type - (important!)sets locks so character cannot be picked up - and its commands only be called by itself, not anyone else. - (to change things, use at_object_creation() instead). - at_post_move(source_location) - Launches the "look" command after every move. - at_post_unpuppet(account) - when Account disconnects from the Character, we - store the current location in the prelogout_location Attribute and - move it to a None-location so the "unpuppeted" character - object does not need to stay on grid. Echoes "Account has disconnected" - to the room. - at_pre_puppet - Just before Account re-connects, retrieves the character's - prelogout_location Attribute and move it back on the grid. - at_post_puppet - Echoes "AccountName has entered the game" to the room. + See mygame/typeclasses/objects.py for a list of + properties and methods available on all Object child classes like this. """ diff --git a/evennia/game_template/typeclasses/exits.py b/evennia/game_template/typeclasses/exits.py index 3a53753c2e..1b1c00561b 100644 --- a/evennia/game_template/typeclasses/exits.py +++ b/evennia/game_template/typeclasses/exits.py @@ -15,27 +15,12 @@ from .objects import ObjectParent class Exit(ObjectParent, DefaultExit): """ Exits are connectors between rooms. Exits are normal Objects except - they defines the `destination` property. It also does work in the - following methods: + they defines the `destination` property and overrides some hooks + and methods to represent the exits. - basetype_setup() - sets default exit locks (to change, use `at_object_creation` instead). - at_cmdset_get(**kwargs) - this is called when the cmdset is accessed and should - rebuild the Exit cmdset along with a command matching the name - of the Exit object. Conventionally, a kwarg `force_init` - should force a rebuild of the cmdset, this is triggered - by the `@alias` command when aliases are changed. - at_failed_traverse() - gives a default error message ("You cannot - go there") if exit traversal fails and an - attribute `err_traverse` is not defined. + See mygame/typeclasses/objects.py for a list of + properties and methods available on all Objects child classes like this. - Relevant hooks to overload (compared to other types of Objects): - at_traverse(traveller, target_loc) - called to do the actual traversal and calling of the other hooks. - If overloading this, consider using super() to use the default - movement implementation (and hook-calling). - at_post_traverse(traveller, source_loc) - called by at_traverse just after traversing. - at_failed_traverse(traveller) - called by at_traverse if traversal failed for some reason. Will - not be called if the attribute `err_traverse` is - defined, in which case that will simply be echoed. """ pass diff --git a/evennia/game_template/typeclasses/objects.py b/evennia/game_template/typeclasses/objects.py index 11b7363505..9734c2fbde 100644 --- a/evennia/game_template/typeclasses/objects.py +++ b/evennia/game_template/typeclasses/objects.py @@ -1,13 +1,10 @@ """ Object -The Object is the "naked" base class for things in the game world. +The Object is the class for general items in the game world. -Note that the default Character, Room and Exit does not inherit from -this Object, but from their respective default implementations in the -evennia library. If you want to use this class as a parent to change -the other types, you can do so by adding this as a multiple -inheritance. +Use the ObjectParent class to implement common features for *all* entities +with a location in the game world (like Characters, Rooms, Exits). """ @@ -28,20 +25,18 @@ class ObjectParent: class Object(ObjectParent, DefaultObject): """ - This is the root typeclass object, implementing an in-game Evennia - game object, such as having a location, being able to be - manipulated or looked at, etc. If you create a new typeclass, it - must always inherit from this object (or any of the other objects - in this file, since they all actually inherit from BaseObject, as - seen in src.object.objects). + This is the root Object typeclass, representing all entities that + have an actual presence in-game. DefaultObjects generally have a + location. They can also be manipulated and looked at. Game + entities you define should inherit from DefaultObject at some distance. - The BaseObject class implements several hooks tying into the game - engine. By re-implementing these hooks you can control the - system. You should never need to re-implement special Python - methods, such as __init__ and especially never __getattribute__ and - __setattr__ since these are used heavily by the typeclass system - of Evennia and messing with them might well break things for you. + It is recommended to create children of this class using the + `evennia.create_object()` function rather than to initialize the class + directly - this will both set things up and efficiently save the object + without `obj.save()` having to be called explicitly. + Note: Check the autodocs for complete class members, this may not always + be up-to date. * Base properties defined/available on all Objects @@ -58,12 +53,16 @@ class Object(ObjectParent, DefaultObject): location (Object) - current location. Is None if this is a room home (Object) - safety start-location has_account (bool, read-only)- will only return *connected* accounts - contents (list of Objects, read-only) - returns all objects inside this - object (including exits) + contents (list, read only) - returns all objects inside this object exits (list of Objects, read-only) - returns all exits from this object, if any destination (Object) - only set if this object is an exit. is_superuser (bool, read-only) - True/False if this user is a superuser + is_connected (bool, read-only) - True if this object is associated with + an Account with any connected sessions. + 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. * Handlers available @@ -84,18 +83,49 @@ class Object(ObjectParent, DefaultObject): * Helper methods (see src.objects.objects.py for full headers) - search(ostring, global_search=False, attribute_name=None, - use_nicks=False, location=None, ignore_errors=False, account=False) - execute_cmd(raw_string) - msg(text=None, **kwargs) - msg_contents(message, exclude=None, from_obj=None, **kwargs) + get_search_query_replacement(searchdata, **kwargs) + get_search_direct_match(searchdata, **kwargs) + get_search_candidates(searchdata, **kwargs) + get_search_result(searchdata, attribute_name=None, typeclass=None, + candidates=None, exact=False, use_dbref=None, tags=None, **kwargs) + get_stacked_result(results, **kwargs) + handle_search_results(searchdata, results, **kwargs) + search(searchdata, global_search=False, use_nicks=True, typeclass=None, + location=None, attribute_name=None, quiet=False, exact=False, + candidates=None, use_locks=True, nofound_string=None, + multimatch_string=None, use_dbref=None, tags=None, stacked=0) + search_account(searchdata, quiet=False) + execute_cmd(raw_string, session=None, **kwargs)) + msg(text=None, from_obj=None, session=None, options=None, **kwargs) + for_contents(func, exclude=None, **kwargs) + msg_contents(message, exclude=None, from_obj=None, mapping=None, + raise_funcparse_errors=False, **kwargs) move_to(destination, quiet=False, emit_to_obj=None, use_destination=True) + clear_contents() + create(key, account, caller, method, **kwargs) copy(new_key=None) + at_object_post_copy(new_obj, **kwargs) delete() is_typeclass(typeclass, exact=False) swap_typeclass(new_typeclass, clean_attributes=False, no_default=True) - access(accessing_obj, access_type='read', default=False) + access(accessing_obj, access_type='read', default=False, + no_superuser_bypass=False, **kwargs) + filter_visible(obj_list, looker, **kwargs) + get_default_lockstring() + get_cmdsets(caller, current, **kwargs) check_permstring(permstring) + get_cmdset_providers() + get_display_name(looker=None, **kwargs) + get_extra_display_name_info(looker=None, **kwargs) + get_numbered_name(count, looker, **kwargs) + get_display_header(looker, **kwargs) + get_display_desc(looker, **kwargs) + get_display_exits(looker, **kwargs) + get_display_characters(looker, **kwargs) + get_display_things(looker, **kwargs) + get_display_footer(looker, **kwargs) + format_appearance(appearance, looker, **kwargs) + return_apperance(looker, **kwargs) * Hooks (these are class methods, so args should start with self): @@ -113,6 +143,7 @@ class Object(ObjectParent, DefaultObject): at_init() - called whenever typeclass is cached from memory, at least once every server restart/reload + at_first_save() at_cmdset_get(**kwargs) - this is called just before the command handler requests a cmdset from this object. The kwargs are not normally used unless the cmdset is created @@ -140,12 +171,16 @@ class Object(ObjectParent, DefaultObject): after move, if obj.move_to() has quiet=False at_post_move(source_location) - always called after a move has been successfully performed. + at_pre_object_leave(leaving_object, destination, **kwargs) + at_object_leave(obj, target_location, move_type="move", **kwargs) at_object_leave(obj, target_location) - called when an object leaves this object in any fashion - at_object_receive(obj, source_location) - called when this object receives + at_pre_object_receive(obj, source_location) + at_object_receive(obj, source_location, move_type="move", **kwargs) - called when this object receives another object + at_post_move(source_location, move_type="move", **kwargs) - at_traverse(traversing_object, source_loc) - (exit-objects only) + at_traverse(traversing_object, target_location, **kwargs) - (exit-objects only) handles all moving across the exit, including calling the other exit hooks. Use super() to retain the default functionality. @@ -164,11 +199,18 @@ class Object(ObjectParent, DefaultObject): command by default at_desc(looker=None) - called by 'look' whenever the appearance is requested. + at_pre_get(getter, **kwargs) at_get(getter) - called after object has been picked up. Does not stop pickup. - at_drop(dropper) - called when this object has been dropped. - at_say(speaker, message) - by default, called if an object inside this - object speaks + at_pre_give(giver, getter, **kwargs) + at_give(giver, getter, **kwargs) + at_pre_drop(dropper, **kwargs) + at_drop(dropper, **kwargs) - called when this object has been dropped. + at_pre_say(speaker, message, **kwargs) + at_say(message, msg_self=None, msg_location=None, receivers=None, msg_receivers=None, **kwargs) + + at_look(target, **kwargs) + at_desc(looker=None) """ diff --git a/evennia/game_template/typeclasses/scripts.py b/evennia/game_template/typeclasses/scripts.py index 63f3bb8e83..06e2385143 100644 --- a/evennia/game_template/typeclasses/scripts.py +++ b/evennia/game_template/typeclasses/scripts.py @@ -17,10 +17,17 @@ from evennia.scripts.scripts import DefaultScript class Script(DefaultScript): """ + This is the base TypeClass for all Scripts. Scripts describe + all entities/systems without a physical existence in the game world + that require database storage (like an economic system or + combat tracker). They + can also have a timer/ticker component. + A script type is customized by redefining some or all of its hook methods and variables. - * available properties + * available properties (check docs for full listing, this could be + outdated). key (string) - name of object name (string)- same as key @@ -52,6 +59,7 @@ class Script(DefaultScript): * Helper methods + create(key, **kwargs) start() - start script (this usually happens automatically at creation and obj.script.add() etc) stop() - stop script, and delete it @@ -81,11 +89,14 @@ class Script(DefaultScript): will delay the first call of this method by self.interval seconds. If self.interval==0, this method will never be called. + at_pause() at_stop() - Called as the script object is stopped and is about to be removed from the game, e.g. because is_valid() returned False. + at_script_delete() at_server_reload() - Called when server reloads. Can be used to save temporary variables you want should survive a reload. at_server_shutdown() - called at a full server shutdown. + at_server_start() """ diff --git a/evennia/help/models.py b/evennia/help/models.py index 8d1a1e8509..37565256f7 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -15,6 +15,7 @@ from django.db import models from django.urls import reverse from django.utils import timezone from django.utils.text import slugify + from evennia.help.manager import HelpEntryManager from evennia.locks.lockhandler import LockHandler from evennia.typeclasses.models import AliasHandler, Tag, TagHandler diff --git a/evennia/objects/models.py b/evennia/objects/models.py index 5d4bfd4acf..544f33fd2d 100644 --- a/evennia/objects/models.py +++ b/evennia/objects/models.py @@ -20,7 +20,6 @@ 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 @@ -71,8 +70,18 @@ class ContentsHandler: objects = self.load() self._pkcache = {obj.pk: True for obj in objects} for obj in objects: - for ctype in obj._content_types: - self._typecache[ctype][obj.pk] = True + try: + ctypes = obj._content_types + except AttributeError: + logger.log_err( + f"Object {obj} has no `_content_types` property. Skipping content-cache setup. " + "This error suggests it is not a valid Evennia Typeclass but maybe a root model " + "like `ObjectDB`. Investigate the `db_typeclass_path` of the object and make sure " + "it points to a proper, existing Typeclass." + ) + else: + for ctype in obj._content_types: + self._typecache[ctype][obj.pk] = True def get(self, exclude=None, content_type=None): """ diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 7e4b72bb4c..82e087bb4a 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -10,11 +10,10 @@ 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 @@ -204,6 +203,184 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): directly - this will both set things up and efficiently save the object without `obj.save()` having to be called explicitly. + Note: Check the autodocs for complete class members, this may not always + be up-to date. + + * Base properties defined/available on all Objects + + key (string) - name of object + name (string)- same as key + dbref (int, read-only) - unique #id-number. Also "id" can be used. + date_created (string) - time stamp of object creation + + account (Account) - controlling account (if any, only set together with + sessid below) + sessid (int, read-only) - session id (if any, only set together with + account above). Use `sessions` handler to get the + Sessions directly. + location (Object) - current location. Is None if this is a room + home (Object) - safety start-location + has_account (bool, read-only)- will only return *connected* accounts + contents (list, read only) - returns all objects inside this object + exits (list of Objects, read-only) - returns all exits from this + object, if any + destination (Object) - only set if this object is an exit. + is_superuser (bool, read-only) - True/False if this user is a superuser + is_connected (bool, read-only) - True if this object is associated with + an Account with any connected sessions. + 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. + + * Handlers available + + aliases - alias-handler: use aliases.add/remove/get() to use. + permissions - permission-handler: use permissions.add/remove() to + add/remove new perms. + locks - lock-handler: use locks.add() to add new lock strings + scripts - script-handler. Add new scripts to object with scripts.add() + cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object + nicks - nick-handler. New nicks with nicks.add(). + sessions - sessions-handler. Get Sessions connected to this + object with sessions.get() + attributes - attribute-handler. Use attributes.add/remove/get. + db - attribute-handler: Shortcut for attribute-handler. Store/retrieve + database attributes using self.db.myattr=val, val=self.db.myattr + ndb - non-persistent attribute handler: same as db but does not create + a database entry when storing data + + * Helper methods (see src.objects.objects.py for full headers) + + get_search_query_replacement(searchdata, **kwargs) + get_search_direct_match(searchdata, **kwargs) + get_search_candidates(searchdata, **kwargs) + get_search_result(searchdata, attribute_name=None, typeclass=None, + candidates=None, exact=False, use_dbref=None, tags=None, **kwargs) + get_stacked_result(results, **kwargs) + handle_search_results(searchdata, results, **kwargs) + search(searchdata, global_search=False, use_nicks=True, typeclass=None, + location=None, attribute_name=None, quiet=False, exact=False, + candidates=None, use_locks=True, nofound_string=None, + multimatch_string=None, use_dbref=None, tags=None, stacked=0) + search_account(searchdata, quiet=False) + execute_cmd(raw_string, session=None, **kwargs)) + msg(text=None, from_obj=None, session=None, options=None, **kwargs) + for_contents(func, exclude=None, **kwargs) + msg_contents(message, exclude=None, from_obj=None, mapping=None, + raise_funcparse_errors=False, **kwargs) + move_to(destination, quiet=False, emit_to_obj=None, use_destination=True) + clear_contents() + create(key, account, caller, method, **kwargs) + copy(new_key=None) + at_object_post_copy(new_obj, **kwargs) + delete() + is_typeclass(typeclass, exact=False) + swap_typeclass(new_typeclass, clean_attributes=False, no_default=True) + access(accessing_obj, access_type='read', default=False, + no_superuser_bypass=False, **kwargs) + filter_visible(obj_list, looker, **kwargs) + get_default_lockstring() + get_cmdsets(caller, current, **kwargs) + check_permstring(permstring) + get_cmdset_providers() + get_display_name(looker=None, **kwargs) + get_extra_display_name_info(looker=None, **kwargs) + get_numbered_name(count, looker, **kwargs) + get_display_header(looker, **kwargs) + get_display_desc(looker, **kwargs) + get_display_exits(looker, **kwargs) + get_display_characters(looker, **kwargs) + get_display_things(looker, **kwargs) + get_display_footer(looker, **kwargs) + format_appearance(appearance, looker, **kwargs) + return_apperance(looker, **kwargs) + + * Hooks (these are class methods, so args should start with self): + + basetype_setup() - only called once, used for behind-the-scenes + setup. Normally not modified. + basetype_posthook_setup() - customization in basetype, after the object + has been created; Normally not modified. + + at_object_creation() - only called once, when object is first created. + Object customizations 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_init() - called whenever typeclass is cached from memory, + at least once every server restart/reload + at_first_save() + at_cmdset_get(**kwargs) - this is called just before the command handler + requests a cmdset from this object. The kwargs are + not normally used unless the cmdset is created + dynamically (see e.g. Exits). + at_pre_puppet(account)- (account-controlled objects only) called just + before puppeting + at_post_puppet() - (account-controlled objects only) called just + after completing connection account<->object + at_pre_unpuppet() - (account-controlled objects only) called just + before un-puppeting + at_post_unpuppet(account) - (account-controlled objects only) called just + after disconnecting account<->object link + at_server_reload() - called before server is reloaded + at_server_shutdown() - called just before server is fully shut down + + at_access(result, accessing_obj, access_type) - called with the result + of a lock access check on this object. Return value + does not affect check result. + + at_pre_move(destination) - called just before moving object + to the destination. If returns False, move is cancelled. + announce_move_from(destination) - called in old location, just + before move, if obj.move_to() has quiet=False + announce_move_to(source_location) - called in new location, just + after move, if obj.move_to() has quiet=False + at_post_move(source_location) - always called after a move has + been successfully performed. + at_pre_object_leave(leaving_object, destination, **kwargs) + at_object_leave(obj, target_location, move_type="move", **kwargs) + at_object_leave(obj, target_location) - called when an object leaves + this object in any fashion + at_pre_object_receive(obj, source_location) + at_object_receive(obj, source_location, move_type="move", **kwargs) - called when this object + receives another object + at_post_move(source_location, move_type="move", **kwargs) + + at_traverse(traversing_object, target_location, **kwargs) - (exit-objects only) + handles all moving across the exit, including + calling the other exit hooks. Use super() to retain + the default functionality. + at_post_traverse(traversing_object, source_location) - (exit-objects only) + called just after a traversal has happened. + at_failed_traverse(traversing_object) - (exit-objects only) called if + traversal fails and property err_traverse is not defined. + + at_msg_receive(self, msg, from_obj=None, **kwargs) - called when a message + (via self.msg()) is sent to this obj. + If returns false, aborts send. + at_msg_send(self, msg, to_obj=None, **kwargs) - called when this objects + sends a message to someone via self.msg(). + + return_appearance(looker) - describes this object. Used by "look" + command by default + at_desc(looker=None) - called by 'look' whenever the + appearance is requested. + at_pre_get(getter, **kwargs) + at_get(getter) - called after object has been picked up. + Does not stop pickup. + at_pre_give(giver, getter, **kwargs) + at_give(giver, getter, **kwargs) + at_pre_drop(dropper, **kwargs) + at_drop(dropper, **kwargs) - called when this object has been dropped. + at_pre_say(speaker, message, **kwargs) + at_say(message, msg_self=None, msg_location=None, receivers=None, msg_receivers=None, **kwargs) + + at_look(target, **kwargs) + at_desc(looker=None) + + """ # Determines which order command sets begin to be assembled from. @@ -316,7 +493,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): "obj.location to move an object here.".format(self.__class__) ) - contents = property(contents_get, contents_set, contents_set, contents_set) + contents = property(contents_get, contents_set, contents_set) @property def exits(self): @@ -378,7 +555,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): provided to the search function is included, and could be modified in-place here. Args: - searchdata (str): The search criterion (could be modified by `get_search_query_replacement`). + searchdata (str): The search criterion (could be modified by + `get_search_query_replacement`). **kwargs (any): These are the same as passed to the `search` method. Returns: @@ -1011,8 +1189,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): the text message generated by announce_move_to and announce_move_from by defining their {"type": move_type} for outgoing text. This can be used for altering messages and/or overloaded hook behaviors. - **kwargs: Passed on to announce_move_to and announce_move_from hooks. Exits will set - the "exit_obj" kwarg to themselves. + **kwargs: Passed on to all movement- and announcement hooks. Use in your game to let + hooks know about any special condition of the move (such as running or sneaking). + Exits will pass an "exit_obj" kwarg. Returns: bool: True/False depending on if there were problems with the move. This method may also diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index e6441ba137..569179817f 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -12,7 +12,6 @@ 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 @@ -104,6 +103,7 @@ def homogenize_prototype(prototype, custom_keys=None): prototype[protkey] = "" homogenized = {} + homogenized_aliases = [] homogenized_tags = [] homogenized_attrs = [] homogenized_parents = [] @@ -111,7 +111,10 @@ def homogenize_prototype(prototype, custom_keys=None): for key, val in prototype.items(): if key in reserved: # check all reserved keys - if key == "tags": + if key == "aliases": + # make sure aliases are always in a list even if given as a single string + homogenized_aliases = make_iter(val) + elif key == "tags": # tags must be on form [(tag, category, data), ...] tags = make_iter(prototype.get("tags", [])) for tag in tags: @@ -160,13 +163,14 @@ def homogenize_prototype(prototype, custom_keys=None): else: # normal prototype-parent names are added as-is homogenized_parents.append(parent) - else: # another reserved key homogenized[key] = val else: # unreserved keys -> attrs homogenized_attrs.append((key, val, None, "")) + if homogenized_aliases: + homogenized["aliases"] = homogenized_aliases if homogenized_attrs: homogenized["attrs"] = homogenized_attrs if homogenized_tags: diff --git a/evennia/scripts/scripthandler.py b/evennia/scripts/scripthandler.py index 720dd6e3a6..35971669bf 100644 --- a/evennia/scripts/scripthandler.py +++ b/evennia/scripts/scripthandler.py @@ -7,7 +7,6 @@ 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 @@ -73,18 +72,27 @@ class ScriptHandler(object): Script: The newly created Script. """ - if self.obj.__dbclass__.__name__ == "AccountDB": - # we add to an Account, not an Object - script = create.create_script( - scriptclass, key=key, account=self.obj, autostart=autostart - ) - elif isinstance(scriptclass, str) or callable(scriptclass): + if isinstance(scriptclass, str) or callable(scriptclass): # a str or class to use create before adding to an Object. We wait to autostart # so we can differentiate a failing creation from a script that immediately starts/stops. - script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False) + if self.obj.__dbclass__.__name__ == "AccountDB": + # we add to an Account, not an Object + script = create.create_script( + scriptclass, key=key, account=self.obj, autostart=False + ) + else: + script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False) else: # already an instantiated class script = scriptclass + if script.db_obj and script.db_obj != self.obj: + logger.log_err( + f"Script instance {script} already belongs to " + f"another object: {script.db_obj}." + ) + return None + script.db_obj = self.obj + script.save() if not script: logger.log_err(f"Script {scriptclass} failed to be created.") diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index c4417af3ad..1f2da85513 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -6,13 +6,12 @@ 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"] @@ -423,7 +422,12 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): updates = [] if not cdict.get("key"): if not self.db_key: - self.db_key = "#%i" % self.dbid + if hasattr(self, "key"): + # take key from the object typeclass + self.db_key = self.key + else: + # no key set anywhere, use class+dbid as key + self.db_key = f"{self.__class__.__name__}(#{self.dbid})" updates.append("db_key") elif self.db_key != cdict["key"]: self.db_key = cdict["key"] @@ -672,8 +676,85 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): class DefaultScript(ScriptBase): """ This is the base TypeClass for all Scripts. Scripts describe - events, timers and states in game, they can have a time component - or describe a state that changes under certain conditions. + all entities/systems without a physical existence in the game world + that require database storage (like an economic system or + combat tracker). They + can also have a timer/ticker component. + + A script type is customized by redefining some or all of its hook + methods and variables. + + * available properties (check docs for full listing, this could be + outdated). + + key (string) - name of object + name (string)- same as key + aliases (list of strings) - aliases to the object. Will be saved + to database as AliasDB entries but returned as strings. + dbref (int, read-only) - unique #id-number. Also "id" can be used. + date_created (string) - time stamp of object creation + permissions (list of strings) - list of permission strings + + desc (string) - optional description of script, shown in listings + obj (Object) - optional object that this script is connected to + and acts on (set automatically by obj.scripts.add()) + interval (int) - how often script should run, in seconds. <0 turns + off ticker + start_delay (bool) - if the script should start repeating right away or + wait self.interval seconds + repeats (int) - how many times the script should repeat before + stopping. 0 means infinite repeats + persistent (bool) - if script should survive a server shutdown or not + is_active (bool) - if script is currently running + + * Handlers + + locks - lock-handler: use locks.add() to add new lock strings + db - attribute-handler: store/retrieve database attributes on this + self.db.myattr=val, val=self.db.myattr + ndb - non-persistent attribute handler: same as db but does not + create a database entry when storing data + + * Helper methods + + create(key, **kwargs) + start() - start script (this usually happens automatically at creation + and obj.script.add() etc) + stop() - stop script, and delete it + pause() - put the script on hold, until unpause() is called. If script + is persistent, the pause state will survive a shutdown. + unpause() - restart a previously paused script. The script will continue + from the paused timer (but at_start() will be called). + time_until_next_repeat() - if a timed script (interval>0), returns time + until next tick + + * Hook methods (should also include self as the first argument): + + at_script_creation() - called only once, when an object of this + class is first created. + is_valid() - is called to check if the script is valid to be running + at the current time. If is_valid() returns False, the running + script is stopped and removed from the game. You can use this + to check state changes (i.e. an script tracking some combat + stats at regular intervals is only valid to run while there is + actual combat going on). + at_start() - Called every time the script is started, which for persistent + scripts is at least once every server start. Note that this is + unaffected by self.delay_start, which only delays the first + call to at_repeat(). + at_repeat() - Called every self.interval seconds. It will be called + immediately upon launch unless self.delay_start is True, which + will delay the first call of this method by self.interval + seconds. If self.interval==0, this method will never + be called. + at_pause() + at_stop() - Called as the script object is stopped and is about to be + removed from the game, e.g. because is_valid() returned False. + at_script_delete() + at_server_reload() - Called when server reloads. Can be used to + save temporary variables you want should survive a reload. + at_server_shutdown() - called at a full server shutdown. + at_server_start() """ diff --git a/evennia/scripts/tests.py b/evennia/scripts/tests.py index 8bf6983e21..e1277b8584 100644 --- a/evennia/scripts/tests.py +++ b/evennia/scripts/tests.py @@ -6,8 +6,6 @@ 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 @@ -19,6 +17,7 @@ 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): @@ -105,6 +104,15 @@ class TestScriptHandler(BaseEvenniaTest): script = self.obj.scripts.get("interval_test") self.assertTrue(bool(script)) + def test_add_already_existing_script(self): + "Checks that Scripthandler add function adds script correctly" + + # make a new script with no obj connection + script = create_script(TestingListIntervalScript, key="interval_test2") + self.obj.scripts.add(script) + self.assertEqual([script], list(self.obj.scripts.get("interval_test2"))) + self.assertTrue(bool(self.obj.scripts.get("interval_test"))) + class TestScriptDB(TestCase): "Check the singleton/static ScriptDB object works correctly" diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index cc0d2256a4..46d1de0baa 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -474,7 +474,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS): _RE_N.sub("", prompt) + ("||n" if prompt.endswith("|") else "|n"), strip_ansi=nocolor, xterm256=xterm256, - truecolor=truecolor + truecolor=truecolor, ) if mxp: prompt = mxp_parse(prompt) @@ -511,7 +511,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS): strip_ansi=nocolor, xterm256=xterm256, mxp=mxp, - truecolor=truecolor + truecolor=truecolor, ) if mxp: linetosend = mxp_parse(linetosend) diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py index 58705eb12d..d1b4c738b2 100644 --- a/evennia/server/portal/ttype.py +++ b/evennia/server/portal/ttype.py @@ -130,10 +130,10 @@ class Ttype: self.protocol.protocol_flags["NOPROMPTGOAHEAD"] = False if ( - clientname.startswith("XTERM") - or clientname.endswith("-256COLOR") - or clientname - in ( + clientname.startswith("XTERM") + or clientname.endswith("-256COLOR") + or clientname + in ( "ATLANTIS", # > 0.9.9.0 (aug 2009) "CMUD", # > 3.04 (mar 2009) "KILDCLIENT", # > 2.2.0 (sep 2005) @@ -143,17 +143,13 @@ class Ttype: "BEIP", # > 2.00.206 (late 2009) (BeipMu) "POTATO", # > 2.00 (maybe earlier) "TINYFUGUE", # > 4.x (maybe earlier) - ) + ) ): xterm256 = True # use name to identify support for xterm truecolor truecolor = False - if (clientname.endswith("-TRUECOLOR") or - clientname in ( - "AXMUD", - "TINTIN" - )): + if clientname.endswith("-TRUECOLOR") or clientname in ("AXMUD", "TINTIN"): truecolor = True # all clients supporting TTYPE at all seem to support ANSI @@ -169,9 +165,9 @@ class Ttype: tupper = term.upper() # identify xterm256 based on flag xterm256 = ( - tupper.endswith("-256COLOR") - or tupper.endswith("XTERM") # Apple Terminal, old Tintin - and not tupper.endswith("-COLOR") # old Tintin, Putty + tupper.endswith("-256COLOR") + or tupper.endswith("XTERM") # Apple Terminal, old Tintin + and not tupper.endswith("-COLOR") # old Tintin, Putty ) if xterm256: self.protocol.protocol_flags["ANSI"] = True diff --git a/evennia/server/service.py b/evennia/server/service.py index c1c57e5d56..80eba12bb0 100644 --- a/evennia/server/service.py +++ b/evennia/server/service.py @@ -8,20 +8,19 @@ 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__ @@ -282,11 +281,10 @@ class EvenniaServerService(MultiService): and settings.DATABASES.get("default", {}).get("ENGINE", None) == "django.db.backends.sqlite3" ): + # sqlite3 database pragmas (directives) cursor = connection.cursor() - cursor.execute("PRAGMA cache_size=10000") - cursor.execute("PRAGMA synchronous=OFF") - cursor.execute("PRAGMA count_changes=OFF") - cursor.execute("PRAGMA temp_store=2") + for pragma in settings.SQLITE3_PRAGMAS: + cursor.execute(pragma) def update_defaults(self): """ diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 50a27ce1b5..ccd3bcccc2 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -300,6 +300,16 @@ DATABASES = { "PORT": "", } } +# 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. +SQLITE3_PRAGMAS = ( + "PRAGMA cache_size=10000", + "PRAGMA synchronous=1", + "PRAGMA count_changes=OFF", + "PRAGMA temp_store=2", + "PRAGMA journal_mode=WAL", +) + # How long the django-database connection should be kept open, in seconds. # If you get errors about the database having gone away after long idle # periods, shorten this value (e.g. MySQL defaults to a timeout of 8 hrs) diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 03929dac43..174b9ab056 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -17,7 +17,6 @@ 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 @@ -142,6 +141,8 @@ class InMemoryAttribute(IAttribute): # Value and locks are special. We must call the wrappers. if key == "value": self.value = value + elif key == "strvalue": + self.db_strvalue = value elif key == "lock_storage": self.lock_storage = value else: diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index ac33727c8e..476e11b045 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -26,7 +26,6 @@ these to create custom managers. """ -import evennia from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist @@ -37,21 +36,30 @@ from django.urls import reverse from django.utils import timezone from django.utils.encoding import smart_str from django.utils.text import slugify + +import evennia from evennia.locks.lockhandler import LockHandler from evennia.server.signals import SIGNAL_TYPED_OBJECT_POST_RENAME from evennia.typeclasses import managers -from evennia.typeclasses.attributes import (Attribute, AttributeHandler, - AttributeProperty, DbHolder, - InMemoryAttributeBackend, - ModelAttributeBackend) -from evennia.typeclasses.tags import (AliasHandler, PermissionHandler, Tag, - TagCategoryProperty, TagHandler, - TagProperty) -from evennia.utils.idmapper.models import (SharedMemoryModel, - SharedMemoryModelBase) +from evennia.typeclasses.attributes import ( + Attribute, + AttributeHandler, + AttributeProperty, + DbHolder, + InMemoryAttributeBackend, + ModelAttributeBackend, +) +from evennia.typeclasses.tags import ( + AliasHandler, + PermissionHandler, + Tag, + TagCategoryProperty, + TagHandler, + TagProperty, +) +from evennia.utils.idmapper.models import SharedMemoryModel, SharedMemoryModelBase from evennia.utils.logger import log_trace -from evennia.utils.utils import (class_from_module, inherits_from, is_iter, - lazy_property) +from evennia.utils.utils import class_from_module, inherits_from, is_iter, lazy_property __all__ = ("TypedObject",) diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 68dbc0c103..06e2438251 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -67,10 +67,9 @@ import re from collections import OrderedDict from django.conf import settings - from evennia.utils import logger, utils -from evennia.utils.utils import to_str from evennia.utils.hex_colors import HexColors +from evennia.utils.utils import to_str hex2truecolor = HexColors() hex_sub = HexColors.hex_sub @@ -84,6 +83,11 @@ ANSI_ESCAPE = "\033" ANSI_NORMAL = "\033[0m" ANSI_UNDERLINE = "\033[4m" +ANSI_UNDERLINE_RESET = "\033[24m" +ANSI_ITALIC = "\033[3m" +ANSI_ITALIC_RESET = "\033[23m" +ANSI_STRIKE = "\033[9m" +ANSI_STRIKE_RESET = "\033[29m" ANSI_HILITE = "\033[1m" ANSI_UNHILITE = "\033[22m" ANSI_BLINK = "\033[5m" @@ -119,7 +123,7 @@ ANSI_TAB = "\t" ANSI_SPACE = " " # Escapes -ANSI_ESCAPES = ("{{", "\\\\", "\|\|") +ANSI_ESCAPES = ("{{", r"\\", r"\|\|") _PARSE_CACHE = OrderedDict() _PARSE_CACHE_SIZE = 10000 @@ -149,6 +153,11 @@ class ANSIParser(object): (r"|*", ANSI_INVERSE), # invert (r"|^", ANSI_BLINK), # blinking text (very annoying and not supported by all clients) (r"|u", ANSI_UNDERLINE), # underline + (r"|U", ANSI_UNDERLINE_RESET), # underline reset + (r"|i", ANSI_ITALIC), # italic + (r"|I", ANSI_ITALIC_RESET), # italic reset + (r"|s", ANSI_STRIKE), # strikethrough + (r"|S", ANSI_STRIKE_RESET), # strikethrough reset (r"|r", ANSI_HILITE + ANSI_RED), (r"|g", ANSI_HILITE + ANSI_GREEN), (r"|y", ANSI_HILITE + ANSI_YELLOW), @@ -812,7 +821,7 @@ class ANSIString(str, metaclass=ANSIMeta): if not decoded: # Completely new ANSI String clean_string = parser.parse_ansi(string, strip_ansi=True, mxp=MXP_ENABLED) - string = parser.parse_ansi(string, xterm256=True, mxp=MXP_ENABLED) + string = parser.parse_ansi(string, xterm256=True, mxp=MXP_ENABLED, truecolor=True) elif clean_string is not None: # We have an explicit clean string. pass diff --git a/evennia/utils/containers.py b/evennia/utils/containers.py index 4eec3f042c..53496e5a0c 100644 --- a/evennia/utils/containers.py +++ b/evennia/utils/containers.py @@ -14,7 +14,7 @@ from pickle import dumps from django.conf import settings from django.db.utils import OperationalError, ProgrammingError - +from evennia.scripts.models import ScriptDB from evennia.utils import logger from evennia.utils.utils import callables_from_module, class_from_module @@ -217,7 +217,7 @@ class GlobalScriptContainer(Container): """ if not self.loaded: self.load_data() - out_value = default + script_found = None if key in self.loaded_data: if key not in self.typeclass_storage: # this means we are trying to load in a loop @@ -230,8 +230,12 @@ class GlobalScriptContainer(Container): script_found = self._load_script(key) if script_found: out_value = script_found + else: + # script not found in settings, see if one exists in database (not + # auto-started/recreated) + script_found = ScriptDB.objects.filter(db_key__iexact=key, db_obj__isnull=True).first() - return out_value + return script_found if script_found is not None else default def all(self): """ @@ -239,12 +243,19 @@ class GlobalScriptContainer(Container): scripts defined in settings. Returns: - scripts (list): All global script objects stored on the container. + list: All global script objects in game (both managed and unmanaged), + sorted alphabetically. """ if not self.loaded: self.load_data() - return list(self.loaded_data.values()) + managed_scripts = list(self.loaded_data.values()) + unmanaged_scripts = list( + ScriptDB.objects.filter(db_obj__isnull=True).exclude( + id__in=[scr.id for scr in managed_scripts] + ) + ) + return list(sorted(managed_scripts + unmanaged_scripts, key=lambda scr: scr.db_key)) # Create all singletons diff --git a/evennia/utils/eveditor.py b/evennia/utils/eveditor.py index 0259faeaa0..2657d61393 100644 --- a/evennia/utils/eveditor.py +++ b/evennia/utils/eveditor.py @@ -722,7 +722,7 @@ class CmdEditorGroup(CmdEditorBase): } align_name = {"f": "Full", "c": "Center", "l": "Left", "r": "Right"} # shift width arg right if no alignment specified - if self.arg1.startswith('='): + if self.arg1.startswith("="): self.arg2 = self.arg1 self.arg1 = None if self.arg1 and self.arg1.lower() not in align_map: diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index fc6e6e5ce4..6bf4361827 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -520,13 +520,15 @@ class EvMenu: startnode (str, optional): The starting node name in the menufile. cmdset_mergetype (str, optional): 'Replace' (default) means the menu commands will be exclusive - no other normal commands will - be usable while the user is in the menu. 'Union' means the - menu commands will be integrated with the existing commands - (it will merge with `merge_priority`), if so, make sure that - the menu's command names don't collide with existing commands - in an unexpected way. Also the CMD_NOMATCH and CMD_NOINPUT will - be overloaded by the menu cmdset. Other cmdser mergetypes - has little purpose for the menu. + be usable while the user is in the menu. 'Union' does merge the menu + command, but note that the only command used in EvMenu has key/alias + of NOINPUT/NOMATCH. So if you merge with 'Union' and a high `cmdset_prio` + (below), you won't replace individual normal commands as you may + expect. Instead commands will work normally and you'll only always fall + back to menu commands when no other command is found. There is no way + to partially replace normal commands with EvMenu actions - to do this, + remove the normal command from the caller's cmdset - if not found + the menu's version will kick in instead. cmdset_priority (int, optional): The merge priority for the menu command set. The default (1) is usually enough for most types of menus. diff --git a/evennia/utils/hex_colors.py b/evennia/utils/hex_colors.py index 781496ac71..3ca921bc3b 100644 --- a/evennia/utils/hex_colors.py +++ b/evennia/utils/hex_colors.py @@ -9,9 +9,9 @@ class HexColors: Based on code from @InspectorCaracal """ - _RE_FG = "\|#" - _RE_BG = "\|\[#" - _RE_FG_OR_BG = "\|\[?#" + _RE_FG = r"\|#" + _RE_BG = r"\|\[#" + _RE_FG_OR_BG = r"\|\[?#" _RE_HEX_LONG = "[0-9a-fA-F]{6}" _RE_HEX_SHORT = "[0-9a-fA-F]{3}" _RE_BYTE = "[0-2]?[0-9]?[0-9]" @@ -23,8 +23,8 @@ class HexColors: # Used for greyscale _GREYS = "abcdefghijklmnopqrstuvwxyz" - TRUECOLOR_FG = f"\x1b\[38;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m" - TRUECOLOR_BG = f"\x1b\[48;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m" + TRUECOLOR_FG = rf"\x1b\[38;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m" + TRUECOLOR_BG = rf"\x1b\[48;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m" # Our matchers for use with ANSIParser and ANSIString hex_sub = re.compile(rf"{_RE_HEX_PATTERN}", re.DOTALL) @@ -121,27 +121,22 @@ class HexColors: r, g, b = self._hex_to_rgb_24_bit(tag) - # Is it greyscale? - if r == g and g == b: - return f"{indicator}=" + self._GREYS[self._grey_int(r)] + if not truecolor: + # Fallback to xterm256 syntax + r, g, b = self._rgb_24_bit_to_256(r, g, b) + return f"{indicator}{r}{g}{b}" else: - if not truecolor: - # Fallback to xterm256 syntax - r, g, b = self._rgb_24_bit_to_256(r, g, b) - return f"{indicator}{r}{g}{b}" + xtag = f"\033[" + if "[" in indicator: + # Background Color + xtag += "4" else: - xtag = f"\033[" - if "[" in indicator: - # Background Color - xtag += "4" + xtag += "3" - else: - xtag += "3" - - xtag += f"8;2;{r};{g};{b}m" - return xtag + xtag += f"8;2;{r};{g};{b}m" + return xtag def xterm_truecolor_to_html_style(self, fg="", bg="") -> str: """ diff --git a/evennia/utils/tests/test_funcparser.py b/evennia/utils/tests/test_funcparser.py index 96892283fe..7756a95999 100644 --- a/evennia/utils/tests/test_funcparser.py +++ b/evennia/utils/tests/test_funcparser.py @@ -10,10 +10,11 @@ from ast import literal_eval from unittest.mock import MagicMock, patch from django.test import TestCase, override_settings -from evennia.utils import funcparser, test_resources from parameterized import parameterized from simpleeval import simple_eval +from evennia.utils import funcparser, test_resources + def _test_callable(*args, **kwargs): kwargs.pop("funcparser", None) diff --git a/evennia/utils/tests/test_text2html.py b/evennia/utils/tests/test_text2html.py index 74bd61bf18..f8aec0c1d4 100644 --- a/evennia/utils/tests/test_text2html.py +++ b/evennia/utils/tests/test_text2html.py @@ -49,9 +49,7 @@ class TestText2Html(TestCase): # True Color self.assertEqual( 'redfoo', - parser.format_styles( - f'\x1b[38;2;255;0;0m' + "red" + ansi.ANSI_NORMAL + "foo" - ), + parser.format_styles(f"\x1b[38;2;255;0;0m" + "red" + ansi.ANSI_NORMAL + "foo"), ) def test_remove_bells(self): diff --git a/evennia/utils/tests/test_truecolor.py b/evennia/utils/tests/test_truecolor.py index 33fddeca16..f1d995790c 100644 --- a/evennia/utils/tests/test_truecolor.py +++ b/evennia/utils/tests/test_truecolor.py @@ -1,6 +1,7 @@ from django.test import TestCase -from evennia.utils.ansi import ANSIString as AN, ANSIParser +from evennia.utils.ansi import ANSIParser +from evennia.utils.ansi import ANSIString as AN parser = ANSIParser().parse_ansi @@ -14,57 +15,77 @@ class TestANSIStringHex(TestCase): def setUp(self): self.str = "test " self.output1 = "\x1b[38;5;16mtest \x1b[0m" + self.output1_truecolor = "\x1b[38;2;0;0;0mtest \x1b[0m" self.output2 = "\x1b[48;5;16mtest \x1b[0m" + self.output2_truecolor = "\x1b[48;2;0;0;0mtest \x1b[0m" self.output3 = "\x1b[38;5;46mtest \x1b[0m" + self.output3_truecolor = "\x1b[38;2;0;255;0mtest \x1b[0m" self.output4 = "\x1b[48;5;46mtest \x1b[0m" + self.output4_truecolor = "\x1b[48;2;0;255;0mtest \x1b[0m" def test_long_grayscale_fg(self): raw = f"|#000000{self.str}|n" ansi = AN(raw) + ansi_256 = parser(raw, xterm256=True) self.assertEqual(ansi.clean(), self.str, "Cleaned") - self.assertEqual(ansi.raw(), self.output1, "Output") + self.assertEqual(ansi.raw(), self.output1_truecolor, "Output truecolor") + self.assertEqual(ansi_256, self.output1, "Output xterm256") def test_long_grayscale_bg(self): raw = f"|[#000000{self.str}|n" ansi = AN(raw) + ansi_256 = parser(raw, xterm256=True) self.assertEqual(ansi.clean(), self.str, "Cleaned") - self.assertEqual(ansi.raw(), self.output2, "Output") + self.assertEqual(ansi.raw(), self.output2_truecolor, "Output truecolor") + self.assertEqual(ansi_256, self.output2, "Output xterm256") def test_short_grayscale_fg(self): raw = f"|#000{self.str}|n" ansi = AN(raw) + ansi_256 = parser(raw, xterm256=True) self.assertEqual(ansi.clean(), self.str, "Cleaned") - self.assertEqual(ansi.raw(), self.output1, "Output") + self.assertEqual(ansi.raw(), self.output1_truecolor, "Output truecolor") + self.assertEqual(ansi_256, self.output1, "Output xterm256") def test_short_grayscale_bg(self): raw = f"|[#000{self.str}|n" ansi = AN(raw) + ansi_256 = parser(raw, xterm256=True) self.assertEqual(ansi.clean(), self.str, "Cleaned") - self.assertEqual(ansi.raw(), self.output2, "Output") + self.assertEqual(ansi.raw(), self.output2_truecolor, "Output truecolor") + self.assertEqual(ansi_256, self.output2, "Output xterm256") def test_short_color_fg(self): raw = f"|#0F0{self.str}|n" ansi = AN(raw) + ansi_256 = parser(raw, xterm256=True) self.assertEqual(ansi.clean(), self.str, "Cleaned") - self.assertEqual(ansi.raw(), self.output3, "Output") + self.assertEqual(ansi.raw(), self.output3_truecolor, "Output truecolor") + self.assertEqual(ansi_256, self.output3, "Output xterm256") def test_short_color_bg(self): raw = f"|[#0f0{self.str}|n" ansi = AN(raw) + ansi_256 = parser(raw, xterm256=True) self.assertEqual(ansi.clean(), self.str, "Cleaned") - self.assertEqual(ansi.raw(), self.output4, "Output") + self.assertEqual(ansi.raw(), self.output4_truecolor, "Output truecolor") + self.assertEqual(ansi_256, self.output4, "Output xterm256") def test_long_color_fg(self): raw = f"|#00ff00{self.str}|n" ansi = AN(raw) + ansi_256 = parser(raw, xterm256=True) self.assertEqual(ansi.clean(), self.str, "Cleaned") - self.assertEqual(ansi.raw(), self.output3, "Output") + self.assertEqual(ansi.raw(), self.output3_truecolor, "Output truecolor") + self.assertEqual(ansi_256, self.output3, "Output xterm256") def test_long_color_bg(self): raw = f"|[#00FF00{self.str}|n" ansi = AN(raw) + ansi_256 = parser(raw, xterm256=True) self.assertEqual(ansi.clean(), self.str, "Cleaned") - self.assertEqual(ansi.raw(), self.output4, "Output") + self.assertEqual(ansi.raw(), self.output4_truecolor, "Output truecolor") + self.assertEqual(ansi_256, self.output4, "Output xterm256") class TestANSIParser(TestCase): diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index 9852802e72..44540338f1 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -812,6 +812,65 @@ class TestJustify(TestCase): self.assertIn(ANSI_RED, str(result)) +class TestAtSearchResult(TestCase): + """ + Test the utils.at_search_result function. + + """ + + class MockObject: + def __init__(self, key): + self.key = key + self.aliases = '' + + def get_display_name(self, looker, **kwargs): + return self.key + + def get_extra_info(self, looker, **kwargs): + return '' + + def __repr__(self): + return f"MockObject({self.key})" + + def test_single_match(self): + """if there is only one match, it should return the matched object""" + obj1 = self.MockObject("obj1") + caller = mock.MagicMock() + self.assertEqual(obj1, utils.at_search_result([obj1], caller, "obj1")) + + def test_no_match(self): + """if there are no provided matches, the caller should receive the correct error message""" + caller = mock.MagicMock() + self.assertIsNone(utils.at_search_result([], caller, "obj1")) + caller.msg.assert_called_once_with("Could not find 'obj1'.") + + 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) ] + caller = mock.MagicMock() + self.assertIsNone(utils.at_search_result(matches, caller, "obj1")) + multimatch_msg = """\ +More than one match for 'obj1' (please narrow target): + obj1-1 + obj1-2 + obj1-3""" + caller.msg.assert_called_once_with(multimatch_msg) + + 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) ] + caller = mock.MagicMock() + self.assertIsNone(utils.at_search_result(matches, caller, "obj")) + multimatch_msg = """\ +More than one match for 'obj' (please narrow target): + obj1-1 + obj1-2 + obj1-3 + obj2-1 + obj2-2""" + caller.msg.assert_called_once_with(multimatch_msg) + + class TestGroupObjectsByKeyAndDesc(TestCase): """ Test the utils.group_objects_by_key_and_desc function. diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index bb9d642915..6b1e0057d7 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -12,7 +12,6 @@ import re from html import escape as html_escape from .ansi import * - from .hex_colors import HexColors # All xterm256 RGB equivalents diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index cc92baee47..5c6264fc5a 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -473,7 +473,7 @@ def iter_to_str(iterable, sep=",", endsep=", and", addquote=False): list_to_string = iter_to_str iter_to_string = iter_to_str -re_empty = re.compile("\n\s*\n") +re_empty = re.compile("\n\\s*\n") def compress_whitespace(text, max_linebreaks=1, max_spacing=2): @@ -494,7 +494,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(f"(?<=\S) {{{max_spacing},}}", " " * max_spacing, text) + text = re.sub(fr"(?<=\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 @@ -2397,28 +2397,35 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): query=query ) - for num, result in enumerate(matches): - # we need to consider that result could be a Command, where .aliases - # is a list of strings - if hasattr(result.aliases, "all"): - # result is a typeclassed entity where `.aliases` is an AliasHandler. - aliases = result.aliases.all(return_objs=True) - # remove pluralization aliases - aliases = [alias.db_key for alias in aliases if alias.db_category != "plural_key"] - else: - # result is likely a Command, where `.aliases` is a list of strings. - aliases = result.aliases - - error += _MULTIMATCH_TEMPLATE.format( - number=num + 1, - name=( - result.get_display_name(caller) - if hasattr(result, "get_display_name") + # group results by display name to properly disambiguate + grouped_matches = defaultdict(list) + for item in matches: + group_key = ( + item.get_display_name(caller) + if hasattr(item, "get_display_name") else query - ), - aliases=" [{alias}]".format(alias=";".join(aliases)) if aliases else "", - info=result.get_extra_info(caller), - ) + ) + grouped_matches[group_key].append(item) + + for key, match_list in grouped_matches.items(): + for num, result in enumerate(match_list): + # we need to consider that result could be a Command, where .aliases + # is a list of strings + if hasattr(result.aliases, "all"): + # result is a typeclassed entity where `.aliases` is an AliasHandler. + aliases = result.aliases.all(return_objs=True) + # remove pluralization aliases + aliases = [alias.db_key for alias in aliases if alias.db_category != "plural_key"] + else: + # result is likely a Command, where `.aliases` is a list of strings. + aliases = result.aliases + + error += _MULTIMATCH_TEMPLATE.format( + number=num + 1, + name=key, + aliases=" [{alias}]".format(alias=";".join(aliases)) if aliases else "", + info=result.get_extra_info(caller), + ) matches = None else: # exactly one match diff --git a/evennia/web/templates/website/channel_detail.html b/evennia/web/templates/website/channel_detail.html index 4af6751b3c..bb9d305a10 100644 --- a/evennia/web/templates/website/channel_detail.html +++ b/evennia/web/templates/website/channel_detail.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} ({{ object }}) diff --git a/evennia/web/templates/website/channel_list.html b/evennia/web/templates/website/channel_list.html index 3fda972479..982b29dccb 100644 --- a/evennia/web/templates/website/channel_list.html +++ b/evennia/web/templates/website/channel_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} diff --git a/evennia/web/templates/website/character_form.html b/evennia/web/templates/website/character_form.html index 20ad71e261..78211597b4 100644 --- a/evennia/web/templates/website/character_form.html +++ b/evennia/web/templates/website/character_form.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} diff --git a/evennia/web/templates/website/character_list.html b/evennia/web/templates/website/character_list.html index 4e7601d49a..61e2f96f6d 100644 --- a/evennia/web/templates/website/character_list.html +++ b/evennia/web/templates/website/character_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} diff --git a/evennia/web/templates/website/character_manage_list.html b/evennia/web/templates/website/character_manage_list.html index c8845f7165..57117caf85 100644 --- a/evennia/web/templates/website/character_manage_list.html +++ b/evennia/web/templates/website/character_manage_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} diff --git a/evennia/web/templates/website/generic_form.html b/evennia/web/templates/website/generic_form.html index bacbfbd183..336589bc56 100644 --- a/evennia/web/templates/website/generic_form.html +++ b/evennia/web/templates/website/generic_form.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} Form diff --git a/evennia/web/templates/website/help_detail.html b/evennia/web/templates/website/help_detail.html index a5ed6045ef..606365d0dc 100644 --- a/evennia/web/templates/website/help_detail.html +++ b/evennia/web/templates/website/help_detail.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} ({{ object|title }}) diff --git a/evennia/web/templates/website/help_list.html b/evennia/web/templates/website/help_list.html index ec9c07b2b4..7f9999f76a 100644 --- a/evennia/web/templates/website/help_list.html +++ b/evennia/web/templates/website/help_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} diff --git a/evennia/web/templates/website/object_confirm_delete.html b/evennia/web/templates/website/object_confirm_delete.html index 1073b26d0c..f98caad088 100644 --- a/evennia/web/templates/website/object_confirm_delete.html +++ b/evennia/web/templates/website/object_confirm_delete.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} diff --git a/evennia/web/templates/website/object_detail.html b/evennia/web/templates/website/object_detail.html index 420ddd1f8a..42fbfc8d5f 100644 --- a/evennia/web/templates/website/object_detail.html +++ b/evennia/web/templates/website/object_detail.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} ({{ object }}) diff --git a/evennia/web/templates/website/object_list.html b/evennia/web/templates/website/object_list.html index 4e7601d49a..61e2f96f6d 100644 --- a/evennia/web/templates/website/object_list.html +++ b/evennia/web/templates/website/object_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} {{ view.page_title }} diff --git a/evennia/web/templates/website/registration/password_change_done.html b/evennia/web/templates/website/registration/password_change_done.html index 8869d8d69c..98e55e5207 100644 --- a/evennia/web/templates/website/registration/password_change_done.html +++ b/evennia/web/templates/website/registration/password_change_done.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} Password Changed diff --git a/evennia/web/templates/website/registration/password_change_form.html b/evennia/web/templates/website/registration/password_change_form.html index 1911b83cf0..806e1e06c8 100644 --- a/evennia/web/templates/website/registration/password_change_form.html +++ b/evennia/web/templates/website/registration/password_change_form.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} Password Change diff --git a/evennia/web/templates/website/registration/password_reset_complete.html b/evennia/web/templates/website/registration/password_reset_complete.html index 697b4bc4ad..73bb5f2284 100644 --- a/evennia/web/templates/website/registration/password_reset_complete.html +++ b/evennia/web/templates/website/registration/password_reset_complete.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} Forgot Password - Reset Successful diff --git a/evennia/web/templates/website/registration/password_reset_confirm.html b/evennia/web/templates/website/registration/password_reset_confirm.html index a7bdc683be..0a43e0a587 100644 --- a/evennia/web/templates/website/registration/password_reset_confirm.html +++ b/evennia/web/templates/website/registration/password_reset_confirm.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} Forgot Password - Reset diff --git a/evennia/web/templates/website/registration/password_reset_done.html b/evennia/web/templates/website/registration/password_reset_done.html index 8de85a5ba3..3741ead5b0 100644 --- a/evennia/web/templates/website/registration/password_reset_done.html +++ b/evennia/web/templates/website/registration/password_reset_done.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} Forgot Password - Reset Link Sent diff --git a/evennia/web/templates/website/registration/password_reset_form.html b/evennia/web/templates/website/registration/password_reset_form.html index eb73118856..86ed7f54d8 100644 --- a/evennia/web/templates/website/registration/password_reset_form.html +++ b/evennia/web/templates/website/registration/password_reset_form.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} Forgot Password diff --git a/evennia/web/templates/website/registration/register.html b/evennia/web/templates/website/registration/register.html index f54d75054d..ffc06710f9 100644 --- a/evennia/web/templates/website/registration/register.html +++ b/evennia/web/templates/website/registration/register.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "website/base.html" %} {% block titleblock %} Register diff --git a/pyproject.toml b/pyproject.toml index 7c507aada5..414f20dce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "evennia" -version = "4.1.1" +version = "4.3.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"