diff --git a/CHANGELOG.md b/CHANGELOG.md index 2687383269..533de8ca14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## 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 diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index 2687383269..533de8ca14 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -1,5 +1,61 @@ # Changelog +## 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 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-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/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 a1ab64fa93..d043858fd5 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,6 +1,6 @@ # Evennia Documentation -This is the manual of [Evennia](https://www.evennia.com), the open source Python `MU*` creation system. Use the Search bar on the left to find or discover interesting articles. This manual was last updated June 27, 2024, see the [Evennia Changelog](Coding/Changelog.md). Latest released Evennia version is 4.2.0. +This is the manual of [Evennia](https://www.evennia.com), the open source Python `MU*` creation system. Use the Search bar on the left to find or discover interesting articles. This manual was last updated 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? - [Evennia in Pictures](./Evennia-In-Pictures.md) - a visual overview of Evennia diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index 6aba2b245a..80895903a1 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -4.2.0 +4.3.0 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/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/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/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/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 7038ae9574..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 @@ -345,8 +344,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 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_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) @@ -494,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): @@ -556,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: @@ -1189,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 64d279e414..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"] 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/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/utils/ansi.py b/evennia/utils/ansi.py index a3145c21d9..06e2438251 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -67,7 +67,6 @@ import re from collections import OrderedDict from django.conf import settings - from evennia.utils import logger, utils from evennia.utils.hex_colors import HexColors from evennia.utils.utils import to_str @@ -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/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_truecolor.py b/evennia/utils/tests/test_truecolor.py index c505cc1f86..f1d995790c 100644 --- a/evennia/utils/tests/test_truecolor.py +++ b/evennia/utils/tests/test_truecolor.py @@ -15,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/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