refactor: simplify component iteration and singleton handling

Update the ECS engine to support automatic mutation tracking for
singletons during iteration, removing the need for manual
`MarkModified` calls.

- Update `ComponentDiscoveryGenerator` to detect singleton components.
- Refactor `PlaceMarkCommand` and `WinCheckSystem` to leverage
  automatic mutation marking.
- Replace `ForEach` usage with `Select` and `ItemN` access in tests
  and systems to align with the updated iterator API.
This commit is contained in:
hypercross 2026-07-21 09:15:27 +08:00
parent b98e8d66af
commit 96d732d6ab
6 changed files with 76 additions and 56 deletions

View File

@ -12,9 +12,10 @@ public class GameFlowTests
{ {
var (world, _) = SetupGame(); var (world, _) = SetupGame();
var query = world.Query().With<Cell>().Without<Mark>().Build(); var query = new Query<Cell>().Without<Mark>();
var emptyCount = 0; var emptyCount = 0;
world.ForEach(query, (Entity e, ref Cell cell) => emptyCount++); using var iter = world.Select(query);
while (iter.MoveNext()) emptyCount++;
emptyCount.Should().Be(9); emptyCount.Should().Be(9);
} }
@ -27,15 +28,15 @@ public class GameFlowTests
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
group.RunLogical(); group.RunLogical();
var query = world.Query().With<Cell>().With<Mark>().Build();
var markedCount = 0; var markedCount = 0;
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) => using var iter = world.Select<Cell, Mark>();
while (iter.MoveNext())
{ {
markedCount++; markedCount++;
cell.Row.Should().Be(0); iter.Item1.Row.Should().Be(0);
cell.Col.Should().Be(0); iter.Item1.Col.Should().Be(0);
mark.Player.Should().Be(Player.X); iter.Item2.Player.Should().Be(Player.X);
}); }
markedCount.Should().Be(1); markedCount.Should().Be(1);
} }
@ -65,12 +66,12 @@ public class GameFlowTests
group.RunLogical(); group.RunLogical();
// Only one mark should exist at (0,0), and it should still be X. // Only one mark should exist at (0,0), and it should still be X.
var query = world.Query().With<Cell>().With<Mark>().Build();
var marks = new List<(int Row, int Col, Player Player)>(); var marks = new List<(int Row, int Col, Player Player)>();
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) => using var iter3 = world.Select<Cell, Mark>();
while (iter3.MoveNext())
{ {
marks.Add((cell.Row, cell.Col, mark.Player)); marks.Add((iter3.Item1.Row, iter3.Item1.Col, iter3.Item2.Player));
}); }
marks.Should().ContainSingle() marks.Should().ContainSingle()
.Which.Should().Be((0, 0, Player.X)); .Which.Should().Be((0, 0, Player.X));
@ -168,12 +169,12 @@ public class GameFlowTests
state.CurrentPlayer.Should().Be(Player.X); state.CurrentPlayer.Should().Be(Player.X);
state.MoveCount.Should().Be(2); state.MoveCount.Should().Be(2);
var query = world2.Query().With<Cell>().With<Mark>().Build();
var marks = new List<(int Row, int Col, Player Player)>(); var marks = new List<(int Row, int Col, Player Player)>();
world2.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) => using var iter2 = world2.Select<Cell, Mark>();
while (iter2.MoveNext())
{ {
marks.Add((cell.Row, cell.Col, mark.Player)); marks.Add((iter2.Item1.Row, iter2.Item1.Col, iter2.Item2.Player));
}); }
marks.Should().BeEquivalentTo([(0, 0, Player.X), (1, 0, Player.O)]); marks.Should().BeEquivalentTo([(0, 0, Player.X), (1, 0, Player.O)]);
} }

View File

@ -163,8 +163,8 @@ public class PlayTests
using (var iter = world.Select<Cell, Mark>()) using (var iter = world.Select<Cell, Mark>())
{ {
while (iter.MoveNext()) while (iter.MoveNext())
grid[iter.Current1.Row, iter.Current1.Col] = grid[iter.Item1.Row, iter.Item1.Col] =
iter.Current2.Player == Player.X ? 'X' : 'O'; iter.Item2.Player == Player.X ? 'X' : 'O';
} }
for (int r = 0; r < 3; r++) for (int r = 0; r < 3; r++)
@ -253,10 +253,10 @@ public class PlayTests
private static List<(int Row, int Col)> GetEmptyCells(World world) private static List<(int Row, int Col)> GetEmptyCells(World world)
{ {
var empty = new List<(int, int)>(); var empty = new List<(int, int)>();
var query = world.Query().With<Cell>().Without<Mark>().Build(); var query = new Query<Cell>().Without<Mark>();
using var iter = world.Select<Cell>(query); using var iter = world.Select(query);
while (iter.MoveNext()) while (iter.MoveNext())
empty.Add((iter.Current1.Row, iter.Current1.Col)); empty.Add((iter.Item1.Row, iter.Item1.Col));
return empty; return empty;
} }
@ -265,7 +265,7 @@ public class PlayTests
var grid = new Player[3, 3]; var grid = new Player[3, 3];
using (var iter = world.Select<Cell, Mark>()) using (var iter = world.Select<Cell, Mark>())
while (iter.MoveNext()) while (iter.MoveNext())
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player; grid[iter.Item1.Row, iter.Item1.Col] = iter.Item2.Player;
grid[row, col] = player; grid[row, col] = player;
for (int r = 0; r < 3; r++) for (int r = 0; r < 3; r++)

View File

@ -212,8 +212,8 @@ public class SnapshotTests
{ {
while (iter.MoveNext()) while (iter.MoveNext())
{ {
grid[iter.Current1.Row, iter.Current1.Col] = grid[iter.Item1.Row, iter.Item1.Col] =
iter.Current2.Player == Player.X ? 'X' : 'O'; iter.Item2.Player == Player.X ? 'X' : 'O';
} }
} }

View File

@ -15,22 +15,23 @@ public struct PlaceMarkCommand : ICommand
public void Execute(World world) public void Execute(World world)
{ {
ref var state = ref world.GetSingleton<GameState>(); // Read-only check before iteration — never auto-marks.
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
if (state.Status != GameStatus.Playing)
return; return;
// Find the cell entity at (Row, Col) that has no Mark. // Find the cell entity at (Row, Col) that has no Mark.
// Use the iterator API with early-exit via break. var query = new Query<Cell>().Without<Mark>();
var query = world.Query().With<Cell>().Without<Mark>().Build();
Entity? target = null; Entity? target = null;
using var iter = world.Select<Cell>(query); // Fetch singleton ref inside iteration so mutations are auto-marked.
using var iter = world.Select(query);
ref var state = ref world.GetSingleton<GameState>();
while (iter.MoveNext()) while (iter.MoveNext())
{ {
if (iter.Current1.Row == Row && iter.Current1.Col == Col) if (iter.Item1.Row == Row && iter.Item1.Col == Col)
{ {
target = iter.CurrentEntity; target = iter.Entity;
break; break;
} }
} }
@ -38,13 +39,11 @@ public struct PlaceMarkCommand : ICommand
if (target == null) if (target == null)
return; // Cell already occupied or invalid position. return; // Cell already occupied or invalid position.
// Place the mark. ComponentAdded is sufficient — MarkModified // Place the mark.
// is only needed when mutating an existing component via GetComponent<T>.
world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer }); world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer });
// Advance turn. // Advance turn. Mutations auto-marked via EndIteration — no MarkModified needed.
state.MoveCount++; state.MoveCount++;
state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X; state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
world.MarkModified<GameState>(World.SingletonEntity);
} }
} }

View File

@ -15,15 +15,17 @@ public class WinCheckSystem : ISystem
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing) if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
return; return;
// Get singleton ref inside iteration so all mutations are auto-marked
// by EndIteration — no MarkModified calls needed.
using var iter = world.Select<Cell, Mark>();
ref var state = ref world.GetSingleton<GameState>(); ref var state = ref world.GetSingleton<GameState>();
// Build a 3×3 grid of marks using the iterator API. // Build a 3×3 grid of marks.
var grid = new Player[3, 3]; var grid = new Player[3, 3];
using var iter = world.Select<Cell, Mark>();
while (iter.MoveNext()) while (iter.MoveNext())
{ {
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player; grid[iter.Item1.Row, iter.Item1.Col] = iter.Item2.Player;
} }
// Check rows. // Check rows.
@ -31,7 +33,7 @@ public class WinCheckSystem : ISystem
{ {
if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner)) if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner))
{ {
SetWinner(world, ref state, winner); SetWinner(ref state, winner);
return; return;
} }
} }
@ -41,7 +43,7 @@ public class WinCheckSystem : ISystem
{ {
if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner)) if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner))
{ {
SetWinner(world, ref state, winner); SetWinner(ref state, winner);
return; return;
} }
} }
@ -49,12 +51,12 @@ public class WinCheckSystem : ISystem
// Check diagonals. // Check diagonals.
if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1)) if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1))
{ {
SetWinner(world, ref state, diag1); SetWinner(ref state, diag1);
return; return;
} }
if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2)) if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2))
{ {
SetWinner(world, ref state, diag2); SetWinner(ref state, diag2);
return; return;
} }
@ -62,7 +64,6 @@ public class WinCheckSystem : ISystem
if (state.MoveCount >= 9) if (state.MoveCount >= 9)
{ {
state.Status = GameStatus.Draw; state.Status = GameStatus.Draw;
world.MarkModified<GameState>(World.SingletonEntity);
} }
} }
@ -77,9 +78,8 @@ public class WinCheckSystem : ISystem
return false; return false;
} }
private static void SetWinner(World world, ref GameState state, Player winner) private static void SetWinner(ref GameState state, Player winner)
{ {
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon; state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
world.MarkModified<GameState>(World.SingletonEntity);
} }
} }

View File

@ -44,6 +44,15 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
"GetSources", "GetSources",
}; };
private static readonly HashSet<string> _singletonMethods = new()
{
"SetSingleton",
"GetSingleton",
"ReadSingleton",
"HasSingleton",
"RemoveSingleton",
};
private static readonly HashSet<string> _forEachNames = new() private static readonly HashSet<string> _forEachNames = new()
{ {
"ForEach", "ForEach",
@ -57,7 +66,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
predicate: IsCandidateInvocation, predicate: IsCandidateInvocation,
transform: ExtractComponentType) transform: ExtractComponentType)
.Where(t => t.FullyQualifiedName != null) .Where(t => t.FullyQualifiedName != null)
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship)); .Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship, t.IsSingleton));
// Also collect ForEach type arguments from the World class. // Also collect ForEach type arguments from the World class.
var forEachCalls = context.SyntaxProvider var forEachCalls = context.SyntaxProvider
@ -104,7 +113,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
return false; return false;
} }
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship) ExtractComponentType( private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship, bool IsSingleton) ExtractComponentType(
GeneratorSyntaxContext ctx, CancellationToken _) GeneratorSyntaxContext ctx, CancellationToken _)
{ {
if (ctx.Node is not InvocationExpressionSyntax invocation) if (ctx.Node is not InvocationExpressionSyntax invocation)
@ -140,7 +149,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
bool isRelationship = typeSymbol.AllInterfaces.Any(i => bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship"); i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
return (fqn, aqn, isRelationship); bool isSingleton = _singletonMethods.Contains(genericName.Identifier.Text);
return (fqn, aqn, isRelationship, isSingleton);
} }
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> ExtractForEachTypes( private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> ExtractForEachTypes(
@ -177,21 +188,29 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
} }
private static void GenerateRegistry(SourceProductionContext context, private static void GenerateRegistry(SourceProductionContext context,
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Left, (ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> Left,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data) ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data)
{ {
var (invocations, forEachTypes) = data; var (invocations, forEachTypes) = data;
// Collect unique types by fully-qualified name, deduplicating. // Collect unique types by fully-qualified name, deduplicating.
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel)>(); var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>();
foreach (var t in invocations) foreach (var t in invocations)
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship); {
if (typeMap.TryGetValue(t.FullyQualifiedName, out var existing))
typeMap[t.FullyQualifiedName] = (existing.Fqn, existing.Aqn, existing.IsRel || t.IsRelationship, existing.IsSingleton || t.IsSingleton);
else
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, t.IsSingleton);
}
foreach (var t in forEachTypes) foreach (var t in forEachTypes)
{ {
if (!typeMap.ContainsKey(t.FullyQualifiedName)) if (!typeMap.ContainsKey(t.FullyQualifiedName))
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship); typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, false);
else if (t.IsRelationship) else if (t.IsRelationship)
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, true); {
var existing = typeMap[t.FullyQualifiedName];
typeMap[t.FullyQualifiedName] = (existing.Fqn, existing.Aqn, true, existing.IsSingleton);
}
} }
if (typeMap.Count == 0) if (typeMap.Count == 0)
@ -213,7 +232,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
sb.AppendLine(" {"); sb.AppendLine(" {");
bool first = true; bool first = true;
foreach (var (fqn, aqn, isRel) in typeMap.Values.OrderBy(t => t.Fqn)) foreach (var (fqn, aqn, isRel, isSingleton) in typeMap.Values.OrderBy(t => t.Fqn))
{ {
if (!first) if (!first)
sb.AppendLine(","); sb.AppendLine(",");
@ -225,7 +244,8 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),"); sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>"); sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data)),"); sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data)),");
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data)"); sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data),");
sb.AppendLine($" isSingleton: {(isSingleton ? "true" : "false")}");
sb.Append(" )"); sb.Append(" )");
} }