Merge branch 'main' into help_search

This commit is contained in:
Chiizujin 2024-08-13 13:54:33 +10:00
commit 9633f68cca
117 changed files with 4376 additions and 748 deletions

View file

@ -33,7 +33,7 @@ jobs:
COVERAGE_TEST_SETTINGS: "--settings=settings --timing"
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up database (${{ matrix.TESTING_DB }})
uses: ./.github/actions/setup-database

View file

@ -2,6 +2,64 @@
## Main branch
Feat: Support `scripts key:typeclass` form to create global scripts
with dynamic keys (rather than just relying on typeclass' key). Support
searching using the same syntax (Griatch)
[Fix][issue3556]: Better error if trying to treat ObjectDB as a typeclass (Griatch)
[Fix][issue3590]: Make `examine` command properly show `strattr` type
Attribute values (Griatch)
[Fix][issue3519]: `GLOBAL_SCRIPTS` container didn't list global scripts not
defined explicitly to be restarted/recrated in settings.py (Griatch)
Fix: Passing an already instantiated Script to `obj.scripts.add` (`ScriptHandler.add`)
did not add it to the handler's object (Griatch)
[Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch)
[issue3591]: https://github.com/evennia/evennia/issues/3591
[issue3590]: https://github.com/evennia/evennia/issues/3590
[issue3556]: https://github.com/evennia/evennia/issues/3556
[issue3519]: https://github.com/evennia/evennia/issues/3519
## Evennia 4.3.0
Aug 11, 2024
- [Feat][pull3531]: New contrib; `in-game reports` for handling user reports,
bugs etc in-game (InspectorCaracal)
- [Feat][pull3586]: Add ANSI color support `|U`, `|I`, `|i`, `|s`, `|S` for
underline reset, italic/reset and strikethrough/reset (0xDEADFED5)
- Feat: Add `Trait.traithandler` back-reference so custom Traits from the Traits
contrib can find and reference other Traits. (Griatch)
- [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (0xDEADFED5)
- [Fix][pull3571]: Better visual display of partial multimatch search results
(InspectorCaracal)
- [Fix][issue3378]: Prototype 'alias' key was not properly homogenized to a list
(Griatch)
- [Fix][pull3550]: Issue where rpsystem contrib search would do a global instead
of local search on multimatch (InspectorCaracal)
- [Fix][pull3585]: `TagCmd.switch_options` was misnamed (erratic-pattern)
- [Fix][pull3580]: Fix typo that made `find/loc` show the wrong dbref in result (erratic-pattern)
- [Fix][pull3571]: Issue disambiguating between certain partial multimatches
(InspectorCaracal)
- [Fix][pull3589]: Fix regex escaping in utils.py for future Python versions (hhsiao)
- [Docs]: Add True-color description for Colors documentation (0xDEADFED5)
- [Docs]: Doc fixes (Griatch, InspectorCaracal, 0xDEADFED5)
[pull3585]: https://github.com/evennia/evennia/pull/3585
[pull3580]: https://github.com/evennia/evennia/pull/3580
[pull3571]: https://github.com/evennia/evennia/pull/3571
[pull3586]: https://github.com/evennia/evennia/pull/3586
[pull3550]: https://github.com/evennia/evennia/pull/3550
[pull3531]: https://github.com/evennia/evennia/pull/3531
[pull3571]: https://github.com/evennia/evennia/pull/3571
[pull3582]: https://github.com/evennia/evennia/pull/3582
[pull3589]: https://github.com/evennia/evennia/pull/3589
[issue3378]: https://github.com/evennia/evennia/issues/3578
## Evennia 4.2.0
June 27, 2024
- [Feature][pull3470]: New `exit_order` kwarg to
`DefaultObject.get_display_exits` to easier customize the order in which
standard exits are displayed in a room (chiizujin)
@ -13,6 +71,16 @@
(pronoun conjugation) for actor stance (InspectorCaracal)
- [Feature][pull3521]: Allow `WORD` (fully capitalized) in GMCP command names
instead of only `Word` (titled) to support specific clients better (InspectorCaracal)
- [Feature][pull3447]: New Contrib: Achievements (InspectorCaracal)
- [Feature][pull3494]: Xterm truecolor hex support `|#0f0` style. Expanded
`color true` to test (michaelFaith84)
- [Feature][pull3497]: Add optional width to EvEditor flood-fill commands using
new `=` argument, for example `:f=40` or `:j 1:2 l = 60` (chiizujin)
- [Feature][pull3549]: Run the `collectstatic` command when reloading server to
keep game assets in sync automatically (InspectorCaracal)
- [Feature][issue3522]: (also a fix) Make `.created_date` property on all models property return
a time adjusted based on `settings.TIME_ZONE` (Griatch)
- [Language][pull3523]: Updated Polish translation (Moonchasered)
- [Fix][pull3495]: Fix rate in Trait contribs not updating after reload (jaborsh)
- [Fix][pull3491]: Fix traceback in EvEditor when searching with malformed regex (chiizujin)
- [Fix][pull3489]: Superuser could break wilderness contrib exits (t34lbytes)
@ -30,7 +98,19 @@
defaults to be used instead of finding nothing (InspectorCaracal)
- [Fix][pull3518]: `GlobalScriptsContainer.all()` raised a traceback (InspectorCaracal)
- [Fix][pull3520]: Exits not included in exit sort order were not listed correctly (chiizujin)
- [Docs]: Doc fixes (Griatch, chiizujin)
- [Fix][pull3529]: Fix page/list command not showing received pages correctly (chiizujin)
- [Fix][pull3530]: EvEditor cmdset priority increased so it doesn't respond to
movement commands while in editor (chiizujin)
- [Fix][pull3537]: Bug setting `_fields` in Components contrib (ChrisLR)
- [Fix][pull3542]: Update `character_creator` contrib to use the Account's look
template properly (InspectorCaracal)
- [Fix][pull3545]: Fix fallback issue in cmdhandler for local-object cmdsets (InspectorCaracal)
- [Fix][pull3554]: Fix/readd custom `ic` command to the `character_creator` contrib (InspectorCaracal)
- [Fix][pull3566]: Make sure the `website/base.html` website base is targeted
explicitly so it doesn't get overridden by same file name elsewhere in app (InspectorCaracal)
- [fix][issue3387]: Update all game template doc strings to be more up-to-date
(Griatch)
- [Docs]: Doc fixes (Griatch, chiizujin, InspectorCaracal, iLPDev)
[pull3470]: https://github.com/evennia/evennia/pull/3470
[pull3495]: https://github.com/evennia/evennia/pull/3495
@ -50,6 +130,20 @@
[pull3518]: https://github.com/evennia/evennia/pull/3518
[pull3520]: https://github.com/evennia/evennia/pull/3520
[pull3521]: https://github.com/evennia/evennia/pull/3521
[pull3447]: https://github.com/evennia/evennia/pull/3447
[pull3494]: https://github.com/evennia/evennia/pull/3494
[pull3497]: https://github.com/evennia/evennia/pull/3497
[pull3529]: https://github.com/evennia/evennia/pull/3529
[pull3530]: https://github.com/evennia/evennia/pull/3530
[pull3537]: https://github.com/evennia/evennia/pull/3537
[pull3542]: https://github.com/evennia/evennia/pull/3542
[pull3545]: https://github.com/evennia/evennia/pull/3545
[pull3549]: https://github.com/evennia/evennia/pull/3549
[pull3554]: https://github.com/evennia/evennia/pull/3554
[pull3523]: https://github.com/evennia/evennia/pull/3523
[pull3566]: https://github.com/evennia/evennia/pull/3566
[issue3522]: https://github.com/evennia/evennia/issue/3522
[issue3387]: https://github.com/evennia/evennia/issue/3387
## Evennia 4.1.1

View file

@ -3,8 +3,10 @@ Update dynamically generated doc pages based on github sources.
"""
from datetime import datetime
from os.path import abspath, dirname
from os.path import join as pathjoin
from re import sub as re_sub
ROOTDIR = dirname(dirname(dirname(abspath(__file__))))
DOCDIR = pathjoin(ROOTDIR, "docs")
@ -86,12 +88,45 @@ if settings.SERVERNAME == "Evennia":
print(" -- Updated Settings-Default.md")
def update_index():
"""
Read the index.md file and inject the latest version number and updated time.
"""
indexfile = pathjoin(DOCSRCDIR, "index.md")
versionfile = pathjoin(EVENNIADIR, "VERSION.txt")
with open(indexfile) as f:
srcdata = f.read()
# replace the version number
with open(versionfile) as f:
version = f.read().strip()
pattern = r"Evennia version is \d+\.\d+\.\d+\."
replacement = f"Evennia version is {version}."
srcdata = re_sub(pattern, replacement, srcdata)
# replace the last-updated time
now = datetime.now().strftime("%B %d, %Y")
pattern = r"This manual was last updated [A-Z][a-z]+ \d{1,2}, \d{4}"
replacement = f"This manual was last updated {now}"
srcdata = re_sub(pattern, replacement, srcdata)
with open(indexfile, "w") as f:
f.write(srcdata)
print(" -- Updated index.md")
def update_dynamic_pages():
"""
Run the various updaters
"""
update_index()
update_changelog()
update_default_settings()
update_code_style()

View file

@ -2,6 +2,64 @@
## Main branch
Feat: Support `scripts key:typeclass` form to create global scripts
with dynamic keys (rather than just relying on typeclass' key). Support
searching using the same syntax (Griatch)
[Fix][issue3556]: Better error if trying to treat ObjectDB as a typeclass (Griatch)
[Fix][issue3590]: Make `examine` command properly show `strattr` type
Attribute values (Griatch)
[Fix][issue3519]: `GLOBAL_SCRIPTS` container didn't list global scripts not
defined explicitly to be restarted/recrated in settings.py (Griatch)
Fix: Passing an already instantiated Script to `obj.scripts.add` (`ScriptHandler.add`)
did not add it to the handler's object (Griatch)
[Docs][issue3591]: Fix of NPC reaction tutorial code (Griatch)
[issue3591]: https://github.com/evennia/evennia/issues/3591
[issue3590]: https://github.com/evennia/evennia/issues/3590
[issue3556]: https://github.com/evennia/evennia/issues/3556
[issue3519]: https://github.com/evennia/evennia/issues/3519
## Evennia 4.3.0
Aug 11, 2024
- [Feat][pull3531]: New contrib; `in-game reports` for handling user reports,
bugs etc in-game (InspectorCaracal)
- [Feat][pull3586]: Add ANSI color support `|U`, `|I`, `|i`, `|s`, `|S` for
underline reset, italic/reset and strikethrough/reset (0xDEADFED5)
- Feat: Add `Trait.traithandler` back-reference so custom Traits from the Traits
contrib can find and reference other Traits. (Griatch)
- [Feat][pull3582]: Add true-color parsing/fallback for ANSIString (0xDEADFED5)
- [Fix][pull3571]: Better visual display of partial multimatch search results
(InspectorCaracal)
- [Fix][issue3378]: Prototype 'alias' key was not properly homogenized to a list
(Griatch)
- [Fix][pull3550]: Issue where rpsystem contrib search would do a global instead
of local search on multimatch (InspectorCaracal)
- [Fix][pull3585]: `TagCmd.switch_options` was misnamed (erratic-pattern)
- [Fix][pull3580]: Fix typo that made `find/loc` show the wrong dbref in result (erratic-pattern)
- [Fix][pull3571]: Issue disambiguating between certain partial multimatches
(InspectorCaracal)
- [Fix][pull3589]: Fix regex escaping in utils.py for future Python versions (hhsiao)
- [Docs]: Add True-color description for Colors documentation (0xDEADFED5)
- [Docs]: Doc fixes (Griatch, InspectorCaracal, 0xDEADFED5)
[pull3585]: https://github.com/evennia/evennia/pull/3585
[pull3580]: https://github.com/evennia/evennia/pull/3580
[pull3571]: https://github.com/evennia/evennia/pull/3571
[pull3586]: https://github.com/evennia/evennia/pull/3586
[pull3550]: https://github.com/evennia/evennia/pull/3550
[pull3531]: https://github.com/evennia/evennia/pull/3531
[pull3571]: https://github.com/evennia/evennia/pull/3571
[pull3582]: https://github.com/evennia/evennia/pull/3582
[pull3589]: https://github.com/evennia/evennia/pull/3589
[issue3378]: https://github.com/evennia/evennia/issues/3578
## Evennia 4.2.0
June 27, 2024
- [Feature][pull3470]: New `exit_order` kwarg to
`DefaultObject.get_display_exits` to easier customize the order in which
standard exits are displayed in a room (chiizujin)
@ -13,6 +71,16 @@
(pronoun conjugation) for actor stance (InspectorCaracal)
- [Feature][pull3521]: Allow `WORD` (fully capitalized) in GMCP command names
instead of only `Word` (titled) to support specific clients better (InspectorCaracal)
- [Feature][pull3447]: New Contrib: Achievements (InspectorCaracal)
- [Feature][pull3494]: Xterm truecolor hex support `|#0f0` style. Expanded
`color true` to test (michaelFaith84)
- [Feature][pull3497]: Add optional width to EvEditor flood-fill commands using
new `=` argument, for example `:f=40` or `:j 1:2 l = 60` (chiizujin)
- [Feature][pull3549]: Run the `collectstatic` command when reloading server to
keep game assets in sync automatically (InspectorCaracal)
- [Feature][issue3522]: (also a fix) Make `.created_date` property on all models property return
a time adjusted based on `settings.TIME_ZONE` (Griatch)
- [Language][pull3523]: Updated Polish translation (Moonchasered)
- [Fix][pull3495]: Fix rate in Trait contribs not updating after reload (jaborsh)
- [Fix][pull3491]: Fix traceback in EvEditor when searching with malformed regex (chiizujin)
- [Fix][pull3489]: Superuser could break wilderness contrib exits (t34lbytes)
@ -30,7 +98,19 @@
defaults to be used instead of finding nothing (InspectorCaracal)
- [Fix][pull3518]: `GlobalScriptsContainer.all()` raised a traceback (InspectorCaracal)
- [Fix][pull3520]: Exits not included in exit sort order were not listed correctly (chiizujin)
- [Docs]: Doc fixes (Griatch, chiizujin)
- [Fix][pull3529]: Fix page/list command not showing received pages correctly (chiizujin)
- [Fix][pull3530]: EvEditor cmdset priority increased so it doesn't respond to
movement commands while in editor (chiizujin)
- [Fix][pull3537]: Bug setting `_fields` in Components contrib (ChrisLR)
- [Fix][pull3542]: Update `character_creator` contrib to use the Account's look
template properly (InspectorCaracal)
- [Fix][pull3545]: Fix fallback issue in cmdhandler for local-object cmdsets (InspectorCaracal)
- [Fix][pull3554]: Fix/readd custom `ic` command to the `character_creator` contrib (InspectorCaracal)
- [Fix][pull3566]: Make sure the `website/base.html` website base is targeted
explicitly so it doesn't get overridden by same file name elsewhere in app (InspectorCaracal)
- [fix][issue3387]: Update all game template doc strings to be more up-to-date
(Griatch)
- [Docs]: Doc fixes (Griatch, chiizujin, InspectorCaracal, iLPDev)
[pull3470]: https://github.com/evennia/evennia/pull/3470
[pull3495]: https://github.com/evennia/evennia/pull/3495
@ -50,6 +130,20 @@
[pull3518]: https://github.com/evennia/evennia/pull/3518
[pull3520]: https://github.com/evennia/evennia/pull/3520
[pull3521]: https://github.com/evennia/evennia/pull/3521
[pull3447]: https://github.com/evennia/evennia/pull/3447
[pull3494]: https://github.com/evennia/evennia/pull/3494
[pull3497]: https://github.com/evennia/evennia/pull/3497
[pull3529]: https://github.com/evennia/evennia/pull/3529
[pull3530]: https://github.com/evennia/evennia/pull/3530
[pull3537]: https://github.com/evennia/evennia/pull/3537
[pull3542]: https://github.com/evennia/evennia/pull/3542
[pull3545]: https://github.com/evennia/evennia/pull/3545
[pull3549]: https://github.com/evennia/evennia/pull/3549
[pull3554]: https://github.com/evennia/evennia/pull/3554
[pull3523]: https://github.com/evennia/evennia/pull/3523
[pull3566]: https://github.com/evennia/evennia/pull/3566
[issue3522]: https://github.com/evennia/evennia/issue/3522
[issue3387]: https://github.com/evennia/evennia/issue/3387
## Evennia 4.1.1

View file

@ -99,7 +99,8 @@ Below are the access_types checked by the default commandset.
- `send` - who may send to the channel.
- `listen` - who may subscribe and listen to the channel.
- [HelpEntry](./Help-System.md):
- `examine` - who may view this help entry (usually everyone)
- `view` - if the help entry header should show up in the help index
- `read` - who may view this help entry (usually everyone)
- `edit` - who may edit this help entry.
So to take an example, whenever an exit is to be traversed, a lock of the type *traverse* will be checked. Defining a suitable lock type for an exit object would thus involve a lockstring `traverse: <lock functions>`.

View file

@ -45,8 +45,7 @@ The `typeclass/list` command will provide a list of all typeclasses known to Eve
## Difference between typeclasses and classes
All Evennia classes inheriting from class in the table above share one important feature and two
[]()important limitations. This is why we don't simply call them "classes" but "typeclasses".
All Evennia classes inheriting from class in the table above share one important feature and two important limitations. This is why we don't simply call them "classes" but "typeclasses".
1. A typeclass can save itself to the database. This means that some properties (actually not that many) on the class actually represents database fields and can only hold very specific data types.
1. Due to its connection to the database, the typeclass' name must be *unique* across the _entire_ server namespace. That is, there must never be two same-named classes defined anywhere. So the below code would give an error (since `DefaultObject` is now globally found both in this module and in the default library):
@ -66,15 +65,14 @@ All Evennia classes inheriting from class in the table above share one important
# my content
```
Apart from this, a typeclass works like any normal Python class and you can
treat it as such.
Apart from this, a typeclass works like any normal Python class and you can treat it as such.
## Working with typeclasses
### Creating a new typeclass
It's easy to work with Typeclasses. Either you use an existing typeclass or you create a new Python class inheriting from an existing typeclass. Here is an example of creating a new type of Object:
```python
from evennia import DefaultObject
@ -94,9 +92,7 @@ chair.save()
```
To use this you must give the database field names as keywords to the call. Which are available
depends on the entity you are creating, but all start with `db_*` in Evennia. This is a method you
may be familiar with if you know Django from before.
To use this you must give the database field names as keywords to the call. Which are available depends on the entity you are creating, but all start with `db_*` in Evennia. This is a method you may be familiar with if you know Django from before.
It is recommended that you instead use the `create_*` functions to create typeclassed entities:
@ -109,17 +105,9 @@ chair = create_object(Furniture, key="Chair")
chair = create_object("furniture.Furniture", key="Chair")
```
The `create_object` (`create_account`, `create_script` etc) takes the typeclass as its first
argument; this can both be the actual class or the python path to the typeclass as found under your
game directory. So if your `Furniture` typeclass sits in `mygame/typeclasses/furniture.py`, you
could point to it as `typeclasses.furniture.Furniture`. Since Evennia will itself look in
`mygame/typeclasses`, you can shorten this even further to just `furniture.Furniture`. The create-
functions take a lot of extra keywords allowing you to set things like [Attributes](./Attributes.md) and
[Tags](./Tags.md) all in one go. These keywords don't use the `db_*` prefix. This will also automatically
save the new instance to the database, so you don't need to call `save()` explicitly.
The `create_object` (`create_account`, `create_script` etc) takes the typeclass as its first argument; this can both be the actual class or the python path to the typeclass as found under your game directory. So if your `Furniture` typeclass sits in `mygame/typeclasses/furniture.py`, you could point to it as `typeclasses.furniture.Furniture`. Since Evennia will itself look in `mygame/typeclasses`, you can shorten this even further to just `furniture.Furniture`. The create-functions take a lot of extra keywords allowing you to set things like [Attributes](./Attributes.md) and [Tags](./Tags.md) all in one go. These keywords don't use the `db_*` prefix. This will also automatically save the new instance to the database, so you don't need to call `save()` explicitly.
An example of a database field is `db_key`. This stores the "name" of the entity you are modifying
and can thus only hold a string. This is one way of making sure to update the `db_key`:
An example of a database field is `db_key`. This stores the "name" of the entity you are modifying and can thus only hold a string. This is one way of making sure to update the `db_key`:
```python
chair.db_key = "Table"
@ -129,9 +117,7 @@ print(chair.db_key)
<<< Table
```
That is, we change the chair object to have the `db_key` "Table", then save this to the database.
However, you almost never do things this way; Evennia defines property wrappers for all the database
fields. These are named the same as the field, but without the `db_` part:
That is, we change the chair object to have the `db_key` "Table", then save this to the database. However, you almost never do things this way; Evennia defines property wrappers for all the database fields. These are named the same as the field, but without the `db_` part:
```python
chair.key = "Table"
@ -141,44 +127,32 @@ print(chair.key)
```
The `key` wrapper is not only shorter to write, it will make sure to save the field for you, and
does so more efficiently by levering sql update mechanics under the hood. So whereas it is good to
be aware that the field is named `db_key` you should use `key` as much as you can.
The `key` wrapper is not only shorter to write, it will make sure to save the field for you, and does so more efficiently by levering sql update mechanics under the hood. So whereas it is good to be aware that the field is named `db_key` you should use `key` as much as you can.
Each typeclass entity has some unique fields relevant to that type. But all also share the
following fields (the wrapper name without `db_` is given):
- `key` (str): The main identifier for the entity, like "Rose", "myscript" or "Paul". `name` is an
alias.
- `key` (str): The main identifier for the entity, like "Rose", "myscript" or "Paul". `name` is an alias.
- `date_created` (datetime): Time stamp when this object was created.
- `typeclass_path` (str): A python path pointing to the location of this (type)class
There is one special field that doesn't use the `db_` prefix (it's defined by Django):
- `id` (int): the database id (database ref) of the object. This is an ever-increasing, unique
integer. It can also be accessed as `dbid` (database ID) or `pk` (primary key). The `dbref` property
returns the string form "#id".
- `id` (int): the database id (database ref) of the object. This is an ever-increasing, unique integer. It can also be accessed as `dbid` (database ID) or `pk` (primary key). The `dbref` property returns the string form "#id".
The typeclassed entity has several common handlers:
- `tags` - the [TagHandler](./Tags.md) that handles tagging. Use `tags.add()` , `tags.get()` etc.
- `locks` - the [LockHandler](./Locks.md) that manages access restrictions. Use `locks.add()`,
`locks.get()` etc.
- `attributes` - the [AttributeHandler](./Attributes.md) that manages Attributes on the object. Use
`attributes.add()`
- `locks` - the [LockHandler](./Locks.md) that manages access restrictions. Use `locks.add()`, `locks.get()` etc.
- `attributes` - the [AttributeHandler](./Attributes.md) that manages Attributes on the object. Use `attributes.add()`
etc.
- `db` (DataBase) - a shortcut property to the AttributeHandler; allowing `obj.db.attrname = value`
- `nattributes` - the [Non-persistent AttributeHandler](./Attributes.md) for attributes not saved in the
database.
- `ndb` (NotDataBase) - a shortcut property to the Non-peristent AttributeHandler. Allows
`obj.ndb.attrname = value`
- `ndb` (NotDataBase) - a shortcut property to the Non-peristent AttributeHandler. Allows `obj.ndb.attrname = value`
Each of the typeclassed entities then extend this list with their own properties. Go to the
respective pages for [Objects](./Objects.md), [Scripts](./Scripts.md), [Accounts](./Accounts.md) and
[Channels](./Channels.md) for more info. It's also recommended that you explore the available
entities using [Evennia's flat API](../Evennia-API.md) to explore which properties and methods they have
available.
Each of the typeclassed entities then extend this list with their own properties. Go to the respective pages for [Objects](./Objects.md), [Scripts](./Scripts.md), [Accounts](./Accounts.md) and [Channels](./Channels.md) for more info. It's also recommended that you explore the available entities using [Evennia's flat API](../Evennia-API.md) to explore which properties and methods they have available.
### Overloading hooks
@ -186,25 +160,16 @@ The way to customize typeclasses is usually to overload *hook methods* on them.
### Querying for typeclasses
Most of the time you search for objects in the database by using convenience methods like the
`caller.search()` of [Commands](./Commands.md) or the search functions like `evennia.search_objects`.
Most of the time you search for objects in the database by using convenience methods like the `caller.search()` of [Commands](./Commands.md) or the search functions like `evennia.search_objects`.
You can however also query for them directly using [Django's query
language](https://docs.djangoproject.com/en/4.1/topics/db/queries/). This makes use of a _database
manager_ that sits on all typeclasses, named `objects`. This manager holds methods that allow
database searches against that particular type of object (this is the way Django normally works
too). When using Django queries, you need to use the full field names (like `db_key`) to search:
You can however also query for them directly using [Django's query language](https://docs.djangoproject.com/en/4.1/topics/db/queries/). This makes use of a _database manager_ that sits on all typeclasses, named `objects`. This manager holds methods that allow database searches against that particular type of object (this is the way Django normally works too). When using Django queries, you need to use the full field names (like `db_key`) to search:
```python
matches = Furniture.objects.get(db_key="Chair")
```
It is important that this will *only* find objects inheriting directly from `Furniture` in your
database. If there was a subclass of `Furniture` named `Sitables` you would not find any chairs
derived from `Sitables` with this query (this is not a Django feature but special to Evennia). To
find objects from subclasses Evennia instead makes the `get_family` and `filter_family` query
methods available:
It is important that this will *only* find objects inheriting directly from `Furniture` in your database. If there was a subclass of `Furniture` named `Sitables` you would not find any chairs derived from `Sitables` with this query (this is not a Django feature but special to Evennia). To find objects from subclasses Evennia instead makes the `get_family` and `filter_family` query methods available:
```python
# search for all furnitures and subclasses of furnitures
@ -213,26 +178,18 @@ matches = Furniture.objects.filter_family(db_key__startswith="Chair")
```
To make sure to search, say, all `Scripts` *regardless* of typeclass, you need to query from the
database model itself. So for Objects, this would be `ObjectDB` in the diagram above. Here's an
example for Scripts:
To make sure to search, say, all `Scripts` *regardless* of typeclass, you need to query from the database model itself. So for Objects, this would be `ObjectDB` in the diagram above. Here's an example for Scripts:
```python
from evennia import ScriptDB
matches = ScriptDB.objects.filter(db_key__contains="Combat")
```
When querying from the database model parent you don't need to use `filter_family` or `get_family` -
you will always query all children on the database model.
When querying from the database model parent you don't need to use `filter_family` or `get_family` - you will always query all children on the database model.
### Updating existing typeclass instances
If you already have created instances of Typeclasses, you can modify the *Python code* at any time -
due to how Python inheritance works your changes will automatically be applied to all children once you have reloaded the server.
However, database-saved data, like `db_*` fields, [Attributes](./Attributes.md), [Tags](./Tags.md) etc, are
not themselves embedded into the class and will *not* be updated automatically. This you need to
manage yourself, by searching for all relevant objects and updating or adding the data:
If you already have created instances of Typeclasses, you can modify the *Python code* at any time - due to how Python inheritance works your changes will automatically be applied to all children once you have reloaded the server. However, database-saved data, like `db_*` fields, [Attributes](./Attributes.md), [Tags](./Tags.md) etc, are not themselves embedded into the class and will *not* be updated automatically. This you need to manage yourself, by searching for all relevant objects and updating or adding the data:
```python
# add a worth Attribute to all existing Furniture
@ -241,11 +198,7 @@ for obj in Furniture.objects.all():
obj.db.worth = 100
```
A common use case is putting all Attributes in the `at_*_creation` hook of the entity, such as
`at_object_creation` for `Objects`. This is called every time an object is created - and only then.
This is usually what you want but it does mean already existing objects won't get updated if you
change the contents of `at_object_creation` later. You can fix this in a similar way as above
(manually setting each Attribute) or with something like this:
A common use case is putting all Attributes in the `at_*_creation` hook of the entity, such as `at_object_creation` for `Objects`. This is called every time an object is created - and only then. This is usually what you want but it does mean already existing objects won't get updated if you change the contents of `at_object_creation` later. You can fix this in a similar way as above (manually setting each Attribute) or with something like this:
```python
# Re-run at_object_creation only on those objects not having the new Attribute
@ -254,19 +207,14 @@ for obj in Furniture.objects.all():
obj.at_object_creation()
```
The above examples can be run in the command prompt created by `evennia shell`. You could also run
it all in-game using `@py`. That however requires you to put the code (including imports) as one
single line using `;` and [list
comprehensions](http://www.secnetix.de/olli/Python/list_comprehensions.hawk), like this (ignore the
line break, that's only for readability in the wiki):
The above examples can be run in the command prompt created by `evennia shell`. You could also run it all in-game using `@py`. That however requires you to put the code (including imports) as one single line using `;` and [list comprehensions](http://www.secnetix.de/olli/Python/list_comprehensions.hawk), like this (ignore the line break, that's only for readability in the wiki):
```
py from typeclasses.furniture import Furniture;
[obj.at_object_creation() for obj in Furniture.objects.all() if not obj.db.worth]
```
It is recommended that you plan your game properly before starting to build, to avoid having to
retroactively update objects more than necessary.
It is recommended that you plan your game properly before starting to build, to avoid having to retroactively update objects more than necessary.
### Swap typeclass
@ -294,8 +242,7 @@ The arguments to this method are described [in the API docs here](github:evennia
*This is considered an advanced section.*
Technically, typeclasses are [Django proxy models](https://docs.djangoproject.com/en/4.1/topics/db/models/#proxy-models). The only database
models that are "real" in the typeclass system (that is, are represented by actual tables in the database) are `AccountDB`, `ObjectDB`, `ScriptDB` and `ChannelDB` (there are also [Attributes](./Attributes.md) and [Tags](./Tags.md) but they are not typeclasses themselves). All the subclasses of them are "proxies", extending them with Python code without actually modifying the database layout.
Technically, typeclasses are [Django proxy models](https://docs.djangoproject.com/en/4.1/topics/db/models/#proxy-models). The only database models that are "real" in the typeclass system (that is, are represented by actual tables in the database) are `AccountDB`, `ObjectDB`, `ScriptDB` and `ChannelDB` (there are also [Attributes](./Attributes.md) and [Tags](./Tags.md) but they are not typeclasses themselves). All the subclasses of them are "proxies", extending them with Python code without actually modifying the database layout.
Evennia modifies Django's proxy model in various ways to allow them to work without any boiler plate (for example you don't need to set the Django "proxy" property in the model `Meta` subclass, Evennia handles this for you using metaclasses). Evennia also makes sure you can query subclasses as well as patches django to allow multiple inheritance from the same base class.
@ -303,9 +250,8 @@ Evennia modifies Django's proxy model in various ways to allow them to work with
Evennia uses the *idmapper* to cache its typeclasses (Django proxy models) in memory. The idmapper allows things like on-object handlers and properties to be stored on typeclass instances and to not get lost as long as the server is running (they will only be cleared on a Server reload). Django does not work like this by default; by default every time you search for an object in the database you'll get a *different* instance of that object back and anything you stored on it that was not in the database would be lost. The bottom line is that Evennia's Typeclass instances subside in memory a lot longer than vanilla Django model instance do.
There is one caveat to consider with this, and that relates to [making your own models](New-
Models): Foreign relationships to typeclasses are cached by Django and that means that if you were to change an object in a foreign relationship via some other means than via that relationship, the object seeing the relationship may not reliably update but will still see its old cached version. Due to typeclasses staying so long in memory, stale caches of such relationships could be more
visible than common in Django. See the [closed issue #1098 and its comments](https://github.com/evennia/evennia/issues/1098) for examples and solutions.
There is one caveat to consider with this, and that relates to [making your own models](New-
Models): Foreign relationships to typeclasses are cached by Django and that means that if you were to change an object in a foreign relationship via some other means than via that relationship, the object seeing the relationship may not reliably update but will still see its old cached version. Due to typeclasses staying so long in memory, stale caches of such relationships could be more visible than common in Django. See the [closed issue #1098 and its comments](https://github.com/evennia/evennia/issues/1098) for examples and solutions.
## Will I run out of dbrefs?
@ -315,4 +261,4 @@ The answer is simply **no**.
For example, the max dbref value for the default sqlite3 database is `2**64`. If you *created 10 000 new objects every second of every minute of every day of the year it would take about **60 million years** for you to run out of dbref numbers*. That's a database of 140 TeraBytes, just to store the dbrefs, no other data.
If you are still using Evennia at that point and have this concern, get back to us and we can discuss adding dbref reuse then.
If you are still using Evennia at that point and have this concern, get back to us and we can discuss adding dbref reuse then.

View file

@ -56,8 +56,14 @@ For the webclient, Evennia will translate the codes to CSS tags.
|\|X | normal black foreground color |
| \|\[# | background colours, e.g. \|\[c for bright cyan background and \|\[C a normal cyan background. |
| \|!# | foreground color that inherits brightness from previous tags. Always uppcase, like \|!R |
| \|h | make any following foreground ANSI colors bright (no effect on Xterm colors). Use with \|!#. Technically, \|h\|G == \|g. |
| \|H | negates the effects of \|h, return foreground to normal (no effect on Xterm colors) |
| \|h | make any following foreground ANSI colors bright (for Xterm256/true color makes the font bold if client supports it). Use with \|!#. Technically, \|h\|G == \|g. |
| \|H | negates the effects of \|h |
| \|u | underline font if client supports it |
| \|U | negates the effects of \|u |
| \|i | italic font if client supports it |
| \|I | negates the effects of \|i |
| \|s | strikethrough font if client supports it |
| \|S | negates the effects of \|s |
| \|/ | line break. Use instead of Python \\n when adding strings from in-game. |
| \|- | tab character when adding strings in-game. Can vay per client, so usually better with spaces. |
| \|_ | a space. Only needed to avoid auto-cropping at the end of a in-game input |
@ -126,14 +132,14 @@ actually change the background color instead of the foreground:
```
|*reversed text |!R now BG is red.
```
For a detailed explanation of these caveats, see the [Understanding Color Tags](Understanding-Color-
Tags) tutorial. But most of the time you might be better off to simply avoid `|*` and mark your text
For a detailed explanation of these caveats, see the [Understanding Color Tags](../Howtos/Tutorial\-Understanding\-Color\-Tags.md)
tutorial. But most of the time you might be better off to simply avoid `|*` and mark your text
manually instead.
## Xterm256 Colours
```{sidebar}
See the [Understanding Color Tags](../Howtos/Tutorial-Understanding-Color-Tags.md) tutorial, for more on the use of ANSI color tags and the pitfalls of mixing ANSI and Xterms256 color tags in the same context.
See the [Understanding Color Tags](../Howtos/Tutorial\-Understanding\-Color\-Tags.md) tutorial, for more on the use of ANSI color tags and the pitfalls of mixing ANSI and Xterms256 color tags in the same context.
```
The _Xterm256_ standard is a colour scheme that supports 256 colours for text and/or background. It can be combined freely with ANSI colors (above), but some ANSI tags don't affect Xterm256 tags.
@ -173,3 +179,40 @@ If you have a client that supports Xterm256, you can use
to get a table of all the 256 colours and the codes that produce them. If the table looks broken up
into a few blocks of colors, it means Xterm256 is not supported and ANSI are used as a replacement. You can use the `options` command to see if xterm256 is active for you. This depends on if your client told Evennia what it supports - if not, and you know what your client supports, you may have to activate some features manually.
## 24-bit Colors (True color)
```{sidebar}
See the [Wikipedia entry on web colors](https://en.wikipedia.org/wiki/Web_colors) for more detailed information on this color format.
```
Some clients support 24-bit colors. This is also called [true color](https://en.wikipedia.org/wiki/Color_depth#True_color_(24-bit)).
Not all clients support true color, they will instead see the closest equivalent. It's important to bear in mind that things may look quite
different from what you intended if you use subtle gradations in true color and it's viewed with a client that doesn't support true color.
The hexadecimal color codes used here are the same ones used in web design.
| Tag | Effect |
| -------- | ---- |
| \|#$$$$$$ | foreground RGB (red/green/blue), 6-digit hexadecimal format, where $ = 0-F |
| \|\[#$$$$$$ | background RGB |
| \|#$$$ | foreground RGB (red/green/blue), 3-digit hexadecimal format. |
| \|\[#$$$ | background RGB |
Some 6-digit examples:
| Tag | Effect |
| -------- | ---- |
| \|#ff0000 | bright red foreground|
| \|#00ff00 | bright green foreground|
| \|#0000ff | bright blue foreground|
| \|#\[ff0000 | bright red background|
Some 3-digit examples:
| Tag | Effect |
| ---- | ---- |
| \|#f00 | bright red foreground|
| \|#0f0 | bright green foreground|
| \|#00f | bright blue foreground|
| \|\[#f00 | bright red background|

View file

@ -27,7 +27,7 @@ updated after Sept 2022 will be missing some translations.
+---------------+----------------------+--------------+
| la | Latin | Feb 2021 |
+---------------+----------------------+--------------+
| pl | Polish | Feb 2019 |
| pl | Polish | Apr 2024 |
+---------------+----------------------+--------------+
| pt | Portugese | Oct 2022 |
+---------------+----------------------+--------------+

View file

@ -0,0 +1,251 @@
# Achievements
A simple, but reasonably comprehensive, system for tracking achievements. Achievements are defined using ordinary Python dicts, reminiscent of the core prototypes system, and while it's expected you'll use it only on Characters or Accounts, they can be tracked for any typeclassed object.
The contrib provides several functions for tracking and accessing achievements, as well as a basic in-game command for viewing achievement status.
## Installation
This contrib requires creation one or more module files containing your achievement data, which you then add to your settings file to make them available.
> See the section below on "Creating Achievements" for what to put in this module.
```python
# in server/conf/settings.py
ACHIEVEMENT_CONTRIB_MODULES = ["world.achievements"]
```
To allow players to check their achievements, you'll also want to add the `achievements` command to your default Character and/or Account command sets.
```python
# in commands/default_cmdsets.py
from evennia.contrib.game_systems.achievements.achievements import CmdAchieve
class CharacterCmdSet(default_cmds.CharacterCmdSet):
key = "DefaultCharacter"
def at_cmdset_creation(self):
# ...
self.add(CmdAchieve)
```
**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
# in settings.py
ACHIEVEMENT_CONTRIB_ATTRIBUTE = ("progress_data", "achievements")
```
## Creating achievements
An achievement is represented by a simple python dictionary defined at the module level in your achievements module(s).
Each achievement requires certain specific keys to be defined to work properly, along with several optional keys that you can use to override defaults.
> Note: Any additional keys not described here are included in the achievement data when you access those acheivements through the contrib, so you can easily add your own extended features.
#### Required keys
- **name** (str): The searchable name for the achievement. Doesn't need to be unique.
- **category** (str): The category, or general type, of condition which can progress this achievement. Usually this will be a player action or result. e.g. you would use a category of "defeat" on an achievement for killing 10 rats.
- **tracking** (str or list): The specific subset of condition which can progress this achievement. e.g. you would use a tracking value of "rat" on an achievement for killing 10 rats. An achievement can also track multiple things, for example killing 10 rats or snakes. For that situation, assign a list of all the values to check against, e.g. `["rat", "snake"]`
#### Optional keys
- **key** (str): *Default value if unset: the variable name.* The unique, case-insensitive key identifying this achievement.
> Note: If any achievements have the same unique key, only *one* will be loaded. It is case-insensitive, but punctuation is respected - "ten_rats", "Ten_Rats" and "TEN_RATS" will conflict, but "ten_rats" and "ten rats" will not.
- **desc** (str): A longer description of the achievement. Common uses for this would be flavor text or hints on how to complete it.
- **count** (int): *Default value if unset: 1* The number of tallies this achievement's requirements need to build up in order to complete the achievement. e.g. killing 10 rats would have a `"count"` value of `10`. For achievements using the "separate" tracking type, *each* item being tracked must tally up to this number to be completed.
- **tracking_type** (str): *Default value if unset: `"sum"`* There are two valid tracking types: "sum" (which is the default) and "separate". `"sum"` will increment a single counter every time any of the tracked items match. `"separate"` will have a counter for each individual item in the tracked items. ("See the Example Achievements" section for a demonstration of the difference.)
- **prereqs** (str or list): The *keys* of any achievements which must be completed before this achievement can start tracking progress.
### Example achievements
A simple achievement which you can get just for logging in the first time. This achievement has no prerequisites and it only needs to be fulfilled once to complete.
```python
# This achievement has the unique key of "first_login_achieve"
FIRST_LOGIN_ACHIEVE = {
"name": "Welcome!", # the searchable, player-friendly display name
"desc": "We're glad to have you here.", # the longer description
"category": "login", # the type of action this tracks
"tracking": "first", # the specific login action
}
```
An achievement for killing a total of 10 rats, and another for killing 10 *dire* rats which requires the "kill 10 rats" achievement to be completed first. The dire rats achievement won't begin tracking *any* progress until the first achievement is completed.
```python
# This achievement has the unique key of "ten_rats" instead of "achieve_ten_rats"
ACHIEVE_TEN_RATS = {
"key": "ten_rats",
"name": "The Usual",
"desc": "Why do all these inns have rat problems?",
"category": "defeat",
"tracking": "rat",
"count": 10,
}
ACHIEVE_DIRE_RATS = {
"name": "Once More, But Bigger",
"desc": "Somehow, normal rats just aren't enough any more.",
"category": "defeat",
"tracking": "dire rat",
"count": 10,
"prereqs": "ACHIEVE_TEN_RATS",
}
```
An achievement for buying a total of 5 of apples, oranges, *or* pears. The "sum" tracking types means that all items are tallied together - so it can be completed by buying 5 apples, or 5 pears, or 3 apples, 1 orange and 1 pear, or any other combination of those three fruits that totals to 5.
```python
FRUIT_FAN_ACHIEVEMENT = {
"name": "A Fan of Fruit", # note, there is no desc here - that's allowed!
"category": "buy",
"tracking": ("apple", "orange", "pear"),
"count": 5,
"tracking_type": "sum", # this is the default, but it's included here for clarity
}
```
An achievement for buying 5 *each* of apples, oranges, and pears. The "separate" tracking type means that each of the tracked items is tallied independently of the other items - so you will need 5 apples, 5 oranges, and 5 pears.
```python
FRUIT_BASKET_ACHIEVEMENT = {
"name": "Fruit Basket",
"desc": "One kind of fruit just isn't enough.",
"category": "buy",
"tracking": ("apple", "orange", "pear"),
"count": 5,
"tracking_type": "separate",
}
```
## Usage
The two main things you'll need to do in order to use the achievements contrib in your game are **tracking achievements** and **getting achievement information**. The first is done with the function `track_achievements`; the second can be done with `search_achievement` or `get_achievement`.
### Tracking achievements
#### `track_achievements`
In any actions or functions in your game's mechanics which you might want to track in an achievement, add a call to `track_achievements` to update that player's achievement progress.
Using the "kill 10 rats" example achievement from earlier, you might have some code that triggers when a character is defeated: for the sake of example, we'll pretend we have an `at_defeated` method on the base Object class that gets called when the Object is defeated.
Adding achievement tracking to it could then look something like this:
```python
# in typeclasses/objects.py
from contrib.game_systems.achievements import track_achievements
class Object(ObjectParent, DefaultObject):
# ....
def at_defeated(self, victor):
"""called when this object is defeated in combat"""
# we'll use the "mob_type" tag-category as the tracked info
# this way we can have rats named "black rat" and "brown rat" that are both rats
mob_type = self.tags.get(category="mob_type")
# only one mob was defeated, so we include a count of 1
track_achievements(victor, category="defeated", tracking=mob_type, count=1)
```
If a player defeats something tagged `rat` with a tag category of `mob_type`, it'd now count towards the rat-killing achievement.
You can also have the tracking information hard-coded into your game, for special or unique situations. The achievement described earlier, `FIRST_LOGIN_ACHIEVE`, for example, would be tracked like this:
```py
# in typeclasses/accounts.py
from contrib.game_systems.achievements import track_achievements
class Account(DefaultAccount):
# ...
def at_first_login(self, **kwargs):
# this function is only called on the first time the account logs in
# so we already know and can just tell the tracker that this is the first
track_achievements(self, category="login", tracking="first")
```
The `track_achievements` function does also return a value: an iterable of keys for any achievements which were newly completed by that update. You can ignore this value, or you can use it to e.g. send a message to the player with their latest achievements.
### Getting achievements
The main method for getting a specific achievement's information is `get_achievement`, which takes an already-known achievement key and returns the data for that one achievement.
For handling more variable and player-friendly input, however, there is also `search_achievement`, which does partial matching on not just the keys, but also the display names and descriptions for the achievements.
#### `get_achievement`
A utility function for retrieving a specific achievement's data from the achievement's unique key. It cannot be used for searching, but if you already have an achievement's key - for example, from the results of `track_achievements` - you can retrieve its data this way.
#### Example:
```py
from evennia.contrib.game_systems.achievements import get_achievement
def toast(achiever, completed_list):
if completed_list:
# `completed_data` will be a list of dictionaries - unrecognized keys return empty dictionaries
completed_data = [get_achievement(key) for key in args]
names = [data.get('name') for data in completed]
achiever.msg(f"|wAchievement Get!|n {iter_to_str(name for name in names if name)}"))
```
#### `search_achievement`
A utility function for searching achievements by name or description. It handles partial matching and returns a dictionary of matching achievements. The provided `achievement` command for in-game uses this function to find matching achievements from user inputs.
#### Example:
The first example does a search for "fruit", which returns the fruit medley achievement as it contains "fruit" in the key and name.
The second example searches for "usual", which returns the ten rats achievement due to its display name.
```py
>>> from evennia.contrib.game_systems.achievements import search_achievement
>>> search_achievement("fruit")
{'fruit_basket_achievement': {'name': 'Fruit Basket', 'desc': "One kind of fruit just isn't enough.", 'category': 'buy', 'tracking': ('apple', 'orange', 'pear'), 'count': 5, 'tracking_type': 'separate'}}
>>> search_achievement("usual")
{'ten_rats': {'key': 'ten_rats', 'name': 'The Usual', 'desc': 'Why do all these inns have rat problems?', 'category': 'defeat', 'tracking': 'rat', 'count': 10}}
```
### The `achievements` command
The contrib's provided command, `CmdAchieve`, aims to be usable as-is, with multiple switches to filter achievements by various progress statuses and the ability to search by achievement names.
To make it easier to customize for your own game (e.g. displaying some of that extra achievement data you might have added), the format and style code is split out from the command logic into the `format_achievement` method and the `template` attribute, both on `CmdAchieve`
#### Example output
```
> achievements
The Usual
Why do all these inns have rat problems?
70% complete
A Fan of Fruit
Not Started
```
```
> achievements/progress
The Usual
Why do all these inns have rat problems?
70% complete
```
```
> achievements/done
There are no matching achievements.
```
----
<small>This document page is generated from `evennia/contrib/game_systems/achievements/README.md`. Changes to this
file will be overwritten, so edit that file rather than this one.</small>

View file

@ -7,17 +7,17 @@ Commands for managing and initiating an in-game character-creation menu.
## Installation
In your game folder `commands/default_cmdsets.py`, import and add
`ContribCmdCharCreate` to your `AccountCmdSet`.
`ContribChargenCmdSet` to your `AccountCmdSet`.
Example:
```python
from evennia.contrib.rpg.character_creator.character_creator import ContribCmdCharCreate
from evennia.contrib.rpg.character_creator.character_creator import ContribChargenCmdSet
class AccountCmdSet(default_cmds.AccountCmdSet):
def at_cmdset_creation(self):
super().at_cmdset_creation()
self.add(ContribCmdCharCreate)
self.add(ContribChargenCmdSet)
```
In your game folder `typeclasses/accounts.py`, import and inherit from `ContribChargenAccount`
@ -100,15 +100,19 @@ character creator menu, as well as supporting exiting/resuming the process. In
addition, unlike the core command, it's designed for the character name to be
chosen later on via the menu, so it won't parse any arguments passed to it.
### Changes to `Account.at_look`
### Changes to `Account`
The contrib version works mostly the same as core evennia, but adds an
additional check to recognize an in-progress character. If you've modified your
own `at_look` hook, it's an easy addition to make: just add this section to the
The contrib version works mostly the same as core evennia, but modifies `ooc_appearance_template`
to match the contrib's command syntax, and the `at_look` method to recognize an in-progress
character.
If you've modified your own `at_look` hook, it's an easy change to add: just add this section to the
playable character list loop.
```python
# the beginning of the loop starts here
for char in characters:
# ...
# contrib code starts here
if char.db.chargen_step:
# currently in-progress character; don't display placeholder names

View file

@ -0,0 +1,136 @@
# In-Game Reporting System
Contrib by InspectorCaracal, 2024
This contrib provides an in-game reports system, handling bug reports, player reports, and idea submissions by default. It also supports adding your own types of reports, or removing any of the default report types.
Each type of report has its own command for submitting new reports, and an admin command is also provided for managing the reports through a menu.
## Installation
To install the reports contrib, just add the provided cmdset to your default AccountCmdSet:
```python
# in commands/default_cmdset.py
from evennia.contrib.base_systems.ingame_reports import ReportsCmdSet
class AccountCmdSet(default_cmds.AccountCmdSet):
# ...
def at_cmdset_creation(self):
# ...
self.add(ReportsCmdSet)
```
The contrib also has two optional settings: `INGAME_REPORT_TYPES` and `INGAME_REPORT_STATUS_TAGS`.
The `INGAME_REPORT_TYPES` setting is covered in detail in the section "Adding new types of reports".
The `INGAME_REPORT_STATUS_TAGS` setting is covered in the section "Managing reports".
## Usage
By default, the following report types are available:
* Bugs: Report bugs encountered during gameplay.
* Ideas: Submit suggestions for game improvement.
* Players: Report inappropriate player behavior.
Players can submit new reports through the command for each report type, and staff are given access to a report-management command and menu.
### Submitting reports
Players can submit reports using the following commands:
* `bug <text>` - Files a bug report. An optional target can be included - `bug <target> = <text>` - making it easier for devs/builders to track down issues.
* `report <player> = <text>` - Reports a player for inappropriate or rule-breaking behavior. *Requires* a target to be provided - it searches among accounts by default.
* `idea <text>` - Submits a general suggestion, with no target. It also has an alias of `ideas` which allows you to view all of your submitted ideas.
### Managing reports
The `manage reports` command allows staff to review and manage the various types of reports by launching a management menu.
This command will dynamically add aliases to itself based on the types of reports available, with each command string launching a menu for that particular report type. The aliases are built on the pattern `manage <report type>s` - by default, this means it makes `manage bugs`, `manage players`, and `manage ideas` available along with the default `manage reports`, and that e.g. `manage bugs` will launch the management menu for `bug`-type reports.
Aside from reading over existing reports, the menu allows you to change the status of any given report. By default, the contrib includes two different status tags: `in progress` and `closed`.
> Note: A report is created with no status tags, which is considered "open"
If you want a different set of statuses for your reports, you can define the `INGAME_REPORT_STATUS_TAGS` to your list of statuses.
**Example**
```python
# in server/conf/settings.py
# this will allow for the statuses of 'in progress', 'rejected', and 'completed', without the contrib-default of 'closed'
INGAME_REPORT_STATUS_TAGS = ('in progress', 'rejected', 'completed')
```
### Adding new types of reports
The contrib is designed to make adding new types of reports to the system as simple as possible, requiring only two steps:
1. Update your settings file to include an `INGAME_REPORT_TYPES` setting.
2. Create and add a new `ReportCmd` to your command set.
#### Update your settings
The contrib optionally references `INGAME_REPORT_TYPES` in your settings.py to see which types of reports can be managed. If you want to change the available report types, you'll need to define this setting.
```python
# in server/conf/settings.py
# this will include the contrib's report types as well as a custom 'complaint' report type
INGAME_REPORT_TYPES = ('bugs', 'ideas', 'players', 'complaints')
```
You can also use this setting to remove any of the contrib's report types - the contrib will respect this setting when building its cmdset with no additional steps.
```python
# in server/conf/settings.py
# this redefines the setting to not include 'ideas', so the ideas command and reports won't be available
INGAME_REPORT_TYPES = ('bugs', 'players')
```
#### Create a new ReportCmd
`ReportCmdBase` is a parent command class which comes with the main functionality for submitting reports. Creating a new reporting command is as simple as inheriting from this class and defining a couple of class attributes.
* `key` - This is the same as for any other command, setting the command's usable key. It also acts as the report type if that isn't explicitly set.
* `report_type` - The type of report this command is for (e.g. `player`). You only need to set it if you want a different string from the key.
* `report_locks` - The locks you want applied to the created reports. Defaults to `"read:pperm(Admin)"`
* `success_msg` - The string which is sent to players after submitting a report of this type. Defaults to `"Your report has been filed."`
* `require_target`: Set to `True` if your report type requires a target (e.g. player reports).
> Note: The contrib's own commands - `CmdBug`, `CmdIdea`, and `CmdReport` - are implemented the same way, so you can review them as examples.
Example:
```python
from evennia.contrib.base_systems.ingame_reports.reports import ReportCmdBase
class CmdCustomReport(ReportCmdBase):
"""
file a custom report
Usage:
customreport <message>
This is a custom report type.
"""
key = "customreport"
report_type = "custom"
success_message = "You have successfully filed a custom report."
```
Add this new command to your default cmdset to enable filing your new report type.
----
<small>This document page is generated from `evennia/contrib/base_systems/ingame_reports/README.md`. Changes to this
file will be overwritten, so edit that file rather than this one.</small>

View file

@ -73,8 +73,14 @@ class Room(ContribRPRoom):
# ...
```
You need to set up Evennia to use the RPsystem's form to separate
between sdescs (`3-tall`) to make it compatible with how the rest of Evennia
separates between other multi-matches of searches/commands:
You will then need to reload the server and potentially force-reload
SEARCH_MULTIMATCH_REGEX = r"(?P<number>[0-9]+)-(?P<name>[^-]*)(?P<args>.*)"
SEARCH_MULTIMATCH_TEMPLATE = " {number}-{name}{aliases}{info}\n"
Finally, you will then need to reload the server and potentially force-reload
your objects, if you originally created them without this.
Example for your character:

View file

@ -4,7 +4,7 @@ Contribution by Griatch 2020, based on code by Whitenoise and Ainneve contribs,
A `Trait` represents a modifiable property on (usually) a Character. They can
be used to represent everything from attributes (str, agi etc) to skills
(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc.
(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc.
Traits differ from normal Attributes in that they track their changes and limit
themselves to particular value-ranges. One can add/subtract from them easily and
they can even change dynamically at a particular rate (like you being poisoned or
@ -50,8 +50,6 @@ class Character(DefaultCharacter):
self.traits.add("hp", "Health", trait_type="gauge", min=0, max=100)
self.traits.add("hunting", "Hunting Skill", trait_type="counter",
base=10, mod=1, min=0, max=100)
```
When adding the trait, you supply the name of the property (`hunting`) along
with a more human-friendly name ("Hunting Skill"). The latter will show if you
@ -78,7 +76,6 @@ class Object(DefaultObject):
strength = TraitProperty("Strength", trait_type="static", base=10, mod=2)
health = TraitProperty("Health", trait_type="gauge", min=0, base=100, mod=2)
hunting = TraitProperty("Hunting Skill", trait_type="counter", base=10, mod=1, min=0, max=100)
```
> Note that the property-name will become the name of the trait and you don't supply `trait_key`
@ -92,7 +89,7 @@ class Object(DefaultObject):
## Using traits
A trait is added to the traithandler (if you use `TraitProperty` the handler is just created under
A trait are entities added to the traithandler (if you use `TraitProperty` the handler is just created under
the hood) after which one can access it as a property on the handler (similarly to how you can do
.db.attrname for Attributes in Evennia).
@ -137,9 +134,30 @@ obj.traits.strength.value
> obj.strength.value += 5
> obj.strength.value
17
```
### Relating traits to one another
From a trait you can access its own Traithandler as `.traithandler`. You can
also find another trait on the same handler by using the
`Trait.get_trait("traitname")` method.
```python
> obj.strength.get_trait("hp").value
100
```
This is not too useful for the default trait types - they are all operating
independently from one another. But if you create your own trait classes, you
can use this to make traits that depend on each other.
For example, you could picture making a Trait that is the sum of the values of
two other traits and capped by the value of a third trait. Such complex
interactions are common in RPG rule systems but are by definition game specific.
See an example in the section about [making your own Trait classes](#expanding-with-your-own-traits).
## Trait types
All default traits have a read-only `.value` property that shows the relevant or
@ -158,7 +176,6 @@ compatible type.
> trait1 + 2
> trait1.value
5
```
Two numerical traits can also be compared (bigger-than etc), which is useful in
@ -168,7 +185,6 @@ all sorts of rule-resolution.
if trait1 > trait2:
# do stuff
```
### Trait
@ -193,7 +209,6 @@ like a glorified Attribute.
> obj.traits.mytrait.value = "stringvalue"
> obj.traits.mytrait.value
"stringvalue"
```
### Static trait
@ -217,7 +232,6 @@ that varies slowly or not at all, and which may be modified in-place.
> obj.traits.mytrait.mod = 0
> obj.traits.mytrait.value
12
```
### Counter
@ -253,8 +267,6 @@ remove it. A suggested use for a Counter Trait would be to track skill values.
# for TraitProperties, pass the args/kwargs of traits.add() to the
# TraitProperty constructor instead.
```
Counters have some extra properties:
@ -286,7 +298,6 @@ By calling `.desc()` on the Counter, you will get the text matching the current
> obj.traits.hunting.desc()
"expert"
```
#### .rate
@ -327,12 +338,10 @@ a previous value.
71 # we have stopped at the ratetarget
> obj.traits.hunting.rate = 0 # disable auto-change
```
Note that when retrieving the `current`, the result will always be of the same
type as the `.base` even `rate` is a non-integer value. So if `base` is an `int`
(default)`, the `current` value will also be rounded the closest full integer.
(default), the `current` value will also be rounded the closest full integer.
If you want to see the exact `current` value, set `base` to a float - you
will then need to use `round()` yourself on the result if you want integers.
@ -347,7 +356,6 @@ return the value as a percentage.
> obj.traits.hunting.percent(formatting=None)
71.0
```
### Gauge
@ -379,7 +387,6 @@ stamina and the like.
> obj.traits.hp.current -= 30
> obj.traits.hp.value
80
```
The Gauge trait is subclass of the Counter, so you have access to the same
@ -412,8 +419,6 @@ class RageTrait(StaticTrait):
def sedate(self):
self.mod = 0
```
Above is an example custom-trait-class "rage" that stores a property "rage" on
@ -432,12 +437,24 @@ Reload the server and you should now be able to use your trait:
> obj.traits.add("mood", "A dark mood", rage=30, trait_type='rage')
> obj.traits.mood.rage
30
```
Remember that you can use `.get_trait("name")` to access other traits on the
same handler. Let's say that the rage modifier is actually limited by
the characters's current STR value times 3, with a max of 100:
```python
class RageTrait(StaticTrait):
#...
def berserk(self):
self.mod = min(100, self.get_trait("STR").value * 3)
```
# as TraitProperty
```
class Character(DefaultCharacter):
rage = TraitProperty("A dark mood", rage=30, trait_type='rage')
```
## Adding additional TraitHandlers
@ -459,7 +476,7 @@ class Character(DefaultCharacter):
def traits(self):
# this adds the handler as .traits
return TraitHandler(self)
@lazy_property
def stats(self):
# this adds the handler as .stats
@ -479,6 +496,9 @@ class Character(DefaultCharacter):
base=10, mod=1, min=0, max=100)
```
> Rememebr that the `.get_traits()` method only works for accessing Traits within the
_same_ TraitHandler.
----

View file

@ -24,7 +24,7 @@ Evennia has a [contrib](./Contribs-Overview.md) directory which contains optiona
| `grid/` | _Systems related to the game worlds topology and structure. Contribs related to rooms, exits and map building._ |
| `rpg/` | _Systems specifically related to roleplaying and rule implementation like character traits, dice rolling and emoting._ |
| `tutorials/` | _Helper resources specifically meant to teach a development concept or to exemplify an Evennia system. Any extra resources tied to documentation tutorials are found here. Also the home of the Tutorial-World and Evadventure demo codes._ |
| `tools/` | _Miscellaneous tools for manipulating text, security auditing, and more._|
| `utils/` | _Miscellaneous tools for manipulating text, security auditing, and more._|
- The folder (package) should be on the following form:

View file

@ -7,7 +7,7 @@ in the [Community Contribs & Snippets][forum] forum.
_Contribs_ are optional code snippets and systems contributed by
the Evennia community. They vary in size and complexity and
may be more specific about game types and styles than 'core' Evennia.
This page is auto-generated and summarizes all **49** 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
@ -29,16 +29,17 @@ If you want to add a contrib, see [the contrib guidelines](./Contribs-Guidelines
| | | | | |
|---|---|---|---|---|
| [auditing](#auditing) | [awsstorage](#awsstorage) | [barter](#barter) | [batchprocessor](#batchprocessor) | [bodyfunctions](#bodyfunctions) |
| [buffs](#buffs) | [building_menu](#building_menu) | [character_creator](#character_creator) | [clothing](#clothing) | [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) |
| [achievements](#achievements) | [auditing](#auditing) | [awsstorage](#awsstorage) | [barter](#barter) | [batchprocessor](#batchprocessor) |
| [bodyfunctions](#bodyfunctions) | [buffs](#buffs) | [building_menu](#building_menu) | [character_creator](#character_creator) | [clothing](#clothing) |
| [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) | [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._
@ -266,6 +278,7 @@ Contribs-Guidelines.md
```{toctree}
:maxdepth: 1
Contrib-Achievements.md
Contrib-Barter.md
Contrib-Clothing.md
Contrib-Containers.md
@ -279,6 +292,16 @@ Contrib-Turnbattle.md
```
### `achievements`
_A simple, but reasonably comprehensive, system for tracking achievements. Achievements are defined using ordinary Python dicts, reminiscent of the core prototypes system, and while it's expected you'll use it only on Characters or Accounts, they can be tracked for any typeclassed object._
The contrib provides several functions for tracking and accessing achievements, as well as a basic in-game command for viewing achievement status.
[Read the documentation](./Contrib-Achievements.md) - [Browse the Code](evennia.contrib.game_systems.achievements)
### `barter`
_Contribution by Griatch, 2012_
@ -631,7 +654,7 @@ _Contribution by Griatch 2020, based on code by Whitenoise and Ainneve contribs,
A `Trait` represents a modifiable property on (usually) a Character. They can
be used to represent everything from attributes (str, agi etc) to skills
(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc.
(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc.
Traits differ from normal Attributes in that they track their changes and limit
themselves to particular value-ranges. One can add/subtract from them easily and
they can even change dynamically at a particular rate (like you being poisoned or

View file

@ -3,8 +3,7 @@
In this lesson we'll learn how to create our own Evennia [Commands](../../../Components/Commands.md) If you are new to Python you'll also learn some more basics about how to manipulate strings and get information out of Evennia.
A Command is something that handles the input from a user and causes a result to happen.
An example is `look`, which examines your current location and tells you what it looks like and
what is in it.
An example is `look`, which examines your current location and tells you what it looks like and what is in it.
```{sidebar} Commands are not typeclassed
@ -20,7 +19,7 @@ Command-Sets are then associated with objects, for example with your Character.
## Creating a custom command
Open `mygame/commands/command.py`:
Open `mygame/commands/command.py`. This file already has stuff filled in for you.
```python
"""

View file

@ -23,12 +23,15 @@ On the `DefaultObject` is a `.search` method which we have already tried out whe
- It will always return exactly one match. If it found zero or more than one match, the return is `None`. This is different from `evennia.search` (see below), which always returns a list.
- On a no-match or multimatch, `.search` will automatically send an error message to `obj`. So you don't have to worry about reporting messages if the result is `None`.
In other words, this method handles error messaging for you. A very common way to use it is in commands:
In other words, this method handles error messaging for you. A very common way to use it is in commands. You can put your command anywhere, but let's try the pre-filled-in `mygame/commands/command.py`.
```python
# in for example mygame/commands/command.py
from evennia import Command
from evennia import Command as BaseCommand
class Command(BaseCommand):
# ...
class CmdQuickFind(Command):
"""
@ -132,9 +135,9 @@ Above we find first the rose and then an Account. You can try both using `py`:
> py evennia.search_account("YourName")[0]
<Player: YourName>
In the example above we used `[0]` to only get the first match of the queryset, which in this case gives us the rose and your Account respectively. Note that if you don't find any matches, using `[0]` like this leads to an error, so it's mostly useful for debugging.
The `search_object/account` returns all matches. We use `[0]` to only get the first match of the queryset, which in this case gives us the rose and your Account respectively. Note that if you don't find any matches, using `[0]` like this leads to an error, so it's mostly useful for debugging.
If you you really want all matches to the search parameters you specify. In other situations, having zero or more than one match is a sign of a problem and you need to handle this case yourself. This is too detailed for testing out just with `py`, but good to know if you want to make your own search methods:
In other situations, having zero or more than one match is a sign of a problem and you need to handle this case yourself. This is too detailed for testing out just with `py`, but good to know if you want to make your own search methods:
```python
the_one_ring = evennia.search_object("The one Ring")

View file

@ -586,7 +586,7 @@ class TestEvAdventureRuleEngine(BaseEvenniaTest):
As before, run the specific test with
evennia test --settings settings.py .evadventure.tests.test_rules
evennia test --settings settings.py evadventure.tests.test_rules
### Mocking and patching

View file

@ -27,7 +27,7 @@ class NPC(Character):
"""
A NPC typeclass which extends the character class.
"""
def at_char_entered(self, character):
def at_char_entered(self, character, **kwargs):
"""
A simple is_aggressive check.
Can be expanded upon later.
@ -38,7 +38,13 @@ class NPC(Character):
self.execute_cmd(f"say Greetings, {character}!")
```
Here we make a simple method on the `NPC`˙. We expect it to be called when a (player-)character enters the room. We don't actually set the `is_aggressive` [Attribute](../Components/Attributes.md) beforehand; if it's not set, the NPC is simply non-hostile.
```{sidebar} Passing extra information
Note that we don't use the `**kwargs` property here. This can be used to pass extra information into hooks in your game and would be used when you make custom move commands. For example, if you `run` into the room, you could inform all hooks by doing `obj.move_to(..., running=True)`. Maybe your librarian NPC should have a separate reaction for people running into their library!
We make sure to pass the `**kwargs` from the standard `at_object_receive` hook below.
```
Here we make a simple method on the `NPC`˙ called `at_char_entered`. We expect it to be called when a (player-)character enters the room. We don't actually set the `is_aggressive` [Attribute](../Components/Attributes.md) beforehand; we leave this up for the admin to activate in-game. if it's not set, the NPC is simply non-hostile.
Whenever _something_ enters the `Room`, its [at_object_receive](DefaultObject.at_object_receive) hook will be called. So we should override it.
@ -54,18 +60,18 @@ class Room(ObjectParent, DefaultRoom):
# ...
def at_object_receive(self, arriving_obj, source_location):
def at_object_receive(self, arriving_obj, source_location, **kwargs):
if arriving_obj.account:
# this has an active acccount - a player character
for item in self.contents:
# get all npcs in the room and inform them
if utils.inherits_from(item, "typeclasses.npcs.NPC"):
self.at_char_entered(arriving_obj)
if utils.inherits_from(item, "typeclasses.npcs.NPC"):
item.at_char_entered(arriving_obj, **kwargs)
```
```{sidebar} Universal Object methods
Remember that Rooms are `Objects`. So the same `at_object_receive` hook will fire for you when you pick something up (making you 'receive' it). Or for a box when putting something inside it.
Remember that Rooms are `Objects`, and other Objects have these same hooks. So an `at_object_receive` hook will fire for you when you pick something up (making you 'receive' it). Or for a box when putting something inside it, for example.
```
A currently puppeted Character will have an `.account` attached to it. We use that to know that the thing arriving is a Character. We then use Evennia's [utils.inherits_from](evennia.utils.utils.inherits_from) helper utility to get every NPC in the room can each of their newly created `at_char_entered` method.

View file

@ -332,7 +332,7 @@ Evennia users:
| [Heficed][5] | VPS & Cloud | $5/month | Multiple regions. Cheapest for 1GB ram server is $5/month. |
| [Scaleway][6] | Cloud | &euro;3/month / on-demand | EU based (Paris, Amsterdam). Smallest option provides 2GB RAM. |
| [Prgmr][10] | VPS | $5/month | 1 month free with a year prepay. You likely want some experience with servers with this option as they don't have a lot of support.|
| [Akami (formerly Linode)][11] | Cloud | $5/month / on-demand | Multiple regions. Smallest option provides 1GB RAM|
| [Akami (formerly Linode)][11] | VPS | $5/month / on-demand | Multiple regions. Smallest option ($5/mo) provides 1GB RAM. Also offers cloud services. |
| [Genesis MUD hosting][4] | Shell account | $8/month | Dedicated MUD host with very limited memory offerings. May run very old Python versions. Evennia needs *at least* the "Deluxe" package (50MB RAM) and probably *a lot* higher for a production game. While it's sometimes mentioned in a MUD context, this host is *not* recommended for Evennia.|
*Please help us expand this list.*

View file

@ -0,0 +1,19 @@
```{eval-rst}
evennia.contrib.base\_systems.ingame\_reports
=====================================================
.. automodule:: evennia.contrib.base_systems.ingame_reports
:members:
:undoc-members:
:show-inheritance:
.. toctree::
:maxdepth: 6
evennia.contrib.base_systems.ingame_reports.menu
evennia.contrib.base_systems.ingame_reports.reports
evennia.contrib.base_systems.ingame_reports.tests
```

View file

@ -0,0 +1,10 @@
```{eval-rst}
evennia.contrib.base\_systems.ingame\_reports.menu
=========================================================
.. automodule:: evennia.contrib.base_systems.ingame_reports.menu
:members:
:undoc-members:
:show-inheritance:
```

View file

@ -0,0 +1,10 @@
```{eval-rst}
evennia.contrib.base\_systems.ingame\_reports.reports
============================================================
.. automodule:: evennia.contrib.base_systems.ingame_reports.reports
:members:
:undoc-members:
:show-inheritance:
```

View file

@ -0,0 +1,10 @@
```{eval-rst}
evennia.contrib.base\_systems.ingame\_reports.tests
==========================================================
.. automodule:: evennia.contrib.base_systems.ingame_reports.tests
:members:
:undoc-members:
:show-inheritance:
```

View file

@ -19,6 +19,7 @@ evennia.contrib.base\_systems
evennia.contrib.base_systems.email_login
evennia.contrib.base_systems.godotwebsocket
evennia.contrib.base_systems.ingame_python
evennia.contrib.base_systems.ingame_reports
evennia.contrib.base_systems.menu_login
evennia.contrib.base_systems.mux_comms_cmds
evennia.contrib.base_systems.unixcommand

View file

@ -0,0 +1,10 @@
```{eval-rst}
evennia.contrib.game\_systems.achievements.achievements
==============================================================
.. automodule:: evennia.contrib.game_systems.achievements.achievements
:members:
:undoc-members:
:show-inheritance:
```

View file

@ -0,0 +1,18 @@
```{eval-rst}
evennia.contrib.game\_systems.achievements
==================================================
.. automodule:: evennia.contrib.game_systems.achievements
:members:
:undoc-members:
:show-inheritance:
.. toctree::
:maxdepth: 6
evennia.contrib.game_systems.achievements.achievements
evennia.contrib.game_systems.achievements.tests
```

View file

@ -0,0 +1,10 @@
```{eval-rst}
evennia.contrib.game\_systems.achievements.tests
=======================================================
.. automodule:: evennia.contrib.game_systems.achievements.tests
:members:
:undoc-members:
:show-inheritance:
```

View file

@ -11,6 +11,7 @@ evennia.contrib.game\_systems
.. toctree::
:maxdepth: 6
evennia.contrib.game_systems.achievements
evennia.contrib.game_systems.barter
evennia.contrib.game_systems.clothing
evennia.contrib.game_systems.containers

View file

@ -0,0 +1,10 @@
```{eval-rst}
evennia.utils.hex\_colors
================================
.. automodule:: evennia.utils.hex_colors
:members:
:undoc-members:
:show-inheritance:
```

View file

@ -24,6 +24,7 @@ evennia.utils
evennia.utils.evtable
evennia.utils.funcparser
evennia.utils.gametime
evennia.utils.hex_colors
evennia.utils.logger
evennia.utils.optionclasses
evennia.utils.optionhandler

View file

@ -1,8 +1,8 @@
# Evennia Documentation
This is the manual of [Evennia](https://www.evennia.com), the open source Python `MU*` creation system. Use the Search bar on the left to find or discover interesting articles.
This is the manual of [Evennia](https://www.evennia.com), the open source Python `MU*` creation system. Use the Search bar on the left to find or discover interesting articles. This manual was last updated August 11, 2024, see the [Evennia Changelog](Coding/Changelog.md). Latest released Evennia version is 4.3.0.
- [Introduction](./Evennia-Introduction.md) - what is this Evennia thing?
- [Introduction](./Evennia-Introduction.md) - what is this Evennia thing?
- [Evennia in Pictures](./Evennia-In-Pictures.md) - a visual overview of Evennia
- [Contributing and Getting help](./Contributing.md) - when you get stuck or want to chip in
@ -10,7 +10,7 @@ This is the manual of [Evennia](https://www.evennia.com), the open source Python
- [Installation](Setup/Setup-Overview.md#installation-and-running) - getting started
- [Running the Game](Setup/Running-Evennia.md) - how to start, stop and reload Evennia
- [Updating the Server](Setup/Updating-Evennia.md) - how to update Evennia
- [Updating the Server](Setup/Updating-Evennia.md) - how to update Evennia
- [Configuration](Setup/Setup-Overview.md#configuration) - how to set up Evennia the way you like it
- [Going Online](Setup/Setup-Overview.md#going-online) - bringing your game online

View file

@ -1 +1 @@
4.1.1
4.3.0

View file

@ -226,7 +226,6 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
- user (User, read-only) - django User authorization object
- obj (Object) - game object controlled by account. 'character' can also
be used.
- sessions (list of Sessions) - sessions connected to this account
- is_superuser (bool, read-only) - if the connected user is a superuser
* Handlers
@ -239,18 +238,47 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
- scripts - script-handler. Add new scripts to object with scripts.add()
- cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object
- nicks - nick-handler. New nicks with nicks.add().
- sessions - session-handler. Use session.get() to see all sessions connected, if any
- options - option-handler. Defaults are taken from settings.OPTIONS_ACCOUNT_DEFAULT
- characters - handler for listing the account's playable characters
* Helper methods
* Helper methods (check autodocs for full updated listing)
- msg(text=None, from_obj=None, session=None, options=None, **kwargs)
- execute_cmd(raw_string)
- search(ostring, global_search=False, attribute_name=None,
use_nicks=False, location=None,
ignore_errors=False, account=False)
- search(searchdata, return_puppet=False, search_object=False, typeclass=None,
nofound_string=None, multimatch_string=None, use_nicks=True,
quiet=False, **kwargs)
- is_typeclass(typeclass, exact=False)
- swap_typeclass(new_typeclass, clean_attributes=False, no_default=True)
- access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False)
- access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False, **kwargs)
- check_permstring(permstring)
- get_cmdsets(caller, current, **kwargs)
- get_cmdset_providers()
- uses_screenreader(session=None)
- get_display_name(looker, **kwargs)
- get_extra_display_name_info(looker, **kwargs)
- disconnect_session_from_account()
- puppet_object(session, obj)
- unpuppet_object(session)
- unpuppet_all()
- get_puppet(session)
- get_all_puppets()
- is_banned(**kwargs)
- get_username_validators(validator_config=settings.AUTH_USERNAME_VALIDATORS)
- authenticate(username, password, ip="", **kwargs)
- normalize_username(username)
- validate_username(username)
- validate_password(password, account=None)
- set_password(password, **kwargs)
- get_character_slots()
- get_available_character_slots()
- create_character(*args, **kwargs)
- create(*args, **kwargs)
- delete(*args, **kwargs)
- channel_msg(message, channel, senders=None, **kwargs)
- idle_time()
- connection_time()
* Hook methods
@ -261,15 +289,26 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
usually handled on the character level:
- at_init()
- at_first_save()
- at_access()
- at_cmdset_get(**kwargs)
- at_password_change(**kwargs)
- at_first_login()
- at_pre_login()
- at_post_login(session=None)
- at_disconnect()
- at_failed_login(session, **kwargs)
- at_disconnect(reason=None, **kwargs)
- at_post_disconnect(**kwargs)
- at_message_receive()
- at_message_send()
- at_server_reload()
- at_server_shutdown()
- at_look(target=None, session=None, **kwargs)
- at_post_create_character(character, **kwargs)
- at_post_add_character(char)
- at_post_remove_character(char)
- at_pre_channel_msg(message, channel, senders=None, **kwargs)
- at_post_chnnel_msg(message, channel, senders=None, **kwargs)
"""

View file

@ -44,7 +44,7 @@ class Migration(migrations.Migration):
verbose_name="username",
validators=[
django.core.validators.RegexValidator(
"^[\\w.@+-]+$", "Enter a valid username.", "invalid"
r"^[\w.@+-]+$", "Enter a valid username.", "invalid"
)
],
),

View file

@ -47,7 +47,7 @@ class Migration(migrations.Migration):
max_length=30,
validators=[
django.core.validators.RegexValidator(
"^[\\w.@+-]+$",
r"^[\w.@+-]+$",
"Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.",
"invalid",
)

View file

@ -21,7 +21,7 @@ class Migration(migrations.Migration):
unique=True,
validators=[
django.core.validators.RegexValidator(
"^[\\w.@+-]+$",
r"^[\w.@+-]+$",
"Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.",
)
],

View file

@ -172,7 +172,7 @@ class TestDefaultAccountAuth(BaseEvenniaTest):
if not uses_database("mysql"):
# TODO As of Mar 2019, mysql does not pass this test due to collation problems
# that has not been possible to resolve
result, error = DefaultAccount.validate_username("¯\_(ツ)_/¯")
result, error = DefaultAccount.validate_username(r"¯\_(ツ)_/¯")
self.assertFalse(result, "Validator allowed kanji in username.")
# Should not allow duplicate username

View file

@ -350,7 +350,7 @@ def get_and_merge_cmdsets(
"""
# Gather cmdsets from location, objects in location or carried
try:
local_obj_cmdsets = [None]
local_obj_cmdsets = []
try:
location = obj.location
except Exception:
@ -438,11 +438,12 @@ def get_and_merge_cmdsets(
cmdset for cmdset in object_cmdsets if cmdset and cmdset.key != "_EMPTY_CMDSET"
]
# report cmdset errors to user (these should already have been logged)
yield [
report_to.msg(err_helper(cmdset.errmessage, cmdid=cmdid))
for cmdset in cmdsets
if cmdset.key == "_CMDSET_ERROR"
]
if report_to:
yield [
report_to.msg(err_helper(cmdset.errmessage, cmdid=cmdid))
for cmdset in cmdsets
if cmdset.key == "_CMDSET_ERROR"
]
if cmdsets:
# faster to do tuple on list than to build tuple directly

View file

@ -671,6 +671,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
"INPUTDEBUG": validate_bool,
"FORCEDENDLINE": validate_bool,
"LOCALECHO": validate_bool,
"TRUECOLOR": validate_bool,
}
name = self.lhs.upper()
@ -794,12 +795,12 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS):
testing which colors your client support
Usage:
color ansi | xterm256
color ansi | xterm256 | truecolor
Prints a color map along with in-mud color codes to use to produce
them. It also tests what is supported in your client. Choices are
16-color ansi (supported in most muds) or the 256-color xterm256
standard. No checking is done to determine your client supports
16-color ansi (supported in most muds), the 256-color xterm256
standard, or truecolor. No checking is done to determine your client supports
color - if not you will see rubbish appear.
"""
@ -838,6 +839,18 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS):
)
return ftable
def make_hex_color_from_column(self, column_number):
r = 255 - column_number * 255 / 76
g = column_number * 510 / 76
b = column_number * 255 / 76
if g > 255:
g = 510 - g
return (
f"#{hex(round(r))[2:].zfill(2)}{hex(round(g))[2:].zfill(2)}{hex(round(b))[2:].zfill(2)}"
)
def func(self):
"""Show color tables"""
@ -916,9 +929,24 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS):
table = self.table_format(table)
string += "\n" + "\n".join("".join(row) for row in table)
self.msg(string)
elif self.args.startswith("t"):
# show abbreviated truecolor sample (16.7 million colors in truecolor)
string = ""
for i in range(76):
string += f"|[{self.make_hex_color_from_column(i)} |n"
string += (
"\n"
+ "some of the truecolor colors (if not all hues show, your client might not report that it can"
" handle trucolor.):"
)
self.msg(string)
else:
# malformed input
self.msg("Usage: color ansi||xterm256")
self.msg("Usage: color ansi || xterm256 || truecolor")
class CmdQuell(COMMAND_DEFAULT_CLASS):

View file

@ -5,11 +5,10 @@ Building and world design commands
import re
import typing
import evennia
from django.conf import settings
from django.core.paginator import Paginator
from django.db.models import Max, Min, Q
import evennia
from evennia import InterruptCommand
from evennia.commands.cmdhandler import generate_cmdset_providers, get_and_merge_cmdsets
from evennia.locks.lockhandler import LockException
@ -2831,8 +2830,12 @@ class CmdExamine(ObjManipCommand):
_FUNCPARSER = funcparser.FuncParser(settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES)
key, category, value = attr.db_key, attr.db_category, attr.value
valuetype = ""
if value is None and attr.strvalue is not None:
value = attr.strvalue
valuetype = " |B[strvalue]|n"
typ = self._get_attribute_value_type(value)
typ = f" |B[type: {typ}]|n" if typ else ""
typ = f" |B[type:{typ}]|n{valuetype}" if typ else f"{valuetype}"
value = utils.to_str(value)
value = _FUNCPARSER.parse(ansi_raw(value), escape=True)
return (
@ -2846,8 +2849,12 @@ class CmdExamine(ObjManipCommand):
_FUNCPARSER = funcparser.FuncParser(settings.FUNCPARSER_OUTGOING_MESSAGES_MODULES)
key, category, value = attr.db_key, attr.db_category, attr.value
valuetype = ""
if value is None and attr.strvalue is not None:
value = attr.strvalue
valuetype = " |B[strvalue]|n"
typ = self._get_attribute_value_type(value)
typ = f" |B[type: {typ}]|n" if typ else ""
typ = f" |B[type: {typ}]|n{valuetype}" if typ else f"{valuetype}"
value = utils.to_str(value)
value = _FUNCPARSER.parse(ansi_raw(value), escape=True)
value = utils.crop(value)
@ -3293,7 +3300,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
if "loc" in self.switches and not is_account and result.location:
string += (
f" (|wlocation|n: |g{result.location.get_display_name(caller)}"
f"{result.get_extra_display_name_info(caller)}|n)"
f"{result.location.get_extra_display_name_info(caller)}|n)"
)
else:
# Not an account/dbref search but a wider search; build a queryset.
@ -3439,7 +3446,7 @@ class ScriptEvMore(EvMore):
if (hasattr(script, "obj") and script.obj)
else "<Global>"
),
script.key,
script.db_key,
script.interval if script.interval > 0 else "--",
nextrep,
rept,
@ -3460,17 +3467,20 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
script[/start||stop] <obj> = [<script.path or script-key>]
Switches:
start - start/unpause an existing script's timer.
stop - stops an existing script's timer
pause - pause a script's timer
start - start/unpause an existing script's timer.
stop - stops an existing script's timer
pause - pause a script's timer
delete - deletes script. This will also stop the timer as needed
Examples:
script - list all scripts
script foo.bar.Script - create a new global Script
script/pause foo.bar.Script - pause global script
script scriptname|#dbref - examine named existing global script
script/delete #dbref[-#dbref] - delete script or range by #dbref
script - list all scripts
script key:foo.bar.Script - create a new global Script with typeclass
and key 'key'
script foo.bar.Script - create a new global Script with typeclass
(key taken from typeclass or auto-generated)
script/pause foo.bar.Script - pause global script
script typeclass|name|#dbref - examine named existing global script
script/delete #dbref[-#dbref] - delete script or range by #dbref
script myobj = - list all scripts on object
script myobj = foo.bar.Script - create and assign script to object
@ -3495,14 +3505,13 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
key = "@scripts"
aliases = ["@script"]
switch_options = ("create", "start", "stop", "pause", "delete")
switch_options = ("start", "stop", "pause", "delete")
locks = "cmd:perm(scripts) or perm(Builder)"
help_category = "System"
excluded_typeclass_paths = ["evennia.prototypes.prototypes.DbPrototype"]
switch_mapping = {
"create": "|gCreated|n",
"start": "|gStarted|n",
"stop": "|RStopped|n",
"pause": "|Paused|n",
@ -3511,21 +3520,32 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
# never show these script types
hide_script_paths = ("evennia.prototypes.prototypes.DbPrototype",)
def _search_script(self, args):
# test first if this is a script match
scripts = ScriptDB.objects.get_all_scripts(key=args).exclude(
db_typeclass_path__in=self.hide_script_paths
)
if scripts:
return scripts
# try typeclass path
def _search_script(self):
# see if a dbref was provided
if dbref(self.typeclass_query):
scripts = ScriptDB.objects.get_all_scripts(self.typeclass_query)
if scripts:
return scripts
self.caller.msg(f"No script found with dbref {self.typeclass_query}")
raise InterruptCommand
# if we provided a key, we must find an exact match, otherwise we're creating that anew
if self.key_query:
return ScriptDB.objects.filter(
db_key__iexact=self.key_query, db_typeclass_path__iendswith=self.typeclass_query
).exclude(db_typeclass_path__in=self.hide_script_paths)
# the more general case - try typeclass path
scripts = (
ScriptDB.objects.filter(db_typeclass_path__iendswith=args)
ScriptDB.objects.filter(db_typeclass_path__iendswith=self.typeclass_query)
.exclude(db_typeclass_path__in=self.hide_script_paths)
.order_by("id")
)
if scripts:
return scripts
args = self.typeclass_query
if "-" in args:
# may be a dbref-range
val1, val2 = (dbref(part.strip()) for part in args.split("-", 1))
@ -3538,6 +3558,29 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
if scripts:
return scripts
def parse(self):
super().parse()
if not self.args:
return
def _separate_key_typeclass(part):
part1, *part2 = part.split(":", 1)
return (part1, part2[0]) if part2 else (None, part1)
if self.rhs:
# arg with "="
self.obj_query = self.lhs
self.key_query, self.typeclass_query = _separate_key_typeclass(self.rhs)
elif self.rhs is not None:
# an empty "="
self.obj_query = self.lhs
self.key_query, self.typeclass_query = None, None
else:
# arg without "="
self.obj_query = None
self.key_query, self.typeclass_query = _separate_key_typeclass(self.args)
def func(self):
"""implement method"""
@ -3553,20 +3596,8 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
return
# find script or object to operate on
scripts, obj = None, None
if self.rhs:
obj_query = self.lhs
script_query = self.rhs
elif self.rhs is not None:
# an empty "="
obj_query = self.lhs
script_query = None
else:
obj_query = None
script_query = self.args
scripts = self._search_script(script_query) if script_query else None
objects = caller.search(obj_query, quiet=True) if obj_query else None
scripts = self._search_script() if self.typeclass_query else None
objects = caller.search(self.obj_query, quiet=True) if self.obj_query else None
obj = objects[0] if objects else None
if not self.switches:
@ -3575,7 +3606,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
# we have an object
if self.rhs:
# creation mode
if obj.scripts.add(self.rhs, autostart=True):
if obj.scripts.add(self.typeclass_query, key=self.key_query, autostart=True):
caller.msg(
f"Script |w{self.rhs}|n successfully added and "
f"started on {obj.get_display_name(caller)}."
@ -3603,7 +3634,9 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
else:
# create global script
try:
new_script = create.create_script(self.args)
new_script = create.create_script(
typeclass=self.typeclass_query, key=self.key_query
)
except ImportError:
logger.log_trace()
new_script = None
@ -3922,7 +3955,7 @@ class CmdTag(COMMAND_DEFAULT_CLASS):
key = "@tag"
aliases = ["@tags"]
options = ("search", "del")
switch_options = ("search", "del")
locks = "cmd:perm(tag) or perm(Builder)"
help_category = "Building"
arg_regex = r"(/\w+?(\s|$))|\s|$"

View file

@ -1413,12 +1413,13 @@ class CmdPage(COMMAND_DEFAULT_CLASS):
message = f"{caller.key} {message.strip(':').strip()}"
# create the persistent message object
target_perms = " or ".join([f"id({target.id})" for target in targets + [caller]])
create.create_message(
caller,
message,
receivers=targets,
locks=(
f"read:id({caller.id}) or perm(Admin);"
f"read:{target_perms} or perm(Admin);"
f"delete:id({caller.id}) or perm(Admin);"
f"edit:id({caller.id}) or perm(Admin)"
),
@ -1498,7 +1499,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS):
if lastpages:
string = f"Your latest pages:\n {lastpages}"
else:
string = "You haven't paged anyone yet."
string = "You haven't sent or received any pages yet."
self.msg(string)
return

View file

@ -14,13 +14,10 @@ main test suite started with
import datetime
from unittest.mock import MagicMock, Mock, patch
import evennia
from anything import Anything
from django.conf import settings
from django.test import override_settings
from parameterized import parameterized
from twisted.internet import task
import evennia
from evennia import (
DefaultCharacter,
DefaultExit,
@ -32,14 +29,7 @@ from evennia import (
from evennia.commands import cmdparser
from evennia.commands.cmdset import CmdSet
from evennia.commands.command import Command, InterruptCommand
from evennia.commands.default import (
account,
admin,
batchprocess,
building,
comms,
general,
)
from evennia.commands.default import account, admin, batchprocess, building, comms, general
from evennia.commands.default import help as help_module
from evennia.commands.default import syscommands, system, unloggedin
from evennia.commands.default.cmdset_character import CharacterCmdSet
@ -48,6 +38,8 @@ from evennia.prototypes import prototypes as protlib
from evennia.utils import create, gametime, utils
from evennia.utils.test_resources import BaseEvenniaCommandTest # noqa
from evennia.utils.test_resources import BaseEvenniaTest, EvenniaCommandTest
from parameterized import parameterized
from twisted.internet import task
# ------------------------------------------------------------
# Command testing
@ -446,7 +438,7 @@ class TestCmdTasks(BaseEvenniaCommandTest):
args = f"/pause {self.task.get_id()}"
wanted_msg = "Pause task 1 with completion date"
cmd_result = self.call(system.CmdTasks(), args, wanted_msg)
self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ")
self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ")
self.char1.execute_cmd("y")
self.assertTrue(self.task.paused)
self.task_handler.clock.advance(self.timedelay + 1)
@ -455,7 +447,7 @@ class TestCmdTasks(BaseEvenniaCommandTest):
self.assertTrue(self.task.exists())
wanted_msg = "Unpause task 1 with completion date"
cmd_result = self.call(system.CmdTasks(), args, wanted_msg)
self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ")
self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ")
self.char1.execute_cmd("y")
# verify task continues after unpause
self.task_handler.clock.advance(1)
@ -465,7 +457,7 @@ class TestCmdTasks(BaseEvenniaCommandTest):
args = f"/do_task {self.task.get_id()}"
wanted_msg = "Do_task task 1 with completion date"
cmd_result = self.call(system.CmdTasks(), args, wanted_msg)
self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ")
self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ")
self.char1.execute_cmd("y")
self.assertFalse(self.task.exists())
@ -473,7 +465,7 @@ class TestCmdTasks(BaseEvenniaCommandTest):
args = f"/remove {self.task.get_id()}"
wanted_msg = "Remove task 1 with completion date"
cmd_result = self.call(system.CmdTasks(), args, wanted_msg)
self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ")
self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ")
self.char1.execute_cmd("y")
self.assertFalse(self.task.exists())
@ -481,7 +473,7 @@ class TestCmdTasks(BaseEvenniaCommandTest):
args = f"/call {self.task.get_id()}"
wanted_msg = "Call task 1 with completion date"
cmd_result = self.call(system.CmdTasks(), args, wanted_msg)
self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ")
self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ")
self.char1.execute_cmd("y")
# make certain the task is still active
self.assertTrue(self.task.active())
@ -493,7 +485,7 @@ class TestCmdTasks(BaseEvenniaCommandTest):
args = f"/cancel {self.task.get_id()}"
wanted_msg = "Cancel task 1 with completion date"
cmd_result = self.call(system.CmdTasks(), args, wanted_msg)
self.assertRegex(cmd_result, " \(func_test_cmd_tasks\) ")
self.assertRegex(cmd_result, r" \(func_test_cmd_tasks\) ")
self.char1.execute_cmd("y")
self.assertTrue(self.task.exists())
self.assertFalse(self.task.active())
@ -797,7 +789,7 @@ class TestBuilding(BaseEvenniaCommandTest):
self.call(
building.CmdExamine(),
"self/test2",
"Attribute Char/test2 [category=None]:\n\nthis is a \$random() value.",
"Attribute Char/test2 [category=None]:\n\nthis is a \\$random() value.",
)
self.room1.scripts.add(self.script.__class__)
@ -805,6 +797,13 @@ class TestBuilding(BaseEvenniaCommandTest):
self.account.scripts.add(self.script.__class__)
self.call(building.CmdExamine(), "*TestAccount")
self.char1.attributes.add("strattr", "testval", strattr=True)
self.call(
building.CmdExamine(),
"self/strattr",
"Attribute Char/strattr [category=None] [strvalue]:\n\ntestval",
)
def test_set_obj_alias(self):
self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj")
self.call(
@ -1654,17 +1653,17 @@ class TestBuilding(BaseEvenniaCommandTest):
)
def test_script_multi_delete(self):
script1 = create.create_script()
script2 = create.create_script()
script3 = create.create_script()
script1 = create.create_script(key="script1")
script2 = create.create_script(key="script2")
script3 = create.create_script(key="script3")
self.call(
building.CmdScripts(),
"/delete #{}-#{}".format(script1.id, script3.id),
(
f"Global Script Deleted - #{script1.id} (evennia.scripts.scripts.DefaultScript)|"
f"Global Script Deleted - #{script2.id} (evennia.scripts.scripts.DefaultScript)|"
f"Global Script Deleted - #{script3.id} (evennia.scripts.scripts.DefaultScript)"
f"Global Script Deleted - script1 (evennia.scripts.scripts.DefaultScript)|"
f"Global Script Deleted - script2 (evennia.scripts.scripts.DefaultScript)|"
f"Global Script Deleted - script3 (evennia.scripts.scripts.DefaultScript)"
),
inputs=["y"],
)

View file

@ -18,7 +18,7 @@ from evennia.utils.utils import inherits_from, make_iter
class DefaultChannel(ChannelDB, metaclass=TypeclassBase):
"""
r"""
This is the base class for all Channel Comms. Inherit from this to
create different types of communication channels.
@ -35,7 +35,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase):
in front of every channel message; use `{channelmessage}` token to insert the
name of the current channel. Set to `None` if you want no prefix (or want to
handle it in a hook during message generation instead.
- `channel_msg_nick_pattern`(str, default `"{alias}\\s*?|{alias}\\s+?(?P<arg1>.+?)") -
- `channel_msg_nick_pattern`(str, default `"{alias}\s*?|{alias}\s+?(?P<arg1>.+?)") -
this is what used when a channel subscriber gets a channel nick assigned to this
channel. The nickhandler uses the pattern to pick out this channel's name from user
input. The `{alias}` token will get both the channel's key and any set/custom aliases
@ -51,6 +51,70 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase):
the account-level `channel` command is used. If you were to rename that command you must
tweak the output to something like `yourchannelcommandname {channelname} = $1`.
* Properties:
mutelist
banlist
wholist
* Working methods:
get_log_filename()
set_log_filename(filename)
has_connection(account) - check if the given account listens to this channel
connect(account) - connect account to this channel
disconnect(account) - disconnect account from channel
access(access_obj, access_type='listen', default=False) - check the
access on this channel (default access_type is listen)
create(key, creator=None, *args, **kwargs)
delete() - delete this channel
message_transform(msg, emit=False, prefix=True,
sender_strings=None, external=False) - called by
the comm system and triggers the hooks below
msg(msgobj, header=None, senders=None, sender_strings=None,
persistent=None, online=False, emit=False, external=False) - main
send method, builds and sends a new message to channel.
tempmsg(msg, header=None, senders=None) - wrapper for sending non-persistent
messages.
distribute_message(msg, online=False) - send a message to all
connected accounts on channel, optionally sending only
to accounts that are currently online (optimized for very large sends)
mute(subscriber, **kwargs)
unmute(subscriber, **kwargs)
ban(target, **kwargs)
unban(target, **kwargs)
add_user_channel_alias(user, alias, **kwargs)
remove_user_channel_alias(user, alias, **kwargs)
Useful hooks:
at_channel_creation() - called once, when the channel is created
basetype_setup()
at_init()
at_first_save()
channel_prefix() - how the channel should be
prefixed when returning to user. Returns a string
format_senders(senders) - should return how to display multiple
senders to a channel
pose_transform(msg, sender_string) - should detect if the
sender is posing, and if so, modify the string
format_external(msg, senders, emit=False) - format messages sent
from outside the game, like from IRC
format_message(msg, emit=False) - format the message body before
displaying it to the user. 'emit' generally means that the
message should not be displayed with the sender's name.
channel_prefix()
pre_join_channel(joiner) - if returning False, abort join
post_join_channel(joiner) - called right after successful join
pre_leave_channel(leaver) - if returning False, abort leave
post_leave_channel(leaver) - called right after successful leave
at_pre_msg(message, **kwargs)
at_post_msg(message, **kwargs)
web_get_admin_url()
web_get_create_url()
web_get_detail_url()
web_get_update_url()
web_get_delete_url()
"""
objects = ChannelManager()
@ -857,7 +921,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase):
# Used by Django Sites/Admin
get_absolute_url = web_get_detail_url
# TODO Evennia 1.0+ removed hooks. Remove in 1.1.
# TODO Evennia 1.0+ removed hooks. Remove in 5.0
def message_transform(self, *args, **kwargs):
raise RuntimeError(
"Channel.message_transform is no longer used in 1.0+. "

View file

@ -151,7 +151,7 @@ class Msg(SharedMemoryModel):
db_header = models.TextField("header", null=True, blank=True)
# the message body itself
db_message = models.TextField("message")
# send date
# send date (note - this is in UTC. Use the .date_created property to get it in local time)
db_date_created = models.DateTimeField(
"date sent", editable=False, auto_now_add=True, db_index=True
)
@ -194,6 +194,11 @@ class Msg(SharedMemoryModel):
def tags(self):
return TagHandler(self)
@property
def date_created(self):
"""Return the field in localized time based on settings.TIME_ZONE."""
return timezone.localtime(self.db_date_created)
# Wrapper properties to easily set database fields. These are
# @property decorators that allows to access these fields using
# normal python operations (without having to remember to save()

View file

@ -65,7 +65,7 @@ class S3Boto3StorageTests(S3Boto3TestCase):
"""
Test the _clean_name when the path has a trailing slash
"""
path = self.storage._clean_name("path\\to\\somewhere")
path = self.storage._clean_name(r"path\to\somewhere")
self.assertEqual(path, "path/to/somewhere")
def test_pickle_with_bucket(self):

View file

@ -16,14 +16,22 @@ class BaseComponent(type):
responsible for registering components to the listing.
"""
@classmethod
def __new__(cls, *args):
def __new__(cls, name, parents, attrs):
"""
Every class that uses this metaclass will be registered
as a component in the Component Listing using its name.
All of them require a unique name.
"""
new_type = super().__new__(*args)
attrs_name = attrs.get("name")
if attrs_name and not COMPONENT_LISTING.get(attrs_name):
new_fields = {}
attrs["_fields"] = new_fields
for parent in parents:
_parent_fields = getattr(parent, "_fields")
if _parent_fields:
new_fields.update(_parent_fields)
new_type = super().__new__(cls, name, parents, attrs)
if new_type.__base__ == object:
return new_type
@ -53,7 +61,7 @@ class Component(metaclass=BaseComponent):
name = ""
slot = None
_fields = {}
_fields: dict | None = None
def __init__(self, host=None):
assert self.name, "All Components must have a name"

View file

@ -85,6 +85,27 @@ class TestComponents(EvenniaTest):
self.assertTrue(self.char1.test_a)
self.assertTrue(self.char1.test_b)
def test_character_components_set_fields_properly(self):
test_a_fields = self.char1.test_a._fields
self.assertIn("my_int", test_a_fields)
self.assertIn("my_list", test_a_fields)
self.assertEqual(len(test_a_fields), 2)
test_b_fields = self.char1.test_b._fields
self.assertIn("my_int", test_b_fields)
self.assertIn("my_list", test_b_fields)
self.assertIn("default_tag", test_b_fields)
self.assertIn("single_tag", test_b_fields)
self.assertIn("multiple_tags", test_b_fields)
self.assertIn("default_single_tag", test_b_fields)
self.assertEqual(len(test_b_fields), 6)
test_ic_a_fields = self.char1.ic_a._fields
self.assertIn("my_int", test_ic_a_fields)
self.assertIn("my_list", test_ic_a_fields)
self.assertIn("my_other_int", test_ic_a_fields)
self.assertEqual(len(test_ic_a_fields), 3)
def test_inherited_typeclass_does_not_include_child_class_components(self):
char_with_c = create.create_object(
InheritedTCWithComponents, key="char_with_c", location=self.room1, home=self.room1

View file

@ -0,0 +1,130 @@
# In-Game Reporting System
Contrib by InspectorCaracal, 2024
This contrib provides an in-game reports system, handling bug reports, player reports, and idea submissions by default. It also supports adding your own types of reports, or removing any of the default report types.
Each type of report has its own command for submitting new reports, and an admin command is also provided for managing the reports through a menu.
## Installation
To install the reports contrib, just add the provided cmdset to your default AccountCmdSet:
```python
# in commands/default_cmdset.py
from evennia.contrib.base_systems.ingame_reports import ReportsCmdSet
class AccountCmdSet(default_cmds.AccountCmdSet):
# ...
def at_cmdset_creation(self):
# ...
self.add(ReportsCmdSet)
```
The contrib also has two optional settings: `INGAME_REPORT_TYPES` and `INGAME_REPORT_STATUS_TAGS`.
The `INGAME_REPORT_TYPES` setting is covered in detail in the section "Adding new types of reports".
The `INGAME_REPORT_STATUS_TAGS` setting is covered in the section "Managing reports".
## Usage
By default, the following report types are available:
* Bugs: Report bugs encountered during gameplay.
* Ideas: Submit suggestions for game improvement.
* Players: Report inappropriate player behavior.
Players can submit new reports through the command for each report type, and staff are given access to a report-management command and menu.
### Submitting reports
Players can submit reports using the following commands:
* `bug <text>` - Files a bug report. An optional target can be included - `bug <target> = <text>` - making it easier for devs/builders to track down issues.
* `report <player> = <text>` - Reports a player for inappropriate or rule-breaking behavior. *Requires* a target to be provided - it searches among accounts by default.
* `idea <text>` - Submits a general suggestion, with no target. It also has an alias of `ideas` which allows you to view all of your submitted ideas.
### Managing reports
The `manage reports` command allows staff to review and manage the various types of reports by launching a management menu.
This command will dynamically add aliases to itself based on the types of reports available, with each command string launching a menu for that particular report type. The aliases are built on the pattern `manage <report type>s` - by default, this means it makes `manage bugs`, `manage players`, and `manage ideas` available along with the default `manage reports`, and that e.g. `manage bugs` will launch the management menu for `bug`-type reports.
Aside from reading over existing reports, the menu allows you to change the status of any given report. By default, the contrib includes two different status tags: `in progress` and `closed`.
> Note: A report is created with no status tags, which is considered "open"
If you want a different set of statuses for your reports, you can define the `INGAME_REPORT_STATUS_TAGS` to your list of statuses.
**Example**
```python
# in server/conf/settings.py
# this will allow for the statuses of 'in progress', 'rejected', and 'completed', without the contrib-default of 'closed'
INGAME_REPORT_STATUS_TAGS = ('in progress', 'rejected', 'completed')
```
### Adding new types of reports
The contrib is designed to make adding new types of reports to the system as simple as possible, requiring only two steps:
1. Update your settings file to include an `INGAME_REPORT_TYPES` setting.
2. Create and add a new `ReportCmd` to your command set.
#### Update your settings
The contrib optionally references `INGAME_REPORT_TYPES` in your settings.py to see which types of reports can be managed. If you want to change the available report types, you'll need to define this setting.
```python
# in server/conf/settings.py
# this will include the contrib's report types as well as a custom 'complaint' report type
INGAME_REPORT_TYPES = ('bugs', 'ideas', 'players', 'complaints')
```
You can also use this setting to remove any of the contrib's report types - the contrib will respect this setting when building its cmdset with no additional steps.
```python
# in server/conf/settings.py
# this redefines the setting to not include 'ideas', so the ideas command and reports won't be available
INGAME_REPORT_TYPES = ('bugs', 'players')
```
#### Create a new ReportCmd
`ReportCmdBase` is a parent command class which comes with the main functionality for submitting reports. Creating a new reporting command is as simple as inheriting from this class and defining a couple of class attributes.
* `key` - This is the same as for any other command, setting the command's usable key. It also acts as the report type if that isn't explicitly set.
* `report_type` - The type of report this command is for (e.g. `player`). You only need to set it if you want a different string from the key.
* `report_locks` - The locks you want applied to the created reports. Defaults to `"read:pperm(Admin)"`
* `success_msg` - The string which is sent to players after submitting a report of this type. Defaults to `"Your report has been filed."`
* `require_target`: Set to `True` if your report type requires a target (e.g. player reports).
> Note: The contrib's own commands - `CmdBug`, `CmdIdea`, and `CmdReport` - are implemented the same way, so you can review them as examples.
Example:
```python
from evennia.contrib.base_systems.ingame_reports.reports import ReportCmdBase
class CmdCustomReport(ReportCmdBase):
"""
file a custom report
Usage:
customreport <message>
This is a custom report type.
"""
key = "customreport"
report_type = "custom"
success_message = "You have successfully filed a custom report."
```
Add this new command to your default cmdset to enable filing your new report type.

View file

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

View file

@ -0,0 +1,134 @@
"""
The report-management menu module.
"""
from django.conf import settings
from evennia.comms.models import Msg
from evennia.utils import logger
from evennia.utils.utils import crop, datetime_format, is_iter, iter_to_str
# the number of reports displayed on each page
_REPORTS_PER_PAGE = 10
_REPORT_STATUS_TAGS = ("closed", "in progress")
if hasattr(settings, "INGAME_REPORT_STATUS_TAGS"):
if is_iter(settings.INGAME_REPORT_STATUS_TAGS):
_REPORT_STATUS_TAGS = settings.INGAME_REPORT_STATUS_TAGS
else:
logger.log_warn(
"The 'INGAME_REPORT_STATUS_TAGS' setting must be an iterable of strings; falling back to defaults."
)
def menunode_list_reports(caller, raw_string, **kwargs):
"""Paginates and lists out reports for the provided hub"""
hub = caller.ndb._evmenu.hub
page = kwargs.get("page", 0)
start = page * _REPORTS_PER_PAGE
end = start + _REPORTS_PER_PAGE
report_slice = report_list[start:end]
hub_name = " ".join(hub.key.split("_")).title()
text = f"Managing {hub_name}"
if not (report_list := getattr(caller.ndb._evmenu, "report_list", None)):
report_list = Msg.objects.search_message(receiver=hub).order_by("db_date_created")
caller.ndb._evmenu.report_list = report_list
# allow the menu to filter print-outs by status
if kwargs.get("status"):
new_report_list = report_list.filter(db_tags__db_key=kwargs["status"])
# we don't filter reports if there are no reports under that filter
if not new_report_list:
text = f"(No {kwargs['status']} reports)\n{text}"
else:
report_list = new_report_list
text = f"Managing {kwargs['status']} {hub_name}"
else:
report_list = report_list.exclude(db_tags__db_key="closed")
# filter by lock access
report_list = [msg for msg in report_list if msg.access(caller, "read")]
# this will catch both no reports filed and no permissions
if not report_list:
return "There is nothing there for you to manage.", {}
options = [
{
"desc": f"{datetime_format(report.date_created)} - {crop(report.message, 50)}",
"goto": ("menunode_manage_report", {"report": report}),
}
for report in report_slice
]
options.append(
{
"key": ("|uF|nilter by status", "filter", "status", "f"),
"goto": "menunode_choose_filter",
}
)
if start > 0:
options.append(
{
"key": (f"|uP|nrevious {_REPORTS_PER_PAGE}", "previous", "prev", "p"),
"goto": (
"menunode_list_reports",
{"page": max(start - _REPORTS_PER_PAGE, 0) // _REPORTS_PER_PAGE},
),
}
)
if end < len(report_list):
options.append(
{
"key": (f"|uN|next {_REPORTS_PER_PAGE}", "next", "n"),
"goto": (
"menunode_list_reports",
{"page": (start + _REPORTS_PER_PAGE) // _REPORTS_PER_PAGE},
),
}
)
return text, options
def menunode_choose_filter(caller, raw_string, **kwargs):
"""apply or clear a status filter to the main report view"""
text = "View which reports?"
# options for all the possible statuses
options = [
{"desc": status, "goto": ("menunode_list_reports", {"status": status})}
for status in _REPORT_STATUS_TAGS
]
# no filter
options.append({"desc": "All open reports", "goto": "menunode_list_reports"})
return text, options
def _report_toggle_tag(caller, raw_string, report, tag, **kwargs):
"""goto callable to toggle a status tag on or off"""
if tag in report.tags.all():
report.tags.remove(tag)
else:
report.tags.add(tag)
return ("menunode_manage_report", {"report": report})
def menunode_manage_report(caller, raw_string, report, **kwargs):
"""
Read out the full report text and targets, and allow for changing the report's status.
"""
receivers = [r for r in report.receivers if r != caller.ndb._evmenu.hub]
text = f"""\
{report.message}
{datetime_format(report.date_created)} by {iter_to_str(report.senders)}{' about '+iter_to_str(r.get_display_name(caller) for r in receivers) if receivers else ''}
{iter_to_str(report.tags.all())}"""
options = []
for tag in _REPORT_STATUS_TAGS:
options.append(
{
"desc": f"{'Unmark' if tag in report.tags.all() else 'Mark' } as {tag}",
"goto": (_report_toggle_tag, {"report": report, "tag": tag}),
}
)
options.append({"desc": f"Manage another report", "goto": "menunode_list_reports"})
return text, options

View file

@ -0,0 +1,315 @@
"""
In-Game Reporting System
This contrib provides an in-game reporting system, with player-facing commands and a staff
management interface.
# Installation
To install, just add the provided cmdset to your default AccountCmdSet:
# in commands/default_cmdset.py
from evennia.contrib.base_systems.ingame_reports import ReportsCmdSet
class AccountCmdSet(default_cmds.AccountCmdSet):
# ...
def at_cmdset_creation(self):
# ...
self.add(ReportsCmdSet)
# Features
The contrib provides three commands by default and their associated report types: `CmdBug`, `CmdIdea`,
and `CmdReport` (which is for reporting other players).
The `ReportCmdBase` class holds most of the functionality for creating new reports, providing a
convenient parent class for adding your own categories of reports.
The contrib can be further configured through two settings, `INGAME_REPORT_TYPES` and `INGAME_REPORT_STATUS_TAGS`
"""
from django.conf import settings
from evennia import CmdSet
from evennia.utils import create, evmenu, logger, search
from evennia.utils.utils import class_from_module, datetime_format, is_iter, iter_to_str
from evennia.commands.default.muxcommand import MuxCommand
from evennia.comms.models import Msg
from . import menu
_DEFAULT_COMMAND_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
# the default report types
_REPORT_TYPES = ("bugs", "ideas", "players")
if hasattr(settings, "INGAME_REPORT_TYPES"):
if is_iter(settings.INGAME_REPORT_TYPES):
_REPORT_TYPES = settings.INGAME_REPORT_TYPES
else:
logger.log_warn(
"The 'INGAME_REPORT_TYPES' setting must be an iterable of strings; falling back to defaults."
)
def _get_report_hub(report_type):
"""
A helper function to retrieve the global script which acts as the hub for a given report type.
Args:
report_type (str): The category of reports to retrieve the script for.
Returns:
Script or None: The global script, or None if it couldn't be retrieved or created
Note: If no matching valid script exists, this function will attempt to create it.
"""
hub_key = f"{report_type}_reports"
# NOTE: due to a regression in GLOBAL_SCRIPTS, we use search_script instead of the container
if not (hub := search.search_script(hub_key)):
hub = create.create_script(key=hub_key)
return hub or None
class CmdManageReports(_DEFAULT_COMMAND_CLASS):
"""
manage the various reports
Usage:
manage [report type]
Available report types:
bugs
ideas
players
Initializes a menu for reviewing and changing the status of current reports.
"""
key = "manage reports"
aliases = tuple(f"manage {report_type}" for report_type in _REPORT_TYPES)
locks = "cmd:pperm(Admin)"
def get_help(self):
"""Returns a help string containing the configured available report types"""
report_types = iter_to_str("\n ".join(_REPORT_TYPES))
helptext = f"""\
manage the various reports
Usage:
manage [report type]
Available report types:
{report_types}
Initializes a menu for reviewing and changing the status of current reports.
"""
return helptext
def func(self):
report_type = self.cmdstring.split()[-1]
if report_type == "reports":
report_type = "players"
if report_type not in _REPORT_TYPES:
self.msg(f"'{report_type}' is not a valid report category.")
return
# remove the trailing s, just so everything reads nicer
report_type = report_type[:-1]
hub = _get_report_hub(report_type)
if not hub:
self.msg("You cannot manage that.")
evmenu.EvMenu(
self.account, menu, startnode="menunode_list_reports", hub=hub, persistent=True
)
class ReportCmdBase(_DEFAULT_COMMAND_CLASS):
"""
A parent class for creating report commands. This help text may be displayed if
your command's help text is not properly configured.
"""
help_category = "reports"
# defines what locks the reports generated by this command will have set
report_locks = "read:pperm(Admin)"
# determines if the report can be filed without a target
require_target = False
# the message sent to the reporter after the report has been created
success_msg = "Your report has been filed."
# the report type for this command, if different from the key
report_type = None
def at_pre_cmd(self):
"""validate that the needed hub script exists - if not, cancel the command"""
hub = _get_report_hub(self.report_type or self.key)
if not hub:
# a return value of True from `at_pre_cmd` cancels the command
return True
self.hub = hub
return super().at_pre_cmd()
def parse(self):
"""
Parse the target and message out of the arguments.
Override if you want different syntax, but make sure to assign `report_message` and `target_str`.
"""
# do the base MuxCommand parsing first
super().parse()
# split out the report message and target strings
if self.rhs:
self.report_message = self.rhs
self.target_str = self.lhs
else:
self.report_message = self.lhs
self.target_str = ""
def target_search(self, searchterm, **kwargs):
"""
Search for a target that matches the given search term. By default, does a normal search via the
caller - a local object search for a Character, or an account search for an Account.
Args:
searchterm (str) - The string to search for
Returns:
result (Object, Account, or None) - the result of the search
"""
return self.caller.search(searchterm)
def create_report(self, *args, **kwargs):
"""
Creates the report. By default, this creates a Msg with any provided args and kwargs.
Returns:
success (bool) - True if the report was created successfully, or False if there was an issue.
"""
return create.create_message(*args, **kwargs)
def func(self):
hub = self.hub
if not self.args:
self.msg("You must provide a message.")
return
target = None
if self.target_str:
target = self.target_search(self.target_str)
if not target:
return
elif self.require_target:
self.msg("You must include a target.")
return
receivers = [hub]
if target:
receivers.append(target)
if self.create_report(
self.account, self.report_message, receivers=receivers, locks=self.report_locks, tags=["report"]
):
# the report Msg was successfully created
self.msg(self.success_msg)
else:
# something went wrong
self.msg(
"Something went wrong creating your report. Please try again later or contact staff directly."
)
# The commands below are the usable reporting commands
class CmdBug(ReportCmdBase):
"""
file a bug
Usage:
bug [<target> =] <message>
Note: If a specific object, location or character is bugged, please target it for the report.
Examples:
bug hammer = This doesn't work as a crafting tool but it should
bug every time I go through a door I get the message twice
"""
key = "bug"
report_locks = "read:pperm(Developer)"
class CmdReport(ReportCmdBase):
"""
report a player
Usage:
report <player> = <message>
All player reports will be reviewed.
"""
key = "report"
report_type = "player"
require_target = True
account_caller = True
class CmdIdea(ReportCmdBase):
"""
submit a suggestion
Usage:
ideas
idea <message>
Example:
idea wouldn't it be cool if we had horses we could ride
"""
key = "idea"
aliases = ("ideas",)
report_locks = "read:pperm(Builder)"
success_msg = "Thank you for your suggestion!"
def func(self):
# we add an extra feature to this command, allowing you to see all your submitted ideas
if self.cmdstring == "ideas":
# list your ideas
if (
ideas := Msg.objects.search_message(sender=self.account, receiver=self.hub)
.order_by("-db_date_created")
.exclude(db_tags__db_key="closed")
):
# todo: use a paginated menu
self.msg(
"Ideas you've submitted:\n "
+ "\n ".join(
f"|w{item.message}|n (submitted {datetime_format(item.date_created)})"
for item in ideas
)
)
else:
self.msg("You have no open suggestions.")
return
# proceed to do the normal report-command functionality
super().func()
class ReportsCmdSet(CmdSet):
key = "Reports CmdSet"
def at_cmdset_creation(self):
super().at_cmdset_creation()
if "bugs" in _REPORT_TYPES:
self.add(CmdBug)
if "ideas" in _REPORT_TYPES:
self.add(CmdIdea)
if "players" in _REPORT_TYPES:
self.add(CmdReport)
self.add(CmdManageReports)

View file

@ -0,0 +1,86 @@
from unittest.mock import Mock, patch, MagicMock
from evennia.utils import create
from evennia.comms.models import TempMsg
from evennia.utils.test_resources import EvenniaCommandTest
from . import menu, reports
class _MockQuerySet(list):
def order_by(self, *args, **kwargs):
return self
def exclude(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def _mock_pre(cmdobj):
"""helper to mock at_pre_cmd"""
cmdobj.hub = Mock()
class TestReportCommands(EvenniaCommandTest):
@patch.object(create, "create_message", new=MagicMock())
def test_report_cmd_base(self):
"""verify that the base command functionality works"""
cmd = reports.ReportCmdBase
# avoid test side-effects
with patch.object(cmd, "at_pre_cmd", new=_mock_pre) as _:
# no arguments
self.call(cmd(), "", "You must provide a message.")
# arguments, no target, no target required
self.call(cmd(), "test", "Your report has been filed.")
# arguments, custom success message
custom_success = "custom success message"
cmd.success_msg = custom_success
self.call(cmd(), "test", custom_success)
# arguments, no target, target required
cmd.require_target = True
self.call(cmd(), "test", "You must include a target.")
@patch.object(create, "create_message", new=MagicMock())
@patch.object(reports, "datetime_format", return_value="now")
def test_ideas_list(self, mock_datetime_format):
cmd = reports.CmdIdea
fake_ideas = _MockQuerySet([TempMsg(message=f"idea {i+1}") for i in range(3)])
expected = """\
Ideas you've submitted:
idea 1 (submitted now)
idea 2 (submitted now)
idea 3 (submitted now)
"""
with patch.object(cmd, "at_pre_cmd", new=_mock_pre) as _:
# submitting an idea
self.call(cmd(), "", "You must provide a message.")
# arguments, no target, no target required
self.call(cmd(), "test", "Thank you for your suggestion!")
# viewing your submitted ideas
with patch.object(reports.Msg.objects, "search_message", return_value=fake_ideas):
self.call(cmd(), "", cmdstring="ideas", msg=expected)
@patch.object(reports.evmenu, "EvMenu")
def test_cmd_manage_reports(self, evmenu_mock):
cmd = reports.CmdManageReports
hub = Mock()
with patch.object(reports, "_get_report_hub", return_value=hub) as _:
# invalid report type fails
self.call(
cmd(), "", cmdstring="manage custom", msg="'custom' is not a valid report category."
)
# verify valid type triggers evmenu
self.call(cmd(), "", cmdstring="manage bugs")
evmenu_mock.assert_called_once_with(
self.account,
menu,
startnode="menunode_list_reports",
hub=hub,
persistent=True,
)

View file

@ -0,0 +1,245 @@
# Achievements
A simple, but reasonably comprehensive, system for tracking achievements. Achievements are defined using ordinary Python dicts, reminiscent of the core prototypes system, and while it's expected you'll use it only on Characters or Accounts, they can be tracked for any typeclassed object.
The contrib provides several functions for tracking and accessing achievements, as well as a basic in-game command for viewing achievement status.
## Installation
This contrib requires creation one or more module files containing your achievement data, which you then add to your settings file to make them available.
> See the section below on "Creating Achievements" for what to put in this module.
```python
# in server/conf/settings.py
ACHIEVEMENT_CONTRIB_MODULES = ["world.achievements"]
```
To allow players to check their achievements, you'll also want to add the `achievements` command to your default Character and/or Account command sets.
```python
# in commands/default_cmdsets.py
from evennia.contrib.game_systems.achievements.achievements import CmdAchieve
class CharacterCmdSet(default_cmds.CharacterCmdSet):
key = "DefaultCharacter"
def at_cmdset_creation(self):
# ...
self.add(CmdAchieve)
```
**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
# in settings.py
ACHIEVEMENT_CONTRIB_ATTRIBUTE = ("progress_data", "achievements")
```
## Creating achievements
An achievement is represented by a simple python dictionary defined at the module level in your achievements module(s).
Each achievement requires certain specific keys to be defined to work properly, along with several optional keys that you can use to override defaults.
> Note: Any additional keys not described here are included in the achievement data when you access those acheivements through the contrib, so you can easily add your own extended features.
#### Required keys
- **name** (str): The searchable name for the achievement. Doesn't need to be unique.
- **category** (str): The category, or general type, of condition which can progress this achievement. Usually this will be a player action or result. e.g. you would use a category of "defeat" on an achievement for killing 10 rats.
- **tracking** (str or list): The specific subset of condition which can progress this achievement. e.g. you would use a tracking value of "rat" on an achievement for killing 10 rats. An achievement can also track multiple things, for example killing 10 rats or snakes. For that situation, assign a list of all the values to check against, e.g. `["rat", "snake"]`
#### Optional keys
- **key** (str): *Default value if unset: the variable name.* The unique, case-insensitive key identifying this achievement.
> Note: If any achievements have the same unique key, only *one* will be loaded. It is case-insensitive, but punctuation is respected - "ten_rats", "Ten_Rats" and "TEN_RATS" will conflict, but "ten_rats" and "ten rats" will not.
- **desc** (str): A longer description of the achievement. Common uses for this would be flavor text or hints on how to complete it.
- **count** (int): *Default value if unset: 1* The number of tallies this achievement's requirements need to build up in order to complete the achievement. e.g. killing 10 rats would have a `"count"` value of `10`. For achievements using the "separate" tracking type, *each* item being tracked must tally up to this number to be completed.
- **tracking_type** (str): *Default value if unset: `"sum"`* There are two valid tracking types: "sum" (which is the default) and "separate". `"sum"` will increment a single counter every time any of the tracked items match. `"separate"` will have a counter for each individual item in the tracked items. ("See the Example Achievements" section for a demonstration of the difference.)
- **prereqs** (str or list): The *keys* of any achievements which must be completed before this achievement can start tracking progress.
### Example achievements
A simple achievement which you can get just for logging in the first time. This achievement has no prerequisites and it only needs to be fulfilled once to complete.
```python
# This achievement has the unique key of "first_login_achieve"
FIRST_LOGIN_ACHIEVE = {
"name": "Welcome!", # the searchable, player-friendly display name
"desc": "We're glad to have you here.", # the longer description
"category": "login", # the type of action this tracks
"tracking": "first", # the specific login action
}
```
An achievement for killing a total of 10 rats, and another for killing 10 *dire* rats which requires the "kill 10 rats" achievement to be completed first. The dire rats achievement won't begin tracking *any* progress until the first achievement is completed.
```python
# This achievement has the unique key of "ten_rats" instead of "achieve_ten_rats"
ACHIEVE_TEN_RATS = {
"key": "ten_rats",
"name": "The Usual",
"desc": "Why do all these inns have rat problems?",
"category": "defeat",
"tracking": "rat",
"count": 10,
}
ACHIEVE_DIRE_RATS = {
"name": "Once More, But Bigger",
"desc": "Somehow, normal rats just aren't enough any more.",
"category": "defeat",
"tracking": "dire rat",
"count": 10,
"prereqs": "ACHIEVE_TEN_RATS",
}
```
An achievement for buying a total of 5 of apples, oranges, *or* pears. The "sum" tracking types means that all items are tallied together - so it can be completed by buying 5 apples, or 5 pears, or 3 apples, 1 orange and 1 pear, or any other combination of those three fruits that totals to 5.
```python
FRUIT_FAN_ACHIEVEMENT = {
"name": "A Fan of Fruit", # note, there is no desc here - that's allowed!
"category": "buy",
"tracking": ("apple", "orange", "pear"),
"count": 5,
"tracking_type": "sum", # this is the default, but it's included here for clarity
}
```
An achievement for buying 5 *each* of apples, oranges, and pears. The "separate" tracking type means that each of the tracked items is tallied independently of the other items - so you will need 5 apples, 5 oranges, and 5 pears.
```python
FRUIT_BASKET_ACHIEVEMENT = {
"name": "Fruit Basket",
"desc": "One kind of fruit just isn't enough.",
"category": "buy",
"tracking": ("apple", "orange", "pear"),
"count": 5,
"tracking_type": "separate",
}
```
## Usage
The two main things you'll need to do in order to use the achievements contrib in your game are **tracking achievements** and **getting achievement information**. The first is done with the function `track_achievements`; the second can be done with `search_achievement` or `get_achievement`.
### Tracking achievements
#### `track_achievements`
In any actions or functions in your game's mechanics which you might want to track in an achievement, add a call to `track_achievements` to update that player's achievement progress.
Using the "kill 10 rats" example achievement from earlier, you might have some code that triggers when a character is defeated: for the sake of example, we'll pretend we have an `at_defeated` method on the base Object class that gets called when the Object is defeated.
Adding achievement tracking to it could then look something like this:
```python
# in typeclasses/objects.py
from contrib.game_systems.achievements import track_achievements
class Object(ObjectParent, DefaultObject):
# ....
def at_defeated(self, victor):
"""called when this object is defeated in combat"""
# we'll use the "mob_type" tag-category as the tracked info
# this way we can have rats named "black rat" and "brown rat" that are both rats
mob_type = self.tags.get(category="mob_type")
# only one mob was defeated, so we include a count of 1
track_achievements(victor, category="defeated", tracking=mob_type, count=1)
```
If a player defeats something tagged `rat` with a tag category of `mob_type`, it'd now count towards the rat-killing achievement.
You can also have the tracking information hard-coded into your game, for special or unique situations. The achievement described earlier, `FIRST_LOGIN_ACHIEVE`, for example, would be tracked like this:
```py
# in typeclasses/accounts.py
from contrib.game_systems.achievements import track_achievements
class Account(DefaultAccount):
# ...
def at_first_login(self, **kwargs):
# this function is only called on the first time the account logs in
# so we already know and can just tell the tracker that this is the first
track_achievements(self, category="login", tracking="first")
```
The `track_achievements` function does also return a value: an iterable of keys for any achievements which were newly completed by that update. You can ignore this value, or you can use it to e.g. send a message to the player with their latest achievements.
### Getting achievements
The main method for getting a specific achievement's information is `get_achievement`, which takes an already-known achievement key and returns the data for that one achievement.
For handling more variable and player-friendly input, however, there is also `search_achievement`, which does partial matching on not just the keys, but also the display names and descriptions for the achievements.
#### `get_achievement`
A utility function for retrieving a specific achievement's data from the achievement's unique key. It cannot be used for searching, but if you already have an achievement's key - for example, from the results of `track_achievements` - you can retrieve its data this way.
#### Example:
```py
from evennia.contrib.game_systems.achievements import get_achievement
def toast(achiever, completed_list):
if completed_list:
# `completed_data` will be a list of dictionaries - unrecognized keys return empty dictionaries
completed_data = [get_achievement(key) for key in args]
names = [data.get('name') for data in completed]
achiever.msg(f"|wAchievement Get!|n {iter_to_str(name for name in names if name)}"))
```
#### `search_achievement`
A utility function for searching achievements by name or description. It handles partial matching and returns a dictionary of matching achievements. The provided `achievement` command for in-game uses this function to find matching achievements from user inputs.
#### Example:
The first example does a search for "fruit", which returns the fruit medley achievement as it contains "fruit" in the key and name.
The second example searches for "usual", which returns the ten rats achievement due to its display name.
```py
>>> from evennia.contrib.game_systems.achievements import search_achievement
>>> search_achievement("fruit")
{'fruit_basket_achievement': {'name': 'Fruit Basket', 'desc': "One kind of fruit just isn't enough.", 'category': 'buy', 'tracking': ('apple', 'orange', 'pear'), 'count': 5, 'tracking_type': 'separate'}}
>>> search_achievement("usual")
{'ten_rats': {'key': 'ten_rats', 'name': 'The Usual', 'desc': 'Why do all these inns have rat problems?', 'category': 'defeat', 'tracking': 'rat', 'count': 10}}
```
### The `achievements` command
The contrib's provided command, `CmdAchieve`, aims to be usable as-is, with multiple switches to filter achievements by various progress statuses and the ability to search by achievement names.
To make it easier to customize for your own game (e.g. displaying some of that extra achievement data you might have added), the format and style code is split out from the command logic into the `format_achievement` method and the `template` attribute, both on `CmdAchieve`
#### Example output
```
> achievements
The Usual
Why do all these inns have rat problems?
70% complete
A Fan of Fruit
Not Started
```
```
> achievements/progress
The Usual
Why do all these inns have rat problems?
70% complete
```
```
> achievements/done
There are no matching achievements.
```

View file

@ -0,0 +1,8 @@
from .achievements import (
CmdAchieve,
all_achievements,
get_achievement,
get_achievement_progress,
search_achievement,
track_achievements,
)

View file

@ -0,0 +1,410 @@
"""
Achievements
This provides a system for adding and tracking player achievements in your game.
Achievements are defined as dicts, loosely similar to the prototypes system.
An example of an achievement dict:
ACHIEVE_DIRE_RATS = {
"name": "Once More, But Bigger",
"desc": "Somehow, normal rats just aren't enough any more.",
"category": "defeat",
"tracking": "dire rat",
"count": 10,
"prereqs": "ACHIEVE_TEN_RATS",
}
The recognized fields for an achievement are:
- key (str): The unique, case-insensitive key identifying this achievement. The variable name is
used by default.
- name (str): The name of the achievement. This is not the key and does not need to be unique.
- desc (str): The longer description of the achievement. Common uses for this would be flavor text
or hints on how to complete it.
- category (str): The category of conditions which this achievement tracks. It will most likely be
an action and you will most likely specify it based on where you're checking from.
e.g. killing 10 rats might have a category of "defeat", which you'd then check from your code
that runs when a player defeats something.
- tracking (str or list): The specific condition this achievement tracks. e.g. for the above example of
10 rats, the tracking field would be "rat".
- tracking_type: The options here are "sum" and "separate". "sum" means that matching any tracked
item will increase the total. "separate" means all tracked items are counted individually.
This is only useful when tracking is a list. The default is "sum".
- count (int): The total tallies the tracked item needs for this to be completed. e.g. for the rats
example, it would be 10. The default is 1
- prereqs (str or list): An optional achievement key or list of keys that must be completed before
this achievement is available.
To add achievement tracking, put `track_achievements` in your relevant hooks.
Example:
def at_defeated(self, victor):
# called when this object is defeated in combat
# we'll use the "mob_type" tag category as the tracked information for achievements
mob_type = self.tags.get(category="mob_type")
track_achievements(victor, category="defeated", tracking=mob_type, count=1)
"""
from collections import Counter
from django.conf import settings
from evennia.commands.default.muxcommand import MuxCommand
from evennia.utils import logger
from evennia.utils.evmore import EvMore
from evennia.utils.utils import (
all_from_module,
is_iter,
make_iter,
string_partial_matching,
)
# this is either a string of the attribute name, or a tuple of strings of the attribute name and category
_ACHIEVEMENT_ATTR = make_iter(getattr(settings, "ACHIEVEMENT_CONTRIB_ATTRIBUTE", "achievements"))
_ATTR_KEY = _ACHIEVEMENT_ATTR[0]
_ATTR_CAT = _ACHIEVEMENT_ATTR[1] if len(_ACHIEVEMENT_ATTR) > 1 else None
# load the achievements data
_ACHIEVEMENT_DATA = {}
if modules := getattr(settings, "ACHIEVEMENT_CONTRIB_MODULES", None):
for module_path in make_iter(modules):
module_achieves = {
val.get("key", key).lower(): val
for key, val in all_from_module(module_path).items()
if isinstance(val, dict) and not key.startswith("_")
}
if any(key in _ACHIEVEMENT_DATA for key in module_achieves.keys()):
logger.log_warn(
"There are conflicting achievement keys! Only the last achievement registered to the key will be recognized."
)
_ACHIEVEMENT_DATA |= module_achieves
else:
logger.log_warn("No achievement modules have been added to settings.")
def _read_player_data(achiever):
"""
helper function to get a player's achievement data from the database.
Args:
achiever (Object or Account): The achieving entity
Returns:
dict: The deserialized achievement data.
"""
if data := achiever.attributes.get(_ATTR_KEY, default={}, category=_ATTR_CAT):
# detach the data from the db
data = data.deserialize()
# return the data
return data or {}
def _write_player_data(achiever, data):
"""
helper function to write a player's achievement data to the database.
Args:
achiever (Object or Account): The achieving entity
data (dict): The full achievement data for this entity.
Returns:
None
Notes:
This function will overwrite any existing achievement data for the entity.
"""
achiever.attributes.add(_ATTR_KEY, data, category=_ATTR_CAT)
def track_achievements(achiever, category=None, tracking=None, count=1, **kwargs):
"""
Update and check achievement progress.
Args:
achiever (Account or Character): The entity that's collecting achievement progress.
Keyword args:
category (str or None): The category of an achievement.
tracking (str or None): The specific item being tracked in the achievement.
Returns:
tuple: The keys of any achievements that were completed by this update.
"""
if not _ACHIEVEMENT_DATA:
# there are no achievements available, there's nothing to do
return tuple()
# get the achiever's progress data
progress_data = _read_player_data(achiever)
# filter all of the achievements down to the relevant ones
relevant_achievements = (
(key, val)
for key, val in _ACHIEVEMENT_DATA.items()
if (not category or category in make_iter(val.get("category", []))) # filter by category
and (
not tracking
or not val.get("tracking")
or tracking in make_iter(val.get("tracking", []))
) # filter by tracked item
and not progress_data.get(key, {}).get("completed") # filter by completion status
and all(
progress_data.get(prereq, {}).get("completed")
for prereq in make_iter(val.get("prereqs", []))
) # filter by prereqs
)
completed = []
# loop through all the relevant achievements and update the progress data
for achieve_key, achieve_data in relevant_achievements:
if target_count := achieve_data.get("count", 1):
# check if we need to track things individually or not
separate_totals = achieve_data.get("tracking_type", "sum") == "separate"
if achieve_key not in progress_data:
progress_data[achieve_key] = {}
if separate_totals and is_iter(achieve_data.get("tracking")):
# do the special handling for tallying totals separately
i = achieve_data["tracking"].index(tracking)
if "progress" not in progress_data[achieve_key]:
# initialize the item counts
progress_data[achieve_key]["progress"] = [
0 for _ in range(len(achieve_data["tracking"]))
]
# increment the matching index count
progress_data[achieve_key]["progress"][i] += count
# have we reached the target on all items? if so, we've completed it
if min(progress_data[achieve_key]["progress"]) >= target_count:
completed.append(achieve_key)
else:
progress_count = progress_data[achieve_key].get("progress", 0)
# update the achievement data
progress_data[achieve_key]["progress"] = progress_count + count
# have we reached the target? if so, we've completed it
if progress_data[achieve_key]["progress"] >= target_count:
completed.append(achieve_key)
else:
# no count means you just need to do the thing to complete it
completed.append(achieve_key)
for key in completed:
if key not in progress_data:
progress_data[key] = {}
progress_data[key]["completed"] = True
# write the updated progress back to the achievement attribute
_write_player_data(achiever, progress_data)
# return all the achievements we just completed
return tuple(completed)
def get_achievement(key):
"""
Get an achievement by its key.
Args:
key (str): The achievement key. This is the variable name the achievement dict is assigned to.
Returns:
dict or None: The achievement data, or None if it doesn't exist
"""
if not _ACHIEVEMENT_DATA:
# there are no achievements available, there's nothing to do
return None
if data := _ACHIEVEMENT_DATA.get(key.lower()):
return dict(data)
return None
def all_achievements():
"""
Returns a dict of all achievements in the game.
Returns:
dict
"""
# we do this to mitigate accidental in-memory modification of reference data
return dict((key, dict(val)) for key, val in _ACHIEVEMENT_DATA.items())
def get_achievement_progress(achiever, key):
"""
Retrieve the progress data on a particular achievement for a particular achiever.
Args:
achiever (Account or Character): The entity tracking achievement progress.
key (str): The achievement key
Returns:
dict: The progress data
"""
if progress_data := _read_player_data(achiever):
# get the specific key's data
return progress_data.get(key, {})
else:
# just return an empty dict
return {}
def search_achievement(search_term):
"""
Search for an achievement containing the search term. If no matches are found in the achievement names, it searches
in the achievement descriptions.
Args:
search_term (str): The string to search for.
Returns:
dict: A dict of key:data pairs of matching achievements.
"""
if not _ACHIEVEMENT_DATA:
# there are no achievements available, there's nothing to do
return {}
keys, names, descs = zip(
*((key, val["name"], val["desc"]) for key, val in _ACHIEVEMENT_DATA.items())
)
indices = string_partial_matching(names, search_term)
if not indices:
indices = string_partial_matching(descs, search_term)
return dict((keys[i], dict(_ACHIEVEMENT_DATA[keys[i]])) for i in indices)
class CmdAchieve(MuxCommand):
"""
view achievements
Usage:
achievements[/switches] [args]
Switches:
all View all achievements, including locked ones.
completed View achievements you've completed.
progress View achievements you have partially completed
Check your achievement statuses or browse the list. Providing a command argument
will search all your currently unlocked achievements for matches, and the switches
will filter the list to something other than "all unlocked". Combining a command
argument with a switch will search only in that list.
Examples:
achievements apples
achievements/all
achievements/progress rats
"""
key = "achievements"
aliases = (
"achievement",
"achieve",
"achieves",
)
switch_options = ("progress", "completed", "done", "all")
template = """\
|w{name}|n
{desc}
{status}
""".rstrip()
def format_achievement(self, achievement_data):
"""
Formats the raw achievement data for display.
Args:
achievement_data (dict): The data to format.
Returns
str: The display string to be sent to the caller.
"""
if achievement_data.get("completed"):
# it's done!
status = "|gCompleted!|n"
elif not achievement_data.get("progress"):
status = "|yNot Started|n"
else:
count = achievement_data.get("count", 1)
# is this achievement tracking items separately?
if is_iter(achievement_data["progress"]):
# we'll display progress as how many items have been completed
completed = Counter(val >= count for val in achievement_data["progress"])[True]
pct = (completed * 100) // len(achievement_data["progress"])
else:
# we display progress as the percent of the total count
pct = (achievement_data["progress"] * 100) // count
status = f"{pct}% complete"
return self.template.format(
name=achievement_data.get("name", ""),
desc=achievement_data.get("desc", ""),
status=status,
)
def func(self):
if self.args:
# we're doing a name lookup
if not (achievements := search_achievement(self.args.strip())):
self.msg(f"Could not find any achievements matching '{self.args.strip()}'.")
return
else:
# we're checking against all achievements
if not (achievements := all_achievements()):
self.msg("There are no achievements in this game.")
return
# get the achiever's progress data
progress_data = _read_player_data(self.caller)
if self.caller != self.account:
progress_data |= _read_player_data(self.account)
# go through switch options
# we only show achievements that are in progress
if "progress" in self.switches:
# we filter our data to incomplete achievements, and combine the base achievement data into it
achievement_data = {
key: achievements[key] | data
for key, data in progress_data.items()
if not data.get("completed")
}
# we only show achievements that are completed
elif "completed" in self.switches or "done" in self.switches:
# we filter our data to finished achievements, and combine the base achievement data into it
achievement_data = {
key: achievements[key] | data
for key, data in progress_data.items()
if data.get("completed")
}
# we show ALL achievements
elif "all" in self.switches:
# we merge our progress data into the full dict of achievements
achievement_data = {
key: data | progress_data.get(key, {}) for key, data in achievements.items()
}
# we show all of the currently available achievements regardless of progress status
else:
achievement_data = {
key: data | progress_data.get(key, {})
for key, data in achievements.items()
if all(
progress_data.get(prereq, {}).get("completed")
for prereq in make_iter(data.get("prereqs", []))
)
}
if not achievement_data:
self.msg("There are no matching achievements.")
return
achievement_str = "\n".join(
self.format_achievement(data) for _, data in achievement_data.items()
)
EvMore(self.caller, achievement_str)

View file

@ -0,0 +1,180 @@
from mock import patch
from evennia.utils.test_resources import BaseEvenniaCommandTest, BaseEvenniaTest
from . import achievements
_dummy_achievements = {
"ACHIEVE_ONE": {
"name": "First Achievement",
"desc": "A first achievement for first achievers.",
"category": "login",
},
"COUNTING_ACHIEVE": {
"name": "The Count",
"desc": "One, two, three! Three counters! Ah ah ah!",
"category": "get",
"tracking": "thing",
"count": 3,
},
"COUNTING_TWO": {
"name": "Son of the Count",
"desc": "Four, five, six! Six counters!",
"category": "get",
"tracking": "thing",
"count": 3,
"prereqs": "COUNTING_ACHIEVE",
},
"SEPARATE_ITEMS": {
"name": "Apples and Pears",
"desc": "Get some apples and some pears.",
"category": "get",
"tracking": ("apple", "pear"),
"tracking_type": "separate",
"count": 3,
},
}
class TestAchievements(BaseEvenniaTest):
@patch(
"evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA",
_dummy_achievements,
)
def test_completion(self):
"""no defined count means a single match completes it"""
self.assertIn(
"ACHIEVE_ONE",
achievements.track_achievements(self.char1, category="login", track="first"),
)
@patch(
"evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA",
_dummy_achievements,
)
def test_counter_progress(self):
"""progressing a counter should update the achiever"""
# this should not complete any achievements; verify it returns the right empty result
self.assertEqual(achievements.track_achievements(self.char1, "get", "thing"), tuple())
# first, verify that the data is created
self.assertTrue(self.char1.attributes.has("achievements"))
self.assertEqual(self.char1.db.achievements["COUNTING_ACHIEVE"]["progress"], 1)
# verify that it gets updated
achievements.track_achievements(self.char1, "get", "thing")
self.assertEqual(self.char1.db.achievements["COUNTING_ACHIEVE"]["progress"], 2)
# also verify that `get_achievement_progress` returns the correct data
self.assertEqual(
achievements.get_achievement_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 2}
)
@patch(
"evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA",
_dummy_achievements,
)
def test_prereqs(self):
"""verify progress is not counted on achievements with unmet prerequisites"""
achievements.track_achievements(self.char1, "get", "thing")
# this should mark progress on COUNTING_ACHIEVE, but NOT on COUNTING_TWO
self.assertEqual(
achievements.get_achievement_progress(self.char1, "COUNTING_ACHIEVE"), {"progress": 1}
)
self.assertEqual(achievements.get_achievement_progress(self.char1, "COUNTING_TWO"), {})
# now we complete COUNTING_ACHIEVE...
self.assertIn(
"COUNTING_ACHIEVE", achievements.track_achievements(self.char1, "get", "thing", count=2)
)
# and track again to progress COUNTING_TWO
achievements.track_achievements(self.char1, "get", "thing")
self.assertEqual(
achievements.get_achievement_progress(self.char1, "COUNTING_TWO"), {"progress": 1}
)
@patch(
"evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA",
_dummy_achievements,
)
def test_separate_tracking(self):
"""achievements with 'tracking_type': 'separate' should count progress for each item"""
# getting one item only increments that one item
achievements.track_achievements(self.char1, "get", "apple")
progress = achievements.get_achievement_progress(self.char1, "SEPARATE_ITEMS")
self.assertEqual(progress["progress"], [1, 0])
# the other item then increments that item
achievements.track_achievements(self.char1, "get", "pear")
progress = achievements.get_achievement_progress(self.char1, "SEPARATE_ITEMS")
self.assertEqual(progress["progress"], [1, 1])
# completing one does not complete the achievement
self.assertEqual(
achievements.track_achievements(self.char1, "get", "apple", count=2), tuple()
)
# completing the second as well DOES complete the achievement
self.assertIn(
"SEPARATE_ITEMS", achievements.track_achievements(self.char1, "get", "pear", count=2)
)
@patch(
"evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA",
_dummy_achievements,
)
def test_search_achievement(self):
"""searching for achievements by name"""
results = achievements.search_achievement("count")
self.assertEqual(["COUNTING_ACHIEVE", "COUNTING_TWO"], list(results.keys()))
class TestAchieveCommand(BaseEvenniaCommandTest):
@patch(
"evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA",
_dummy_achievements,
)
def test_switches(self):
# print only achievements that have no prereqs
expected_output = "\n".join(
f"{data['name']}\n{data['desc']}\nNot Started"
for key, data in _dummy_achievements.items()
if not data.get("prereqs")
)
self.call(achievements.CmdAchieve(), "", expected_output)
# print all achievements
expected_output = "\n".join(
f"{data['name']}\n{data['desc']}\nNot Started"
for key, data in _dummy_achievements.items()
)
self.call(achievements.CmdAchieve(), "/all", expected_output)
# these should both be empty
self.call(achievements.CmdAchieve(), "/progress", "There are no matching achievements.")
self.call(achievements.CmdAchieve(), "/done", "There are no matching achievements.")
# update one and complete one, then verify they show up correctly
achievements.track_achievements(self.char1, "login")
achievements.track_achievements(self.char1, "get", "thing")
self.call(
achievements.CmdAchieve(),
"/progress",
"The Count\nOne, two, three! Three counters! Ah ah ah!\n33% complete",
)
self.call(
achievements.CmdAchieve(),
"/done",
"First Achievement\nA first achievement for first achievers.\nCompleted!",
)
@patch(
"evennia.contrib.game_systems.achievements.achievements._ACHIEVEMENT_DATA",
_dummy_achievements,
)
def test_search(self):
# by default, only returns matching items that are trackable
self.call(
achievements.CmdAchieve(),
" count",
"The Count\nOne, two, three! Three counters! Ah ah ah!\nNot Started",
)
# with switches, returns matching items from the switch set
self.call(
achievements.CmdAchieve(),
"/all count",
"The Count\nOne, two, three! Three counters! Ah ah ah!\nNot Started\n"
+ "Son of the Count\nFour, five, six! Six counters!\nNot Started",
)

View file

@ -34,8 +34,8 @@ class CharacterCmdset(default_cmds.Character_CmdSet):
```
Then reload to make the bew commands available. Note that they only work
on rooms with the typeclass `ExtendedRoom`. Create new rooms with the right
Then, reload to make the new commands available. Note that they only work
on rooms with the `ExtendedRoom` typeclass. Create new rooms with the correct
typeclass or use the `typeclass` command to swap existing rooms.
"""
@ -677,7 +677,7 @@ class CmdExtendedRoomDesc(default_cmds.CmdDesc):
edit - Open up a line editor for more advanced editing.
del - Delete the description of an object. If another state is given, its description
will be deleted.
spring|summer|autumn|winter - room description to use in respective in-game season
spring||summer||autumn||winter - room description to use in respective in-game season
<other> - room description to use with an arbitrary room state.
Sets the description an object. If an object is not given,

View file

@ -7,17 +7,17 @@ Commands for managing and initiating an in-game character-creation menu.
## Installation
In your game folder `commands/default_cmdsets.py`, import and add
`ContribCmdCharCreate` to your `AccountCmdSet`.
`ContribChargenCmdSet` to your `AccountCmdSet`.
Example:
```python
from evennia.contrib.rpg.character_creator.character_creator import ContribCmdCharCreate
from evennia.contrib.rpg.character_creator.character_creator import ContribChargenCmdSet
class AccountCmdSet(default_cmds.AccountCmdSet):
def at_cmdset_creation(self):
super().at_cmdset_creation()
self.add(ContribCmdCharCreate)
self.add(ContribChargenCmdSet)
```
In your game folder `typeclasses/accounts.py`, import and inherit from `ContribChargenAccount`
@ -100,15 +100,19 @@ character creator menu, as well as supporting exiting/resuming the process. In
addition, unlike the core command, it's designed for the character name to be
chosen later on via the menu, so it won't parse any arguments passed to it.
### Changes to `Account.at_look`
### Changes to `Account`
The contrib version works mostly the same as core evennia, but adds an
additional check to recognize an in-progress character. If you've modified your
own `at_look` hook, it's an easy addition to make: just add this section to the
The contrib version works mostly the same as core evennia, but modifies `ooc_appearance_template`
to match the contrib's command syntax, and the `at_look` method to recognize an in-progress
character.
If you've modified your own `at_look` hook, it's an easy change to add: just add this section to the
playable character list loop.
```python
# the beginning of the loop starts here
for char in characters:
# ...
# contrib code starts here
if char.db.chargen_step:
# currently in-progress character; don't display placeholder names

View file

@ -22,10 +22,12 @@ from random import choices
from django.conf import settings
from evennia import DefaultAccount
from evennia.commands.cmdset import CmdSet
from evennia.commands.default.account import CmdIC
from evennia.commands.default.muxcommand import MuxAccountCommand
from evennia.objects.models import ObjectDB
from evennia.utils.evmenu import EvMenu
from evennia.utils.utils import is_iter
from evennia.utils.utils import is_iter, string_partial_matching
_MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS
@ -35,6 +37,17 @@ except AttributeError:
_CHARGEN_MENU = "evennia.contrib.rpg.character_creator.example_menu"
class ContribCmdIC(CmdIC):
def func(self):
if self.args:
# check if the args match an in-progress character
wips = [chara for chara in self.account.characters if chara.db.chargen_step]
if matches := string_partial_matching([c.key for c in wips], self.args):
# the character is in progress, resume creation
return self.execute_cmd("charcreate")
super().func()
class ContribCmdCharCreate(MuxAccountCommand):
"""
create a new character
@ -87,21 +100,47 @@ class ContribCmdCharCreate(MuxAccountCommand):
char = session.new_char
if char.db.chargen_step:
# this means the character creation process was exited in the middle
account.execute_cmd("look")
account.execute_cmd("look", session=session)
else:
# this means character creation was completed - start playing!
# execute the ic command to start puppeting the character
account.execute_cmd("ic {}".format(char.key))
account.execute_cmd("ic {}".format(char.key), session=session)
EvMenu(session, _CHARGEN_MENU, startnode=startnode, cmd_on_exit=finish_char_callback)
class ContribChargenCmdSet(CmdSet):
key = "Contrib Chargen CmdSet"
def at_cmdset_creation(self):
super().at_cmdset_creation()
self.add(ContribCmdIC)
self.add(ContribCmdCharCreate)
class ContribChargenAccount(DefaultAccount):
"""
A modified Account class that makes minor changes to the OOC look
output to incorporate in-progress characters.
A modified Account class that changes the OOC look output to better match the contrib and
incorporate in-progress characters.
"""
ooc_appearance_template = """
--------------------------------------------------------------------
{header}
{sessions}
|whelp|n - more commands
|wcharcreate|n - create new character
|wchardelete <name>|n - delete a character
|wic <name>|n - enter the game as character (|wooc|n to get back here)
|wic|n - enter the game as latest character controlled.
{characters}
{footer}
--------------------------------------------------------------------
""".strip()
def at_look(self, target=None, session=None, **kwargs):
"""
Called when this object executes a look. It allows to customize
@ -156,7 +195,7 @@ class ContribChargenAccount(DefaultAccount):
txt_sessions = "|wConnected session(s):|n\n" + "\n".join(sess_strings)
if not characters:
txt_characters = "You don't have a character yet. Use |wcharcreate|n."
txt_characters = "You don't have a character yet."
else:
max_chars = (
"unlimited"

View file

@ -73,8 +73,14 @@ class Room(ContribRPRoom):
# ...
```
You need to set up Evennia to use the RPsystem's form to separate
between sdescs (`3-tall`) to make it compatible with how the rest of Evennia
separates between other multi-matches of searches/commands:
You will then need to reload the server and potentially force-reload
SEARCH_MULTIMATCH_REGEX = r"(?P<number>[0-9]+)-(?P<name>[^-]*)(?P<args>.*)"
SEARCH_MULTIMATCH_TEMPLATE = " {number}-{name}{aliases}{info}\n"
Finally, you will then need to reload the server and potentially force-reload
your objects, if you originally created them without this.
Example for your character:

View file

@ -392,7 +392,7 @@ def parse_sdescs_and_recogs(
# if no sdesc, include key plus aliases instead
else:
candidate_map.append((obj, obj.key))
candidate_map.extend([(obj, alias) for alias in obj.aliases.all()])
candidate_map.extend([(obj, alias) for alias in obj.aliases.all()])
# escape mapping syntax on the form {#id} if it exists already in emote,
# if so it is replaced with just "id".
@ -422,7 +422,10 @@ def parse_sdescs_and_recogs(
# first see if there is a number given (e.g. 1-tall)
num_identifier, _ = marker_match.groups("") # return "" if no match, rather than None
# get the beginning of the actual text, minus the numeric identifier
match_index = marker_match.start()
if num_identifier:
match_index += len(num_identifier) + 1
# split the emote string at the reference marker, to process everything after it
head = string[:match_index]
tail = string[match_index + 1 :]
@ -439,7 +442,7 @@ def parse_sdescs_and_recogs(
(re.search(rquery, text, _RE_FLAGS), obj, text) for obj, text in candidate_map
)
# filter out any non-matching candidates
bestmatches = [(obj, match.group()) for match, obj, text in matches if match]
bestmatches = [(obj, mtch.group()) for mtch, obj, text in matches if mtch]
else:
# to find the longest match, we start from the marker and lengthen the
@ -1333,30 +1336,30 @@ class ContribRPObject(DefaultObject):
"""
# we also want to use the default search method
search_obj = super().get_search_result
is_builder = self.locks.check_lockstring(self, "perm(Builder)")
is_builder = self.permissions.check("Builder")
results = []
if candidates:
candidates = parse_sdescs_and_recogs(
if candidates is not None:
searched_results = parse_sdescs_and_recogs(
self, candidates, _PREFIX + searchdata, search_mode=True
)
results = []
for candidate in candidates:
# we search by candidate keys here; this allows full error
# management and use of all kwargs - we will use searchdata
# in eventual error reporting later (not their keys). Doing
# it like this e.g. allows for use of the typeclass kwarg
# limiter.
results.extend(
[obj for obj in search_obj(candidate.key, **kwargs) if obj not in results]
)
if not results and is_builder:
# builders get to do a global search by key+alias
results = search_obj(searchdata, **kwargs)
if not searched_results and is_builder:
# builders get to do a search by key
results = search_obj(searchdata, candidates=candidates, **kwargs)
else:
# we do a default search on each result by key, here, to apply extra filtering kwargs
for searched_obj in searched_results:
results.extend(
[
obj
for obj in search_obj(
searched_obj.key, candidates=[searched_obj], **kwargs
)
if obj not in results
]
)
else:
# global searches with #drefs end up here. Global searches are
# only done in code, so is controlled, #dbrefs are turned off
# for non-Builders.
# no candidates means it's a global search, so we pass it back to the default
results = search_obj(searchdata, **kwargs)
return results

View file

@ -346,6 +346,44 @@ class TestRPSystem(BaseEvenniaTest):
self.assertEqual(self.speaker.search("receiver of emotes"), self.receiver1)
self.assertEqual(self.speaker.search("colliding"), self.receiver2)
def test_get_search_result(self):
self.obj1 = create_object(rpsystem.ContribRPObject, key="Obj1", location=self.room)
self.obj1.sdesc.add("something")
self.obj2 = create_object(rpsystem.ContribRPCharacter, key="Obj2", location=self.room)
self.obj2.sdesc.add("something")
candidates = [self.obj1, self.obj2]
# search candidates by sdesc: both objects should be found
result = self.speaker.get_search_result("something", candidates)
self.assertEqual(list(result), candidates)
# search by sdesc with 2-disambiguator: only second object should be found
result = self.speaker.get_search_result("2-something", candidates)
self.assertEqual(list(result), [self.obj2])
# search empty candidates: no objects should be found
result = self.speaker.get_search_result("something", candidates=[])
self.assertEqual(list(result), [])
# typeclass was given: only matching object should be found
result = self.speaker.get_search_result(
"something", candidates=candidates, typeclass=rpsystem.ContribRPCharacter
)
self.assertEqual(list(result), [self.obj2])
# search by key with player permissions: no objects should be found
result = self.speaker.get_search_result("obj1", candidates)
self.assertEqual(list(result), [])
# search by key with builder permissions: object should be found
self.speaker.permissions.add("builder")
result = self.speaker.get_search_result("obj1", candidates)
self.assertEqual(list(result), [self.obj1])
# search by key with builder permissions when NOT IN candidates: object should NOT be found
result = self.speaker.get_search_result("obj1", [self.obj2])
self.assertEqual(list(result), [])
class TestRPSystemCommands(BaseEvenniaCommandTest):
def setUp(self):

View file

@ -4,7 +4,7 @@ Contribution by Griatch 2020, based on code by Whitenoise and Ainneve contribs,
A `Trait` represents a modifiable property on (usually) a Character. They can
be used to represent everything from attributes (str, agi etc) to skills
(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc.
(hunting 10, swords 14 etc) and dynamically changing things like HP, XP etc.
Traits differ from normal Attributes in that they track their changes and limit
themselves to particular value-ranges. One can add/subtract from them easily and
they can even change dynamically at a particular rate (like you being poisoned or
@ -50,8 +50,6 @@ class Character(DefaultCharacter):
self.traits.add("hp", "Health", trait_type="gauge", min=0, max=100)
self.traits.add("hunting", "Hunting Skill", trait_type="counter",
base=10, mod=1, min=0, max=100)
```
When adding the trait, you supply the name of the property (`hunting`) along
with a more human-friendly name ("Hunting Skill"). The latter will show if you
@ -78,7 +76,6 @@ class Object(DefaultObject):
strength = TraitProperty("Strength", trait_type="static", base=10, mod=2)
health = TraitProperty("Health", trait_type="gauge", min=0, base=100, mod=2)
hunting = TraitProperty("Hunting Skill", trait_type="counter", base=10, mod=1, min=0, max=100)
```
> Note that the property-name will become the name of the trait and you don't supply `trait_key`
@ -92,7 +89,7 @@ class Object(DefaultObject):
## Using traits
A trait is added to the traithandler (if you use `TraitProperty` the handler is just created under
A trait are entities added to the traithandler (if you use `TraitProperty` the handler is just created under
the hood) after which one can access it as a property on the handler (similarly to how you can do
.db.attrname for Attributes in Evennia).
@ -137,9 +134,30 @@ obj.traits.strength.value
> obj.strength.value += 5
> obj.strength.value
17
```
### Relating traits to one another
From a trait you can access its own Traithandler as `.traithandler`. You can
also find another trait on the same handler by using the
`Trait.get_trait("traitname")` method.
```python
> obj.strength.get_trait("hp").value
100
```
This is not too useful for the default trait types - they are all operating
independently from one another. But if you create your own trait classes, you
can use this to make traits that depend on each other.
For example, you could picture making a Trait that is the sum of the values of
two other traits and capped by the value of a third trait. Such complex
interactions are common in RPG rule systems but are by definition game specific.
See an example in the section about [making your own Trait classes](#expanding-with-your-own-traits).
## Trait types
All default traits have a read-only `.value` property that shows the relevant or
@ -158,7 +176,6 @@ compatible type.
> trait1 + 2
> trait1.value
5
```
Two numerical traits can also be compared (bigger-than etc), which is useful in
@ -168,7 +185,6 @@ all sorts of rule-resolution.
if trait1 > trait2:
# do stuff
```
### Trait
@ -193,7 +209,6 @@ like a glorified Attribute.
> obj.traits.mytrait.value = "stringvalue"
> obj.traits.mytrait.value
"stringvalue"
```
### Static trait
@ -217,7 +232,6 @@ that varies slowly or not at all, and which may be modified in-place.
> obj.traits.mytrait.mod = 0
> obj.traits.mytrait.value
12
```
### Counter
@ -253,8 +267,6 @@ remove it. A suggested use for a Counter Trait would be to track skill values.
# for TraitProperties, pass the args/kwargs of traits.add() to the
# TraitProperty constructor instead.
```
Counters have some extra properties:
@ -286,7 +298,6 @@ By calling `.desc()` on the Counter, you will get the text matching the current
> obj.traits.hunting.desc()
"expert"
```
#### .rate
@ -327,12 +338,10 @@ a previous value.
71 # we have stopped at the ratetarget
> obj.traits.hunting.rate = 0 # disable auto-change
```
Note that when retrieving the `current`, the result will always be of the same
type as the `.base` even `rate` is a non-integer value. So if `base` is an `int`
(default)`, the `current` value will also be rounded the closest full integer.
(default), the `current` value will also be rounded the closest full integer.
If you want to see the exact `current` value, set `base` to a float - you
will then need to use `round()` yourself on the result if you want integers.
@ -347,7 +356,6 @@ return the value as a percentage.
> obj.traits.hunting.percent(formatting=None)
71.0
```
### Gauge
@ -379,7 +387,6 @@ stamina and the like.
> obj.traits.hp.current -= 30
> obj.traits.hp.value
80
```
The Gauge trait is subclass of the Counter, so you have access to the same
@ -412,8 +419,6 @@ class RageTrait(StaticTrait):
def sedate(self):
self.mod = 0
```
Above is an example custom-trait-class "rage" that stores a property "rage" on
@ -432,12 +437,24 @@ Reload the server and you should now be able to use your trait:
> obj.traits.add("mood", "A dark mood", rage=30, trait_type='rage')
> obj.traits.mood.rage
30
```
Remember that you can use `.get_trait("name")` to access other traits on the
same handler. Let's say that the rage modifier is actually limited by
the characters's current STR value times 3, with a max of 100:
```python
class RageTrait(StaticTrait):
#...
def berserk(self):
self.mod = min(100, self.get_trait("STR").value * 3)
```
# as TraitProperty
```
class Character(DefaultCharacter):
rage = TraitProperty("A dark mood", rage=30, trait_type='rage')
```
## Adding additional TraitHandlers
@ -459,7 +476,7 @@ class Character(DefaultCharacter):
def traits(self):
# this adds the handler as .traits
return TraitHandler(self)
@lazy_property
def stats(self):
# this adds the handler as .stats
@ -478,3 +495,6 @@ class Character(DefaultCharacter):
self.skills.add("hunting", "Hunting Skill", trait_type="counter",
base=10, mod=1, min=0, max=100)
```
> Rememebr that the `.get_traits()` method only works for accessing Traits within the
_same_ TraitHandler.

View file

@ -9,10 +9,9 @@ Unit test module for Trait classes.
from copy import copy
from anything import Something
from mock import MagicMock, patch
from evennia.objects.objects import DefaultCharacter
from evennia.utils.test_resources import BaseEvenniaTestCase, EvenniaTest
from mock import MagicMock, patch
from . import traits
@ -156,6 +155,16 @@ class TraitHandlerTest(_TraitHandlerBase):
self.obj.attributes.get("traits", category="traits")["test1"]["value"], None
)
def test_related_traits(self):
"""Test traits related to each other via Trait.get_trait()"""
trait1 = self.traithandler.test1
trait2 = self.traithandler.test2
self.assertEqual(trait1.traithandler, self.traithandler)
self.assertEqual(trait1.get_trait("test1"), trait1)
self.assertEqual(trait1.get_trait("test2"), trait2)
class TestTrait(_TraitHandlerBase):
"""

View file

@ -456,15 +456,9 @@ from functools import total_ordering
from time import time
from django.conf import settings
from evennia.utils import logger
from evennia.utils.dbserialize import _SaverDict
from evennia.utils.utils import (
class_from_module,
inherits_from,
list_to_string,
percent,
)
from evennia.utils.utils import class_from_module, inherits_from, list_to_string, percent
# Available Trait classes.
# This way the user can easily supply their own. Each
@ -657,7 +651,9 @@ class TraitHandler:
if trait is None and trait_key in self.trait_data:
trait_type = self.trait_data[trait_key]["trait_type"]
trait_cls = self._get_trait_class(trait_type)
trait = self._cache[trait_key] = trait_cls(_GA(self, "trait_data")[trait_key])
trait = self._cache[trait_key] = trait_cls(
_GA(self, "trait_data")[trait_key], handler=self
)
return trait
def add(
@ -856,7 +852,7 @@ class Trait:
# and have them treated like data to store.
allow_extra_properties = True
def __init__(self, trait_data):
def __init__(self, trait_data, handler=None):
"""
This both initializes and validates the Trait on creation. It must
raise exception if validation fails. The TraitHandler will call this
@ -869,12 +865,15 @@ class Trait:
value in cls.data_default_values. Any extra kwargs will be made
available as extra properties on the Trait, assuming the class
variable `allow_extra_properties` is set.
handler (TraitHandler): The handler that this Trait is connected to.
This is for referencing other traits.
Raises:
TraitException: If input-validation failed.
"""
self._data = self.__class__.validate_input(self.__class__, trait_data)
self.traithandler = handler
if not isinstance(trait_data, _SaverDict):
logger.log_warn(
@ -955,6 +954,7 @@ class Trait:
"data_default",
"trait_type",
"allow_extra_properties",
"traithandler",
):
return _GA(self, key)
try:
@ -970,10 +970,9 @@ class Trait:
"""Set extra parameters as attributes.
Arbitrary attributes set on a Trait object will be
stored in the 'extra' key of the `_data` attribute.
stored as extra keys in the Trait's data.
This behavior is enabled by setting the instance
variable `_locked` to True.
This behavior is enabled by setting the instance variable `allow_extra_properties`.
"""
propobj = getattr(self.__class__, key, None)
@ -984,7 +983,7 @@ class Trait:
return
else:
# this is some other value
if key in ("_data",):
if key in ("_data", "traithandler"):
_SA(self, key, value)
return
if _GA(self, "allow_extra_properties"):
@ -1053,6 +1052,11 @@ class Trait:
"""Display name for the trait."""
return self._data["name"]
def get_trait(self, trait_key):
"""Get another Trait from the handler. Not used by default, but can be used
for custom traits that are affected by other traits on the same handler."""
return self.traithandler.get(trait_key)
key = name
# Numeric operations

View file

@ -68,8 +68,8 @@ class EvAdventureQuest:
self.data = self.questhandler.load_quest_data(self.key)
self._current_step = self.get_data("current_step")
if not self.current_step:
self.current_step = self.start_step
if not self._current_step:
self._current_step = self.start_step
def add_data(self, key, value):
"""

View file

@ -2,6 +2,7 @@ import datetime
import git
from django.conf import settings
from django.core.management import call_command
import evennia
from evennia import CmdSet, InterruptCommand
@ -140,21 +141,25 @@ class GitCommand(MuxCommand):
Provide basic Git functionality within the game.
"""
caller = self.caller
reload = False
if self.action == "status":
caller.msg(self.get_status())
elif self.action == "branch" or (self.action == "checkout" and not self.args):
caller.msg(self.get_branches())
elif self.action == "checkout":
if self.checkout():
evennia.SESSION_HANDLER.portal_restart_server()
reload = self.checkout()
elif self.action == "pull":
if self.pull():
evennia.SESSION_HANDLER.portal_restart_server()
reload = self.pull()
else:
caller.msg("You can only git status, git branch, git checkout, or git pull.")
return
if reload:
# reload the server and the static file cache
evennia.SESSION_HANDLER.portal_restart_server()
call_command("collectstatic", interactive=False)
class CmdGitEvennia(GitCommand):
"""

View file

@ -27,68 +27,112 @@ from evennia.accounts.accounts import DefaultAccount, DefaultGuest
class Account(DefaultAccount):
"""
This class describes the actual OOC account (i.e. the user connecting
to the MUD). It does NOT have visual appearance in the game world (that
is handled by the character which is connected to this). Comm channels
are attended/joined using this object.
An Account is the actual OOC player entity. It doesn't exist in the game,
but puppets characters.
It can be useful e.g. for storing configuration options for your game, but
should generally not hold any character-related info (that's best handled
on the character level).
This is the base Typeclass for all Accounts. Accounts represent
the person playing the game and tracks account info, password
etc. They are OOC entities without presence in-game. An Account
can connect to a Character Object in order to "enter" the
game.
Can be set using BASE_ACCOUNT_TYPECLASS.
Account Typeclass API:
* Available properties (only available on initiated typeclass objects)
* available properties
key (string) - name of account
name (string)- wrapper for user.username
aliases (list of strings) - aliases to the object. Will be saved to database as AliasDB entries but returned as strings.
dbref (int, read-only) - unique #id-number. Also "id" can be used.
date_created (string) - time stamp of object creation
permissions (list of strings) - list of permission strings
user (User, read-only) - django User authorization object
obj (Object) - game object controlled by account. 'character' can also be used.
sessions (list of Sessions) - sessions connected to this account
is_superuser (bool, read-only) - if the connected user is a superuser
- key (string) - name of account
- name (string)- wrapper for user.username
- aliases (list of strings) - aliases to the object. Will be saved to
database as AliasDB entries but returned as strings.
- dbref (int, read-only) - unique #id-number. Also "id" can be used.
- date_created (string) - time stamp of object creation
- permissions (list of strings) - list of permission strings
- user (User, read-only) - django User authorization object
- obj (Object) - game object controlled by account. 'character' can also
be used.
- is_superuser (bool, read-only) - if the connected user is a superuser
* Handlers
locks - lock-handler: use locks.add() to add new lock strings
db - attribute-handler: store/retrieve database attributes on this self.db.myattr=val, val=self.db.myattr
ndb - non-persistent attribute handler: same as db but does not create a database entry when storing data
scripts - script-handler. Add new scripts to object with scripts.add()
cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object
nicks - nick-handler. New nicks with nicks.add().
- locks - lock-handler: use locks.add() to add new lock strings
- db - attribute-handler: store/retrieve database attributes on this
self.db.myattr=val, val=self.db.myattr
- ndb - non-persistent attribute handler: same as db but does not
create a database entry when storing data
- scripts - script-handler. Add new scripts to object with scripts.add()
- cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object
- nicks - nick-handler. New nicks with nicks.add().
- sessions - session-handler. Use session.get() to see all sessions connected, if any
- options - option-handler. Defaults are taken from settings.OPTIONS_ACCOUNT_DEFAULT
- characters - handler for listing the account's playable characters
* Helper methods
* Helper methods (check autodocs for full updated listing)
msg(text=None, **kwargs)
execute_cmd(raw_string, session=None)
search(ostring, global_search=False, attribute_name=None, use_nicks=False, location=None, ignore_errors=False, account=False)
is_typeclass(typeclass, exact=False)
swap_typeclass(new_typeclass, clean_attributes=False, no_default=True)
access(accessing_obj, access_type='read', default=False)
check_permstring(permstring)
- msg(text=None, from_obj=None, session=None, options=None, **kwargs)
- execute_cmd(raw_string)
- search(searchdata, return_puppet=False, search_object=False, typeclass=None,
nofound_string=None, multimatch_string=None, use_nicks=True,
quiet=False, **kwargs)
- is_typeclass(typeclass, exact=False)
- swap_typeclass(new_typeclass, clean_attributes=False, no_default=True)
- access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False, **kwargs)
- check_permstring(permstring)
- get_cmdsets(caller, current, **kwargs)
- get_cmdset_providers()
- uses_screenreader(session=None)
- get_display_name(looker, **kwargs)
- get_extra_display_name_info(looker, **kwargs)
- disconnect_session_from_account()
- puppet_object(session, obj)
- unpuppet_object(session)
- unpuppet_all()
- get_puppet(session)
- get_all_puppets()
- is_banned(**kwargs)
- get_username_validators(validator_config=settings.AUTH_USERNAME_VALIDATORS)
- authenticate(username, password, ip="", **kwargs)
- normalize_username(username)
- validate_username(username)
- validate_password(password, account=None)
- set_password(password, **kwargs)
- get_character_slots()
- get_available_character_slots()
- create_character(*args, **kwargs)
- create(*args, **kwargs)
- delete(*args, **kwargs)
- channel_msg(message, channel, senders=None, **kwargs)
- idle_time()
- connection_time()
* Hook methods (when re-implementation, remember methods need to have self as first arg)
* Hook methods
basetype_setup()
at_account_creation()
- note that the following hooks are also found on Objects and are
> note that the following hooks are also found on Objects and are
usually handled on the character level:
at_init()
at_cmdset_get(**kwargs)
at_first_login()
at_post_login(session=None)
at_disconnect()
at_message_receive()
at_message_send()
at_server_reload()
at_server_shutdown()
- at_init()
- at_first_save()
- at_access()
- at_cmdset_get(**kwargs)
- at_password_change(**kwargs)
- at_first_login()
- at_pre_login()
- at_post_login(session=None)
- at_failed_login(session, **kwargs)
- at_disconnect(reason=None, **kwargs)
- at_post_disconnect(**kwargs)
- at_message_receive()
- at_message_send()
- at_server_reload()
- at_server_shutdown()
- at_look(target=None, session=None, **kwargs)
- at_post_create_character(character, **kwargs)
- at_post_add_character(char)
- at_post_remove_character(char)
- at_pre_channel_msg(message, channel, senders=None, **kwargs)
- at_post_chnnel_msg(message, channel, senders=None, **kwargs)
"""

View file

@ -16,14 +16,53 @@ from evennia.comms.comms import DefaultChannel
class Channel(DefaultChannel):
"""
Working methods:
at_channel_creation() - called once, when the channel is created
r"""
This is the base class for all Channel Comms. Inherit from this to
create different types of communication channels.
Class-level variables:
- `send_to_online_only` (bool, default True) - if set, will only try to
send to subscribers that are actually active. This is a useful optimization.
- `log_file` (str, default `"channel_{channelname}.log"`). This is the
log file to which the channel history will be saved. The `{channelname}` tag
will be replaced by the key of the Channel. If an Attribute 'log_file'
is set, this will be used instead. If this is None and no Attribute is found,
no history will be saved.
- `channel_prefix_string` (str, default `"[{channelname} ]"`) - this is used
as a simple template to get the channel prefix with `.channel_prefix()`. It is used
in front of every channel message; use `{channelmessage}` token to insert the
name of the current channel. Set to `None` if you want no prefix (or want to
handle it in a hook during message generation instead.
- `channel_msg_nick_pattern`(str, default `"{alias}\s*?|{alias}\s+?(?P<arg1>.+?)") -
this is what used when a channel subscriber gets a channel nick assigned to this
channel. The nickhandler uses the pattern to pick out this channel's name from user
input. The `{alias}` token will get both the channel's key and any set/custom aliases
per subscriber. You need to allow for an `<arg1>` regex group to catch any message
that should be send to the channel. You usually don't need to change this pattern
unless you are changing channel command-style entirely.
- `channel_msg_nick_replacement` (str, default `"channel {channelname} = $1"` - this
is used by the nickhandler to generate a replacement string once the nickhandler (using
the `channel_msg_nick_pattern`) identifies that the channel should be addressed
to send a message to it. The `<arg1>` regex pattern match from `channel_msg_nick_pattern`
will end up at the `$1` position in the replacement. Together, this allows you do e.g.
'public Hello' and have that become a mapping to `channel public = Hello`. By default,
the account-level `channel` command is used. If you were to rename that command you must
tweak the output to something like `yourchannelcommandname {channelname} = $1`.
* Properties:
mutelist
banlist
wholist
* Working methods:
get_log_filename()
set_log_filename(filename)
has_connection(account) - check if the given account listens to this channel
connect(account) - connect account to this channel
disconnect(account) - disconnect account from channel
access(access_obj, access_type='listen', default=False) - check the
access on this channel (default access_type is listen)
create(key, creator=None, *args, **kwargs)
delete() - delete this channel
message_transform(msg, emit=False, prefix=True,
sender_strings=None, external=False) - called by
@ -36,8 +75,19 @@ class Channel(DefaultChannel):
distribute_message(msg, online=False) - send a message to all
connected accounts on channel, optionally sending only
to accounts that are currently online (optimized for very large sends)
mute(subscriber, **kwargs)
unmute(subscriber, **kwargs)
ban(target, **kwargs)
unban(target, **kwargs)
add_user_channel_alias(user, alias, **kwargs)
remove_user_channel_alias(user, alias, **kwargs)
Useful hooks:
at_channel_creation() - called once, when the channel is created
basetype_setup()
at_init()
at_first_save()
channel_prefix() - how the channel should be
prefixed when returning to user. Returns a string
format_senders(senders) - should return how to display multiple
@ -49,13 +99,19 @@ class Channel(DefaultChannel):
format_message(msg, emit=False) - format the message body before
displaying it to the user. 'emit' generally means that the
message should not be displayed with the sender's name.
channel_prefix()
pre_join_channel(joiner) - if returning False, abort join
post_join_channel(joiner) - called right after successful join
pre_leave_channel(leaver) - if returning False, abort leave
post_leave_channel(leaver) - called right after successful leave
pre_send_message(msg) - runs just before a message is sent to channel
post_send_message(msg) - called just after message was sent to channel
at_pre_msg(message, **kwargs)
at_post_msg(message, **kwargs)
web_get_admin_url()
web_get_create_url()
web_get_detail_url()
web_get_update_url()
web_get_delete_url()
"""

View file

@ -15,22 +15,11 @@ from .objects import ObjectParent
class Character(ObjectParent, DefaultCharacter):
"""
The Character defaults to reimplementing some of base Object's hook methods with the
following functionality:
The Character just re-implements some of the Object's methods and hooks
to represent a Character entity in-game.
at_basetype_setup - always assigns the DefaultCmdSet to this object type
(important!)sets locks so character cannot be picked up
and its commands only be called by itself, not anyone else.
(to change things, use at_object_creation() instead).
at_post_move(source_location) - Launches the "look" command after every move.
at_post_unpuppet(account) - when Account disconnects from the Character, we
store the current location in the prelogout_location Attribute and
move it to a None-location so the "unpuppeted" character
object does not need to stay on grid. Echoes "Account has disconnected"
to the room.
at_pre_puppet - Just before Account re-connects, retrieves the character's
prelogout_location Attribute and move it back on the grid.
at_post_puppet - Echoes "AccountName has entered the game" to the room.
See mygame/typeclasses/objects.py for a list of
properties and methods available on all Object child classes like this.
"""

View file

@ -15,27 +15,12 @@ from .objects import ObjectParent
class Exit(ObjectParent, DefaultExit):
"""
Exits are connectors between rooms. Exits are normal Objects except
they defines the `destination` property. It also does work in the
following methods:
they defines the `destination` property and overrides some hooks
and methods to represent the exits.
basetype_setup() - sets default exit locks (to change, use `at_object_creation` instead).
at_cmdset_get(**kwargs) - this is called when the cmdset is accessed and should
rebuild the Exit cmdset along with a command matching the name
of the Exit object. Conventionally, a kwarg `force_init`
should force a rebuild of the cmdset, this is triggered
by the `@alias` command when aliases are changed.
at_failed_traverse() - gives a default error message ("You cannot
go there") if exit traversal fails and an
attribute `err_traverse` is not defined.
See mygame/typeclasses/objects.py for a list of
properties and methods available on all Objects child classes like this.
Relevant hooks to overload (compared to other types of Objects):
at_traverse(traveller, target_loc) - called to do the actual traversal and calling of the other hooks.
If overloading this, consider using super() to use the default
movement implementation (and hook-calling).
at_post_traverse(traveller, source_loc) - called by at_traverse just after traversing.
at_failed_traverse(traveller) - called by at_traverse if traversal failed for some reason. Will
not be called if the attribute `err_traverse` is
defined, in which case that will simply be echoed.
"""
pass

View file

@ -1,13 +1,10 @@
"""
Object
The Object is the "naked" base class for things in the game world.
The Object is the class for general items in the game world.
Note that the default Character, Room and Exit does not inherit from
this Object, but from their respective default implementations in the
evennia library. If you want to use this class as a parent to change
the other types, you can do so by adding this as a multiple
inheritance.
Use the ObjectParent class to implement common features for *all* entities
with a location in the game world (like Characters, Rooms, Exits).
"""
@ -28,20 +25,18 @@ class ObjectParent:
class Object(ObjectParent, DefaultObject):
"""
This is the root typeclass object, implementing an in-game Evennia
game object, such as having a location, being able to be
manipulated or looked at, etc. If you create a new typeclass, it
must always inherit from this object (or any of the other objects
in this file, since they all actually inherit from BaseObject, as
seen in src.object.objects).
This is the root Object typeclass, representing all entities that
have an actual presence in-game. DefaultObjects generally have a
location. They can also be manipulated and looked at. Game
entities you define should inherit from DefaultObject at some distance.
The BaseObject class implements several hooks tying into the game
engine. By re-implementing these hooks you can control the
system. You should never need to re-implement special Python
methods, such as __init__ and especially never __getattribute__ and
__setattr__ since these are used heavily by the typeclass system
of Evennia and messing with them might well break things for you.
It is recommended to create children of this class using the
`evennia.create_object()` function rather than to initialize the class
directly - this will both set things up and efficiently save the object
without `obj.save()` having to be called explicitly.
Note: Check the autodocs for complete class members, this may not always
be up-to date.
* Base properties defined/available on all Objects
@ -58,12 +53,16 @@ class Object(ObjectParent, DefaultObject):
location (Object) - current location. Is None if this is a room
home (Object) - safety start-location
has_account (bool, read-only)- will only return *connected* accounts
contents (list of Objects, read-only) - returns all objects inside this
object (including exits)
contents (list, read only) - returns all objects inside this object
exits (list of Objects, read-only) - returns all exits from this
object, if any
destination (Object) - only set if this object is an exit.
is_superuser (bool, read-only) - True/False if this user is a superuser
is_connected (bool, read-only) - True if this object is associated with
an Account with any connected sessions.
has_account (bool, read-only) - True is this object has an associated account.
is_superuser (bool, read-only): True if this object has an account and that
account is a superuser.
* Handlers available
@ -84,18 +83,49 @@ class Object(ObjectParent, DefaultObject):
* Helper methods (see src.objects.objects.py for full headers)
search(ostring, global_search=False, attribute_name=None,
use_nicks=False, location=None, ignore_errors=False, account=False)
execute_cmd(raw_string)
msg(text=None, **kwargs)
msg_contents(message, exclude=None, from_obj=None, **kwargs)
get_search_query_replacement(searchdata, **kwargs)
get_search_direct_match(searchdata, **kwargs)
get_search_candidates(searchdata, **kwargs)
get_search_result(searchdata, attribute_name=None, typeclass=None,
candidates=None, exact=False, use_dbref=None, tags=None, **kwargs)
get_stacked_result(results, **kwargs)
handle_search_results(searchdata, results, **kwargs)
search(searchdata, global_search=False, use_nicks=True, typeclass=None,
location=None, attribute_name=None, quiet=False, exact=False,
candidates=None, use_locks=True, nofound_string=None,
multimatch_string=None, use_dbref=None, tags=None, stacked=0)
search_account(searchdata, quiet=False)
execute_cmd(raw_string, session=None, **kwargs))
msg(text=None, from_obj=None, session=None, options=None, **kwargs)
for_contents(func, exclude=None, **kwargs)
msg_contents(message, exclude=None, from_obj=None, mapping=None,
raise_funcparse_errors=False, **kwargs)
move_to(destination, quiet=False, emit_to_obj=None, use_destination=True)
clear_contents()
create(key, account, caller, method, **kwargs)
copy(new_key=None)
at_object_post_copy(new_obj, **kwargs)
delete()
is_typeclass(typeclass, exact=False)
swap_typeclass(new_typeclass, clean_attributes=False, no_default=True)
access(accessing_obj, access_type='read', default=False)
access(accessing_obj, access_type='read', default=False,
no_superuser_bypass=False, **kwargs)
filter_visible(obj_list, looker, **kwargs)
get_default_lockstring()
get_cmdsets(caller, current, **kwargs)
check_permstring(permstring)
get_cmdset_providers()
get_display_name(looker=None, **kwargs)
get_extra_display_name_info(looker=None, **kwargs)
get_numbered_name(count, looker, **kwargs)
get_display_header(looker, **kwargs)
get_display_desc(looker, **kwargs)
get_display_exits(looker, **kwargs)
get_display_characters(looker, **kwargs)
get_display_things(looker, **kwargs)
get_display_footer(looker, **kwargs)
format_appearance(appearance, looker, **kwargs)
return_apperance(looker, **kwargs)
* Hooks (these are class methods, so args should start with self):
@ -113,6 +143,7 @@ class Object(ObjectParent, DefaultObject):
at_init() - called whenever typeclass is cached from memory,
at least once every server restart/reload
at_first_save()
at_cmdset_get(**kwargs) - this is called just before the command handler
requests a cmdset from this object. The kwargs are
not normally used unless the cmdset is created
@ -140,12 +171,16 @@ class Object(ObjectParent, DefaultObject):
after move, if obj.move_to() has quiet=False
at_post_move(source_location) - always called after a move has
been successfully performed.
at_pre_object_leave(leaving_object, destination, **kwargs)
at_object_leave(obj, target_location, move_type="move", **kwargs)
at_object_leave(obj, target_location) - called when an object leaves
this object in any fashion
at_object_receive(obj, source_location) - called when this object receives
at_pre_object_receive(obj, source_location)
at_object_receive(obj, source_location, move_type="move", **kwargs) - called when this object receives
another object
at_post_move(source_location, move_type="move", **kwargs)
at_traverse(traversing_object, source_loc) - (exit-objects only)
at_traverse(traversing_object, target_location, **kwargs) - (exit-objects only)
handles all moving across the exit, including
calling the other exit hooks. Use super() to retain
the default functionality.
@ -164,11 +199,18 @@ class Object(ObjectParent, DefaultObject):
command by default
at_desc(looker=None) - called by 'look' whenever the
appearance is requested.
at_pre_get(getter, **kwargs)
at_get(getter) - called after object has been picked up.
Does not stop pickup.
at_drop(dropper) - called when this object has been dropped.
at_say(speaker, message) - by default, called if an object inside this
object speaks
at_pre_give(giver, getter, **kwargs)
at_give(giver, getter, **kwargs)
at_pre_drop(dropper, **kwargs)
at_drop(dropper, **kwargs) - called when this object has been dropped.
at_pre_say(speaker, message, **kwargs)
at_say(message, msg_self=None, msg_location=None, receivers=None, msg_receivers=None, **kwargs)
at_look(target, **kwargs)
at_desc(looker=None)
"""

View file

@ -17,10 +17,17 @@ from evennia.scripts.scripts import DefaultScript
class Script(DefaultScript):
"""
This is the base TypeClass for all Scripts. Scripts describe
all entities/systems without a physical existence in the game world
that require database storage (like an economic system or
combat tracker). They
can also have a timer/ticker component.
A script type is customized by redefining some or all of its hook
methods and variables.
* available properties
* available properties (check docs for full listing, this could be
outdated).
key (string) - name of object
name (string)- same as key
@ -52,6 +59,7 @@ class Script(DefaultScript):
* Helper methods
create(key, **kwargs)
start() - start script (this usually happens automatically at creation
and obj.script.add() etc)
stop() - stop script, and delete it
@ -81,11 +89,14 @@ class Script(DefaultScript):
will delay the first call of this method by self.interval
seconds. If self.interval==0, this method will never
be called.
at_pause()
at_stop() - Called as the script object is stopped and is about to be
removed from the game, e.g. because is_valid() returned False.
at_script_delete()
at_server_reload() - Called when server reloads. Can be used to
save temporary variables you want should survive a reload.
at_server_shutdown() - called at a full server shutdown.
at_server_start()
"""

View file

@ -13,6 +13,7 @@ game world, policy info, rules and similar.
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from evennia.help.manager import HelpEntryManager
@ -79,7 +80,8 @@ class HelpEntry(SharedMemoryModel):
help_text="tags on this object. Tags are simple string markers to "
"identify, group and alias objects.",
)
# Creation date. This is not changed once the object is created.
# Creation date. This is not changed once the object is created. This is in UTC,
# use the property date_created to get it in local time.
db_date_created = models.DateTimeField("creation date", editable=False, auto_now=True)
# Database manager
@ -100,6 +102,11 @@ class HelpEntry(SharedMemoryModel):
def aliases(self):
return AliasHandler(self)
@property
def date_created(self):
"""Return the field in localized time based on settings.TIME_ZONE."""
return timezone.localtime(self.db_date_created)
class Meta:
"Define Django meta options"
verbose_name = "Help Entry"

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,6 @@ from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import validate_comma_separated_integer_list
from django.db import models
from evennia.objects.manager import ObjectDBManager
from evennia.typeclasses.models import TypedObject
from evennia.utils import logger
@ -71,8 +70,18 @@ class ContentsHandler:
objects = self.load()
self._pkcache = {obj.pk: True for obj in objects}
for obj in objects:
for ctype in obj._content_types:
self._typecache[ctype][obj.pk] = True
try:
ctypes = obj._content_types
except AttributeError:
logger.log_err(
f"Object {obj} has no `_content_types` property. Skipping content-cache setup. "
"This error suggests it is not a valid Evennia Typeclass but maybe a root model "
"like `ObjectDB`. Investigate the `db_typeclass_path` of the object and make sure "
"it points to a proper, existing Typeclass."
)
else:
for ctype in obj._content_types:
self._typecache[ctype][obj.pk] = True
def get(self, exclude=None, content_type=None):
"""

View file

@ -10,11 +10,10 @@ import time
import typing
from collections import defaultdict
import evennia
import inflect
from django.conf import settings
from django.utils.translation import gettext as _
import evennia
from evennia.commands import cmdset
from evennia.commands.cmdsethandler import CmdSetHandler
from evennia.objects.manager import ObjectManager
@ -204,6 +203,184 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
directly - this will both set things up and efficiently save the object
without `obj.save()` having to be called explicitly.
Note: Check the autodocs for complete class members, this may not always
be up-to date.
* Base properties defined/available on all Objects
key (string) - name of object
name (string)- same as key
dbref (int, read-only) - unique #id-number. Also "id" can be used.
date_created (string) - time stamp of object creation
account (Account) - controlling account (if any, only set together with
sessid below)
sessid (int, read-only) - session id (if any, only set together with
account above). Use `sessions` handler to get the
Sessions directly.
location (Object) - current location. Is None if this is a room
home (Object) - safety start-location
has_account (bool, read-only)- will only return *connected* accounts
contents (list, read only) - returns all objects inside this object
exits (list of Objects, read-only) - returns all exits from this
object, if any
destination (Object) - only set if this object is an exit.
is_superuser (bool, read-only) - True/False if this user is a superuser
is_connected (bool, read-only) - True if this object is associated with
an Account with any connected sessions.
has_account (bool, read-only) - True is this object has an associated account.
is_superuser (bool, read-only): True if this object has an account and that
account is a superuser.
* Handlers available
aliases - alias-handler: use aliases.add/remove/get() to use.
permissions - permission-handler: use permissions.add/remove() to
add/remove new perms.
locks - lock-handler: use locks.add() to add new lock strings
scripts - script-handler. Add new scripts to object with scripts.add()
cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object
nicks - nick-handler. New nicks with nicks.add().
sessions - sessions-handler. Get Sessions connected to this
object with sessions.get()
attributes - attribute-handler. Use attributes.add/remove/get.
db - attribute-handler: Shortcut for attribute-handler. Store/retrieve
database attributes using self.db.myattr=val, val=self.db.myattr
ndb - non-persistent attribute handler: same as db but does not create
a database entry when storing data
* Helper methods (see src.objects.objects.py for full headers)
get_search_query_replacement(searchdata, **kwargs)
get_search_direct_match(searchdata, **kwargs)
get_search_candidates(searchdata, **kwargs)
get_search_result(searchdata, attribute_name=None, typeclass=None,
candidates=None, exact=False, use_dbref=None, tags=None, **kwargs)
get_stacked_result(results, **kwargs)
handle_search_results(searchdata, results, **kwargs)
search(searchdata, global_search=False, use_nicks=True, typeclass=None,
location=None, attribute_name=None, quiet=False, exact=False,
candidates=None, use_locks=True, nofound_string=None,
multimatch_string=None, use_dbref=None, tags=None, stacked=0)
search_account(searchdata, quiet=False)
execute_cmd(raw_string, session=None, **kwargs))
msg(text=None, from_obj=None, session=None, options=None, **kwargs)
for_contents(func, exclude=None, **kwargs)
msg_contents(message, exclude=None, from_obj=None, mapping=None,
raise_funcparse_errors=False, **kwargs)
move_to(destination, quiet=False, emit_to_obj=None, use_destination=True)
clear_contents()
create(key, account, caller, method, **kwargs)
copy(new_key=None)
at_object_post_copy(new_obj, **kwargs)
delete()
is_typeclass(typeclass, exact=False)
swap_typeclass(new_typeclass, clean_attributes=False, no_default=True)
access(accessing_obj, access_type='read', default=False,
no_superuser_bypass=False, **kwargs)
filter_visible(obj_list, looker, **kwargs)
get_default_lockstring()
get_cmdsets(caller, current, **kwargs)
check_permstring(permstring)
get_cmdset_providers()
get_display_name(looker=None, **kwargs)
get_extra_display_name_info(looker=None, **kwargs)
get_numbered_name(count, looker, **kwargs)
get_display_header(looker, **kwargs)
get_display_desc(looker, **kwargs)
get_display_exits(looker, **kwargs)
get_display_characters(looker, **kwargs)
get_display_things(looker, **kwargs)
get_display_footer(looker, **kwargs)
format_appearance(appearance, looker, **kwargs)
return_apperance(looker, **kwargs)
* Hooks (these are class methods, so args should start with self):
basetype_setup() - only called once, used for behind-the-scenes
setup. Normally not modified.
basetype_posthook_setup() - customization in basetype, after the object
has been created; Normally not modified.
at_object_creation() - only called once, when object is first created.
Object customizations go here.
at_object_delete() - called just before deleting an object. If returning
False, deletion is aborted. Note that all objects
inside a deleted object are automatically moved
to their <home>, they don't need to be removed here.
at_init() - called whenever typeclass is cached from memory,
at least once every server restart/reload
at_first_save()
at_cmdset_get(**kwargs) - this is called just before the command handler
requests a cmdset from this object. The kwargs are
not normally used unless the cmdset is created
dynamically (see e.g. Exits).
at_pre_puppet(account)- (account-controlled objects only) called just
before puppeting
at_post_puppet() - (account-controlled objects only) called just
after completing connection account<->object
at_pre_unpuppet() - (account-controlled objects only) called just
before un-puppeting
at_post_unpuppet(account) - (account-controlled objects only) called just
after disconnecting account<->object link
at_server_reload() - called before server is reloaded
at_server_shutdown() - called just before server is fully shut down
at_access(result, accessing_obj, access_type) - called with the result
of a lock access check on this object. Return value
does not affect check result.
at_pre_move(destination) - called just before moving object
to the destination. If returns False, move is cancelled.
announce_move_from(destination) - called in old location, just
before move, if obj.move_to() has quiet=False
announce_move_to(source_location) - called in new location, just
after move, if obj.move_to() has quiet=False
at_post_move(source_location) - always called after a move has
been successfully performed.
at_pre_object_leave(leaving_object, destination, **kwargs)
at_object_leave(obj, target_location, move_type="move", **kwargs)
at_object_leave(obj, target_location) - called when an object leaves
this object in any fashion
at_pre_object_receive(obj, source_location)
at_object_receive(obj, source_location, move_type="move", **kwargs) - called when this object
receives another object
at_post_move(source_location, move_type="move", **kwargs)
at_traverse(traversing_object, target_location, **kwargs) - (exit-objects only)
handles all moving across the exit, including
calling the other exit hooks. Use super() to retain
the default functionality.
at_post_traverse(traversing_object, source_location) - (exit-objects only)
called just after a traversal has happened.
at_failed_traverse(traversing_object) - (exit-objects only) called if
traversal fails and property err_traverse is not defined.
at_msg_receive(self, msg, from_obj=None, **kwargs) - called when a message
(via self.msg()) is sent to this obj.
If returns false, aborts send.
at_msg_send(self, msg, to_obj=None, **kwargs) - called when this objects
sends a message to someone via self.msg().
return_appearance(looker) - describes this object. Used by "look"
command by default
at_desc(looker=None) - called by 'look' whenever the
appearance is requested.
at_pre_get(getter, **kwargs)
at_get(getter) - called after object has been picked up.
Does not stop pickup.
at_pre_give(giver, getter, **kwargs)
at_give(giver, getter, **kwargs)
at_pre_drop(dropper, **kwargs)
at_drop(dropper, **kwargs) - called when this object has been dropped.
at_pre_say(speaker, message, **kwargs)
at_say(message, msg_self=None, msg_location=None, receivers=None, msg_receivers=None, **kwargs)
at_look(target, **kwargs)
at_desc(looker=None)
"""
# Determines which order command sets begin to be assembled from.
@ -316,7 +493,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
"obj.location to move an object here.".format(self.__class__)
)
contents = property(contents_get, contents_set, contents_set, contents_set)
contents = property(contents_get, contents_set, contents_set)
@property
def exits(self):
@ -378,7 +555,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
provided to the search function is included, and could be modified in-place here.
Args:
searchdata (str): The search criterion (could be modified by `get_search_query_replacement`).
searchdata (str): The search criterion (could be modified by
`get_search_query_replacement`).
**kwargs (any): These are the same as passed to the `search` method.
Returns:
@ -1011,8 +1189,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
the text message generated by announce_move_to and announce_move_from by defining
their {"type": move_type} for outgoing text. This can be used for altering
messages and/or overloaded hook behaviors.
**kwargs: Passed on to announce_move_to and announce_move_from hooks. Exits will set
the "exit_obj" kwarg to themselves.
**kwargs: Passed on to all movement- and announcement hooks. Use in your game to let
hooks know about any special condition of the move (such as running or sneaking).
Exits will pass an "exit_obj" kwarg.
Returns:
bool: True/False depending on if there were problems with the move. This method may also

View file

@ -12,7 +12,6 @@ from django.conf import settings
from django.core.paginator import Paginator
from django.db.models import Q
from django.utils.translation import gettext as _
from evennia.locks.lockhandler import check_lockstring, validate_lockstring
from evennia.objects.models import ObjectDB
from evennia.scripts.scripts import DefaultScript
@ -104,6 +103,7 @@ def homogenize_prototype(prototype, custom_keys=None):
prototype[protkey] = ""
homogenized = {}
homogenized_aliases = []
homogenized_tags = []
homogenized_attrs = []
homogenized_parents = []
@ -111,7 +111,10 @@ def homogenize_prototype(prototype, custom_keys=None):
for key, val in prototype.items():
if key in reserved:
# check all reserved keys
if key == "tags":
if key == "aliases":
# make sure aliases are always in a list even if given as a single string
homogenized_aliases = make_iter(val)
elif key == "tags":
# tags must be on form [(tag, category, data), ...]
tags = make_iter(prototype.get("tags", []))
for tag in tags:
@ -160,13 +163,14 @@ def homogenize_prototype(prototype, custom_keys=None):
else:
# normal prototype-parent names are added as-is
homogenized_parents.append(parent)
else:
# another reserved key
homogenized[key] = val
else:
# unreserved keys -> attrs
homogenized_attrs.append((key, val, None, ""))
if homogenized_aliases:
homogenized["aliases"] = homogenized_aliases
if homogenized_attrs:
homogenized["attrs"] = homogenized_attrs
if homogenized_tags:

View file

@ -7,7 +7,6 @@ added to all game objects. You access it through the property
"""
from django.utils.translation import gettext as _
from evennia.scripts.models import ScriptDB
from evennia.utils import create, logger
@ -73,18 +72,27 @@ class ScriptHandler(object):
Script: The newly created Script.
"""
if self.obj.__dbclass__.__name__ == "AccountDB":
# we add to an Account, not an Object
script = create.create_script(
scriptclass, key=key, account=self.obj, autostart=autostart
)
elif isinstance(scriptclass, str) or callable(scriptclass):
if isinstance(scriptclass, str) or callable(scriptclass):
# a str or class to use create before adding to an Object. We wait to autostart
# so we can differentiate a failing creation from a script that immediately starts/stops.
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False)
if self.obj.__dbclass__.__name__ == "AccountDB":
# we add to an Account, not an Object
script = create.create_script(
scriptclass, key=key, account=self.obj, autostart=False
)
else:
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False)
else:
# already an instantiated class
script = scriptclass
if script.db_obj and script.db_obj != self.obj:
logger.log_err(
f"Script instance {script} already belongs to "
f"another object: {script.db_obj}."
)
return None
script.db_obj = self.obj
script.save()
if not script:
logger.log_err(f"Script {scriptclass} failed to be created.")

View file

@ -6,13 +6,12 @@ ability to run timers.
"""
from django.utils.translation import gettext as _
from twisted.internet.defer import Deferred, maybeDeferred
from twisted.internet.task import LoopingCall
from evennia.scripts.manager import ScriptManager
from evennia.scripts.models import ScriptDB
from evennia.typeclasses.models import TypeclassBase
from evennia.utils import create, logger
from twisted.internet.defer import Deferred, maybeDeferred
from twisted.internet.task import LoopingCall
__all__ = ["DefaultScript", "DoNothing", "Store"]
@ -423,7 +422,12 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
updates = []
if not cdict.get("key"):
if not self.db_key:
self.db_key = "#%i" % self.dbid
if hasattr(self, "key"):
# take key from the object typeclass
self.db_key = self.key
else:
# no key set anywhere, use class+dbid as key
self.db_key = f"{self.__class__.__name__}(#{self.dbid})"
updates.append("db_key")
elif self.db_key != cdict["key"]:
self.db_key = cdict["key"]
@ -672,8 +676,85 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
class DefaultScript(ScriptBase):
"""
This is the base TypeClass for all Scripts. Scripts describe
events, timers and states in game, they can have a time component
or describe a state that changes under certain conditions.
all entities/systems without a physical existence in the game world
that require database storage (like an economic system or
combat tracker). They
can also have a timer/ticker component.
A script type is customized by redefining some or all of its hook
methods and variables.
* available properties (check docs for full listing, this could be
outdated).
key (string) - name of object
name (string)- same as key
aliases (list of strings) - aliases to the object. Will be saved
to database as AliasDB entries but returned as strings.
dbref (int, read-only) - unique #id-number. Also "id" can be used.
date_created (string) - time stamp of object creation
permissions (list of strings) - list of permission strings
desc (string) - optional description of script, shown in listings
obj (Object) - optional object that this script is connected to
and acts on (set automatically by obj.scripts.add())
interval (int) - how often script should run, in seconds. <0 turns
off ticker
start_delay (bool) - if the script should start repeating right away or
wait self.interval seconds
repeats (int) - how many times the script should repeat before
stopping. 0 means infinite repeats
persistent (bool) - if script should survive a server shutdown or not
is_active (bool) - if script is currently running
* Handlers
locks - lock-handler: use locks.add() to add new lock strings
db - attribute-handler: store/retrieve database attributes on this
self.db.myattr=val, val=self.db.myattr
ndb - non-persistent attribute handler: same as db but does not
create a database entry when storing data
* Helper methods
create(key, **kwargs)
start() - start script (this usually happens automatically at creation
and obj.script.add() etc)
stop() - stop script, and delete it
pause() - put the script on hold, until unpause() is called. If script
is persistent, the pause state will survive a shutdown.
unpause() - restart a previously paused script. The script will continue
from the paused timer (but at_start() will be called).
time_until_next_repeat() - if a timed script (interval>0), returns time
until next tick
* Hook methods (should also include self as the first argument):
at_script_creation() - called only once, when an object of this
class is first created.
is_valid() - is called to check if the script is valid to be running
at the current time. If is_valid() returns False, the running
script is stopped and removed from the game. You can use this
to check state changes (i.e. an script tracking some combat
stats at regular intervals is only valid to run while there is
actual combat going on).
at_start() - Called every time the script is started, which for persistent
scripts is at least once every server start. Note that this is
unaffected by self.delay_start, which only delays the first
call to at_repeat().
at_repeat() - Called every self.interval seconds. It will be called
immediately upon launch unless self.delay_start is True, which
will delay the first call of this method by self.interval
seconds. If self.interval==0, this method will never
be called.
at_pause()
at_stop() - Called as the script object is stopped and is about to be
removed from the game, e.g. because is_valid() returned False.
at_script_delete()
at_server_reload() - Called when server reloads. Can be used to
save temporary variables you want should survive a reload.
at_server_shutdown() - called at a full server shutdown.
at_server_start()
"""

View file

@ -6,8 +6,6 @@ Unit tests for the scripts package
from collections import defaultdict
from unittest import TestCase, mock
from parameterized import parameterized
from evennia import DefaultScript
from evennia.objects.objects import DefaultObject
from evennia.scripts.manager import ScriptDBManager
@ -19,6 +17,7 @@ from evennia.scripts.tickerhandler import TickerHandler
from evennia.utils.create import create_script
from evennia.utils.dbserialize import dbserialize
from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTest
from parameterized import parameterized
class TestScript(BaseEvenniaTest):
@ -105,6 +104,15 @@ class TestScriptHandler(BaseEvenniaTest):
script = self.obj.scripts.get("interval_test")
self.assertTrue(bool(script))
def test_add_already_existing_script(self):
"Checks that Scripthandler add function adds script correctly"
# make a new script with no obj connection
script = create_script(TestingListIntervalScript, key="interval_test2")
self.obj.scripts.add(script)
self.assertEqual([script], list(self.obj.scripts.get("interval_test2")))
self.assertTrue(bool(self.obj.scripts.get("interval_test")))
class TestScriptDB(TestCase):
"Check the singleton/static ScriptDB object works correctly"

View file

@ -429,6 +429,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS):
- xterm256: Enforce xterm256 colors, regardless of TTYPE.
- noxterm256: Enforce no xterm256 color support, regardless of TTYPE.
- nocolor: Strip all Color, regardless of ansi/xterm256 setting.
- truecolor: Enforce truecolor, regardless of TTYPE.
- raw: Pass string through without any ansi processing
(i.e. include Evennia ansi markers but do not
convert them into ansi tokens)
@ -447,6 +448,9 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS):
xterm256 = options.get(
"xterm256", flags.get("XTERM256", False) if flags.get("TTYPE", False) else True
)
truecolor = options.get(
"truecolor", flags.get("TRUECOLOR", False) if flags.get("TTYPE", False) else True
)
useansi = options.get(
"ansi", flags.get("ANSI", False) if flags.get("TTYPE", False) else True
)
@ -470,6 +474,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS):
_RE_N.sub("", prompt) + ("||n" if prompt.endswith("|") else "|n"),
strip_ansi=nocolor,
xterm256=xterm256,
truecolor=truecolor,
)
if mxp:
prompt = mxp_parse(prompt)
@ -506,6 +511,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS):
strip_ansi=nocolor,
xterm256=xterm256,
mxp=mxp,
truecolor=truecolor,
)
if mxp:
linetosend = mxp_parse(linetosend)

View file

@ -44,7 +44,7 @@ class Ttype:
def __init__(self, protocol):
"""
Initialize ttype by storing protocol on ourselves and calling
the client to see if it supporst ttype.
the client to see if it supports ttype.
Args:
protocol (Protocol): The protocol instance.
@ -147,9 +147,15 @@ class Ttype:
):
xterm256 = True
# use name to identify support for xterm truecolor
truecolor = False
if clientname.endswith("-TRUECOLOR") or clientname in ("AXMUD", "TINTIN"):
truecolor = True
# all clients supporting TTYPE at all seem to support ANSI
self.protocol.protocol_flags["ANSI"] = True
self.protocol.protocol_flags["XTERM256"] = xterm256
self.protocol.protocol_flags["TRUECOLOR"] = truecolor
self.protocol.protocol_flags["CLIENTNAME"] = clientname
self.protocol.requestNegotiation(TTYPE, SEND)

View file

@ -17,7 +17,6 @@ from copy import copy
from django.conf import settings
from django.db import models
from django.utils.encoding import smart_str
from evennia.locks.lockhandler import LockHandler
from evennia.utils.dbserialize import from_pickle, to_pickle
from evennia.utils.idmapper.models import SharedMemoryModel
@ -142,6 +141,8 @@ class InMemoryAttribute(IAttribute):
# Value and locks are special. We must call the wrappers.
if key == "value":
self.value = value
elif key == "strvalue":
self.db_strvalue = value
elif key == "lock_storage":
self.lock_storage = value
else:

View file

@ -33,6 +33,7 @@ from django.db import models
from django.db.models import signals
from django.db.models.base import ModelBase
from django.urls import reverse
from django.utils import timezone
from django.utils.encoding import smart_str
from django.utils.text import slugify
@ -225,7 +226,8 @@ class TypedObject(SharedMemoryModel):
),
db_index=True,
)
# Creation date. This is not changed once the object is created.
# Creation date. This is not changed once the object is created. Note that this is UTC,
# use the .date_created property to get a localized version.
db_date_created = models.DateTimeField("creation date", editable=False, auto_now_add=True)
# Lock storage
db_lock_storage = models.TextField(
@ -420,6 +422,11 @@ class TypedObject(SharedMemoryModel):
self.at_rename(oldname, value)
SIGNAL_TYPED_OBJECT_POST_RENAME.send(sender=self, old_key=oldname, new_key=value)
@property
def date_created(self):
"""Get the localized date created, based on settings.TIME_ZONE."""
return timezone.localtime(self.db_date_created)
#
#
# TypedObject main class methods and properties

View file

@ -67,10 +67,13 @@ 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
hex2truecolor = HexColors()
hex_sub = HexColors.hex_sub
MXP_ENABLED = settings.MXP_ENABLED
# ANSI definitions
@ -80,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"
@ -115,7 +123,7 @@ ANSI_TAB = "\t"
ANSI_SPACE = " "
# Escapes
ANSI_ESCAPES = ("{{", "\\\\", "\|\|")
ANSI_ESCAPES = ("{{", r"\\", r"\|\|")
_PARSE_CACHE = OrderedDict()
_PARSE_CACHE_SIZE = 10000
@ -145,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),
@ -432,7 +445,7 @@ class ANSIParser(object):
"""
return self.unsafe_tokens.sub("", string)
def parse_ansi(self, string, strip_ansi=False, xterm256=False, mxp=False):
def parse_ansi(self, string, strip_ansi=False, xterm256=False, mxp=False, truecolor=False):
"""
Parses a string, subbing color codes according to the stored
mapping.
@ -459,13 +472,17 @@ class ANSIParser(object):
# check cached parsings
global _PARSE_CACHE
cachekey = "%s-%s-%s-%s" % (string, strip_ansi, xterm256, mxp)
cachekey = f"{string}-{strip_ansi}-{xterm256}-{mxp}-{truecolor}"
if cachekey in _PARSE_CACHE:
return _PARSE_CACHE[cachekey]
# pre-convert bright colors to xterm256 color tags
string = self.brightbg_sub.sub(self.sub_brightbg, string)
def do_truecolor(part: re.Match, truecolor=truecolor):
return hex2truecolor.sub_truecolor(part, truecolor)
def do_xterm256_fg(part):
return self.sub_xterm256(part, xterm256, "fg")
@ -484,7 +501,8 @@ class ANSIParser(object):
parsed_string = []
parts = self.ansi_escapes.split(in_string) + [" "]
for part, sep in zip(parts[::2], parts[1::2]):
pstring = self.xterm256_fg_sub.sub(do_xterm256_fg, part)
pstring = hex_sub.sub(do_truecolor, part)
pstring = self.xterm256_fg_sub.sub(do_xterm256_fg, pstring)
pstring = self.xterm256_bg_sub.sub(do_xterm256_bg, pstring)
pstring = self.xterm256_gfg_sub.sub(do_xterm256_gfg, pstring)
pstring = self.xterm256_gbg_sub.sub(do_xterm256_gbg, pstring)
@ -516,7 +534,9 @@ ANSI_PARSER = ANSIParser()
#
def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=False):
def parse_ansi(
string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=False, truecolor=False
):
"""
Parses a string, subbing color codes as needed.
@ -526,13 +546,16 @@ def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp
parser (ansi.AnsiParser, optional): A parser instance to use.
xterm256 (bool, optional): Support xterm256 or not.
mxp (bool, optional): Support MXP markup or not.
truecolor (bool, optional): Support for truecolor or not.
Returns:
string (str): The parsed string.
"""
string = string or ""
return parser.parse_ansi(string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp)
return parser.parse_ansi(
string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp, truecolor=truecolor
)
def strip_ansi(string, parser=ANSI_PARSER):
@ -798,7 +821,7 @@ class ANSIString(str, metaclass=ANSIMeta):
if not decoded:
# Completely new ANSI String
clean_string = parser.parse_ansi(string, strip_ansi=True, mxp=MXP_ENABLED)
string = parser.parse_ansi(string, xterm256=True, mxp=MXP_ENABLED)
string = parser.parse_ansi(string, xterm256=True, mxp=MXP_ENABLED, truecolor=True)
elif clean_string is not None:
# We have an explicit clean string.
pass

View file

@ -14,7 +14,7 @@ from pickle import dumps
from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError
from evennia.scripts.models import ScriptDB
from evennia.utils import logger
from evennia.utils.utils import callables_from_module, class_from_module
@ -217,7 +217,7 @@ class GlobalScriptContainer(Container):
"""
if not self.loaded:
self.load_data()
out_value = default
script_found = None
if key in self.loaded_data:
if key not in self.typeclass_storage:
# this means we are trying to load in a loop
@ -230,8 +230,12 @@ class GlobalScriptContainer(Container):
script_found = self._load_script(key)
if script_found:
out_value = script_found
else:
# script not found in settings, see if one exists in database (not
# auto-started/recreated)
script_found = ScriptDB.objects.filter(db_key__iexact=key, db_obj__isnull=True).first()
return out_value
return script_found if script_found is not None else default
def all(self):
"""
@ -239,12 +243,19 @@ class GlobalScriptContainer(Container):
scripts defined in settings.
Returns:
scripts (list): All global script objects stored on the container.
list: All global script objects in game (both managed and unmanaged),
sorted alphabetically.
"""
if not self.loaded:
self.load_data()
return list(self.loaded_data.values())
managed_scripts = list(self.loaded_data.values())
unmanaged_scripts = list(
ScriptDB.objects.filter(db_obj__isnull=True).exclude(
id__in=[scr.id for scr in managed_scripts]
)
)
return list(sorted(managed_scripts + unmanaged_scripts, key=lambda scr: scr.db_key))
# Create all singletons

View file

@ -67,7 +67,7 @@ _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
# -------------------------------------------------------------
_HELP_TEXT = _(
"""
f"""
<txt> - any non-command is appended to the end of the buffer.
: <l> - view buffer or only line(s) <l>
:: <l> - raw-view buffer or only line(s) <l>
@ -97,8 +97,11 @@ _HELP_TEXT = _(
:s <l> <w> <txt> - search/replace word or regex <w> in buffer or on line <l>
:j <l> <w> - justify buffer or line <l>. <w> is f, c, l or r. Default f (full)
:f <l> - flood-fill entire buffer or line <l>. Equivalent to :j <l> l
:j <l> <a> = <w> - justify buffer or line <l>. <a> is f, c, l or r. <w> is
width. <a> and <w> are optional and default to l (left)
and {_DEFAULT_WIDTH} respectively
:f <l> = <w> - flood-fill entire buffer or line <l> to width <w>.
Equivalent to :j <l> l. <w> is optional, as for :j
:fi <l> - indent entire buffer or line <l>
:fd <l> - de-indent entire buffer or line <l>
@ -686,7 +689,14 @@ class CmdEditorGroup(CmdEditorBase):
editor.update_buffer(buf)
elif cmd == ":f":
# :f <l> flood-fill buffer or <l> lines of buffer.
# :f <l> =<w> flood-fill buffer or <l> lines of buffer to width <w>.
width = _DEFAULT_WIDTH
if self.arg1:
value = self.arg1.lstrip("=")
if not value.isdigit():
self.caller.msg("Width must be a number.")
return
width = int(value)
if not self.linerange:
lstart = 0
lend = self.cline + 1
@ -698,7 +708,7 @@ class CmdEditorGroup(CmdEditorBase):
buf = linebuffer[:lstart] + fbuf.split("\n") + linebuffer[lend:]
editor.update_buffer(buf)
elif cmd == ":j":
# :f <l> <w> justify buffer of <l> with <w> as align (one of
# :j <l> <a> =<w> justify buffer of <l> to width <w> with <a> as align (one of
# f(ull), c(enter), r(ight) or l(left). Default is full.
align_map = {
"full": "f",
@ -711,7 +721,10 @@ class CmdEditorGroup(CmdEditorBase):
"l": "l",
}
align_name = {"f": "Full", "c": "Center", "l": "Left", "r": "Right"}
width = _DEFAULT_WIDTH
# shift width arg right if no alignment specified
if self.arg1.startswith("="):
self.arg2 = self.arg1
self.arg1 = None
if self.arg1 and self.arg1.lower() not in align_map:
self.caller.msg(
_("Valid justifications are")
@ -719,6 +732,13 @@ class CmdEditorGroup(CmdEditorBase):
)
return
align = align_map[self.arg1.lower()] if self.arg1 else "f"
width = _DEFAULT_WIDTH
if self.arg2:
value = self.arg2.lstrip("=")
if not value.isdigit():
self.caller.msg("Width must be a number.")
return
width = int(value)
if not self.linerange:
lstart = 0
lend = self.cline + 1
@ -822,6 +842,7 @@ class EvEditorCmdSet(CmdSet):
"""CmdSet for the editor commands"""
key = "editorcmdset"
priority = 150 # override other cmdsets.
mergetype = "Replace"
def at_cmdset_creation(self):

View file

@ -520,13 +520,15 @@ class EvMenu:
startnode (str, optional): The starting node name in the menufile.
cmdset_mergetype (str, optional): 'Replace' (default) means the menu
commands will be exclusive - no other normal commands will
be usable while the user is in the menu. 'Union' means the
menu commands will be integrated with the existing commands
(it will merge with `merge_priority`), if so, make sure that
the menu's command names don't collide with existing commands
in an unexpected way. Also the CMD_NOMATCH and CMD_NOINPUT will
be overloaded by the menu cmdset. Other cmdser mergetypes
has little purpose for the menu.
be usable while the user is in the menu. 'Union' does merge the menu
command, but note that the only command used in EvMenu has key/alias
of NOINPUT/NOMATCH. So if you merge with 'Union' and a high `cmdset_prio`
(below), you won't replace individual normal commands as you may
expect. Instead commands will work normally and you'll only always fall
back to menu commands when no other command is found. There is no way
to partially replace normal commands with EvMenu actions - to do this,
remove the normal command from the caller's cmdset - if not found
the menu's version will kick in instead.
cmdset_priority (int, optional): The merge priority for the
menu command set. The default (1) is usually enough for most
types of menus.

168
evennia/utils/hex_colors.py Normal file
View file

@ -0,0 +1,168 @@
import re
class HexColors:
"""
This houses a method for converting hex codes to xterm truecolor codes
or falls back to evennia xterm256 codes to be handled by sub_xterm256
Based on code from @InspectorCaracal
"""
_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]"
_RE_XTERM_TRUECOLOR = rf"\[([34])8;2;({_RE_BYTE});({_RE_BYTE});({_RE_BYTE})m"
# Used in hex_sub
_RE_HEX_PATTERN = f"({_RE_FG_OR_BG})({_RE_HEX_LONG}|{_RE_HEX_SHORT})"
# Used for greyscale
_GREYS = "abcdefghijklmnopqrstuvwxyz"
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)
def _split_hex_to_bytes(self, tag: str) -> tuple[str, str, str]:
"""
Splits hex string into separate bytes:
#00FF00 -> ('00', 'FF', '00')
#CF3 -> ('CC', 'FF', '33')
Args:
tag (str): the tag to convert
Returns:
str: the text with converted tags
"""
strip_leading = re.compile(rf"{self._RE_FG_OR_BG}")
tag = strip_leading.sub("", tag)
if len(tag) == 6:
# 6 digits
r, g, b = (tag[i : i + 2] for i in range(0, 6, 2))
else:
# 3 digits
r, g, b = (tag[i : i + 1] * 2 for i in range(0, 3, 1))
return r, g, b
def _grey_int(self, num: int) -> int:
"""
Returns a grey greyscale integer
Returns:
"""
return round(max((int(num) - 8), 0) / 10)
def _hue_int(self, num: int) -> int:
return round(max((int(num) - 45), 0) / 40)
def _hex_to_rgb_24_bit(self, hex_code: str) -> tuple[int, int, int]:
"""
Converts a hex color code (#000 or #000000) into
a 3-int tuple (0, 255, 90)
Args:
hex_code (str): HTML hex color code
Returns:
24-bit rgb tuple: (int, int, int)
"""
# Strip the leading indicator if present
hex_code = re.sub(rf"{self._RE_FG_OR_BG}", "", hex_code)
r, g, b = self._split_hex_to_bytes(hex_code)
return int(r, 16), int(g, 16), int(b, 16)
def _rgb_24_bit_to_256(self, r: int, g: int, b: int) -> tuple[int, int, int]:
"""
converts 0-255 hex color codes to 0-5
Args:
r (int): red
g (int): green
b (int): blue
Returns:
256 color rgb tuple: (int, int, int)
"""
return self._hue_int(r), self._hue_int(g), self._hue_int(b)
def sub_truecolor(self, match: re.Match, truecolor=False) -> str:
"""
Converts a hex string to xterm truecolor code, greyscale, or
falls back to evennia xterm256 to be handled by sub_xterm256
Args:
match (re.match): first group is the leading indicator,
second is the tag
truecolor (bool): return xterm truecolor or fallback
Returns:
Newly formatted indicator and tag (str)
"""
indicator, tag = match.groups()
# Remove the # sign
indicator = indicator.replace("#", "")
r, g, b = self._hex_to_rgb_24_bit(tag)
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:
xtag = f"\033["
if "[" in indicator:
# Background Color
xtag += "4"
else:
xtag += "3"
xtag += f"8;2;{r};{g};{b}m"
return xtag
def xterm_truecolor_to_html_style(self, fg="", bg="") -> str:
"""
Converts xterm truecolor to an html style property
Args:
fg: xterm truecolor
bg: xterm truecolor
Returns: style='color and or background-color'
"""
prop = 'style="'
if fg != "":
res = re.search(self._RE_XTERM_TRUECOLOR, fg, re.DOTALL)
fg_bg, r, g, b = res.groups()
r = hex(int(r))[2:].zfill(2)
g = hex(int(g))[2:].zfill(2)
b = hex(int(b))[2:].zfill(2)
prop += f"color: #{r}{g}{b};"
if bg != "":
res = re.search(self._RE_XTERM_TRUECOLOR, bg, re.DOTALL)
fg_bg, r, g, b = res.groups()
r = hex(int(r))[2:].zfill(2)
g = hex(int(g))[2:].zfill(2)
b = hex(int(b))[2:].zfill(2)
prop += f"background-color: #{r}{g}{b};"
prop += f'"'
return prop

View file

@ -144,12 +144,12 @@ class TestFuncParser(TestCase):
(r'Test args3 $bar(foo, bar, " too")', "Test args3 _test(foo, bar, too)"),
("Test args4 $foo('')", "Test args4 _test('')"), # ' treated as literal
('Test args4 $foo("")', "Test args4 _test()"),
("Test args5 $foo(\(\))", "Test args5 _test(())"),
("Test args6 $foo(\()", "Test args6 _test(()"),
(r"Test args5 $foo(\(\))", "Test args5 _test(())"),
(r"Test args6 $foo(\()", "Test args6 _test(()"),
("Test args7 $foo(())", "Test args7 _test(())"),
("Test args8 $foo())", "Test args8 _test())"),
("Test args9 $foo(=)", "Test args9 _test(=)"),
("Test args10 $foo(\,)", "Test args10 _test(,)"),
(r"Test args10 $foo(\,)", "Test args10 _test(,)"),
(r'Test args10 $foo(",")', "Test args10 _test(,)"),
("Test args11 $foo(()", "Test args11 $foo(()"), # invalid syntax
(
@ -327,7 +327,7 @@ class TestFuncParser(TestCase):
"""
string = "Test $foo(a) and $bar() and $rep(c) things"
ret = self.parser.parse(string, escape=True)
self.assertEqual("Test \$foo(a) and \$bar() and \$rep(c) things", ret)
self.assertEqual(r"Test \$foo(a) and \$bar() and \$rep(c) things", ret)
def test_parse_lit(self):
"""

View file

@ -46,6 +46,12 @@ class TestText2Html(TestCase):
parser.format_styles("a " + ansi.ANSI_INVERSE + "red" + ansi.ANSI_NORMAL + "foo"),
)
# True Color
self.assertEqual(
'<span class="" style="color: #ff0000;">red</span>foo',
parser.format_styles(f"\x1b[38;2;255;0;0m" + "red" + ansi.ANSI_NORMAL + "foo"),
)
def test_remove_bells(self):
parser = text2html.HTML_PARSER
self.assertEqual("foo", parser.remove_bells("foo"))

View file

@ -0,0 +1,148 @@
from django.test import TestCase
from evennia.utils.ansi import ANSIParser
from evennia.utils.ansi import ANSIString as AN
parser = ANSIParser().parse_ansi
class TestANSIStringHex(TestCase):
"""
Tests the conversion of html hex colors
to xterm-style colors
"""
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_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_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_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_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_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_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_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_truecolor, "Output truecolor")
self.assertEqual(ansi_256, self.output4, "Output xterm256")
class TestANSIParser(TestCase):
"""
Tests the ansi fallback of the hex color conversion and
truecolor conversion
"""
def setUp(self):
self.parser = ANSIParser().parse_ansi
self.str = "test "
# ANSI FALLBACK
# Red
self.output1 = "\x1b[1m\x1b[31mtest \x1b[0m"
# White
self.output2 = "\x1b[1m\x1b[37mtest \x1b[0m"
# Red BG
self.output3 = "\x1b[41mtest \x1b[0m"
# Blue FG, Red BG
self.output4 = "\x1b[41m\x1b[1m\x1b[34mtest \x1b[0m"
def test_hex_color(self):
raw = f"|#F00{self.str}|n"
ansi = parser(raw)
# self.assertEqual(ansi, self.str, "Cleaned")
self.assertEqual(ansi, self.output1, "Output")
def test_hex_greyscale(self):
raw = f"|#FFF{self.str}|n"
ansi = parser(raw)
self.assertEqual(ansi, self.output2, "Output")
def test_hex_color_bg(self):
raw = f"|[#Ff0000{self.str}|n"
ansi = parser(raw)
self.assertEqual(ansi, self.output3, "Output")
def test_hex_color_fg_bg(self):
raw = f"|[#Ff0000|#00f{self.str}|n"
ansi = parser(raw)
self.assertEqual(ansi, self.output4, "Output")
def test_truecolor_fg(self):
raw = f"|#00c700{self.str}|n"
ansi = parser(raw, truecolor=True)
output = f"\x1b[38;2;0;199;0m{self.str}\x1b[0m"
self.assertEqual(ansi, output, "Output")
def test_truecolor_bg(self):
raw = f"|[#00c700{self.str}|n"
ansi = parser(raw, truecolor=True)
output = f"\x1b[48;2;0;199;0m{self.str}\x1b[0m"
self.assertEqual(ansi, output, "Output")
def test_truecolor_fg_bg(self):
raw = f"|[#00c700|#880000{self.str}|n"
ansi = parser(raw, truecolor=True)
output = f"\x1b[48;2;0;199;0m\x1b[38;2;136;0;0m{self.str}\x1b[0m"
self.assertEqual(ansi, output, "Output")

View file

@ -812,6 +812,65 @@ class TestJustify(TestCase):
self.assertIn(ANSI_RED, str(result))
class TestAtSearchResult(TestCase):
"""
Test the utils.at_search_result function.
"""
class MockObject:
def __init__(self, key):
self.key = key
self.aliases = ''
def get_display_name(self, looker, **kwargs):
return self.key
def get_extra_info(self, looker, **kwargs):
return ''
def __repr__(self):
return f"MockObject({self.key})"
def test_single_match(self):
"""if there is only one match, it should return the matched object"""
obj1 = self.MockObject("obj1")
caller = mock.MagicMock()
self.assertEqual(obj1, utils.at_search_result([obj1], caller, "obj1"))
def test_no_match(self):
"""if there are no provided matches, the caller should receive the correct error message"""
caller = mock.MagicMock()
self.assertIsNone(utils.at_search_result([], caller, "obj1"))
caller.msg.assert_called_once_with("Could not find 'obj1'.")
def test_basic_multimatch(self):
"""multiple matches with the same name should return a message with incrementing indices"""
matches = [ self.MockObject("obj1") for _ in range(3) ]
caller = mock.MagicMock()
self.assertIsNone(utils.at_search_result(matches, caller, "obj1"))
multimatch_msg = """\
More than one match for 'obj1' (please narrow target):
obj1-1
obj1-2
obj1-3"""
caller.msg.assert_called_once_with(multimatch_msg)
def test_partial_multimatch(self):
"""multiple partial matches with different names should increment index by unique name"""
matches = [ self.MockObject("obj1") for _ in range(3) ] + [ self.MockObject("obj2") for _ in range(2) ]
caller = mock.MagicMock()
self.assertIsNone(utils.at_search_result(matches, caller, "obj"))
multimatch_msg = """\
More than one match for 'obj' (please narrow target):
obj1-1
obj1-2
obj1-3
obj2-1
obj2-2"""
caller.msg.assert_called_once_with(multimatch_msg)
class TestGroupObjectsByKeyAndDesc(TestCase):
"""
Test the utils.group_objects_by_key_and_desc function.

View file

@ -12,12 +12,15 @@ import re
from html import escape as html_escape
from .ansi import *
from .hex_colors import HexColors
# All xterm256 RGB equivalents
XTERM256_FG = "\033[38;5;{}m"
XTERM256_BG = "\033[48;5;{}m"
hex_colors = HexColors()
class TextToHTMLparser(object):
"""
@ -67,12 +70,12 @@ class TextToHTMLparser(object):
]
xterm_bg_codes = [XTERM256_BG.format(i + 16) for i in range(240)]
re_style = re.compile(
r"({})".format(
r"({}|{})".format(
"|".join(
style_codes + ansi_color_codes + xterm_fg_codes + ansi_bg_codes + xterm_bg_codes
).replace("[", r"\[")
).replace("[", r"\["),
"|".join([HexColors.TRUECOLOR_FG, HexColors.TRUECOLOR_BG]),
)
)
@ -244,6 +247,7 @@ class TextToHTMLparser(object):
# split out the ANSI codes and clean out any empty items
str_list = [substr for substr in self.re_style.split(text) if substr]
# initialize all the flags and classes
classes = []
clean = True
@ -253,6 +257,8 @@ class TextToHTMLparser(object):
fg = ANSI_WHITE
# default bg is black
bg = ANSI_BACK_BLACK
truecolor_fg = ""
truecolor_bg = ""
for i, substr in enumerate(str_list):
# reset all current styling
@ -266,6 +272,8 @@ class TextToHTMLparser(object):
hilight = ANSI_UNHILITE
fg = ANSI_WHITE
bg = ANSI_BACK_BLACK
truecolor_fg = ""
truecolor_bg = ""
# change color
elif substr in self.ansi_color_codes + self.xterm_fg_codes:
@ -281,6 +289,14 @@ class TextToHTMLparser(object):
# set new bg
bg = substr
elif re.match(hex_colors.TRUECOLOR_FG, substr):
str_list[i] = ""
truecolor_fg = substr
elif re.match(hex_colors.TRUECOLOR_BG, substr):
str_list[i] = ""
truecolor_bg = substr
# non-color codes
elif substr in self.style_codes:
# erase ANSI code from output
@ -319,9 +335,23 @@ class TextToHTMLparser(object):
color_index = self.colorlist.index(fg)
if inverse:
# inverse means swap fg and bg indices
bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0"))
color_class = "color-{}".format(str(bg_index).rjust(3, "0"))
if truecolor_fg != "" and truecolor_bg != "":
# True startcolor only
truecolor_fg, truecolor_bg = truecolor_bg, truecolor_fg
elif truecolor_fg != "" and truecolor_bg == "":
# Truecolor fg, class based bg
truecolor_bg = truecolor_fg
truecolor_fg = ""
color_class = "color-{}".format(str(bg_index).rjust(3, "0"))
elif truecolor_fg == "" and truecolor_bg != "":
# Truecolor bg, class based fg
truecolor_fg = truecolor_bg
truecolor_bg = ""
bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0"))
else:
# inverse means swap fg and bg indices
bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0"))
color_class = "color-{}".format(str(bg_index).rjust(3, "0"))
else:
# use fg and bg indices for classes
bg_class = "bgcolor-{}".format(str(bg_index).rjust(3, "0"))
@ -333,8 +363,18 @@ class TextToHTMLparser(object):
# light grey text is the default, don't explicitly style
if color_class != "color-007":
classes.append(color_class)
# define the new style span
prefix = '<span class="{}">'.format(" ".join(classes))
if truecolor_fg == "" and truecolor_bg == "":
prefix = f'<span class="{" ".join(classes)}">'
else:
# Classes can't be used for truecolor--but they can be extras such as 'blink'
prefix = (
f"<span "
f'class="{" ".join(classes)}" '
f"{hex_colors.xterm_truecolor_to_html_style(fg=truecolor_fg, bg=truecolor_bg)}>"
)
# close any prior span
if not clean:
prefix = "</span>" + prefix
@ -366,7 +406,7 @@ class TextToHTMLparser(object):
"""
# parse everything to ansi first
text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True)
text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True, truecolor=True)
# convert all ansi to html
result = re.sub(self.re_string, self.sub_text, text)
result = re.sub(self.re_mxplink, self.sub_mxp_links, result)

View file

@ -473,7 +473,7 @@ def iter_to_str(iterable, sep=",", endsep=", and", addquote=False):
list_to_string = iter_to_str
iter_to_string = iter_to_str
re_empty = re.compile("\n\s*\n")
re_empty = re.compile("\n\\s*\n")
def compress_whitespace(text, max_linebreaks=1, max_spacing=2):
@ -494,7 +494,7 @@ def compress_whitespace(text, max_linebreaks=1, max_spacing=2):
# this allows the blank-line compression to eliminate them if needed
text = re_empty.sub("\n\n", text)
# replace groups of extra spaces with the maximum number of spaces
text = re.sub(f"(?<=\S) {{{max_spacing},}}", " " * max_spacing, text)
text = re.sub(fr"(?<=\S) {{{max_spacing},}}", " " * max_spacing, text)
# replace groups of extra newlines with the maximum number of newlines
text = re.sub(f"\n{{{max_linebreaks},}}", "\n" * max_linebreaks, text)
return text
@ -2397,28 +2397,35 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs):
query=query
)
for num, result in enumerate(matches):
# we need to consider that result could be a Command, where .aliases
# is a list of strings
if hasattr(result.aliases, "all"):
# result is a typeclassed entity where `.aliases` is an AliasHandler.
aliases = result.aliases.all(return_objs=True)
# remove pluralization aliases
aliases = [alias.db_key for alias in aliases if alias.db_category != "plural_key"]
else:
# result is likely a Command, where `.aliases` is a list of strings.
aliases = result.aliases
error += _MULTIMATCH_TEMPLATE.format(
number=num + 1,
name=(
result.get_display_name(caller)
if hasattr(result, "get_display_name")
# group results by display name to properly disambiguate
grouped_matches = defaultdict(list)
for item in matches:
group_key = (
item.get_display_name(caller)
if hasattr(item, "get_display_name")
else query
),
aliases=" [{alias}]".format(alias=";".join(aliases)) if aliases else "",
info=result.get_extra_info(caller),
)
)
grouped_matches[group_key].append(item)
for key, match_list in grouped_matches.items():
for num, result in enumerate(match_list):
# we need to consider that result could be a Command, where .aliases
# is a list of strings
if hasattr(result.aliases, "all"):
# result is a typeclassed entity where `.aliases` is an AliasHandler.
aliases = result.aliases.all(return_objs=True)
# remove pluralization aliases
aliases = [alias.db_key for alias in aliases if alias.db_category != "plural_key"]
else:
# result is likely a Command, where `.aliases` is a list of strings.
aliases = result.aliases
error += _MULTIMATCH_TEMPLATE.format(
number=num + 1,
name=key,
aliases=" [{alias}]".format(alias=";".join(aliases)) if aliases else "",
info=result.get_extra_info(caller),
)
matches = None
else:
# exactly one match

View file

@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "website/base.html" %}
{% block titleblock %}
{{ view.page_title }} ({{ object }})

View file

@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "website/base.html" %}
{% block titleblock %}
{{ view.page_title }}

Some files were not shown because too many files have changed in this diff Show more