Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions tcod/ecs/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
86 changes: 86 additions & 0 deletions tests/test_relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading