mirror of
https://github.com/evennia/evennia.git
synced 2026-04-05 23:47:16 +02:00
Merge branch 'evennia:main' into is_typing
This commit is contained in:
commit
a92244b8e8
68 changed files with 2540 additions and 491 deletions
70
CHANGELOG.md
70
CHANGELOG.md
|
|
@ -5,13 +5,81 @@
|
|||
- [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)
|
||||
- [Feature][pull3498]: Properly update Evennia's screen width when client
|
||||
changes width (assuming client supports NAWS properly) (michaelfaith84)
|
||||
- [Feature][pull3502]: New `sethelp/locks` allows for editing help entry
|
||||
locks after they were first created (chiizujin)
|
||||
- [Feature][pull3514]: Support `$pron(pronoun, key)` and new `$pconj(verb, key)`
|
||||
(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)
|
||||
- [Docs]: Doc fixes (Griatch, chiizujin)
|
||||
- [Fix][pull3489]: Superuser could break wilderness contrib exits (t34lbytes)
|
||||
- [Fix][pull3496]: EvEditor would not correctly show search&replace feedback
|
||||
when replacing colors (Chiizujin)
|
||||
- [Fix][pull3499]: Dig/tunnel commands didn't echo the typeclass of the newly
|
||||
created room properly (chiizujin)
|
||||
- [Fix][pull3501]: Using `sethelp` to create a help entry colliding with a
|
||||
command-name made the entry impossible to edit/delete later (chiizujin)
|
||||
- [Fix][pull3506]: Fix Traceback when setting prototype parent in the in-game OLC wizard (chiizujin)
|
||||
- [Fix][pull3507]: Prototype wizard would not save changes if aborting the
|
||||
updating of existing spawned instances (chiizujun)
|
||||
- [Fix][pull3516]: Quitting the chargen contrib menu will now trigger auto-look (InspectorCaracal)
|
||||
- [Fix][pull3517]: Supply `Object.search` with an empty `candidates` list caused
|
||||
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)
|
||||
- [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)
|
||||
- [Docs]: Doc fixes (Griatch, chiizujin, InspectorCaracal, iLPDev)
|
||||
|
||||
[pull3470]: https://github.com/evennia/evennia/pull/3470
|
||||
[pull3495]: https://github.com/evennia/evennia/pull/3495
|
||||
[pull3491]: https://github.com/evennia/evennia/pull/3491
|
||||
[pull3489]: https://github.com/evennia/evennia/pull/3489
|
||||
[pull3496]: https://github.com/evennia/evennia/pull/3496
|
||||
[pull3498]: https://github.com/evennia/evennia/pull/3498
|
||||
[pull3499]: https://github.com/evennia/evennia/pull/3499
|
||||
[pull3501]: https://github.com/evennia/evennia/pull/3501
|
||||
[pull3502]: https://github.com/evennia/evennia/pull/3502
|
||||
[pull3503]: https://github.com/evennia/evennia/pull/3503
|
||||
[pull3506]: https://github.com/evennia/evennia/pull/3506
|
||||
[pull3507]: https://github.com/evennia/evennia/pull/3507
|
||||
[pull3514]: https://github.com/evennia/evennia/pull/3514
|
||||
[pull3516]: https://github.com/evennia/evennia/pull/3516
|
||||
[pull3517]: https://github.com/evennia/evennia/pull/3517
|
||||
[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
|
||||
[issue3522]: https://github.com/evennia/evennia/issue/3522
|
||||
|
||||
|
||||
## Evennia 4.1.1
|
||||
|
|
|
|||
|
|
@ -1,5 +1,87 @@
|
|||
# Changelog
|
||||
|
||||
## Main branch
|
||||
|
||||
- [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)
|
||||
- [Feature][pull3498]: Properly update Evennia's screen width when client
|
||||
changes width (assuming client supports NAWS properly) (michaelfaith84)
|
||||
- [Feature][pull3502]: New `sethelp/locks` allows for editing help entry
|
||||
locks after they were first created (chiizujin)
|
||||
- [Feature][pull3514]: Support `$pron(pronoun, key)` and new `$pconj(verb, key)`
|
||||
(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)
|
||||
- [Fix][pull3496]: EvEditor would not correctly show search&replace feedback
|
||||
when replacing colors (Chiizujin)
|
||||
- [Fix][pull3499]: Dig/tunnel commands didn't echo the typeclass of the newly
|
||||
created room properly (chiizujin)
|
||||
- [Fix][pull3501]: Using `sethelp` to create a help entry colliding with a
|
||||
command-name made the entry impossible to edit/delete later (chiizujin)
|
||||
- [Fix][pull3506]: Fix Traceback when setting prototype parent in the in-game OLC wizard (chiizujin)
|
||||
- [Fix][pull3507]: Prototype wizard would not save changes if aborting the
|
||||
updating of existing spawned instances (chiizujun)
|
||||
- [Fix][pull3516]: Quitting the chargen contrib menu will now trigger auto-look (InspectorCaracal)
|
||||
- [Fix][pull3517]: Supply `Object.search` with an empty `candidates` list caused
|
||||
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)
|
||||
- [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)
|
||||
- [Docs]: Doc fixes (Griatch, chiizujin, InspectorCaracal, iLPDev)
|
||||
|
||||
[pull3470]: https://github.com/evennia/evennia/pull/3470
|
||||
[pull3495]: https://github.com/evennia/evennia/pull/3495
|
||||
[pull3491]: https://github.com/evennia/evennia/pull/3491
|
||||
[pull3489]: https://github.com/evennia/evennia/pull/3489
|
||||
[pull3496]: https://github.com/evennia/evennia/pull/3496
|
||||
[pull3498]: https://github.com/evennia/evennia/pull/3498
|
||||
[pull3499]: https://github.com/evennia/evennia/pull/3499
|
||||
[pull3501]: https://github.com/evennia/evennia/pull/3501
|
||||
[pull3502]: https://github.com/evennia/evennia/pull/3502
|
||||
[pull3503]: https://github.com/evennia/evennia/pull/3503
|
||||
[pull3506]: https://github.com/evennia/evennia/pull/3506
|
||||
[pull3507]: https://github.com/evennia/evennia/pull/3507
|
||||
[pull3514]: https://github.com/evennia/evennia/pull/3514
|
||||
[pull3516]: https://github.com/evennia/evennia/pull/3516
|
||||
[pull3517]: https://github.com/evennia/evennia/pull/3517
|
||||
[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
|
||||
[issue3522]: https://github.com/evennia/evennia/issue/3522
|
||||
|
||||
|
||||
## Evennia 4.1.1
|
||||
|
||||
April 6, 2024
|
||||
|
|
|
|||
|
|
@ -337,13 +337,17 @@ Here the `caller` is the one sending the message and `receiver` the one to see i
|
|||
result of `you_obj.get_display_name(looker=receiver)`. This allows for a single string to echo differently
|
||||
depending on who sees it, and also to reference other people in the same way.
|
||||
- `$You([key])` - same as `$you` but always capitalized.
|
||||
- `$conj(verb)` ([code](evennia.utils.funcparser.funcparser_callable_conjugate)) - conjugates a verb
|
||||
between 2nd person presens to 3rd person presence depending on who
|
||||
- `$conj(verb [,key])` ([code](evennia.utils.funcparser.funcparser_callable_conjugate)) - conjugates a verb
|
||||
between 2nd person presence to 3rd person presence depending on who
|
||||
sees the string. For example `"$You() $conj(smiles)".` will show as "You smile." and "Tom smiles." depending
|
||||
on who sees it. This makes use of the tools in [evennia.utils.verb_conjugation](evennia.utils.verb_conjugation)
|
||||
to do this, and only works for English verbs.
|
||||
- `$pron(pronoun [,options])` ([code](evennia.utils.funcparser.funcparser_callable_pronoun)) - Dynamically
|
||||
- `$pron(pronoun [,options] [,key])` ([code](evennia.utils.funcparser.funcparser_callable_pronoun)) - Dynamically
|
||||
map pronouns (like his, herself, you, its etc) between 1st/2nd person to 3rd person.
|
||||
- `$pconj(verb, [,key])` ([code](evennia.utils.funcparser.funcparser_callable_conjugate_for_pronouns)) - conjugates
|
||||
a verb between 2nd and 3rd person, like `$conj`, but for pronouns instead of nouns to account for plural
|
||||
gendering. For example `"$Pron(you) $pconj(smiles)"` will show to others as "He smiles" for a gender of "male", or
|
||||
"They smile" for a gender of "plural".
|
||||
|
||||
|
||||
### `evennia.prototypes.protfuncs`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
+---------------+----------------------+--------------+
|
||||
|
|
@ -67,6 +67,7 @@ translate *hard-coded strings that the end player may see* - things you
|
|||
can't easily change from your mygame/ folder. Outputs from Commands and
|
||||
Typeclasses are generally *not* translated, nor are console/log outputs.
|
||||
|
||||
To cut down on work, you may consider only translating the player-facing commands (look, get etc) and leave the default admin commands in English. To change the language of some commands (such as `look`) you need to override the relevant hook-methods on your Typeclasses (check out the code for the default command to see what it calls).
|
||||
```
|
||||
|
||||
```{sidebar} Windows users
|
||||
|
|
|
|||
251
docs/source/Contribs/Contrib-Achievements.md
Normal file
251
docs/source/Contribs/Contrib-Achievements.md
Normal 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.attributes`. 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,22 +5,27 @@ Contribution by Griatch, 2019
|
|||
A full engine for creating multiplayer escape-rooms in Evennia. Allows players to
|
||||
spawn and join puzzle rooms that track their state independently. Any number of players
|
||||
can join to solve a room together. This is the engine created for 'EvscapeRoom', which won
|
||||
the MUD Coders Guild "One Room" Game Jam in April-May, 2019. The contrib has no game
|
||||
content but contains the utilities and base classes and an empty example room.
|
||||
|
||||
The original code for the contest is found at
|
||||
https://github.com/Griatch/evscaperoom but the version on the public Evennia
|
||||
demo is more updated, so if you really want the latest bug fixes etc you should
|
||||
rather look at https://github.com/evennia/evdemo/tree/master/evdemo/evscaperoom
|
||||
instead. A copy of the full game can also be played on the Evennia demo server
|
||||
at https://demo.evennia.com - just connect to the server and write `evscaperoom`
|
||||
in the first room to start!
|
||||
the MUD Coders Guild "One Room" Game Jam in April-May, 2019. The contrib has only
|
||||
very minimal game content, it contains the utilities and base classes and an empty example room.
|
||||
|
||||
## Introduction
|
||||
|
||||
Evscaperoom is, as it sounds, an escaperoom in text form. You start locked into
|
||||
a room and have to figure out how to get out. This engine contains everything
|
||||
needed to make a fully-featured puzzle game of this type!
|
||||
Evscaperoom is, as it sounds, an [escape room](https://en.wikipedia.org/wiki/Escape_room) in text form. You start locked into
|
||||
a room and have to figure out how to get out. This contrib contains everything
|
||||
needed to make a fully-featured puzzle game of this type. It also contains a
|
||||
'lobby' for creating new rooms, allowing players to join another person's room
|
||||
to collaborate solving it!
|
||||
|
||||
This is the game engine for the original _EvscapeRoom_. It
|
||||
allows you to recreate the same game experience, but it doesn't contain any of
|
||||
the story content created for the game jam. If you want to see the full game
|
||||
(where you must escape the cottage of a very tricky jester girl or lose the
|
||||
village's pie-eating contest...), you can find it at Griatch's github page [here](https://github.com/Griatch/evscaperoom),
|
||||
(but the recommended version is the one that used to run on the Evennia demo server which has
|
||||
some more bug fixes, found [here instead](https://github.com/evennia/evdemo/tree/master/evdemo/evscaperoom)).
|
||||
|
||||
If you want to read more about how _EvscapeRoom_ was created and designed, you can read the
|
||||
dev blog, [part 1](https://www.evennia.com/devblog/2019.html#2019-05-18-creating-evscaperoom-part-1) and [part 2](https://www.evennia.com/devblog/2019.html#2019-05-26-creating-evscaperoom-part-2).
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Evennia has a [contrib](./Contribs-Overview.md) directory which contains optiona
|
|||
| `grid/` | _Systems related to the game world’s 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:
|
||||
|
|
|
|||
|
|
@ -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 **50** contribs currently included
|
||||
with the Evennia distribution.
|
||||
|
||||
All contrib categories are imported from `evennia.contrib`, such as
|
||||
|
|
@ -29,16 +29,16 @@ 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) | [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) |
|
||||
|
||||
|
||||
|
||||
|
|
@ -241,8 +241,8 @@ _Contribution by Griatch, 2019_
|
|||
A full engine for creating multiplayer escape-rooms in Evennia. Allows players to
|
||||
spawn and join puzzle rooms that track their state independently. Any number of players
|
||||
can join to solve a room together. This is the engine created for 'EvscapeRoom', which won
|
||||
the MUD Coders Guild "One Room" Game Jam in April-May, 2019. The contrib has no game
|
||||
content but contains the utilities and base classes and an empty example room.
|
||||
the MUD Coders Guild "One Room" Game Jam in April-May, 2019. The contrib has only
|
||||
very minimal game content, it contains the utilities and base classes and an empty example room.
|
||||
|
||||
[Read the documentation](./Contrib-Evscaperoom.md) - [Browse the Code](evennia.contrib.full_systems.evscaperoom)
|
||||
|
||||
|
|
@ -266,6 +266,7 @@ Contribs-Guidelines.md
|
|||
```{toctree}
|
||||
:maxdepth: 1
|
||||
|
||||
Contrib-Achievements.md
|
||||
Contrib-Barter.md
|
||||
Contrib-Clothing.md
|
||||
Contrib-Containers.md
|
||||
|
|
@ -279,6 +280,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_
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | €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.*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.game\_systems.achievements.achievements
|
||||
==============================================================
|
||||
|
||||
.. automodule:: evennia.contrib.game_systems.achievements.achievements
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
18
docs/source/api/evennia.contrib.game_systems.achievements.md
Normal file
18
docs/source/api/evennia.contrib.game_systems.achievements.md
Normal 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
|
||||
|
||||
```
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.game\_systems.achievements.tests
|
||||
=======================================================
|
||||
|
||||
.. automodule:: evennia.contrib.game_systems.achievements.tests
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
10
docs/source/api/evennia.utils.hex_colors.md
Normal file
10
docs/source/api/evennia.utils.hex_colors.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.utils.hex\_colors
|
||||
================================
|
||||
|
||||
.. automodule:: evennia.utils.hex_colors
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
import evennia.accounts.manager
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
|
@ -47,7 +46,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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,18 +3,14 @@
|
|||
from random import randint
|
||||
from unittest import TestCase
|
||||
|
||||
from django.test import override_settings
|
||||
from mock import MagicMock, Mock, patch
|
||||
|
||||
import evennia
|
||||
from evennia.accounts.accounts import (
|
||||
AccountSessionHandler,
|
||||
DefaultAccount,
|
||||
DefaultGuest,
|
||||
)
|
||||
from django.test import override_settings
|
||||
from evennia.accounts.accounts import (AccountSessionHandler, DefaultAccount,
|
||||
DefaultGuest)
|
||||
from evennia.utils import create
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
from evennia.utils.utils import uses_database
|
||||
from mock import MagicMock, Mock, patch
|
||||
|
||||
|
||||
class TestAccountSessionHandler(TestCase):
|
||||
|
|
@ -172,7 +168,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -636,6 +636,11 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
|
|||
self.msg(f"Option |w{new_name}|n was kept as '|w{old_val}|n'.")
|
||||
else:
|
||||
flags[new_name] = new_val
|
||||
|
||||
# If we're manually assign a display size, turn off auto-resizing
|
||||
if new_name in ["SCREENWIDTH", "SCREENHEIGHT"]:
|
||||
flags["AUTORESIZE"] = False
|
||||
|
||||
self.msg(
|
||||
f"Option |w{new_name}|n was changed from '|w{old_val}|n' to"
|
||||
f" '|w{new_val}|n'."
|
||||
|
|
@ -658,6 +663,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
|
|||
"RAW": validate_bool,
|
||||
"SCREENHEIGHT": validate_size,
|
||||
"SCREENWIDTH": validate_size,
|
||||
"AUTORESIZE": validate_bool,
|
||||
"SCREENREADER": validate_bool,
|
||||
"TERM": utils.to_str,
|
||||
"UTF-8": validate_bool,
|
||||
|
|
@ -665,6 +671,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
|
|||
"INPUTDEBUG": validate_bool,
|
||||
"FORCEDENDLINE": validate_bool,
|
||||
"LOCALECHO": validate_bool,
|
||||
"TRUECOLOR": validate_bool,
|
||||
}
|
||||
|
||||
name = self.lhs.upper()
|
||||
|
|
@ -788,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.
|
||||
"""
|
||||
|
||||
|
|
@ -832,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"""
|
||||
|
||||
|
|
@ -910,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):
|
||||
|
|
|
|||
|
|
@ -178,26 +178,30 @@ class ObjManipCommand(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
def get_object_typeclass(
|
||||
self, obj_type: str = "object", typeclass: str = None, method: str = "cmd_create", **kwargs
|
||||
) -> tuple[typing.Optional["Builder"], list[str]]:
|
||||
) -> tuple[typing.Optional["Typeclass"], list[str]]:
|
||||
"""
|
||||
This hook is called by build commands to determine which typeclass to use for a specific purpose. For instance,
|
||||
when using dig, the system can use this to autodetect which kind of Room typeclass to use based on where the
|
||||
builder is currently located.
|
||||
|
||||
Note: Although intended to be used with typeclasses, as long as this hook returns a class with a create method,
|
||||
which accepts the same API as DefaultObject.create(), build commands and other places should take it.
|
||||
This hook is called by build commands to determine which typeclass to use for a specific
|
||||
purpose.
|
||||
|
||||
Args:
|
||||
obj_type (str, optional): The type of object that is being created. Defaults to "object". Evennia provides
|
||||
"room", "exit", and "character" by default, but this can be extended.
|
||||
typeclass (str, optional): The typeclass that was requested by the player. Defaults to None.
|
||||
Can also be an actual class.
|
||||
obj_type (str, optional): The type of object that is being created. Defaults to
|
||||
"object". Evennia provides "room", "exit", and "character" by default, but this can be
|
||||
extended.
|
||||
typeclass (str, optional): The typeclass that was requested by the player. Defaults to
|
||||
None. Can also be an actual class.
|
||||
method (str, optional): The method that is calling this hook. Defaults to "cmd_create".
|
||||
Others are "cmd_dig", "cmd_open", "cmd_tunnel", etc.
|
||||
|
||||
Returns:
|
||||
results_tuple (tuple[Optional[Builder], list[str]]): A tuple containing the typeclass to use and a list of
|
||||
errors. (which might be empty.)
|
||||
tuple: A tuple containing the typeclass to use and a list of errors. (which might be
|
||||
empty.)
|
||||
|
||||
Notes:
|
||||
Although intended to be used with typeclasses, as long as this hook returns a class with
|
||||
a create method, which accepts the same API as DefaultObject.create(), build commands
|
||||
and other places should take it. While not used by default, one could picture using this
|
||||
for things like autodetecting which room to build next based on the current location.
|
||||
|
||||
"""
|
||||
|
||||
found_typeclass = typeclass or self.default_typeclasses.get(obj_type, None)
|
||||
|
|
@ -1028,7 +1032,7 @@ class CmdDig(ObjManipCommand):
|
|||
if new_room.aliases.all():
|
||||
alias_string = " (%s)" % ", ".join(new_room.aliases.all())
|
||||
|
||||
room_string = f"Created room {new_room}({new_room.dbref}){alias_string} of type {new_room}."
|
||||
room_string = f"Created room {new_room}({new_room.dbref}){alias_string} of type {new_room.typeclass_path}."
|
||||
|
||||
# create exit to room
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ Communication commands:
|
|||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from evennia.accounts import bots
|
||||
from evennia.accounts.models import AccountDB
|
||||
from evennia.comms.comms import DefaultChannel
|
||||
|
|
@ -1413,12 +1412,15 @@ 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 +1500,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
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from django.conf import settings
|
|||
from evennia.help.filehelp import FILE_HELP_ENTRIES
|
||||
from evennia.help.models import HelpEntry
|
||||
from evennia.help.utils import help_search_with_index, parse_entry_for_subcategories
|
||||
from evennia.locks.lockhandler import LockException
|
||||
from evennia.utils import create, evmore
|
||||
from evennia.utils.ansi import ANSIString
|
||||
from evennia.utils.eveditor import EvEditor
|
||||
|
|
@ -781,13 +782,14 @@ class CmdSetHelp(CmdHelp):
|
|||
|
||||
Usage:
|
||||
sethelp[/switches] <topic>[[;alias;alias][,category[,locks]]
|
||||
[= <text or new category>]
|
||||
[= <text or new value>]
|
||||
Switches:
|
||||
edit - open a line editor to edit the topic's help text.
|
||||
replace - overwrite existing help topic.
|
||||
append - add text to the end of existing topic with a newline between.
|
||||
extend - as append, but don't add a newline.
|
||||
category - change category of existing help topic.
|
||||
locks - change locks of existing help topic.
|
||||
delete - remove help topic.
|
||||
|
||||
Examples:
|
||||
|
|
@ -795,6 +797,7 @@ class CmdSetHelp(CmdHelp):
|
|||
sethelp/append pickpocketing,Thievery = This steals ...
|
||||
sethelp/replace pickpocketing, ,attr(is_thief) = This steals ...
|
||||
sethelp/edit thievery
|
||||
sethelp/locks thievery = read:all()
|
||||
sethelp/category thievery = classes
|
||||
|
||||
If not assigning a category, the `settings.DEFAULT_HELP_CATEGORY` category
|
||||
|
|
@ -842,7 +845,7 @@ class CmdSetHelp(CmdHelp):
|
|||
|
||||
key = "sethelp"
|
||||
aliases = []
|
||||
switch_options = ("edit", "replace", "append", "extend", "category", "delete")
|
||||
switch_options = ("edit", "replace", "append", "extend", "category", "locks", "delete")
|
||||
locks = "cmd:perm(Helper)"
|
||||
help_category = "Building"
|
||||
arg_regex = None
|
||||
|
|
@ -856,6 +859,7 @@ class CmdSetHelp(CmdHelp):
|
|||
|
||||
switches = self.switches
|
||||
lhslist = self.lhslist
|
||||
rhslist = self.rhslist
|
||||
|
||||
if not self.args:
|
||||
self.msg(
|
||||
|
|
@ -932,7 +936,17 @@ class CmdSetHelp(CmdHelp):
|
|||
# types of entries.
|
||||
self.msg(f"|rWarning:\n|r{warning}|n")
|
||||
repl = yield ("|wDo you still want to continue? Y/[N]?|n")
|
||||
if repl.lower() not in ("y", "yes"):
|
||||
if repl.lower() in ("y", "yes"):
|
||||
# find a db-based help entry if one already exists
|
||||
db_topics = {**db_help_topics}
|
||||
db_categories = list(
|
||||
set(HelpCategory(topic.help_category) for topic in db_topics.values())
|
||||
)
|
||||
entries = list(db_topics.values()) + db_categories
|
||||
match, _ = self.do_search(querystr, entries)
|
||||
if match:
|
||||
old_entry = match
|
||||
else:
|
||||
self.msg("Aborted.")
|
||||
return
|
||||
else:
|
||||
|
|
@ -1001,6 +1015,35 @@ class CmdSetHelp(CmdHelp):
|
|||
self.msg(f"Category for entry '{topicstr}'{aliastxt} changed to '{category}'.")
|
||||
return
|
||||
|
||||
if "locks" in switches:
|
||||
# set the locks
|
||||
if not old_entry:
|
||||
self.msg(f"Could not find topic '{topicstr}'{aliastxt}.")
|
||||
return
|
||||
show_locks = not rhslist
|
||||
clear_locks = rhslist and not rhslist[0]
|
||||
if show_locks:
|
||||
self.msg(f"Current locks for entry '{topicstr}'{aliastxt} are: {old_entry.locks}")
|
||||
return
|
||||
if clear_locks:
|
||||
old_entry.locks.clear()
|
||||
old_entry.locks.add("read:all()")
|
||||
self.msg(f"Locks for entry '{topicstr}'{aliastxt} reset to: read:all()")
|
||||
return
|
||||
lockstring = ",".join(rhslist)
|
||||
# locks.validate() does not throw an exception for things like "read:id(1),read:id(6)"
|
||||
# but locks.add() does
|
||||
existing_locks = old_entry.locks.all()
|
||||
old_entry.locks.clear()
|
||||
try:
|
||||
old_entry.locks.add(lockstring)
|
||||
except LockException as e:
|
||||
old_entry.locks.add(existing_locks)
|
||||
self.msg(str(e) + " Locks not changed.")
|
||||
else:
|
||||
self.msg(f"Locks for entry '{topicstr}'{aliastxt} changed to: {lockstring}")
|
||||
return
|
||||
|
||||
if "delete" in switches or "del" in switches:
|
||||
# delete the help entry
|
||||
if not old_entry:
|
||||
|
|
|
|||
|
|
@ -5,11 +5,10 @@ Base typeclass for in-game Channels.
|
|||
|
||||
import re
|
||||
|
||||
import evennia
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
|
||||
import evennia
|
||||
from evennia.comms.managers import ChannelManager
|
||||
from evennia.comms.models import ChannelDB
|
||||
from evennia.typeclasses.models import TypeclassBase
|
||||
|
|
@ -18,7 +17,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 +34,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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ necessary to easily be able to delete connections on the fly).
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from evennia.comms import managers
|
||||
from evennia.locks.lockhandler import LockHandler
|
||||
from evennia.typeclasses.models import TypedObject
|
||||
|
|
@ -151,7 +150,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 +193,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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -15,15 +15,22 @@ class BaseComponent(type):
|
|||
This is the metaclass for components,
|
||||
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 +60,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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,22 +5,27 @@ Contribution by Griatch, 2019
|
|||
A full engine for creating multiplayer escape-rooms in Evennia. Allows players to
|
||||
spawn and join puzzle rooms that track their state independently. Any number of players
|
||||
can join to solve a room together. This is the engine created for 'EvscapeRoom', which won
|
||||
the MUD Coders Guild "One Room" Game Jam in April-May, 2019. The contrib has no game
|
||||
content but contains the utilities and base classes and an empty example room.
|
||||
|
||||
The original code for the contest is found at
|
||||
https://github.com/Griatch/evscaperoom but the version on the public Evennia
|
||||
demo is more updated, so if you really want the latest bug fixes etc you should
|
||||
rather look at https://github.com/evennia/evdemo/tree/master/evdemo/evscaperoom
|
||||
instead. A copy of the full game can also be played on the Evennia demo server
|
||||
at https://demo.evennia.com - just connect to the server and write `evscaperoom`
|
||||
in the first room to start!
|
||||
the MUD Coders Guild "One Room" Game Jam in April-May, 2019. The contrib has only
|
||||
very minimal game content, it contains the utilities and base classes and an empty example room.
|
||||
|
||||
## Introduction
|
||||
|
||||
Evscaperoom is, as it sounds, an escaperoom in text form. You start locked into
|
||||
a room and have to figure out how to get out. This engine contains everything
|
||||
needed to make a fully-featured puzzle game of this type!
|
||||
Evscaperoom is, as it sounds, an [escape room](https://en.wikipedia.org/wiki/Escape_room) in text form. You start locked into
|
||||
a room and have to figure out how to get out. This contrib contains everything
|
||||
needed to make a fully-featured puzzle game of this type. It also contains a
|
||||
'lobby' for creating new rooms, allowing players to join another person's room
|
||||
to collaborate solving it!
|
||||
|
||||
This is the game engine for the original _EvscapeRoom_. It
|
||||
allows you to recreate the same game experience, but it doesn't contain any of
|
||||
the story content created for the game jam. If you want to see the full game
|
||||
(where you must escape the cottage of a very tricky jester girl or lose the
|
||||
village's pie-eating contest...), you can find it at Griatch's github page [here](https://github.com/Griatch/evscaperoom),
|
||||
(but the recommended version is the one that used to run on the Evennia demo server which has
|
||||
some more bug fixes, found [here instead](https://github.com/evennia/evdemo/tree/master/evdemo/evscaperoom)).
|
||||
|
||||
If you want to read more about how _EvscapeRoom_ was created and designed, you can read the
|
||||
dev blog, [part 1](https://www.evennia.com/devblog/2019.html#2019-05-18-creating-evscaperoom-part-1) and [part 2](https://www.evennia.com/devblog/2019.html#2019-05-26-creating-evscaperoom-part-2).
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
|
|||
245
evennia/contrib/game_systems/achievements/README.md
Normal file
245
evennia/contrib/game_systems/achievements/README.md
Normal 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.attributes`. 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.
|
||||
```
|
||||
8
evennia/contrib/game_systems/achievements/__init__.py
Normal file
8
evennia/contrib/game_systems/achievements/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from .achievements import (
|
||||
get_achievement,
|
||||
search_achievement,
|
||||
all_achievements,
|
||||
track_achievements,
|
||||
get_achievement_progress,
|
||||
CmdAchieve,
|
||||
)
|
||||
404
evennia/contrib/game_systems/achievements/achievements.py
Normal file
404
evennia/contrib/game_systems/achievements/achievements.py
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
"""
|
||||
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.utils import logger
|
||||
from evennia.utils.utils import all_from_module, is_iter, make_iter, string_partial_matching
|
||||
from evennia.utils.evmore import EvMore
|
||||
from evennia.commands.default.muxcommand import MuxCommand
|
||||
|
||||
# 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)
|
||||
178
evennia/contrib/game_systems/achievements/tests.py
Normal file
178
evennia/contrib/game_systems/achievements/tests.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
from evennia.utils.test_resources import BaseEvenniaTest, BaseEvenniaCommandTest
|
||||
from mock import patch
|
||||
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",
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@ class WildernessExit(DefaultExit):
|
|||
Returns:
|
||||
bool: True if traversing_object is allowed to traverse
|
||||
"""
|
||||
return True
|
||||
return self.wilderness.is_valid_coordinates(new_coordinates)
|
||||
|
||||
def at_traverse(self, traversing_object, target_location):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ from django.conf import settings
|
|||
|
||||
from evennia import DefaultAccount
|
||||
from evennia.commands.default.muxcommand import MuxAccountCommand
|
||||
from evennia.commands.default.account import CmdIC
|
||||
from evennia.commands.cmdset import CmdSet
|
||||
from evennia.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
|
||||
|
|
@ -85,20 +98,49 @@ class ContribCmdCharCreate(MuxAccountCommand):
|
|||
# this gets called every time the player exits the chargen menu
|
||||
def finish_char_callback(session, menu):
|
||||
char = session.new_char
|
||||
if not char.db.chargen_step:
|
||||
if char.db.chargen_step:
|
||||
# this means the character creation process was exited in the middle
|
||||
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
|
||||
|
|
@ -153,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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ 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
|
||||
from evennia.locks.lockhandler import LockHandler
|
||||
from evennia.typeclasses.models import AliasHandler, Tag, TagHandler
|
||||
|
|
@ -79,7 +79,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 +101,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"
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -397,7 +397,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
|
||||
# if candidates were already given, use them
|
||||
candidates = kwargs.get("candidates")
|
||||
if candidates:
|
||||
if candidates is not None:
|
||||
return candidates
|
||||
|
||||
# find candidates based on location
|
||||
|
|
@ -1595,7 +1595,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
return names
|
||||
sort_index = {name: key for key, name in enumerate(exit_order)}
|
||||
names = sorted(names)
|
||||
end_pos = len(names) + 1
|
||||
end_pos = len(sort_index)
|
||||
names.sort(key=lambda name: sort_index.get(name, end_pos))
|
||||
return names
|
||||
|
||||
|
|
|
|||
|
|
@ -835,7 +835,7 @@ def _prototype_parent_actions(caller, raw_inp, **kwargs):
|
|||
return "node_prototype_parent"
|
||||
|
||||
|
||||
def _prototype_parent_select(caller, new_parent):
|
||||
def _prototype_parent_select(caller, new_parent, **kwargs):
|
||||
ret = None
|
||||
prototype_parent = protlib.search_prototype(new_parent)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ def prototype_from_object(obj):
|
|||
prot["prototype_locks"] = "spawn:all();edit:all()"
|
||||
prot["prototype_tags"] = []
|
||||
else:
|
||||
prot = prot[0]
|
||||
prot = prot[0].copy()
|
||||
|
||||
prot["key"] = obj.db_key or hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:6]
|
||||
prot["typeclass"] = obj.db_typeclass_path
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ _CLIENT_OPTIONS = (
|
|||
"MCCP",
|
||||
"SCREENHEIGHT",
|
||||
"SCREENWIDTH",
|
||||
"AUTORESIZE",
|
||||
"INPUTDEBUG",
|
||||
"RAW",
|
||||
"NOCOLOR",
|
||||
|
|
@ -202,6 +203,7 @@ def client_options(session, *args, **kwargs):
|
|||
mccp (bool): MCCP compression on/off
|
||||
screenheight (int): Screen height in lines
|
||||
screenwidth (int): Screen width in characters
|
||||
autoresize (bool): Use NAWS updates to dynamically adjust format
|
||||
inputdebug (bool): Debug input functions
|
||||
nocolor (bool): Strip color
|
||||
raw (bool): Turn off parsing
|
||||
|
|
@ -257,6 +259,8 @@ def client_options(session, *args, **kwargs):
|
|||
flags["SCREENHEIGHT"] = validate_size(value)
|
||||
elif key == "screenwidth":
|
||||
flags["SCREENWIDTH"] = validate_size(value)
|
||||
elif key == "autoresize":
|
||||
flags["AUTORESIZE"] = validate_size(value)
|
||||
elif key == "inputdebug":
|
||||
flags["INPUTDEBUG"] = validate_bool(value)
|
||||
elif key == "nocolor":
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class Naws:
|
|||
option (Option): Not used.
|
||||
|
||||
"""
|
||||
self.protocol.protocol_flags["AUTORESIZE"] = False
|
||||
self.protocol.handshake_done()
|
||||
|
||||
def do_naws(self, option):
|
||||
|
|
@ -68,6 +69,7 @@ class Naws:
|
|||
option (Option): Not used.
|
||||
|
||||
"""
|
||||
self.protocol.protocol_flags["AUTORESIZE"] = True
|
||||
self.protocol.handshake_done()
|
||||
|
||||
def negotiate_sizes(self, options):
|
||||
|
|
|
|||
|
|
@ -467,7 +467,7 @@ class PortalSessionHandler(SessionHandler):
|
|||
kwargs (any): Each key is a command instruction to the
|
||||
protocol on the form key = [[args],{kwargs}]. This will
|
||||
call a method send_<key> on the protocol. If no such
|
||||
method exixts, it sends the data to a method send_default.
|
||||
method exits, it sends the data to a method send_default.
|
||||
|
||||
"""
|
||||
# from evennia.server.profiling.timetrace import timetrace # DEBUG
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from twisted.internet.task import LoopingCall
|
|||
from evennia.server.portal import mssp, naws, suppress_ga, telnet_oob, ttype
|
||||
from evennia.server.portal.mccp import MCCP, Mccp, mccp_compress
|
||||
from evennia.server.portal.mxp import Mxp, mxp_parse
|
||||
from evennia.server.portal.naws import NAWS
|
||||
from evennia.utils import ansi
|
||||
from evennia.utils.utils import class_from_module, to_bytes
|
||||
|
||||
|
|
@ -91,8 +92,17 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS):
|
|||
of incoming data.
|
||||
|
||||
"""
|
||||
# print(f"telnet dataReceived: {data}")
|
||||
try:
|
||||
# Do we have a NAWS update?
|
||||
if (
|
||||
NAWS in data
|
||||
and len([data[i : i + 1] for i in range(0, len(data))]) == 9
|
||||
and
|
||||
# Is auto resizing on?
|
||||
self.protocol_flags.get("AUTORESIZE")
|
||||
):
|
||||
self.sessionhandler.sync(self.sessionhandler.get(self.sessid))
|
||||
|
||||
super().dataReceived(data)
|
||||
except ValueError as err:
|
||||
from evennia.utils import logger
|
||||
|
|
@ -419,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)
|
||||
|
|
@ -437,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
|
||||
)
|
||||
|
|
@ -460,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)
|
||||
|
|
@ -496,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)
|
||||
|
|
|
|||
|
|
@ -257,11 +257,10 @@ class TelnetOOB:
|
|||
if cmdname in EVENNIA_TO_GMCP:
|
||||
gmcp_cmdname = EVENNIA_TO_GMCP[cmdname]
|
||||
elif "_" in cmdname:
|
||||
if cmdname.istitle():
|
||||
# leave without capitalization
|
||||
gmcp_cmdname = ".".join(word for word in cmdname.split("_"))
|
||||
else:
|
||||
gmcp_cmdname = ".".join(word.capitalize() for word in cmdname.split("_"))
|
||||
# enforce initial capitalization of each command part, leaving fully-capitalized sections intact
|
||||
gmcp_cmdname = ".".join(
|
||||
word.capitalize() if not word.isupper() else word for word in cmdname.split("_")
|
||||
)
|
||||
else:
|
||||
gmcp_cmdname = "Core.%s" % (cmdname if cmdname.istitle() else cmdname.capitalize())
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -130,10 +130,10 @@ class Ttype:
|
|||
self.protocol.protocol_flags["NOPROMPTGOAHEAD"] = False
|
||||
|
||||
if (
|
||||
clientname.startswith("XTERM")
|
||||
or clientname.endswith("-256COLOR")
|
||||
or clientname
|
||||
in (
|
||||
clientname.startswith("XTERM")
|
||||
or clientname.endswith("-256COLOR")
|
||||
or clientname
|
||||
in (
|
||||
"ATLANTIS", # > 0.9.9.0 (aug 2009)
|
||||
"CMUD", # > 3.04 (mar 2009)
|
||||
"KILDCLIENT", # > 2.2.0 (sep 2005)
|
||||
|
|
@ -143,13 +143,23 @@ class Ttype:
|
|||
"BEIP", # > 2.00.206 (late 2009) (BeipMu)
|
||||
"POTATO", # > 2.00 (maybe earlier)
|
||||
"TINYFUGUE", # > 4.x (maybe earlier)
|
||||
)
|
||||
)
|
||||
):
|
||||
xterm256 = True
|
||||
|
||||
# use name to identify support for xterm truecolor
|
||||
truecolor = False
|
||||
if (clientname.endswith("-TRUECOLOR") or
|
||||
clientname in (
|
||||
"AXMUD",
|
||||
"TINTIN"
|
||||
)):
|
||||
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)
|
||||
|
||||
|
|
@ -159,9 +169,9 @@ class Ttype:
|
|||
tupper = term.upper()
|
||||
# identify xterm256 based on flag
|
||||
xterm256 = (
|
||||
tupper.endswith("-256COLOR")
|
||||
or tupper.endswith("XTERM") # Apple Terminal, old Tintin
|
||||
and not tupper.endswith("-COLOR") # old Tintin, Putty
|
||||
tupper.endswith("-256COLOR")
|
||||
or tupper.endswith("XTERM") # Apple Terminal, old Tintin
|
||||
and not tupper.endswith("-COLOR") # old Tintin, Putty
|
||||
)
|
||||
if xterm256:
|
||||
self.protocol.protocol_flags["ANSI"] = True
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ class ServerSession(_BASE_SESSION_CLASS):
|
|||
|
||||
Notes:
|
||||
Since protocols can vary, no checking is done
|
||||
as to the existene of the flag or not. The input
|
||||
as to the existence of the flag or not. The input
|
||||
data should have been validated before this call.
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ these to create custom managers.
|
|||
|
||||
"""
|
||||
|
||||
import evennia
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
|
@ -33,32 +34,24 @@ 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
|
||||
|
||||
import evennia
|
||||
from evennia.locks.lockhandler import LockHandler
|
||||
from evennia.server.signals import SIGNAL_TYPED_OBJECT_POST_RENAME
|
||||
from evennia.typeclasses import managers
|
||||
from evennia.typeclasses.attributes import (
|
||||
Attribute,
|
||||
AttributeHandler,
|
||||
AttributeProperty,
|
||||
DbHolder,
|
||||
InMemoryAttributeBackend,
|
||||
ModelAttributeBackend,
|
||||
)
|
||||
from evennia.typeclasses.tags import (
|
||||
AliasHandler,
|
||||
PermissionHandler,
|
||||
Tag,
|
||||
TagCategoryProperty,
|
||||
TagHandler,
|
||||
TagProperty,
|
||||
)
|
||||
from evennia.utils.idmapper.models import SharedMemoryModel, SharedMemoryModelBase
|
||||
from evennia.typeclasses.attributes import (Attribute, AttributeHandler,
|
||||
AttributeProperty, DbHolder,
|
||||
InMemoryAttributeBackend,
|
||||
ModelAttributeBackend)
|
||||
from evennia.typeclasses.tags import (AliasHandler, PermissionHandler, Tag,
|
||||
TagCategoryProperty, TagHandler,
|
||||
TagProperty)
|
||||
from evennia.utils.idmapper.models import (SharedMemoryModel,
|
||||
SharedMemoryModelBase)
|
||||
from evennia.utils.logger import log_trace
|
||||
from evennia.utils.utils import class_from_module, inherits_from, is_iter, lazy_property
|
||||
from evennia.utils.utils import (class_from_module, inherits_from, is_iter,
|
||||
lazy_property)
|
||||
|
||||
__all__ = ("TypedObject",)
|
||||
|
||||
|
|
@ -225,7 +218,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 +414,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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ from django.conf import settings
|
|||
|
||||
from evennia.utils import logger, utils
|
||||
from evennia.utils.utils import to_str
|
||||
from evennia.utils.hex_colors import HexColors
|
||||
|
||||
hex2truecolor = HexColors()
|
||||
hex_sub = HexColors.hex_sub
|
||||
|
||||
MXP_ENABLED = settings.MXP_ENABLED
|
||||
|
||||
|
|
@ -432,7 +436,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 +463,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 +492,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 +525,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 +537,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):
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ class GlobalScriptContainer(Container):
|
|||
"""
|
||||
if not self.loaded:
|
||||
self.load_data()
|
||||
return self.scripts.values()
|
||||
return list(self.loaded_data.values())
|
||||
|
||||
|
||||
# Create all singletons
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -673,20 +676,27 @@ class CmdEditorGroup(CmdEditorBase):
|
|||
if not self.linerange:
|
||||
caller.msg(
|
||||
_("Search-replaced {arg1} -> {arg2} for lines {l1}-{l2}.").format(
|
||||
arg1=self.arg1, arg2=self.arg2, l1=lstart + 1, l2=lend
|
||||
arg1=raw(self.arg1), arg2=raw(self.arg2), l1=lstart + 1, l2=lend
|
||||
)
|
||||
)
|
||||
else:
|
||||
caller.msg(
|
||||
_("Search-replaced {arg1} -> {arg2} for {line}.").format(
|
||||
arg1=self.arg1, arg2=self.arg2, line=self.lstr
|
||||
arg1=raw(self.arg1), arg2=raw(self.arg2), line=self.lstr
|
||||
)
|
||||
)
|
||||
buf = linebuffer[:lstart] + sarea.split("\n") + linebuffer[lend:]
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -1320,15 +1320,18 @@ def funcparser_callable_your_capitalize(
|
|||
)
|
||||
|
||||
|
||||
def funcparser_callable_conjugate(*args, caller=None, receiver=None, **kwargs):
|
||||
def funcparser_callable_conjugate(*args, caller=None, receiver=None, mapping=None, **kwargs):
|
||||
"""
|
||||
Usage: $conj(word, [options])
|
||||
Usage: $conj(word, [key])
|
||||
|
||||
Conjugate a verb according to if it should be 2nd or third person.
|
||||
|
||||
Keyword Args:
|
||||
caller (Object): The object who represents 'you' in the string.
|
||||
receiver (Object): The recipient of the string.
|
||||
mapping (dict, optional): This is a mapping `{key:Object, ...}` and is
|
||||
used to find which object the optional `key` argument refers to. If not given,
|
||||
the `caller` kwarg is used.
|
||||
|
||||
Returns:
|
||||
str: The parsed string.
|
||||
|
|
@ -1337,13 +1340,10 @@ def funcparser_callable_conjugate(*args, caller=None, receiver=None, **kwargs):
|
|||
ParsingError: If `you` and `recipient` were not both supplied.
|
||||
|
||||
Notes:
|
||||
Note that the verb will not be capitalized. It also
|
||||
assumes that the active party (You) is the one performing the verb.
|
||||
This automatic conjugation will fail if the active part is another person
|
||||
than 'you'. The caller/receiver must be passed to the parser directly.
|
||||
Note that the verb will not be capitalized.
|
||||
|
||||
Examples:
|
||||
This is often used in combination with the $you/You( callables.
|
||||
This is often used in combination with the $you/You callables.
|
||||
|
||||
- `With a grin, $you() $conj(jump)`
|
||||
|
||||
|
|
@ -1356,14 +1356,80 @@ def funcparser_callable_conjugate(*args, caller=None, receiver=None, **kwargs):
|
|||
if not (caller and receiver):
|
||||
raise ParsingError("No caller/receiver supplied to $conj callable")
|
||||
|
||||
second_person_str, third_person_str = verb_actor_stance_components(args[0])
|
||||
return second_person_str if caller == receiver else third_person_str
|
||||
verb, *options = args
|
||||
obj = caller
|
||||
if mapping and options:
|
||||
# get the correct referenced object from the mapping, or fall back to caller
|
||||
obj = mapping.get(options[0], caller)
|
||||
|
||||
second_person_str, third_person_str = verb_actor_stance_components(verb)
|
||||
return second_person_str if obj == receiver else third_person_str
|
||||
|
||||
|
||||
def funcparser_callable_pronoun(*args, caller=None, receiver=None, capitalize=False, **kwargs):
|
||||
def funcparser_callable_conjugate_for_pronouns(
|
||||
*args, caller=None, receiver=None, mapping=None, **kwargs
|
||||
):
|
||||
"""
|
||||
Usage: $pconj(word, [key])
|
||||
|
||||
Conjugate a verb according to if it should be 2nd or third person, respecting the
|
||||
singular/plural gendering for third person.
|
||||
|
||||
Keyword Args:
|
||||
caller (Object): The object who represents 'you' in the string.
|
||||
receiver (Object): The recipient of the string.
|
||||
mapping (dict, optional): This is a mapping `{key:Object, ...}` and is
|
||||
used to find which object the optional `key` argument refers to. If not given,
|
||||
the `caller` kwarg is used.
|
||||
|
||||
Returns:
|
||||
str: The parsed string.
|
||||
|
||||
Raises:
|
||||
ParsingError: If `you` and `recipient` were not both supplied.
|
||||
|
||||
Notes:
|
||||
Note that the verb will not be capitalized.
|
||||
|
||||
Examples:
|
||||
This is often used in combination with the $pron/Pron callables.
|
||||
|
||||
- `With a grin, $pron(you) $pconj(jump)`
|
||||
|
||||
You will see "With a grin, you jump."
|
||||
With your gender as "male", others will see "With a grin, he jumps."
|
||||
With your gender as "plural", others will see "With a grin, they jump."
|
||||
|
||||
"""
|
||||
if not args:
|
||||
return ""
|
||||
if not (caller and receiver):
|
||||
raise ParsingError("No caller/receiver supplied to $conj callable")
|
||||
|
||||
verb, *options = args
|
||||
obj = caller
|
||||
if mapping and options:
|
||||
# get the correct referenced object from the mapping, or fall back to caller
|
||||
obj = mapping.get(options[0], caller)
|
||||
|
||||
# identify whether the 3rd person form should be singular or plural
|
||||
plural = False
|
||||
if hasattr(obj, "gender"):
|
||||
if callable(obj.gender):
|
||||
plural = obj.gender() == "plural"
|
||||
else:
|
||||
plural = obj.gender == "plural"
|
||||
|
||||
second_person_str, third_person_str = verb_actor_stance_components(verb, plural=plural)
|
||||
return second_person_str if obj == receiver else third_person_str
|
||||
|
||||
|
||||
def funcparser_callable_pronoun(
|
||||
*args, caller=None, receiver=None, mapping=None, capitalize=False, **kwargs
|
||||
):
|
||||
"""
|
||||
|
||||
Usage: $pron(word, [options])
|
||||
Usage: $pron(word, [options], [key])
|
||||
|
||||
Adjust pronouns to the expected form. Pronouns are words you use instead of a
|
||||
proper name, such as 'him', 'herself', 'theirs' etc. These look different
|
||||
|
|
@ -1424,6 +1490,9 @@ def funcparser_callable_pronoun(*args, caller=None, receiver=None, capitalize=Fa
|
|||
- `1st person`/`1st`/`1`
|
||||
- `2nd person`/`2nd`/`2`
|
||||
- `3rd person`/`3rd`/`3`
|
||||
key (str, optional): If a mapping is provided, a string defining which object to
|
||||
reference when finding the correct pronoun. If not provided, it defaults
|
||||
to `caller`
|
||||
|
||||
Keyword Args:
|
||||
|
||||
|
|
@ -1435,6 +1504,9 @@ def funcparser_callable_pronoun(*args, caller=None, receiver=None, capitalize=Fa
|
|||
receiver (Object): The recipient of the string. This being the same as
|
||||
`caller` or not helps determine 2nd vs 3rd-person forms. This is
|
||||
provided automatically by the funcparser.
|
||||
mapping (dict, optional): This is a mapping `{key:Object, ...}` and is
|
||||
used to find which object the optional `key` argument refers to. If not given,
|
||||
the `caller` kwarg is used.
|
||||
capitalize (bool): The input retains its capitalization. If this is set the output is
|
||||
always capitalized.
|
||||
|
||||
|
|
@ -1457,8 +1529,17 @@ def funcparser_callable_pronoun(*args, caller=None, receiver=None, capitalize=Fa
|
|||
"""
|
||||
if not args:
|
||||
return ""
|
||||
# by default, we use the caller as the object being referred to
|
||||
obj = caller
|
||||
|
||||
pronoun, *options = args
|
||||
if options and mapping:
|
||||
# check if the last argument is a valid mapping key
|
||||
if options[-1] in mapping:
|
||||
# get the object and remove the key from options
|
||||
obj = mapping[options[-1]]
|
||||
options = options[:-1]
|
||||
|
||||
# options is either multiple args or a space-separated string
|
||||
if len(options) == 1:
|
||||
options = options[0]
|
||||
|
|
@ -1468,11 +1549,11 @@ def funcparser_callable_pronoun(*args, caller=None, receiver=None, capitalize=Fa
|
|||
default_gender = "neutral"
|
||||
default_viewpoint = "2nd person"
|
||||
|
||||
if hasattr(caller, "gender"):
|
||||
if callable(caller.gender):
|
||||
default_gender = caller.gender()
|
||||
if hasattr(obj, "gender"):
|
||||
if callable(obj.gender):
|
||||
default_gender = obj.gender()
|
||||
else:
|
||||
default_gender = caller.gender
|
||||
default_gender = obj.gender
|
||||
|
||||
if "viewpoint" in kwargs:
|
||||
# passed into FuncParser initialization
|
||||
|
|
@ -1490,7 +1571,7 @@ def funcparser_callable_pronoun(*args, caller=None, receiver=None, capitalize=Fa
|
|||
pronoun_1st_or_2nd_person = pronoun_1st_or_2nd_person.capitalize()
|
||||
pronoun_3rd_person = pronoun_3rd_person.capitalize()
|
||||
|
||||
return pronoun_1st_or_2nd_person if caller == receiver else pronoun_3rd_person
|
||||
return pronoun_1st_or_2nd_person if obj == receiver else pronoun_3rd_person
|
||||
|
||||
|
||||
def funcparser_callable_pronoun_capitalize(
|
||||
|
|
@ -1557,6 +1638,7 @@ ACTOR_STANCE_CALLABLES = {
|
|||
"obj": funcparser_callable_you,
|
||||
"Obj": funcparser_callable_you_capitalize,
|
||||
"conj": funcparser_callable_conjugate,
|
||||
"pconj": funcparser_callable_conjugate_for_pronouns,
|
||||
"pron": funcparser_callable_pronoun,
|
||||
"Pron": funcparser_callable_pronoun_capitalize,
|
||||
**FUNCPARSER_CALLABLES,
|
||||
|
|
|
|||
173
evennia/utils/hex_colors.py
Normal file
173
evennia/utils/hex_colors.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
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 = "\|#"
|
||||
_RE_BG = "\|\[#"
|
||||
_RE_FG_OR_BG = "\|\[?#"
|
||||
_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 = f"\x1b\[38;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m"
|
||||
TRUECOLOR_BG = f"\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)
|
||||
|
||||
# Is it greyscale?
|
||||
if r == g and g == b:
|
||||
return f"{indicator}=" + self._GREYS[self._grey_int(r)]
|
||||
|
||||
else:
|
||||
if not truecolor:
|
||||
# Fallback to xterm256 syntax
|
||||
r, g, b = self._rgb_24_bit_to_256(r, g, b)
|
||||
return f"{indicator}{r}{g}{b}"
|
||||
|
||||
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
|
||||
|
|
@ -10,11 +10,10 @@ from ast import literal_eval
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from evennia.utils import funcparser, test_resources
|
||||
from parameterized import parameterized
|
||||
from simpleeval import simple_eval
|
||||
|
||||
from evennia.utils import funcparser, test_resources
|
||||
|
||||
|
||||
def _test_callable(*args, **kwargs):
|
||||
kwargs.pop("funcparser", None)
|
||||
|
|
@ -144,12 +143,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 +326,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):
|
||||
"""
|
||||
|
|
@ -435,6 +434,11 @@ class TestDefaultCallables(TestCase):
|
|||
("$You() $conj(smile) at him.", "You smile at him.", "Char1 smiles at him."),
|
||||
("$You() $conj(smile) at $You(char1).", "You smile at You.", "Char1 smiles at Char1."),
|
||||
("$You() $conj(smile) at $You(char2).", "You smile at Char2.", "Char1 smiles at You."),
|
||||
(
|
||||
"$You() $conj(smile) while $You(char2) $conj(waves, char2).",
|
||||
"You smile while Char2 waves.",
|
||||
"Char1 smiles while You wave.",
|
||||
),
|
||||
(
|
||||
"$You(char2) $conj(smile) at $you(char1).",
|
||||
"Char2 smile at you.",
|
||||
|
|
@ -512,6 +516,20 @@ class TestDefaultCallables(TestCase):
|
|||
ret = self.parser.parse(string, caller=self.obj1, raise_errors=True)
|
||||
self.assertEqual(expected, ret)
|
||||
|
||||
def test_pronoun_mapping(self):
|
||||
self.obj1.gender = "female"
|
||||
self.obj2.gender = "male"
|
||||
|
||||
string = "Char1 raises $pron(your, char1) fist as Char2 raises $pron(yours, char2)"
|
||||
expected = "Char1 raises her fist as Char2 raises his"
|
||||
ret = self.parser.parse(
|
||||
string,
|
||||
caller=self.obj1,
|
||||
mapping={"char1": self.obj1, "char2": self.obj2},
|
||||
raise_errors=True,
|
||||
)
|
||||
self.assertEqual(expected, ret)
|
||||
|
||||
def test_pronoun_viewpoint(self):
|
||||
string = "Char1 smiles at $pron(I)"
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ 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"))
|
||||
|
|
|
|||
127
evennia/utils/tests/test_truecolor.py
Normal file
127
evennia/utils/tests/test_truecolor.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from evennia.utils.ansi import ANSIString as AN, ANSIParser
|
||||
|
||||
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.output2 = "\x1b[48;5;16mtest \x1b[0m"
|
||||
self.output3 = "\x1b[38;5;46mtest \x1b[0m"
|
||||
self.output4 = "\x1b[48;5;46mtest \x1b[0m"
|
||||
|
||||
def test_long_grayscale_fg(self):
|
||||
raw = f"|#000000{self.str}|n"
|
||||
ansi = AN(raw)
|
||||
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||
self.assertEqual(ansi.raw(), self.output1, "Output")
|
||||
|
||||
def test_long_grayscale_bg(self):
|
||||
raw = f"|[#000000{self.str}|n"
|
||||
ansi = AN(raw)
|
||||
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||
self.assertEqual(ansi.raw(), self.output2, "Output")
|
||||
|
||||
def test_short_grayscale_fg(self):
|
||||
raw = f"|#000{self.str}|n"
|
||||
ansi = AN(raw)
|
||||
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||
self.assertEqual(ansi.raw(), self.output1, "Output")
|
||||
|
||||
def test_short_grayscale_bg(self):
|
||||
raw = f"|[#000{self.str}|n"
|
||||
ansi = AN(raw)
|
||||
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||
self.assertEqual(ansi.raw(), self.output2, "Output")
|
||||
|
||||
def test_short_color_fg(self):
|
||||
raw = f"|#0F0{self.str}|n"
|
||||
ansi = AN(raw)
|
||||
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||
self.assertEqual(ansi.raw(), self.output3, "Output")
|
||||
|
||||
def test_short_color_bg(self):
|
||||
raw = f"|[#0f0{self.str}|n"
|
||||
ansi = AN(raw)
|
||||
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||
self.assertEqual(ansi.raw(), self.output4, "Output")
|
||||
|
||||
def test_long_color_fg(self):
|
||||
raw = f"|#00ff00{self.str}|n"
|
||||
ansi = AN(raw)
|
||||
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||
self.assertEqual(ansi.raw(), self.output3, "Output")
|
||||
|
||||
def test_long_color_bg(self):
|
||||
raw = f"|[#00FF00{self.str}|n"
|
||||
ansi = AN(raw)
|
||||
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||
self.assertEqual(ansi.raw(), self.output4, "Output")
|
||||
|
||||
|
||||
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")
|
||||
|
|
@ -13,11 +13,15 @@ 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 +71,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 +248,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 +258,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 +273,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 +290,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 +336,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 +364,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 +407,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)
|
||||
|
|
|
|||
|
|
@ -2885,7 +2885,7 @@ def int2str(number, adjective=False):
|
|||
|
||||
Args:
|
||||
number (int): The number to convert. Floats will be converted to ints.
|
||||
adjective (int): If set, map 1->1st, 2->2nd etc. If unset, map 1->one, 2->two etc.
|
||||
adjective (bool): If True, map 1->1st, 2->2nd etc. If unset or False, map 1->one, 2->two etc.
|
||||
up to twelve.
|
||||
Return:
|
||||
str: The number expressed as a string.
|
||||
|
|
|
|||
|
|
@ -365,25 +365,28 @@ def verb_is_past_participle(verb):
|
|||
return tense == "past participle"
|
||||
|
||||
|
||||
def verb_actor_stance_components(verb):
|
||||
def verb_actor_stance_components(verb, plural=False):
|
||||
"""
|
||||
Figure out actor stance components of a verb.
|
||||
|
||||
Args:
|
||||
verb (str): The verb to analyze
|
||||
plural (bool): Whether to force 3rd person to plural form
|
||||
|
||||
Returns:
|
||||
tuple: The 2nd person (you) and 3rd person forms of the verb,
|
||||
in the same tense as the ingoing verb.
|
||||
|
||||
"""
|
||||
tense = verb_tense(verb)
|
||||
them = "*" if plural else "3"
|
||||
them_suff = "" if plural else "s"
|
||||
|
||||
if "participle" in tense or "plural" in tense:
|
||||
return (verb, verb)
|
||||
if tense == "infinitive" or "present" in tense:
|
||||
you_str = verb_present(verb, person="2") or verb
|
||||
them_str = verb_present(verb, person="3") or verb + "s"
|
||||
them_str = verb_present(verb, person=them) or verb + them_suff
|
||||
else:
|
||||
you_str = verb_past(verb, person="2") or verb
|
||||
them_str = verb_past(verb, person="3") or verb + "s"
|
||||
them_str = verb_past(verb, person=them) or verb + them_suff
|
||||
return (you_str, them_str)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue