From 48f3319615386e5f6df8a7cf535f380df1565ed3 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 19:42:59 +0800 Subject: [PATCH] feat: add relationship components and reverse indexing Introduces a relationship system to allow entities to point to other entities. Includes a `Relationship` component and a `RelationshipIndex` to enable efficient reverse lookups (finding all sources pointing to a specific target). The `World.DestroyEntity` method now performs cascading removals: - Relationships where the entity is the source are removed. - Relationships where the entity is the target are removed from their respective source entities. Also adds MessagePack support to `Entity` and `Relationship`. --- src/OECS/ComponentStore.cs | 26 +++ src/OECS/Entity.cs | 9 +- src/OECS/IRelationship.cs | 22 +++ src/OECS/Relationship.cs | 40 +++++ src/OECS/RelationshipIndex.cs | 144 ++++++++++++++++ src/OECS/World.cs | 65 +++++++ tests/OECS.Tests/RelationshipTests.cs | 235 ++++++++++++++++++++++++++ 7 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 src/OECS/IRelationship.cs create mode 100644 src/OECS/Relationship.cs create mode 100644 src/OECS/RelationshipIndex.cs create mode 100644 tests/OECS.Tests/RelationshipTests.cs diff --git a/src/OECS/ComponentStore.cs b/src/OECS/ComponentStore.cs index edb4a4a..93726a7 100644 --- a/src/OECS/ComponentStore.cs +++ b/src/OECS/ComponentStore.cs @@ -52,6 +52,16 @@ internal class ComponentStore GetSetIfExists()?.Remove(entity); } + /// + /// Removes the component of the given type from the entity. + /// No-op if the entity does not have the component or the type is not registered. + /// Used for cascading relationship removal during entity destruction. + /// + public void Remove(Entity entity, Type componentType) + { + GetSet(componentType)?.Remove(entity); + } + public ref T Get(Entity entity) where T : struct { return ref GetSet().Get(entity); @@ -62,6 +72,22 @@ internal class ComponentStore return GetSetIfExists()?.Contains(entity) ?? false; } + /// + /// Tries to get the component value for the given entity. + /// Returns true if the component exists, with the value copied to . + /// + public bool TryGet(Entity entity, out T value) where T : struct + { + var set = GetSetIfExists(); + if (set != null && set.Contains(entity)) + { + value = set.Get(entity); + return true; + } + value = default; + return false; + } + /// /// Removes all components for the given entity across all component types. /// diff --git a/src/OECS/Entity.cs b/src/OECS/Entity.cs index ab5ffa5..dccb5fd 100644 --- a/src/OECS/Entity.cs +++ b/src/OECS/Entity.cs @@ -1,3 +1,5 @@ +using MessagePack; + namespace OECS; /// @@ -5,7 +7,8 @@ namespace OECS; /// Combines a 24-bit identifier with an 8-bit version to prevent /// use-after-free bugs when entity IDs are recycled. /// -public readonly struct Entity : IEquatable +[MessagePackObject(AllowPrivate = true)] +public readonly partial struct Entity : IEquatable { private const uint IdMask = 0x00FF_FFFF; // lower 24 bits private const uint VersionMask = 0xFF00_0000; // upper 8 bits @@ -18,6 +21,7 @@ public readonly struct Entity : IEquatable /// public static readonly Entity Null = new(0); + [Key(0)] private readonly uint _value; internal Entity(uint id, uint version) @@ -33,16 +37,19 @@ public readonly struct Entity : IEquatable /// /// The 24-bit entity identifier. /// + [IgnoreMember] public uint Id => _value & IdMask; /// /// The 8-bit generation version. Incremented each time the ID is recycled. /// + [IgnoreMember] public uint Version => (_value & VersionMask) >> VersionShift; /// /// Returns true if this entity is the null sentinel. /// + [IgnoreMember] public bool IsNull => _value == 0; /// diff --git a/src/OECS/IRelationship.cs b/src/OECS/IRelationship.cs new file mode 100644 index 0000000..349a34f --- /dev/null +++ b/src/OECS/IRelationship.cs @@ -0,0 +1,22 @@ +namespace OECS; + +/// +/// Marker interface for components that represent a directed relationship +/// between two entities. The component is stored on the +/// entity and points to the entity. +/// +/// The automatically maintains a reverse index so that +/// all sources pointing to a given target can be looked up efficiently. +/// +public interface IRelationship +{ + /// + /// The entity that owns this relationship component. + /// + Entity Source { get; } + + /// + /// The entity that this relationship points to. + /// + Entity Target { get; } +} diff --git a/src/OECS/Relationship.cs b/src/OECS/Relationship.cs new file mode 100644 index 0000000..95b308c --- /dev/null +++ b/src/OECS/Relationship.cs @@ -0,0 +1,40 @@ +using MessagePack; + +namespace OECS; + +/// +/// A convenience base struct for relationship components. +/// +/// The type parameters and +/// are phantom types that differentiate relationship kinds at the type level, +/// enabling type-safe queries and reverse lookups. +/// +/// +/// +/// // Define a "ChildOf" relationship between a child and a parent entity. +/// world.AddComponent(child, new Relationship<ChildOf, Parent> +/// { +/// Source = child, +/// Target = parent +/// }); +/// +/// // Later, find all children of a parent. +/// var children = world.GetSources<Relationship<ChildOf, Parent>>(parent); +/// +/// +/// +[MessagePackObject] +public struct Relationship : IRelationship +{ + /// + /// The entity that owns this relationship component. + /// + [Key(0)] + public Entity Source { get; set; } + + /// + /// The entity that this relationship points to. + /// + [Key(1)] + public Entity Target { get; set; } +} diff --git a/src/OECS/RelationshipIndex.cs b/src/OECS/RelationshipIndex.cs new file mode 100644 index 0000000..d5f7684 --- /dev/null +++ b/src/OECS/RelationshipIndex.cs @@ -0,0 +1,144 @@ +using System.Runtime.CompilerServices; + +namespace OECS; + +/// +/// Maintains a reverse index for relationship components, mapping +/// target entities to the set of source entities that point to them. +/// +/// Updated automatically by when relationship +/// components are added, removed, or when entities are destroyed. +/// +internal class RelationshipIndex +{ + /// + /// Per relationship type: target entity → set of source entities. + /// + private readonly Dictionary>> _index = new(); + + /// + /// Registers that a source entity now has a relationship pointing to a target. + /// + public void OnAdded(Type relationshipType, Entity source, Entity target) + { + if (!_index.TryGetValue(relationshipType, out var targetMap)) + { + targetMap = new Dictionary>(); + _index[relationshipType] = targetMap; + } + + if (!targetMap.TryGetValue(target, out var sources)) + { + sources = new HashSet(); + targetMap[target] = sources; + } + + sources.Add(source); + } + + /// + /// Unregisters a relationship from a source entity pointing to a target. + /// + public void OnRemoved(Type relationshipType, Entity source, Entity target) + { + if (!_index.TryGetValue(relationshipType, out var targetMap)) + return; + + if (!targetMap.TryGetValue(target, out var sources)) + return; + + sources.Remove(source); + + if (sources.Count == 0) + targetMap.Remove(target); + } + + /// + /// Returns all source entities that have a relationship of the given type + /// pointing to the specified target entity. + /// + public IReadOnlyCollection GetSources(Entity target) + where T : struct, IRelationship + { + var type = typeof(T); + if (!_index.TryGetValue(type, out var targetMap)) + return Array.Empty(); + + if (!targetMap.TryGetValue(target, out var sources)) + return Array.Empty(); + + return sources; + } + + /// + /// Returns all target entities that the given source entity points to + /// via the specified relationship type. Returns an empty collection if none. + /// + public IReadOnlyCollection GetTargets(Type relationshipType, Entity source) + { + if (!_index.TryGetValue(relationshipType, out var targetMap)) + return Array.Empty(); + + var result = new List(); + foreach (var (target, sources) in targetMap) + { + if (sources.Contains(source)) + result.Add(target); + } + return result; + } + + /// + /// Removes all index entries where the given entity appears as a source. + /// Called before the entity's components are removed during destruction. + /// + public void RemoveAllSourcesForEntity(Entity entity) + { + foreach (var (_, targetMap) in _index) + { + foreach (var (target, sources) in targetMap) + { + sources.Remove(entity); + } + } + + // Clean up empty target entries. + foreach (var (_, targetMap) in _index) + { + var emptyTargets = new List(); + foreach (var (target, sources) in targetMap) + { + if (sources.Count == 0) + emptyTargets.Add(target); + } + foreach (var target in emptyTargets) + { + targetMap.Remove(target); + } + } + } + + /// + /// Returns all relationship types for which the given entity is a target, + /// along with the set of source entities pointing to it. + /// Used during entity destruction to cascade-remove incoming relationships. + /// + public IEnumerable<(Type RelationshipType, IReadOnlyCollection Sources)> GetIncomingRelationships(Entity target) + { + foreach (var (relType, targetMap) in _index) + { + if (targetMap.TryGetValue(target, out var sources) && sources.Count > 0) + { + yield return (relType, sources); + } + } + } + + /// + /// Returns true if the index has any entries for the given relationship type. + /// + public bool HasType(Type relationshipType) + { + return _index.ContainsKey(relationshipType); + } +} diff --git a/src/OECS/World.cs b/src/OECS/World.cs index dd62cdb..c4e1364 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -11,12 +11,14 @@ public class World : IDisposable private readonly EntityAllocator _allocator; private readonly ComponentStore _components; private readonly CommandQueue _commands; + private readonly RelationshipIndex _relationships; public World() { _allocator = new EntityAllocator(); _components = new ComponentStore(); _commands = new CommandQueue(); + _relationships = new RelationshipIndex(); } // ── Entity Management ──────────────────────────────────────────── @@ -31,6 +33,11 @@ public class World : IDisposable /// /// Destroys an entity, removing all its components and recycling its ID. + /// + /// Cascade behavior: + /// - All relationships where this entity is the source are removed. + /// - All relationships where this entity is the target are removed + /// from the source entities. /// public void DestroyEntity(Entity entity) { @@ -41,6 +48,19 @@ public class World : IDisposable if (entity.Id == 1) return; + // Remove all relationships where this entity is the target. + foreach (var (relType, sources) in _relationships.GetIncomingRelationships(entity)) + { + foreach (var source in sources) + { + _relationships.OnRemoved(relType, source, entity); + _components.Remove(source, relType); + } + } + + // Remove all relationships where this entity is the source. + _relationships.RemoveAllSourcesForEntity(entity); + _components.RemoveAll(entity); _allocator.Free(entity); } @@ -58,19 +78,52 @@ public class World : IDisposable /// /// Adds a component to the entity. Replaces the existing component if /// the entity already has one of type . + /// + /// If implements , + /// the reverse index is updated automatically. /// public void AddComponent(Entity entity, T component) where T : struct { ThrowIfNotAlive(entity); + + // If replacing an existing relationship, remove the old index entry first. + if (component is IRelationship newRel) + { + if (_components.TryGet(entity, out var old)) + { + var oldRel = (IRelationship)(object)old; + _relationships.OnRemoved(typeof(T), entity, oldRel.Target); + } + } + _components.Add(entity, component); + + // Update relationship index. + if (component is IRelationship rel) + { + _relationships.OnAdded(typeof(T), entity, rel.Target); + } } /// /// Removes the component of type from the entity. /// No-op if the entity does not have the component. + /// + /// If implements , + /// the reverse index is updated automatically. /// public void RemoveComponent(Entity entity) where T : struct { + // Update relationship index before removal. + if (typeof(IRelationship).IsAssignableFrom(typeof(T))) + { + if (_components.TryGet(entity, out var existing)) + { + var rel = (IRelationship)(object)existing; + _relationships.OnRemoved(typeof(T), entity, rel.Target); + } + } + _components.Remove(entity); } @@ -91,6 +144,18 @@ public class World : IDisposable return _components.Has(entity); } + // ── Relationships ──────────────────────────────────────────────── + + /// + /// Returns all source entities that have a relationship of type + /// pointing to the given target entity. + /// + public IReadOnlyCollection GetSources(Entity target) + where T : struct, IRelationship + { + return _relationships.GetSources(target); + } + // ── Commands ────────────────────────────────────────────────────── /// diff --git a/tests/OECS.Tests/RelationshipTests.cs b/tests/OECS.Tests/RelationshipTests.cs new file mode 100644 index 0000000..33ac59e --- /dev/null +++ b/tests/OECS.Tests/RelationshipTests.cs @@ -0,0 +1,235 @@ +using FluentAssertions; +using Xunit; + +namespace OECS.Tests; + +// ── Phantom types for relationship differentiation ─────────────────── + +public struct ChildOf { } +public struct ParentOf { } +public struct OwnedBy { } +public struct MemberOf { } + +// ── Tests ──────────────────────────────────────────────────────────── + +public class RelationshipTests +{ + [Fact] + public void AddRelationship_UpdatesReverseIndex() + { + var world = new World(); + var child = world.CreateEntity(); + var parent = world.CreateEntity(); + + world.AddComponent(child, new Relationship + { + Source = child, + Target = parent + }); + + var sources = world.GetSources>(parent); + sources.Should().BeEquivalentTo([child]); + } + + [Fact] + public void RemoveRelationship_UpdatesReverseIndex() + { + var world = new World(); + var child = world.CreateEntity(); + var parent = world.CreateEntity(); + + world.AddComponent(child, new Relationship + { + Source = child, + Target = parent + }); + + world.RemoveComponent>(child); + + var sources = world.GetSources>(parent); + sources.Should().BeEmpty(); + } + + [Fact] + public void ReplaceRelationship_UpdatesReverseIndex() + { + var world = new World(); + var child = world.CreateEntity(); + var parent1 = world.CreateEntity(); + var parent2 = world.CreateEntity(); + + world.AddComponent(child, new Relationship + { + Source = child, + Target = parent1 + }); + + // Replace with a new target. + world.AddComponent(child, new Relationship + { + Source = child, + Target = parent2 + }); + + world.GetSources>(parent1).Should().BeEmpty(); + world.GetSources>(parent2).Should().BeEquivalentTo([child]); + } + + [Fact] + public void DestroySourceEntity_CleansUpRelationships() + { + var world = new World(); + var child = world.CreateEntity(); + var parent = world.CreateEntity(); + + world.AddComponent(child, new Relationship + { + Source = child, + Target = parent + }); + + world.DestroyEntity(child); + + // The relationship should be gone from the reverse index. + world.GetSources>(parent).Should().BeEmpty(); + + // The destroyed entity should not be alive. + world.IsAlive(child).Should().BeFalse(); + } + + [Fact] + public void DestroyTargetEntity_CleansUpIncomingRelationships() + { + var world = new World(); + var child = world.CreateEntity(); + var parent = world.CreateEntity(); + + world.AddComponent(child, new Relationship + { + Source = child, + Target = parent + }); + + world.DestroyEntity(parent); + + // The relationship component should be removed from the source entity. + world.HasComponent>(child).Should().BeFalse(); + + // The reverse index should be empty for the destroyed target. + world.GetSources>(parent).Should().BeEmpty(); + } + + [Fact] + public void MultipleSources_ForSameTarget() + { + var world = new World(); + var child1 = world.CreateEntity(); + var child2 = world.CreateEntity(); + var child3 = world.CreateEntity(); + var parent = world.CreateEntity(); + + world.AddComponent(child1, new Relationship + { + Source = child1, Target = parent + }); + world.AddComponent(child2, new Relationship + { + Source = child2, Target = parent + }); + world.AddComponent(child3, new Relationship + { + Source = child3, Target = parent + }); + + var sources = world.GetSources>(parent); + sources.Should().BeEquivalentTo([child1, child2, child3]); + } + + [Fact] + public void DifferentRelationshipTypes_AreIndependent() + { + var world = new World(); + var entity = world.CreateEntity(); + var owner = world.CreateEntity(); + var group = world.CreateEntity(); + + world.AddComponent(entity, new Relationship + { + Source = entity, Target = owner + }); + world.AddComponent(entity, new Relationship + { + Source = entity, Target = group + }); + + world.GetSources>(owner).Should().BeEquivalentTo([entity]); + world.GetSources>(group).Should().BeEquivalentTo([entity]); + + // Cross-check: OwnedBy sources should not appear in MemberOf lookups. + world.GetSources>(owner).Should().BeEmpty(); + } + + [Fact] + public void ReverseLookup_ReturnsEmpty_WhenNoRelationships() + { + var world = new World(); + var entity = world.CreateEntity(); + + var sources = world.GetSources>(entity); + sources.Should().BeEmpty(); + } + + [Fact] + public void Relationship_CanBeQueried_LikeNormalComponent() + { + var world = new World(); + var child = world.CreateEntity(); + var parent = world.CreateEntity(); + + world.AddComponent(child, new Relationship + { + Source = child, Target = parent + }); + + var query = world.Query().With>().Build(); + var results = new List(); + + world.ForEach(query, (Entity e, ref Relationship rel) => + { + results.Add(e); + rel.Target.Should().Be(parent); + }); + + results.Should().BeEquivalentTo([child]); + } + + [Fact] + public void DestroyEntity_WithBothIncomingAndOutgoingRelationships() + { + var world = new World(); + var a = world.CreateEntity(); + var b = world.CreateEntity(); + var c = world.CreateEntity(); + + // a → b (a is child of b) + world.AddComponent(a, new Relationship + { + Source = a, Target = b + }); + + // c → a (c is child of a) + world.AddComponent(c, new Relationship + { + Source = c, Target = a + }); + + // Destroy a. This should: + // 1. Remove the a→b relationship (a is source). + // 2. Remove the c→a relationship (a is target, so c's component is removed). + world.DestroyEntity(a); + + world.IsAlive(a).Should().BeFalse(); + world.HasComponent>(c).Should().BeFalse(); + world.GetSources>(b).Should().BeEmpty(); + } +}