From 268313d8ff9c75e7e877930dcab4cd4a7bb61696 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Mon, 12 Oct 2026 17:26:10 -0700 Subject: [PATCH 1/2] Preserve proxy set operations and relation assignments --- tcod/ecs/entity.py | 11 ++++++ tests/test_relations.py | 86 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/tcod/ecs/entity.py b/tcod/ecs/entity.py index 3f5d1dc..28e38c2 100644 --- a/tcod/ecs/entity.py +++ b/tcod/ecs/entity.py @@ -602,6 +602,11 @@ class EntityTags(MutableSet[Any]): entity: Entity traverse: tuple[object, ...] + @classmethod + def _from_iterable(cls, it: Iterable[_T1]) -> set[_T1]: + """Return an independent set for the inherited set operations.""" + return set(it) + def __call__(self, *, traverse: Iterable[object]) -> Self: """Update this view with alternative parameters, such as a specific traversal relation. @@ -725,6 +730,11 @@ class EntityRelationsMapping(MutableSet[Entity]): key: object traverse: tuple[object, ...] + @classmethod + def _from_iterable(cls, it: Iterable[_T1]) -> set[_T1]: + """Return an independent set for the inherited set operations.""" + return set(it) + if __debug__: def __attrs_post_init__(self) -> None: @@ -826,6 +836,7 @@ def __getitem__(self, key: object) -> EntityRelationsMapping: def __setitem__(self, key: object, values: Iterable[Entity]) -> None: """Overwrite the targets of a relation tag with the new values.""" assert not isinstance(values, Entity), "Did you mean `entity.relations[key] = (target,)`?" + values = tuple(values) # Values may be a live view of the mapping being replaced. mapping = EntityRelationsMapping(self.entity, key, self.traverse) mapping.clear() for v in values: diff --git a/tests/test_relations.py b/tests/test_relations.py index b0df2ea..4374056 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -13,6 +13,92 @@ ChildOf: Final = "ChildOf" +@pytest.mark.parametrize("operation", ["union", "intersection", "difference", "symmetric_difference"]) +def test_inplace_relation_updates(operation: str) -> None: + world = tcod.ecs.Registry() + player = world["player"] + first, second, third = world["first"], world["second"], world["third"] + expected = {first, second} + other = {second, third} + player.relation_tags_many["allies"] = expected + query = world.Q.all_of(relations=[("allies", second)]) + assert set(query) == {player} + + if operation == "union": + expected |= other + player.relation_tags_many["allies"] |= other + elif operation == "intersection": + expected &= other + player.relation_tags_many["allies"] &= other + elif operation == "difference": + expected -= other + player.relation_tags_many["allies"] -= other + else: + expected ^= other + player.relation_tags_many["allies"] ^= other + + assert set(player.relation_tags_many["allies"]) == expected + assert set(query) == ({player} if second in expected else set()) + assert set(world.Q.all_of(relations=[(player, "allies", None)])) == expected + + +def test_relation_assignment_from_lazy_self_view() -> None: + world = tcod.ecs.Registry() + player = world["player"] + first, second = world["first"], world["second"] + player.relation_tags_many["allies"] = (first, second) + + player.relation_tags_many["allies"] = (target for target in player.relation_tags_many["allies"] if target is first) + + assert set(player.relation_tags_many["allies"]) == {first} + + +def test_relation_assignment_from_another_key() -> None: + world = tcod.ecs.Registry() + player = world["player"] + first, second = world["first"], world["second"] + player.relation_tags_many["allies"] = (first,) + player.relation_tags_many["rivals"] = (second,) + + player.relation_tags_many["allies"] = player.relation_tags_many["rivals"] + + assert set(player.relation_tags_many["allies"]) == {second} + assert set(player.relation_tags_many["rivals"]) == {second} + + +def test_relation_set_operations() -> None: + world = tcod.ecs.Registry() + player = world["player"] + first, second, third = world["first"], world["second"], world["third"] + player.relation_tags_many["allies"] = (first, second) + allies = player.relation_tags_many["allies"] + other = {second, third} + + assert allies | other == {first, second, third} + assert allies & other == {second} + assert allies - other == {first} + assert allies ^ other == {first, third} + assert set(allies) == {first, second} + + +def test_tag_set_operations() -> None: + world = tcod.ecs.Registry() + player = world["player"] + player.tags |= {"visible", "alive"} + other = {"alive", "friendly"} + query = world.Q.all_of(tags=["visible"]) + assert set(query) == {player} + + assert player.tags | other == {"visible", "alive", "friendly"} + assert player.tags & other == {"alive"} + assert player.tags - other == {"visible"} + assert player.tags ^ other == {"visible", "friendly"} + assert set(player.tags) == {"visible", "alive"} + player.tags &= other + assert set(player.tags) == {"alive"} + assert set(query) == set() + + def test_relations() -> None: world = tcod.ecs.Registry() entity_a = world["A"] From 3ba212d9f774c2bcff3b3d748dbc7cc5024a1682 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 13 Oct 2026 00:22:18 -0700 Subject: [PATCH 2/2] Note fixes for Entity set-like attributes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7384559..a28c8c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Relation components were incorrectly loaded from pickled data as pointing in the wrong direction. +- Set manipulations for `Entity.tags` and `Entity.relation_tags_many` are now more reliable and will no longer drop data. + https://github.com/HexDecimal/python-tcod-ecs/pull/58 ## [5.5.0] - 2026-01-14