mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
Merge branch 'develop' of github.com:Tegiminis/evennia into develop
This commit is contained in:
commit
7618cf752e
51 changed files with 32888 additions and 269 deletions
58
.github/workflows/github_action_test_suite.yml
vendored
58
.github/workflows/github_action_test_suite.yml
vendored
|
|
@ -33,6 +33,7 @@ jobs:
|
|||
postgresql db: 'evennia'
|
||||
postgresql user: 'evennia'
|
||||
postgresql password: 'password'
|
||||
|
||||
- name: Set up MySQL server
|
||||
uses: mirromutth/mysql-action@v1.1
|
||||
if: ${{ matrix.TESTING_DB == 'mysql'}}
|
||||
|
|
@ -46,16 +47,48 @@ jobs:
|
|||
mysql database: 'evennia'
|
||||
mysql user: 'evennia'
|
||||
mysql password: 'password'
|
||||
mysql root password: root_password
|
||||
|
||||
# wait for db to activage, get logs from their start
|
||||
- name: Wait / sleep
|
||||
uses: jakejarvis/wait-action@v0.1.0
|
||||
if: ${{ matrix.TESTING_DB == 'postgresql' || matrix.TESTING_DB == 'mysql' }}
|
||||
with:
|
||||
time: '10s'
|
||||
# wait for db to activate
|
||||
- name: wait for db to activate
|
||||
if: matrix.TESTING_DB == 'postgresql' || matrix.TESTING_DB == 'mysql'
|
||||
run: |
|
||||
|
||||
if [ ${{ matrix.TESTING_DB }} = mysql ]
|
||||
then
|
||||
while ! mysqladmin ping -h 127.0.0.1 -u root -proot_password -s >/dev/null 2>&1
|
||||
do
|
||||
sleep 1
|
||||
echo -n .
|
||||
done
|
||||
echo
|
||||
else
|
||||
while ! pg_isready -h 127.0.0.1 -q >/dev/null 2>&1
|
||||
do
|
||||
sleep 1
|
||||
echo -n .
|
||||
done
|
||||
echo
|
||||
fi
|
||||
|
||||
- name: mysql privileges
|
||||
if: matrix.TESTING_DB == 'mysql'
|
||||
run: |
|
||||
|
||||
cat <<EOF | mysql -u root -proot_password -h 127.0.0.1 mysql
|
||||
create user 'evennia'@'%' identified by 'password';
|
||||
grant all on \`evennia%\`.* to 'evennia'@'%';
|
||||
grant process on *.* to 'evennia'@'%';
|
||||
flush privileges
|
||||
EOF
|
||||
|
||||
# get logs from db start
|
||||
- name: Database container logs
|
||||
if: matrix.TESTING_DB == 'postgresql' || matrix.TESTING_DB == 'mysql'
|
||||
uses: jwalton/gh-docker-logs@v1.0.0
|
||||
|
||||
- name: Check running containers
|
||||
if: matrix.TESTING_DB == 'postgresql' || matrix.TESTING_DB == 'mysql'
|
||||
run: docker ps -a
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
|
@ -71,6 +104,7 @@ jobs:
|
|||
pip install mysqlclient
|
||||
pip install coveralls
|
||||
pip install codacy-coverage
|
||||
pip install tblib
|
||||
pip install -e .
|
||||
|
||||
- name: Install extra dependencies
|
||||
|
|
@ -87,7 +121,15 @@ jobs:
|
|||
- name: Run test suite
|
||||
run: |
|
||||
cd testing_mygame
|
||||
coverage run --source=../evennia --omit=*/migrations/*,*/urls.py,*/test*.py,*.sh,*.txt,*.md,*.pyc,*.service ../bin/unix/evennia test --settings=settings --keepdb evennia
|
||||
coverage run \
|
||||
--source=../evennia \
|
||||
--omit=*/migrations/*,*/urls.py,*/test*.py,*.sh,*.txt,*.md,*.pyc,*.service \
|
||||
../bin/unix/evennia test \
|
||||
--settings=settings \
|
||||
--keepdb \
|
||||
--parallel 4 \
|
||||
--timing \
|
||||
evennia
|
||||
coverage xml
|
||||
|
||||
# we only want to run coverall/codacy once, so we only do it for one of the matrix combinations
|
||||
|
|
@ -109,7 +151,7 @@ jobs:
|
|||
with:
|
||||
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
||||
coverage-reports: ./testing_mygame/coverage.xml
|
||||
|
||||
|
||||
# docker setup and push
|
||||
-
|
||||
name: Set up QEMU
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -168,6 +168,19 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10
|
|||
now return `None` instead of `.db.desc` if no sdesc is set; fallback in hook (inspectorCaracal)
|
||||
- Reworked text2html parser to avoid problems with stateful color tags (inspectorCaracal)
|
||||
- Simplified `EvMenu.options_formatter` hook to use `EvColumn` and f-strings (inspectorcaracal)
|
||||
- Added `move_type` str kwarg to `move_to()` calls, optionally identifying the type of
|
||||
move being done ('teleport', 'disembark', 'give' etc). (volund)
|
||||
- Made RPSystem contrib msg calls pass `pose` or `say` as msg-`type` for use in
|
||||
e.g. webclient pane filtering where desired. (volund)
|
||||
- Fixed bug in `cmdset.remove()` where a command could not be deleted by `key`,
|
||||
even though doc suggested one could (ChrisLR)
|
||||
- New contrib `name_generator` for building random real-world based or fantasy-names
|
||||
based on phonetic rules.
|
||||
- Enable proper serialization of dict subclasses in Attributes (aogier)
|
||||
- `object.search` fuzzy-matching now uses `icontains` instead of `istartswith`
|
||||
to better match how search works elsewhere (volund)
|
||||
- The `.at_traverse` hook now receives a `exit_obj` kwarg, linking back to the
|
||||
exit triggering the hook (volund)
|
||||
|
||||
|
||||
## Evennia 0.9.5
|
||||
|
|
|
|||
44
LICENSE.txt
44
LICENSE.txt
|
|
@ -1,35 +1,19 @@
|
|||
BSD license
|
||||
===========
|
||||
BSD 3-Clause License
|
||||
|
||||
Evennia MU* creation system
|
||||
Copyright (c) 2012-, Griatch (griatch <AT> gmail <DOT> com), Gregory Taylor
|
||||
All rights reserved.
|
||||
Copyright 2012- Griatch (griatch <AT> gmail <DOT> com), Gregory Taylor
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
- Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
- Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
- Neither the name of the Copyright Holders nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED
|
||||
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
|
||||
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
|
||||
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
|
||||
OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
|
||||
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ PS1="evennia|docker \w $ "
|
|||
|
||||
cmd="$@"
|
||||
output="Docker starting with argument '$cmd' ..."
|
||||
if test -z $cmd; then
|
||||
if test -z "$cmd"; then
|
||||
cmd="bash"
|
||||
output="No argument given, starting shell ..."
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -160,6 +160,14 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10
|
|||
- Attribute storage support defaultdics (Hendher)
|
||||
- Add ObjectParent mixin to default game folder template as an easy, ready-made
|
||||
way to override features on all ObjectDB-inheriting objects easily.
|
||||
- Add `TagProperty`, `AliasProperty` and `PermissionProperty` to assign these
|
||||
data in a similar way to django fields.
|
||||
- The db pickle-serializer now checks for methods `__serialize_dbobjs__` and `__deserialize_dbobjs__`
|
||||
to allow custom packing/unpacking of nested dbobjs, to allow storing in Attribute.
|
||||
- Optimizations to rpsystem contrib performance. Breaking change: `.get_sdesc()` will
|
||||
now return `None` instead of `.db.desc` if no sdesc is set; fallback in hook (inspectorCaracal)
|
||||
- Reworked text2html parser to avoid problems with stateful color tags (inspectorCaracal)
|
||||
- Simplified `EvMenu.options_formatter` hook to use `EvColumn` and f-strings (inspectorcaracal)
|
||||
|
||||
|
||||
## Evennia 0.9.5
|
||||
|
|
|
|||
|
|
@ -325,14 +325,15 @@ values into a string representation before storing it to the database. This is d
|
|||
|
||||
### Storing single objects
|
||||
|
||||
With a single object, we mean anything that is *not iterable*, like numbers, strings or custom class
|
||||
instances without the `__iter__` method.
|
||||
With a single object, we mean anything that is *not iterable*, like numbers,
|
||||
strings or custom class instances without the `__iter__` method.
|
||||
|
||||
* You can generally store any non-iterable Python entity that can be _pickled_.
|
||||
* Single database objects/typeclasses can be stored, despite them normally not being possible
|
||||
to pickle. Evennia will convert them to an internal representation using theihr classname,
|
||||
database-id and creation-date with a microsecond precision. When retrieving, the object
|
||||
instance will be re-fetched from the database using this information.
|
||||
* Single database objects/typeclasses can be stored, despite them normally not
|
||||
being possible to pickle. Evennia will convert them to an internal
|
||||
representation using theihr classname, database-id and creation-date with a
|
||||
microsecond precision. When retrieving, the object instance will be re-fetched
|
||||
from the database using this information.
|
||||
* If you 'hide' a db-obj as a property on a custom class, Evennia will not be
|
||||
able to find it to serialize it. For that you need to help it out (see below).
|
||||
|
||||
|
|
@ -367,8 +368,8 @@ obj.db.mydata = container # will raise error!
|
|||
By adding two methods `__serialize_dbobjs__` and `__deserialize_dbobjs__` to the
|
||||
object you want to save, you can pre-serialize and post-deserialize all 'hidden'
|
||||
objects before Evennia's main serializer gets to work. Inside these methods, use Evennia's
|
||||
[evennia.utils.dbserialize.dbserialize](api:evennia.utils.dbserialize.dbserialize) and
|
||||
[dbunserialize](api:evennia.utils.dbserialize.dbunserialize) functions to safely
|
||||
[evennia.utils.dbserialize.dbserialize](evennia.utils.dbserialize.dbserialize) and
|
||||
[dbunserialize](evennia.utils.dbserialize.dbunserialize) functions to safely
|
||||
serialize the db-objects you want to store.
|
||||
|
||||
```{code-block} python
|
||||
|
|
@ -389,13 +390,23 @@ class Container:
|
|||
def __deserialize_dbobjs__(self):
|
||||
"""This is called after deserialization and allows you to
|
||||
restore the 'hidden' dbobjs you serialized before"""
|
||||
self.mydbobj = dbserialize.dbunserialize(self.mydbobj)
|
||||
if isinstance(self.mydbobj, bytes):
|
||||
# make sure to check if it's bytes before trying dbunserialize
|
||||
self.mydbobj = dbserialize.dbunserialize(self.mydbobj)
|
||||
|
||||
# let's assume myobj is a db-object
|
||||
container = Container(myobj)
|
||||
obj.db.mydata = container # will now work fine!
|
||||
```
|
||||
|
||||
> Note the extra check in `__deserialize_dbobjs__` to make sure the thing you
|
||||
> are deserializing is a `bytes` object. This is needed because the Attribute's
|
||||
> cache reruns deserializations in some situations when the data was already
|
||||
> once deserialized. If you see errors in the log saying
|
||||
> `Could not unpickle data for storage: ...`, the reason is
|
||||
> likely that you forgot to add this check.
|
||||
|
||||
|
||||
### Storing multiple objects
|
||||
|
||||
This means storing objects in a collection of some kind and are examples of *iterables*, pickle-able
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class Sword(DefaultObject):
|
|||
|
||||
```
|
||||
|
||||
_Tags_ are short text lables one can 'hang' on objects in order to organize, group and quickly find out their properties. An Evennia entity can be tagged by any number of tags. They are more efficient than [Attributes](Attributes) since on the database-side, Tags are _shared_ between all objects with that particular tag. A tag does not carry a value in itself; it either sits on the entity
|
||||
_Tags_ are short text lables one can 'hang' on objects in order to organize, group and quickly find out their properties. An Evennia entity can be tagged by any number of tags. They are more efficient than [Attributes](./Attributes.md) since on the database-side, Tags are _shared_ between all objects with that particular tag. A tag does not carry a value in itself; it either sits on the entity
|
||||
|
||||
Above, the tags inform us that the `Sword` is both sharp and can be wielded. If that's all they do, they could just be a normal Python flag. When tags become important is if there are a lot of objects with different combinations of tags. Maybe you have a magical spell that dulls _all_ sharp-edged objects in the castle - whether sword, dagger, spear or kitchen knife! You can then just grab all objects with the `has_sharp_edge` tag.
|
||||
Another example would be a weather script affecting all rooms tagged as `outdoors` or finding all characters tagged with `belongs_to_fighter_guild`.
|
||||
|
|
|
|||
198
docs/source/Contribs/Contrib-Components.md
Normal file
198
docs/source/Contribs/Contrib-Components.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Components
|
||||
|
||||
_Contrib by ChrisLR 2021_
|
||||
|
||||
# The Components Contrib
|
||||
|
||||
This contrib introduces Components and Composition to Evennia.
|
||||
Each 'Component' class represents a feature that will be 'enabled' on a typeclass instance.
|
||||
You can register these components on an entire typeclass or a single object at runtime.
|
||||
It supports both persisted attributes and in-memory attributes by using Evennia's AttributeHandler.
|
||||
|
||||
# Pros
|
||||
- You can reuse a feature across multiple typeclasses without inheritance
|
||||
- You can cleanly organize each feature into a self-contained class.
|
||||
- You can check if your object supports a feature without checking its instance.
|
||||
|
||||
# Cons
|
||||
- It introduces additional complexity.
|
||||
- A host typeclass instance is required.
|
||||
|
||||
# How to install
|
||||
|
||||
To enable component support for a typeclass,
|
||||
import and inherit the ComponentHolderMixin, similar to this
|
||||
```python
|
||||
from evennia.contrib.base_systems.components import ComponentHolderMixin
|
||||
class Character(ComponentHolderMixin, DefaultCharacter):
|
||||
# ...
|
||||
```
|
||||
|
||||
Components need to inherit the Component class directly and require a name.
|
||||
```python
|
||||
from evennia.contrib.components import Component
|
||||
|
||||
class Health(Component):
|
||||
name = "health"
|
||||
```
|
||||
|
||||
Components may define DBFields or NDBFields at the class level.
|
||||
DBField will store its values in the host's DB with a prefixed key.
|
||||
NDBField will store its values in the host's NDB and will not persist.
|
||||
The key used will be 'component_name::field_name'.
|
||||
They use AttributeProperty under the hood.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from evennia.contrib.base_systems.components import Component, DBField
|
||||
|
||||
class Health(Component):
|
||||
health = DBField(default=1)
|
||||
```
|
||||
|
||||
Note that default is optional and will default to None.
|
||||
|
||||
Adding a component to a host will also a similarly named tag with 'components' as category.
|
||||
A Component named health will appear as key="health, category="components".
|
||||
This allows you to retrieve objects with specific components by searching with the tag.
|
||||
|
||||
It is also possible to add Component Tags the same way, using TagField.
|
||||
TagField accepts a default value and can be used to store a single or multiple tags.
|
||||
Default values are automatically added when the component is added.
|
||||
Component Tags are cleared from the host if the component is removed.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from evennia.contrib.base_systems.components import Component, TagField
|
||||
|
||||
class Health(Component):
|
||||
resistances = TagField()
|
||||
vulnerability = TagField(default="fire", enforce_single=True)
|
||||
```
|
||||
|
||||
The 'resistances' field in this example can be set to multiple times and it will keep the added tags.
|
||||
The 'vulnerability' field in this example will override the previous tag with the new one.
|
||||
|
||||
|
||||
|
||||
Each typeclass using the ComponentHolderMixin can declare its components
|
||||
in the class via the ComponentProperty.
|
||||
These are components that will always be present in a typeclass.
|
||||
You can also pass kwargs to override the default values
|
||||
Example
|
||||
```python
|
||||
from evennia.contrib.base_systems.components import ComponentHolderMixin
|
||||
class Character(ComponentHolderMixin, DefaultCharacter):
|
||||
health = ComponentProperty("health", hp=10, max_hp=50)
|
||||
```
|
||||
|
||||
You can then use character.components.health to access it.
|
||||
The shorter form character.cmp.health also exists.
|
||||
character.health would also be accessible but only for typeclasses that have
|
||||
this component defined on the class.
|
||||
|
||||
Alternatively you can add those components at runtime.
|
||||
You will have to access those via the component handler.
|
||||
Example
|
||||
```python
|
||||
character = self
|
||||
vampirism = components.Vampirism.create(character)
|
||||
character.components.add(vampirism)
|
||||
|
||||
...
|
||||
|
||||
vampirism_from_elsewhere = character.components.get("vampirism")
|
||||
```
|
||||
|
||||
Keep in mind that all components must be imported to be visible in the listing.
|
||||
As such, I recommend regrouping them in a package.
|
||||
You can then import all your components in that package's __init__
|
||||
|
||||
Because of how Evennia import typeclasses and the behavior of python imports
|
||||
I recommend placing the components package inside the typeclass package.
|
||||
In other words, create a folder named components inside your typeclass folder.
|
||||
Then, inside the 'typeclasses/__init__.py' file add the import to the folder, like
|
||||
```python
|
||||
from typeclasses import components
|
||||
```
|
||||
This ensures that the components package will be imported when the typeclasses are imported.
|
||||
You will also need to import each components inside the package's own 'typeclasses/components/__init__.py' file.
|
||||
You only need to import each module/file from there but importing the right class is a good practice.
|
||||
```python
|
||||
from typeclasses.components.health import Health
|
||||
```
|
||||
```python
|
||||
from typeclasses.components import health
|
||||
```
|
||||
Both of the above examples will work.
|
||||
|
||||
# Full Example
|
||||
```python
|
||||
from evennia.contrib.base_systems import components
|
||||
|
||||
|
||||
# This is the Component class
|
||||
class Health(components.Component):
|
||||
name = "health"
|
||||
|
||||
# Stores the current and max values as Attributes on the host, defaulting to 100
|
||||
current = components.DBField(default=100)
|
||||
max = components.DBField(default=100)
|
||||
|
||||
def damage(self, value):
|
||||
if self.current <= 0:
|
||||
return
|
||||
|
||||
self.current -= value
|
||||
if self.current > 0:
|
||||
return
|
||||
|
||||
self.current = 0
|
||||
self.on_death()
|
||||
|
||||
def heal(self, value):
|
||||
hp = self.current
|
||||
hp += value
|
||||
if hp >= self.max_hp:
|
||||
hp = self.max_hp
|
||||
|
||||
self.current = hp
|
||||
|
||||
@property
|
||||
def is_dead(self):
|
||||
return self.current <= 0
|
||||
|
||||
def on_death(self):
|
||||
# Behavior is defined on the typeclass
|
||||
self.host.on_death()
|
||||
|
||||
|
||||
# This is how the Character inherits the mixin and registers the component 'health'
|
||||
class Character(ComponentHolderMixin, DefaultCharacter):
|
||||
health = ComponentProperty("health")
|
||||
|
||||
|
||||
# This is an example of a command that checks for the component
|
||||
class Attack(Command):
|
||||
key = "attack"
|
||||
aliases = ('melee', 'hit')
|
||||
|
||||
def at_pre_cmd(self):
|
||||
caller = self.caller
|
||||
targets = self.caller.search(args, quiet=True)
|
||||
valid_target = None
|
||||
for target in targets:
|
||||
# Attempt to retrieve the component, None is obtained if it does not exist.
|
||||
if target.components.health:
|
||||
valid_target = target
|
||||
|
||||
if not valid_target:
|
||||
caller.msg("You can't attack that!")
|
||||
return True
|
||||
```
|
||||
|
||||
|
||||
----
|
||||
|
||||
<small>This document page is generated from `evennia/contrib/base_systems/components/README.md`. Changes to this
|
||||
file will be overwritten, so edit that file rather than this one.</small>
|
||||
|
|
@ -728,4 +728,4 @@ available at https://evennia.github.io/evennia/latest/.
|
|||
[linkdemo]: #Links
|
||||
[retext]: https://github.com/retext-project/retext
|
||||
[grip]: https://github.com/joeyespo/grip
|
||||
[pycharm]: https://www.jetbrains.com/pycharm/
|
||||
[pycharm]: https://www.jetbrains.com/pycharm/
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ def menunode_inspect_and_buy(caller, raw_string):
|
|||
if wealth >= value:
|
||||
rtext = f"You pay {value} gold and purchase {ware.key}!"
|
||||
caller.db.gold -= value
|
||||
ware.move_to(caller, quiet=True)
|
||||
ware.move_to(caller, quiet=True, move_type="buy")
|
||||
else:
|
||||
rtext = f"You cannot afford {value} gold for {ware.key}!"
|
||||
caller.msg(rtext)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ class CmdEnterTrain(Command):
|
|||
def func(self):
|
||||
train = self.obj
|
||||
self.caller.msg("You board the train.")
|
||||
self.caller.move_to(train)
|
||||
self.caller.move_to(train, move_type="board")
|
||||
|
||||
|
||||
class CmdLeaveTrain(Command):
|
||||
|
|
@ -107,7 +107,7 @@ class CmdLeaveTrain(Command):
|
|||
def func(self):
|
||||
train = self.obj
|
||||
parent = train.location
|
||||
self.caller.move_to(parent)
|
||||
self.caller.move_to(parent, move_type="disembark")
|
||||
|
||||
|
||||
class CmdSetTrain(CmdSet):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.base\_systems.components.component
|
||||
=========================================================
|
||||
|
||||
.. automodule:: evennia.contrib.base_systems.components.component
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.base\_systems.components.dbfield
|
||||
=======================================================
|
||||
|
||||
.. automodule:: evennia.contrib.base_systems.components.dbfield
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.base\_systems.components.holder
|
||||
======================================================
|
||||
|
||||
.. automodule:: evennia.contrib.base_systems.components.holder
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
21
docs/source/api/evennia.contrib.base_systems.components.md
Normal file
21
docs/source/api/evennia.contrib.base_systems.components.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.base\_systems.components
|
||||
================================================
|
||||
|
||||
.. automodule:: evennia.contrib.base_systems.components
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 6
|
||||
|
||||
evennia.contrib.base_systems.components.component
|
||||
evennia.contrib.base_systems.components.dbfield
|
||||
evennia.contrib.base_systems.components.holder
|
||||
evennia.contrib.base_systems.components.signals
|
||||
evennia.contrib.base_systems.components.tests
|
||||
|
||||
```
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.base\_systems.components.signals
|
||||
=======================================================
|
||||
|
||||
.. automodule:: evennia.contrib.base_systems.components.signals
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.base\_systems.components.tests
|
||||
=====================================================
|
||||
|
||||
.. automodule:: evennia.contrib.base_systems.components.tests
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -298,7 +298,7 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
|
|||
return
|
||||
if not obj.access(self, "puppet"):
|
||||
# no access
|
||||
self.msg("You don't have permission to puppet '{obj.key}'.")
|
||||
self.msg(f"You don't have permission to puppet '{obj.key}'.")
|
||||
return
|
||||
if obj.account:
|
||||
# object already puppeted
|
||||
|
|
|
|||
|
|
@ -61,38 +61,14 @@ def build_matches(raw_string, cmdset, include_prefixes=False):
|
|||
"""
|
||||
matches = []
|
||||
try:
|
||||
if include_prefixes:
|
||||
# use the cmdname as-is
|
||||
l_raw_string = raw_string.lower()
|
||||
for cmd in cmdset:
|
||||
matches.extend(
|
||||
[
|
||||
create_match(cmdname, raw_string, cmd, cmdname)
|
||||
for cmdname in [cmd.key] + cmd.aliases
|
||||
if cmdname
|
||||
and l_raw_string.startswith(cmdname.lower())
|
||||
and (not cmd.arg_regex or cmd.arg_regex.match(l_raw_string[len(cmdname) :]))
|
||||
]
|
||||
)
|
||||
else:
|
||||
# strip prefixes set in settings
|
||||
raw_string = (
|
||||
raw_string.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_string) > 1 else raw_string
|
||||
)
|
||||
l_raw_string = raw_string.lower()
|
||||
for cmd in cmdset:
|
||||
for raw_cmdname in [cmd.key] + cmd.aliases:
|
||||
cmdname = (
|
||||
raw_cmdname.lstrip(_CMD_IGNORE_PREFIXES)
|
||||
if len(raw_cmdname) > 1
|
||||
else raw_cmdname
|
||||
)
|
||||
if (
|
||||
cmdname
|
||||
and l_raw_string.startswith(cmdname.lower())
|
||||
and (not cmd.arg_regex or cmd.arg_regex.match(l_raw_string[len(cmdname) :]))
|
||||
):
|
||||
matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname))
|
||||
orig_string = raw_string
|
||||
if not include_prefixes and len(raw_string) > 1:
|
||||
raw_string = raw_string.lstrip(_CMD_IGNORE_PREFIXES)
|
||||
search_string = raw_string.lower()
|
||||
for cmd in cmdset:
|
||||
cmdname, raw_cmdname = cmd.match(search_string, include_prefixes=include_prefixes)
|
||||
if cmdname:
|
||||
matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname))
|
||||
except Exception:
|
||||
log_trace("cmdhandler error. raw_input:%s" % raw_string)
|
||||
return matches
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ Set theory.
|
|||
|
||||
"""
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from evennia.utils.utils import inherits_from, is_iter
|
||||
|
||||
|
|
@ -546,10 +547,7 @@ class CmdSet(object, metaclass=_CmdSetMeta):
|
|||
commands[ic] = cmd # replace
|
||||
except ValueError:
|
||||
commands.append(cmd)
|
||||
self.commands = commands
|
||||
if not allow_duplicates:
|
||||
# extra run to make sure to avoid doublets
|
||||
self.commands = list(set(self.commands))
|
||||
|
||||
# add system_command to separate list as well,
|
||||
# for quick look-up
|
||||
if cmd.key.startswith("__"):
|
||||
|
|
@ -559,6 +557,11 @@ class CmdSet(object, metaclass=_CmdSetMeta):
|
|||
except ValueError:
|
||||
system_commands.append(cmd)
|
||||
|
||||
self.commands = commands
|
||||
if not allow_duplicates:
|
||||
# extra run to make sure to avoid doublets
|
||||
self.commands = list(set(self.commands))
|
||||
|
||||
def remove(self, cmd):
|
||||
"""
|
||||
Remove a command instance from the cmdset.
|
||||
|
|
@ -568,6 +571,15 @@ class CmdSet(object, metaclass=_CmdSetMeta):
|
|||
or the key of such a command.
|
||||
|
||||
"""
|
||||
if isinstance(cmd, str):
|
||||
_cmd = next((_cmd for _cmd in self.commands if _cmd.key == cmd), None)
|
||||
if _cmd is None:
|
||||
if not cmd.startswith("__"):
|
||||
# if a syscommand, keep the original string and instantiate on it
|
||||
return None
|
||||
else:
|
||||
cmd = _cmd
|
||||
|
||||
cmd = self._instantiate(cmd)
|
||||
if cmd.key.startswith("__"):
|
||||
try:
|
||||
|
|
@ -591,6 +603,15 @@ class CmdSet(object, metaclass=_CmdSetMeta):
|
|||
cmd (Command): The first matching Command in the set.
|
||||
|
||||
"""
|
||||
if isinstance(cmd, str):
|
||||
_cmd = next((_cmd for _cmd in self.commands if _cmd.key == cmd), None)
|
||||
if _cmd is None:
|
||||
if not cmd.startswith("__"):
|
||||
# if a syscommand, keep the original string and instantiate on it
|
||||
return None
|
||||
else:
|
||||
cmd = _cmd
|
||||
|
||||
cmd = self._instantiate(cmd)
|
||||
for thiscmd in self.commands:
|
||||
if thiscmd == cmd:
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ class Command(metaclass=CommandMeta):
|
|||
"""
|
||||
if kwargs:
|
||||
_init_command(self, **kwargs)
|
||||
self._optimize()
|
||||
|
||||
@lazy_property
|
||||
def lockhandler(self):
|
||||
|
|
@ -297,10 +298,15 @@ class Command(metaclass=CommandMeta):
|
|||
Optimize the key and aliases for lookups.
|
||||
"""
|
||||
# optimization - a set is much faster to match against than a list
|
||||
self._matchset = set([self.key] + self.aliases)
|
||||
matches = [self.key.lower()]
|
||||
matches.extend(x.lower() for x in self.aliases)
|
||||
|
||||
self._matchset = set(matches)
|
||||
# optimization for looping over keys+aliases
|
||||
self._keyaliases = tuple(self._matchset)
|
||||
|
||||
self._noprefix_aliases = {x.lstrip(CMD_IGNORE_PREFIXES): x for x in matches}
|
||||
|
||||
def set_key(self, new_key):
|
||||
"""
|
||||
Update key.
|
||||
|
|
@ -336,7 +342,7 @@ class Command(metaclass=CommandMeta):
|
|||
self.aliases = list(set(alias for alias in aliases if alias != self.key))
|
||||
self._optimize()
|
||||
|
||||
def match(self, cmdname):
|
||||
def match(self, cmdname, include_prefixes=True):
|
||||
"""
|
||||
This is called by the system when searching the available commands,
|
||||
in order to determine if this is the one we wanted. cmdname was
|
||||
|
|
@ -345,11 +351,23 @@ class Command(metaclass=CommandMeta):
|
|||
Args:
|
||||
cmdname (str): Always lowercase when reaching this point.
|
||||
|
||||
Kwargs:
|
||||
include_prefixes (bool): If false, will compare against the _noprefix
|
||||
variants of commandnames.
|
||||
|
||||
Returns:
|
||||
result (bool): Match result.
|
||||
|
||||
"""
|
||||
return cmdname in self._matchset
|
||||
if include_prefixes:
|
||||
for cmd_key in self._keyaliases:
|
||||
if cmdname.startswith(cmd_key) and (not self.arg_regex or self.arg_regex.match(cmdname[len(cmd_key) :])):
|
||||
return cmd_key, cmd_key
|
||||
else:
|
||||
for k, v in self._noprefix_aliases.items():
|
||||
if cmdname.startswith(k) and (not self.arg_regex or self.arg_regex.match(cmdname[len(k) :])):
|
||||
return k, v
|
||||
return None, None
|
||||
|
||||
def access(self, srcobj, access_type="cmd", default=False):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
|
|||
_FUNCPARSER = None
|
||||
_ATTRFUNCPARSER = None
|
||||
|
||||
_KEY_REGEX = re.compile(r"(?P<attr>.*?)(?P<key>(\[.*\]\ *)+)?$")
|
||||
|
||||
# limit symbol import for API
|
||||
__all__ = (
|
||||
"ObjManipCommand",
|
||||
|
|
@ -126,7 +128,28 @@ class ObjManipCommand(COMMAND_DEFAULT_CLASS):
|
|||
aliases = [alias.strip() for alias in aliases.split(";") if alias.strip()]
|
||||
if "/" in objdef:
|
||||
objdef, attrs = [part.strip() for part in objdef.split("/", 1)]
|
||||
attrs = [part.strip().lower() for part in attrs.split("/") if part.strip()]
|
||||
_attrs = []
|
||||
|
||||
# Should an attribute key is specified, ie. we're working
|
||||
# on a dict, what we want is to lowercase attribute name
|
||||
# as usual but to preserve dict key case as one would
|
||||
# expect:
|
||||
#
|
||||
# set box/MyAttr = {'FooBar': 1}
|
||||
# Created attribute box/myattr [category:None] = {'FooBar': 1}
|
||||
# set box/MyAttr['FooBar'] = 2
|
||||
# Modified attribute box/myattr [category:None] = {'FooBar': 2}
|
||||
for match in (
|
||||
match
|
||||
for part in map(str.strip, attrs.split("/"))
|
||||
if part and (match := _KEY_REGEX.match(part.strip()))
|
||||
):
|
||||
attr = match.group("attr").lower()
|
||||
# reappend untouched key, if present
|
||||
if match.group("key"):
|
||||
attr += match.group("key")
|
||||
_attrs.append(attr)
|
||||
attrs = _attrs
|
||||
# store data
|
||||
obj_defs[iside].append({"name": objdef, "option": option, "aliases": aliases})
|
||||
obj_attrs[iside].append({"name": objdef, "attrs": attrs})
|
||||
|
|
@ -603,7 +626,7 @@ class CmdCreate(ObjManipCommand):
|
|||
if "drop" in self.switches:
|
||||
if caller.location:
|
||||
obj.home = caller.location
|
||||
obj.move_to(caller.location, quiet=True)
|
||||
obj.move_to(caller.location, quiet=True, move_type="drop")
|
||||
if string:
|
||||
caller.msg(string)
|
||||
|
||||
|
|
@ -993,7 +1016,7 @@ class CmdDig(ObjManipCommand):
|
|||
)
|
||||
caller.msg("%s%s%s" % (room_string, exit_to_string, exit_back_string))
|
||||
if new_room and "teleport" in self.switches:
|
||||
caller.move_to(new_room)
|
||||
caller.move_to(new_room, move_type="teleport")
|
||||
|
||||
|
||||
class CmdTunnel(COMMAND_DEFAULT_CLASS):
|
||||
|
|
@ -1927,14 +1950,11 @@ class CmdSetAttribute(ObjManipCommand):
|
|||
if self.rhs is None:
|
||||
# no = means we inspect the attribute(s)
|
||||
if not attrs:
|
||||
attrs = [attr.key for attr in obj.attributes.get(category=None)]
|
||||
attrs = [attr.key for attr in obj.attributes.get(category=None, return_obj=True, return_list=True)]
|
||||
for attr in attrs:
|
||||
if not self.check_attr(obj, attr, category):
|
||||
continue
|
||||
result.append(self.view_attr(obj, attr, category))
|
||||
# we view it without parsing markup.
|
||||
self.caller.msg("".join(result).strip(), options={"raw": True})
|
||||
return
|
||||
else:
|
||||
# deleting the attribute(s)
|
||||
if not (obj.access(self.caller, "control") or obj.access(self.caller, "edit")):
|
||||
|
|
@ -1979,8 +1999,12 @@ class CmdSetAttribute(ObjManipCommand):
|
|||
else:
|
||||
value = _convert_from_string(self, value)
|
||||
result.append(self.set_attr(obj, attr, value, category))
|
||||
# send feedback
|
||||
caller.msg("".join(result).strip("\n"))
|
||||
# check if anything was done
|
||||
if not result:
|
||||
caller.msg("No valid attributes were found. Usage: set obj/attr[:category] = value. Use empty value to clear.")
|
||||
else:
|
||||
# send feedback
|
||||
caller.msg("".join(result).strip("\n"))
|
||||
|
||||
|
||||
class CmdTypeclass(COMMAND_DEFAULT_CLASS):
|
||||
|
|
@ -3708,6 +3732,7 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
|
|||
quiet="quiet" in self.switches,
|
||||
emit_to_obj=caller,
|
||||
use_destination="intoexit" not in self.switches,
|
||||
move_type="teleport"
|
||||
):
|
||||
|
||||
if obj_to_teleport == caller:
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class CmdHome(COMMAND_DEFAULT_CLASS):
|
|||
caller.msg("You are already home!")
|
||||
else:
|
||||
caller.msg("There's no place like home ...")
|
||||
caller.move_to(home)
|
||||
caller.move_to(home, move_type="teleport")
|
||||
|
||||
|
||||
class CmdLook(COMMAND_DEFAULT_CLASS):
|
||||
|
|
@ -434,7 +434,7 @@ class CmdGet(COMMAND_DEFAULT_CLASS):
|
|||
if not obj.at_pre_get(caller):
|
||||
return
|
||||
|
||||
success = obj.move_to(caller, quiet=True)
|
||||
success = obj.move_to(caller, quiet=True, move_type="get")
|
||||
if not success:
|
||||
caller.msg("This can't be picked up.")
|
||||
else:
|
||||
|
|
@ -484,7 +484,7 @@ class CmdDrop(COMMAND_DEFAULT_CLASS):
|
|||
if not obj.at_pre_drop(caller):
|
||||
return
|
||||
|
||||
success = obj.move_to(caller.location, quiet=True)
|
||||
success = obj.move_to(caller.location, quiet=True, move_type="drop")
|
||||
if not success:
|
||||
caller.msg("This couldn't be dropped.")
|
||||
else:
|
||||
|
|
@ -538,7 +538,7 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
|
|||
return
|
||||
|
||||
# give object
|
||||
success = to_give.move_to(target, quiet=True)
|
||||
success = to_give.move_to(target, quiet=True, move_type="get")
|
||||
if not success:
|
||||
caller.msg("This could not be given.")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -874,14 +874,14 @@ class CmdSetHelp(CmdHelp):
|
|||
if isinstance(match, HelpCategory):
|
||||
warning = (
|
||||
f"'{querystr}' matches (or partially matches) the name of "
|
||||
"help-category '{match.key}'. If you continue, your help entry will "
|
||||
f"help-category '{match.key}'. If you continue, your help entry will "
|
||||
"take precedence and the category (or part of its name) *may* not "
|
||||
"be usable for grouping help entries anymore."
|
||||
)
|
||||
elif inherits_from(match, "evennia.commands.command.Command"):
|
||||
warning = (
|
||||
f"'{querystr}' matches (or partially matches) the key/alias of "
|
||||
"Command '{match.key}'. Command-help take precedence over other "
|
||||
f"Command '{match.key}'. Command-help take precedence over other "
|
||||
"help entries so your help *may* be impossible to reach for those "
|
||||
"with access to that command."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -957,6 +957,39 @@ class TestBuilding(BaseEvenniaCommandTest):
|
|||
"{'one': 99, 'three': 3, '+': 42, '+1': 33}",
|
||||
)
|
||||
|
||||
# dict - case sensitive keys
|
||||
|
||||
self.call(
|
||||
building.CmdSetAttribute(),
|
||||
"Obj/test_case = {'FooBar': 1}",
|
||||
"Created attribute Obj/test_case [category:None] = {'FooBar': 1}",
|
||||
)
|
||||
self.call(
|
||||
building.CmdSetAttribute(),
|
||||
"Obj/test_case['FooBar'] = 2",
|
||||
"Modified attribute Obj/test_case [category:None] = {'FooBar': 2}",
|
||||
)
|
||||
self.call(
|
||||
building.CmdSetAttribute(),
|
||||
"Obj/test_case",
|
||||
"Attribute Obj/test_case [category:None] = {'FooBar': 2}",
|
||||
)
|
||||
self.call(
|
||||
building.CmdSetAttribute(),
|
||||
"Obj/test_case['FooBar'] = {'BarBaz': 1}",
|
||||
"Modified attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 1}}",
|
||||
)
|
||||
self.call(
|
||||
building.CmdSetAttribute(),
|
||||
"Obj/test_case['FooBar']['BarBaz'] = 2",
|
||||
"Modified attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 2}}",
|
||||
)
|
||||
self.call(
|
||||
building.CmdSetAttribute(),
|
||||
"Obj/test_case",
|
||||
"Attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 2}}",
|
||||
)
|
||||
|
||||
# tuple
|
||||
self.call(
|
||||
building.CmdSetAttribute(),
|
||||
|
|
|
|||
|
|
@ -1199,3 +1199,21 @@ class TestCmdSetNesting(BaseEvenniaTest):
|
|||
|
||||
cmd = self.char1.cmdset.cmdset_stack[-1].commands[0]
|
||||
self.assertEqual(cmd.obj, self.char1)
|
||||
|
||||
|
||||
class TestCmdSet(BaseEvenniaTest):
|
||||
"""
|
||||
General tests for cmdsets
|
||||
"""
|
||||
|
||||
def test_cmdset_remove_by_key(self):
|
||||
test_cmd_set = _CmdSetTest()
|
||||
test_cmd_set.remove("another command")
|
||||
|
||||
self.assertNotIn(_CmdTest2, test_cmd_set.commands)
|
||||
|
||||
def test_cmdset_gets_by_key(self):
|
||||
test_cmd_set = _CmdSetTest()
|
||||
result = test_cmd_set.get("another command")
|
||||
|
||||
self.assertIsInstance(result, _CmdTest2)
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ class EventCharacter(DefaultCharacter):
|
|||
"""Return the CallbackHandler."""
|
||||
return CallbackHandler(self)
|
||||
|
||||
def announce_move_from(self, destination, msg=None, mapping=None):
|
||||
def announce_move_from(self, destination, msg=None, move_type="move", mapping=None, **kwargs):
|
||||
"""
|
||||
Called if the move is to be announced. This is
|
||||
called while we are still standing in the old
|
||||
|
|
@ -234,9 +234,9 @@ class EventCharacter(DefaultCharacter):
|
|||
if not string:
|
||||
return
|
||||
|
||||
super().announce_move_from(destination, msg=string, mapping=mapping)
|
||||
super().announce_move_from(destination, msg=string, move_type=move_type, mapping=mapping, **kwargs)
|
||||
|
||||
def announce_move_to(self, source_location, msg=None, mapping=None):
|
||||
def announce_move_to(self, source_location, msg=None, move_type="move", mapping=None, **kwargs):
|
||||
"""
|
||||
Called after the move if the move was not quiet. At this point
|
||||
we are standing in the new location.
|
||||
|
|
@ -292,9 +292,9 @@ class EventCharacter(DefaultCharacter):
|
|||
if not string:
|
||||
return
|
||||
|
||||
super().announce_move_to(source_location, msg=string, mapping=mapping)
|
||||
super().announce_move_to(source_location, msg=string, move_type=move_type, mapping=mapping, **kwargs)
|
||||
|
||||
def at_pre_move(self, destination):
|
||||
def at_pre_move(self, destination, move_type="move", **kwargs):
|
||||
"""
|
||||
Called just before starting to move this object to
|
||||
destination.
|
||||
|
|
@ -334,7 +334,7 @@ class EventCharacter(DefaultCharacter):
|
|||
|
||||
return True
|
||||
|
||||
def at_post_move(self, source_location):
|
||||
def at_post_move(self, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Called after move has completed, regardless of quiet mode or
|
||||
not. Allows changes to the object due to the location it is
|
||||
|
|
@ -644,7 +644,7 @@ class EventExit(DefaultExit):
|
|||
"""Return the CallbackHandler."""
|
||||
return CallbackHandler(self)
|
||||
|
||||
def at_traverse(self, traversing_object, target_location):
|
||||
def at_traverse(self, traversing_object, target_location, **kwargs):
|
||||
"""
|
||||
This hook is responsible for handling the actual traversal,
|
||||
normally by calling
|
||||
|
|
@ -665,7 +665,7 @@ class EventExit(DefaultExit):
|
|||
if not allow:
|
||||
return
|
||||
|
||||
super().at_traverse(traversing_object, target_location)
|
||||
super().at_traverse(traversing_object, target_location, **kwargs)
|
||||
|
||||
# After traversing
|
||||
if is_character:
|
||||
|
|
@ -732,7 +732,7 @@ class EventObject(DefaultObject):
|
|||
"""Return the CallbackHandler."""
|
||||
return CallbackHandler(self)
|
||||
|
||||
def at_get(self, getter):
|
||||
def at_get(self, getter, **kwargs):
|
||||
"""
|
||||
Called by the default `get` command when this object has been
|
||||
picked up.
|
||||
|
|
@ -745,10 +745,10 @@ class EventObject(DefaultObject):
|
|||
permissions for that.
|
||||
|
||||
"""
|
||||
super().at_get(getter)
|
||||
super().at_get(getter, **kwargs)
|
||||
self.callbacks.call("get", getter, self)
|
||||
|
||||
def at_drop(self, dropper):
|
||||
def at_drop(self, dropper, **kwargs):
|
||||
"""
|
||||
Called by the default `drop` command when this object has been
|
||||
dropped.
|
||||
|
|
@ -761,7 +761,7 @@ class EventObject(DefaultObject):
|
|||
permissions from that.
|
||||
|
||||
"""
|
||||
super().at_drop(dropper)
|
||||
super().at_drop(dropper, **kwargs)
|
||||
self.callbacks.call("drop", dropper, self)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ class CmdGiveUp(CmdEvscapeRoom):
|
|||
# manually call move hooks
|
||||
self.room.msg_room(self.caller, f"|r{self.caller.key} gave up and was whisked away!|n")
|
||||
self.room.at_object_leave(self.caller, self.caller.home)
|
||||
self.caller.move_to(self.caller.home, quiet=True, move_hooks=False)
|
||||
self.caller.move_to(self.caller.home, quiet=True, move_hooks=False, move_type="teleport")
|
||||
|
||||
# back to menu
|
||||
run_evscaperoom_menu(self.caller)
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ class EvscapeRoom(EvscaperoomObject, DefaultRoom):
|
|||
|
||||
# Evennia hooks
|
||||
|
||||
def at_object_receive(self, moved_obj, source_location):
|
||||
def at_object_receive(self, moved_obj, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Called when an object arrives in the room. This can be used to
|
||||
sum up the situation, set tags etc.
|
||||
|
|
@ -195,7 +195,7 @@ class EvscapeRoom(EvscaperoomObject, DefaultRoom):
|
|||
self.log(f"JOIN: {moved_obj} joined room")
|
||||
self.state.character_enters(moved_obj)
|
||||
|
||||
def at_object_leave(self, moved_obj, target_location, **kwargs):
|
||||
def at_object_leave(self, moved_obj, target_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Called when an object leaves the room; if this is a Character we need
|
||||
to clean them up and move them to the menu state.
|
||||
|
|
|
|||
|
|
@ -611,7 +611,7 @@ class CmdDrop(MuxCommand):
|
|||
if obj.db.worn:
|
||||
obj.remove(caller, quiet=True)
|
||||
|
||||
obj.move_to(caller.location, quiet=True)
|
||||
obj.move_to(caller.location, quiet=True, move_type="drop")
|
||||
caller.msg("You drop %s." % (obj.name,))
|
||||
caller.location.msg_contents("%s drops %s." % (caller.name, obj.name), exclude=caller)
|
||||
# Call the object script's at_drop() method.
|
||||
|
|
@ -664,10 +664,10 @@ class CmdGive(MuxCommand):
|
|||
# Remove clothes if they're given.
|
||||
if to_give.db.worn:
|
||||
to_give.remove(caller)
|
||||
to_give.move_to(caller.location, quiet=True)
|
||||
to_give.move_to(caller.location, quiet=True, move_type="remove")
|
||||
# give object
|
||||
caller.msg("You give %s to %s." % (to_give.key, target.key))
|
||||
to_give.move_to(target, quiet=True)
|
||||
to_give.move_to(target, quiet=True, move_type="give")
|
||||
target.msg("%s gives you %s." % (caller.key, to_give.key))
|
||||
# Call the object script's at_give() method.
|
||||
to_give.at_give(caller, target)
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class SlowExit(DefaultExit):
|
|||
def move_callback():
|
||||
"This callback will be called by utils.delay after move_delay seconds."
|
||||
source_location = traversing_object.location
|
||||
if traversing_object.move_to(target_location):
|
||||
if traversing_object.move_to(target_location, move_type="traverse"):
|
||||
self.at_post_traverse(traversing_object, source_location)
|
||||
else:
|
||||
if self.db.err_traverse:
|
||||
|
|
|
|||
|
|
@ -538,7 +538,7 @@ class WildernessRoom(DefaultRoom):
|
|||
# This object wasn't in the wilderness yet. Let's add it.
|
||||
itemcoords[moved_obj] = self.coordinates
|
||||
|
||||
def at_object_leave(self, moved_obj, target_location):
|
||||
def at_object_leave(self, moved_obj, target_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Called just before an object leaves from inside this object. This is a
|
||||
default Evennia hook.
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ _RE_RIGHT_BRACKETS = re.compile(r"\}+", _RE_FLAGS)
|
|||
_RE_REF = re.compile(r"\{+\#([0-9]+[\^\~tv]{0,1})\}+")
|
||||
|
||||
# This regex is used to quickly reference one self in an emote.
|
||||
_RE_SELF_REF = re.compile(r"/me|@", _RE_FLAGS)
|
||||
_RE_SELF_REF = re.compile(r"(/me|@)(?=\W+)", _RE_FLAGS)
|
||||
|
||||
# regex for non-alphanumberic end of a string
|
||||
_RE_CHAREND = re.compile(r"\W+$", _RE_FLAGS)
|
||||
|
|
@ -213,6 +213,7 @@ _RE_REF_LANG = re.compile(r"\{+\##([0-9]+)\}+")
|
|||
# this regex returns in groups (langname, say), where langname can be empty.
|
||||
_RE_LANGUAGE = re.compile(r"(?:\((\w+)\))*(\".+?\")")
|
||||
|
||||
|
||||
# the emote parser works in two steps:
|
||||
# 1) convert the incoming emote into an intermediary
|
||||
# form with all object references mapped to ids.
|
||||
|
|
@ -235,6 +236,26 @@ class RecogError(Exception):
|
|||
class LanguageError(Exception):
|
||||
pass
|
||||
|
||||
def _get_case_ref(string):
|
||||
"""
|
||||
Helper function which parses capitalization and
|
||||
returns the appropriate case-ref character for emotes.
|
||||
"""
|
||||
# default to retaining the original case
|
||||
case = "~"
|
||||
# internal flags for the case used for the original /query
|
||||
# - t for titled input (like /Name)
|
||||
# - ^ for all upercase input (like /NAME)
|
||||
# - v for lower-case input (like /name)
|
||||
# - ~ for mixed case input (like /nAmE)
|
||||
if string.istitle():
|
||||
case = "t"
|
||||
elif string.isupper():
|
||||
case = "^"
|
||||
elif string.islower():
|
||||
case = "v"
|
||||
|
||||
return case
|
||||
|
||||
# emoting mechanisms
|
||||
def parse_language(speaker, emote):
|
||||
|
|
@ -339,7 +360,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
|
|||
"""
|
||||
# build a list of candidates with all possible referrable names
|
||||
# include 'me' keyword for self-ref
|
||||
candidate_map = [(sender, "me")]
|
||||
candidate_map = []
|
||||
for obj in candidates:
|
||||
# check if sender has any recogs for obj and add
|
||||
if hasattr(sender, "recog"):
|
||||
|
|
@ -365,6 +386,15 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
|
|||
errors = []
|
||||
obj = None
|
||||
nmatches = 0
|
||||
# first, find and replace any self-refs
|
||||
for self_match in list(_RE_SELF_REF.finditer(string)):
|
||||
matched = self_match.group()
|
||||
case = _get_case_ref(matched.lstrip(_PREFIX)) if case_sensitive else ""
|
||||
key = f"#{sender.id}{case}"
|
||||
# replaced with ref
|
||||
string = _RE_SELF_REF.sub(f"{{{key}}}", string, count=1)
|
||||
mapping[key] = sender
|
||||
|
||||
for marker_match in reversed(list(_RE_OBJ_REF_START.finditer(string))):
|
||||
# we scan backwards so we can replace in-situ without messing
|
||||
# up later occurrences. Given a marker match, query from
|
||||
|
|
@ -375,7 +405,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
|
|||
match_index = marker_match.start()
|
||||
# split the emote string at the reference marker, to process everything after it
|
||||
head = string[:match_index]
|
||||
tail = string[match_index + 1 :]
|
||||
tail = string[match_index + 1:]
|
||||
|
||||
if search_mode:
|
||||
# match the candidates against the whole search string after the marker
|
||||
|
|
@ -421,7 +451,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
|
|||
# save search string
|
||||
matched_text = "".join(tail[1:iend])
|
||||
# recombine remainder of emote back into a string
|
||||
tail = "".join(tail[iend + 1 :])
|
||||
tail = "".join(tail[iend + 1:])
|
||||
|
||||
nmatches = len(bestmatches)
|
||||
|
||||
|
|
@ -456,24 +486,9 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
|
|||
errors.append(_EMOTE_NOMATCH_ERROR.format(ref=marker_match.group()))
|
||||
elif nmatches == 1:
|
||||
# a unique match - parse into intermediary representation
|
||||
case = "~" # retain original case of sdesc
|
||||
if case_sensitive:
|
||||
# case sensitive mode
|
||||
# internal flags for the case used for the original /query
|
||||
# - t for titled input (like /Name)
|
||||
# - ^ for all upercase input (like /NAME)
|
||||
# - v for lower-case input (like /name)
|
||||
# - ~ for mixed case input (like /nAmE)
|
||||
matchtext = marker_match.group().lstrip(_PREFIX)
|
||||
if matchtext.istitle():
|
||||
case = "t"
|
||||
elif matchtext.isupper():
|
||||
case = "^"
|
||||
elif matchtext.islower():
|
||||
case = "v"
|
||||
|
||||
key = f"#{obj.id}{case}"
|
||||
case = _get_case_ref(marker_match.group()) if case_sensitive else ""
|
||||
# recombine emote with matched text replaced by ref
|
||||
key = f"#{obj.id}{case}"
|
||||
string = f"{head}{{{key}}}{tail}"
|
||||
mapping[key] = obj
|
||||
|
||||
|
|
@ -513,7 +528,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_
|
|||
return string, mapping
|
||||
|
||||
|
||||
def send_emote(sender, receivers, emote, anonymous_add="first", **kwargs):
|
||||
def send_emote(sender, receivers, emote, msg_type="pose", anonymous_add="first", **kwargs):
|
||||
"""
|
||||
Main access function for distribute an emote.
|
||||
|
||||
|
|
@ -523,6 +538,9 @@ def send_emote(sender, receivers, emote, anonymous_add="first", **kwargs):
|
|||
will also form the basis for which sdescs are
|
||||
'valid' to use in the emote.
|
||||
emote (str): The raw emote string as input by emoter.
|
||||
msg_type (str): The type of emote this is. "say" or "pose"
|
||||
for example. This is arbitrary and used for generating
|
||||
extra data for .msg(text) tuple.
|
||||
anonymous_add (str or None, optional): If `sender` is not
|
||||
self-referencing in the emote, this will auto-add
|
||||
`sender`'s data to the emote. Possible values are
|
||||
|
|
@ -599,7 +617,7 @@ def send_emote(sender, receivers, emote, anonymous_add="first", **kwargs):
|
|||
)
|
||||
|
||||
# do the template replacement of the sdesc/recog {#num} markers
|
||||
receiver.msg(sendemote.format(**receiver_sdesc_mapping), from_obj=sender, **kwargs)
|
||||
receiver.msg(text=(sendemote.format(**receiver_sdesc_mapping), {"type": msg_type}), from_obj=sender, **kwargs)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
|
@ -910,7 +928,7 @@ class CmdSay(RPCommand): # replaces standard say
|
|||
# calling the speech modifying hook
|
||||
speech = caller.at_pre_say(self.args)
|
||||
targets = self.caller.location.contents
|
||||
send_emote(self.caller, targets, speech, anonymous_add=None)
|
||||
send_emote(self.caller, targets, speech, msg_type="say", anonymous_add=None)
|
||||
|
||||
|
||||
class CmdSdesc(RPCommand): # set/look at own sdesc
|
||||
|
|
@ -1253,19 +1271,19 @@ class ContribRPObject(DefaultObject):
|
|||
self.sdesc.add("Something")
|
||||
|
||||
def search(
|
||||
self,
|
||||
searchdata,
|
||||
global_search=False,
|
||||
use_nicks=True,
|
||||
typeclass=None,
|
||||
location=None,
|
||||
attribute_name=None,
|
||||
quiet=False,
|
||||
exact=False,
|
||||
candidates=None,
|
||||
nofound_string=None,
|
||||
multimatch_string=None,
|
||||
use_dbref=None,
|
||||
self,
|
||||
searchdata,
|
||||
global_search=False,
|
||||
use_nicks=True,
|
||||
typeclass=None,
|
||||
location=None,
|
||||
attribute_name=None,
|
||||
quiet=False,
|
||||
exact=False,
|
||||
candidates=None,
|
||||
nofound_string=None,
|
||||
multimatch_string=None,
|
||||
use_dbref=None,
|
||||
):
|
||||
"""
|
||||
Returns an Object matching a search string/condition, taking
|
||||
|
|
@ -1349,10 +1367,10 @@ class ContribRPObject(DefaultObject):
|
|||
)
|
||||
|
||||
if global_search or (
|
||||
is_string
|
||||
and searchdata.startswith("#")
|
||||
and len(searchdata) > 1
|
||||
and searchdata[1:].isdigit()
|
||||
is_string
|
||||
and searchdata.startswith("#")
|
||||
and len(searchdata) > 1
|
||||
and searchdata[1:].isdigit()
|
||||
):
|
||||
# only allow exact matching if searching the entire database
|
||||
# or unique #dbrefs
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ recog02 = "Mr Receiver2"
|
|||
recog10 = "Mr Sender"
|
||||
emote = 'With a flair, /me looks at /first and /colliding sdesc-guy. She says "This is a test."'
|
||||
case_emote = "/Me looks at /first. Then, /me looks at /FIRST, /First and /Colliding twice."
|
||||
poss_emote = "/Me frowns at /first for trying to steal /me's test."
|
||||
|
||||
|
||||
class TestRPSystem(BaseEvenniaTest):
|
||||
|
|
@ -140,18 +141,21 @@ class TestRPSystem(BaseEvenniaTest):
|
|||
),
|
||||
)
|
||||
|
||||
def parse_sdescs_and_recogs(self):
|
||||
def test_parse_sdescs_and_recogs(self):
|
||||
speaker = self.speaker
|
||||
speaker.sdesc.add(sdesc0)
|
||||
self.receiver1.sdesc.add(sdesc1)
|
||||
self.receiver2.sdesc.add(sdesc2)
|
||||
id0 = f"#{speaker.id}"
|
||||
id1 = f"#{self.receiver1.id}"
|
||||
id2 = f"#{self.receiver2.id}"
|
||||
candidates = (self.receiver1, self.receiver2)
|
||||
result = (
|
||||
'With a flair, {#9} looks at {#10} and {#11}. She says "This is a test."',
|
||||
'With a flair, {'+id0+'} looks at {'+id1+'} and {'+id2+'}. She says "This is a test."',
|
||||
{
|
||||
"#11": "Another nice colliding sdesc-guy for tests",
|
||||
"#10": "The first receiver of emotes.",
|
||||
"#9": "A nice sender of emotes",
|
||||
id2: self.receiver2,
|
||||
id1: self.receiver1,
|
||||
id0: speaker,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
|
|
@ -164,6 +168,27 @@ class TestRPSystem(BaseEvenniaTest):
|
|||
result,
|
||||
)
|
||||
|
||||
def test_possessive_selfref(self):
|
||||
speaker = self.speaker
|
||||
speaker.sdesc.add(sdesc0)
|
||||
self.receiver1.sdesc.add(sdesc1)
|
||||
self.receiver2.sdesc.add(sdesc2)
|
||||
id0 = f"#{speaker.id}"
|
||||
id1 = f"#{self.receiver1.id}"
|
||||
id2 = f"#{self.receiver2.id}"
|
||||
candidates = (self.receiver1, self.receiver2)
|
||||
result = (
|
||||
"{"+id0+"} frowns at {"+id1+"} for trying to steal {"+id0+"}'s test.",
|
||||
{
|
||||
id1: self.receiver1,
|
||||
id0: speaker,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
rpsystem.parse_sdescs_and_recogs(speaker, candidates, poss_emote, case_sensitive=False),
|
||||
result,
|
||||
)
|
||||
|
||||
def test_get_sdesc(self):
|
||||
looker = self.speaker # Sender
|
||||
target = self.receiver1 # Receiver1
|
||||
|
|
@ -197,17 +222,17 @@ class TestRPSystem(BaseEvenniaTest):
|
|||
receiver2.msg = lambda text, **kwargs: setattr(self, "out2", text)
|
||||
rpsystem.send_emote(speaker, receivers, emote, case_sensitive=False)
|
||||
self.assertEqual(
|
||||
self.out0,
|
||||
self.out0[0],
|
||||
"With a flair, |mSender|n looks at |bThe first receiver of emotes.|n "
|
||||
'and |bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n',
|
||||
)
|
||||
self.assertEqual(
|
||||
self.out1,
|
||||
self.out1[0],
|
||||
"With a flair, |bA nice sender of emotes|n looks at |mReceiver1|n and "
|
||||
'|bAnother nice colliding sdesc-guy for tests|n. She says |w"This is a test."|n',
|
||||
)
|
||||
self.assertEqual(
|
||||
self.out2,
|
||||
self.out2[0],
|
||||
"With a flair, |bA nice sender of emotes|n looks at |bThe first "
|
||||
'receiver of emotes.|n and |mReceiver2|n. She says |w"This is a test."|n',
|
||||
)
|
||||
|
|
@ -226,19 +251,19 @@ class TestRPSystem(BaseEvenniaTest):
|
|||
receiver2.msg = lambda text, **kwargs: setattr(self, "out2", text)
|
||||
rpsystem.send_emote(speaker, receivers, case_emote)
|
||||
self.assertEqual(
|
||||
self.out0,
|
||||
self.out0[0],
|
||||
"|mSender|n looks at |bthe first receiver of emotes.|n. Then, |mSender|n "
|
||||
"looks at |bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of emotes.|n "
|
||||
"and |bAnother nice colliding sdesc-guy for tests|n twice.",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.out1,
|
||||
self.out1[0],
|
||||
"|bA nice sender of emotes|n looks at |mReceiver1|n. Then, "
|
||||
"|ba nice sender of emotes|n looks at |mReceiver1|n, |mReceiver1|n "
|
||||
"and |bAnother nice colliding sdesc-guy for tests|n twice.",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.out2,
|
||||
self.out2[0],
|
||||
"|bA nice sender of emotes|n looks at |bthe first receiver of emotes.|n. "
|
||||
"Then, |ba nice sender of emotes|n looks at |bTHE FIRST RECEIVER OF EMOTES.|n, "
|
||||
"|bThe first receiver of emotes.|n and |mReceiver2|n twice.",
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ class CmdTutorialGiveUp(default_cmds.MuxCommand):
|
|||
)
|
||||
return
|
||||
|
||||
self.caller.move_to(outro_room)
|
||||
self.caller.move_to(outro_room, move_type="teleport")
|
||||
|
||||
|
||||
class TutorialRoomCmdSet(CmdSet):
|
||||
|
|
@ -259,7 +259,7 @@ class TutorialRoom(DefaultRoom):
|
|||
)
|
||||
self.cmdset.add_default(TutorialRoomCmdSet)
|
||||
|
||||
def at_object_receive(self, new_arrival, source_location):
|
||||
def at_object_receive(self, new_arrival, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
When an object enter a tutorial room we tell other objects in
|
||||
the room about it by trying to call a hook on them. The Mob object
|
||||
|
|
@ -451,7 +451,7 @@ class IntroRoom(TutorialRoom):
|
|||
"the account."
|
||||
)
|
||||
|
||||
def at_object_receive(self, character, source_location):
|
||||
def at_object_receive(self, character, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Assign properties on characters
|
||||
"""
|
||||
|
|
@ -523,7 +523,7 @@ class CmdEast(Command):
|
|||
# Move to the east room.
|
||||
eexit = search_object(self.obj.db.east_exit)
|
||||
if eexit:
|
||||
caller.move_to(eexit[0])
|
||||
caller.move_to(eexit[0], move_type="traverse")
|
||||
else:
|
||||
caller.msg("No east exit was found for this room. Contact an admin.")
|
||||
return
|
||||
|
|
@ -570,7 +570,7 @@ class CmdWest(Command):
|
|||
# Move to the west room.
|
||||
wexit = search_object(self.obj.db.west_exit)
|
||||
if wexit:
|
||||
caller.move_to(wexit[0])
|
||||
caller.move_to(wexit[0], move_type="traverse")
|
||||
else:
|
||||
caller.msg("No west exit was found for this room. Contact an admin.")
|
||||
return
|
||||
|
|
@ -658,7 +658,7 @@ class CmdLookBridge(Command):
|
|||
fall_exit = search_object(self.obj.db.fall_exit)
|
||||
if fall_exit:
|
||||
self.caller.msg("|r%s|n" % FALL_MESSAGE)
|
||||
self.caller.move_to(fall_exit[0], quiet=True)
|
||||
self.caller.move_to(fall_exit[0], quiet=True, move_type="fall")
|
||||
# inform others on the bridge
|
||||
self.obj.msg_contents(
|
||||
"A plank gives way under %s's feet and "
|
||||
|
|
@ -770,7 +770,7 @@ class BridgeRoom(WeatherRoom):
|
|||
# send a message most of the time
|
||||
self.msg_contents("|w%s|n" % random.choice(BRIDGE_WEATHER))
|
||||
|
||||
def at_object_receive(self, character, source_location):
|
||||
def at_object_receive(self, character, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
This hook is called by the engine whenever the player is moved
|
||||
into this room.
|
||||
|
|
@ -796,7 +796,7 @@ class BridgeRoom(WeatherRoom):
|
|||
character.db.tutorial_bridge_position = 0
|
||||
character.execute_cmd("look")
|
||||
|
||||
def at_object_leave(self, character, target_location):
|
||||
def at_object_leave(self, character, target_location, move_type="move", **kwargs):
|
||||
"""
|
||||
This is triggered when the player leaves the bridge room.
|
||||
"""
|
||||
|
|
@ -1038,7 +1038,7 @@ class DarkRoom(TutorialRoom):
|
|||
# put players in darkness
|
||||
char.msg("The room is completely dark.")
|
||||
|
||||
def at_object_receive(self, obj, source_location):
|
||||
def at_object_receive(self, obj, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Called when an object enters the room.
|
||||
"""
|
||||
|
|
@ -1048,7 +1048,7 @@ class DarkRoom(TutorialRoom):
|
|||
# in case the new guy carries light with them
|
||||
self.check_light_state()
|
||||
|
||||
def at_object_leave(self, obj, target_location):
|
||||
def at_object_leave(self, obj, target_location, move_type="move", **kwargs):
|
||||
"""
|
||||
In case people leave with the light, we make sure to clear the
|
||||
DarkCmdSet if necessary. This also works if they are
|
||||
|
|
@ -1103,7 +1103,7 @@ class TeleportRoom(TutorialRoom):
|
|||
self.db.failure_teleport_msg = "You fail!"
|
||||
self.db.failure_teleport_to = "dark cell"
|
||||
|
||||
def at_object_receive(self, character, source_location):
|
||||
def at_object_receive(self, character, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
This hook is called by the engine whenever the player is moved into
|
||||
this room.
|
||||
|
|
@ -1130,7 +1130,7 @@ class TeleportRoom(TutorialRoom):
|
|||
else:
|
||||
character.msg(self.db.failure_teleport_msg)
|
||||
# teleport quietly to the new place
|
||||
character.move_to(results[0], quiet=True, move_hooks=False)
|
||||
character.move_to(results[0], quiet=True, move_hooks=False, move_type="teleport")
|
||||
# we have to call this manually since we turn off move_hooks
|
||||
# - this is necessary to make the target dark room aware of an
|
||||
# already carried light.
|
||||
|
|
@ -1167,7 +1167,7 @@ class OutroRoom(TutorialRoom):
|
|||
"character."
|
||||
)
|
||||
|
||||
def at_object_receive(self, character, source_location):
|
||||
def at_object_receive(self, character, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Do cleanup.
|
||||
"""
|
||||
|
|
@ -1183,6 +1183,6 @@ class OutroRoom(TutorialRoom):
|
|||
obj.delete()
|
||||
character.tags.clear(category="tutorial_world")
|
||||
|
||||
def at_object_leave(self, character, destination):
|
||||
def at_object_leave(self, character, destination, move_type="move", **kwargs):
|
||||
if character.account:
|
||||
character.account.execute_cmd("unquell")
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ class TestTutorialWorldRooms(BaseEvenniaCommandTest):
|
|||
def test_bridgeroom(self):
|
||||
room = create_object(tutrooms.BridgeRoom, key="bridgeroom")
|
||||
room.update_weather()
|
||||
self.char1.move_to(room)
|
||||
self.char1.move_to(room, move_type="teleport")
|
||||
self.call(
|
||||
tutrooms.CmdBridgeHelp(),
|
||||
"",
|
||||
|
|
@ -181,7 +181,7 @@ class TestTutorialWorldRooms(BaseEvenniaCommandTest):
|
|||
|
||||
def test_darkroom(self):
|
||||
room = create_object(tutrooms.DarkRoom, key="darkroom")
|
||||
self.char1.move_to(room)
|
||||
self.char1.move_to(room, move_type="teleport")
|
||||
self.call(tutrooms.CmdDarkHelp(), "", "Can't help you until")
|
||||
|
||||
def test_teleportroom(self):
|
||||
|
|
|
|||
277
evennia/contrib/utils/name_generator/README.md
Normal file
277
evennia/contrib/utils/name_generator/README.md
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# Random Name Generator
|
||||
|
||||
Contribution by InspectorCaracal (2022)
|
||||
|
||||
A module for generating random names, both real-world and fantasy. Real-world
|
||||
names can be generated either as first (personal) names, family (last) names, or
|
||||
full names (first, optional middles, and last). The name data is from [Behind the Name](https://www.behindthename.com/)
|
||||
and used under the [CC BY-SA 4.0 license](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||
Fantasy names are generated from basic phonetic rules, using CVC syllable syntax.
|
||||
|
||||
Both real-world and fantasy name generation can be extended to include additional
|
||||
information via your game's `settings.py`
|
||||
|
||||
## Installation
|
||||
|
||||
This is a stand-alone utility. Just import this module (`from evennia.contrib.utils import name_generator`) and use its functions wherever you like.
|
||||
|
||||
## Usage
|
||||
|
||||
Import the module where you need it with the following:
|
||||
```py
|
||||
from evennia.contrib.utils.name_generator import namegen
|
||||
```
|
||||
|
||||
By default, all of the functions will return a string with one generated name.
|
||||
If you specify more than one, or pass `return_list=True` as a keyword argument, the returned value will be a list of strings.
|
||||
|
||||
The module is especially useful for naming newly-created NPCs, like so:
|
||||
```py
|
||||
npc_name = namegen.full_name()
|
||||
npc_obj = create_object(key=npc_name, typeclass="typeclasses.characters.NPC")
|
||||
```
|
||||
|
||||
## Available Settings
|
||||
|
||||
These settings can all be defined in your game's `server/conf/settings.py` file.
|
||||
|
||||
- `NAMEGEN_FIRST_NAMES` adds a new list of first (personal) names.
|
||||
- `NAMEGEN_LAST_NAMES` adds a new list of last (family) names.
|
||||
- `NAMEGEN_REPLACE_LISTS` - set to `True` if you want to use only the names defined in your settings.
|
||||
- `NAMEGEN_FANTASY_RULES` lets you add new phonetic rules for generating entirely made-up names. See the section "Custom Fantasy Name style rules" for details on how this should look.
|
||||
|
||||
Examples:
|
||||
```py
|
||||
NAMEGEN_FIRST_NAMES = [
|
||||
("Evennia", 'mf'),
|
||||
("Green Tea", 'f'),
|
||||
]
|
||||
|
||||
NAMEGEN_LAST_NAMES = [ "Beeblebrox", "Son of Odin" ]
|
||||
|
||||
NAMEGEN_FANTASY_RULES = {
|
||||
"example_style": {
|
||||
"syllable": "(C)VC",
|
||||
"consonants": [ 'z','z','ph','sh','r','n' ],
|
||||
"start": ['m'],
|
||||
"end": ['x','n'],
|
||||
"vowels": [ "e","e","e","a","i","i","u","o", ],
|
||||
"length": (2,4),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Generating Real Names
|
||||
|
||||
The contrib offers three functions for generating random real-world names:
|
||||
`first_name()`, `last_name()`, and `full_name()`. If you want more than one name
|
||||
generated at once, you can use the `num` keyword argument to specify how many.
|
||||
|
||||
Example:
|
||||
```
|
||||
>>> namegen.first_name(num=5)
|
||||
['Genesis', 'Tali', 'Budur', 'Dominykas', 'Kamau']
|
||||
>>> namegen.first_name(gender='m')
|
||||
'Blanchard'
|
||||
```
|
||||
|
||||
The `first_name` function also takes a `gender` keyword argument to filter names
|
||||
by gender association. 'f' for feminine, 'm' for masculine, 'mf' for feminine
|
||||
_and_ masculine, or the default `None` to match any gendering.
|
||||
|
||||
The `full_name` function also takes the `gender` keyword, as well as `parts` which
|
||||
defines how many names make up the full name. The minimum is two: a first name and
|
||||
a last name. You can also generate names with the family name first by setting
|
||||
the keyword arg `surname_first` to `True`
|
||||
|
||||
Example:
|
||||
```
|
||||
>>> namegen.full_name()
|
||||
'Keeva Bernat'
|
||||
>>> namegen.full_name(parts=4)
|
||||
'Suzu Shabnam Kafka Baier'
|
||||
>>> namegen.full_name(parts=3, surname_first=True)
|
||||
'Ó Muircheartach Torunn Dyson'
|
||||
>>> namegen.full_name(gender='f')
|
||||
'Wikolia Ó Deasmhumhnaigh'
|
||||
```
|
||||
|
||||
### Adding your own names
|
||||
|
||||
You can add additional names with the settings `NAMEGEN_FIRST_NAMES` and
|
||||
`NAMEGEN_LAST_NAMES`
|
||||
|
||||
`NAMEGEN_FIRST_NAMES` should be a list of tuples, where the first value is the name
|
||||
and then second value is the gender flag - 'm' for masculine-only, 'f' for feminine-
|
||||
only, and 'mf' for either one.
|
||||
|
||||
`NAMEGEN_LAST_NAMES` should be a list of strings, where each item is an available
|
||||
surname.
|
||||
|
||||
Examples:
|
||||
```py
|
||||
NAMEGEN_FIRST_NAMES = [
|
||||
("Evennia", 'mf'),
|
||||
("Green Tea", 'f'),
|
||||
]
|
||||
|
||||
NAMEGEN_LAST_NAMES = [ "Beeblebrox", "Son of Odin" ]
|
||||
```
|
||||
|
||||
Set `NAMEGEN_REPLACE_LISTS = True` if you want your custom lists above to entirely replace the built-in lists rather than extend them.
|
||||
|
||||
## Generating Fantasy Names
|
||||
|
||||
Generating completely made-up names is done with the `fantasy_name` function. The
|
||||
contrib comes with three built-in styles of names which you can use, or you can
|
||||
put a dictionary of custom name rules into `settings.py`
|
||||
|
||||
Generating a fantasy name takes the ruleset key as the "style" keyword, and can
|
||||
return either a single name or multiple names. By default, it will return a
|
||||
single name in the built-in "harsh" style. The contrib also comes with "fluid" and "alien" styles.
|
||||
|
||||
```py
|
||||
>>> namegen.fantasy_name()
|
||||
'Vhon'
|
||||
>>> namegen.fantasy_name(num=3, style="harsh")
|
||||
['Kha', 'Kizdhu', 'Godögäk']
|
||||
>>> namegen.fantasy_name(num=3, style="fluid")
|
||||
['Aewalisash', 'Ayi', 'Iaa']
|
||||
>>> namegen.fantasy_name(num=5, style="alien")
|
||||
["Qz'vko'", "Xv'w'hk'hxyxyz", "Wxqv'hv'k", "Wh'k", "Xbx'qk'vz"]
|
||||
```
|
||||
|
||||
### Multi-Word Fantasy Names
|
||||
|
||||
The `fantasy_name` function will only generate one name-word at a time, so for multi-word names
|
||||
you'll need to combine pieces together. Depending on what kind of end result you want, there are
|
||||
several approaches.
|
||||
|
||||
|
||||
#### The simple approach
|
||||
|
||||
If all you need is for it to have multiple parts, you can generate multiple names at once and `join` them.
|
||||
|
||||
```py
|
||||
>>> name = " ".join(namegen.fantasy_name(num=2))
|
||||
>>> name
|
||||
'Dezhvözh Khäk'
|
||||
```
|
||||
|
||||
If you want a little more variation between first/last names, you can also generate names for
|
||||
different styles and then combine them.
|
||||
|
||||
```py
|
||||
>>> first = namegen.fantasy_name(style="fluid")
|
||||
>>> last = namegen.fantasy_name(style="harsh")
|
||||
>>> name = f"{first} {last}"
|
||||
>>> name
|
||||
'Ofasa Käkudhu'
|
||||
```
|
||||
|
||||
#### "Nakku Silversmith"
|
||||
|
||||
One common fantasy name practice is profession- or title-based surnames. To achieve this effect,
|
||||
you can use the `last_name` function with a custom list of last names and combine it with your generated
|
||||
fantasy name.
|
||||
|
||||
Example:
|
||||
```py
|
||||
NAMEGEN_LAST_NAMES = [ "Silversmith", "the Traveller", "Destroyer of Worlds" ]
|
||||
NAMEGEN_REPLACE_LISTS = True
|
||||
|
||||
>>> first = namegen.fantasy_name()
|
||||
>>> last = namegen.last_name()
|
||||
>>> name = f"{first} {last}"
|
||||
>>> name
|
||||
'Tözhkheko the Traveller'
|
||||
```
|
||||
|
||||
#### Elarion d'Yrinea, Thror Obinson
|
||||
|
||||
Another common flavor of fantasy names is to use a surname suffix or prefix. For that, you'll
|
||||
need to add in the extra bit yourself.
|
||||
|
||||
Examples:
|
||||
```py
|
||||
>>> names = namegen.fantasy_name(num=2)
|
||||
>>> name = f"{names[0]} za'{names[1]}"
|
||||
>>> name
|
||||
"Tithe za'Dhudozkok"
|
||||
|
||||
>>> names = namegen.fantasy_name(num=2)
|
||||
>>> name = f"{names[0]} {names[1]}son"
|
||||
>>> name
|
||||
'Kön Ködhöddoson'
|
||||
```
|
||||
|
||||
|
||||
### Custom Fantasy Name style rules
|
||||
|
||||
The style rules are contained in a dictionary of dictionaries, where the style name
|
||||
is the key and the style rules are the dictionary value.
|
||||
|
||||
The following is how you would add a custom style to `settings.py`:
|
||||
```py
|
||||
NAMEGEN_FANTASY_RULES = {
|
||||
"example_style": {
|
||||
"syllable": "(C)VC",
|
||||
"consonants": [ 'z','z','ph','sh','r','n' ],
|
||||
"start": ['m'],
|
||||
"end": ['x','n'],
|
||||
"vowels": [ "e","e","e","a","i","i","u","o", ],
|
||||
"length": (2,4),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then you could generate names following that ruleset with `namegen.fantasy_name(style="example_style")`.
|
||||
|
||||
The keys `syllable`, `consonants`, `vowels`, and `length` must be present, and `length` must be the minimum and maximum syllable counts. `start` and `end` are optional.
|
||||
|
||||
|
||||
#### syllable
|
||||
The "syllable" field defines the structure of each syllable. C is consonant, V is vowel,
|
||||
and parentheses mean it's optional. So, the example `(C)VC` means that every syllable
|
||||
will always have a vowel followed by a consonant, and will *sometimes* have another
|
||||
consonant at the beginning. e.g. `en`, `bak`
|
||||
|
||||
*Note:* While it's not standard, the contrib lets you nest parentheses, with each layer
|
||||
being less likely to show up. Additionally, any other characters put into the syllable
|
||||
structure - e.g. an apostrophe - will be read and inserted as written. The
|
||||
"alien" style rules in the module gives an example of both: the syllable structure is `C(C(V))(')(C)`
|
||||
which results in syllables such as `khq`, `xho'q`, and `q'` with a much lower frequency of vowels than
|
||||
`C(C)(V)(')(C)` would have given.
|
||||
|
||||
#### consonants
|
||||
A simple list of consonant phonemes that can be chosen from. Multi-character strings are
|
||||
perfectly acceptable, such as "th", but each one will be treated as a single consonant.
|
||||
|
||||
The function uses a naive form of weighting, where you make a phoneme more likely to
|
||||
occur by putting more copies of it into the list.
|
||||
|
||||
#### start and end
|
||||
These are **optional** lists for the first and last letters of a syllable, if they're
|
||||
a consonant. You can add on additional consonants which can only occur at the beginning
|
||||
or end of a syllable, or you can add extra copies of already-defined consonants to
|
||||
increase the frequency of them at the start/end of syllables.
|
||||
|
||||
For example, in the `example_style` above, we have a `start` of m, and `end` of x and n.
|
||||
Taken with the rest of the consonants/vowels, this means you can have the syllables of `mez`
|
||||
but not `zem`, and you can have `phex` or `phen` but not `xeph` or `neph`.
|
||||
|
||||
They can be left out of custom rulesets entirely.
|
||||
|
||||
#### vowels
|
||||
Vowels is a simple list of vowel phonemes - exactly like consonants, but instead used for the
|
||||
vowel selection. Single-or multi-character strings are equally fine. It uses the same naive weighting system
|
||||
as consonants - you can increase the frequency of any given vowel by putting it into the list multiple times.
|
||||
|
||||
#### length
|
||||
A tuple with the minimum and maximum number of syllables a name can have.
|
||||
|
||||
When setting this, keep in mind how long your syllables can get! 4 syllables might
|
||||
not seem like very many, but if you have a (C)(V)VC structure with one- and
|
||||
two-letter phonemes, you can get up to eight characters per syllable.
|
||||
24215
evennia/contrib/utils/name_generator/btn_givennames.txt
Normal file
24215
evennia/contrib/utils/name_generator/btn_givennames.txt
Normal file
File diff suppressed because it is too large
Load diff
6939
evennia/contrib/utils/name_generator/btn_surnames.txt
Normal file
6939
evennia/contrib/utils/name_generator/btn_surnames.txt
Normal file
File diff suppressed because it is too large
Load diff
355
evennia/contrib/utils/name_generator/namegen.py
Normal file
355
evennia/contrib/utils/name_generator/namegen.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
"""
|
||||
Random Name Generator
|
||||
|
||||
Contribution by InspectorCaracal (2022)
|
||||
|
||||
A module for generating random names, both real-world and fantasy. Real-world
|
||||
names can be generated either as first (personal) names, family (last) names, or
|
||||
full names (first, optional middles, and last). The name data is from [Behind the Name](https://www.behindthename.com/)
|
||||
and used under the [CC BY-SA 4.0 license](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||
Fantasy names are generated from basic phonetic rules, using CVC syllable syntax.
|
||||
|
||||
Both real-world and fantasy name generation can be extended to include additional
|
||||
information via your game's `settings.py`
|
||||
|
||||
|
||||
Available Methods:
|
||||
|
||||
first_name - Selects a random a first (personal) name from the name lists.
|
||||
last_name - Selects a random last (family) name from the name lists.
|
||||
full_name - Generates a randomized full name, optionally including middle names, by selecting first/last names from the name lists.
|
||||
fantasy_name - Generates a completely new made-up name based on phonetic rules.
|
||||
|
||||
Method examples:
|
||||
|
||||
>>> namegen.first_name(num=5)
|
||||
['Genesis', 'Tali', 'Budur', 'Dominykas', 'Kamau']
|
||||
|
||||
>>> namegen.full_name(parts=3, surname_first=True)
|
||||
'Ó Muircheartach Torunn Dyson'
|
||||
>>> namegen.full_name(gender='f')
|
||||
'Wikolia Ó Deasmhumhnaigh'
|
||||
|
||||
>>> namegen.fantasy_name(num=3, style="fluid")
|
||||
['Aewalisash', 'Ayi', 'Iaa']
|
||||
|
||||
|
||||
Available Settings (define these in your `settings.py`)
|
||||
|
||||
NAMEGEN_FIRST_NAMES - Option to add a new list of first (personal) names.
|
||||
NAMEGEN_LAST_NAMES - Option to add a new list of last (family) names.
|
||||
NAMEGEN_REPLACE_LISTS - Set to True if you want to use ONLY your name lists and not the ones that come with the contrib.
|
||||
NAMEGEN_FANTASY_RULES - Option to add new fantasy-name style rules.
|
||||
Must be a dictionary that includes "syllable", "consonants", "vowels", and "length" - see the example.
|
||||
"start" and "end" keys are optional.
|
||||
|
||||
Settings examples:
|
||||
|
||||
NAMEGEN_FIRST_NAMES = [
|
||||
("Evennia", 'mf'),
|
||||
("Green Tea", 'f'),
|
||||
]
|
||||
|
||||
NAMEGEN_LAST_NAMES = [ "Beeblebrox", "Son of Odin" ]
|
||||
|
||||
NAMEGEN_FANTASY_RULES = {
|
||||
"example_style": {
|
||||
"syllable": "(C)VC",
|
||||
"consonants": [ 'z','z','ph','sh','r','n' ],
|
||||
"start": ['m'],
|
||||
"end": ['x','n'],
|
||||
"vowels": [ "e","e","e","a","i","i","u","o", ],
|
||||
"length": (2,4),
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
from os import path
|
||||
from django.conf import settings
|
||||
|
||||
from evennia.utils.utils import is_iter
|
||||
|
||||
# Load name data from Behind the Name lists
|
||||
dirpath = path.dirname(path.abspath(__file__))
|
||||
_FIRSTNAME_LIST = []
|
||||
with open(path.join(dirpath, "btn_givennames.txt"),'r', encoding='utf-8') as file:
|
||||
_FIRSTNAME_LIST = [ line.strip().rsplit(" ") for line in file if line and not line.startswith("#") ]
|
||||
|
||||
_SURNAME_LIST = []
|
||||
with open(path.join(dirpath, "btn_surnames.txt"),'r', encoding='utf-8') as file:
|
||||
_SURNAME_LIST = [ line.strip() for line in file if line and not line.startswith("#") ]
|
||||
|
||||
_REQUIRED_KEYS = { "syllable", "consonants", "vowels", "length" }
|
||||
# Define phoneme structure for built-in fantasy name generators.
|
||||
_FANTASY_NAME_STRUCTURES = {
|
||||
"harsh": {
|
||||
"syllable": "CV(C)",
|
||||
"consonants": [ "k", "k", "k", "z", "zh", "g", "v", "t", "th", "w", "n", "d", "d", ],
|
||||
"start": ["dh", "kh", "kh", "kh", "vh", ],
|
||||
"end": ["n", "x", ],
|
||||
"vowels": [ "o", "o", "o", "a", "y", "u", "u", "u", "ä", "ö", "e", "i", "i", ],
|
||||
"length": (1,3),
|
||||
},
|
||||
"fluid": {
|
||||
"syllable": "V(C)",
|
||||
"consonants": [ 'r','r','l','l','l','l','s','s','s','sh','m','n','n','f','v','w','th' ],
|
||||
"start": [],
|
||||
"end": [],
|
||||
"vowels": [ "a","a","a","a","a","e","i","i","i","y","u","o", ],
|
||||
"length": (3,5),
|
||||
},
|
||||
"alien": {
|
||||
"syllable": "C(C(V))(')(C)",
|
||||
"consonants": [ 'q','q','x','z','v','w','k','h','b' ],
|
||||
"start": ['x',],
|
||||
"end": [],
|
||||
"vowels": [ 'y','w','o','y' ],
|
||||
"length": (1,5),
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
_RE_DOUBLES = re.compile(r'(\w)\1{2,}')
|
||||
|
||||
# Load in optional settings
|
||||
|
||||
custom_first_names = settings.NAMEGEN_FIRST_NAMES if hasattr(settings, "NAMEGEN_FIRST_NAMES") else []
|
||||
custom_last_names = settings.NAMEGEN_LAST_NAMES if hasattr(settings, "NAMEGEN_LAST_NAMES") else []
|
||||
|
||||
if hasattr(settings, "NAMEGEN_FANTASY_RULES"):
|
||||
_FANTASY_NAME_STRUCTURES |= settings.NAMEGEN_FANTASY_RULES
|
||||
|
||||
if hasattr(settings, "NAMEGEN_REPLACE_LISTS") and settings.NAMEGEN_REPLACE_LISTS:
|
||||
_FIRSTNAME_LIST = custom_first_names or _FIRSTNAME_LIST
|
||||
_SURNAME_LIST = custom_last_names or _SURNAME_LIST
|
||||
|
||||
else:
|
||||
_FIRSTNAME_LIST += custom_first_names
|
||||
_SURNAME_LIST += custom_last_names
|
||||
|
||||
|
||||
|
||||
def fantasy_name(num=1, style="harsh", return_list=False):
|
||||
"""
|
||||
Generate made-up names in one of a number of "styles".
|
||||
|
||||
Keyword args:
|
||||
num (int) - How many names to return.
|
||||
style (string) - The "style" of name. This references an existing algorithm.
|
||||
return_list (bool) - Whether to always return a list. `False` by default,
|
||||
which returns a string if there is only one value and a list if more.
|
||||
"""
|
||||
|
||||
def _validate(style_name):
|
||||
if style_name not in _FANTASY_NAME_STRUCTURES:
|
||||
raise ValueError(f"Invalid style name: '{style_name}'. Available style names: {' '.join(_FANTASY_NAME_STRUCTURES.keys())}")
|
||||
style_dict = _FANTASY_NAME_STRUCTURES[style_name]
|
||||
|
||||
if type(style_dict) is not dict:
|
||||
raise ValueError(f"Style {style_name} must be a dictionary.")
|
||||
|
||||
keys = set(style_dict.keys())
|
||||
missing_keys = _REQUIRED_KEYS - keys
|
||||
if len(missing_keys):
|
||||
raise KeyError(f"Style dictionary {style_name} is missing required keys: {' '.join(missing_keys)}")
|
||||
|
||||
if not (type(style_dict['consonants']) is list and type(style_dict['vowels']) is list):
|
||||
raise TypeError(f"'consonants' and 'vowels' for style {style_name} must be lists.")
|
||||
|
||||
if not (is_iter(style_dict['length']) and len(style_dict['length']) == 2):
|
||||
raise ValueError(f"'length' key for {style_name} must have a minimum and maximum number of syllables.")
|
||||
|
||||
return style_dict
|
||||
|
||||
# validate num first
|
||||
num = int(num)
|
||||
if num < 1:
|
||||
raise ValueError("Number of names to generate must be positive.")
|
||||
|
||||
style_dict = _validate(style)
|
||||
|
||||
syllable = []
|
||||
weight = 8
|
||||
# parse out the syllable structure with weights
|
||||
for key in style_dict["syllable"]:
|
||||
# parentheses mean optional - allow nested parens
|
||||
if key == "(":
|
||||
weight = weight/2
|
||||
elif key == ")":
|
||||
weight = weight*2
|
||||
else:
|
||||
if key == "C":
|
||||
sound_type = "consonants"
|
||||
elif key == "V":
|
||||
sound_type = "vowels"
|
||||
else:
|
||||
sound_type = key
|
||||
# append the sound type and weight
|
||||
syllable.append( (sound_type, int(weight)) )
|
||||
|
||||
name_list = []
|
||||
|
||||
# time to generate a name!
|
||||
for n in range(num):
|
||||
# build a list of syllables
|
||||
length = random.randint(*style_dict['length'])
|
||||
name = ""
|
||||
for i in range(length):
|
||||
# build the syllable itself
|
||||
syll = ""
|
||||
for sound, weight in syllable:
|
||||
# random chance to skip this key; lower weights mean less likely
|
||||
if random.randint(0,8) > weight:
|
||||
continue
|
||||
|
||||
if sound not in style_dict:
|
||||
# extra character, like apostrophes
|
||||
syll += sound
|
||||
continue
|
||||
|
||||
# get a random sound from the sound list
|
||||
choices = list(style_dict[sound])
|
||||
|
||||
if sound == "consonants":
|
||||
# if it's a starting consonant, add starting-sounds to the options
|
||||
if not len(syll):
|
||||
choices += style_dict.get('start',[])
|
||||
# if it's an ending consonant, add ending-sounds to the options
|
||||
elif i+1 == length:
|
||||
choices += style_dict.get('end',[])
|
||||
|
||||
syll += random.choice(choices)
|
||||
|
||||
name += syll
|
||||
|
||||
# condense repeating letters down to a maximum of 2
|
||||
name = _RE_DOUBLES.sub(lambda m: m.group(1)*2, name)
|
||||
# capitalize the first letter
|
||||
name = name[0].upper() + name[1:] if len(name) > 1 else name.upper()
|
||||
name_list.append(name)
|
||||
|
||||
if len(name_list) == 1 and not return_list:
|
||||
return name_list[0]
|
||||
return name_list
|
||||
|
||||
def first_name(num=1, gender=None, return_list=False, ):
|
||||
"""
|
||||
Generate first names, also known as personal names.
|
||||
|
||||
Keyword args:
|
||||
num (int) - How many names to return.
|
||||
gender (str) - Restrict names by gender association. `None` by default, which selects from
|
||||
all possible names. Set to "m" for masculine, "f" for feminine, "mf" for androgynous
|
||||
return_list (bool) - Whether to always return a list. `False` by default,
|
||||
which returns a string if there is only one value and a list if more.
|
||||
"""
|
||||
# validate num first
|
||||
num = int(num)
|
||||
if num < 1:
|
||||
raise ValueError("Number of names to generate must be positive.")
|
||||
|
||||
if gender:
|
||||
# filter the options by gender
|
||||
name_options = [ name_data[0] for name_data in _FIRSTNAME_LIST if all([gender_key in gender for gender_key in name_data[1]])]
|
||||
if not len(name_options):
|
||||
raise ValueError(f"Invalid gender '{gender}'.")
|
||||
else:
|
||||
name_options = [ name_data[0] for name_data in _FIRSTNAME_LIST ]
|
||||
|
||||
# take a random selection of `num` names, without repeats
|
||||
results = random.sample(name_options,num)
|
||||
|
||||
if len(results) == 1 and not return_list:
|
||||
# return single value as a string
|
||||
return results[0]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def last_name(num=1, return_list=False):
|
||||
"""
|
||||
Generate family names, also known as surnames or last names.
|
||||
|
||||
Keyword args:
|
||||
num (int) - How many names to return.
|
||||
return_list (bool) - Whether to always return a list. `False` by default,
|
||||
which returns a string if there is only one value and a list if more.
|
||||
"""
|
||||
# validate num first
|
||||
num = int(num)
|
||||
if num < 1:
|
||||
raise ValueError("Number of names to generate must be positive.")
|
||||
|
||||
# take a random selection of `num` names, without repeats
|
||||
results = random.sample(_SURNAME_LIST,num)
|
||||
|
||||
if len(results) == 1 and not return_list:
|
||||
# return single value as a string
|
||||
return results[0]
|
||||
|
||||
return results
|
||||
|
||||
def full_name(num=1, parts=2, gender=None, return_list=False, surname_first=False):
|
||||
"""
|
||||
Generate complete names with a personal name, family name, and optionally middle names.
|
||||
|
||||
Keyword args:
|
||||
num (int) - How many names to return.
|
||||
parts (int) - How many parts the name should have. By default two: first and last.
|
||||
gender (str) - Restrict names by gender association. `None` by default, which selects from
|
||||
all possible names. Set to "m" for masculine, "f" for feminine, "mf" for androgynous
|
||||
return_list (bool) - Whether to always return a list. `False` by default,
|
||||
which returns a string if there is only one value and a list if more.
|
||||
surname_first (bool) - Default `False`. Set to `True` if you want the family name to be
|
||||
placed at the beginning of the name instead of the end.
|
||||
"""
|
||||
# validate num first
|
||||
num = int(num)
|
||||
if num < 1:
|
||||
raise ValueError("Number of names to generate must be positive.")
|
||||
# validate parts next
|
||||
parts = int(parts)
|
||||
if parts < 2:
|
||||
raise ValueError("Number of name parts to generate must be at least 2.")
|
||||
|
||||
name_lists = []
|
||||
|
||||
middle = parts-2
|
||||
if middle:
|
||||
# calculate "middle" names.
|
||||
# we want them to be an intelligent mix of personal names and family names
|
||||
# first, split the total number of middle-name parts into "personal" and "family" at a random point
|
||||
total_mids = middle*num
|
||||
personals = random.randint(1,total_mids)
|
||||
familys = total_mids - personals
|
||||
# then get the names for each
|
||||
personal_mids = first_name(num=personals, gender=gender, return_list=True)
|
||||
family_mids = last_name(num=familys, return_list=True) if familys else []
|
||||
# splice them together according to surname_first....
|
||||
middle_names = family_mids+personal_mids if surname_first else personal_mids+family_mids
|
||||
# ...and then split into `num`-length lists to be used for the final names
|
||||
name_lists = [ middle_names[num*i:num*(i+1)] for i in range(0,middle) ]
|
||||
|
||||
# get personal and family names
|
||||
personal_names = first_name(num=num, gender=gender, return_list=True)
|
||||
last_names = last_name(num=num, return_list=True)
|
||||
|
||||
# attach personal/family names to the list of name lists, according to surname_first
|
||||
if surname_first:
|
||||
name_lists = [last_names] + name_lists + [personal_names]
|
||||
else:
|
||||
name_lists = [personal_names] + name_lists + [last_names]
|
||||
|
||||
# lastly, zip them all up and join them together
|
||||
names = list(zip(*name_lists))
|
||||
names = [ " ".join(name) for name in names ]
|
||||
|
||||
if len(names) == 1 and not return_list:
|
||||
# return single value as a string
|
||||
return names[0]
|
||||
|
||||
return names
|
||||
158
evennia/contrib/utils/name_generator/tests.py
Normal file
158
evennia/contrib/utils/name_generator/tests.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
|
||||
"""
|
||||
Tests for the Random Name Generator
|
||||
"""
|
||||
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
from evennia.contrib.utils.name_generator import namegen
|
||||
|
||||
_INVALID_STYLES = {
|
||||
"missing_keys": {
|
||||
"consonants": ['c','d'],
|
||||
"length": (1,2),
|
||||
},
|
||||
"invalid_vowels": {
|
||||
"syllable": "CVC",
|
||||
"consonants": ['c','d'],
|
||||
"vowels": "aeiou",
|
||||
"length": (1,2),
|
||||
},
|
||||
"invalid_length": {
|
||||
"syllable": "CVC",
|
||||
"consonants": ['c','d'],
|
||||
"vowels": ['a','e'],
|
||||
"length": 2,
|
||||
},
|
||||
}
|
||||
|
||||
namegen._FANTASY_NAME_STRUCTURES |= _INVALID_STYLES
|
||||
|
||||
class TestNameGenerator(BaseEvenniaTest):
|
||||
def test_fantasy_name(self):
|
||||
"""
|
||||
Verify output types and lengths.
|
||||
|
||||
fantasy_name() - str
|
||||
fantasy_name(style="fluid") - str
|
||||
fantasy_name(num=3) - list of length 3
|
||||
fantasy_name(return_list=True) - list of length 1
|
||||
|
||||
raises KeyError on missing style or ValueError on num
|
||||
"""
|
||||
single_name = namegen.fantasy_name()
|
||||
self.assertEqual(type(single_name), str)
|
||||
|
||||
fluid_name = namegen.fantasy_name(style="fluid")
|
||||
self.assertEqual(type(fluid_name), str)
|
||||
|
||||
three_names = namegen.fantasy_name(num=3)
|
||||
self.assertEqual(type(three_names), list)
|
||||
self.assertEqual(len(three_names), 3)
|
||||
|
||||
single_list = namegen.fantasy_name(return_list=True)
|
||||
self.assertEqual(type(single_list), list)
|
||||
self.assertEqual(len(single_list), 1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
namegen.fantasy_name(num=-1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
namegen.fantasy_name(style="dummy")
|
||||
|
||||
def test_structure_validation(self):
|
||||
"""
|
||||
Verify that validation raises the correct errors for invalid inputs.
|
||||
"""
|
||||
with self.assertRaises(KeyError):
|
||||
namegen.fantasy_name(style="missing_keys")
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
namegen.fantasy_name(style="invalid_vowels")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
namegen.fantasy_name(style="invalid_length")
|
||||
|
||||
def test_first_name(self):
|
||||
"""
|
||||
Verify output types and lengths.
|
||||
|
||||
first_name() - str
|
||||
first_name(num=3) - list of length 3
|
||||
first_name(gender='f') - str
|
||||
first_name(return_list=True) - list of length 1
|
||||
"""
|
||||
single_name = namegen.first_name()
|
||||
self.assertEqual(type(single_name), str)
|
||||
|
||||
three_names = namegen.first_name(num=3)
|
||||
self.assertEqual(type(three_names), list)
|
||||
self.assertEqual(len(three_names), 3)
|
||||
|
||||
gendered_name = namegen.first_name(gender='f')
|
||||
self.assertEqual(type(gendered_name), str)
|
||||
|
||||
single_list = namegen.first_name(return_list=True)
|
||||
self.assertEqual(type(single_list), list)
|
||||
self.assertEqual(len(single_list), 1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
namegen.first_name(gender='x')
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
namegen.first_name(num=-1)
|
||||
|
||||
def test_last_name(self):
|
||||
"""
|
||||
Verify output types and lengths.
|
||||
|
||||
last_name() - str
|
||||
last_name(num=3) - list of length 3
|
||||
last_name(return_list=True) - list of length 1
|
||||
"""
|
||||
single_name = namegen.last_name()
|
||||
self.assertEqual(type(single_name), str)
|
||||
|
||||
three_names = namegen.last_name(num=3)
|
||||
self.assertEqual(type(three_names), list)
|
||||
self.assertEqual(len(three_names), 3)
|
||||
|
||||
single_list = namegen.last_name(return_list=True)
|
||||
self.assertEqual(type(single_list), list)
|
||||
self.assertEqual(len(single_list), 1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
namegen.last_name(num=-1)
|
||||
|
||||
def test_full_name(self):
|
||||
"""
|
||||
Verify output types and lengths.
|
||||
|
||||
full_name() - str
|
||||
full_name(num=3) - list of length 3
|
||||
full_name(gender='f') - str
|
||||
full_name(return_list=True) - list of length 1
|
||||
"""
|
||||
single_name = namegen.full_name()
|
||||
self.assertEqual(type(single_name), str)
|
||||
|
||||
three_names = namegen.full_name(num=3)
|
||||
self.assertEqual(type(three_names), list)
|
||||
self.assertEqual(len(three_names), 3)
|
||||
|
||||
gendered_name = namegen.full_name(gender='f')
|
||||
self.assertEqual(type(gendered_name), str)
|
||||
|
||||
single_list = namegen.full_name(return_list=True)
|
||||
self.assertEqual(type(single_list), list)
|
||||
self.assertEqual(len(single_list), 1)
|
||||
|
||||
parts_name = namegen.full_name(parts=4)
|
||||
# a name made of 4 parts must have at least 3 spaces, but may have more
|
||||
parts = parts_name.split(" ")
|
||||
self.assertGreaterEqual(len(parts), 3)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
namegen.full_name(parts=1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
namegen.full_name(num=-1)
|
||||
|
|
@ -324,7 +324,7 @@ class ObjectDBManager(TypedObjectManager):
|
|||
search_candidates = (
|
||||
self.filter(
|
||||
type_restriction
|
||||
& (Q(db_key__istartswith=ostring) | Q(db_tags__db_key__istartswith=ostring))
|
||||
& (Q(db_key__icontains=ostring) | Q(db_tags__db_key__icontains=ostring))
|
||||
)
|
||||
.distinct()
|
||||
.order_by("id")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from collections import defaultdict
|
|||
import inflect
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from evennia.commands import cmdset
|
||||
from evennia.commands.cmdsethandler import CmdSetHandler
|
||||
from evennia.objects.manager import ObjectManager
|
||||
|
|
@ -836,6 +835,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
use_destination=True,
|
||||
to_none=False,
|
||||
move_hooks=True,
|
||||
move_type="move",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
|
|
@ -857,9 +857,15 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
move_hooks (bool): If False, turn off the calling of move-related hooks
|
||||
(at_pre/post_move etc) with quiet=True, this is as quiet a move
|
||||
as can be done.
|
||||
move_type (str): The "kind of move" being performed, such as "teleport", "traverse",
|
||||
"get", "give", or "drop". The value can be arbitrary. By default, it only affects
|
||||
the text message generated by announce_move_to and announce_move_from by defining
|
||||
their {"type": move_type} for outgoing text. This can be used for altering
|
||||
messages and/or overloaded hook behaviors.
|
||||
|
||||
Keyword Args:
|
||||
Passed on to announce_move_to and announce_move_from hooks.
|
||||
Exits will set the "exit_obj" kwarg to themselves.
|
||||
|
||||
Returns:
|
||||
result (bool): True/False depending on if there were problems with the move.
|
||||
|
|
@ -906,7 +912,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
# Before the move, call eventual pre-commands.
|
||||
if move_hooks:
|
||||
try:
|
||||
if not self.at_pre_move(destination, **kwargs):
|
||||
if not self.at_pre_move(destination, move_type=move_type, **kwargs):
|
||||
return False
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="at_pre_move()"), err)
|
||||
|
|
@ -918,7 +924,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
# Call hook on source location
|
||||
if move_hooks and source_location:
|
||||
try:
|
||||
source_location.at_object_leave(self, destination, **kwargs)
|
||||
source_location.at_object_leave(self, destination, move_type=move_type, **kwargs)
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="at_object_leave()"), err)
|
||||
return False
|
||||
|
|
@ -926,7 +932,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
if not quiet:
|
||||
# tell the old room we are leaving
|
||||
try:
|
||||
self.announce_move_from(destination, **kwargs)
|
||||
self.announce_move_from(destination, move_type=move_type, **kwargs)
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="announce_move_from()"), err)
|
||||
return False
|
||||
|
|
@ -941,7 +947,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
if not quiet:
|
||||
# Tell the new room we are there.
|
||||
try:
|
||||
self.announce_move_to(source_location, **kwargs)
|
||||
self.announce_move_to(source_location, move_type=move_type, **kwargs)
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="announce_move_to()"), err)
|
||||
return False
|
||||
|
|
@ -950,7 +956,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
# Perform eventual extra commands on the receiving location
|
||||
# (the object has already arrived at this point)
|
||||
try:
|
||||
destination.at_object_receive(self, source_location, **kwargs)
|
||||
destination.at_object_receive(self, source_location, move_type=move_type, **kwargs)
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="at_object_receive()"), err)
|
||||
return False
|
||||
|
|
@ -959,7 +965,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
# (usually calling 'look')
|
||||
if move_hooks:
|
||||
try:
|
||||
self.at_post_move(source_location, **kwargs)
|
||||
self.at_post_move(source_location, move_type=move_type, **kwargs)
|
||||
except Exception as err:
|
||||
logerr(errtxt.format(err="at_post_move"), err)
|
||||
return False
|
||||
|
|
@ -1020,7 +1026,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
# Famous last words: The account should never see this.
|
||||
string = "This place should not exist ... contact an admin."
|
||||
obj.msg(_(string))
|
||||
obj.move_to(home)
|
||||
obj.move_to(home, move_type="teleport")
|
||||
|
||||
@classmethod
|
||||
def create(cls, key, account=None, **kwargs):
|
||||
|
|
@ -1472,13 +1478,17 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
|
||||
# hooks called when moving the object
|
||||
|
||||
def at_pre_move(self, destination, **kwargs):
|
||||
def at_pre_move(self, destination, move_type="move", **kwargs):
|
||||
"""
|
||||
Called just before starting to move this object to
|
||||
destination.
|
||||
|
||||
Args:
|
||||
destination (Object): The object we are moving to
|
||||
move_type (str): The type of move. "give", "traverse", etc.
|
||||
This is an arbitrary string provided to obj.move_to().
|
||||
Useful for altering messages or altering logic depending
|
||||
on the kind of movement.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
|
|
@ -1496,7 +1506,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
# deprecated alias
|
||||
at_before_move = at_pre_move
|
||||
|
||||
def announce_move_from(self, destination, msg=None, mapping=None, **kwargs):
|
||||
def announce_move_from(self, destination, msg=None, mapping=None, move_type="move", **kwargs):
|
||||
"""
|
||||
Called if the move is to be announced. This is
|
||||
called while we are still standing in the old
|
||||
|
|
@ -1506,6 +1516,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
destination (Object): The place we are going to.
|
||||
msg (str, optional): a replacement message.
|
||||
mapping (dict, optional): additional mapping objects.
|
||||
move_type (str): The type of move. "give", "traverse", etc.
|
||||
This is an arbitrary string provided to obj.move_to().
|
||||
Useful for altering messages or altering logic depending
|
||||
on the kind of movement.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
|
|
@ -1541,9 +1555,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
}
|
||||
)
|
||||
|
||||
location.msg_contents(string, exclude=(self,), from_obj=self, mapping=mapping)
|
||||
location.msg_contents(
|
||||
(string, {"type": move_type}), exclude=(self,), from_obj=self, mapping=mapping
|
||||
)
|
||||
|
||||
def announce_move_to(self, source_location, msg=None, mapping=None, **kwargs):
|
||||
def announce_move_to(self, source_location, msg=None, mapping=None, move_type="move", **kwargs):
|
||||
"""
|
||||
Called after the move if the move was not quiet. At this point
|
||||
we are standing in the new location.
|
||||
|
|
@ -1552,6 +1568,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
source_location (Object): The place we came from
|
||||
msg (str, optional): the replacement message if location.
|
||||
mapping (dict, optional): additional mapping objects.
|
||||
move_type (str): The type of move. "give", "traverse", etc.
|
||||
This is an arbitrary string provided to obj.move_to().
|
||||
Useful for altering messages or altering logic depending
|
||||
on the kind of movement.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
|
|
@ -1605,9 +1625,11 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
}
|
||||
)
|
||||
|
||||
destination.msg_contents(string, exclude=(self,), from_obj=self, mapping=mapping)
|
||||
destination.msg_contents(
|
||||
(string, {"type": move_type}), exclude=(self,), from_obj=self, mapping=mapping
|
||||
)
|
||||
|
||||
def at_post_move(self, source_location, **kwargs):
|
||||
def at_post_move(self, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Called after move has completed, regardless of quiet mode or
|
||||
not. Allows changes to the object due to the location it is
|
||||
|
|
@ -1615,6 +1637,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
|
||||
Args:
|
||||
source_location (Object): Wwhere we came from. This may be `None`.
|
||||
move_type (str): The type of move. "give", "traverse", etc.
|
||||
This is an arbitrary string provided to obj.move_to().
|
||||
Useful for altering messages or altering logic depending
|
||||
on the kind of movement.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
|
|
@ -1624,20 +1650,24 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
# deprecated
|
||||
at_after_move = at_post_move
|
||||
|
||||
def at_object_leave(self, moved_obj, target_location, **kwargs):
|
||||
def at_object_leave(self, moved_obj, target_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Called just before an object leaves from inside this object
|
||||
|
||||
Args:
|
||||
moved_obj (Object): The object leaving
|
||||
target_location (Object): Where `moved_obj` is going.
|
||||
move_type (str): The type of move. "give", "traverse", etc.
|
||||
This is an arbitrary string provided to obj.move_to().
|
||||
Useful for altering messages or altering logic depending
|
||||
on the kind of movement.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def at_object_receive(self, moved_obj, source_location, **kwargs):
|
||||
def at_object_receive(self, moved_obj, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
Called after an object has been moved into this object.
|
||||
|
||||
|
|
@ -1645,6 +1675,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
moved_obj (Object): The object moved into this one
|
||||
source_location (Object): Where `moved_object` came from.
|
||||
Note that this could be `None`.
|
||||
move_type (str): The type of move. "give", "traverse", etc.
|
||||
This is an arbitrary string provided to obj.move_to().
|
||||
Useful for altering messages or altering logic depending
|
||||
on the kind of movement.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
|
|
@ -2381,7 +2415,7 @@ class DefaultCharacter(DefaultObject):
|
|||
# add the default cmdset
|
||||
self.cmdset.add_default(settings.CMDSET_CHARACTER, persistent=True)
|
||||
|
||||
def at_post_move(self, source_location, **kwargs):
|
||||
def at_post_move(self, source_location, move_type="move", **kwargs):
|
||||
"""
|
||||
We make sure to look around after a move.
|
||||
|
||||
|
|
@ -2648,9 +2682,9 @@ class ExitCommand(_COMMAND_DEFAULT_CLASS):
|
|||
|
||||
"""
|
||||
if self.obj.destination:
|
||||
return " (exit to %s)" % self.obj.destination.get_display_name(caller)
|
||||
return " (exit to %s)" % self.obj.destination.get_display_name(caller, **kwargs)
|
||||
else:
|
||||
return " (%s)" % self.obj.get_display_name(caller)
|
||||
return " (%s)" % self.obj.get_display_name(caller, **kwargs)
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -2816,8 +2850,8 @@ class DefaultExit(DefaultObject):
|
|||
)
|
||||
)
|
||||
|
||||
# an exit should have a destination (this is replaced at creation time)
|
||||
if self.location:
|
||||
# an exit should have a destination - try to make sure it does
|
||||
if self.location and not self.destination:
|
||||
self.destination = self.location
|
||||
|
||||
def at_cmdset_get(self, **kwargs):
|
||||
|
|
@ -2859,7 +2893,7 @@ class DefaultExit(DefaultObject):
|
|||
|
||||
"""
|
||||
source_location = traversing_object.location
|
||||
if traversing_object.move_to(target_location):
|
||||
if traversing_object.move_to(target_location, move_type="traverse", exit_obj=self):
|
||||
self.at_post_traverse(traversing_object, source_location)
|
||||
else:
|
||||
if self.db.err_traverse:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ The client for sending data to the Evennia Game Index
|
|||
"""
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
import platform
|
||||
import warnings
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
|
|
|||
|
|
@ -269,7 +269,12 @@ class SessionHandler(dict):
|
|||
rkwargs[key] = [_validate(data), {}]
|
||||
else:
|
||||
rkwargs[key] = [[_validate(data)], {}]
|
||||
rkwargs[key][1]["options"] = options
|
||||
rkwargs[key][1]["options"] = dict(options)
|
||||
# make sure that any "prompt" message will be processed last
|
||||
# by moving it to the end
|
||||
if "prompt" in rkwargs:
|
||||
prompt = rkwargs.pop("prompt")
|
||||
rkwargs["prompt"] = prompt
|
||||
return rkwargs
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
Unit tests for typeclass base system
|
||||
|
||||
"""
|
||||
|
||||
from django.test import override_settings
|
||||
from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTestCase
|
||||
from evennia.typeclasses import attributes
|
||||
from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTestCase
|
||||
from mock import patch
|
||||
from parameterized import parameterized
|
||||
|
||||
|
|
@ -13,6 +14,10 @@ from parameterized import parameterized
|
|||
# ------------------------------------------------------------
|
||||
|
||||
|
||||
class DictSubclass(dict):
|
||||
pass
|
||||
|
||||
|
||||
class TestAttributes(BaseEvenniaTest):
|
||||
def test_attrhandler(self):
|
||||
key = "testattr"
|
||||
|
|
@ -22,6 +27,25 @@ class TestAttributes(BaseEvenniaTest):
|
|||
self.obj1.db.testattr = value
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
|
||||
# "plain" subclasses
|
||||
value = DictSubclass({"fo": "foo", "bar": "bar"})
|
||||
self.obj1.db.testattr = value
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
|
||||
self.obj1.db.testattr["fo"] = "foo2"
|
||||
value.update({"fo": "foo2"})
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
self.assertEqual(self.obj1.attributes.get("testattr"), value)
|
||||
|
||||
# nested subclasses
|
||||
value = DictSubclass({"nested": True, "deep": DictSubclass({"fo": "foo", "bar": "bar"})})
|
||||
self.obj1.db.testattr = value
|
||||
|
||||
self.obj1.db.testattr["deep"]["fo"] = "nemo"
|
||||
value["deep"].update({"fo": "nemo"})
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
self.assertEqual(self.obj1.attributes.get("testattr"), value)
|
||||
|
||||
@override_settings(TYPECLASS_AGGRESSIVE_CACHE=False)
|
||||
@patch("evennia.typeclasses.attributes._TYPECLASS_AGGRESSIVE_CACHE", False)
|
||||
def test_attrhandler_nocache(self):
|
||||
|
|
@ -35,6 +59,27 @@ class TestAttributes(BaseEvenniaTest):
|
|||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
self.assertFalse(self.obj1.attributes.backend._cache)
|
||||
|
||||
# "plain" subclasses
|
||||
value = DictSubclass({"fo": "foo", "bar": "bar"})
|
||||
self.obj1.db.testattr = value
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
|
||||
self.obj1.db.testattr["fo"] = "foo2"
|
||||
value.update({"fo": "foo2"})
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
self.assertEqual(self.obj1.attributes.get("testattr"), value)
|
||||
self.assertFalse(self.obj1.attributes.backend._cache)
|
||||
|
||||
# nested subclasses
|
||||
value = DictSubclass({"nested": True, "deep": DictSubclass({"fo": "foo", "bar": "bar"})})
|
||||
self.obj1.db.testattr = value
|
||||
|
||||
self.obj1.db.testattr["deep"]["fo"] = "nemo"
|
||||
value["deep"].update({"fo": "nemo"})
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
self.assertEqual(self.obj1.attributes.get("testattr"), value)
|
||||
self.assertFalse(self.obj1.attributes.backend._cache)
|
||||
|
||||
def test_weird_text_save(self):
|
||||
"test 'weird' text type (different in py2 vs py3)"
|
||||
from django.utils.safestring import SafeText
|
||||
|
|
|
|||
|
|
@ -18,19 +18,20 @@ in-situ, e.g `obj.db.mynestedlist[3][5] = 3` would never be saved and
|
|||
be out of sync with the database.
|
||||
|
||||
"""
|
||||
from collections import OrderedDict, defaultdict, deque
|
||||
from collections.abc import MutableMapping, MutableSequence, MutableSet
|
||||
from functools import update_wrapper
|
||||
from collections import deque, OrderedDict, defaultdict
|
||||
from collections.abc import MutableSequence, MutableSet, MutableMapping
|
||||
|
||||
try:
|
||||
from pickle import dumps, loads, UnpicklingError
|
||||
from pickle import UnpicklingError, dumps, loads
|
||||
except ImportError:
|
||||
from pickle import dumps, loads
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.safestring import SafeString
|
||||
from evennia.utils.utils import uses_database, is_iter, to_bytes
|
||||
from evennia.utils import logger
|
||||
from evennia.utils.utils import is_iter, to_bytes, uses_database
|
||||
|
||||
__all__ = ("to_pickle", "from_pickle", "do_pickle", "do_unpickle", "dbserialize", "dbunserialize")
|
||||
|
||||
|
|
@ -242,6 +243,9 @@ class _SaverMutable(object):
|
|||
def __or__(self, other):
|
||||
return self._data | other
|
||||
|
||||
def __ror__(self, other):
|
||||
return self._data | other
|
||||
|
||||
@_save
|
||||
def __setitem__(self, key, value):
|
||||
self._data.__setitem__(key, self._convert_mutables(value))
|
||||
|
|
@ -262,7 +266,7 @@ class _SaverList(_SaverMutable, MutableSequence):
|
|||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._data = list()
|
||||
self._data = kwargs.pop("_class", list)()
|
||||
|
||||
@_save
|
||||
def __iadd__(self, otherlist):
|
||||
|
|
@ -306,7 +310,7 @@ class _SaverDict(_SaverMutable, MutableMapping):
|
|||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._data = dict()
|
||||
self._data = kwargs.pop("_class", dict)()
|
||||
|
||||
def has_key(self, key):
|
||||
return key in self._data
|
||||
|
|
@ -644,11 +648,20 @@ def to_pickle(data):
|
|||
pass
|
||||
|
||||
if hasattr(item, "__iter__"):
|
||||
# we try to conserve the iterable class, if not convert to list
|
||||
try:
|
||||
return item.__class__([process_item(val) for val in item])
|
||||
except (AttributeError, TypeError):
|
||||
return [process_item(val) for val in item]
|
||||
# we try to conserve the iterable class, if not convert to dict
|
||||
try:
|
||||
return item.__class__(
|
||||
(process_item(key), process_item(val)) for key, val in item.items()
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
return {process_item(key): process_item(val) for key, val in item.items()}
|
||||
except Exception:
|
||||
# we try to conserve the iterable class, if not convert to list
|
||||
try:
|
||||
return item.__class__([process_item(val) for val in item])
|
||||
except (AttributeError, TypeError):
|
||||
return [process_item(val) for val in item]
|
||||
elif hasattr(item, "sessid") and hasattr(item, "conn_time"):
|
||||
return pack_session(item)
|
||||
try:
|
||||
|
|
@ -713,11 +726,20 @@ def from_pickle(data, db_obj=None):
|
|||
return deque(process_item(val) for val in item)
|
||||
elif hasattr(item, "__iter__"):
|
||||
try:
|
||||
# we try to conserve the iterable class if
|
||||
# it accepts an iterator
|
||||
return item.__class__(process_item(val) for val in item)
|
||||
except (AttributeError, TypeError):
|
||||
return [process_item(val) for val in item]
|
||||
# we try to conserve the iterable class, if not convert to dict
|
||||
try:
|
||||
return item.__class__(
|
||||
(process_item(key), process_item(val)) for key, val in item.items()
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
return {process_item(key): process_item(val) for key, val in item.items()}
|
||||
except Exception:
|
||||
try:
|
||||
# we try to conserve the iterable class if
|
||||
# it accepts an iterator
|
||||
return item.__class__(process_item(val) for val in item)
|
||||
except (AttributeError, TypeError):
|
||||
return [process_item(val) for val in item]
|
||||
|
||||
if hasattr(item, "__deserialize_dbobjs__"):
|
||||
# this allows the object to custom-deserialize any embedded dbobjs
|
||||
|
|
@ -779,20 +801,46 @@ def from_pickle(data, db_obj=None):
|
|||
return dat
|
||||
elif hasattr(item, "__iter__"):
|
||||
try:
|
||||
# we try to conserve the iterable class if it
|
||||
# accepts an iterator
|
||||
return item.__class__(process_tree(val, parent) for val in item)
|
||||
except (AttributeError, TypeError):
|
||||
dat = _SaverList(_parent=parent)
|
||||
dat._data.extend(process_tree(val, dat) for val in item)
|
||||
return dat
|
||||
# we try to conserve the iterable class, if not convert to dict
|
||||
try:
|
||||
dat = _SaverDict(_parent=parent, _class=item.__class__)
|
||||
dat._data.update(
|
||||
(process_item(key), process_tree(val, dat)) for key, val in item.items()
|
||||
)
|
||||
return dat
|
||||
except (AttributeError, TypeError):
|
||||
dat = _SaverDict(_parent=parent)
|
||||
dat._data.update(
|
||||
(process_item(key), process_tree(val, dat)) for key, val in item.items()
|
||||
)
|
||||
return dat
|
||||
except Exception:
|
||||
try:
|
||||
# we try to conserve the iterable class if it
|
||||
# accepts an iterator
|
||||
dat = _SaverList(_parent=parent, _class=item.__class__)
|
||||
dat._data.extend(process_tree(val, dat) for val in item)
|
||||
return dat
|
||||
except (AttributeError, TypeError):
|
||||
dat = _SaverList(_parent=parent)
|
||||
dat._data.extend(process_tree(val, dat) for val in item)
|
||||
return dat
|
||||
|
||||
if hasattr(item, "__deserialize_dbobjs__"):
|
||||
try:
|
||||
item.__deserialize_dbobjs__()
|
||||
except (TypeError, UnpicklingError):
|
||||
pass
|
||||
|
||||
return item
|
||||
|
||||
if db_obj:
|
||||
# convert lists, dicts and sets to their Saved* counterparts. It
|
||||
# is only relevant if the "root" is an iterable of the right type.
|
||||
dtype = type(data)
|
||||
if dtype == list:
|
||||
if dtype in (str, int, float, bool, bytes, SafeString, tuple):
|
||||
return process_item(data)
|
||||
elif dtype == list:
|
||||
dat = _SaverList(_db_obj=db_obj)
|
||||
dat._data.extend(process_tree(val, dat) for val in data)
|
||||
return dat
|
||||
|
|
@ -822,6 +870,34 @@ def from_pickle(data, db_obj=None):
|
|||
dat = _SaverDeque(_db_obj=db_obj)
|
||||
dat._data.extend(process_item(val) for val in data)
|
||||
return dat
|
||||
elif hasattr(data, "__iter__"):
|
||||
try:
|
||||
# we try to conserve the iterable class, if not convert to dict
|
||||
try:
|
||||
dat = _SaverDict(_db_obj=db_obj, _class=data.__class__)
|
||||
dat._data.update(
|
||||
(process_item(key), process_tree(val, dat)) for key, val in data.items()
|
||||
)
|
||||
return dat
|
||||
except (AttributeError, TypeError):
|
||||
dat = _SaverDict(_db_obj=db_obj)
|
||||
dat._data.update(
|
||||
(process_item(key), process_tree(val, dat)) for key, val in data.items()
|
||||
)
|
||||
return dat
|
||||
except Exception:
|
||||
try:
|
||||
# we try to conserve the iterable class if it
|
||||
# accepts an iterator
|
||||
dat = _SaverList(_db_obj=db_obj, _class=data.__class__)
|
||||
dat._data.extend(process_tree(val, dat) for val in data)
|
||||
return dat
|
||||
|
||||
except (AttributeError, TypeError):
|
||||
dat = _SaverList(_db_obj=db_obj)
|
||||
dat._data.extend(process_tree(val, dat) for val in data)
|
||||
return dat
|
||||
|
||||
return process_item(data)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
Tests for dbserialize module
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from django.test import TestCase
|
||||
from evennia.utils import dbserialize
|
||||
from evennia.objects.objects import DefaultObject
|
||||
from evennia.utils import dbserialize
|
||||
from parameterized import parameterized
|
||||
|
||||
|
||||
|
|
@ -93,8 +94,6 @@ class TestDbSerialize(TestCase):
|
|||
self.assertEqual(self.obj.db.test, {"a": True, "b": False})
|
||||
|
||||
def test_defaultdict(self):
|
||||
from collections import defaultdict
|
||||
|
||||
# baseline behavior for a defaultdict
|
||||
_dd = defaultdict(list)
|
||||
_dd["a"]
|
||||
|
|
@ -164,12 +163,45 @@ class DbObjWrappers(TestCase):
|
|||
con = _ValidContainer(self.dbobj2)
|
||||
self.dbobj1.db.testarg = con
|
||||
|
||||
# accessing the same data twice
|
||||
# accessing the same data multiple times
|
||||
res1 = self.dbobj1.db.testarg
|
||||
res2 = self.dbobj1.db.testarg
|
||||
res3 = self.dbobj1.db.testarg
|
||||
|
||||
self.assertEqual(res1, res2)
|
||||
self.assertEqual(res1, res3)
|
||||
self.assertEqual(res1, con)
|
||||
self.assertEqual(res2, con)
|
||||
self.assertEqual(res1.hidden_obj, self.dbobj2)
|
||||
self.assertEqual(res2.hidden_obj, self.dbobj2)
|
||||
self.assertEqual(res3.hidden_obj, self.dbobj2)
|
||||
|
||||
def test_dbobj_hidden_dict(self):
|
||||
con1 = _ValidContainer(self.dbobj2)
|
||||
con2 = _ValidContainer(self.dbobj2)
|
||||
|
||||
self.dbobj1.db.dict = {}
|
||||
|
||||
self.dbobj1.db.dict["key1"] = con1
|
||||
self.dbobj1.db.dict["key2"] = con2
|
||||
|
||||
self.assertEqual(self.dbobj1.db.dict["key1"].hidden_obj, self.dbobj2)
|
||||
self.assertEqual(self.dbobj1.db.dict["key1"].hidden_obj, self.dbobj2)
|
||||
self.assertEqual(self.dbobj1.db.dict["key2"].hidden_obj, self.dbobj2)
|
||||
self.assertEqual(self.dbobj1.db.dict["key2"].hidden_obj, self.dbobj2)
|
||||
|
||||
def test_dbobj_hidden_defaultdict(self):
|
||||
|
||||
con1 = _ValidContainer(self.dbobj2)
|
||||
con2 = _ValidContainer(self.dbobj2)
|
||||
|
||||
self.dbobj1.db.dfdict = defaultdict(dict)
|
||||
|
||||
self.dbobj1.db.dfdict["key"]["con1"] = con1
|
||||
self.dbobj1.db.dfdict["key"]["con2"] = con2
|
||||
|
||||
self.assertEqual(self.dbobj1.db.dfdict["key"]["con1"].hidden_obj, self.dbobj2)
|
||||
|
||||
self.assertEqual(self.dbobj1.db.dfdict["key"]["con1"].hidden_obj, self.dbobj2)
|
||||
self.assertEqual(self.dbobj1.db.dfdict["key"]["con2"].hidden_obj, self.dbobj2)
|
||||
self.assertEqual(self.dbobj1.db.dfdict["key"]["con2"].hidden_obj, self.dbobj2)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from evennia.scripts.scripts import DefaultScript
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia.utils.search import search_script_attribute, search_script_tag
|
||||
from evennia.utils.search import search_script_attribute, search_script_tag, search_script
|
||||
|
||||
class TestSearch(EvenniaTest):
|
||||
|
||||
|
|
@ -48,3 +48,17 @@ class TestSearch(EvenniaTest):
|
|||
script.db.an_attribute = "some value"
|
||||
found = search_script_attribute(key="an_attribute", value="wrong value")
|
||||
self.assertEqual(len(found), 0, errors)
|
||||
|
||||
def test_search_script_key(self):
|
||||
"""Check that a script can be found by its key value."""
|
||||
script, errors = DefaultScript.create("a-script")
|
||||
found = search_script("a-script")
|
||||
self.assertEqual(len(found), 1, errors)
|
||||
self.assertEqual(script.key, found[0].key, errors)
|
||||
|
||||
def test_search_script_wrong_key(self):
|
||||
"""Check that a script cannot be found by a wrong key value."""
|
||||
script, errors = DefaultScript.create("a-script")
|
||||
found = search_script("wrong_key")
|
||||
self.assertEqual(len(found), 0, errors)
|
||||
|
||||
|
|
|
|||
|
|
@ -57,21 +57,30 @@ class TestDedent(TestCase):
|
|||
class TestListToString(TestCase):
|
||||
"""
|
||||
Default function header from utils.py:
|
||||
list_to_string(inlist, endsep="and", addquote=False)
|
||||
list_to_string(inlist, sep=",", endsep=", and", addquote=False)
|
||||
|
||||
Examples:
|
||||
no endsep:
|
||||
with defaults:
|
||||
[1,2,3] -> '1, 2, and 3'
|
||||
with endsep==',':
|
||||
[1,2,3] -> '1, 2, 3'
|
||||
with sep==';' and endsep==';':
|
||||
[1,2,3] -> '1; 2; 3'
|
||||
with endsep=='and':
|
||||
[1,2,3] -> '1, 2 and 3'
|
||||
with addquote and endsep
|
||||
with endsep=='':
|
||||
[1,2,3] -> '1, 2 3'
|
||||
with addquote and endsep="and"
|
||||
[1,2,3] -> '"1", "2" and "3"'
|
||||
"""
|
||||
|
||||
def test_list_to_string(self):
|
||||
self.assertEqual("1, 2, 3", utils.list_to_string([1, 2, 3], endsep=""))
|
||||
self.assertEqual('"1", "2", "3"', utils.list_to_string([1, 2, 3], endsep="", addquote=True))
|
||||
self.assertEqual("1, 2, and 3", utils.list_to_string([1, 2, 3]))
|
||||
self.assertEqual("1, 2, 3", utils.list_to_string([1, 2, 3], endsep=","))
|
||||
self.assertEqual("1, 2 and 3", utils.list_to_string([1, 2, 3], endsep="and"))
|
||||
self.assertEqual("1, 2 3", utils.list_to_string([1, 2, 3], endsep=""))
|
||||
self.assertEqual("1; 2; 3", utils.list_to_string([1, 2, 3], sep=";", endsep=";"))
|
||||
self.assertEqual('"1", "2", "3"', utils.list_to_string([1, 2, 3], endsep=",", addquote=True))
|
||||
self.assertEqual(
|
||||
'"1", "2" and "3"', utils.list_to_string([1, 2, 3], endsep="and", addquote=True)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ def columnize(string, columns=2, spacing=4, align="l", width=None):
|
|||
return "\n".join(rows)
|
||||
|
||||
|
||||
def iter_to_str(iterable, endsep=", and", addquote=False):
|
||||
def iter_to_str(iterable, sep=",", endsep=", and", addquote=False):
|
||||
"""
|
||||
This pretty-formats an iterable list as string output, adding an optional
|
||||
alternative separator to the second to last entry. If `addquote`
|
||||
|
|
@ -372,8 +372,8 @@ def iter_to_str(iterable, endsep=", and", addquote=False):
|
|||
iterable (any): Usually an iterable to print. Each element must be possible to
|
||||
present with a string. Note that if this is a generator, it will be
|
||||
consumed by this operation.
|
||||
endsep (str, optional): If set, the last item separator will
|
||||
be replaced with this value.
|
||||
sep (str, optional): The string to use as a separator for each item in the iterable.
|
||||
endsep (str, optional): The last item separator will be replaced with this value.
|
||||
addquote (bool, optional): This will surround all outgoing
|
||||
values with double quotes.
|
||||
|
||||
|
|
@ -381,17 +381,20 @@ def iter_to_str(iterable, endsep=", and", addquote=False):
|
|||
str: The list represented as a string.
|
||||
|
||||
Notes:
|
||||
Default is to use 'Oxford comma', like 1, 2, 3, and 4. To remove, give
|
||||
`endsep` as just `and`.
|
||||
Default is to use 'Oxford comma', like 1, 2, 3, and 4.
|
||||
|
||||
Examples:
|
||||
|
||||
```python
|
||||
>>> list_to_string([1,2,3], endsep='')
|
||||
>>> list_to_string([1,2,3], endsep=',')
|
||||
'1, 2, 3'
|
||||
>>> list_to_string([1,2,3], endsep='')
|
||||
'1, 2 3'
|
||||
>>> list_to_string([1,2,3], ensdep='and')
|
||||
'1, 2 and 3'
|
||||
>>> list_to_string([1,2,3], endsep=', and', addquote=True)
|
||||
>>> list_to_string([1,2,3], sep=';', endsep=';')
|
||||
'1; 2; 3'
|
||||
>>> list_to_string([1,2,3], addquote=True)
|
||||
'"1", "2", and "3"'
|
||||
```
|
||||
|
||||
|
|
@ -406,22 +409,19 @@ def iter_to_str(iterable, endsep=", and", addquote=False):
|
|||
else:
|
||||
iterable = tuple(str(val) for val in iterable)
|
||||
|
||||
if endsep.startswith(","):
|
||||
if endsep.startswith(sep):
|
||||
# oxford comma alternative
|
||||
endsep = endsep[1:] if len_iter < 3 else endsep
|
||||
elif endsep:
|
||||
# normal space-separated end separator
|
||||
endsep = " " + str(endsep).strip()
|
||||
else:
|
||||
# no separator given - use comma
|
||||
endsep = ","
|
||||
|
||||
if len_iter == 1:
|
||||
return str(iterable[0])
|
||||
elif len_iter == 2:
|
||||
return f"{endsep} ".join(str(v) for v in iterable)
|
||||
else:
|
||||
return ", ".join(str(v) for v in iterable[:-1]) + f"{endsep} {iterable[-1]}"
|
||||
return f"{sep} ".join(str(v) for v in iterable[:-1]) + f"{endsep} {iterable[-1]}"
|
||||
|
||||
|
||||
# legacy aliases
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue