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)
{
// 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.
return world.GetSources<Holds>(handEntity).Count;
}

View File

@ -41,7 +41,7 @@ public struct HitCommand : ICommand
// Remove from deck, add to hand.
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)

View File

@ -42,7 +42,7 @@ public struct NewRoundCommand : ICommand
foreach (var card in cards)
{
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]
public record struct Holds : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
[Key(0)] public Entity Target { get; set; }
}

View File

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

View File

@ -36,7 +36,7 @@ public class DeckSetupSystem : ISystem
{
var cardEntity = world.CreateEntity();
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++)
{
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);
if (world.HasComponent<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.RemoveSingleton<PendingNun>();
@ -293,7 +293,7 @@ public class PegasusEffect : ICardEffect
world.RemoveComponent<OnField>(target);
if (world.HasComponent<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>();
return true;
@ -321,7 +321,7 @@ public class CurseMasterEffect : ICardEffect
{
var skull = world.CreateEntity();
world.AddComponent(skull, new Skull());
world.AddComponent(skull, new PlacedOn { Source = skull, Target = pt });
world.AddComponent(skull, new PlacedOn { Target = pt });
}
}
world.RemoveSingleton<PendingCurseMaster>();
@ -366,14 +366,14 @@ public static class CardEffectHelpers
{
var horn = world.CreateEntity();
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)
{
var skull = world.CreateEntity();
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)

View File

@ -38,7 +38,7 @@ public struct PlayCardCommand : ICommand
// Move from hand to field.
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 });
// Resolve on-play effects via registry.

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@ public static class GameUtil
var cardEntity = cards.First();
world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new HeldBy { Source = cardEntity, Target = player });
world.AddComponent(cardEntity, new HeldBy { Target = player });
return true;
}
@ -54,7 +54,7 @@ public static class GameUtil
for (int i = cards.Length - 1; i >= 0; i--)
world.RemoveComponent<InDeck>(cards[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>
@ -135,7 +135,7 @@ public static class GameUtil
{
world.RemoveComponent<OnField>(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.
@ -156,7 +156,7 @@ public static class GameUtil
foreach (var card in cards)
{
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);
foreach (var (defEntity, _) in defs)
{
world.AddComponent(defEntity, new InDeck { Source = defEntity, Target = publicDeck });
world.AddComponent(defEntity, new InDeck { Target = publicDeck });
}
// Shuffle public deck.
@ -79,12 +79,12 @@ public class GameSetupSystem : ISystem
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 Card { Rank = 0, FaceDown = false });
world.AddComponent(leader, new OnField { Source = leader, Target = player });
world.AddComponent(leader, new OnField { Target = player });
// Create banner.
var banner = world.CreateEntity();
world.AddComponent(banner, new Banner());
world.AddComponent(banner, new OnField { Source = banner, Target = player });
world.AddComponent(banner, new OnField { Target = player });
// Create faction deck.
var factionDeck = world.CreateEntity();

View File

@ -42,7 +42,7 @@ public class ScoringSystem : ISystem
if (castles.Count > 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);
}

View File

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

View File

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

View File

@ -2,19 +2,14 @@ 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.
/// between two entities. The component is stored on the source 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
/// 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>

View File

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

View File

@ -203,19 +203,8 @@ public class World : IDisposable
ThrowIfNotAlive(entity);
// 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))
{
var oldRel = (IRelationship)(object)old;

View File

@ -1,4 +1,3 @@
using System.Reflection;
using MessagePack;
namespace OECS;
@ -94,32 +93,10 @@ public static class WorldSerializer
$"Ensure the component type is used with the World " +
$"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);
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);
}
}
}
/// <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();
}