Merge branch 'main' into deps

This commit is contained in:
0xDEADFED5 2024-08-16 00:57:25 -07:00
commit 306cacba69
40 changed files with 1498 additions and 254 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 <text>` - Files a bug report. An optional target can be included - `bug <target> = <text>` - making it easier for devs/builders to track down issues.
* `report <player> = <text>` - Reports a player for inappropriate or rule-breaking behavior. *Requires* a target to be provided - it searches among accounts by default.
* `idea <text>` - 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 <report type>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 <message>
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.
----
<small>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.</small>

View file

@ -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.
----

View file

@ -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

View file

@ -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.

View file

@ -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
```

View file

@ -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:
```

View file

@ -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:
```

View file

@ -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:
```

View file

@ -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

View file

@ -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

View file

@ -1 +1 @@
4.2.0
4.3.0

View file

@ -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 "<Global>"
),
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] <obj> = [<script.path or script-key>]
Switches:
start - start/unpause an existing script's timer.
stop - stops an existing script's timer
pause - pause a script's timer
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|$"

View file

@ -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"],
)

View file

@ -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 <text>` - Files a bug report. An optional target can be included - `bug <target> = <text>` - making it easier for devs/builders to track down issues.
* `report <player> = <text>` - Reports a player for inappropriate or rule-breaking behavior. *Requires* a target to be provided - it searches among accounts by default.
* `idea <text>` - 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 <report type>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 <message>
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.

View file

@ -0,0 +1 @@
from .reports import ReportsCmdSet

View file

@ -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

View file

@ -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 [<target> =] <message>
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 <player> = <message>
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 <message>
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)

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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.

View file

@ -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):
"""

View file

@ -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

View file

@ -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):
"""

View file

@ -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

View file

@ -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:

View file

@ -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.")

View file

@ -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"]

View file

@ -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"

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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:
"""

View file

@ -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):

View file

@ -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.

View file

@ -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