diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba3f4a613..8e7182eb0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - New Contrib: `Container` typeclass with new commands for storing and retrieving things inside them (InspectorCaracal) +- Updated Contrib: `ExtendedRoom` now supports arbitrary room-states, + state-based descriptions, embedded funcparser tags, details and random messages. - Feature: Add `TagCategoryProperty` for setting categories with multiple tags as properties directly on objects. Complements `TagProperty`. - Feature: Attribute-support for saving/loading `deques` with `maxlen=` set. diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index 3ba3f4a613..8e7182eb0e 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -4,6 +4,8 @@ - New Contrib: `Container` typeclass with new commands for storing and retrieving things inside them (InspectorCaracal) +- Updated Contrib: `ExtendedRoom` now supports arbitrary room-states, + state-based descriptions, embedded funcparser tags, details and random messages. - Feature: Add `TagCategoryProperty` for setting categories with multiple tags as properties directly on objects. Complements `TagProperty`. - Feature: Attribute-support for saving/loading `deques` with `maxlen=` set. diff --git a/docs/source/Components/Tags.md b/docs/source/Components/Tags.md index 9aeecee161..42aaa92455 100644 --- a/docs/source/Components/Tags.md +++ b/docs/source/Components/Tags.md @@ -24,10 +24,19 @@ class Sword(DefaultObject): # name of property is the category, tag-keys are arguments damage_type = TagCategoryProperty("piercing", "slashing") - crafting_element = TagCategory("blade", "hilt", "pommel") + crafting_element = TagCategoryProperty("blade", "hilt", "pommel") ``` +In-game, tags are controlled `tag` command: + + > tag Chair = furniture + > tag Chair = furniture + > tag Table = furniture + + > tag/search furniture + Chair, Sofa, Table + _Tags_ are short text lables one can 'hang' on objects in order to organize, group and quickly find out their properties. An Evennia entity can be tagged by any number of tags. They are more efficient than [Attributes](./Attributes.md) since on the database-side, Tags are _shared_ between all objects with that particular tag. A tag does not carry a value in itself; it either sits on the entity You manage Tags using the `TagHandler` (`.tags`) on typeclassed entities. You can also assign Tags on the class level through the `TagProperty` (one tag, one category per line) or the `TagCategoryProperty` (one category, multiple tags per line). Both of these use the `TagHandler` under the hood, they are just convenient ways to add tags already when you define your class. @@ -37,62 +46,16 @@ Another example would be a weather script affecting all rooms tagged as `outdoor In Evennia, Tags are technically also used to implement `Aliases` (alternative names for objects) and `Permissions` (simple strings for [Locks](./Locks.md) to check for). + ## Working with Tags -### Properties of Tags (and Aliases and Permissions) +### Searching for tags -Tags are *unique*. This means that there is only ever one Tag object with a given key and category. +The common way to use tags (once they have been set) is find all objects tagged with a particular tag combination: -```{important} -Not specifying a category (default) gives the tag a category of `None`, which is also considered a unique key + category combination. You cannot use `TagCategoryProperty` to set Tags with `None` categories, since the property name may not be `None`. Use the `TagHandler` (or `TagProperty`) for this. + objs = evennia.search_tag(key=("foo", "bar"), category='mycategory') -``` -When Tags are assigned to game entities, these entities are actually sharing the same Tag. This means that Tags are not suitable for storing information about a single object - use an -[Attribute](./Attributes.md) for this instead. Tags are a lot more limited than Attributes but this also -makes them very quick to lookup in the database - this is the whole point. - -Tags have the following properties, stored in the database: - -- **key** - the name of the Tag. This is the main property to search for when looking up a Tag. -- **category** - this category allows for retrieving only specific subsets of tags used for different purposes. You could have one category of tags for "zones", another for "outdoor locations", for example. If not given, the category will be `None`, which is also considered a separate, default, category. -- **data** - this is an optional text field with information about the tag. Remember that Tags are shared between entities, so this field cannot hold any object-specific information. Usually it would be used to hold info about the group of entities the Tag is tagging - possibly used for contextual help like a tool tip. It is not used by default. - -There are also two special properties. These should usually not need to be changed or set, it is used internally by Evennia to implement various other uses it makes of the `Tag` object: - -- **model** - this holds a *natural-key* description of the model object that this tag deals with, on the form *application.modelclass*, for example `objects.objectdb`. It used by the TagHandler of each entity type for correctly storing the data behind the scenes. -- **tagtype** - this is a "top-level category" of sorts for the inbuilt children of Tags, namely *Aliases* and *Permissions*. The Taghandlers using this special field are especially intended to free up the *category* property for any use you desire. - -### Adding/Removing Tags - -You can tag any *typeclassed* object, namely [Objects](./Objects.md), [Accounts](./Accounts.md), [Scripts](./Scripts.md) and [Channels](./Channels.md). General tags are added by the *Taghandler*. The tag handler is accessed as a property `tags` on the relevant entity: - -```python - mychair.tags.add("furniture") - mychair.tags.add("furniture", category="luxurious") - myroom.tags.add("dungeon#01") - myscript.tags.add("weather", category="climate") - myaccount.tags.add("guestaccount") - - mychair.tags.all() # returns a list of Tags - mychair.tags.remove("furniture") - mychair.tags.clear() -``` - -Adding a new tag will either create a new Tag or re-use an already existing one. Note that there are -_two_ "furniture" tags, one with a `None` category, and one with the "luxurious" category. - -When using `remove`, the `Tag` is not deleted but are just disconnected from the tagged object. This -makes for very quick operations. The `clear` method removes (disconnects) all Tags from the object. -You can also use the default `@tag` command: - - @tag mychair = furniture - -This tags the chair with a 'furniture' Tag (the one with a `None` category). - -### Searching for objects with a given tag - -Usually tags are used as a quick way to find tagged database entities. You can retrieve all objects -with a given Tag like this in code: +As shown above, you can also have tags without a category (category of `None`). ```python import evennia @@ -119,8 +82,7 @@ with a given Tag like this in code: Using any of the `search_tag` variants will all return [Django Querysets](https://docs.djangoproject.com/en/4.1/ref/models/querysets/), including if you only have one match. You can treat querysets as lists and iterate over them, or continue building search queries with them. -Remember when searching that not setting a category means setting it to `None` - this does *not* -mean that category is undefined, rather `None` is considered the default, unnamed category. +Remember when searching that not setting a category means setting it to `None` - this does *not* mean that category is undefined, rather `None` is considered the default, unnamed category. ```python import evennia @@ -141,6 +103,109 @@ There is also an in-game command that deals with assigning and using ([Object-]( tag/search furniture + +### TagHandler + +This is the main way to work with tags when you have the entry already. This handler sits on all typeclassed entities as `.tags` and you use `.tags.add()`, `.tags.remove()` and `.tags.has()` to manage Tags on the object. [See the api docs](evennia.typeclasses.tags.TagHandler) for more useful methods. + +The TagHandler can be found on any of the base *typeclassed* objects, namely [Objects](./Objects.md), [Accounts](./Accounts.md), [Scripts](./Scripts.md) and [Channels](./Channels.md) (as well as their children). Here are some examples of use: + +```python + mychair.tags.add("furniture") + mychair.tags.add("furniture", category="luxurious") + myroom.tags.add("dungeon#01") + myscript.tags.add("weather", category="climate") + myaccount.tags.add("guestaccount") + + mychair.tags.all() # returns a list of Tags + mychair.tags.remove("furniture") + mychair.tags.clear() +``` + +Adding a new tag will either create a new Tag or re-use an already existing one. Note that there are _two_ "furniture" tags, one with a `None` category, and one with the "luxurious" category. + +When using `remove`, the `Tag` is not deleted but are just disconnected from the tagged object. This makes for very quick operations. The `clear` method removes (disconnects) all Tags from the object. + + +### TagProperty + +This is used as a property when you create a new class: + +```python +from evennia import TagProperty +from typeclasses import Object + +class MyClass(Object): + mytag = TagProperty(tagcategory) +``` + +This will create a Tag named `mytag` and category `tagcategory` in the database. You'll be able to find it by `obj.mytag` but more useful you can find it with the normal Tag searching methods in the database. + +Note that if you were to delete this tag with `obj.tags.remove("mytag", "tagcategory")`, that tag will be _re-added_ to the object next time this property is accessed! + +### TagCategoryProperty + +This is the inverse of `TagProperty`: + +```python +from evennia import TagCategoryProperty +from typeclasses import Object + +class MyClass(Object): + tagcategory = TagCategroyProperty(tagkey1, tagkey2) +``` + +The above example means you'll have two tags (`tagkey1` and `tagkey2`), each with the `tagcategory` category, assigned to this object. + +Note that similarly to how it works for `TagProperty`, if you were to delete these tags from the object with the `TagHandler` (`obj.tags.remove("tagkey1", "tagcategory")`, then these tags will be _re-added_ automatically next time the property is accessed. + +The reverse is however not true: If you were to _add_ a new tag of the same category to the object, via the `TagHandler`, then this property will include that in the list of returned tags. + +If you want to 're-sync' the tags in the property with that in the database, you can use the `del` operation on it - next time the property is accessed, it will then only show the default keys you specify in it. Here's how it works: + +```python +>>> obj.tagcategory +["tagkey1", "tagkey2"] + +# remove one of the default tags outside the property +>>> obj.tags.remove("tagkey1", "tagcategory") +>>> obj.tagcategory +["tagkey1", "tagkey2"] # missing tag is auto-created! + +# add a new tag from outside the property +>>> obj.tags.add("tagkey3", "tagcategory") +>>> obj.tagcategory +["tagkey1", "tagkey2", "tagkey3"] # includes the new tag! + +# sync property with datbase +>>> del obj.tagcategory +>>> obj.tagcategory +["tagkey1", "tagkey2"] # property/database now in sync +``` + +## Properties of Tags (and Aliases and Permissions) + +Tags are *unique*. This means that there is only ever one Tag object with a given key and category. + +```{important} +Not specifying a category (default) gives the tag a category of `None`, which is also considered a unique key + category combination. You cannot use `TagCategoryProperty` to set Tags with `None` categories, since the property name may not be `None`. Use the `TagHandler` (or `TagProperty`) for this. + +``` +When Tags are assigned to game entities, these entities are actually sharing the same Tag. This means that Tags are not suitable for storing information about a single object - use an +[Attribute](./Attributes.md) for this instead. Tags are a lot more limited than Attributes but this also +makes them very quick to lookup in the database - this is the whole point. + +Tags have the following properties, stored in the database: + +- **key** - the name of the Tag. This is the main property to search for when looking up a Tag. +- **category** - this category allows for retrieving only specific subsets of tags used for different purposes. You could have one category of tags for "zones", another for "outdoor locations", for example. If not given, the category will be `None`, which is also considered a separate, default, category. +- **data** - this is an optional text field with information about the tag. Remember that Tags are shared between entities, so this field cannot hold any object-specific information. Usually it would be used to hold info about the group of entities the Tag is tagging - possibly used for contextual help like a tool tip. It is not used by default. + +There are also two special properties. These should usually not need to be changed or set, it is used internally by Evennia to implement various other uses it makes of the `Tag` object: + +- **model** - this holds a *natural-key* description of the model object that this tag deals with, on the form *application.modelclass*, for example `objects.objectdb`. It used by the TagHandler of each entity type for correctly storing the data behind the scenes. +- **tagtype** - this is a "top-level category" of sorts for the inbuilt children of Tags, namely *Aliases* and *Permissions*. The Taghandlers using this special field are especially intended to free up the *category* property for any use you desire. + ## Aliases and Permissions Aliases and Permissions are implemented using normal TagHandlers that simply save Tags with a @@ -155,5 +220,4 @@ used in the same way as Tags above: all_aliases = boy.aliases.all() ``` -and so on. Similarly to how `tag` works in-game, there is also the `perm` command for assigning permissions and `@alias` command for aliases. - +and so on. Similarly to how `tag` works in-game, there is also the `perm` command for assigning permissions and `@alias` command for aliases. \ No newline at end of file diff --git a/docs/source/Contribs/Contrib-Extended-Room.md b/docs/source/Contribs/Contrib-Extended-Room.md index e71745f6eb..e7a8d8992b 100644 --- a/docs/source/Contribs/Contrib-Extended-Room.md +++ b/docs/source/Contribs/Contrib-Extended-Room.md @@ -1,15 +1,19 @@ # Extended Room -Contribution - Griatch 2012, vincent-lg 2019 +Contribution - Griatch 2012, vincent-lg 2019, Griatch 2023 -This extends the normal `Room` typeclass to allow its description to change -with time-of-day and/or season. It also adds 'details' for the player to look at -in the room (without having to create a new in-game object for each). The room is -supported by new `look` and `desc` commands. +This extends the normal `Room` typeclass to allow its description to change with +time-of-day and/or season as well as any other state (like flooded or dark). +Embedding `$state(burning, This place is on fire!)` in the description will +allow for changing the description based on room state. The room also supports +`details` for the player to look at in the room (without having to create a new +in-game object for each), as well as support for random echoes. The room +comes with a set of alternate commands for `look` and `@desc`, as well as new +commands `detail`, `roomstate` and `time`. -## Installation/testing: +## Installation -Adding the `ExtendedRoomCmdset` to the default character cmdset will add all +Add the `ExtendedRoomCmdset` to the default character cmdset will add all new commands for use. In more detail, in `mygame/commands/default_cmdsets.py`: @@ -30,55 +34,160 @@ class CharacterCmdset(default_cmds.CharacterCmdSet): Then reload to make the new commands available. Note that they only work on rooms with the typeclass `ExtendedRoom`. Create new rooms with the right typeclass or use the `typeclass` command to swap existing rooms. Note that since -this contrib overrides the `look` command, you will need to add the +this contrib overrides the `look` and `@desc` commands, you will need to add the `extended_room.ExtendedRoomCmdSet` to the default character cmdset *after* -super().at_cmdset_creation(), or it will be overridden by the default look. +`super().at_cmdset_creation()`, or they will be overridden by the default look. + +To dig a new extended room: + + dig myroom:evennia.contrib.grid.extended_room.ExtendedRoom = north,south + +To make all new rooms ExtendedRooms without having to specify it, make your +`Room` typeclass inherit from the `ExtendedRoom` and then reload: + +```python +# in mygame/typeclasses/rooms.py + +from evennia.contrib.grid.extended_room import ExtendedRoom + +# ... + +class Room(ObjectParent, ExtendedRoom): + # ... + +``` ## Features -### Time-changing description slots +### State-dependent description slots -This allows to change the full description text the room shows -depending on larger time variations. Four seasons (spring, summer, -autumn and winter) are used by default. The season is calculated -on-demand (no Script or timer needed) and updates the full text block. +By default, the normal `room.db.desc` description is used. You can however +add new state-ful descriptions with `room.add_desc(description, +room_state=roomstate)` or with the in-game command -There is also a general description which is used as fallback if -one or more of the seasonal descriptions are not set when their -time comes. +``` +@desc/roomstate [] +``` -An updated `desc` command allows for setting seasonal descriptions. +For example -The room uses the `evennia.utils.gametime.GameTime` global script. This is -started by default, but if you have deactivated it, you need to -supply your own time keeping mechanism. +``` +@desc/dark This room is pitch black.`. -### In-description changing tags +``` -Within each seasonal (or general) description text, you can also embed -time-of-day dependent sections. Text inside such a tag will only show -during that particular time of day. The tags looks like ` ... -`. By default there are four timeslots per day - morning, -afternoon, evening and night. + +These will be stored in Attributes `desc_`. To set the default, +fallback description, just use `@desc `. +To activate a state on the room, use `room.add/remove_state(*roomstate)` or the in-game +command +``` +roomstate (use it again to toggle the state off) +``` +For example +``` +roomstate dark +``` +There is one in-built, time-based state `season`. By default these are 'spring', +'summer', 'autumn' and 'winter'. The `room.get_season()` method returns the +current season based on the in-game time. By default they change with a 12-month +in-game time schedule. You can control them with +``` +ExtendedRoom.months_per_year # default 12 +ExtendedRoom.seasons_per year # a dict of {"season": (start, end), ...} where + # start/end are given in fractions of the whole year +``` +To set a seasonal description, just set it as normal, with `room.add_desc` or +in-game with + +``` +@desc/winter This room is filled with snow. +@desc/autumn Red and yellow leaves cover the ground. +``` + +Normally the season changes with the in-game time, you can also 'force' a given +season by setting its state +``` +roomstate winter +``` +If you set the season manually like this, it won't change automatically again +until you unset it. + +You can get the stateful description from the room with `room.get_stateful_desc()`. + +### Changing parts of description based on state + +All descriptions can have embedded `$state(roomstate, description)` +[FuncParser tags](../Components/FuncParser.md) embedded in them. Here is an example: + +```py +room.add_desc("This a nice beach. " + "$state(empty, It is completely empty)" + "$state(full, It is full of people).", room_state="summer") +``` + +This is a summer-description with special embedded strings. If you set the room +with + + > room.add_room_state("summer", "empty") + > room.get_stateful_desc() + + This is a nice beach. It is completely empty + + > room.remove_room_state("empty") + > room.add_room_state("full") + > room.get_stateful_desc() + + This is a nice beach. It is full of people. + +There are four time-of-day states that are meant to be used with these tags. The +room tracks and changes these automatically. By default they are 'morning', +'afternoon', 'evening' and 'night'. You can get the current time-slot with +`room.get_time_of_day`. You can control them with + +``` +ExtendedRoom.hours_per_day # default 24 +ExtendedRoom.times_of_day # dict of {season: (start, end), ...} where + # the start/end are given as fractions of the day +``` + +You use these inside descriptions as normal: + + "A glade. $(morning, The morning sun shines down through the branches)." ### Details -The Extended Room can be "detailed" with special keywords. This makes -use of a special `Look` command. Details are "virtual" targets to look -at, without there having to be a database object created for it. The -Details are simply stored in a dictionary on the room and if the look -command cannot find an object match for a `look ` command it -will also look through the available details at the current location -if applicable. The `detail` command is used to change details. +_Details_ are "virtual" targets to look at in a room, without having to create a +new database instance for every thing. It's good to add more information to a +location. The details are stored as strings in a dictionary. + + detail window = There is a window leading out. + detail rock = The rock has a text written on it: 'Do not dare lift me'. + +When you are in the room you can then do `look window` or `look rock` and get +the matching detail-description. This requires the new custom `look` command. + +### Random echoes + +The `ExtendedRoom` supports random echoes. Just set them as an Attribute list +`room_messages`: + +``` +room.room_message_rate = 120 # in seconds. 0 to disable +room.db.room_messages = ["A car passes by.", "You hear the sound of car horns."] +room.start_repeat_broadcast_messages() # also a server reload works +``` + +These will start randomly echoing to the room every 120s. + ### Extra commands -- `CmdExtendedRoomLook` - look command supporting room details -- `CmdExtendedRoomDesc` - desc command allowing to add seasonal descs, -- `CmdExtendedRoomDetail` - command allowing to manipulate details in this room - as well as listing them -- `CmdExtendedRoomGameTime` - A simple `time` command, displaying the current - time and season. +- `CmdExtendedRoomLook` (`look`) - look command supporting room details +- `CmdExtendedRoomDesc` (`@desc`) - desc command allowing to add stateful descs, +- `CmdExtendeRoomState` (`roomstate`) - toggle room states +- `CmdExtendedRoomDetail` (`detail`) - list and manipulate room details +- `CmdExtendedRoomGameTime` (`time`) - Shows the current time and season in the room. ---- diff --git a/docs/source/Contribs/Contribs-Overview.md b/docs/source/Contribs/Contribs-Overview.md index c297c9f15c..b51058abb6 100644 --- a/docs/source/Contribs/Contribs-Overview.md +++ b/docs/source/Contribs/Contribs-Overview.md @@ -441,12 +441,16 @@ Contrib-XYZGrid.md ### `extended_room` -_Contribution - Griatch 2012, vincent-lg 2019_ +_Contribution - Griatch 2012, vincent-lg 2019, Griatch 2023_ -This extends the normal `Room` typeclass to allow its description to change -with time-of-day and/or season. It also adds 'details' for the player to look at -in the room (without having to create a new in-game object for each). The room is -supported by new `look` and `desc` commands. +This extends the normal `Room` typeclass to allow its description to change with +time-of-day and/or season as well as any other state (like flooded or dark). +Embedding `$state(burning, This place is on fire!)` in the description will +allow for changing the description based on room state. The room also supports +`details` for the player to look at in the room (without having to create a new +in-game object for each), as well as support for random echoes. The room +comes with a set of alternate commands for `look` and `@desc`, as well as new +commands `detail`, `roomstate` and `time`. [Read the documentation](./Contrib-Extended-Room.md) - [Browse the Code](evennia.contrib.grid.extended_room) diff --git a/evennia/contrib/grid/extended_room/tests.py b/evennia/contrib/grid/extended_room/tests.py index 8b86d1d9f2..c0c0694a7f 100644 --- a/evennia/contrib/grid/extended_room/tests.py +++ b/evennia/contrib/grid/extended_room/tests.py @@ -35,9 +35,14 @@ class TestExtendedRoom(EvenniaTestCase): base_room_desc = "Base room description." def setUp(self): + super().setUp() self.room = create_object(extended_room.ExtendedRoom, key="Test Room") self.room.desc = self.base_room_desc + def tearDown(self): + super().tearDown() + self.room.delete() + def test_room_description(self): """ Test that the vanilla room description is returned as expected. @@ -88,7 +93,8 @@ class TestExtendedRoom(EvenniaTestCase): "$state(night, Night room description.)" " What a great day!" ) - room_desc = self.room.get_display_desc(None) + char = Mock() + room_desc = self.room.get_display_desc(char) self.assertEqual(room_desc, f"{desc} What a great day!") def test_room_states(self): @@ -102,18 +108,19 @@ class TestExtendedRoom(EvenniaTestCase): ) self.room.add_room_state("under_construction") self.assertEqual(self.room.room_states, ["under_construction"]) - self.assertEqual(self.room.get_display_desc(None), "This room is under construction. ") + char = Mock() + self.assertEqual(self.room.get_display_desc(char), "This room is under construction. ") self.room.add_room_state("under_repair") self.assertEqual(self.room.room_states, ["under_construction", "under_repair"]) self.assertEqual( - self.room.get_display_desc(None), + self.room.get_display_desc(char), "This room is under construction. This room is under repair.", ) self.room.remove_room_state("under_construction") self.assertEqual( - self.room.get_display_desc(None), + self.room.get_display_desc(char), " This room is under repair.", ) @@ -122,6 +129,10 @@ class TestExtendedRoom(EvenniaTestCase): Test rooms with alternate descriptions. """ + from evennia import ObjectDB + + ObjectDB.objects.all() # TODO - fixes an issue with home FK missing + self.room.add_desc("The room is burning!", room_state="burning") self.room.add_desc("The room is flooding!", room_state="flooding") self.assertEqual(self.room.get_display_desc(None), self.base_room_desc) diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index 41d5ce1f6c..4807069ba5 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -354,6 +354,54 @@ class TestProperties(EvenniaTestCase): self.assertTrue(obj.tags.has("category_tag2", category="tagcategory2")) self.assertTrue(obj.tags.has("category_tag3", category="tagcategory2")) + self.assertEqual(obj.tagcategory1, ["category_tag1"]) + self.assertEqual( + set(obj.tagcategory2), set(["category_tag1", "category_tag2", "category_tag3"]) + ) + + def test_tag_category_properties_external_modification(self): + obj = self.obj + + self.assertEqual(obj.tagcategory1, ["category_tag1"]) + self.assertEqual( + set(obj.tagcategory2), set(["category_tag1", "category_tag2", "category_tag3"]) + ) + + # add extra tag to category + obj.tags.add("category_tag2", category="tagcategory1") + self.assertEqual( + obj.tags.get(category="tagcategory1"), + ["category_tag1", "category_tag2"], + ) + self.assertEqual(set(obj.tagcategory1), set(["category_tag1", "category_tag2"])) + + # add/remove extra tags to category + obj.tags.add("category_tag4", category="tagcategory2") + obj.tags.remove("category_tag3", category="tagcategory2") + self.assertEqual( + set(obj.tags.get(category="tagcategory2", return_list=True)), + set(["category_tag1", "category_tag2", "category_tag4"]), + ) + # note that when we access the property again, it will be updated to contain the same tags + self.assertEqual( + set(obj.tagcategory2), + set(["category_tag1", "category_tag2", "category_tag3", "category_tag4"]), + ) + + del obj.tagcategory1 + # should be deleted from database + self.assertEqual(obj.tags.get(category="tagcategory1", return_list=True), []) + # accessing the property should return the default value + self.assertEqual(obj.tagcategory1, ["category_tag1"]) + + del obj.tagcategory2 + # should be deleted from database + self.assertEqual(obj.tags.get(category="tagcategory2", return_list=True), []) + # accessing the property should return the default value + self.assertEqual( + set(obj.tagcategory2), set(["category_tag1", "category_tag2", "category_tag3"]) + ) + def test_object_awareness(self): """Test the "object-awareness" of customized AttributeProperty getter/setters""" obj = self.obj diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index 20b76621aa..a8054e17d3 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -177,7 +177,7 @@ class TagCategoryProperty: taghandler_name = "tags" - def __init__(self, *args): + def __init__(self, *default_tags): """ Assign a property for a Tag Category, with any number of Tag keys. This is often more useful than the `TagProperty` since it's common to want to check which @@ -185,8 +185,12 @@ class TagCategoryProperty: Args: *args (str or callable): Tag keys to assign to this property, using the category given - by the name of the property. If a callable, it will be called without arguments - to return the tag key. It is not possible to set tag `data` this way (use the + by the name of the property. Note that, if these tags are always set on the object, + if they are removed by some other means, they will be re-added when this property + is accessed. Furthermore, changing this list after the object was created, will + not remove any old tags (there is no way for the property to know if the + new list is new or not). If a callable, it will be called without arguments to + return the tag key. It is not possible to set tag `data` this way (use the Taghandler directly for that). Tag keys are not case sensitive. Raises: @@ -204,7 +208,7 @@ class TagCategoryProperty: """ self._category = "" - self._tags = self._parse_tag_input(*args) + self._default_tags = self._parse_tag_input(*default_tags) def _parse_tag_input(self, *args): """ @@ -240,56 +244,43 @@ class TagCategoryProperty: """ taghandler = getattr(instance, self.taghandler_name) - tags = [] - add_new = [] - for tagkey in self._tags: - try: - tag = taghandler.get( - key=tagkey, category=self._category, return_list=False, raise_exception=True - ) - except AttributeError: - add_new.append(tagkey) - else: - tags.append(tag) - if add_new: - for new_tag in add_new: - # we must remove this from the internal store or system will think it already - # existed when determining the sets in __set__ - self._tags.remove(new_tag) - self.__set__(instance, *add_new) + default_tags = self._default_tags + tags = taghandler.get(category=self._category, return_list=True) + + missing_default_tags = set(default_tags) - set(tags) + + if missing_default_tags: + getattr(instance, self.taghandler_name).batch_add( + *[(tag, self._category) for tag in missing_default_tags] + ) + + tags += missing_default_tags # to avoid a second db call return tags def __set__(self, instance, *args): """ - Assign a new set of tags to the category. This replaces the previous set of tags. + Assign a new set of tags to the category. Note that we can't know if previous + tags were assigned from this property or from TagHandler, so we don't + remove old tags. To refresh to only have the tags in this constructor, first + use `del` on this property and re-access the property with the changed default list. """ - taghandler = getattr(instance, self.taghandler_name) - - old_tags = set(self._tags) - new_tags = set(self._parse_tag_input(*args)) - - # new_tags could be a sub/superset of old tags - removed_tags = old_tags - new_tags - added_tags = new_tags - old_tags - - # remove tags - for tag in removed_tags: - taghandler.remove(key=tag, category=self._category) - - # add new tags (won't re-add if obj already had it) - taghandler.batch_add(*[(tag, self._category) for tag in added_tags]) + getattr(instance, self.taghandler_name).batch_add(*[(tag, self._category) for tag in args]) def __delete__(self, instance): """ Called when running `del` on the property. Will remove all tags of this - category from the object. Note that the tags will be readded on next fetch - unless the TagCategoryProperty is also removed in code! + category from the object. Note that next time this desriptor is accessed, the + default ones will be re-added! + + Note: + This will remove _all_ tags of this category from the object. This is necessary + in order to be able to be able to combine this with `__set__` to get a tag + list where property and handler are in sync. """ - for tagkey in self.tags: - getattr(instance, self.taghandler_name).remove(key=self.tagkey, category=self._category) + getattr(instance, self.taghandler_name).remove(category=self._category) class TagHandler(object): @@ -726,7 +717,7 @@ class TagHandler(object): for tup in args: tup = make_iter(tup) nlen = len(tup) - if nlen == 1: # just a key + if nlen == 1: # just a key, no category keys[None].append(tup[0]) elif nlen == 2: keys[tup[1]].append(tup[0]) @@ -736,6 +727,27 @@ class TagHandler(object): for category, key in keys.items(): self.add(key=key, category=category, data=data.get(category, None)) + def batch_remove(self, *args): + """ + Batch-remove tags from a list of tuples. + + Args: + *args (tuple or str): Each argument should be a `tagstr` keys or tuple + `(keystr, category)` or `(keystr, category, data)` (the `data` field is ignored, + only `keystr`/`category` matters). It's possible to mix input types. + + """ + keys = defaultdict(list) + for tup in args: + tup = make_iter(tup) + nlen = len(tup) + if nlen == 1: # just a key, no category + keys[None].append(tup[0]) + elif nlen > 1: + keys[tup[1]].append(tup[0]) + for category, key in keys.items(): + self.remove(key=key, category=category, data=data.get(category, None)) + def __str__(self): return ",".join(self.all())