refactor: remove Source property from IRelationship

Remove the redundant `Source` property from `IRelationship` and its
implementations. Since the relationship component is inherently
stored on the source entity, the `Source` field was unnecessary and
required manual synchronization during deserialization.

This change simplifies the relationship model, reduces memory usage,
and removes the need for reflection-based fixups in the serializer.
This commit is contained in:
hypercross 2026-07-20 22:59:27 +08:00
parent 2de735498c
commit 91c6f4aa2c
21 changed files with 39 additions and 98 deletions

View File

@ -243,7 +243,7 @@ public class GameFlowTests
private static int CountCardsInHand(World world, Entity handEntity) private static int CountCardsInHand(World world, Entity handEntity)
{ {
// Holds relationship: Source = card entity, Target = hand entity. // Holds relationship: the card entity (which Holds points to the hand entity).
// GetSources returns all card entities that have a Holds pointing to handEntity. // GetSources returns all card entities that have a Holds pointing to handEntity.
return world.GetSources<Holds>(handEntity).Count; return world.GetSources<Holds>(handEntity).Count;
} }

View File

@ -41,7 +41,7 @@ public struct HitCommand : ICommand
// Remove from deck, add to hand. // Remove from deck, add to hand.
world.RemoveComponent<InDeck>(cardEntity); world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new Holds { Source = cardEntity, Target = handEntity }); world.AddComponent(cardEntity, new Holds { Target = handEntity });
} }
internal static Entity FindEntity<T>(World world, Entity singletonEntity) internal static Entity FindEntity<T>(World world, Entity singletonEntity)

View File

@ -42,7 +42,7 @@ public struct NewRoundCommand : ICommand
foreach (var card in cards) foreach (var card in cards)
{ {
world.RemoveComponent<Holds>(card); world.RemoveComponent<Holds>(card);
world.AddComponent(card, new InDeck { Source = card, Target = deckEntity }); world.AddComponent(card, new InDeck { Target = deckEntity });
} }
} }

View File

@ -10,6 +10,5 @@ namespace Game.Blackjack;
[MessagePackObject] [MessagePackObject]
public record struct Holds : IRelationship public record struct Holds : IRelationship
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Target { get; set; }
[Key(1)] public Entity Target { get; set; }
} }

View File

@ -10,6 +10,5 @@ namespace Game.Blackjack;
[MessagePackObject] [MessagePackObject]
public record struct InDeck : IRelationship public record struct InDeck : IRelationship
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Target { get; set; }
[Key(1)] public Entity Target { get; set; }
} }

View File

@ -36,7 +36,7 @@ public class DeckSetupSystem : ISystem
{ {
var cardEntity = world.CreateEntity(); var cardEntity = world.CreateEntity();
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank }); world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
world.AddComponent(cardEntity, new InDeck { Source = cardEntity, Target = deckEntity }); world.AddComponent(cardEntity, new InDeck { Target = deckEntity });
} }
} }
@ -81,7 +81,7 @@ public class DeckSetupSystem : ISystem
} }
for (int i = 0; i < cardEntities.Length; i++) for (int i = 0; i < cardEntities.Length; i++)
{ {
world.AddComponent(cardEntities[i], new InDeck { Source = cardEntities[i], Target = deckEntity }); world.AddComponent(cardEntities[i], new InDeck { Target = deckEntity });
} }
} }
} }

View File

@ -206,7 +206,7 @@ public class NunEffect : ICardEffect
world.RemoveComponent<OnField>(target); world.RemoveComponent<OnField>(target);
if (world.HasComponent<Card>(target)) if (world.HasComponent<Card>(target))
world.RemoveComponent<Card>(target); world.RemoveComponent<Card>(target);
world.AddComponent(target, new InDeck { Source = target, Target = discardDeck }); world.AddComponent(target, new InDeck { Target = discardDeck });
} }
world.Commands.Enqueue(new DrawCardCommand { Player = pending.Player, Kind = null }); world.Commands.Enqueue(new DrawCardCommand { Player = pending.Player, Kind = null });
world.RemoveSingleton<PendingNun>(); world.RemoveSingleton<PendingNun>();
@ -293,7 +293,7 @@ public class PegasusEffect : ICardEffect
world.RemoveComponent<OnField>(target); world.RemoveComponent<OnField>(target);
if (world.HasComponent<Card>(target)) if (world.HasComponent<Card>(target))
world.RemoveComponent<Card>(target); world.RemoveComponent<Card>(target);
world.AddComponent(target, new HeldBy { Source = target, Target = pending.Player }); world.AddComponent(target, new HeldBy { Target = pending.Player });
} }
world.RemoveSingleton<PendingPegasus>(); world.RemoveSingleton<PendingPegasus>();
return true; return true;
@ -321,7 +321,7 @@ public class CurseMasterEffect : ICardEffect
{ {
var skull = world.CreateEntity(); var skull = world.CreateEntity();
world.AddComponent(skull, new Skull()); world.AddComponent(skull, new Skull());
world.AddComponent(skull, new PlacedOn { Source = skull, Target = pt }); world.AddComponent(skull, new PlacedOn { Target = pt });
} }
} }
world.RemoveSingleton<PendingCurseMaster>(); world.RemoveSingleton<PendingCurseMaster>();
@ -366,14 +366,14 @@ public static class CardEffectHelpers
{ {
var horn = world.CreateEntity(); var horn = world.CreateEntity();
world.AddComponent(horn, new Horn()); world.AddComponent(horn, new Horn());
world.AddComponent(horn, new PlacedOn { Source = horn, Target = target }); world.AddComponent(horn, new PlacedOn { Target = target });
} }
public static void AddSkull(World world, Entity target) public static void AddSkull(World world, Entity target)
{ {
var skull = world.CreateEntity(); var skull = world.CreateEntity();
world.AddComponent(skull, new Skull()); world.AddComponent(skull, new Skull());
world.AddComponent(skull, new PlacedOn { Source = skull, Target = target }); world.AddComponent(skull, new PlacedOn { Target = target });
} }
public static Entity GetBanner(World world, Entity player) public static Entity GetBanner(World world, Entity player)

View File

@ -38,7 +38,7 @@ public struct PlayCardCommand : ICommand
// Move from hand to field. // Move from hand to field.
world.RemoveComponent<HeldBy>(CardEntity); world.RemoveComponent<HeldBy>(CardEntity);
world.AddComponent(CardEntity, new OnField { Source = CardEntity, Target = fieldOwner }); world.AddComponent(CardEntity, new OnField { Target = fieldOwner });
world.AddComponent(CardEntity, new Card { Rank = Rank, FaceDown = FaceDown }); world.AddComponent(CardEntity, new Card { Rank = Rank, FaceDown = FaceDown });
// Resolve on-play effects via registry. // Resolve on-play effects via registry.

View File

@ -10,6 +10,5 @@ namespace Game.CardWars;
[MessagePackObject] [MessagePackObject]
public record struct HeldBy : IRelationship public record struct HeldBy : IRelationship
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Target { get; set; }
[Key(1)] public Entity Target { get; set; }
} }

View File

@ -9,6 +9,5 @@ namespace Game.CardWars;
[MessagePackObject] [MessagePackObject]
public record struct InDeck : IRelationship public record struct InDeck : IRelationship
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Target { get; set; }
[Key(1)] public Entity Target { get; set; }
} }

View File

@ -9,6 +9,5 @@ namespace Game.CardWars;
[MessagePackObject] [MessagePackObject]
public record struct OnField : IRelationship public record struct OnField : IRelationship
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Target { get; set; }
[Key(1)] public Entity Target { get; set; }
} }

View File

@ -9,6 +9,5 @@ namespace Game.CardWars;
[MessagePackObject] [MessagePackObject]
public record struct PlacedOn : IRelationship public record struct PlacedOn : IRelationship
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Target { get; set; }
[Key(1)] public Entity Target { get; set; }
} }

View File

@ -38,7 +38,7 @@ public static class GameUtil
var cardEntity = cards.First(); var cardEntity = cards.First();
world.RemoveComponent<InDeck>(cardEntity); world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new HeldBy { Source = cardEntity, Target = player }); world.AddComponent(cardEntity, new HeldBy { Target = player });
return true; return true;
} }
@ -54,7 +54,7 @@ public static class GameUtil
for (int i = cards.Length - 1; i >= 0; i--) for (int i = cards.Length - 1; i >= 0; i--)
world.RemoveComponent<InDeck>(cards[i]); world.RemoveComponent<InDeck>(cards[i]);
for (int i = 0; i < cards.Length; i++) for (int i = 0; i < cards.Length; i++)
world.AddComponent(cards[i], new InDeck { Source = cards[i], Target = deck }); world.AddComponent(cards[i], new InDeck { Target = deck });
} }
/// <summary>Get all field cards for a player.</summary> /// <summary>Get all field cards for a player.</summary>
@ -135,7 +135,7 @@ public static class GameUtil
{ {
world.RemoveComponent<OnField>(card); world.RemoveComponent<OnField>(card);
world.RemoveComponent<Card>(card); world.RemoveComponent<Card>(card);
world.AddComponent(card, new InDeck { Source = card, Target = discardDeck }); world.AddComponent(card, new InDeck { Target = discardDeck });
} }
// Destroy horns and skulls placed on discarded cards. // Destroy horns and skulls placed on discarded cards.
@ -156,7 +156,7 @@ public static class GameUtil
foreach (var card in cards) foreach (var card in cards)
{ {
world.RemoveComponent<InDeck>(card); world.RemoveComponent<InDeck>(card);
world.AddComponent(card, new InDeck { Source = card, Target = drawDeck }); world.AddComponent(card, new InDeck { Target = drawDeck });
} }
} }
} }

View File

@ -57,7 +57,7 @@ public class GameSetupSystem : ISystem
var defs = CardDataLoader.LoadDefinitions(world, _cardDataCsv); var defs = CardDataLoader.LoadDefinitions(world, _cardDataCsv);
foreach (var (defEntity, _) in defs) foreach (var (defEntity, _) in defs)
{ {
world.AddComponent(defEntity, new InDeck { Source = defEntity, Target = publicDeck }); world.AddComponent(defEntity, new InDeck { Target = publicDeck });
} }
// Shuffle public deck. // Shuffle public deck.
@ -79,12 +79,12 @@ public class GameSetupSystem : ISystem
world.AddComponent(leader, new Leader()); world.AddComponent(leader, new Leader());
world.AddComponent(leader, new CardDef { Name = $"领袖{i}", Ranks = new[] { 0 }, Effect = CardEffect.None, Kind = CardKind.Faction }); world.AddComponent(leader, new CardDef { Name = $"领袖{i}", Ranks = new[] { 0 }, Effect = CardEffect.None, Kind = CardKind.Faction });
world.AddComponent(leader, new Card { Rank = 0, FaceDown = false }); world.AddComponent(leader, new Card { Rank = 0, FaceDown = false });
world.AddComponent(leader, new OnField { Source = leader, Target = player }); world.AddComponent(leader, new OnField { Target = player });
// Create banner. // Create banner.
var banner = world.CreateEntity(); var banner = world.CreateEntity();
world.AddComponent(banner, new Banner()); world.AddComponent(banner, new Banner());
world.AddComponent(banner, new OnField { Source = banner, Target = player }); world.AddComponent(banner, new OnField { Target = player });
// Create faction deck. // Create faction deck.
var factionDeck = world.CreateEntity(); var factionDeck = world.CreateEntity();

View File

@ -42,7 +42,7 @@ public class ScoringSystem : ISystem
if (castles.Count > 0) if (castles.Count > 0)
{ {
var castle = castles[0]; var castle = castles[0];
world.AddComponent(castle, new HeldBy { Source = castle, Target = winner }); world.AddComponent(castle, new HeldBy { Target = winner });
world.RemoveComponent<Castle>(castle); world.RemoveComponent<Castle>(castle);
} }

View File

@ -111,7 +111,6 @@ public class EdgeCaseTests
sources.Add(source); sources.Add(source);
world.AddComponent(source, new Relationship<ChildOf, ParentOf> world.AddComponent(source, new Relationship<ChildOf, ParentOf>
{ {
Source = source,
Target = target Target = target
}); });
} }

View File

@ -24,7 +24,6 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf> world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{ {
Source = child,
Target = parent Target = parent
}); });
@ -41,7 +40,6 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf> world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{ {
Source = child,
Target = parent Target = parent
}); });
@ -61,14 +59,12 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf> world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{ {
Source = child,
Target = parent1 Target = parent1
}); });
// Replace with a new target. // Replace with a new target.
world.AddComponent(child, new Relationship<ChildOf, ParentOf> world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{ {
Source = child,
Target = parent2 Target = parent2
}); });
@ -85,7 +81,6 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf> world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{ {
Source = child,
Target = parent Target = parent
}); });
@ -107,7 +102,6 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf> world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{ {
Source = child,
Target = parent Target = parent
}); });
@ -131,15 +125,15 @@ public class RelationshipTests
world.AddComponent(child1, new Relationship<ChildOf, ParentOf> world.AddComponent(child1, new Relationship<ChildOf, ParentOf>
{ {
Source = child1, Target = parent Target = parent
}); });
world.AddComponent(child2, new Relationship<ChildOf, ParentOf> world.AddComponent(child2, new Relationship<ChildOf, ParentOf>
{ {
Source = child2, Target = parent Target = parent
}); });
world.AddComponent(child3, new Relationship<ChildOf, ParentOf> world.AddComponent(child3, new Relationship<ChildOf, ParentOf>
{ {
Source = child3, Target = parent Target = parent
}); });
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(parent); var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
@ -156,11 +150,11 @@ public class RelationshipTests
world.AddComponent(entity, new Relationship<OwnedBy, Thing> world.AddComponent(entity, new Relationship<OwnedBy, Thing>
{ {
Source = entity, Target = owner Target = owner
}); });
world.AddComponent(entity, new Relationship<MemberOf, Thing> world.AddComponent(entity, new Relationship<MemberOf, Thing>
{ {
Source = entity, Target = group Target = group
}); });
world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]); world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]);
@ -189,7 +183,7 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf> world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{ {
Source = child, Target = parent Target = parent
}); });
var query = world.Query().With<Relationship<ChildOf, ParentOf>>().Build(); var query = world.Query().With<Relationship<ChildOf, ParentOf>>().Build();
@ -215,13 +209,13 @@ public class RelationshipTests
// a → b (a is child of b) // a → b (a is child of b)
world.AddComponent(a, new Relationship<ChildOf, ParentOf> world.AddComponent(a, new Relationship<ChildOf, ParentOf>
{ {
Source = a, Target = b Target = b
}); });
// c → a (c is child of a) // c → a (c is child of a)
world.AddComponent(c, new Relationship<ChildOf, ParentOf> world.AddComponent(c, new Relationship<ChildOf, ParentOf>
{ {
Source = c, Target = a Target = a
}); });
// Destroy a. This should: // Destroy a. This should:

View File

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

View File

@ -14,7 +14,6 @@ namespace OECS;
/// // Define a "ChildOf" relationship between a child and a parent entity. /// // Define a "ChildOf" relationship between a child and a parent entity.
/// world.AddComponent(child, new Relationship&lt;ChildOf, Parent&gt; /// world.AddComponent(child, new Relationship&lt;ChildOf, Parent&gt;
/// { /// {
/// Source = child,
/// Target = parent /// Target = parent
/// }); /// });
/// ///
@ -27,15 +26,9 @@ namespace OECS;
public struct Relationship<TSelf, TTarget> : IRelationship public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct where TSelf : struct where TTarget : struct
{ {
/// <summary>
/// The entity that owns this relationship component.
/// </summary>
[Key(0)]
public Entity Source { get; set; }
/// <summary> /// <summary>
/// The entity that this relationship points to. /// The entity that this relationship points to.
/// </summary> /// </summary>
[Key(1)] [Key(0)]
public Entity Target { get; set; } public Entity Target { get; set; }
} }

View File

@ -203,19 +203,8 @@ public class World : IDisposable
ThrowIfNotAlive(entity); ThrowIfNotAlive(entity);
// If replacing an existing relationship, remove the old index entry first. // If replacing an existing relationship, remove the old index entry first.
if (component is IRelationship newRel) if (component is IRelationship)
{ {
// Guard against mismatched Source: the relationship must be stored
// on the entity it claims as its Source, otherwise the reverse
// index becomes corrupted.
if (newRel.Source != entity)
{
throw new InvalidOperationException(
$"Relationship of type {typeof(T).Name} has Source={newRel.Source} " +
$"but is being added to entity {entity}. " +
$"The Source must match the entity the component is added to.");
}
if (_components.TryGet<T>(entity, out var old)) if (_components.TryGet<T>(entity, out var old))
{ {
var oldRel = (IRelationship)(object)old; var oldRel = (IRelationship)(object)old;

View File

@ -1,4 +1,3 @@
using System.Reflection;
using MessagePack; using MessagePack;
namespace OECS; namespace OECS;
@ -94,32 +93,10 @@ public static class WorldSerializer
$"Ensure the component type is used with the World " + $"Ensure the component type is used with the World " +
$"API so the source generator can discover it."); $"API so the source generator can discover it.");
// Deserialize and fix up IRelationship.Source before adding. // Deserialize and add via the typed internal method.
var component = desc.Deserialize(ce.Data); var component = desc.Deserialize(ce.Data);
if (component is IRelationship rel)
FixupRelationshipSource(rel, entity);
// Add via the typed internal method — no reflection for the add itself.
world.AddComponentBoxed(entity, component, desc.Type); world.AddComponentBoxed(entity, component, desc.Type);
} }
} }
} }
/// <summary>
/// Sets the Source field on a deserialized IRelationship to match
/// the entity it's being restored to. Uses a small cache of PropertyInfo
/// to avoid repeated reflection lookups.
/// </summary>
private static void FixupRelationshipSource(IRelationship rel, Entity entity)
{
var type = rel.GetType();
if (!_sourcePropCache.TryGetValue(type, out var prop))
{
prop = type.GetProperty("Source");
_sourcePropCache[type] = prop;
}
prop?.SetValue(rel, entity);
}
private static readonly Dictionary<Type, PropertyInfo?> _sourcePropCache = new();
} }