mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 12:56:30 +01:00
Merge branch 'main' into deps
This commit is contained in:
commit
306cacba69
40 changed files with 1498 additions and 254 deletions
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -1,5 +1,61 @@
|
|||
# Changelog
|
||||
|
||||
## Main branch
|
||||
|
||||
Feat: Support `scripts key:typeclass` form to create global scripts
|
||||
with dynamic keys (rather than just relying on typeclass' key). Support
|
||||
searching using the same syntax (Griatch)
|
||||
[Fix][issue3556]: Better error if trying to treat ObjectDB as a typeclass (Griatch)
|
||||
[Fix][issue3590]: Make `examine` command properly show `strattr` type
|
||||
Attribute values (Griatch)
|
||||
[Fix][issue3519]: `GLOBAL_SCRIPTS` container didn't list global scripts not
|
||||
defined explicitly to be restarted/recrated in settings.py (Griatch)
|
||||
Fix: Passing an already instantiated Script to `obj.scripts.add` (`ScriptHandler.add`)
|
||||
did not add it to the handler's object (Griatch)
|
||||
[Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch)
|
||||
|
||||
[issue3591]: https://github.com/evennia/evennia/issues/3591
|
||||
[issue3590]: https://github.com/evennia/evennia/issues/3590
|
||||
[issue3556]: https://github.com/evennia/evennia/issues/3556
|
||||
[issue3519]: https://github.com/evennia/evennia/issues/3519
|
||||
|
||||
|
||||
## Evennia 4.3.0
|
||||
|
||||
Aug 11, 2024
|
||||
|
||||
- [Feat][pull3531]: New contrib; `in-game reports` for handling user reports,
|
||||
bugs etc in-game (InspectorCaracal)
|
||||
- [Feat][pull3586]: Add ANSI color support `|U`, `|I`, `|i`, `|s`, `|S` for
|
||||
underline reset, italic/reset and strikethrough/reset (0xDEADFED5)
|
||||
- Feat: Add `Trait.traithandler` back-reference so custom Traits from the Traits
|
||||
contrib can find and reference other Traits. (Griatch)
|
||||
- [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (0xDEADFED5)
|
||||
- [Fix][pull3571]: Better visual display of partial multimatch search results
|
||||
(InspectorCaracal)
|
||||
- [Fix][issue3378]: Prototype 'alias' key was not properly homogenized to a list
|
||||
(Griatch)
|
||||
- [Fix][pull3550]: Issue where rpsystem contrib search would do a global instead
|
||||
of local search on multimatch (InspectorCaracal)
|
||||
- [Fix][pull3585]: `TagCmd.switch_options` was misnamed (erratic-pattern)
|
||||
- [Fix][pull3580]: Fix typo that made `find/loc` show the wrong dbref in result (erratic-pattern)
|
||||
- [Fix][pull3571]: Issue disambiguating between certain partial multimatches
|
||||
(InspectorCaracal)
|
||||
- [Fix][pull3589]: Fix regex escaping in utils.py for future Python versions (hhsiao)
|
||||
- [Docs]: Add True-color description for Colors documentation (0xDEADFED5)
|
||||
- [Docs]: Doc fixes (Griatch, InspectorCaracal, 0xDEADFED5)
|
||||
|
||||
[pull3585]: https://github.com/evennia/evennia/pull/3585
|
||||
[pull3580]: https://github.com/evennia/evennia/pull/3580
|
||||
[pull3571]: https://github.com/evennia/evennia/pull/3571
|
||||
[pull3586]: https://github.com/evennia/evennia/pull/3586
|
||||
[pull3550]: https://github.com/evennia/evennia/pull/3550
|
||||
[pull3531]: https://github.com/evennia/evennia/pull/3531
|
||||
[pull3571]: https://github.com/evennia/evennia/pull/3571
|
||||
[pull3582]: https://github.com/evennia/evennia/pull/3582
|
||||
[pull3589]: https://github.com/evennia/evennia/pull/3589
|
||||
[issue3378]: https://github.com/evennia/evennia/issues/3578
|
||||
|
||||
## Evennia 4.2.0
|
||||
|
||||
June 27, 2024
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
136
docs/source/Contribs/Contrib-Ingame-Reports.md
Normal file
136
docs/source/Contribs/Contrib-Ingame-Reports.md
Normal 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>
|
||||
|
|
@ -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.
|
||||
|
||||
|
||||
----
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
@ -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:
|
||||
|
||||
```
|
||||
|
|
@ -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:
|
||||
|
||||
```
|
||||
|
|
@ -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:
|
||||
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
4.2.0
|
||||
4.3.0
|
||||
|
|
|
|||
|
|
@ -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|$"
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
130
evennia/contrib/base_systems/ingame_reports/README.md
Normal file
130
evennia/contrib/base_systems/ingame_reports/README.md
Normal 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.
|
||||
1
evennia/contrib/base_systems/ingame_reports/__init__.py
Normal file
1
evennia/contrib/base_systems/ingame_reports/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .reports import ReportsCmdSet
|
||||
134
evennia/contrib/base_systems/ingame_reports/menu.py
Normal file
134
evennia/contrib/base_systems/ingame_reports/menu.py
Normal 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
|
||||
315
evennia/contrib/base_systems/ingame_reports/reports.py
Normal file
315
evennia/contrib/base_systems/ingame_reports/reports.py
Normal 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)
|
||||
86
evennia/contrib/base_systems/ingame_reports/tests.py
Normal file
86
evennia/contrib/base_systems/ingame_reports/tests.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue