feat: add relationship components and reverse indexing

Introduces a relationship system to allow entities to point to other
entities. Includes a `Relationship<TSelf, TTarget>` 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`.
This commit is contained in:
hypercross 2026-07-18 19:42:59 +08:00
parent c5578d4412
commit 48f3319615
7 changed files with 540 additions and 1 deletions

View File

@ -52,6 +52,16 @@ internal class ComponentStore
GetSetIfExists<T>()?.Remove(entity); GetSetIfExists<T>()?.Remove(entity);
} }
/// <summary>
/// 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.
/// </summary>
public void Remove(Entity entity, Type componentType)
{
GetSet(componentType)?.Remove(entity);
}
public ref T Get<T>(Entity entity) where T : struct public ref T Get<T>(Entity entity) where T : struct
{ {
return ref GetSet<T>().Get(entity); return ref GetSet<T>().Get(entity);
@ -62,6 +72,22 @@ internal class ComponentStore
return GetSetIfExists<T>()?.Contains(entity) ?? false; return GetSetIfExists<T>()?.Contains(entity) ?? false;
} }
/// <summary>
/// Tries to get the component value for the given entity.
/// Returns true if the component exists, with the value copied to <paramref name="value"/>.
/// </summary>
public bool TryGet<T>(Entity entity, out T value) where T : struct
{
var set = GetSetIfExists<T>();
if (set != null && set.Contains(entity))
{
value = set.Get(entity);
return true;
}
value = default;
return false;
}
/// <summary> /// <summary>
/// Removes all components for the given entity across all component types. /// Removes all components for the given entity across all component types.
/// </summary> /// </summary>

View File

@ -1,3 +1,5 @@
using MessagePack;
namespace OECS; namespace OECS;
/// <summary> /// <summary>
@ -5,7 +7,8 @@ namespace OECS;
/// Combines a 24-bit identifier with an 8-bit version to prevent /// Combines a 24-bit identifier with an 8-bit version to prevent
/// use-after-free bugs when entity IDs are recycled. /// use-after-free bugs when entity IDs are recycled.
/// </summary> /// </summary>
public readonly struct Entity : IEquatable<Entity> [MessagePackObject(AllowPrivate = true)]
public readonly partial struct Entity : IEquatable<Entity>
{ {
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
private const uint VersionMask = 0xFF00_0000; // upper 8 bits private const uint VersionMask = 0xFF00_0000; // upper 8 bits
@ -18,6 +21,7 @@ public readonly struct Entity : IEquatable<Entity>
/// </summary> /// </summary>
public static readonly Entity Null = new(0); public static readonly Entity Null = new(0);
[Key(0)]
private readonly uint _value; private readonly uint _value;
internal Entity(uint id, uint version) internal Entity(uint id, uint version)
@ -33,16 +37,19 @@ public readonly struct Entity : IEquatable<Entity>
/// <summary> /// <summary>
/// The 24-bit entity identifier. /// The 24-bit entity identifier.
/// </summary> /// </summary>
[IgnoreMember]
public uint Id => _value & IdMask; public uint Id => _value & IdMask;
/// <summary> /// <summary>
/// The 8-bit generation version. Incremented each time the ID is recycled. /// The 8-bit generation version. Incremented each time the ID is recycled.
/// </summary> /// </summary>
[IgnoreMember]
public uint Version => (_value & VersionMask) >> VersionShift; public uint Version => (_value & VersionMask) >> VersionShift;
/// <summary> /// <summary>
/// Returns true if this entity is the null sentinel. /// Returns true if this entity is the null sentinel.
/// </summary> /// </summary>
[IgnoreMember]
public bool IsNull => _value == 0; public bool IsNull => _value == 0;
/// <summary> /// <summary>

22
src/OECS/IRelationship.cs Normal file
View File

@ -0,0 +1,22 @@
namespace OECS;
/// <summary>
/// Marker interface for components that represent a directed relationship
/// between two entities. The component is stored on the <see cref="Source"/>
/// entity and points to the <see cref="Target"/> entity.
///
/// The <see cref="World"/> automatically maintains a reverse index so that
/// all sources pointing to a given target can be looked up efficiently.
/// </summary>
public interface IRelationship
{
/// <summary>
/// The entity that owns this relationship component.
/// </summary>
Entity Source { get; }
/// <summary>
/// The entity that this relationship points to.
/// </summary>
Entity Target { get; }
}

40
src/OECS/Relationship.cs Normal file
View File

@ -0,0 +1,40 @@
using MessagePack;
namespace OECS;
/// <summary>
/// A convenience base struct for relationship components.
///
/// The type parameters <typeparamref name="TSelf"/> and <typeparamref name="TTarget"/>
/// are phantom types that differentiate relationship kinds at the type level,
/// enabling type-safe queries and reverse lookups.
///
/// <example>
/// <code>
/// // Define a "ChildOf" relationship between a child and a parent entity.
/// world.AddComponent(child, new Relationship&lt;ChildOf, Parent&gt;
/// {
/// Source = child,
/// Target = parent
/// });
///
/// // Later, find all children of a parent.
/// var children = world.GetSources&lt;Relationship&lt;ChildOf, Parent&gt;&gt;(parent);
/// </code>
/// </example>
/// </summary>
[MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship
{
/// <summary>
/// The entity that owns this relationship component.
/// </summary>
[Key(0)]
public Entity Source { get; set; }
/// <summary>
/// The entity that this relationship points to.
/// </summary>
[Key(1)]
public Entity Target { get; set; }
}

View File

@ -0,0 +1,144 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Maintains a reverse index for relationship components, mapping
/// target entities to the set of source entities that point to them.
///
/// Updated automatically by <see cref="World"/> when relationship
/// components are added, removed, or when entities are destroyed.
/// </summary>
internal class RelationshipIndex
{
/// <summary>
/// Per relationship type: target entity → set of source entities.
/// </summary>
private readonly Dictionary<Type, Dictionary<Entity, HashSet<Entity>>> _index = new();
/// <summary>
/// Registers that a source entity now has a relationship pointing to a target.
/// </summary>
public void OnAdded(Type relationshipType, Entity source, Entity target)
{
if (!_index.TryGetValue(relationshipType, out var targetMap))
{
targetMap = new Dictionary<Entity, HashSet<Entity>>();
_index[relationshipType] = targetMap;
}
if (!targetMap.TryGetValue(target, out var sources))
{
sources = new HashSet<Entity>();
targetMap[target] = sources;
}
sources.Add(source);
}
/// <summary>
/// Unregisters a relationship from a source entity pointing to a target.
/// </summary>
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);
}
/// <summary>
/// Returns all source entities that have a relationship of the given type
/// pointing to the specified target entity.
/// </summary>
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship
{
var type = typeof(T);
if (!_index.TryGetValue(type, out var targetMap))
return Array.Empty<Entity>();
if (!targetMap.TryGetValue(target, out var sources))
return Array.Empty<Entity>();
return sources;
}
/// <summary>
/// Returns all target entities that the given source entity points to
/// via the specified relationship type. Returns an empty collection if none.
/// </summary>
public IReadOnlyCollection<Entity> GetTargets(Type relationshipType, Entity source)
{
if (!_index.TryGetValue(relationshipType, out var targetMap))
return Array.Empty<Entity>();
var result = new List<Entity>();
foreach (var (target, sources) in targetMap)
{
if (sources.Contains(source))
result.Add(target);
}
return result;
}
/// <summary>
/// Removes all index entries where the given entity appears as a source.
/// Called before the entity's components are removed during destruction.
/// </summary>
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<Entity>();
foreach (var (target, sources) in targetMap)
{
if (sources.Count == 0)
emptyTargets.Add(target);
}
foreach (var target in emptyTargets)
{
targetMap.Remove(target);
}
}
}
/// <summary>
/// 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.
/// </summary>
public IEnumerable<(Type RelationshipType, IReadOnlyCollection<Entity> Sources)> GetIncomingRelationships(Entity target)
{
foreach (var (relType, targetMap) in _index)
{
if (targetMap.TryGetValue(target, out var sources) && sources.Count > 0)
{
yield return (relType, sources);
}
}
}
/// <summary>
/// Returns true if the index has any entries for the given relationship type.
/// </summary>
public bool HasType(Type relationshipType)
{
return _index.ContainsKey(relationshipType);
}
}

View File

@ -11,12 +11,14 @@ public class World : IDisposable
private readonly EntityAllocator _allocator; private readonly EntityAllocator _allocator;
private readonly ComponentStore _components; private readonly ComponentStore _components;
private readonly CommandQueue _commands; private readonly CommandQueue _commands;
private readonly RelationshipIndex _relationships;
public World() public World()
{ {
_allocator = new EntityAllocator(); _allocator = new EntityAllocator();
_components = new ComponentStore(); _components = new ComponentStore();
_commands = new CommandQueue(); _commands = new CommandQueue();
_relationships = new RelationshipIndex();
} }
// ── Entity Management ──────────────────────────────────────────── // ── Entity Management ────────────────────────────────────────────
@ -31,6 +33,11 @@ public class World : IDisposable
/// <summary> /// <summary>
/// Destroys an entity, removing all its components and recycling its ID. /// Destroys an entity, removing all its components and recycling its ID.
///
/// Cascade behavior:
/// - All relationships where this entity is the <b>source</b> are removed.
/// - All relationships where this entity is the <b>target</b> are removed
/// from the source entities.
/// </summary> /// </summary>
public void DestroyEntity(Entity entity) public void DestroyEntity(Entity entity)
{ {
@ -41,6 +48,19 @@ public class World : IDisposable
if (entity.Id == 1) if (entity.Id == 1)
return; 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); _components.RemoveAll(entity);
_allocator.Free(entity); _allocator.Free(entity);
} }
@ -58,19 +78,52 @@ public class World : IDisposable
/// <summary> /// <summary>
/// Adds a component to the entity. Replaces the existing component if /// Adds a component to the entity. Replaces the existing component if
/// the entity already has one of type <typeparamref name="T"/>. /// the entity already has one of type <typeparamref name="T"/>.
///
/// If <typeparamref name="T"/> implements <see cref="IRelationship"/>,
/// the reverse index is updated automatically.
/// </summary> /// </summary>
public void AddComponent<T>(Entity entity, T component) where T : struct public void AddComponent<T>(Entity entity, T component) where T : struct
{ {
ThrowIfNotAlive(entity); ThrowIfNotAlive(entity);
// If replacing an existing relationship, remove the old index entry first.
if (component is IRelationship newRel)
{
if (_components.TryGet<T>(entity, out var old))
{
var oldRel = (IRelationship)(object)old;
_relationships.OnRemoved(typeof(T), entity, oldRel.Target);
}
}
_components.Add(entity, component); _components.Add(entity, component);
// Update relationship index.
if (component is IRelationship rel)
{
_relationships.OnAdded(typeof(T), entity, rel.Target);
}
} }
/// <summary> /// <summary>
/// Removes the component of type <typeparamref name="T"/> from the entity. /// Removes the component of type <typeparamref name="T"/> from the entity.
/// No-op if the entity does not have the component. /// No-op if the entity does not have the component.
///
/// If <typeparamref name="T"/> implements <see cref="IRelationship"/>,
/// the reverse index is updated automatically.
/// </summary> /// </summary>
public void RemoveComponent<T>(Entity entity) where T : struct public void RemoveComponent<T>(Entity entity) where T : struct
{ {
// Update relationship index before removal.
if (typeof(IRelationship).IsAssignableFrom(typeof(T)))
{
if (_components.TryGet<T>(entity, out var existing))
{
var rel = (IRelationship)(object)existing;
_relationships.OnRemoved(typeof(T), entity, rel.Target);
}
}
_components.Remove<T>(entity); _components.Remove<T>(entity);
} }
@ -91,6 +144,18 @@ public class World : IDisposable
return _components.Has<T>(entity); return _components.Has<T>(entity);
} }
// ── Relationships ────────────────────────────────────────────────
/// <summary>
/// Returns all source entities that have a relationship of type
/// <typeparamref name="T"/> pointing to the given target entity.
/// </summary>
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship
{
return _relationships.GetSources<T>(target);
}
// ── Commands ────────────────────────────────────────────────────── // ── Commands ──────────────────────────────────────────────────────
/// <summary> /// <summary>

View File

@ -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<ChildOf, ParentOf>
{
Source = child,
Target = parent
});
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(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<ChildOf, ParentOf>
{
Source = child,
Target = parent
});
world.RemoveComponent<Relationship<ChildOf, ParentOf>>(child);
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(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<ChildOf, ParentOf>
{
Source = child,
Target = parent1
});
// Replace with a new target.
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{
Source = child,
Target = parent2
});
world.GetSources<Relationship<ChildOf, ParentOf>>(parent1).Should().BeEmpty();
world.GetSources<Relationship<ChildOf, ParentOf>>(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<ChildOf, ParentOf>
{
Source = child,
Target = parent
});
world.DestroyEntity(child);
// The relationship should be gone from the reverse index.
world.GetSources<Relationship<ChildOf, ParentOf>>(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<ChildOf, ParentOf>
{
Source = child,
Target = parent
});
world.DestroyEntity(parent);
// The relationship component should be removed from the source entity.
world.HasComponent<Relationship<ChildOf, ParentOf>>(child).Should().BeFalse();
// The reverse index should be empty for the destroyed target.
world.GetSources<Relationship<ChildOf, ParentOf>>(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<ChildOf, ParentOf>
{
Source = child1, Target = parent
});
world.AddComponent(child2, new Relationship<ChildOf, ParentOf>
{
Source = child2, Target = parent
});
world.AddComponent(child3, new Relationship<ChildOf, ParentOf>
{
Source = child3, Target = parent
});
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(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<OwnedBy, object>
{
Source = entity, Target = owner
});
world.AddComponent(entity, new Relationship<MemberOf, object>
{
Source = entity, Target = group
});
world.GetSources<Relationship<OwnedBy, object>>(owner).Should().BeEquivalentTo([entity]);
world.GetSources<Relationship<MemberOf, object>>(group).Should().BeEquivalentTo([entity]);
// Cross-check: OwnedBy sources should not appear in MemberOf lookups.
world.GetSources<Relationship<MemberOf, object>>(owner).Should().BeEmpty();
}
[Fact]
public void ReverseLookup_ReturnsEmpty_WhenNoRelationships()
{
var world = new World();
var entity = world.CreateEntity();
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(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<ChildOf, ParentOf>
{
Source = child, Target = parent
});
var query = world.Query().With<Relationship<ChildOf, ParentOf>>().Build();
var results = new List<Entity>();
world.ForEach(query, (Entity e, ref Relationship<ChildOf, ParentOf> 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<ChildOf, ParentOf>
{
Source = a, Target = b
});
// c → a (c is child of a)
world.AddComponent(c, new Relationship<ChildOf, ParentOf>
{
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<Relationship<ChildOf, ParentOf>>(c).Should().BeFalse();
world.GetSources<Relationship<ChildOf, ParentOf>>(b).Should().BeEmpty();
}
}