diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f882bb57b..11ba6f0975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,12 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10 - New `at_pre_object_receive(obj, source_location)` method on Objects. Called on destination, mimicking behavior of `at_pre_move` hook - returning False will abort move. - New `at_pre_object_leave(obj, destination)` method on Objects. Called on +- 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 diff --git a/CODING_STYLE.md b/CODING_STYLE.md index 3bc2dd346f..800c1e463d 100644 --- a/CODING_STYLE.md +++ b/CODING_STYLE.md @@ -193,7 +193,7 @@ or in the chat. [pep8]: http://www.python.org/dev/peps/pep-0008 [pep8tool]: https://pypi.python.org/pypi/pep8 -[googlestyle]: http://www.sphinx-doc.org/en/stable/ext/example_google.html +[googlestyle]: https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html [githubmarkdown]: https://help.github.com/articles/github-flavored-markdown/ [markdown-hilight]: https://help.github.com/articles/github-flavored-markdown/#syntax-highlighting [command-docstrings]: https://github.com/evennia/evennia/wiki/Using%20MUX%20As%20a%20Standard#documentation-policy diff --git a/docs/Makefile b/docs/Makefile index eed5098f2f..f7d7feae4b 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -152,6 +152,8 @@ mv-local: @echo "Documentation built (multiversion + autodocs)." @echo "To see result, open evennia/docs/build/html//index.html in a browser." +# note - don't run the following manually, the result will clash with the result +# of the github actions! deploy: make _multiversion-deploy @echo "Documentation deployed." diff --git a/docs/source/Components/Attributes.md b/docs/source/Components/Attributes.md index 18291ff774..978fcdf39d 100644 --- a/docs/source/Components/Attributes.md +++ b/docs/source/Components/Attributes.md @@ -3,7 +3,7 @@ ```{code-block} :caption: In-game > set obj/myattr = "test" -``` +``` ```{code-block} python :caption: In-code, using the .db wrapper obj.db.foo = [1, 2, 3, "bar"] @@ -16,8 +16,8 @@ value = attributes.get("myattr", category="bar") ``` ```{code-block} python :caption: In-code, using `AttributeProperty` at class level -from evennia import DefaultObject -from evennia import AttributeProperty +from evennia import DefaultObject +from evennia import AttributeProperty class MyObject(DefaultObject): foo = AttributeProperty(default=[1, 2, 3, "bar"]) @@ -25,20 +25,20 @@ class MyObject(DefaultObject): ``` -_Attributes_ allow you to to store arbitrary data on objects and make sure the data survives a server reboot. An Attribute can store pretty much any -Python data structure and data type, like numbers, strings, lists, dicts etc. You can also +_Attributes_ allow you to to store arbitrary data on objects and make sure the data survives a server reboot. An Attribute can store pretty much any +Python data structure and data type, like numbers, strings, lists, dicts etc. You can also store (references to) database objects like characters and rooms. - [What can be stored in an Attribute](#what-types-of-data-can-i-save-in-an-attribute) is a must-read to avoid being surprised, also for experienced developers. Attributes can store _almost_ everything but you need to know the quirks. -- [NAttributes](#in-memory-attributes-nattributes) are the in-memory, non-persistent +- [NAttributes](#in-memory-attributes-nattributes) are the in-memory, non-persistent siblings of Attributes. - [Managing Attributes In-game](#managing-attributes-in-game) for in-game builder commands. -## Managing Attributes in Code +## Managing Attributes in Code -Attributes are usually handled in code. All [Typeclassed](./Typeclasses.md) entities -([Accounts](./Accounts.md), [Objects](./Objects.md), [Scripts](./Scripts.md) and +Attributes are usually handled in code. All [Typeclassed](./Typeclasses.md) entities +([Accounts](./Accounts.md), [Objects](./Objects.md), [Scripts](./Scripts.md) and [Channels](./Channels.md)) can (and usually do) have Attributes associated with them. There are three ways to manage Attributes, all of which can be mixed. @@ -50,8 +50,8 @@ are three ways to manage Attributes, all of which can be mixed. The simplest way to get/set Attributes is to use the `.db` shortcut. This allows for setting and getting Attributes that lack a _category_ (having category `None`) -```python -import evennia +```python +import evennia obj = evennia.create_object(key="Foo") @@ -64,10 +64,10 @@ obj.db.self_reference = obj # stores a reference to the obj rose = evennia.search_object(key="rose")[0] # returns a list, grab 0th element rose.db.has_thorns = True -# retrieving +# retrieving val1 = obj.db.foo1 val2 = obj.db.foo2 -weap = obj.db.weapon +weap = obj.db.weapon myself = obj.db.self_reference # retrieve reference from db, get object back is_ouch = rose.db.has_thorns @@ -75,25 +75,25 @@ is_ouch = rose.db.has_thorns # this will return None, not AttributeError! not_found = obj.db.jiwjpowiwwerw -# returns all Attributes on the object -obj.db.all +# returns all Attributes on the object +obj.db.all # delete an Attribute del obj.db.foo2 ``` -Trying to access a non-existing Attribute will never lead to an `AttributeError`. Instead -you will get `None` back. The special `.db.all` will return a list of all Attributes on -the object. You can replace this with your own Attribute `all` if you want, it will replace the +Trying to access a non-existing Attribute will never lead to an `AttributeError`. Instead +you will get `None` back. The special `.db.all` will return a list of all Attributes on +the object. You can replace this with your own Attribute `all` if you want, it will replace the default `all` functionality until you delete it again. ### Using .attributes -If you want to group your Attribute in a category, or don't know the name of the Attribute beforehand, you can make use of -the [AttributeHandler](evennia.typeclasses.attributes.AttributeHandler), available as `.attributes` on all typeclassed entities. With no extra keywords, this is identical to using the `.db` shortcut (`.db` is actually using the `AttributeHandler` internally): +If you want to group your Attribute in a category, or don't know the name of the Attribute beforehand, you can make use of +the [AttributeHandler](evennia.typeclasses.attributes.AttributeHandler), available as `.attributes` on all typeclassed entities. With no extra keywords, this is identical to using the `.db` shortcut (`.db` is actually using the `AttributeHandler` internally): + +```python +is_ouch = rose.attributes.get("has_thorns") -```python -is_ouch = rose.attributes.get("has_thorns") - obj.attributes.add("helmet", "Knight's helmet") helmet = obj.attributes.get("helmet") @@ -103,7 +103,7 @@ obj.attributes.add("my game log", "long text about ...") By using a category you can separate same-named Attributes on the same object to help organization. -```python +```python # store (let's say we have gold_necklace and ringmail_armor from before) obj.attributes.add("neck", gold_necklace, category="clothing") obj.attributes.add("neck", ringmail_armor, category="armor") @@ -113,19 +113,19 @@ neck_clothing = obj.attributes.get("neck", category="clothing") neck_armor = obj.attributes.get("neck", category="armor") ``` -If you don't specify a category, the Attribute's `category` will be `None` and can thus also be found via `.db`. `None` is considered a category of its own, so you won't find `None`-category Attributes mixed with Attributes having categories. +If you don't specify a category, the Attribute's `category` will be `None` and can thus also be found via `.db`. `None` is considered a category of its own, so you won't find `None`-category Attributes mixed with Attributes having categories. -Here are the methods of the `AttributeHandler`. See +Here are the methods of the `AttributeHandler`. See the [AttributeHandler API](evennia.typeclasses.attributes.AttributeHandler) for more details. - `has(...)` - this checks if the object has an Attribute with this key. This is equivalent to doing `obj.db.attrname` except you can also check for a specific `category. -- `get(...)` - this retrieves the given Attribute. You can also provide a `default` value to return +- `get(...)` - this retrieves the given Attribute. You can also provide a `default` value to return if the Attribute is not defined (instead of None). By supplying an `accessing_object` to the call one can also make sure to check permissions before modifying - anything. The `raise_exception` kwarg allows you to raise an `AttributeError` instead of returning - `None` when you access a non-existing `Attribute`. The `strattr` kwarg tells the system to store - the Attribute as a raw string rather than to pickle it. While an optimization this should usually + anything. The `raise_exception` kwarg allows you to raise an `AttributeError` instead of returning + `None` when you access a non-existing `Attribute`. The `strattr` kwarg tells the system to store + the Attribute as a raw string rather than to pickle it. While an optimization this should usually not be used unless the Attribute is used for some particular, limited purpose. - `add(...)` - this adds a new Attribute to the object. An optional [lockstring](./Locks.md) can be supplied here to restrict future access and also the call itself may be checked against locks. @@ -135,30 +135,30 @@ the [AttributeHandler API](evennia.typeclasses.attributes.AttributeHandler) for Examples: -```python +```python try: - # raise error if Attribute foo does not exist + # raise error if Attribute foo does not exist val = obj.attributes.get("foo", raise_exception=True): except AttributeError: # ... - + # return default value if foo2 doesn't exist -val2 = obj.attributes.get("foo2", default=[1, 2, 3, "bar"]) +val2 = obj.attributes.get("foo2", default=[1, 2, 3, "bar"]) # delete foo if it exists (will silently fail if unset, unless # raise_exception is set) obj.attributes.remove("foo") - + # view all clothes on obj -all_clothes = obj.attributes.all(category="clothes") +all_clothes = obj.attributes.all(category="clothes") ``` -### Using AttributeProperty +### Using AttributeProperty -The third way to set up an Attribute is to use an `AttributeProperty`. This +The third way to set up an Attribute is to use an `AttributeProperty`. This is done on the _class level_ of your typeclass and allows you to treat Attributes a bit like Django database Fields. Unlike using `.db` and `.attributes`, an `AttributeProperty` can't be created on the fly, you must assign it in the class code. -```python +```python # mygame/typeclasses/characters.py from evennia import DefaultCharacter @@ -173,16 +173,16 @@ class Character(DefaultCharacter): sleepy = AttributeProperty(False, autocreate=False) poisoned = AttributeProperty(False, autocreate=False) - - def at_object_creation(self): - # ... -``` + + def at_object_creation(self): + # ... +``` When a new instance of the class is created, new `Attributes` will be created with the value and category given. -With `AttributeProperty`'s set up like this, one can access the underlying `Attribute` like a regular property on the created object: +With `AttributeProperty`'s set up like this, one can access the underlying `Attribute` like a regular property on the created object: -```python +```python char = create_object(Character) char.strength # returns 10 @@ -195,15 +195,15 @@ char.db.sleepy # returns None because autocreate=False (see below) ``` -```{warning} +```{warning} Be careful to not assign AttributeProperty's to names of properties and methods already existing on the class, like 'key' or 'at_object_creation'. That could lead to very confusing errors. ``` -The `autocreate=False` (default is `True`) used for `sleepy` and `poisoned` is worth a closer explanation. When `False`, _no_ Attribute will be auto-created for these AttributProperties unless they are _explicitly_ set. -The advantage of not creating an Attribute is that the default value given to `AttributeProperty` is returned with no database access unless you change it. This also means that if you want to change the default later, all entities previously create will inherit the new default. -The drawback is that without a database precense you can't find the Attribute via `.db` and `.attributes.get` (or by querying for it in other ways in the database): +The `autocreate=False` (default is `True`) used for `sleepy` and `poisoned` is worth a closer explanation. When `False`, _no_ Attribute will be auto-created for these AttributProperties unless they are _explicitly_ set. +The advantage of not creating an Attribute is that the default value given to `AttributeProperty` is returned with no database access unless you change it. This also means that if you want to change the default later, all entities previously create will inherit the new default. +The drawback is that without a database precense you can't find the Attribute via `.db` and `.attributes.get` (or by querying for it in other ways in the database): -```python +```python char.sleepy # returns False, no db access char.db.sleepy # returns None - no Attribute exists @@ -217,39 +217,39 @@ char.sleepy # now returns True, involves db access ``` -You can e.g. `del char.strength` to set the value back to the default (the value defined -in the `AttributeProperty`). +You can e.g. `del char.strength` to set the value back to the default (the value defined +in the `AttributeProperty`). See the [AttributeProperty API](evennia.typeclasses.attributes.AttributeProperty) for more details on how to create it with special options, like giving access-restrictions. ## Managing Attributes in-game -Attributes are mainly used by code. But one can also allow the builder to use Attributes to -'turn knobs' in-game. For example a builder could want to manually tweak the "level" Attribute of an +Attributes are mainly used by code. But one can also allow the builder to use Attributes to +'turn knobs' in-game. For example a builder could want to manually tweak the "level" Attribute of an enemy NPC to lower its difficuly. -When setting Attributes this way, you are severely limited in what can be stored - this is because +When setting Attributes this way, you are severely limited in what can be stored - this is because giving players (even builders) the ability to store arbitrary Python would be a severe security -problem. +problem. -In game you can set an Attribute like this: +In game you can set an Attribute like this: set myobj/foo = "bar" -To view, do +To view, do - set myobj/foo + set myobj/foo -or see them together with all object-info with +or see them together with all object-info with examine myobj -The first `set`-example will store a new Attribute `foo` on the object `myobj` and give it the +The first `set`-example will store a new Attribute `foo` on the object `myobj` and give it the value "bar". -You can store numbers, booleans, strings, tuples, lists and dicts this way. But if +You can store numbers, booleans, strings, tuples, lists and dicts this way. But if you store a list/tuple/dict they must be proper Python structures and may _only_ contain strings -or numbers. If you try to insert an unsupported structure, the input will be converted to a +or numbers. If you try to insert an unsupported structure, the input will be converted to a string. set myobj/mybool = True @@ -263,8 +263,8 @@ For the last line you'll get a warning and the value instead will be saved as a ## Locking and checking Attributes -While the `set` command is limited to builders, individual Attributes are usually not -locked down. You may want to lock certain sensitive Attributes, in particular for games +While the `set` command is limited to builders, individual Attributes are usually not +locked down. You may want to lock certain sensitive Attributes, in particular for games where you allow player building. You can add such limitations by adding a [lock string](./Locks.md) to your Attribute. A NAttribute have no locks. @@ -273,7 +273,7 @@ The relevant lock types are - `attrread` - limits who may read the value of the Attribute - `attredit` - limits who may set/change this Attribute -You must use the `AttributeHandler` to assign the lockstring to the Attribute: +You must use the `AttributeHandler` to assign the lockstring to the Attribute: ```python lockstring = "attread:all();attredit:perm(Admins)" @@ -281,7 +281,7 @@ obj.attributes.add("myattr", "bar", lockstring=lockstring)" ``` If you already have an Attribute and want to add a lock in-place you can do so -by having the `AttributeHandler` return the `Attribute` object itself (rather than +by having the `AttributeHandler` return the `Attribute` object itself (rather than its value) and then assign the lock to it directly: ```python @@ -293,8 +293,8 @@ Note the `return_obj` keyword which makes sure to return the `Attribute` object could be accessed. A lock is no good if nothing checks it -- and by default Evennia does not check locks on Attributes. -To check the `lockstring` you provided, make sure you include `accessing_obj` and set -`default_access=False` as you make a `get` call. +To check the `lockstring` you provided, make sure you include `accessing_obj` and set +`default_access=False` as you make a `get` call. ```python # in some command code where we want to limit @@ -328,13 +328,13 @@ values into a string representation before storing it to the database. This is d 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 wil convert them to an internal representation using their classname, - database-id and creation-date with a microsecond precision. When retrieving, the object +* 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. -* To convert the database object, Evennia must know it's there. If you *hide* a database object - inside a non-iterable class, you will run into errors - this is not supported! +* 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). ```{code-block} python :caption: Valid assignments @@ -345,16 +345,55 @@ obj.db.test1 = False # a database object (will be stored as an internal representation) obj.db.test2 = myobj ``` + +As mentioned, Evennia will not be able to automatically serialize db-objects +'hidden' in arbitrary properties on an object. This will lead to an error +when saving the Attribute. + ```{code-block} python :caption: Invalid, 'hidden' dbobject - -# example of an invalid, "hidden" dbobject +# example of storing an invalid, "hidden" dbobject in Attribute class Container: def __init__(self, mydbobj): # no way for Evennia to know this is a database object! self.mydbobj = mydbobj + +# let's assume myobj is a db-object container = Container(myobj) -obj.db.invalid = container # will cause error! +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 +serialize the db-objects you want to store. + +```{code-block} python +:caption: Fixing an invalid 'hidden' dbobj for storing in Attribute + +from evennia.utils import dbserialize # important + +class Container: + def __init__(self, mydbobj): + # A 'hidden' db-object + self.mydbobj = mydbobj + + def __serialize_dbobjs__(self): + """This is called before serialization and allows + us to custom-handle those 'hidden' dbobjs""" + self.mydbobj = dbserialize.dbserialize(self.mydbobj + + 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) + +# let's assume myobj is a db-object +container = Container(myobj) +obj.db.mydata = container # will now work fine! ``` ### Storing multiple objects @@ -404,6 +443,12 @@ obj.db.test8[2]["test"] = 5 # test8 is now [4,2,{"test":5}] ``` +Note that if make some advanced iterable object, and store an db-object on it in +a way such that it is _not_ returned by iterating over it, you have created a +'hidden' db-object. See [the previous section](#storing-single-objects) for how +to tell Evennia how to serialize such hidden objects safely. + + ### Retrieving Mutable objects A side effect of the way Evennia stores Attributes is that *mutable* iterables (iterables that can @@ -429,41 +474,41 @@ print(obj.db.mylist) # now also [1, 2, 3, 5] ``` When you extract your mutable Attribute data into a variable like `mylist`, think of it as getting a _snapshot_ -of the variable. If you update the snapshot, it will save to the database, but this change _will not propagate to +of the variable. If you update the snapshot, it will save to the database, but this change _will not propagate to any other snapshots you may have done previously_. -```python +```python obj.db.mylist = [1, 2, 3, 4] -mylist1 = obj.db.mylist -mylist2 = obj.db.mylist -mylist1[3] = 5 +mylist1 = obj.db.mylist +mylist2 = obj.db.mylist +mylist1[3] = 5 print(mylist1) # this is now [1, 2, 3, 5] -print(obj.db.mylist) # also updated to [1, 2, 3, 5] +print(obj.db.mylist) # also updated to [1, 2, 3, 5] -print(mylist2) # still [1, 2, 3, 4] ! +print(mylist2) # still [1, 2, 3, 4] ! ``` ```{sidebar} Remember, the complexities of this section only relate to *mutable* iterables - things you can update -in-place, like lists and dicts. [Immutable](https://en.wikipedia.org/wiki/Immutable) objects (strings, +in-place, like lists and dicts. [Immutable](https://en.wikipedia.org/wiki/Immutable) objects (strings, numbers, tuples etc) are already disconnected from the database from the onset. ``` -To avoid confusion with mutable Attributes, only work with one variable (snapshot) at a time and save -back the results as needed. +To avoid confusion with mutable Attributes, only work with one variable (snapshot) at a time and save +back the results as needed. You can also choose to "disconnect" the Attribute entirely from the database with the help of the `.deserialize()` method: ```python obj.db.mylist = [1, 2, 3, 4, {1: 2}] -mylist = obj.db.mylist.deserialize() +mylist = obj.db.mylist.deserialize() ``` The result of this operation will be a structure only consisting of normal Python mutables (`list` -instead of `_SaverList`, `dict` instead of `_SaverDict` and so on). If you update it, you need to +instead of `_SaverList`, `dict` instead of `_SaverDict` and so on). If you update it, you need to explicitly save it back to the Attribute for it to save. ## Properties of Attributes @@ -518,7 +563,7 @@ are **non-persistent** - they will _not_ survive a server reload. Differences between `Attributes` and `NAttributes`: - `NAttribute`s are always wiped on a server reload. -- They only exist in memory and never involve the database at all, making them faster to +- They only exist in memory and never involve the database at all, making them faster to access and edit than `Attribute`s. - `NAttribute`s can store _any_ Python structure (and database object) without limit. - They can _not_ be set with the standard `set` command (but they are visible with `examine`) @@ -526,10 +571,10 @@ Differences between `Attributes` and `NAttributes`: There are some important reasons we recommend using `ndb` to store temporary data rather than the simple alternative of just storing a variable directly on an object: -- NAttributes are tracked by Evennia and will not be purged in various cache-cleanup operations - the server may do. So using them guarantees that they'll remain available at least as long as +- NAttributes are tracked by Evennia and will not be purged in various cache-cleanup operations + the server may do. So using them guarantees that they'll remain available at least as long as the server lives. -- It's a consistent style - `.db/.attributes` and `.ndb/.nattributes` makes for clean-looking code +- It's a consistent style - `.db/.attributes` and `.ndb/.nattributes` makes for clean-looking code where it's clear how long-lived (or not) your data is to be. ### Persistent vs non-persistent @@ -557,4 +602,4 @@ useful in a few situations though. - `NAttribute`s have no restrictions at all on what they can store, since they don't need to worry about being saved to the database - they work very well for temporary storage. - You want to implement a fully or partly *non-persistent world*. Who are we to argue with your - grand vision! \ No newline at end of file + grand vision! diff --git a/docs/source/Links.md b/docs/source/Links.md index ed9cdb5714..514f225a92 100644 --- a/docs/source/Links.md +++ b/docs/source/Links.md @@ -129,10 +129,11 @@ Contains a very useful list of things to think about when starting your new MUD. Essential reading for the design of any persistent game world, written by the co-creator of the original game *MUD*. Published in 2003 but it's still as relevant now as when it came out. Covers everything you need to know and then some. -- Zed A. Shaw *Learn Python the Hard way* ([homepage](https://learnpythonthehardway.org/)) - Despite - the imposing name this book is for the absolute Python/programming beginner. One learns the language - by gradually creating a small text game! It has been used by multiple users before moving on to - Evennia. *Update: This used to be free to read online, this is no longer the case.* + + When the rights to Designing Virtual Worlds returned to him, Richard Bartle + made the PDF of his Designing Virtual Worlds freely available through his own + website ([Designing Virtual Worlds](https://mud.co.uk/dvw/)). A direct link to + the PDF can be found [here](https://mud.co.uk/richard/DesigningVirtualWorlds.pdf). - David M. Beazley *Python Essential Reference (4th ed)* ([amazon page](https://www.amazon.com/Python-Essential-Reference-David-Beazley/dp/0672329786/)) - Our recommended book on Python; it not only efficiently summarizes the language but is also diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 9f0f6359cc..e8d3b6a5b6 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2732,7 +2732,7 @@ class CmdExamine(ObjManipCommand): return if ndb_attr and ndb_attr[0]: - return "\n " + " \n".join( + return "\n " + "\n ".join( sorted(self.format_single_attribute(attr) for attr in ndb_attr) ) diff --git a/evennia/contrib/base_systems/custom_gametime/custom_gametime.py b/evennia/contrib/base_systems/custom_gametime/custom_gametime.py index 5611580187..33588deca9 100644 --- a/evennia/contrib/base_systems/custom_gametime/custom_gametime.py +++ b/evennia/contrib/base_systems/custom_gametime/custom_gametime.py @@ -328,4 +328,4 @@ class GametimeScript(DefaultScript): callback() seconds = real_seconds_until(**self.db.gametime) - self.restart(interval=seconds) + self.start(interval=seconds,force_restart=True) diff --git a/evennia/contrib/rpg/rpsystem/__init__.py b/evennia/contrib/rpg/rpsystem/__init__.py index 9affd32487..395aef660b 100644 --- a/evennia/contrib/rpg/rpsystem/__init__.py +++ b/evennia/contrib/rpg/rpsystem/__init__.py @@ -4,7 +4,6 @@ Roleplaying emotes and language - Griatch, 2015 """ from .rpsystem import EmoteError, SdescError, RecogError, LanguageError # noqa -from .rpsystem import ordered_permutation_regex, regex_tuple_from_key_alias # noqa from .rpsystem import parse_language, parse_sdescs_and_recogs, send_emote # noqa from .rpsystem import SdescHandler, RecogHandler # noqa from .rpsystem import RPCommand, CmdEmote, CmdSay, CmdSdesc, CmdPose, CmdRecog, CmdMask # noqa diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index 2c431280c0..c191d72d93 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -53,7 +53,7 @@ Add `RPSystemCmdSet` from this module to your CharacterCmdSet: # ... -from evennia.contrib.rpg.rpsystem import RPSystemCmdSet <--- +from evennia.contrib.rpg.rpsystem.rpsystem import RPSystemCmdSet <--- class CharacterCmdSet(default_cmds.CharacterCmdset): # ... @@ -69,7 +69,7 @@ the typeclasses in this module: ```python # in mygame/typeclasses/characters.py -from evennia.contrib.rpg import ContribRPCharacter +from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPCharacter class Character(ContribRPCharacter): # ... @@ -79,7 +79,7 @@ class Character(ContribRPCharacter): ```python # in mygame/typeclasses/objects.py -from evennia.contrib.rpg import ContribRPObject +from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPObject class Object(ContribRPObject): # ... @@ -89,7 +89,7 @@ class Object(ContribRPObject): ```python # in mygame/typeclasses/rooms.py -from evennia.contrib.rpg import ContribRPRoom +from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPRoom class Room(ContribRPRoom): # ... @@ -125,7 +125,7 @@ Extra Installation Instructions: 1. In typeclasses/character.py: Import the `ContribRPCharacter` class: - `from evennia.contrib.rpg.rpsystem import ContribRPCharacter` + `from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPCharacter` Inherit ContribRPCharacter: Change "class Character(DefaultCharacter):" to `class Character(ContribRPCharacter):` @@ -133,13 +133,13 @@ Extra Installation Instructions: Add `super().at_object_creation()` as the top line. 2. In `typeclasses/rooms.py`: Import the `ContribRPRoom` class: - `from evennia.contrib.rpg.rpsystem import ContribRPRoom` + `from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPRoom` Inherit `ContribRPRoom`: Change `class Room(DefaultRoom):` to `class Room(ContribRPRoom):` 3. In `typeclasses/objects.py` Import the `ContribRPObject` class: - `from evennia.contrib.rpg.rpsystem import ContribRPObject` + `from evennia.contrib.rpg.rpsystem.rpsystem import ContribRPObject` Inherit `ContribRPObject`: Change `class Object(DefaultObject):` to `class Object(ContribRPObject):` @@ -149,18 +149,15 @@ Extra Installation Instructions: """ import re -from re import escape as re_escape -import itertools +from string import punctuation from django.conf import settings from evennia.objects.objects import DefaultObject, DefaultCharacter from evennia.objects.models import ObjectDB from evennia.commands.command import Command from evennia.commands.cmdset import CmdSet -from evennia.utils import ansi +from evennia.utils import ansi, logger from evennia.utils.utils import lazy_property, make_iter, variable_from_module -_REGEX_TUPLE_CACHE = {} - _AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1)) # ------------------------------------------------------------ # Emote parser @@ -189,13 +186,13 @@ _EMOTE_MULTIMATCH_ERROR = """|RMultiple possibilities for {ref}: _RE_FLAGS = re.MULTILINE + re.IGNORECASE + re.UNICODE -_RE_PREFIX = re.compile(r"^%s" % _PREFIX, re.UNICODE) +_RE_PREFIX = re.compile(rf"^{_PREFIX}", re.UNICODE) # This regex will return groups (num, word), where num is an optional counter to # separate multimatches from one another and word is the first word in the # marker. So entering "/tall man" will return groups ("", "tall") # and "/2-tall man" will return groups ("2", "tall"). -_RE_OBJ_REF_START = re.compile(r"%s(?:([0-9]+)%s)*(\w+)" % (_PREFIX, _NUM_SEP), _RE_FLAGS) +_RE_OBJ_REF_START = re.compile(rf"{_PREFIX}(?:([0-9]+){_NUM_SEP})*(\w+)", _RE_FLAGS) _RE_LEFT_BRACKETS = re.compile(r"\{+", _RE_FLAGS) _RE_RIGHT_BRACKETS = re.compile(r"\}+", _RE_FLAGS) @@ -239,97 +236,7 @@ class LanguageError(Exception): pass -def _dummy_process(text, *args, **kwargs): - "Pass-through processor" - return text - - # emoting mechanisms - - -def ordered_permutation_regex(sentence): - """ - Builds a regex that matches 'ordered permutations' of a sentence's - words. - - Args: - sentence (str): The sentence to build a match pattern to - - Returns: - regex (re object): Compiled regex object represented the - possible ordered permutations of the sentence, from longest to - shortest. - Example: - The sdesc_regex for an sdesc of " very tall man" will - result in the following allowed permutations, - regex-matched in inverse order of length (case-insensitive): - "the very tall man", "the very tall", "very tall man", - "very tall", "the very", "tall man", "the", "very", "tall", - and "man". - We also add regex to make sure it also accepts num-specifiers, - like /2-tall. - - """ - # escape {#nnn} markers from sentence, replace with nnn - sentence = _RE_REF.sub(r"\1", sentence) - # escape {##nnn} markers, replace with nnn - sentence = _RE_REF_LANG.sub(r"\1", sentence) - # escape self-ref marker from sentence - sentence = _RE_SELF_REF.sub(r"", sentence) - - # ordered permutation algorithm - words = sentence.split() - combinations = itertools.product((True, False), repeat=len(words)) - solution = [] - for combination in combinations: - comb = [] - for iword, word in enumerate(words): - if combination[iword]: - comb.append(word) - elif comb: - break - if comb: - solution.append( - _PREFIX - + r"[0-9]*%s*%s(?=\W|$)+" % (_NUM_SEP, re_escape(" ".join(comb)).rstrip("\\")) - ) - - # combine into a match regex, first matching the longest down to the shortest components - regex = r"|".join(sorted(set(solution), key=lambda item: (-len(item), item))) - return regex - - -def regex_tuple_from_key_alias(obj): - """ - This will build a regex tuple for any object, not just from those - with sdesc/recog handlers. It's used as a legacy mechanism for - being able to mix this contrib with objects not using sdescs, but - note that creating the ordered permutation regex dynamically for - every object will add computational overhead. - - Args: - obj (Object): This object's key and eventual aliases will - be used to build the tuple. - - Returns: - regex_tuple (tuple): A tuple - (ordered_permutation_regex, obj, key/alias) - - - """ - global _REGEX_TUPLE_CACHE - permutation_string = " ".join([obj.key] + obj.aliases.all()) - cache_key = f"{obj.id} {permutation_string}" - - if cache_key not in _REGEX_TUPLE_CACHE: - _REGEX_TUPLE_CACHE[cache_key] = ( - re.compile(ordered_permutation_regex(permutation_string), _RE_FLAGS), - obj, - obj.key, - ) - return _REGEX_TUPLE_CACHE[cache_key] - - def parse_language(speaker, emote): """ Parse the emote for language. This is @@ -375,9 +282,9 @@ def parse_language(speaker, emote): langname, saytext = say_match.groups() istart, iend = say_match.start(), say_match.end() # the key is simply the running match in the emote - key = "##%i" % imatch + key = f"##{imatch}" # replace say with ref markers in emote - emote = emote[:istart] + "{%s}" % key + emote[iend:] + emote = "{start}{{{key}}}{end}".format( start=emote[:istart], key=key, end=emote[iend:] ) mapping[key] = (langname, saytext) if errors: @@ -430,24 +337,20 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_ - says, "..." are """ - # Load all candidate regex tuples [(regex, obj, sdesc/recog),...] - candidate_regexes = ( - ([(_RE_SELF_REF, sender, sender.sdesc.get())] if hasattr(sender, "sdesc") else []) - + ( - [sender.recog.get_regex_tuple(obj) for obj in candidates] - if hasattr(sender, "recog") - else [] - ) - + [obj.sdesc.get_regex_tuple() for obj in candidates if hasattr(obj, "sdesc")] - + [ - regex_tuple_from_key_alias(obj) # handle objects without sdescs - for obj in candidates - if not (hasattr(obj, "recog") and hasattr(obj, "sdesc")) - ] - ) - - # filter out non-found data - candidate_regexes = [tup for tup in candidate_regexes if tup] + # build a list of candidates with all possible referrable names + # include 'me' keyword for self-ref + candidate_map = [(sender, 'me')] + for obj in candidates: + # check if sender has any recogs for obj and add + if hasattr(sender, "recog"): + if recog := sender.recog.get(obj): + candidate_map.append((obj, recog)) + # check if obj has an sdesc and add + if hasattr(obj, "sdesc"): + candidate_map.append((obj, obj.sdesc.get())) + # if no sdesc, include key plus aliases instead + else: + candidate_map.extend( [(obj, obj.key)] + [(obj, alias) for alias in obj.aliases.all()] ) # escape mapping syntax on the form {#id} if it exists already in emote, # if so it is replaced with just "id". @@ -468,18 +371,48 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_ # first see if there is a number given (e.g. 1-tall) num_identifier, _ = marker_match.groups("") # return "" if no match, rather than None - istart0 = marker_match.start() - istart = istart0 + 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:] + + if search_mode: + # match the candidates against the whole search string after the marker + rquery = "".join([r"\b(" + re.escape(word.strip(punctuation)) + r").*" for word in iter(tail.split())]) + matches = ((re.search(rquery, text, _RE_FLAGS), obj, text) for obj, text in candidate_map) + # filter out any non-matching candidates + bestmatches = [(obj, match.group()) for match, obj, text in matches if match] - # loop over all candidate regexes and match against the string following the match - matches = ((reg.match(string[istart:]), obj, text) for reg, obj, text in candidate_regexes) + else: + # to find the longest match, we start from the marker and lengthen the + # match query one word at a time. + word_list = [] + bestmatches = [] + # preserve punctuation when splitting + tail = re.split('(\W)', tail) + iend = 0 + for i, item in enumerate(tail): + # don't add non-word characters to the search query + if not item.isalpha(): + continue + word_list.append(item) + rquery = "".join([r"\b(" + re.escape(word) + r").*" for word in word_list]) + # match candidates against the current set of words + matches = ((re.search(rquery, text, _RE_FLAGS), obj, text) for obj, text in candidate_map) + matches = [(obj, match.group()) for match, obj, text in matches if match] + if len(matches) == 0: + # no matches at this length, keep previous iteration as best + break + # since this is the longest match so far, set latest match set as best matches + bestmatches = matches + # save current index as end point of matched text + iend = i - # score matches by how long part of the string was matched - matches = [(match.end() if match else -1, obj, text) for match, obj, text in matches] - maxscore = max(score for score, obj, text in matches) + # save search string + matched_text = "".join(tail[1:iend]) + # recombine remainder of emote back into a string + tail = "".join(tail[iend+1:]) - # we have a valid maxscore, extract all matches with this value - bestmatches = [(obj, text) for score, obj, text in matches if maxscore == score != -1] nmatches = len(bestmatches) if not nmatches: @@ -488,12 +421,11 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_ nmatches = 0 elif nmatches == 1: # an exact match. - obj = bestmatches[0][0] - nmatches = 1 + obj, match_str = bestmatches[0] elif all(bestmatches[0][0].id == obj.id for obj, text in bestmatches): # multi-match but all matches actually reference the same # obj (could happen with clashing recogs + sdescs) - obj = bestmatches[0][0] + obj, match_str = bestmatches[0] nmatches = 1 else: # multi-match. @@ -501,7 +433,7 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_ inum = min(max(0, int(num_identifier) - 1), nmatches - 1) if num_identifier else None if inum is not None: # A valid inum is given. Use this to separate data. - obj = bestmatches[inum][0] + obj, match_str = bestmatches[inum] nmatches = 1 else: # no identifier given - a real multimatch. @@ -519,35 +451,32 @@ def parse_sdescs_and_recogs(sender, candidates, string, search_mode=False, case_ # case sensitive mode # internal flags for the case used for the original /query # - t for titled input (like /Name) - # - ^ for all upercase input (likle /NAME) + # - ^ for all upercase input (like /NAME) # - v for lower-case input (like /name) # - ~ for mixed case input (like /nAmE) - matchtext = marker_match.group() - if not _RE_SELF_REF.match(matchtext): - # self-refs are kept as-is, others are parsed by case - matchtext = marker_match.group().lstrip(_PREFIX) - if matchtext.istitle(): - case = "t" - elif matchtext.isupper(): - case = "^" - elif matchtext.islower(): - case = "v" + matchtext = marker_match.group().lstrip(_PREFIX) + if matchtext.istitle(): + case = "t" + elif matchtext.isupper(): + case = "^" + elif matchtext.islower(): + case = "v" - key = "#%i%s" % (obj.id, case) - string = string[:istart0] + "{%s}" % key + string[istart + maxscore :] + key = f"#{obj.id}{case}" + # recombine emote with matched text replaced by ref + string = f"{head}{{{key}}}{tail}" mapping[key] = obj else: # multimatch error refname = marker_match.group() reflist = [ - "%s%s%s (%s%s)" - % ( - inum + 1, - _NUM_SEP, - _RE_PREFIX.sub("", refname), - text, - " (%s)" % sender.key if sender == ob else "", + "{num}{sep}{name} ({text}{key})".format( + num=inum + 1, + sep=_NUM_SEP, + name=_RE_PREFIX.sub("", refname), + text=text, + key=f" ({sender.key})" if sender == ob else "", ) for inum, (ob, text) in enumerate(obj) ] @@ -611,7 +540,7 @@ def send_emote(sender, receivers, emote, anonymous_add="first", **kwargs): sender.msg(str(err)) return - skey = "#%i" % sender.id + skey = f"#{sender.id}" # we escape the object mappings since we'll do the language ones first # (the text could have nested object mappings). @@ -619,66 +548,45 @@ def send_emote(sender, receivers, emote, anonymous_add="first", **kwargs): # if anonymous_add is passed as a kwarg, collect and remove it from kwargs if "anonymous_add" in kwargs: anonymous_add = kwargs.pop("anonymous_add") - if anonymous_add and not any(1 for tag in obj_mapping if tag.startswith(skey)): - # no self-reference in the emote - add to the end - obj_mapping[skey] = sender + # make sure to catch all possible self-refs + self_refs = [f"{skey}{ref}" for ref in ('t','^','v','~','')] + if anonymous_add and not any(1 for tag in obj_mapping if tag in self_refs): + # no self-reference in the emote - add it if anonymous_add == "first": - possessive = "" if emote.startswith("'") else " " - emote = "%s%s%s" % ("{{%s}}" % skey, possessive, emote) + # add case flag for initial caps + skey += 't' + # don't put a space after the self-ref if it's a possessive emote + femote = "{key}{emote}" if emote.startswith("'") else "{key} {emote}" else: - emote = "%s [%s]" % (emote, "{{%s}}" % skey) + # add it to the end + femote = "{emote} [{key}]" + emote = femote.format( key="{{"+ skey +"}}", emote=emote ) + obj_mapping[skey] = sender # broadcast emote to everyone for receiver in receivers: # first handle the language mapping, which always produce different keys ##nn - receiver_lang_mapping = {} - try: - process_language = receiver.process_language - except AttributeError: - process_language = _dummy_process - for key, (langname, saytext) in language_mapping.items(): - # color says - receiver_lang_mapping[key] = process_language(saytext, sender, langname) + if hasattr(receiver, "process_language") and callable(receiver.process_language): + receiver_lang_mapping = { + key: receiver.process_language(saytext, sender, langname) + for key, (langname, saytext) in language_mapping.items() + } + else: + receiver_lang_mapping = { + key: saytext for key, (langname, saytext) in language_mapping.items() + } # map the language {##num} markers. This will convert the escaped sdesc markers on # the form {{#num}} to {#num} markers ready to sdesc-map in the next step. sendemote = emote.format(**receiver_lang_mapping) - # handle sdesc mappings. we make a temporary copy that we can modify - try: - process_sdesc = receiver.process_sdesc - except AttributeError: - process_sdesc = _dummy_process - - try: - process_recog = receiver.process_recog - except AttributeError: - process_recog = _dummy_process - - try: - recog_get = receiver.recog.get - receiver_sdesc_mapping = dict( - (ref, process_recog(recog_get(obj), obj, ref=ref, **kwargs)) - for ref, obj in obj_mapping.items() - ) - except AttributeError: - receiver_sdesc_mapping = dict( - ( - ref, - process_sdesc(obj.sdesc.get(), obj, ref=ref) - if hasattr(obj, "sdesc") - else process_sdesc(obj.key, obj, ref=ref), - ) - for ref, obj in obj_mapping.items() - ) - # make sure receiver always sees their real name - rkey_start = "#%i" % receiver.id - rkey_keep_case = rkey_start + "~" # signifies keeping the case - for rkey in (key for key in receiver_sdesc_mapping if key.startswith(rkey_start)): - # we could have #%i^, #%it etc depending on input case - we want the - # self-reference to retain case. - receiver_sdesc_mapping[rkey] = process_sdesc( - receiver.key, receiver, ref=rkey_keep_case, **kwargs + # map the ref keys to sdescs + receiver_sdesc_mapping = dict( + ( + ref, + obj.get_display_name(receiver, ref=ref, noid=True), ) + for ref, obj in obj_mapping.items() + ) # do the template replacement of the sdesc/recog {#num} markers receiver.msg(sendemote.format(**receiver_sdesc_mapping), from_obj=sender, **kwargs) @@ -713,17 +621,13 @@ class SdescHandler: """ self.obj = obj self.sdesc = "" - self.sdesc_regex = "" self._cache() def _cache(self): """ Cache data from storage - """ - self.sdesc = self.obj.attributes.get("_sdesc", default="") - sdesc_regex = self.obj.attributes.get("_sdesc_regex", default="") - self.sdesc_regex = re.compile(sdesc_regex, _RE_FLAGS) + self.sdesc = self.obj.attributes.get("_sdesc", default=self.obj.key) def add(self, sdesc, max_length=60): """ @@ -759,17 +663,13 @@ class SdescHandler: if len(cleaned_sdesc) > max_length: raise SdescError( - "Short desc can max be %i chars long (was %i chars)." - % (max_length, len(cleaned_sdesc)) + "Short desc can max be {} chars long (was {} chars).".format(max_length, len(cleaned_sdesc)) ) # store to attributes - sdesc_regex = ordered_permutation_regex(cleaned_sdesc) self.obj.attributes.add("_sdesc", sdesc) - self.obj.attributes.add("_sdesc_regex", sdesc_regex) # local caching self.sdesc = sdesc - self.sdesc_regex = re.compile(sdesc_regex, _RE_FLAGS) return sdesc @@ -781,15 +681,6 @@ class SdescHandler: """ return self.sdesc or self.obj.key - def get_regex_tuple(self): - """ - Return data for sdesc/recog handling - - Returns: - tup (tuple): tuple (sdesc_regex, obj, sdesc) - - """ - return self.sdesc_regex, self.obj, self.sdesc class RecogHandler: @@ -802,7 +693,6 @@ class RecogHandler: _recog_ref2recog _recog_obj2recog - _recog_obj2regex """ @@ -817,7 +707,6 @@ class RecogHandler: self.obj = obj # mappings self.ref2recog = {} - self.obj2regex = {} self.obj2recog = {} self._cache() @@ -826,11 +715,7 @@ class RecogHandler: Load data to handler cache """ self.ref2recog = self.obj.attributes.get("_recog_ref2recog", default={}) - obj2regex = self.obj.attributes.get("_recog_obj2regex", default={}) obj2recog = self.obj.attributes.get("_recog_obj2recog", default={}) - self.obj2regex = dict( - (obj, re.compile(regex, _RE_FLAGS)) for obj, regex in obj2regex.items() if obj - ) self.obj2recog = dict((obj, recog) for obj, recog in obj2recog.items() if obj) def add(self, obj, recog, max_length=60): @@ -873,31 +758,27 @@ class RecogHandler: if len(cleaned_recog) > max_length: raise RecogError( - "Recog string cannot be longer than %i chars (was %i chars)" - % (max_length, len(cleaned_recog)) + "Recog string cannot be longer than {} chars (was {} chars)".format(max_length, len(cleaned_recog)) ) # mapping #dbref:obj - key = "#%i" % obj.id + key = f"#{obj.id}" self.obj.attributes.get("_recog_ref2recog", default={})[key] = recog self.obj.attributes.get("_recog_obj2recog", default={})[obj] = recog - regex = ordered_permutation_regex(cleaned_recog) - self.obj.attributes.get("_recog_obj2regex", default={})[obj] = regex # local caching self.ref2recog[key] = recog self.obj2recog[obj] = recog - self.obj2regex[obj] = re.compile(regex, _RE_FLAGS) return recog def get(self, obj): """ - Get recog replacement string, if one exists, otherwise - get sdesc and as a last resort, the object's key. + Get recog replacement string, if one exists. Args: obj (Object): The object, whose sdesc to replace Returns: - recog (str): The replacement string to use. + recog (str or None): The replacement string to use, or + None if there is no recog for this object. Notes: This method will respect a "enable_recog" lock set on @@ -908,10 +789,10 @@ class RecogHandler: # check an eventual recog_masked lock on the object # to avoid revealing masked characters. If lock # does not exist, pass automatically. - return self.obj2recog.get(obj, obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key) + return self.obj2recog.get(obj, None) else: - # recog_mask log not passed, disable recog - return obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key + # recog_mask lock not passed, disable recog + return None def all(self): """ @@ -932,19 +813,9 @@ class RecogHandler: """ if obj in self.obj2recog: del self.obj.db._recog_obj2recog[obj] - del self.obj.db._recog_obj2regex[obj] - del self.obj.db._recog_ref2recog["#%i" % obj.id] + del self.obj.db._recog_ref2recog[f"#{obj.id}"] self._cache() - def get_regex_tuple(self, obj): - """ - Returns: - rec (tuple): Tuple (recog_regex, obj, recog) - """ - if obj in self.obj2recog and obj.access(self.obj, "enable_recog", default=True): - return self.obj2regex[obj], obj, self.obj2regex[obj] - return None - # ------------------------------------------------------------ # RP Commands @@ -994,8 +865,8 @@ class CmdEmote(RPCommand): # replaces the main emote # we also include ourselves here. emote = self.args targets = self.caller.location.contents - if not emote.endswith((".", "?", "!")): # If emote is not punctuated, - emote = "%s." % emote # add a full-stop for good measure. + if not emote.endswith((".", "?", "!", '"')): # If emote is not punctuated or speech, + emote += "." # add a full-stop for good measure. send_emote(self.caller, targets, emote, anonymous_add="first") @@ -1025,7 +896,6 @@ class CmdSay(RPCommand): # replaces standard say # calling the speech modifying hook speech = caller.at_pre_say(self.args) - # preparing the speech with sdesc/speech parsing. targets = self.caller.location.contents send_emote(self.caller, targets, speech, anonymous_add=None) @@ -1061,7 +931,7 @@ class CmdSdesc(RPCommand): # set/look at own sdesc except AttributeError: caller.msg(f"Cannot set sdesc on {caller.key}.") return - caller.msg("%s's sdesc was set to '%s'." % (caller.key, sdesc)) + caller.msg(f"{caller.key}'s sdesc was set to '{sdesc}'.") class CmdPose(RPCommand): # set current pose and default pose @@ -1121,8 +991,8 @@ class CmdPose(RPCommand): # set current pose and default pose caller.msg("Usage: pose OR pose obj = ") return - if not pose.endswith("."): - pose = "%s." % pose + if not pose.endswith((".", "?", "!", '"')): + pose += "." if target: # affect something else target = caller.search(target) @@ -1134,18 +1004,18 @@ class CmdPose(RPCommand): # set current pose and default pose else: target = caller + target_name = target.sdesc.get() if hasattr(target, "sdesc") else target.key if not target.attributes.has("pose"): - caller.msg("%s cannot be posed." % target.key) + caller.msg(f"{target_name} cannot be posed.") return - target_name = target.sdesc.get() if hasattr(target, "sdesc") else target.key # set the pose if self.reset: pose = target.db.pose_default target.db.pose = pose elif self.default: target.db.pose_default = pose - caller.msg("Default pose is now '%s %s'." % (target_name, pose)) + caller.msg(f"Default pose is now '{target_name} {pose}'.") return else: # set the pose. We do one-time ref->sdesc mapping here. @@ -1157,12 +1027,12 @@ class CmdPose(RPCommand): # set current pose and default pose pose = parsed.format(**mapping) if len(target_name) + len(pose) > 60: - caller.msg("Your pose '%s' is too long." % pose) + caller.msg(f"'{pose}' is too long.") return target.db.pose = pose - caller.msg("Pose will read '%s %s'." % (target_name, pose)) + caller.msg(f"Pose will read '{target_name} {pose}'.") class CmdRecog(RPCommand): # assign personal alias to object in room @@ -1241,12 +1111,12 @@ class CmdRecog(RPCommand): # assign personal alias to object in room caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc)) elif nmatches > 1: reflist = [ - "{}{}{} ({}{})".format( - inum + 1, - _NUM_SEP, - _RE_PREFIX.sub("", sdesc), - caller.recog.get(obj), - " (%s)" % caller.key if caller == obj else "", + "{num}{sep}{sdesc} ({recog}{key})".format( + num=inum + 1, + sep=_NUM_SEP, + sdesc=_RE_PREFIX.sub("", sdesc), + recog=caller.recog.get(obj) or "no recog", + key=f" ({caller.key})" if caller == obj else "", ) for inum, obj in enumerate(matches) ] @@ -1262,7 +1132,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room if forget_mode: # remove existing recog caller.recog.remove(obj) - caller.msg("%s will now know them only as '%s'." % (caller.key, obj.recog.get(obj))) + caller.msg("You will now know them only as '{}'.".format( obj.get_display_name(caller, noid=True) )) else: # set recog sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key @@ -1271,7 +1141,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room except RecogError as err: caller.msg(err) return - caller.msg("%s will now remember |w%s|n as |w%s|n." % (caller.key, sdesc, alias)) + caller.msg("You will now remember |w{}|n as |w{}|n.".format(sdesc, alias)) class CmdMask(RPCommand): @@ -1302,14 +1172,14 @@ class CmdMask(RPCommand): caller.msg("You are already wearing a mask.") return sdesc = _RE_CHAREND.sub("", self.args) - sdesc = "%s |H[masked]|n" % sdesc + sdesc = f"{sdesc} |H[masked]|n" if len(sdesc) > 60: caller.msg("Your masked sdesc is too long.") return caller.db.unmasked_sdesc = caller.sdesc.get() caller.locks.add("enable_recog:false()") caller.sdesc.add(sdesc) - caller.msg("You wear a mask as '%s'." % sdesc) + caller.msg(f"You wear a mask as '{sdesc}'.") else: # unmask old_sdesc = caller.db.unmasked_sdesc @@ -1319,7 +1189,7 @@ class CmdMask(RPCommand): del caller.db.unmasked_sdesc caller.locks.remove("enable_recog") caller.sdesc.add(old_sdesc) - caller.msg("You remove your mask and are again '%s'." % old_sdesc) + caller.msg(f"You remove your mask and are again '{old_sdesc}'.") class RPSystemCmdSet(CmdSet): @@ -1346,6 +1216,9 @@ class ContribRPObject(DefaultObject): This class is meant as a mix-in or parent for objects in an rp-heavy game. It implements the base functionality for poses. """ + @lazy_property + def sdesc(self): + return SdescHandler(self) def at_object_creation(self): """ @@ -1357,6 +1230,10 @@ class ContribRPObject(DefaultObject): self.db.pose = "" self.db.pose_default = "is here." + # initializing sdesc + self.db._sdesc = "" + self.sdesc.add("Something") + def search( self, searchdata, @@ -1529,6 +1406,22 @@ class ContribRPObject(DefaultObject): multimatch_string=multimatch_string, ) + def get_posed_sdesc(self, sdesc, **kwargs): + """ + Displays the object with its current pose string. + + Returns: + pose (str): A string containing the object's sdesc and + current or default pose. + """ + + # get the current pose, or default if no pose is set + pose = self.db.pose or self.db.pose_default + + # return formatted string, or sdesc as fallback + return f"{sdesc} {pose}" if pose else sdesc + + def get_display_name(self, looker, **kwargs): """ Displays the name of the object in a viewer-aware manner. @@ -1539,28 +1432,41 @@ class ContribRPObject(DefaultObject): Keyword Args: pose (bool): Include the pose (if available) in the return. + ref (str): The reference marker found in string to replace. + This is on the form #{num}{case}, like '#12^', where + the number is a processing location in the string and the + case symbol indicates the case of the original tag input + - `t` - input was Titled, like /Tall + - `^` - input was all uppercase, like /TALL + - `v` - input was all lowercase, like /tall + - `~` - input case should be kept, or was mixed-case + noid (bool): Don't show DBREF even if viewer has control access. Returns: name (str): A string of the sdesc containing the name of the object, - if this is defined. - including the DBREF if this user is privileged to control - said object. - - Notes: - The RPObject version doesn't add color to its display. + if this is defined. By default, included the DBREF if this user + is privileged to control said object. """ - idstr = "(#%s)" % self.id if self.access(looker, access_type="control") else "" + ref = kwargs.get("ref","~") + if looker == self: + # always show your own key sdesc = self.key else: try: - recog = looker.recog.get(self) + # get the sdesc looker should see + sdesc = looker.get_sdesc(self, ref=ref) except AttributeError: - recog = None - sdesc = recog or (hasattr(self, "sdesc") and self.sdesc.get()) or self.key - pose = " %s" % (self.db.pose or "") if kwargs.get("pose", False) else "" - return "%s%s%s" % (sdesc, idstr, pose) + # use own sdesc as a fallback + sdesc = self.sdesc.get() + + # add dbref is looker has control access and `noid` is not set + if self.access(looker, access_type="control") and not kwargs.get("noid",False): + sdesc = f"{sdesc}(#{self.id})" + + return self.get_posed_sdesc(sdesc) if kwargs.get("pose", False) else sdesc + def return_appearance(self, looker): """ @@ -1569,6 +1475,10 @@ class ContribRPObject(DefaultObject): Args: looker (Object): Object doing the looking. + + Returns: + string (str): A string containing the name, appearance and contents + of the object. """ if not looker: return "" @@ -1592,6 +1502,7 @@ class ContribRPObject(DefaultObject): string += "\n|wExits:|n " + ", ".join(exits) if users or things: string += "\n " + "\n ".join(users + things) + return string @@ -1608,11 +1519,6 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): This is a character class that has poses, sdesc and recog. """ - # Handlers - @lazy_property - def sdesc(self): - return SdescHandler(self) - @lazy_property def recog(self): return RecogHandler(self) @@ -1627,29 +1533,45 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): Keyword Args: pose (bool): Include the pose (if available) in the return. + ref (str): The reference marker found in string to replace. + This is on the form #{num}{case}, like '#12^', where + the number is a processing location in the string and the + case symbol indicates the case of the original tag input + - `t` - input was Titled, like /Tall + - `^` - input was all uppercase, like /TALL + - `v` - input was all lowercase, like /tall + - `~` - input case should be kept, or was mixed-case + noid (bool): Don't show DBREF even if viewer has control access. Returns: name (str): A string of the sdesc containing the name of the object, - if this is defined. - including the DBREF if this user is privileged to control - said object. + if this is defined. By default, included the DBREF if this user + is privileged to control said object. Notes: - The RPCharacter version of this method colors its display to make + The RPCharacter version adds additional processing to sdescs to make characters stand out from other objects. """ - idstr = "(#%s)" % self.id if self.access(looker, access_type="control") else "" + ref = kwargs.get("ref","~") + if looker == self: - sdesc = self.key + # process your key as recog since you recognize yourself + sdesc = self.process_recog(self.key,self) else: try: - recog = looker.recog.get(self) + # get the sdesc looker should see, with formatting + sdesc = looker.get_sdesc(self, process=True, ref=ref) except AttributeError: - recog = None - sdesc = recog or (hasattr(self, "sdesc") and self.sdesc.get()) or self.key - pose = " %s" % (self.db.pose or "is here.") if kwargs.get("pose", False) else "" - return "|c%s|n%s%s" % (sdesc, idstr, pose) + # use own sdesc as a fallback + sdesc = self.sdesc.get() + + # add dbref is looker has control access and `noid` is not set + if self.access(looker, access_type="control") and not kwargs.get("noid",False): + sdesc = f"{sdesc}(#{self.id})" + + return self.get_posed_sdesc(sdesc) if kwargs.get("pose", False) else sdesc + def at_object_creation(self): """ @@ -1658,10 +1580,8 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): super().at_object_creation() self.db._sdesc = "" - self.db._sdesc_regex = "" self.db._recog_ref2recog = {} - self.db._recog_obj2regex = {} self.db._recog_obj2recog = {} self.cmdset.add(RPSystemCmdSet, persistent=True) @@ -1679,8 +1599,38 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): """ if kwargs.get("whisper"): - return f'/me whispers "{message}"' - return f'/me says, "{message}"' + return f'/Me whispers "{message}"' + return f'/Me says, "{message}"' + + def get_sdesc(self, obj, process=False, **kwargs): + """ + Single method to handle getting recogs with sdesc fallback in an + aware manner, to allow separate processing of recogs from sdescs. + Gets the sdesc or recog for obj from the view of self. + + Args: + obj (Object): the object whose sdesc or recog is being gotten + Keyword Args: + process (bool): If True, the sdesc/recog is run through the + appropriate process method for self - .process_sdesc or + .process_recog + """ + # always see own key + if obj == self: + recog = self.key + sdesc = self.key + else: + # first check if we have a recog for this object + recog = self.recog.get(obj) + # set sdesc to recog, using sdesc as a fallback, or the object's key if no sdesc + sdesc = recog or (hasattr(obj, "sdesc") and obj.sdesc.get()) or obj.key + + if process: + # process the sdesc as a recog if a recog was found, else as an sdesc + sdesc = (self.process_recog if recog else self.process_sdesc)(sdesc, obj, **kwargs) + + return sdesc + def process_sdesc(self, sdesc, obj, **kwargs): """ @@ -1721,7 +1671,7 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): sdesc = sdesc.upper() elif "v" in ref: sdesc = sdesc.lower() - return "|b%s|n" % sdesc + return f"|b{sdesc}|n" def process_recog(self, recog, obj, **kwargs): """ @@ -1732,14 +1682,15 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): translated from the original sdesc at this point. obj (Object): The object the recog:ed string belongs to. This is not used by default. - Kwargs: - ref (str): See process_sdesc. Returns: recog (str): The modified recog string. """ - return self.process_sdesc(recog, obj, **kwargs) + if not recog: + return "" + + return f"|m{recog}|n" def process_language(self, text, speaker, language, **kwargs): """ @@ -1762,4 +1713,7 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): the evennia.contrib.rpg.rplanguage module. """ - return "%s|w%s|n" % ("|W(%s)" % language if language else "", text) + return "{label}|w{text}|n".format( + label=f"|W({language})" if language else "", + text=text + ) diff --git a/evennia/contrib/rpg/rpsystem/tests.py b/evennia/contrib/rpg/rpsystem/tests.py index cd96ac43b1..f0d040d6f7 100644 --- a/evennia/contrib/rpg/rpsystem/tests.py +++ b/evennia/contrib/rpg/rpsystem/tests.py @@ -96,7 +96,7 @@ recog01 = "Mr Receiver" 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 /FIRST, /First and /Colliding twice." +case_emote = "/Me looks at /first. Then, /me looks at /FIRST, /First and /Colliding twice." class TestRPSystem(BaseEvenniaTest): @@ -113,41 +113,11 @@ class TestRPSystem(BaseEvenniaTest): rpsystem.ContribRPCharacter, key="Receiver2", location=self.room ) - def test_ordered_permutation_regex(self): - self.assertEqual( - rpsystem.ordered_permutation_regex(sdesc0), - "/[0-9]*-*A\\ nice\\ sender\\ of\\ emotes(?=\\W|$)+|" - "/[0-9]*-*nice\\ sender\\ of\\ emotes(?=\\W|$)+|" - "/[0-9]*-*A\\ nice\\ sender\\ of(?=\\W|$)+|" - "/[0-9]*-*sender\\ of\\ emotes(?=\\W|$)+|" - "/[0-9]*-*nice\\ sender\\ of(?=\\W|$)+|" - "/[0-9]*-*A\\ nice\\ sender(?=\\W|$)+|" - "/[0-9]*-*nice\\ sender(?=\\W|$)+|" - "/[0-9]*-*of\\ emotes(?=\\W|$)+|" - "/[0-9]*-*sender\\ of(?=\\W|$)+|" - "/[0-9]*-*A\\ nice(?=\\W|$)+|" - "/[0-9]*-*emotes(?=\\W|$)+|" - "/[0-9]*-*sender(?=\\W|$)+|" - "/[0-9]*-*nice(?=\\W|$)+|" - "/[0-9]*-*of(?=\\W|$)+|" - "/[0-9]*-*A(?=\\W|$)+", - ) - def test_sdesc_handler(self): self.speaker.sdesc.add(sdesc0) self.assertEqual(self.speaker.sdesc.get(), sdesc0) self.speaker.sdesc.add("This is {#324} ignored") self.assertEqual(self.speaker.sdesc.get(), "This is 324 ignored") - self.speaker.sdesc.add("Testing three words") - self.assertEqual( - self.speaker.sdesc.get_regex_tuple()[0].pattern, - "/[0-9]*-*Testing\ three\ words(?=\W|$)+|" - "/[0-9]*-*Testing\ three(?=\W|$)+|" - "/[0-9]*-*three\ words(?=\W|$)+|" - "/[0-9]*-*Testing(?=\W|$)+|" - "/[0-9]*-*three(?=\W|$)+|" - "/[0-9]*-*words(?=\W|$)+", - ) def test_recog_handler(self): self.speaker.sdesc.add(sdesc0) @@ -156,12 +126,8 @@ class TestRPSystem(BaseEvenniaTest): self.speaker.recog.add(self.receiver2, recog02) self.assertEqual(self.speaker.recog.get(self.receiver1), recog01) self.assertEqual(self.speaker.recog.get(self.receiver2), recog02) - self.assertEqual( - self.speaker.recog.get_regex_tuple(self.receiver1)[0].pattern, - "/[0-9]*-*Mr\\ Receiver(?=\\W|$)+|/[0-9]*-*Receiver(?=\\W|$)+|/[0-9]*-*Mr(?=\\W|$)+", - ) self.speaker.recog.remove(self.receiver1) - self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1) + self.assertEqual(self.speaker.recog.get(self.receiver1), None) self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2}) @@ -198,6 +164,24 @@ class TestRPSystem(BaseEvenniaTest): result, ) + def test_get_sdesc(self): + looker = self.speaker # Sender + target = self.receiver1 # Receiver1 + looker.sdesc.add(sdesc0) # A nice sender of emotes + target.sdesc.add(sdesc1) # The first receiver of emotes. + + # sdesc with no processing + self.assertEqual(looker.get_sdesc(target), "The first receiver of emotes.") + # sdesc with processing + self.assertEqual(looker.get_sdesc(target, process=True), "|bThe first receiver of emotes.|n") + + looker.recog.add(target, recog01) # Mr Receiver + + # recog with no processing + self.assertEqual(looker.get_sdesc(target), "Mr Receiver") + # recog with processing + self.assertEqual(looker.get_sdesc(target, process=True), "|mMr Receiver|n") + def test_send_emote(self): speaker = self.speaker receiver1 = self.receiver1 @@ -212,18 +196,18 @@ class TestRPSystem(BaseEvenniaTest): rpsystem.send_emote(speaker, receivers, emote, case_sensitive=False) self.assertEqual( self.out0, - "With a flair, |bSender|n looks at |bThe first receiver of emotes.|n " + "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, - "With a flair, |bA nice sender of emotes|n looks at |bReceiver1|n and " + "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, "With a flair, |bA nice sender of emotes|n looks at |bThe first " - 'receiver of emotes.|n and |bReceiver2|n. She says |w"This is a test."|n', + 'receiver of emotes.|n and |mReceiver2|n. She says |w"This is a test."|n', ) def test_send_case_sensitive_emote(self): @@ -241,20 +225,21 @@ class TestRPSystem(BaseEvenniaTest): rpsystem.send_emote(speaker, receivers, case_emote) self.assertEqual( self.out0, - "|bSender|n looks at |bthe first receiver of emotes.|n, then " - "|bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of emotes.|n and " - "|bAnother nice colliding sdesc-guy for tests|n twice.", + "|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, - "|bA nice sender of emotes|n looks at |bReceiver1|n, then |bReceiver1|n, " - "|bReceiver1|n and |bAnother nice colliding sdesc-guy for tests|n twice.", + "|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, - "|bA nice sender of emotes|n looks at |bthe first receiver of emotes.|n, " - "then |bTHE FIRST RECEIVER OF EMOTES.|n, |bThe first receiver of " - "emotes.|n and |bReceiver2|n twice.", + "|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.", ) def test_rpsearch(self): @@ -265,18 +250,6 @@ class TestRPSystem(BaseEvenniaTest): self.assertEqual(self.speaker.search("receiver of emotes"), self.receiver1) self.assertEqual(self.speaker.search("colliding"), self.receiver2) - def test_regex_tuple_from_key_alias(self): - self.speaker.aliases.add("foo bar") - self.speaker.aliases.add("this thing is a long thing") - t0 = time.time() - result = rpsystem.regex_tuple_from_key_alias(self.speaker) - t1 = time.time() - result = rpsystem.regex_tuple_from_key_alias(self.speaker) - t2 = time.time() - # print(f"t1: {t1 - t0}, t2: {t2 - t1}") - self.assertLess(t2 - t1, 10**-4) - self.assertEqual(result, (Anything, self.speaker, self.speaker.key)) - class TestRPSystemCommands(BaseEvenniaCommandTest): def setUp(self): @@ -305,7 +278,7 @@ class TestRPSystemCommands(BaseEvenniaCommandTest): self.call( rpsystem.CmdRecog(), "barfoo as friend", - "Char will now remember BarFoo Character as friend.", + "You will now remember BarFoo Character as friend.", ) self.call( rpsystem.CmdRecog(), @@ -316,6 +289,6 @@ class TestRPSystemCommands(BaseEvenniaCommandTest): self.call( rpsystem.CmdRecog(), "friend", - "Char will now know them only as 'BarFoo Character'", + "You will now know them only as 'BarFoo Character'", cmdstring="forget", ) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index c8ad666e23..e5d1130f12 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -286,7 +286,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): categories = make_iter(category) if category else [] n_keys = len(keys) n_categories = len(categories) - unique_categories = sorted(set(categories)) + unique_categories = set(categories) n_unique_categories = len(unique_categories) dbmodel = self.model.__dbclass__.__name__.lower() diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py index aea6ce7518..90c4945898 100644 --- a/evennia/typeclasses/tests.py +++ b/evennia/typeclasses/tests.py @@ -142,6 +142,13 @@ class TestTypedObjectManager(BaseEvenniaTest): [self.obj1], ) + def test_get_tag_with_any_including_nones(self): + self.obj1.tags.add("tagA", "categoryA") + self.assertEqual( + self._manager("get_by_tag", ["tagA", "tagB"], ["categoryA", "categoryB", None], match="any"), + [self.obj1], + ) + def test_get_tag_withnomatch(self): self.obj1.tags.add("tagC", "categoryC") self.assertEqual( diff --git a/evennia/utils/containers.py b/evennia/utils/containers.py index 85678ee03e..ae3236a2b3 100644 --- a/evennia/utils/containers.py +++ b/evennia/utils/containers.py @@ -12,6 +12,7 @@ evennia.OPTION_CLASSES from pickle import dumps +from django.db.utils import OperationalError, ProgrammingError from django.conf import settings from evennia.utils.utils import class_from_module, callables_from_module from evennia.utils import logger @@ -167,7 +168,6 @@ class GlobalScriptContainer(Container): # store a hash representation of the setup script.attributes.add("_global_script_settings", compare_hash, category="settings_hash") - script.start() return script @@ -183,9 +183,16 @@ class GlobalScriptContainer(Container): # populate self.typeclass_storage self.load_data() - # start registered scripts + # make sure settings-defined scripts are loaded for key in self.loaded_data: self._load_script(key) + # start all global scripts + try: + for script in self._get_scripts(): + script.start() + except (OperationalError, ProgrammingError): + # this can happen if db is not loaded yet (such as when building docs) + pass def load_data(self): """ diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py index ce6743bc5b..a12f986e04 100644 --- a/evennia/utils/dbserialize.py +++ b/evennia/utils/dbserialize.py @@ -239,6 +239,9 @@ class _SaverMutable(object): def __gt__(self, other): return self._data > other + def __or__(self, other): + return self._data | other + @_save def __setitem__(self, key, value): self._data.__setitem__(key, self._convert_mutables(value)) @@ -450,7 +453,9 @@ def deserialize(obj): elif tname in ("_SaverOrderedDict", "OrderedDict"): return OrderedDict([(_iter(key), _iter(val)) for key, val in obj.items()]) elif tname in ("_SaverDefaultDict", "defaultdict"): - return defaultdict(obj.default_factory, {_iter(key): _iter(val) for key, val in obj.items()}) + return defaultdict( + obj.default_factory, {_iter(key): _iter(val) for key, val in obj.items()} + ) elif tname in _DESERIALIZE_MAPPING: return _DESERIALIZE_MAPPING[tname](_iter(val) for val in obj) elif is_iter(obj): @@ -602,7 +607,9 @@ def to_pickle(data): def process_item(item): """Recursive processor and identification of data""" + dtype = type(item) + if dtype in (str, int, float, bool, bytes, SafeString): return item elif dtype == tuple: @@ -612,7 +619,10 @@ def to_pickle(data): elif dtype in (dict, _SaverDict): return dict((process_item(key), process_item(val)) for key, val in item.items()) elif dtype in (defaultdict, _SaverDefaultDict): - return defaultdict(item.default_factory, ((process_item(key), process_item(val)) for key, val in item.items())) + return defaultdict( + item.default_factory, + ((process_item(key), process_item(val)) for key, val in item.items()), + ) elif dtype in (set, _SaverSet): return set(process_item(val) for val in item) elif dtype in (OrderedDict, _SaverOrderedDict): @@ -620,7 +630,20 @@ def to_pickle(data): elif dtype in (deque, _SaverDeque): return deque(process_item(val) for val in item) - elif hasattr(item, "__iter__"): + # not one of the base types + if hasattr(item, "__serialize_dbobjs__"): + # Allows custom serialization of any dbobjects embedded in + # the item that Evennia will otherwise not found (these would + # otherwise lead to an error). Use the dbserialize helper from + # this method. + try: + item.__serialize_dbobjs__() + except TypeError: + # we catch typerrors so we can handle both classes (requiring + # classmethods) and instances + 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]) @@ -678,7 +701,10 @@ def from_pickle(data, db_obj=None): elif dtype == dict: return dict((process_item(key), process_item(val)) for key, val in item.items()) elif dtype == defaultdict: - return defaultdict(item.default_factory, ((process_item(key), process_item(val)) for key, val in item.items())) + return defaultdict( + item.default_factory, + ((process_item(key), process_item(val)) for key, val in item.items()), + ) elif dtype == set: return set(process_item(val) for val in item) elif dtype == OrderedDict: @@ -692,6 +718,18 @@ def from_pickle(data, db_obj=None): 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 + # that we previously serialized with __serialize_dbobjs__. + # use the dbunserialize helper in this module. + try: + item.__deserialize_dbobjs__() + except TypeError: + # handle recoveries both of classes (requiring classmethods + # or instances + pass + return item def process_tree(item, parent): diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a3f34f47b2..4dae1b700d 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -274,12 +274,13 @@ import inspect from ast import literal_eval from fnmatch import fnmatch +from math import ceil from inspect import isfunction, getargspec from django.conf import settings from evennia import Command, CmdSet from evennia.utils import logger -from evennia.utils.evtable import EvTable +from evennia.utils.evtable import EvTable, EvColumn from evennia.utils.ansi import strip_ansi from evennia.utils.utils import mod_import, make_iter, pad, to_str, m_len, is_iter, dedent, crop from evennia.commands import cmdhandler @@ -1210,7 +1211,6 @@ class EvMenu: Args: optionlist (list): List of (key, description) tuples for every option related to this node. - caller (Object, Account or None, optional): The caller of the node. Returns: options (str): The formatted option display. @@ -1229,7 +1229,7 @@ class EvMenu: table = [] for key, desc in optionlist: if key or desc: - desc_string = ": %s" % desc if desc else "" + desc_string = f": {desc}" if desc else "" table_width_max = max( table_width_max, max(m_len(p) for p in key.split("\n")) @@ -1239,42 +1239,31 @@ class EvMenu: raw_key = strip_ansi(key) if raw_key != key: # already decorations in key definition - table.append(" |lc%s|lt%s|le%s" % (raw_key, key, desc_string)) + table.append(f" |lc{raw_key}|lt{key}|le{desc_string}") else: # add a default white color to key - table.append(" |lc%s|lt|w%s|n|le%s" % (raw_key, raw_key, desc_string)) - ncols = _MAX_TEXT_WIDTH // table_width_max # number of ncols + table.append(f" |lc{raw_key}|lt|w{key}|n|le{desc_string}") + ncols = _MAX_TEXT_WIDTH // table_width_max # number of columns if ncols < 0: - # no visible option at all + # no visible options at all return "" - ncols = ncols + 1 if ncols == 0 else ncols - # get the amount of rows needed (start with 4 rows) - nrows = 4 - while nrows * ncols < nlist: - nrows += 1 - ncols = nlist // nrows # number of full columns - nlastcol = nlist % nrows # number of elements in last column + ncols = 1 if ncols == 0 else ncols - # get the final column count - ncols = ncols + 1 if nlastcol > 0 else ncols - if ncols > 1: - # only extend if longer than one column - table.extend([" " for i in range(nrows - nlastcol)]) + # minimum number of rows in a column + min_rows = 4 - # build the actual table grid - table = [table[icol * nrows : (icol * nrows) + nrows] for icol in range(0, ncols)] + # split the items into columns + split = max(min_rows, ceil(len(table)/ncols)) + max_end = len(table) + cols_list = [] + for icol in range(ncols): + start = icol*split + end = min(start+split,max_end) + cols_list.append(EvColumn(*table[start:end])) - # adjust the width of each column - for icol in range(len(table)): - col_width = ( - max(max(m_len(p) for p in part.split("\n")) for part in table[icol]) + colsep - ) - table[icol] = [pad(part, width=col_width + colsep, align="l") for part in table[icol]] - - # format the table into columns - return str(EvTable(table=table, border="none")) + return str(EvTable(table=cols_list, border="none")) def node_formatter(self, nodetext, optionstext): """ diff --git a/evennia/utils/gametime.py b/evennia/utils/gametime.py index 0e6358285c..ab14c77847 100644 --- a/evennia/utils/gametime.py +++ b/evennia/utils/gametime.py @@ -67,7 +67,7 @@ class TimeScript(DefaultScript): callback(*args, **kwargs) seconds = real_seconds_until(**self.db.gametime) - self.restart(interval=seconds) + self.start(interval=seconds,force_restart=True) # Access functions diff --git a/evennia/utils/tests/test_dbserialize.py b/evennia/utils/tests/test_dbserialize.py index 028d6d1f72..480893c466 100644 --- a/evennia/utils/tests/test_dbserialize.py +++ b/evennia/utils/tests/test_dbserialize.py @@ -62,10 +62,12 @@ class TestDbSerialize(TestCase): self.obj.db.test.sort(key=lambda d: str(d)) self.assertEqual(self.obj.db.test, [{0: 1}, {1: 0}]) - def test_dict(self): + def test_saverdict(self): self.obj.db.test = {"a": True} self.obj.db.test.update({"b": False}) self.assertEqual(self.obj.db.test, {"a": True, "b": False}) + self.obj.db.test |= {"c": 5} + self.assertEqual(self.obj.db.test, {"a": True, "b": False, "c": 5}) @parameterized.expand( [ @@ -88,27 +90,30 @@ class TestDbSerialize(TestCase): self.assertIsInstance(value, base_type) self.assertNotIsInstance(value, saver_type) self.assertEqual(value, default_value) - self.obj.db.test = {'a': True} - self.obj.db.test.update({'b': False}) - self.assertEqual(self.obj.db.test, {'a': True, 'b': False}) + self.obj.db.test = {"a": True} + self.obj.db.test.update({"b": False}) + 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'] - self.assertEqual(_dd, {'a': []}) + _dd["a"] + self.assertEqual(_dd, {"a": []}) # behavior after defaultdict is set as attribute dd = defaultdict(list) self.obj.db.test = dd - self.obj.db.test['a'] - self.assertEqual(self.obj.db.test, {'a': []}) + self.obj.db.test["a"] + self.assertEqual(self.obj.db.test, {"a": []}) - self.obj.db.test['a'].append(1) - self.assertEqual(self.obj.db.test, {'a': [1]}) - self.obj.db.test['a'].append(2) - self.assertEqual(self.obj.db.test, {'a': [1, 2]}) - self.obj.db.test['a'].append(3) - self.assertEqual(self.obj.db.test, {'a': [1, 2, 3]}) + self.obj.db.test["a"].append(1) + self.assertEqual(self.obj.db.test, {"a": [1]}) + self.obj.db.test["a"].append(2) + self.assertEqual(self.obj.db.test, {"a": [1, 2]}) + self.obj.db.test["a"].append(3) + self.assertEqual(self.obj.db.test, {"a": [1, 2, 3]}) + self.obj.db.test |= {"b": [5, 6]} + self.assertEqual(self.obj.db.test, {"a": [1, 2, 3], "b": [5, 6]}) diff --git a/evennia/utils/tests/test_text2html.py b/evennia/utils/tests/test_text2html.py index 3b67cd426e..361ab086cf 100644 --- a/evennia/utils/tests/test_text2html.py +++ b/evennia/utils/tests/test_text2html.py @@ -7,20 +7,20 @@ import mock class TestText2Html(TestCase): - def test_re_color(self): + def test_format_styles(self): parser = text2html.HTML_PARSER - self.assertEqual("foo", parser.re_color("foo")) + self.assertEqual("foo", parser.format_styles("foo")) self.assertEqual( 'redfoo', - parser.re_color(ansi.ANSI_UNHILITE + ansi.ANSI_RED + "red" + ansi.ANSI_NORMAL + "foo"), + parser.format_styles(ansi.ANSI_UNHILITE + ansi.ANSI_RED + "red" + ansi.ANSI_NORMAL + "foo"), ) self.assertEqual( 'redfoo', - parser.re_color(ansi.ANSI_BACK_RED + "red" + ansi.ANSI_NORMAL + "foo"), + parser.format_styles(ansi.ANSI_BACK_RED + "red" + ansi.ANSI_NORMAL + "foo"), ) self.assertEqual( - 'redfoo', - parser.re_color( + 'redfoo', + parser.format_styles( ansi.ANSI_BACK_RED + ansi.ANSI_UNHILITE + ansi.ANSI_GREEN @@ -29,63 +29,37 @@ class TestText2Html(TestCase): + "foo" ), ) - - @unittest.skip("parser issues") - def test_re_bold(self): - parser = text2html.HTML_PARSER - self.assertEqual("foo", parser.re_bold("foo")) self.assertEqual( - # "a redfoo", # TODO: why not? - "a redfoo", - parser.re_bold("a " + ansi.ANSI_HILITE + "red" + ansi.ANSI_UNHILITE + "foo"), - ) - - @unittest.skip("parser issues") - def test_re_underline(self): - parser = text2html.HTML_PARSER - self.assertEqual("foo", parser.re_underline("foo")) - self.assertEqual( - 'a red' + ansi.ANSI_NORMAL + "foo", - parser.re_underline( + 'a redfoo', + parser.format_styles( "a " + ansi.ANSI_UNDERLINE + "red" - + ansi.ANSI_NORMAL # TODO: why does it keep it? + + ansi.ANSI_NORMAL + "foo" ), ) - - @unittest.skip("parser issues") - def test_re_blinking(self): - parser = text2html.HTML_PARSER - self.assertEqual("foo", parser.re_blinking("foo")) self.assertEqual( - 'a red' + ansi.ANSI_NORMAL + "foo", - parser.re_blinking( + 'a redfoo', + parser.format_styles( "a " + ansi.ANSI_BLINK + "red" - + ansi.ANSI_NORMAL # TODO: why does it keep it? + + ansi.ANSI_NORMAL + "foo" ), ) - - @unittest.skip("parser issues") - def test_re_inversing(self): - parser = text2html.HTML_PARSER - self.assertEqual("foo", parser.re_inversing("foo")) self.assertEqual( - 'a red' + ansi.ANSI_NORMAL + "foo", - parser.re_inversing( + 'a redfoo', + parser.format_styles( "a " + ansi.ANSI_INVERSE + "red" - + ansi.ANSI_NORMAL # TODO: why does it keep it? + + ansi.ANSI_NORMAL + "foo" ), ) - @unittest.skip("parser issues") def test_remove_bells(self): parser = text2html.HTML_PARSER self.assertEqual("foo", parser.remove_bells("foo")) @@ -95,7 +69,7 @@ class TestText2Html(TestCase): "a " + ansi.ANSI_BEEP + "red" - + ansi.ANSI_NORMAL # TODO: why does it keep it? + + ansi.ANSI_NORMAL + "foo" ), ) @@ -110,7 +84,6 @@ class TestText2Html(TestCase): self.assertEqual("foo", parser.convert_linebreaks("foo")) self.assertEqual("a
redfoo
", parser.convert_linebreaks("a\n redfoo\n")) - @unittest.skip("parser issues") def test_convert_urls(self): parser = text2html.HTML_PARSER self.assertEqual("foo", parser.convert_urls("foo")) @@ -118,7 +91,6 @@ class TestText2Html(TestCase): 'a http://redfoo runs', parser.convert_urls("a http://redfoo runs"), ) - # TODO: doesn't URL encode correctly def test_sub_mxp_links(self): parser = text2html.HTML_PARSER @@ -186,22 +158,22 @@ class TestText2Html(TestCase): self.assertEqual("foo", text2html.parse_html("foo")) self.maxDiff = None self.assertEqual( - # TODO: note that the blink is currently *not* correctly aborted - # with |n here! This is probably not possible to correctly handle - # with regex - a stateful parser may be needed. - # blink back-cyan normal underline red green yellow blue magenta cyan back-green text2html.parse_html("|^|[CHello|n|u|rW|go|yr|bl|md|c!|[G!"), - '' - 'Hello' # noqa - '' - 'W' # noqa - 'o' - 'r' - 'l' - 'd' - '!' - '!' # noqa - "" - "" - "", + '' + 'Hello' + '' + 'W' + '' + 'o' + '' + 'r' + '' + 'l' + '' + 'd' + '' + '!' + '' + '!' + '', ) diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index be8f459c87..ab3f2930d6 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -12,11 +12,10 @@ import re from html import escape as html_escape from .ansi import * - # All xterm256 RGB equivalents -XTERM256_FG = "\033[38;5;%sm" -XTERM256_BG = "\033[48;5;%sm" +XTERM256_FG = "\033[38;5;{}m" +XTERM256_BG = "\033[48;5;{}m" class TextToHTMLparser(object): @@ -25,77 +24,65 @@ class TextToHTMLparser(object): """ tabstop = 4 - # mapping html color name <-> ansi code. - hilite = ANSI_HILITE - unhilite = ANSI_UNHILITE # this will be stripped - there is no css equivalent. - normal = ANSI_NORMAL # " - underline = ANSI_UNDERLINE - blink = ANSI_BLINK - inverse = ANSI_INVERSE # this will produce an outline; no obvious css equivalent? - colorcodes = [ - ("color-000", unhilite + ANSI_BLACK), # pure black - ("color-001", unhilite + ANSI_RED), - ("color-002", unhilite + ANSI_GREEN), - ("color-003", unhilite + ANSI_YELLOW), - ("color-004", unhilite + ANSI_BLUE), - ("color-005", unhilite + ANSI_MAGENTA), - ("color-006", unhilite + ANSI_CYAN), - ("color-007", unhilite + ANSI_WHITE), # light grey - ("color-008", hilite + ANSI_BLACK), # dark grey - ("color-009", hilite + ANSI_RED), - ("color-010", hilite + ANSI_GREEN), - ("color-011", hilite + ANSI_YELLOW), - ("color-012", hilite + ANSI_BLUE), - ("color-013", hilite + ANSI_MAGENTA), - ("color-014", hilite + ANSI_CYAN), - ("color-015", hilite + ANSI_WHITE), # pure white - ] + [("color-%03i" % (i + 16), XTERM256_FG % ("%i" % (i + 16))) for i in range(240)] - colorback = [ - ("bgcolor-000", ANSI_BACK_BLACK), # pure black - ("bgcolor-001", ANSI_BACK_RED), - ("bgcolor-002", ANSI_BACK_GREEN), - ("bgcolor-003", ANSI_BACK_YELLOW), - ("bgcolor-004", ANSI_BACK_BLUE), - ("bgcolor-005", ANSI_BACK_MAGENTA), - ("bgcolor-006", ANSI_BACK_CYAN), - ("bgcolor-007", ANSI_BACK_WHITE), # light grey - ("bgcolor-008", hilite + ANSI_BACK_BLACK), # dark grey - ("bgcolor-009", hilite + ANSI_BACK_RED), - ("bgcolor-010", hilite + ANSI_BACK_GREEN), - ("bgcolor-011", hilite + ANSI_BACK_YELLOW), - ("bgcolor-012", hilite + ANSI_BACK_BLUE), - ("bgcolor-013", hilite + ANSI_BACK_MAGENTA), - ("bgcolor-014", hilite + ANSI_BACK_CYAN), - ("bgcolor-015", hilite + ANSI_BACK_WHITE), # pure white - ] + [("bgcolor-%03i" % (i + 16), XTERM256_BG % ("%i" % (i + 16))) for i in range(240)] + style_codes = [ + # non-color style markers + ANSI_NORMAL, + ANSI_UNDERLINE, + ANSI_HILITE, + ANSI_UNHILITE, + ANSI_INVERSE, + ANSI_BLINK, + ANSI_INV_HILITE, + ANSI_BLINK_HILITE, + ANSI_INV_BLINK, + ANSI_INV_BLINK_HILITE, + ] - # make sure to escape [ - # colorcodes = [(c, code.replace("[", r"\[")) for c, code in colorcodes] - # colorback = [(c, code.replace("[", r"\[")) for c, code in colorback] - fg_colormap = dict((code, clr) for clr, code in colorcodes) - bg_colormap = dict((code, clr) for clr, code in colorback) + ansi_color_codes = [ + # Foreground colors + ANSI_BLACK, + ANSI_RED, + ANSI_GREEN, + ANSI_YELLOW, + ANSI_BLUE, + ANSI_MAGENTA, + ANSI_CYAN, + ANSI_WHITE, + ] - # create stop markers - fgstop = "(?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m|\033\[0m|$" - bgstop = "(?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m|\033\[0m|$" - bgfgstop = bgstop[:-2] + fgstop + xterm_fg_codes = [XTERM256_FG.format(i + 16) for i in range(240)] - fgstart = "((?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m)" - bgstart = "((?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m)" - bgfgstart = bgstart + r"((?:\033\[1m|\033\[22m){0,1}\033\[[3-4][0-8].*?m){0,1}" + ansi_bg_codes = [ + # Background colors + ANSI_BACK_BLACK, + ANSI_BACK_RED, + ANSI_BACK_GREEN, + ANSI_BACK_YELLOW, + ANSI_BACK_BLUE, + ANSI_BACK_MAGENTA, + ANSI_BACK_CYAN, + ANSI_BACK_WHITE, + ] - # extract color markers, tagging the start marker and the text marked - re_fgs = re.compile(fgstart + "(.*?)(?=" + fgstop + ")") - re_bgs = re.compile(bgstart + "(.*?)(?=" + bgstop + ")") - re_bgfg = re.compile(bgfgstart + "(.*?)(?=" + bgfgstop + ")") + xterm_bg_codes = [XTERM256_BG.format(i + 16) for i in range(240)] + + re_style = re.compile( + r"({})".format( + "|".join( + style_codes + ansi_color_codes + xterm_fg_codes + ansi_bg_codes + xterm_bg_codes + ).replace("[", r"\[") + ) + ) + + colorlist = ( + [ANSI_UNHILITE + code for code in ansi_color_codes] + + [ANSI_HILITE + code for code in ansi_color_codes] + + xterm_fg_codes + ) + + bglist = ansi_bg_codes + [ANSI_HILITE + code for code in ansi_bg_codes] + xterm_bg_codes - re_normal = re.compile(normal.replace("[", r"\[")) - re_hilite = re.compile("(?:%s)(.*)(?=%s|%s)" % (hilite.replace("[", r"\["), fgstop, bgstop)) - re_unhilite = re.compile("(?:%s)(.*)(?=%s|%s)" % (unhilite.replace("[", r"\["), fgstop, bgstop)) - re_uline = re.compile("(?:%s)(.*?)(?=%s|%s)" % (underline.replace("[", r"\["), fgstop, bgstop)) - re_blink = re.compile("(?:%s)(.*?)(?=%s|%s)" % (blink.replace("[", r"\["), fgstop, bgstop)) - re_inverse = re.compile("(?:%s)(.*?)(?=%s|%s)" % (inverse.replace("[", r"\["), fgstop, bgstop)) re_string = re.compile( r"(?P[<&>])|(?P[\t]+)|(?P\r\n|\r|\n)", re.S | re.M | re.I, @@ -106,100 +93,6 @@ class TextToHTMLparser(object): re_mxplink = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL) re_mxpurl = re.compile(r"\|lu(.*?)\|lt(.*?)\|le", re.DOTALL) - def _sub_bgfg(self, colormatch): - # print("colormatch.groups()", colormatch.groups()) - bgcode, fgcode, text = colormatch.groups() - if not fgcode: - ret = r"""%s""" % ( - self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")), - text, - ) - else: - ret = r"""%s""" % ( - self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")), - self.fg_colormap.get(fgcode, self.bg_colormap.get(fgcode, "err")), - text, - ) - return ret - - def _sub_fg(self, colormatch): - code, text = colormatch.groups() - return r"""%s""" % (self.fg_colormap.get(code, "err"), text) - - def _sub_bg(self, colormatch): - code, text = colormatch.groups() - return r"""%s""" % (self.bg_colormap.get(code, "err"), text) - - def re_color(self, text): - """ - Replace ansi colors with html color class names. Let the - client choose how it will display colors, if it wishes to. - - Args: - text (str): the string with color to replace. - - Returns: - text (str): Re-colored text. - - """ - text = self.re_bgfg.sub(self._sub_bgfg, text) - text = self.re_fgs.sub(self._sub_fg, text) - text = self.re_bgs.sub(self._sub_bg, text) - text = self.re_normal.sub("", text) - return text - - def re_bold(self, text): - """ - Clean out superfluous hilights rather than set to make - it match the look of telnet. - - Args: - text (str): Text to process. - - Returns: - text (str): Processed text. - - """ - text = self.re_hilite.sub(r"\1", text) - return self.re_unhilite.sub(r"\1", text) # strip unhilite - there is no equivalent in css. - - def re_underline(self, text): - """ - Replace ansi underline with html underline class name. - - Args: - text (str): Text to process. - - Returns: - text (str): Processed text. - - """ - return self.re_uline.sub(r'\1', text) - - def re_blinking(self, text): - """ - Replace ansi blink with custom blink css class - - Args: - text (str): Text to process. - - Returns: - text (str): Processed text. - """ - return self.re_blink.sub(r'\1', text) - - def re_inversing(self, text): - """ - Replace ansi inverse with custom inverse css class - - Args: - text (str): Text to process. - - Returns: - text (str): Processed text. - """ - return self.re_inverse.sub(r'\1', text) - def remove_bells(self, text): """ Remove ansi specials @@ -211,7 +104,7 @@ class TextToHTMLparser(object): text (str): Processed text. """ - return text.replace("\07", "") + return text.replace(ANSI_BEEP, "") def remove_backspaces(self, text): """ @@ -315,6 +208,128 @@ class TextToHTMLparser(object): return text return None + def format_styles(self, text): + """ + Takes a string with parsed ANSI codes and replaces them with + HTML spans and CSS classes. + + Args: + text (str): The string to process. + + Returns: + text (str): Processed text. + """ + + # split out the ANSI codes and clean out any empty items + str_list = [substr for substr in self.re_style.split(text) if substr] + # initialize all the flags and classes + classes = [] + clean = True + inverse = False + # default color is light grey - unhilite + white + hilight = ANSI_UNHILITE + fg = ANSI_WHITE + # default bg is black + bg = ANSI_BACK_BLACK + + for i, substr in enumerate(str_list): + # reset all current styling + if substr == ANSI_NORMAL and not clean: + # replace with close existing tag + str_list[i] = "" + # reset to defaults + classes = [] + clean = True + inverse = False + hilight = ANSI_UNHILITE + fg = ANSI_WHITE + bg = ANSI_BACK_BLACK + + # change color + elif substr in self.ansi_color_codes + self.xterm_fg_codes: + # erase ANSI code from output + str_list[i] = "" + # set new color + fg = substr + + # change bg color + elif substr in self.ansi_bg_codes + self.xterm_bg_codes: + # erase ANSI code from output + str_list[i] = "" + # set new bg + bg = substr + + # non-color codes + elif substr in self.style_codes: + # erase ANSI code from output + str_list[i] = "" + + # hilight codes + if substr in (ANSI_HILITE, ANSI_UNHILITE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE): + # set new hilight status + hilight = ANSI_UNHILITE if substr == ANSI_UNHILITE else ANSI_HILITE + + # inversion codes + if substr in (ANSI_INVERSE, ANSI_INV_HILITE, ANSI_INV_BLINK_HILITE): + inverse = True + + # blink codes + if ( + substr in (ANSI_BLINK, ANSI_BLINK_HILITE, ANSI_INV_BLINK_HILITE) + and "blink" not in classes + ): + classes.append("blink") + + # underline + if substr == ANSI_UNDERLINE and "underline" not in classes: + classes.append("underline") + + else: + # normal text, add text back to list + if not str_list[i - 1]: + # prior entry was cleared, which means style change + # get indices for the fg and bg codes + bg_index = self.bglist.index(bg) + try: + color_index = self.colorlist.index(hilight + fg) + except ValueError: + # xterm256 colors don't have the hilight codes + color_index = self.colorlist.index(fg) + + if inverse: + # inverse means swap fg and bg indices + bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) + color_class = "color-{}".format(str(bg_index).rjust(3, "0")) + else: + # use fg and bg indices for classes + bg_class = "bgcolor-{}".format(str(bg_index).rjust(3, "0")) + color_class = "color-{}".format(str(color_index).rjust(3, "0")) + + # black bg is the default, don't explicitly style + if bg_class != "bgcolor-000": + classes.append(bg_class) + # light grey text is the default, don't explicitly style + if color_class != "color-007": + classes.append(color_class) + # define the new style span + prefix = ''.format(" ".join(classes)) + # close any prior span + if not clean: + prefix = "" + prefix + # add span to output + str_list[i - 1] = prefix + + # clean out color classes to easily update next time + classes = [cls for cls in classes if "color" not in cls] + # flag as currently being styled + clean = False + + # close span if necessary + if not clean: + str_list.append("") + # recombine back into string + return "".join(str_list) + def parse(self, text, strip_ansi=False): """ Main access function, converts a text containing ANSI codes @@ -328,19 +343,14 @@ class TextToHTMLparser(object): text (str): Parsed text. """ - # print(f"incoming text:\n{text}") # parse everything to ansi first text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True) # convert all ansi to html result = re.sub(self.re_string, self.sub_text, text) result = re.sub(self.re_mxplink, self.sub_mxp_links, result) result = re.sub(self.re_mxpurl, self.sub_mxp_urls, result) - result = self.re_color(result) - result = self.re_bold(result) - result = self.re_underline(result) - result = self.re_blinking(result) - result = self.re_inversing(result) result = self.remove_bells(result) + result = self.format_styles(result) result = self.convert_linebreaks(result) result = self.remove_backspaces(result) result = self.convert_urls(result) diff --git a/evennia/web/static/webclient/js/evennia.js b/evennia/web/static/webclient/js/evennia.js index fb740c966e..51da36c468 100644 --- a/evennia/web/static/webclient/js/evennia.js +++ b/evennia/web/static/webclient/js/evennia.js @@ -149,7 +149,7 @@ An "emitter" object must have a function // kwargs (obj): keyword-args to listener // emit: function (cmdname, args, kwargs) { - if (kwargs.cmdid) { + if (kwargs.cmdid && (kwargs.cmdid in cmdmap)) { cmdmap[kwargs.cmdid].apply(this, [args, kwargs]); delete cmdmap[kwargs.cmdid]; }