Compare commits
7 Commits
5594515a53
...
b066ac2eba
| Author | SHA1 | Date |
|---|---|---|
|
|
b066ac2eba | |
|
|
10c4caed90 | |
|
|
f8d0fe314c | |
|
|
343bcd5b2e | |
|
|
e1fb197474 | |
|
|
b4661e714e | |
|
|
7946f4bafd |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Game.Blackjack.Tests</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Game.Blackjack;
|
||||||
|
using OECS;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Game.Blackjack.Tests;
|
||||||
|
|
||||||
|
public class GameFlowTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NewGame_StartsInBettingPhase()
|
||||||
|
{
|
||||||
|
var (world, _) = SetupGame();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.Betting);
|
||||||
|
state.Chips.Should().Be(100);
|
||||||
|
state.RoundNumber.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceBet_AdvancesToDealing()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.PlayerTurn); // Dealing → PlayerTurn happens automatically
|
||||||
|
state.CurrentBet.Should().Be(10);
|
||||||
|
state.Chips.Should().Be(90);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceBet_RejectsInsufficientChips()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 200 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.Betting); // Should not advance
|
||||||
|
state.Chips.Should().Be(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceBet_RejectsZeroOrNegative()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 });
|
||||||
|
group.RunLogical();
|
||||||
|
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.Betting);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = -5 });
|
||||||
|
group.RunLogical();
|
||||||
|
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.Betting);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dealing_CreatesDeckAndHands()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
// Should have a deck entity.
|
||||||
|
var deckEntity = FindEntity<Deck>(world);
|
||||||
|
deckEntity.Should().NotBe(Entity.Null);
|
||||||
|
|
||||||
|
// Should have player and dealer hand entities.
|
||||||
|
var playerHand = FindEntity<PlayerHand>(world);
|
||||||
|
playerHand.Should().NotBe(Entity.Null);
|
||||||
|
|
||||||
|
var dealerHand = FindEntity<DealerHand>(world);
|
||||||
|
dealerHand.Should().NotBe(Entity.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dealing_DealsTwoCardsToEach()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var playerHand = FindEntity<PlayerHand>(world);
|
||||||
|
var dealerHand = FindEntity<DealerHand>(world);
|
||||||
|
|
||||||
|
CountCardsInHand(world, playerHand).Should().Be(2);
|
||||||
|
CountCardsInHand(world, dealerHand).Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hit_DrawsOneCard()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var playerHand = FindEntity<PlayerHand>(world);
|
||||||
|
var before = CountCardsInHand(world, playerHand);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new HitCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var after = CountCardsInHand(world, playerHand);
|
||||||
|
after.Should().Be(before + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stand_AdvancesToDealerTurn()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.RoundOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewRound_ResetsPhase()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// Play a round.
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
// Start new round.
|
||||||
|
world.Commands.Enqueue(new NewRoundCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.Betting);
|
||||||
|
state.Result.Should().Be(RoundResult.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeterministicSeed_ProducesSameDeal()
|
||||||
|
{
|
||||||
|
var (world1, group1) = SetupGame(seed: 42);
|
||||||
|
world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group1.RunLogical();
|
||||||
|
var cards1 = GetAllCards(world1);
|
||||||
|
|
||||||
|
var (world2, group2) = SetupGame(seed: 42);
|
||||||
|
world2.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group2.RunLogical();
|
||||||
|
var cards2 = GetAllCards(world2);
|
||||||
|
|
||||||
|
cards1.Should().Equal(cards2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayerBust_LosesBet()
|
||||||
|
{
|
||||||
|
// Use a seed that produces a bust-prone hand, then hit repeatedly.
|
||||||
|
var (world, group) = SetupGame(seed: 12345);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
// Hit until bust or stand.
|
||||||
|
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
|
||||||
|
{
|
||||||
|
world.Commands.Enqueue(new HitCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game should have resolved.
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.RoundOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialization_RoundTrips()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var state = world2.ReadSingleton<GameState>();
|
||||||
|
state.Chips.Should().Be(90);
|
||||||
|
state.CurrentBet.Should().Be(10);
|
||||||
|
state.RoundNumber.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (World, SystemGroup) SetupGame(uint seed = 42)
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new DeckSetupSystem());
|
||||||
|
group.Add(new DealSystem());
|
||||||
|
group.Add(new PlayerBustCheckSystem());
|
||||||
|
group.Add(new DealerSystem());
|
||||||
|
|
||||||
|
world.SetSingleton(new GameState
|
||||||
|
{
|
||||||
|
Phase = GamePhase.Betting,
|
||||||
|
Result = RoundResult.None,
|
||||||
|
Chips = 100,
|
||||||
|
CurrentBet = 0,
|
||||||
|
RoundNumber = 1,
|
||||||
|
Seed = seed
|
||||||
|
});
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entity FindEntity<T>(World world) where T : struct
|
||||||
|
{
|
||||||
|
using var iter = world.Select<T>();
|
||||||
|
while (iter.MoveNext())
|
||||||
|
{
|
||||||
|
if (iter.CurrentEntity != World.SingletonEntity)
|
||||||
|
return iter.CurrentEntity;
|
||||||
|
}
|
||||||
|
return Entity.Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountCardsInHand(World world, Entity handEntity)
|
||||||
|
{
|
||||||
|
// Holds relationship: Source = card entity, Target = hand entity.
|
||||||
|
// GetSources returns all card entities that have a Holds pointing to handEntity.
|
||||||
|
return world.GetSources<Holds>(handEntity).Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<(Suit, Rank)> GetAllCards(World world)
|
||||||
|
{
|
||||||
|
var cards = new List<(Suit, Rank)>();
|
||||||
|
var query = world.Query().With<Card>().Build();
|
||||||
|
world.ForEach(query, (Entity e, ref Card card) =>
|
||||||
|
{
|
||||||
|
cards.Add((card.Suit, card.Rank));
|
||||||
|
});
|
||||||
|
cards.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
int cmp = a.Item1.CompareTo(b.Item1);
|
||||||
|
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
|
||||||
|
});
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Game.Blackjack;
|
||||||
|
using OECS;
|
||||||
|
using R3;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Game.Blackjack.Tests;
|
||||||
|
|
||||||
|
public class PlayTests
|
||||||
|
{
|
||||||
|
private const int StartingChips = 100;
|
||||||
|
private const int BetAmount = 10;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_BasicStrategy()
|
||||||
|
{
|
||||||
|
var log = new PlayLog();
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var card = world.ReadComponent<Card>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
|
||||||
|
});
|
||||||
|
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var state = world.ReadComponent<GameState>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} GameState = {state}");
|
||||||
|
});
|
||||||
|
|
||||||
|
var agent = new BasicStrategyAgent();
|
||||||
|
log.Header = $"Blackjack: Basic Strategy (seed=42, agent={agent})";
|
||||||
|
|
||||||
|
RunUntilDone(world, group, agent, log);
|
||||||
|
|
||||||
|
log.FinalSnapshot = SnapshotWorld(world);
|
||||||
|
|
||||||
|
var path = SavePlayLog("blackjack_basic_strategy.playlog", log);
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_RandomAgent()
|
||||||
|
{
|
||||||
|
var log = new PlayLog();
|
||||||
|
var (world, group) = SetupGame(seed: 123);
|
||||||
|
|
||||||
|
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var card = world.ReadComponent<Card>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
|
||||||
|
});
|
||||||
|
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var state = world.ReadComponent<GameState>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} GameState = {state}");
|
||||||
|
});
|
||||||
|
|
||||||
|
var agent = new RandomBlackjackAgent();
|
||||||
|
log.Header = $"Blackjack: Random Agent (seed=123, agent={agent})";
|
||||||
|
|
||||||
|
RunUntilDone(world, group, agent, log);
|
||||||
|
|
||||||
|
log.FinalSnapshot = SnapshotWorld(world);
|
||||||
|
|
||||||
|
var path = SavePlayLog("blackjack_random_agent.playlog", log);
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_WeightedPool()
|
||||||
|
{
|
||||||
|
var log = new PlayLog();
|
||||||
|
var (world, group) = SetupGame(seed: 77);
|
||||||
|
|
||||||
|
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var card = world.ReadComponent<Card>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
|
||||||
|
});
|
||||||
|
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var state = world.ReadComponent<GameState>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} GameState = {state}");
|
||||||
|
});
|
||||||
|
|
||||||
|
var pool = new WeightedAgentPool();
|
||||||
|
pool.Add(new BasicStrategyAgent(), 7);
|
||||||
|
pool.Add(new RandomBlackjackAgent(), 3);
|
||||||
|
|
||||||
|
var agent = pool.Pick();
|
||||||
|
log.Header = $"Blackjack: Weighted Pool (seed=77, agent={agent})";
|
||||||
|
|
||||||
|
RunUntilDone(world, group, agent, log);
|
||||||
|
|
||||||
|
log.FinalSnapshot = SnapshotWorld(world);
|
||||||
|
|
||||||
|
var path = SavePlayLog("blackjack_weighted_pool.playlog", log);
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Play Log ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class PlayLog
|
||||||
|
{
|
||||||
|
public string Header { get; set; } = "";
|
||||||
|
public readonly List<string> RoundSummaries = new();
|
||||||
|
public readonly List<string> Decisions = new();
|
||||||
|
public readonly List<string> Reactivity = new();
|
||||||
|
public string FinalSnapshot { get; set; } = "";
|
||||||
|
|
||||||
|
public string Build()
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine(Header);
|
||||||
|
sb.AppendLine(new string('=', Header.Length));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("--- Round Summaries ---");
|
||||||
|
for (int i = 0; i < RoundSummaries.Count; i++)
|
||||||
|
sb.AppendLine($" Round {i + 1}: {RoundSummaries[i]}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("--- Decisions ---");
|
||||||
|
for (int i = 0; i < Decisions.Count; i++)
|
||||||
|
sb.AppendLine($" {i + 1}. {Decisions[i]}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("--- Reactivity ---");
|
||||||
|
foreach (var entry in Reactivity)
|
||||||
|
sb.AppendLine($" {entry}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("--- Final State ---");
|
||||||
|
sb.AppendLine(FinalSnapshot);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Round Runner ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (World, SystemGroup) SetupGame(uint seed)
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new DeckSetupSystem());
|
||||||
|
group.Add(new DealSystem());
|
||||||
|
group.Add(new PlayerBustCheckSystem());
|
||||||
|
group.Add(new DealerSystem());
|
||||||
|
|
||||||
|
world.SetSingleton(new GameState
|
||||||
|
{
|
||||||
|
Phase = GamePhase.Betting,
|
||||||
|
Result = RoundResult.None,
|
||||||
|
Chips = StartingChips,
|
||||||
|
CurrentBet = 0,
|
||||||
|
RoundNumber = 1,
|
||||||
|
Seed = seed
|
||||||
|
});
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs rounds until the player runs out of chips (can't afford minimum bet)
|
||||||
|
/// or doubles their starting chips.
|
||||||
|
/// </summary>
|
||||||
|
private static void RunUntilDone(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log)
|
||||||
|
{
|
||||||
|
int totalRounds = 0;
|
||||||
|
int wins = 0;
|
||||||
|
int losses = 0;
|
||||||
|
int pushes = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
int chips = state.Chips;
|
||||||
|
|
||||||
|
// Stop conditions.
|
||||||
|
if (chips < BetAmount)
|
||||||
|
{
|
||||||
|
log.Header += $" — BUSTED after {totalRounds} rounds";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (chips >= StartingChips * 2)
|
||||||
|
{
|
||||||
|
log.Header += $" — DOUBLED after {totalRounds} rounds";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: max 200 rounds to prevent infinite loops.
|
||||||
|
if (totalRounds >= 200)
|
||||||
|
{
|
||||||
|
log.Header += $" — MAX ROUNDS ({totalRounds})";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place bet and run dealing.
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
// Player turn: agent decides hit/stand.
|
||||||
|
int roundHits = 0;
|
||||||
|
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
|
||||||
|
{
|
||||||
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
||||||
|
var decision = agent.Decide(world);
|
||||||
|
log.Decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
|
||||||
|
|
||||||
|
if (decision == BlackjackDecision.Hit)
|
||||||
|
{
|
||||||
|
world.Commands.Enqueue(new HitCommand());
|
||||||
|
roundHits++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
group.RunLogical();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record round result.
|
||||||
|
state = world.ReadSingleton<GameState>();
|
||||||
|
int newChips = state.Chips;
|
||||||
|
int delta = newChips - chips;
|
||||||
|
string summary = $"{state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}";
|
||||||
|
log.RoundSummaries.Add(summary);
|
||||||
|
|
||||||
|
switch (state.Result)
|
||||||
|
{
|
||||||
|
case RoundResult.PlayerWin:
|
||||||
|
case RoundResult.DealerBust:
|
||||||
|
wins++;
|
||||||
|
break;
|
||||||
|
case RoundResult.DealerWin:
|
||||||
|
case RoundResult.PlayerBust:
|
||||||
|
losses++;
|
||||||
|
break;
|
||||||
|
case RoundResult.Push:
|
||||||
|
pushes++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRounds++;
|
||||||
|
|
||||||
|
// Start next round.
|
||||||
|
world.Commands.Enqueue(new NewRoundCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Header += $" | W:{wins} L:{losses} P:{pushes}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Snapshot ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string SnapshotWorld(World world)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
sb.AppendLine($"Phase: {state.Phase}");
|
||||||
|
sb.AppendLine($"Chips: {state.Chips}, Bet: {state.CurrentBet}");
|
||||||
|
sb.AppendLine($"Round: {state.RoundNumber}");
|
||||||
|
sb.AppendLine($"Result: {state.Result}");
|
||||||
|
|
||||||
|
var playerHand = FindEntity<PlayerHand>(world);
|
||||||
|
if (playerHand != Entity.Null)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"Player ({HandUtil.CalculateHand(world, new PlayerHand())}):");
|
||||||
|
foreach (var card in GetHandCards(world, playerHand))
|
||||||
|
sb.AppendLine($" {card}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var dealerHand = FindEntity<DealerHand>(world);
|
||||||
|
if (dealerHand != Entity.Null)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"Dealer ({HandUtil.CalculateHand(world, new DealerHand())}):");
|
||||||
|
foreach (var card in GetHandCards(world, dealerHand))
|
||||||
|
sb.AppendLine($" {card}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entity FindEntity<T>(World world) where T : struct
|
||||||
|
{
|
||||||
|
using var iter = world.Select<T>();
|
||||||
|
while (iter.MoveNext())
|
||||||
|
if (iter.CurrentEntity != World.SingletonEntity)
|
||||||
|
return iter.CurrentEntity;
|
||||||
|
return Entity.Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetHandCards(World world, Entity hand)
|
||||||
|
{
|
||||||
|
var cards = new List<string>();
|
||||||
|
foreach (var cardEntity in world.GetSources<Holds>(hand))
|
||||||
|
{
|
||||||
|
var card = world.ReadComponent<Card>(cardEntity);
|
||||||
|
cards.Add($"{card.Rank} of {card.Suit}");
|
||||||
|
}
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File I/O ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string SavePlayLog(string filename, PlayLog log)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var path = Path.Combine(dir, filename);
|
||||||
|
File.WriteAllText(path, log.Build());
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReadAndVerify(string path)
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(path);
|
||||||
|
|
||||||
|
content.Should().Contain("--- Round Summaries ---");
|
||||||
|
content.Should().Contain("--- Decisions ---");
|
||||||
|
content.Should().Contain("--- Reactivity ---");
|
||||||
|
content.Should().Contain("--- Final State ---");
|
||||||
|
content.Should().Contain("Result:");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agents ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private enum BlackjackDecision { Hit, Stand }
|
||||||
|
|
||||||
|
private interface IBlackjackAgent
|
||||||
|
{
|
||||||
|
BlackjackDecision Decide(World world);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BasicStrategyAgent : IBlackjackAgent
|
||||||
|
{
|
||||||
|
public BlackjackDecision Decide(World world)
|
||||||
|
{
|
||||||
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
||||||
|
return total < 17 ? BlackjackDecision.Hit : BlackjackDecision.Stand;
|
||||||
|
}
|
||||||
|
public override string ToString() => "BasicStrategy";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RandomBlackjackAgent : IBlackjackAgent
|
||||||
|
{
|
||||||
|
private static readonly Random _rng = new();
|
||||||
|
public BlackjackDecision Decide(World world)
|
||||||
|
{
|
||||||
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
||||||
|
if (total >= 21) return BlackjackDecision.Stand;
|
||||||
|
return _rng.Next(2) == 0 ? BlackjackDecision.Hit : BlackjackDecision.Stand;
|
||||||
|
}
|
||||||
|
public override string ToString() => "Random";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WeightedAgentPool
|
||||||
|
{
|
||||||
|
private readonly List<(IBlackjackAgent Agent, int Weight)> _agents = new();
|
||||||
|
private int _totalWeight;
|
||||||
|
private static readonly Random _rng = new();
|
||||||
|
|
||||||
|
public void Add(IBlackjackAgent agent, int weight)
|
||||||
|
{
|
||||||
|
_agents.Add((agent, weight));
|
||||||
|
_totalWeight += weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBlackjackAgent Pick()
|
||||||
|
{
|
||||||
|
int roll = _rng.Next(_totalWeight);
|
||||||
|
int cumulative = 0;
|
||||||
|
foreach (var (agent, weight) in _agents)
|
||||||
|
{
|
||||||
|
cumulative += weight;
|
||||||
|
if (roll < cumulative)
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
return _agents[^1].Agent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Game.Blackjack;
|
||||||
|
using OECS;
|
||||||
|
using R3;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Game.Blackjack.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baseline snapshot and log tests for Blackjack.
|
||||||
|
/// Captures the world state as text and R3 change logs so they can be
|
||||||
|
/// reviewed manually and compared across changes.
|
||||||
|
/// </summary>
|
||||||
|
public class SnapshotTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public SnapshotTests(ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
_output = output;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_InitialState()
|
||||||
|
{
|
||||||
|
var (world, _) = SetupGame();
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("Phase: Betting");
|
||||||
|
snapshot.Should().Contain("Chips: 100");
|
||||||
|
snapshot.Should().Contain("Round: 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterDeal()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("Phase: PlayerTurn");
|
||||||
|
snapshot.Should().Contain("Chips: 90");
|
||||||
|
snapshot.Should().Contain("Bet: 10");
|
||||||
|
snapshot.Should().Contain("Player hand:");
|
||||||
|
snapshot.Should().Contain("Dealer hand:");
|
||||||
|
snapshot.Should().Contain("Cards in deck:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterHit()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new HitCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
// With seed 42, one hit may bust. Just verify the game resolved.
|
||||||
|
snapshot.Should().NotContain("Phase: Dealing");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterStand()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("Phase: RoundOver");
|
||||||
|
snapshot.Should().Contain("Result:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Log_ReactivityDuringRound()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
var log = new List<string>();
|
||||||
|
|
||||||
|
world.ObserveEntityChanges().Subscribe(change =>
|
||||||
|
{
|
||||||
|
log.Add($"[entity] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
log.Add($"[card] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
log.Add($"[gamestate] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var logText = string.Join("\n", log);
|
||||||
|
_output.WriteLine(logText);
|
||||||
|
|
||||||
|
// Should have entity creations (deck, hands, cards) and component changes.
|
||||||
|
log.Should().Contain(l => l.Contains("EntityAdded"));
|
||||||
|
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card"));
|
||||||
|
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialization_RoundTrip_PreservesCoreState()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var before = SnapshotWorld(world);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var after = SnapshotWorld(world2);
|
||||||
|
|
||||||
|
_output.WriteLine("=== Before ===");
|
||||||
|
_output.WriteLine(before);
|
||||||
|
_output.WriteLine("=== After ===");
|
||||||
|
_output.WriteLine(after);
|
||||||
|
|
||||||
|
// Core game state must round-trip.
|
||||||
|
var state = world2.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.PlayerTurn);
|
||||||
|
state.Chips.Should().Be(90);
|
||||||
|
state.CurrentBet.Should().Be(10);
|
||||||
|
state.RoundNumber.Should().Be(1);
|
||||||
|
|
||||||
|
// Card entities must be preserved.
|
||||||
|
after.Should().Contain("Card entities: 52");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (World, SystemGroup) SetupGame(uint seed = 42)
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new DeckSetupSystem());
|
||||||
|
group.Add(new DealSystem());
|
||||||
|
group.Add(new PlayerBustCheckSystem());
|
||||||
|
group.Add(new DealerSystem());
|
||||||
|
|
||||||
|
world.SetSingleton(new GameState
|
||||||
|
{
|
||||||
|
Phase = GamePhase.Betting,
|
||||||
|
Result = RoundResult.None,
|
||||||
|
Chips = 100,
|
||||||
|
CurrentBet = 0,
|
||||||
|
RoundNumber = 1,
|
||||||
|
Seed = seed
|
||||||
|
});
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produces a human-readable textual snapshot of the world state.
|
||||||
|
/// </summary>
|
||||||
|
private static string SnapshotWorld(World world)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
// GameState singleton.
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
sb.AppendLine($"Phase: {state.Phase}");
|
||||||
|
sb.AppendLine($"Chips: {state.Chips}");
|
||||||
|
sb.AppendLine($"Bet: {state.CurrentBet}");
|
||||||
|
sb.AppendLine($"Round: {state.RoundNumber}");
|
||||||
|
sb.AppendLine($"Result: {state.Result}");
|
||||||
|
sb.AppendLine($"Seed: {state.Seed}");
|
||||||
|
|
||||||
|
// Deck entity.
|
||||||
|
var deckEntity = FindEntity<Deck>(world);
|
||||||
|
if (deckEntity != Entity.Null)
|
||||||
|
{
|
||||||
|
var cardsInDeck = world.GetSources<InDeck>(deckEntity).ToList();
|
||||||
|
sb.AppendLine($"Cards in deck: {cardsInDeck.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player hand.
|
||||||
|
var playerHand = FindEntity<PlayerHand>(world);
|
||||||
|
if (playerHand != Entity.Null)
|
||||||
|
{
|
||||||
|
var cards = world.GetSources<Holds>(playerHand).ToList();
|
||||||
|
sb.AppendLine($"Player hand: {cards.Count} cards");
|
||||||
|
foreach (var cardEntity in cards)
|
||||||
|
{
|
||||||
|
var card = world.ReadComponent<Card>(cardEntity);
|
||||||
|
sb.AppendLine($" {card.Rank} of {card.Suit}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dealer hand.
|
||||||
|
var dealerHand = FindEntity<DealerHand>(world);
|
||||||
|
if (dealerHand != Entity.Null)
|
||||||
|
{
|
||||||
|
var cards = world.GetSources<Holds>(dealerHand).ToList();
|
||||||
|
sb.AppendLine($"Dealer hand: {cards.Count} cards");
|
||||||
|
foreach (var cardEntity in cards)
|
||||||
|
{
|
||||||
|
var card = world.ReadComponent<Card>(cardEntity);
|
||||||
|
sb.AppendLine($" {card.Rank} of {card.Suit}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All entities.
|
||||||
|
int entityCount = 0;
|
||||||
|
using (var iter = world.Select<Card>())
|
||||||
|
{
|
||||||
|
while (iter.MoveNext()) entityCount++;
|
||||||
|
}
|
||||||
|
sb.AppendLine($"Card entities: {entityCount}");
|
||||||
|
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entity FindEntity<T>(World world) where T : struct
|
||||||
|
{
|
||||||
|
using var iter = world.Select<T>();
|
||||||
|
while (iter.MoveNext())
|
||||||
|
{
|
||||||
|
if (iter.CurrentEntity != World.SingletonEntity)
|
||||||
|
return iter.CurrentEntity;
|
||||||
|
}
|
||||||
|
return Entity.Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<RootNamespace>Game.Blackjack</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Source generator: referenced as a built DLL. -->
|
||||||
|
<Target Name="EnsureSourceGenBuilt" BeforeTargets="CoreCompile">
|
||||||
|
<MSBuild Projects="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||||
|
Targets="Build"
|
||||||
|
Properties="Configuration=$(Configuration)" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Analyzer Include="..\OECS.SourceGen\bin\$(Configuration)\netstandard2.0\OECS.SourceGen.dll" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Player hits: draws one card from the deck to the player's hand.
|
/// Player hits: draws one card from the deck to the player's hand.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a new round after the previous one ended.
|
/// Starts a new round after the previous one ended.
|
||||||
|
|
@ -46,6 +46,7 @@ public struct NewRoundCommand : ICommand
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.RoundNumber++;
|
||||||
state.Phase = GamePhase.Betting;
|
state.Phase = GamePhase.Betting;
|
||||||
state.Result = RoundResult.None;
|
state.Result = RoundResult.None;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
world.MarkModified<GameState>(World.SingletonEntity);
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Places a bet and advances the game to the dealing phase.
|
/// Places a bet and advances the game to the dealing phase.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Player stands: advance to the dealer's turn.
|
/// Player stands: advance to the dealer's turn.
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A playing card with a suit and rank.
|
/// A playing card with a suit and rank.
|
||||||
/// One entity per card.
|
/// One entity per card.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Card
|
public record struct Card
|
||||||
{
|
{
|
||||||
[Key(0)] public Suit Suit;
|
[Key(0)] public Suit Suit;
|
||||||
[Key(1)] public Rank Rank;
|
[Key(1)] public Rank Rank;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tag component marking the dealer's hand entity.
|
/// Tag component marking the dealer's hand entity.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tag component marking the deck entity.
|
/// Tag component marking the deck entity.
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relationship from a hand entity to a card entity,
|
/// Relationship from a hand entity to a card entity,
|
||||||
/// representing that the hand holds this card.
|
/// representing that the hand holds this card.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Holds : IRelationship
|
public record struct Holds : IRelationship
|
||||||
{
|
{
|
||||||
[Key(0)] public Entity Source { get; set; }
|
[Key(0)] public Entity Source { get; set; }
|
||||||
[Key(1)] public Entity Target { get; set; }
|
[Key(1)] public Entity Target { get; set; }
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relationship from a card entity to the deck entity,
|
/// Relationship from a card entity to the deck entity,
|
||||||
/// representing that the card is in the deck.
|
/// representing that the card is in the deck.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct InDeck : IRelationship
|
public record struct InDeck : IRelationship
|
||||||
{
|
{
|
||||||
[Key(0)] public Entity Source { get; set; }
|
[Key(0)] public Entity Source { get; set; }
|
||||||
[Key(1)] public Entity Target { get; set; }
|
[Key(1)] public Entity Target { get; set; }
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tag component marking the player's hand entity.
|
/// Tag component marking the player's hand entity.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
public enum Rank : byte
|
public enum Rank : byte
|
||||||
{
|
{
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
public enum Suit : byte
|
public enum Suit : byte
|
||||||
{
|
{
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
|
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
public enum GamePhase : byte
|
public enum GamePhase : byte
|
||||||
{
|
{
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global game state stored on the singleton entity.
|
/// Global game state stored on the singleton entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct GameState
|
public record struct GameState
|
||||||
{
|
{
|
||||||
[Key(0)] public GamePhase Phase;
|
[Key(0)] public GamePhase Phase;
|
||||||
[Key(1)] public RoundResult Result;
|
[Key(1)] public RoundResult Result;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
public enum RoundResult : byte
|
public enum RoundResult : byte
|
||||||
{
|
{
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deals initial two cards to player and dealer when entering the dealing phase.
|
/// Deals initial two cards to player and dealer when entering the dealing phase.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dealer draws cards until reaching 17 or higher,
|
/// Dealer draws cards until reaching 17 or higher,
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates the deck (52 cards + deck/hand entities) and shuffles
|
/// Creates the deck (52 cards + deck/hand entities) and shuffles
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper to calculate the blackjack value of a hand.
|
/// Helper to calculate the blackjack value of a hand.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Evaluates the player's hand total after each hit.
|
/// Evaluates the player's hand total after each hit.
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Game.TicTacToe.Tests</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Game.TicTacToe\TicTacToe.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Game.TicTacToe;
|
||||||
|
using OECS;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Game.TicTacToe.Tests;
|
||||||
|
|
||||||
|
public class GameFlowTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NewGame_HasEmptyBoard()
|
||||||
|
{
|
||||||
|
var (world, _) = SetupGame();
|
||||||
|
|
||||||
|
var query = world.Query().With<Cell>().Without<Mark>().Build();
|
||||||
|
var emptyCount = 0;
|
||||||
|
world.ForEach(query, (Entity e, ref Cell cell) => emptyCount++);
|
||||||
|
|
||||||
|
emptyCount.Should().Be(9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceMark_ClaimsCell()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var query = world.Query().With<Cell>().With<Mark>().Build();
|
||||||
|
var markedCount = 0;
|
||||||
|
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
|
||||||
|
{
|
||||||
|
markedCount++;
|
||||||
|
cell.Row.Should().Be(0);
|
||||||
|
cell.Col.Should().Be(0);
|
||||||
|
mark.Player.Should().Be(Player.X);
|
||||||
|
});
|
||||||
|
|
||||||
|
markedCount.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceMark_TogglesPlayer()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
|
||||||
|
group.RunLogical();
|
||||||
|
world.ReadSingleton<GameState>().CurrentPlayer.Should().Be(Player.O);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 }); // O
|
||||||
|
group.RunLogical();
|
||||||
|
world.ReadSingleton<GameState>().CurrentPlayer.Should().Be(Player.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceMark_CannotOverwriteClaimedCell()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
|
||||||
|
group.RunLogical();
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // O tries same cell
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
// 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)>();
|
||||||
|
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
|
||||||
|
{
|
||||||
|
marks.Add((cell.Row, cell.Col, mark.Player));
|
||||||
|
});
|
||||||
|
|
||||||
|
marks.Should().ContainSingle()
|
||||||
|
.Which.Should().Be((0, 0, Player.X));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void XWins_Row()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0
|
||||||
|
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OWins_Column()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (2,2), O: (1,2) → O wins col 0
|
||||||
|
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.OWon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void XWins_Diagonal()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// X: (0,0), O: (1,0), X: (1,1), O: (1,2), X: (2,2) → X wins diagonal
|
||||||
|
PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Draw_AllCellsFilled()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// Fill all 9 cells without a winner.
|
||||||
|
// X: (0,0) (0,2) (1,0) (2,1) (2,2)
|
||||||
|
// O: (0,1) (1,1) (1,2) (2,0)
|
||||||
|
PlayMoves(world, group,
|
||||||
|
(0, 0), (0, 1), (0, 2),
|
||||||
|
(1, 1), (1, 0), (1, 2),
|
||||||
|
(2, 1), (2, 0), (2, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.Draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovesAfterGameOver_AreIgnored()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// X wins.
|
||||||
|
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||||
|
|
||||||
|
// Try to place another mark — should be ignored.
|
||||||
|
var moveCountBefore = world.ReadSingleton<GameState>().MoveCount;
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 2, Col = 2 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().MoveCount.Should().Be(moveCountBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_RoundTrips()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// Play a few moves.
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
|
||||||
|
group.RunLogical();
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
// Save.
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
// Load into a new world.
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
// Verify state.
|
||||||
|
var state = world2.ReadSingleton<GameState>();
|
||||||
|
state.CurrentPlayer.Should().Be(Player.X);
|
||||||
|
state.MoveCount.Should().Be(2);
|
||||||
|
|
||||||
|
var query = world2.Query().With<Cell>().With<Mark>().Build();
|
||||||
|
var marks = new List<(int Row, int Col, Player Player)>();
|
||||||
|
world2.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
|
||||||
|
{
|
||||||
|
marks.Add((cell.Row, cell.Col, mark.Player));
|
||||||
|
});
|
||||||
|
|
||||||
|
marks.Should().BeEquivalentTo([(0, 0, Player.X), (1, 0, Player.O)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (World, SystemGroup) SetupGame()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new WinCheckSystem());
|
||||||
|
|
||||||
|
// Create the 9 cells.
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
|
||||||
|
|
||||||
|
world.SetSingleton(new GameState
|
||||||
|
{
|
||||||
|
CurrentPlayer = Player.X,
|
||||||
|
Status = GameStatus.Playing,
|
||||||
|
MoveCount = 0
|
||||||
|
});
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
|
||||||
|
{
|
||||||
|
foreach (var (row, col) in moves)
|
||||||
|
{
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
|
||||||
|
group.RunLogical();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Game.TicTacToe;
|
||||||
|
using OECS;
|
||||||
|
using R3;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Game.TicTacToe.Tests;
|
||||||
|
|
||||||
|
public class PlayTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Play_GreedyVsRandom()
|
||||||
|
{
|
||||||
|
var log = new PlayLog();
|
||||||
|
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
|
||||||
|
var agentO = new RandomAgent();
|
||||||
|
|
||||||
|
// Subscribe reactivity.
|
||||||
|
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var mark = world.ReadComponent<Mark>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
|
||||||
|
});
|
||||||
|
|
||||||
|
log.Header = "TicTacToe: Greedy X vs Random O";
|
||||||
|
|
||||||
|
RunGame(world, group, agentX, agentO, log);
|
||||||
|
|
||||||
|
log.FinalSnapshot = SnapshotWorld(world);
|
||||||
|
|
||||||
|
var path = SavePlayLog("tic_tac_toe_greedy_vs_random.playlog", log);
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_RandomVsRandom()
|
||||||
|
{
|
||||||
|
var log = new PlayLog();
|
||||||
|
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
var agentX = new RandomAgent();
|
||||||
|
var agentO = new RandomAgent();
|
||||||
|
|
||||||
|
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var mark = world.ReadComponent<Mark>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
|
||||||
|
});
|
||||||
|
|
||||||
|
log.Header = "TicTacToe: Random X vs Random O";
|
||||||
|
|
||||||
|
RunGame(world, group, agentX, agentO, log);
|
||||||
|
|
||||||
|
log.FinalSnapshot = SnapshotWorld(world);
|
||||||
|
|
||||||
|
var path = SavePlayLog("tic_tac_toe_random_vs_random.playlog", log);
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_GreedyVsGreedy()
|
||||||
|
{
|
||||||
|
var log = new PlayLog();
|
||||||
|
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
|
||||||
|
var agentO = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
|
||||||
|
|
||||||
|
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
var mark = world.ReadComponent<Mark>(change.Entity);
|
||||||
|
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
|
||||||
|
});
|
||||||
|
|
||||||
|
log.Header = "TicTacToe: Greedy X vs Greedy O";
|
||||||
|
|
||||||
|
RunGame(world, group, agentX, agentO, log);
|
||||||
|
|
||||||
|
log.FinalSnapshot = SnapshotWorld(world);
|
||||||
|
|
||||||
|
var path = SavePlayLog("tic_tac_toe_greedy_vs_greedy.playlog", log);
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Play Log ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class PlayLog
|
||||||
|
{
|
||||||
|
public string Header { get; set; } = "";
|
||||||
|
public readonly List<string> Moves = new();
|
||||||
|
public readonly List<string> Reactivity = new();
|
||||||
|
public string FinalSnapshot { get; set; } = "";
|
||||||
|
|
||||||
|
public string Build()
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine(Header);
|
||||||
|
sb.AppendLine(new string('=', Header.Length));
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("--- Moves ---");
|
||||||
|
for (int i = 0; i < Moves.Count; i++)
|
||||||
|
sb.AppendLine($" {i + 1}. {Moves[i]}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("--- Reactivity ---");
|
||||||
|
foreach (var entry in Reactivity)
|
||||||
|
sb.AppendLine($" {entry}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("--- Final Board ---");
|
||||||
|
sb.AppendLine(FinalSnapshot);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Game Runner ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (World, SystemGroup) SetupGame()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new WinCheckSystem());
|
||||||
|
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
|
||||||
|
|
||||||
|
world.SetSingleton(new GameState
|
||||||
|
{
|
||||||
|
CurrentPlayer = Player.X,
|
||||||
|
Status = GameStatus.Playing,
|
||||||
|
MoveCount = 0
|
||||||
|
});
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunGame(World world, SystemGroup group, IAgent agentX, IAgent agentO, PlayLog log)
|
||||||
|
{
|
||||||
|
while (world.ReadSingleton<GameState>().Status == GameStatus.Playing)
|
||||||
|
{
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
var agent = state.CurrentPlayer == Player.X ? agentX : agentO;
|
||||||
|
var cmd = agent.Decide(world);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(cmd);
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
log.Moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})");
|
||||||
|
}
|
||||||
|
|
||||||
|
var final = world.ReadSingleton<GameState>();
|
||||||
|
log.Header += $" — Result: {final.Status}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Snapshot ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string SnapshotWorld(World world)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
var grid = new char?[3, 3];
|
||||||
|
using (var iter = world.Select<Cell, Mark>())
|
||||||
|
{
|
||||||
|
while (iter.MoveNext())
|
||||||
|
grid[iter.Current1.Row, iter.Current1.Col] =
|
||||||
|
iter.Current2.Player == Player.X ? 'X' : 'O';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
{
|
||||||
|
sb.Append(" ");
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
sb.Append(grid[r, c]?.ToString() ?? ".");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File I/O ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string SavePlayLog(string filename, PlayLog log)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var path = Path.Combine(dir, filename);
|
||||||
|
File.WriteAllText(path, log.Build());
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReadAndVerify(string path)
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(path);
|
||||||
|
|
||||||
|
// Verify the play log is well-formed.
|
||||||
|
content.Should().Contain("--- Moves ---");
|
||||||
|
content.Should().Contain("--- Reactivity ---");
|
||||||
|
content.Should().Contain("--- Final Board ---");
|
||||||
|
|
||||||
|
// The game must have finished.
|
||||||
|
content.Should().Contain("Result:");
|
||||||
|
content.Should().NotContain("Result: Playing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agents ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private interface IAgent
|
||||||
|
{
|
||||||
|
PlaceMarkCommand Decide(World world);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RandomAgent : IAgent
|
||||||
|
{
|
||||||
|
private static readonly Random _rng = new();
|
||||||
|
public PlaceMarkCommand Decide(World world)
|
||||||
|
{
|
||||||
|
var empty = GetEmptyCells(world);
|
||||||
|
var (row, col) = empty[_rng.Next(empty.Count)];
|
||||||
|
return new PlaceMarkCommand { Row = row, Col = col };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GreedyAgent : IAgent
|
||||||
|
{
|
||||||
|
public enum Strategy { WinOrBlock }
|
||||||
|
private readonly Strategy _strategy;
|
||||||
|
private static readonly Random _rng = new();
|
||||||
|
public GreedyAgent(Strategy strategy) => _strategy = strategy;
|
||||||
|
|
||||||
|
public PlaceMarkCommand Decide(World world)
|
||||||
|
{
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
var me = state.CurrentPlayer;
|
||||||
|
var empty = GetEmptyCells(world);
|
||||||
|
|
||||||
|
foreach (var (r, c) in empty)
|
||||||
|
if (WouldWin(world, r, c, me))
|
||||||
|
return new PlaceMarkCommand { Row = r, Col = c };
|
||||||
|
|
||||||
|
var opponent = me == Player.X ? Player.O : Player.X;
|
||||||
|
foreach (var (r, c) in empty)
|
||||||
|
if (WouldWin(world, r, c, opponent))
|
||||||
|
return new PlaceMarkCommand { Row = r, Col = c };
|
||||||
|
|
||||||
|
if (empty.Any(c => c.Row == 1 && c.Col == 1))
|
||||||
|
return new PlaceMarkCommand { Row = 1, Col = 1 };
|
||||||
|
|
||||||
|
var (rr, cc) = empty[_rng.Next(empty.Count)];
|
||||||
|
return new PlaceMarkCommand { Row = rr, Col = cc };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<(int Row, int Col)> GetEmptyCells(World world)
|
||||||
|
{
|
||||||
|
var empty = new List<(int, int)>();
|
||||||
|
var query = world.Query().With<Cell>().Without<Mark>().Build();
|
||||||
|
using var iter = world.Select<Cell>(query);
|
||||||
|
while (iter.MoveNext())
|
||||||
|
empty.Add((iter.Current1.Row, iter.Current1.Col));
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool WouldWin(World world, int row, int col, Player player)
|
||||||
|
{
|
||||||
|
var grid = new Player[3, 3];
|
||||||
|
using (var iter = world.Select<Cell, Mark>())
|
||||||
|
while (iter.MoveNext())
|
||||||
|
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
|
||||||
|
grid[row, col] = player;
|
||||||
|
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player)
|
||||||
|
return true;
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player)
|
||||||
|
return true;
|
||||||
|
if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player)
|
||||||
|
return true;
|
||||||
|
if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Game.TicTacToe;
|
||||||
|
using OECS;
|
||||||
|
using R3;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Game.TicTacToe.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baseline snapshot and log tests for TicTacToe.
|
||||||
|
/// Captures the world state as text and R3 change logs so they can be
|
||||||
|
/// reviewed manually and compared across changes.
|
||||||
|
/// </summary>
|
||||||
|
public class SnapshotTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public SnapshotTests(ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
_output = output;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_InitialBoard()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
|
||||||
|
|
||||||
|
world.SetSingleton(new GameState
|
||||||
|
{
|
||||||
|
CurrentPlayer = Player.X,
|
||||||
|
Status = GameStatus.Playing,
|
||||||
|
MoveCount = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
// Verify baseline properties.
|
||||||
|
snapshot.Should().Contain("GameState: X's turn, 0 moves");
|
||||||
|
snapshot.Should().Contain("Cells: 9 total, 0 marked");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterThreeMoves()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// X: (0,0), O: (1,1), X: (2,2)
|
||||||
|
PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("GameState: O's turn, 3 moves");
|
||||||
|
snapshot.Should().Contain("Cells: 9 total, 3 marked");
|
||||||
|
snapshot.Should().Contain("(0,0): X");
|
||||||
|
snapshot.Should().Contain("(1,1): O");
|
||||||
|
snapshot.Should().Contain("(2,2): X");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_XWins()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0
|
||||||
|
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("GameState: XWon");
|
||||||
|
snapshot.Should().Contain("Cells: 9 total, 5 marked");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_Draw()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
PlayMoves(world, group,
|
||||||
|
(0, 0), (0, 1), (0, 2),
|
||||||
|
(1, 1), (1, 0), (1, 2),
|
||||||
|
(2, 1), (2, 0), (2, 2));
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("GameState: Draw");
|
||||||
|
snapshot.Should().Contain("Cells: 9 total, 9 marked");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Log_ReactivityDuringGame()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
var log = new List<string>();
|
||||||
|
|
||||||
|
// Subscribe to all entity-level changes.
|
||||||
|
world.ObserveEntityChanges().Subscribe(change =>
|
||||||
|
{
|
||||||
|
log.Add($"[entity] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to component-specific changes.
|
||||||
|
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
log.Add($"[mark] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
log.Add($"[gamestate] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play a full game: X wins on row 0.
|
||||||
|
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||||
|
|
||||||
|
var logText = string.Join("\n", log);
|
||||||
|
_output.WriteLine(logText);
|
||||||
|
|
||||||
|
// Verify key events appear in the log.
|
||||||
|
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Mark"));
|
||||||
|
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialization_RoundTrip_PreservesSnapshot()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
// Play a few moves.
|
||||||
|
PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
|
||||||
|
|
||||||
|
var before = SnapshotWorld(world);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var after = SnapshotWorld(world2);
|
||||||
|
|
||||||
|
_output.WriteLine("=== Before ===");
|
||||||
|
_output.WriteLine(before);
|
||||||
|
_output.WriteLine("=== After ===");
|
||||||
|
_output.WriteLine(after);
|
||||||
|
|
||||||
|
after.Should().Be(before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (World, SystemGroup) SetupGame()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new WinCheckSystem());
|
||||||
|
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
|
||||||
|
|
||||||
|
world.SetSingleton(new GameState
|
||||||
|
{
|
||||||
|
CurrentPlayer = Player.X,
|
||||||
|
Status = GameStatus.Playing,
|
||||||
|
MoveCount = 0
|
||||||
|
});
|
||||||
|
world.PostChanges();
|
||||||
|
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
|
||||||
|
{
|
||||||
|
foreach (var (row, col) in moves)
|
||||||
|
{
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
|
||||||
|
group.RunLogical();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produces a human-readable textual snapshot of the world state.
|
||||||
|
/// </summary>
|
||||||
|
private static string SnapshotWorld(World world)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
// Singleton: GameState.
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
var statusText = state.Status switch
|
||||||
|
{
|
||||||
|
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves",
|
||||||
|
GameStatus.XWon => "XWon",
|
||||||
|
GameStatus.OWon => "OWon",
|
||||||
|
GameStatus.Draw => "Draw",
|
||||||
|
_ => "Unknown"
|
||||||
|
};
|
||||||
|
sb.AppendLine($"GameState: {statusText}");
|
||||||
|
|
||||||
|
// Board: build a 3x3 grid.
|
||||||
|
var grid = new char?[3, 3];
|
||||||
|
using (var iter = world.Select<Cell, Mark>())
|
||||||
|
{
|
||||||
|
while (iter.MoveNext())
|
||||||
|
{
|
||||||
|
grid[iter.Current1.Row, iter.Current1.Col] =
|
||||||
|
iter.Current2.Player == Player.X ? 'X' : 'O';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalCells = 0;
|
||||||
|
int markedCells = 0;
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
{
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
{
|
||||||
|
totalCells++;
|
||||||
|
var mark = grid[r, c];
|
||||||
|
if (mark.HasValue)
|
||||||
|
{
|
||||||
|
markedCells++;
|
||||||
|
sb.AppendLine($" ({r},{c}): {mark.Value}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.AppendLine($" ({r},{c}): .");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked");
|
||||||
|
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Places the current player's mark on the cell at (Row, Col).
|
/// Places the current player's mark on the cell at (Row, Col).
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Identifies a board position. One entity per cell.
|
/// Identifies a board position. One entity per cell.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Cell
|
public record struct Cell
|
||||||
{
|
{
|
||||||
[Key(0)] public int Row;
|
[Key(0)] public int Row;
|
||||||
[Key(1)] public int Col;
|
[Key(1)] public int Col;
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A mark placed on a cell by a player.
|
/// A mark placed on a cell by a player.
|
||||||
/// Only present on cells that have been claimed.
|
/// Only present on cells that have been claimed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Mark
|
public record struct Mark
|
||||||
{
|
{
|
||||||
[Key(0)] public Player Player;
|
[Key(0)] public Player Player;
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
public enum Player : byte
|
public enum Player : byte
|
||||||
{
|
{
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal CSV loader that creates entities from a CSV file.
|
/// Minimal CSV loader that creates entities from a CSV file.
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global game state stored on the singleton entity.
|
/// Global game state stored on the singleton entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct GameState
|
public record struct GameState
|
||||||
{
|
{
|
||||||
[Key(0)] public Player CurrentPlayer;
|
[Key(0)] public Player CurrentPlayer;
|
||||||
[Key(1)] public GameStatus Status;
|
[Key(1)] public GameStatus Status;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
public enum GameStatus : byte
|
public enum GameStatus : byte
|
||||||
{
|
{
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// After each move, checks whether the game has been won or drawn.
|
/// After each move, checks whether the game has been won or drawn.
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<RootNamespace>Game.TicTacToe</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>TicTacToe</RootNamespace>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
@ -38,6 +38,10 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||||
// Query building
|
// Query building
|
||||||
"With",
|
"With",
|
||||||
"Without",
|
"Without",
|
||||||
|
|
||||||
|
// Iteration and relationships
|
||||||
|
"Select",
|
||||||
|
"GetSources",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> _forEachNames = new()
|
private static readonly HashSet<string> _forEachNames = new()
|
||||||
|
|
@ -53,7 +57,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!));
|
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship));
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -100,7 +104,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string? FullyQualifiedName, string? AssemblyQualifiedName) ExtractComponentType(
|
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship) ExtractComponentType(
|
||||||
GeneratorSyntaxContext ctx, CancellationToken _)
|
GeneratorSyntaxContext ctx, CancellationToken _)
|
||||||
{
|
{
|
||||||
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
||||||
|
|
@ -133,22 +137,25 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||||
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
|
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
|
||||||
: fqn;
|
: fqn;
|
||||||
|
|
||||||
return (fqn, aqn);
|
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
|
||||||
|
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
|
||||||
|
|
||||||
|
return (fqn, aqn, isRelationship);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> ExtractForEachTypes(
|
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> ExtractForEachTypes(
|
||||||
GeneratorSyntaxContext ctx, CancellationToken _)
|
GeneratorSyntaxContext ctx, CancellationToken _)
|
||||||
{
|
{
|
||||||
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
||||||
return ImmutableArray<(string, string)>.Empty;
|
return ImmutableArray<(string, string, bool)>.Empty;
|
||||||
|
|
||||||
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
|
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
|
||||||
return ImmutableArray<(string, string)>.Empty;
|
return ImmutableArray<(string, string, bool)>.Empty;
|
||||||
|
|
||||||
if (memberAccess.Name is not GenericNameSyntax genericName)
|
if (memberAccess.Name is not GenericNameSyntax genericName)
|
||||||
return ImmutableArray<(string, string)>.Empty;
|
return ImmutableArray<(string, string, bool)>.Empty;
|
||||||
|
|
||||||
var types = ImmutableArray.CreateBuilder<(string, string)>();
|
var types = ImmutableArray.CreateBuilder<(string, string, bool)>();
|
||||||
foreach (var typeArg in genericName.TypeArgumentList.Arguments)
|
foreach (var typeArg in genericName.TypeArgumentList.Arguments)
|
||||||
{
|
{
|
||||||
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
|
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
|
||||||
|
|
@ -160,7 +167,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||||
var aqn = typeSymbol.ContainingAssembly != null
|
var aqn = typeSymbol.ContainingAssembly != null
|
||||||
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
|
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
|
||||||
: fqn;
|
: fqn;
|
||||||
types.Add((fqn, aqn));
|
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
|
||||||
|
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
|
||||||
|
types.Add((fqn, aqn, isRelationship));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,18 +177,21 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void GenerateRegistry(SourceProductionContext context,
|
private static void GenerateRegistry(SourceProductionContext context,
|
||||||
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Left,
|
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Left,
|
||||||
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> 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)>();
|
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel)>();
|
||||||
foreach (var t in invocations)
|
foreach (var t in invocations)
|
||||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName);
|
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
|
||||||
foreach (var t in forEachTypes)
|
foreach (var t in forEachTypes)
|
||||||
{
|
{
|
||||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName);
|
if (!typeMap.ContainsKey(t.FullyQualifiedName))
|
||||||
|
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
|
||||||
|
else if (t.IsRelationship)
|
||||||
|
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeMap.Count == 0)
|
if (typeMap.Count == 0)
|
||||||
|
|
@ -197,11 +209,11 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||||
sb.AppendLine(" [ModuleInitializer]");
|
sb.AppendLine(" [ModuleInitializer]");
|
||||||
sb.AppendLine(" public static void Initialize()");
|
sb.AppendLine(" public static void Initialize()");
|
||||||
sb.AppendLine(" {");
|
sb.AppendLine(" {");
|
||||||
sb.AppendLine(" ComponentRegistry.Initialize(new ComponentDescriptor[]");
|
sb.AppendLine(" ComponentRegistry.Register(new ComponentDescriptor[]");
|
||||||
sb.AppendLine(" {");
|
sb.AppendLine(" {");
|
||||||
|
|
||||||
bool first = true;
|
bool first = true;
|
||||||
foreach (var (fqn, aqn) in typeMap.Values.OrderBy(t => t.Fqn))
|
foreach (var (fqn, aqn, isRel) in typeMap.Values.OrderBy(t => t.Fqn))
|
||||||
{
|
{
|
||||||
if (!first)
|
if (!first)
|
||||||
sb.AppendLine(",");
|
sb.AppendLine(",");
|
||||||
|
|
@ -212,7 +224,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||||
sb.AppendLine($" type: typeof({fqn}),");
|
sb.AppendLine($" type: typeof({fqn}),");
|
||||||
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.Append(" )");
|
sb.Append(" )");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.0</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<LangVersion>12</LangVersion>
|
|
||||||
<RootNamespace>OECS.SourceGen</RootNamespace>
|
<RootNamespace>OECS.SourceGen</RootNamespace>
|
||||||
<AssemblyName>OECS.SourceGen</AssemblyName>
|
<AssemblyName>OECS.SourceGen</AssemblyName>
|
||||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>OECS.Tests</RootNamespace>
|
<RootNamespace>OECS.Tests</RootNamespace>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
|
|
@ -20,11 +17,11 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
82
OECS.sln
82
OECS.sln
|
|
@ -1,19 +1,20 @@
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "src\OECS.SourceGen\OECS.SourceGen.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "src\OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "OECS.SourceGen\OECS.SourceGen.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "tests\OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "examples\Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack.Tests", "Game.Blackjack.Tests\Game.Blackjack.Tests.csproj", "{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe.Tests", "Game.TicTacToe.Tests\Game.TicTacToe.Tests.csproj", "{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
|
@ -25,18 +26,6 @@ Global
|
||||||
Release|x86 = Release|x86
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
|
@ -49,6 +38,18 @@ Global
|
||||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
|
@ -73,12 +74,45 @@ Global
|
||||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.Build.0 = Release|Any CPU
|
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.ActiveCfg = Release|Any CPU
|
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.Build.0 = Release|Any CPU
|
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
|
||||||
{C3D4E5F6-A7B8-9012-CDEF-123456789012} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
|
||||||
{85631EDE-F984-4DA6-8EE9-0715AF1204E6} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,23 @@ public sealed class ComponentDescriptor
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Action<World, Entity, byte[]> DeserializeAndAdd { get; }
|
public Action<World, Entity, byte[]> DeserializeAndAdd { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes a MessagePack byte array to a boxed component object.
|
||||||
|
/// Used by <see cref="WorldSerializer"/> for relationship fixup before adding.
|
||||||
|
/// </summary>
|
||||||
|
public Func<byte[], object> Deserialize { get; }
|
||||||
|
|
||||||
public ComponentDescriptor(
|
public ComponentDescriptor(
|
||||||
string typeName,
|
string typeName,
|
||||||
Type type,
|
Type type,
|
||||||
Func<object, byte[]> serialize,
|
Func<object, byte[]> serialize,
|
||||||
Action<World, Entity, byte[]> deserializeAndAdd)
|
Action<World, Entity, byte[]> deserializeAndAdd,
|
||||||
|
Func<byte[], object> deserialize)
|
||||||
{
|
{
|
||||||
TypeName = typeName;
|
TypeName = typeName;
|
||||||
Type = type;
|
Type = type;
|
||||||
Serialize = serialize;
|
Serialize = serialize;
|
||||||
DeserializeAndAdd = deserializeAndAdd;
|
DeserializeAndAdd = deserializeAndAdd;
|
||||||
|
Deserialize = deserialize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry of component types used by <see cref="WorldSerializer"/>.
|
||||||
|
/// Types are discovered via the OECS.SourceGen incremental generator
|
||||||
|
/// at compile time, with a runtime fallback that scans loaded assemblies
|
||||||
|
/// for <c>[MessagePackObject]</c> structs.
|
||||||
|
/// </summary>
|
||||||
|
public static class ComponentRegistry
|
||||||
|
{
|
||||||
|
private static ComponentDescriptor[]? _descriptors;
|
||||||
|
private static Dictionary<string, ComponentDescriptor>? _byTypeName;
|
||||||
|
private static bool _scanned;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All discovered component descriptors.
|
||||||
|
/// </summary>
|
||||||
|
public static ComponentDescriptor[] Descriptors
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureScanned();
|
||||||
|
return _descriptors ?? Array.Empty<ComponentDescriptor>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lookup by assembly-qualified type name.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureScanned();
|
||||||
|
if (_byTypeName == null)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, ComponentDescriptor>();
|
||||||
|
foreach (var desc in Descriptors)
|
||||||
|
dict[desc.TypeName] = desc;
|
||||||
|
_byTypeName = dict;
|
||||||
|
}
|
||||||
|
return _byTypeName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by generated code to register discovered component types.
|
||||||
|
/// Multiple assemblies may call this. Descriptors are accumulated.
|
||||||
|
/// </summary>
|
||||||
|
public static void Register(ComponentDescriptor[] descriptors)
|
||||||
|
{
|
||||||
|
if (_descriptors == null)
|
||||||
|
{
|
||||||
|
_descriptors = descriptors;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = new HashSet<string>(_descriptors.Select(d => d.TypeName));
|
||||||
|
var merged = new List<ComponentDescriptor>(_descriptors);
|
||||||
|
foreach (var d in descriptors)
|
||||||
|
{
|
||||||
|
if (!existing.Contains(d.TypeName))
|
||||||
|
{
|
||||||
|
existing.Add(d.TypeName);
|
||||||
|
merged.Add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_descriptors = merged.ToArray();
|
||||||
|
}
|
||||||
|
_byTypeName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureScanned()
|
||||||
|
{
|
||||||
|
if (_scanned) return;
|
||||||
|
_scanned = true;
|
||||||
|
|
||||||
|
// Runtime fallback: scan loaded assemblies for [MessagePackObject] structs.
|
||||||
|
// This ensures serialization works even when the source generator
|
||||||
|
// doesn't run (e.g., in test projects referencing game DLLs).
|
||||||
|
var scanned = new List<ComponentDescriptor>(_descriptors ?? Array.Empty<ComponentDescriptor>());
|
||||||
|
var seen = new HashSet<string>(scanned.Select(d => d.TypeName));
|
||||||
|
|
||||||
|
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var type in asm.GetTypes())
|
||||||
|
{
|
||||||
|
if (!type.IsValueType || type.IsAbstract || !type.IsPublic)
|
||||||
|
continue;
|
||||||
|
if (type.GetCustomAttribute<MessagePackObjectAttribute>() == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var aqn = $"{type.FullName}, {type.Assembly.GetName().Name}";
|
||||||
|
if (!seen.Add(aqn))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var desc = CreateDescriptor(type, aqn);
|
||||||
|
scanned.Add(desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Some assemblies may throw during reflection (e.g., mixed-mode).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_descriptors = scanned.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ComponentDescriptor CreateDescriptor(Type type, string aqn)
|
||||||
|
{
|
||||||
|
// Build serialize/deserialize delegates via reflection.
|
||||||
|
var method = typeof(ComponentRegistry).GetMethod(
|
||||||
|
nameof(CreateTypedDescriptor),
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||||
|
var generic = method.MakeGenericMethod(type);
|
||||||
|
return (ComponentDescriptor)generic.Invoke(null, [aqn])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ComponentDescriptor CreateTypedDescriptor<T>(string aqn) where T : struct
|
||||||
|
{
|
||||||
|
return new ComponentDescriptor(
|
||||||
|
typeName: aqn,
|
||||||
|
type: typeof(T),
|
||||||
|
serialize: obj => MessagePackSerializer.Serialize((T)obj),
|
||||||
|
deserializeAndAdd: (world, entity, data) =>
|
||||||
|
world.AddComponent(entity, MessagePackSerializer.Deserialize<T>(data)),
|
||||||
|
deserialize: data => MessagePackSerializer.Deserialize<T>(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,21 +109,4 @@ internal class ComponentStore
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the count of entities in the sparse set for type <typeparamref name="T"/>.
|
|
||||||
/// Returns 0 if the set does not exist.
|
|
||||||
/// </summary>
|
|
||||||
public int Count<T>() where T : struct
|
|
||||||
{
|
|
||||||
return GetSetIfExists<T>()?.Count ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the count of entities in the sparse set for the given type.
|
|
||||||
/// Returns 0 if the set does not exist.
|
|
||||||
/// </summary>
|
|
||||||
public int Count(Type componentType)
|
|
||||||
{
|
|
||||||
return GetSet(componentType)?.Count ?? 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>OECS</RootNamespace>
|
<RootNamespace>OECS</RootNamespace>
|
||||||
<AssemblyName>OECS</AssemblyName>
|
<AssemblyName>OECS</AssemblyName>
|
||||||
|
|
||||||
|
|
@ -70,24 +70,6 @@ internal class RelationshipIndex
|
||||||
return sources;
|
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>
|
/// <summary>
|
||||||
/// Removes all index entries where the given entity appears as a source.
|
/// Removes all index entries where the given entity appears as a source.
|
||||||
/// Called before the entity's components are removed during destruction.
|
/// Called before the entity's components are removed during destruction.
|
||||||
|
|
@ -132,29 +132,6 @@ internal class SparseSet<T> : ISparseSet where T : struct
|
||||||
return ref _dense[index];
|
return ref _dense[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tries to get the component value for the given entity.
|
|
||||||
/// Returns true and copies the value to <paramref name="value"/> if present.
|
|
||||||
/// </summary>
|
|
||||||
public bool TryGet(Entity entity, out T value)
|
|
||||||
{
|
|
||||||
if (entity.Id >= (uint)_sparse.Length)
|
|
||||||
{
|
|
||||||
value = default;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int index = _sparse[entity.Id];
|
|
||||||
if (index == -1)
|
|
||||||
{
|
|
||||||
value = default;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
value = _dense[index];
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ThrowEntityNotPresent(Entity entity)
|
private static void ThrowEntityNotPresent(Entity entity)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|
@ -181,6 +181,23 @@ public class World : IDisposable
|
||||||
AddComponentImpl(entity, component);
|
AddComponentImpl(entity, component);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a boxed component to the entity. Used by <see cref="WorldSerializer"/>
|
||||||
|
/// during deserialization after relationship Source fixup.
|
||||||
|
/// Calls <see cref="AddComponentImpl{T}"/> via reflection to avoid
|
||||||
|
/// duplicating the relationship index logic.
|
||||||
|
/// </summary>
|
||||||
|
internal void AddComponentBoxed(Entity entity, object component, Type componentType)
|
||||||
|
{
|
||||||
|
var method = s_addComponentImpl.MakeGenericMethod(componentType);
|
||||||
|
method.Invoke(this, [entity, component]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly System.Reflection.MethodInfo s_addComponentImpl =
|
||||||
|
typeof(World).GetMethod(
|
||||||
|
nameof(AddComponentImpl),
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||||
|
|
||||||
private void AddComponentImpl<T>(Entity entity, T component) where T : struct
|
private void AddComponentImpl<T>(Entity entity, T component) where T : struct
|
||||||
{
|
{
|
||||||
ThrowIfNotAlive(entity);
|
ThrowIfNotAlive(entity);
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Reflection;
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
@ -93,9 +94,32 @@ 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 add in one typed operation — no reflection.
|
// Deserialize and fix up IRelationship.Source before adding.
|
||||||
desc.DeserializeAndAdd(world, entity, 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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();
|
||||||
}
|
}
|
||||||
|
|
@ -139,13 +139,17 @@ public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1
|
||||||
|
|
||||||
Extension methods on `World` for `foreach`-style iteration over queries.
|
Extension methods on `World` for `foreach`-style iteration over queries.
|
||||||
Returns `ref struct` iterators that support zero-allocation iteration with
|
Returns `ref struct` iterators that support zero-allocation iteration with
|
||||||
`ref` access to components. Supports 1–3 component types.
|
`ref` access to components.
|
||||||
|
|
||||||
|
Two sets of overloads: one accepting an explicit `QueryDescriptor` for
|
||||||
|
filtering with `Without<T>`, and one without for simple "has component" scans.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
||||||
public static class EntityIterator
|
public static class EntityIterator
|
||||||
{
|
{
|
||||||
|
// With explicit QueryDescriptor (supports Without<T> filters).
|
||||||
public static Select1<T1> Select<T1>(
|
public static Select1<T1> Select<T1>(
|
||||||
this World world, QueryDescriptor query) where T1 : struct;
|
this World world, QueryDescriptor query) where T1 : struct;
|
||||||
|
|
||||||
|
|
@ -156,16 +160,38 @@ public static class EntityIterator
|
||||||
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
|
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
|
||||||
this World world, QueryDescriptor query)
|
this World world, QueryDescriptor query)
|
||||||
where T1 : struct where T2 : struct where T3 : struct;
|
where T1 : struct where T2 : struct where T3 : struct;
|
||||||
|
|
||||||
|
// Without QueryDescriptor — iterates all entities with the given component(s).
|
||||||
|
public static Select1<T1> Select<T1>(
|
||||||
|
this World world) where T1 : struct;
|
||||||
|
|
||||||
|
public static Select2<T1, T2> Select<T1, T2>(
|
||||||
|
this World world)
|
||||||
|
where T1 : struct where T2 : struct;
|
||||||
|
|
||||||
|
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
|
||||||
|
this World world)
|
||||||
|
where T1 : struct where T2 : struct where T3 : struct;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Usage:
|
The singletons `CurrentEntity`, `Current1`, `Current2`, `Current3` are
|
||||||
|
exposed on the ref struct iterators directly. Usage:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using var iter = world.Select<Position, Velocity>(query);
|
// Simple scan: all entities with PlayerHand.
|
||||||
foreach (ref var item in iter)
|
using var iter = world.Select<PlayerHand>();
|
||||||
|
while (iter.MoveNext())
|
||||||
{
|
{
|
||||||
item.Current1.X += item.Current2.X * dt;
|
Console.WriteLine(iter.CurrentEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With query filter:
|
||||||
|
var query = world.Query().With<Position>().Without<Frozen>().Build();
|
||||||
|
using var iter2 = world.Select<Position>(query);
|
||||||
|
while (iter2.MoveNext())
|
||||||
|
{
|
||||||
|
iter2.Current1.X += 1;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -202,20 +228,32 @@ public class QueryDescriptor
|
||||||
|
|
||||||
## ISystem
|
## ISystem
|
||||||
|
|
||||||
|
A system is a unit of logic that runs during a tick. It receives the `World`
|
||||||
|
and decides what to do — typically reading singletons, iterating queries,
|
||||||
|
or enqueuing commands.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
||||||
public interface ISystem
|
public interface ISystem
|
||||||
{
|
{
|
||||||
QueryDescriptor Query { get; }
|
|
||||||
void Run(World world);
|
void Run(World world);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Systems do **not** declare a query on the interface. Instead, they either
|
||||||
|
read singletons directly (`world.ReadSingleton<T>()`) or use the iterator /
|
||||||
|
`ForEach` API with a `QueryDescriptor` they build internally. This keeps
|
||||||
|
the interface minimal and gives systems full flexibility over what they
|
||||||
|
inspect at runtime.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ITickedSystem
|
## ITickedSystem
|
||||||
|
|
||||||
|
An optional extension for systems that need tick metadata (delta time or
|
||||||
|
logical tick marker).
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
||||||
|
|
@ -225,6 +263,10 @@ public interface ITickedSystem : ISystem
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`SystemGroup` checks each system at runtime: if it implements
|
||||||
|
`ITickedSystem`, the `Run(World, Tick)` overload is called; otherwise
|
||||||
|
`Run(World)` is called. Both overloads must be implemented.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tick
|
## Tick
|
||||||
|
|
@ -266,6 +308,9 @@ public class SystemGroup
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Systems execute in registration order. `SystemGroup` automatically drains
|
||||||
|
commands and posts changes after each system and after the full tick.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ICommand
|
## ICommand
|
||||||
|
|
@ -279,6 +324,9 @@ public interface ICommand
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Implementations should be `[MessagePackObject]` structs so they can be
|
||||||
|
serialized and replayed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CommandQueue
|
## CommandQueue
|
||||||
|
|
@ -297,6 +345,9 @@ public class CommandQueue
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
|
||||||
|
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## IRelationship
|
## IRelationship
|
||||||
|
|
@ -324,6 +375,7 @@ namespace OECS;
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Relationship<TSelf, TTarget> : IRelationship
|
public struct Relationship<TSelf, TTarget> : IRelationship
|
||||||
|
where TSelf : struct where TTarget : struct
|
||||||
{
|
{
|
||||||
[Key(0)] public Entity Source { get; set; }
|
[Key(0)] public Entity Source { get; set; }
|
||||||
[Key(1)] public Entity Target { get; set; }
|
[Key(1)] public Entity Target { get; set; }
|
||||||
|
|
@ -359,7 +411,7 @@ public enum ChangeKind
|
||||||
## WorldSerializer
|
## WorldSerializer
|
||||||
|
|
||||||
Saves and loads a `World` to/from a MessagePack stream. Components are
|
Saves and loads a `World` to/from a MessagePack stream. Components are
|
||||||
serialized by their runtime type.
|
serialized by their runtime type using the source-generated `ComponentRegistry`.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace OECS;
|
namespace OECS;
|
||||||
|
|
@ -393,6 +445,7 @@ public class EntitySnapshot
|
||||||
[Key(0)] public uint Id { get; set; }
|
[Key(0)] public uint Id { get; set; }
|
||||||
[Key(1)] public uint Version { get; set; }
|
[Key(1)] public uint Version { get; set; }
|
||||||
[Key(2)] public ComponentEntry[] Components { get; set; }
|
[Key(2)] public ComponentEntry[] Components { get; set; }
|
||||||
|
[IgnoreMember]
|
||||||
public Entity Entity { get; }
|
public Entity Entity { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,37 +463,30 @@ public class ComponentEntry
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using OECS;
|
using OECS;
|
||||||
using R3;
|
|
||||||
|
|
||||||
// Define components
|
// Define components
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Position : IMessagePackSerializationCallbackReceiver
|
public struct Position
|
||||||
{
|
{
|
||||||
[Key(0)] public float X;
|
[Key(0)] public float X;
|
||||||
[Key(1)] public float Y;
|
[Key(1)] public float Y;
|
||||||
|
|
||||||
public void OnBeforeSerialize() { }
|
|
||||||
public void OnAfterDeserialize() { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Velocity : IMessagePackSerializationCallbackReceiver
|
public struct Velocity
|
||||||
{
|
{
|
||||||
[Key(0)] public float X;
|
[Key(0)] public float X;
|
||||||
[Key(1)] public float Y;
|
[Key(1)] public float Y;
|
||||||
|
|
||||||
public void OnBeforeSerialize() { }
|
|
||||||
public void OnAfterDeserialize() { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a system
|
// Define a system
|
||||||
public class MovementSystem : ITickedSystem
|
public class MovementSystem : ITickedSystem
|
||||||
{
|
{
|
||||||
public QueryDescriptor Query { get; }
|
private readonly QueryDescriptor _query;
|
||||||
|
|
||||||
public MovementSystem(World world)
|
public MovementSystem(World world)
|
||||||
{
|
{
|
||||||
Query = world.Query()
|
_query = world.Query()
|
||||||
.With<Position>()
|
.With<Position>()
|
||||||
.With<Velocity>()
|
.With<Velocity>()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
@ -452,7 +498,7 @@ public class MovementSystem : ITickedSystem
|
||||||
{
|
{
|
||||||
float dt = tick.DeltaTime;
|
float dt = tick.DeltaTime;
|
||||||
|
|
||||||
world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
||||||
{
|
{
|
||||||
pos.X += vel.X * dt;
|
pos.X += vel.X * dt;
|
||||||
pos.Y += vel.Y * dt;
|
pos.Y += vel.Y * dt;
|
||||||
|
|
@ -466,16 +512,15 @@ var world = new World();
|
||||||
var group = new SystemGroup(world);
|
var group = new SystemGroup(world);
|
||||||
group.Add(new MovementSystem(world));
|
group.Add(new MovementSystem(world));
|
||||||
|
|
||||||
// Observe changes
|
|
||||||
world.ObserveComponentChanges<Position>()
|
|
||||||
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"))
|
|
||||||
.AddTo(disposables);
|
|
||||||
|
|
||||||
// Create entities
|
// Create entities
|
||||||
var player = world.CreateEntity();
|
var player = world.CreateEntity();
|
||||||
world.AddComponent(player, new Position { X = 0, Y = 0 });
|
world.AddComponent(player, new Position { X = 0, Y = 0 });
|
||||||
world.AddComponent(player, new Velocity { X = 1, Y = 0 });
|
world.AddComponent(player, new Velocity { X = 1, Y = 0 });
|
||||||
|
|
||||||
|
// Observe changes via R3
|
||||||
|
world.ObserveComponentChanges<Position>()
|
||||||
|
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"));
|
||||||
|
|
||||||
// Run a tick
|
// Run a tick
|
||||||
group.RunTimed(0.016f); // ~60 FPS
|
group.RunTimed(0.016f); // ~60 FPS
|
||||||
```
|
```
|
||||||
|
|
@ -495,3 +540,5 @@ These types are implementation details and may change without notice:
|
||||||
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
|
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
|
||||||
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
|
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
|
||||||
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
|
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
|
||||||
|
| `ComponentRegistry` | Source-generated registry of all component types for serialization. |
|
||||||
|
| `ComponentDescriptor` | Source-generated per-type descriptor with serialize/deserialize callbacks. |
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
|
||||||
|
|
||||||
## Target
|
## Target
|
||||||
|
|
||||||
- .NET 8 (LTS), `net8.0`
|
- .NET 8 (LTS), `net8.0` (C# 12)
|
||||||
- Dependencies: `MessagePack` (serialization), `R3` (reactivity)
|
- Dependencies: `MessagePack` (serialization), `R3` (reactivity)
|
||||||
- Output: `OECS.dll`
|
- Output: `OECS.dll`
|
||||||
|
- Source generator: `OECS.SourceGen.dll` (component registry for serialization)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -22,8 +23,12 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
|
||||||
|
|
||||||
```
|
```
|
||||||
OECS.sln
|
OECS.sln
|
||||||
├── src/OECS/OECS.csproj
|
├── OECS/OECS.csproj
|
||||||
└── tests/OECS.Tests/OECS.Tests.csproj
|
├── OECS.SourceGen/OECS.SourceGen.csproj
|
||||||
|
├── OECS.Tests/OECS.Tests.csproj
|
||||||
|
├── Game.Blackjack/Blackjack.csproj
|
||||||
|
├── Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
|
||||||
|
└── Game.TicTacToe/...
|
||||||
```
|
```
|
||||||
|
|
||||||
- `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`.
|
- `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`.
|
||||||
|
|
@ -76,6 +81,8 @@ Holds a `Dictionary<Type, object>` mapping component types to their
|
||||||
- `void Add<T>(Entity, T)`
|
- `void Add<T>(Entity, T)`
|
||||||
- `void Remove<T>(Entity)`
|
- `void Remove<T>(Entity)`
|
||||||
- `ref T Get<T>(Entity)`
|
- `ref T Get<T>(Entity)`
|
||||||
|
- `T Read<T>(Entity)` — copy without auto-mark
|
||||||
|
- `bool TryGet<T>(Entity, out T)`
|
||||||
- `bool Has<T>(Entity)`
|
- `bool Has<T>(Entity)`
|
||||||
- `void RemoveAll(Entity)` — called on entity destruction
|
- `void RemoveAll(Entity)` — called on entity destruction
|
||||||
|
|
||||||
|
|
@ -99,6 +106,8 @@ void DestroyEntity(Entity entity);
|
||||||
void AddComponent<T>(Entity entity, T component) where T : struct;
|
void AddComponent<T>(Entity entity, T component) where T : struct;
|
||||||
void RemoveComponent<T>(Entity entity) where T : struct;
|
void RemoveComponent<T>(Entity entity) where T : struct;
|
||||||
ref T GetComponent<T>(Entity entity) where T : struct;
|
ref T GetComponent<T>(Entity entity) where T : struct;
|
||||||
|
T ReadComponent<T>(Entity entity) where T : struct;
|
||||||
|
bool TryGetComponent<T>(Entity entity, out T value) where T : struct;
|
||||||
bool HasComponent<T>(Entity entity) where T : struct;
|
bool HasComponent<T>(Entity entity) where T : struct;
|
||||||
bool IsAlive(Entity entity);
|
bool IsAlive(Entity entity);
|
||||||
```
|
```
|
||||||
|
|
@ -128,8 +137,8 @@ A query is defined by:
|
||||||
```
|
```
|
||||||
class QueryDescriptor
|
class QueryDescriptor
|
||||||
{
|
{
|
||||||
HashSet<Type> With { get; }
|
IReadOnlySet<Type> With { get; }
|
||||||
HashSet<Type> Without { get; }
|
IReadOnlySet<Type> Without { get; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -147,49 +156,74 @@ world.Query()
|
||||||
|
|
||||||
### 2.3 Query Execution
|
### 2.3 Query Execution
|
||||||
|
|
||||||
`World` provides iteration over matching entities. The smallest "with" sparse
|
`World` provides two iteration styles:
|
||||||
set is used as the driver; other sets are probed for membership.
|
|
||||||
|
|
||||||
|
**ForEach callbacks** (1–6 component types):
|
||||||
```csharp
|
```csharp
|
||||||
void ForEach<T1, T2>(QueryDescriptor query, Action<Entity, ref T1, ref T2> action);
|
void ForEach<T1, T2>(QueryDescriptor query, ForEachAction<T1, T2> action);
|
||||||
```
|
```
|
||||||
|
|
||||||
Overloads for 1–6 component types. The `Without` filter is checked by probing
|
**Ref struct iterators** via `EntityIterator.Select<T>()` extensions (1–3 component types):
|
||||||
the corresponding sparse sets.
|
```csharp
|
||||||
|
using var iter = world.Select<Position, Velocity>(query);
|
||||||
|
while (iter.MoveNext()) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Both accept an optional `QueryDescriptor`. When omitted, all entities with
|
||||||
|
the given component types are iterated (no `Without` filter).
|
||||||
|
|
||||||
|
The smallest "with" sparse set is used as the driver; other sets are probed
|
||||||
|
for membership. The singleton entity (ID 1) is always skipped.
|
||||||
|
|
||||||
### 2.4 ISystem
|
### 2.4 ISystem
|
||||||
|
|
||||||
```
|
```
|
||||||
interface ISystem
|
interface ISystem
|
||||||
{
|
{
|
||||||
QueryDescriptor Query { get; }
|
|
||||||
void Run(World world);
|
void Run(World world);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Systems declare their query and receive the world in `Run`. They call
|
Systems do **not** declare a query on the interface. Instead, they either
|
||||||
`world.ForEach(query, ...)` to iterate.
|
read singletons directly or build queries internally and iterate with
|
||||||
|
`ForEach` / `Select`. This keeps the interface minimal and gives systems
|
||||||
|
full flexibility.
|
||||||
|
|
||||||
Alternative considered: auto-injection of component refs. Rejected because it
|
**Why no auto-injection?** The explicit API makes the iteration cost
|
||||||
hides the iteration cost and makes the API less explicit. The explicit
|
visible and avoids the need for source generators or reflection for
|
||||||
`ForEach` call keeps the system author aware of what they're iterating.
|
system dispatch.
|
||||||
|
|
||||||
### 2.5 System Registration & Ordering
|
### 2.5 ITickedSystem
|
||||||
|
|
||||||
|
```
|
||||||
|
interface ITickedSystem : ISystem
|
||||||
|
{
|
||||||
|
void Run(World world, Tick tick);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Systems that need tick metadata (delta time or logical tick marker)
|
||||||
|
implement this. Both `Run` overloads must be implemented; `SystemGroup`
|
||||||
|
dispatches to the appropriate one at runtime.
|
||||||
|
|
||||||
|
### 2.6 System Registration & Ordering
|
||||||
|
|
||||||
```
|
```
|
||||||
class SystemGroup
|
class SystemGroup
|
||||||
{
|
{
|
||||||
void Add(ISystem system); // registration order = execution order
|
void Add(ISystem system); // registration order = execution order
|
||||||
void RunTimed(float deltaTime); // calls each system's Run
|
void Remove(ISystem system);
|
||||||
void RunLogical(); // calls each system's Run
|
void RunTimed(float deltaTime);
|
||||||
|
void RunLogical();
|
||||||
|
int Count { get; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Systems run in registration order. For now, no explicit dependency graph —
|
Systems run in registration order. `SystemGroup` automatically:
|
||||||
this is the simplest model that works. If needed later, `Before()`/`After()`
|
- Drains commands before the tick, after each system, and after the full tick.
|
||||||
constraints can be added without breaking the API.
|
- Posts changes after each system and after the full tick.
|
||||||
|
|
||||||
### 2.6 Tick
|
### 2.7 Tick
|
||||||
|
|
||||||
```
|
```
|
||||||
readonly struct Tick
|
readonly struct Tick
|
||||||
|
|
@ -199,16 +233,7 @@ readonly struct Tick
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Passed to systems that opt into it via a separate interface:
|
### 2.8 Tests
|
||||||
|
|
||||||
```
|
|
||||||
interface ITickedSystem : ISystem
|
|
||||||
{
|
|
||||||
void Run(World world, Tick tick);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.7 Tests
|
|
||||||
|
|
||||||
- Query with single component returns matching entities.
|
- Query with single component returns matching entities.
|
||||||
- Query with multiple components returns intersection.
|
- Query with multiple components returns intersection.
|
||||||
|
|
@ -216,6 +241,7 @@ interface ITickedSystem : ISystem
|
||||||
- Adding/removing components updates query results.
|
- Adding/removing components updates query results.
|
||||||
- Systems run in registration order.
|
- Systems run in registration order.
|
||||||
- Destroyed entities don't appear in queries.
|
- Destroyed entities don't appear in queries.
|
||||||
|
- `ITickedSystem` receives correct tick data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -226,31 +252,38 @@ interface ITickedSystem : ISystem
|
||||||
### 3.1 ICommand
|
### 3.1 ICommand
|
||||||
|
|
||||||
```
|
```
|
||||||
[MessagePackObject]
|
|
||||||
interface ICommand
|
interface ICommand
|
||||||
{
|
{
|
||||||
void Execute(World world);
|
void Execute(World world);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Commands are `[MessagePackObject]` structs implementing `ICommand`. They are
|
Commands are structs implementing `ICommand`. They are typically
|
||||||
not stored in ECS sparse sets — they live in a queue.
|
`[MessagePackObject]` for serialization. Not stored in ECS sparse
|
||||||
|
sets — they live in a queue.
|
||||||
|
|
||||||
### 3.2 CommandQueue
|
### 3.2 CommandQueue
|
||||||
|
|
||||||
```
|
```
|
||||||
class CommandQueue
|
class CommandQueue
|
||||||
{
|
{
|
||||||
void Enqueue(ICommand command);
|
void Enqueue<T>(T command) where T : struct, ICommand;
|
||||||
void ExecuteAll(World world); // FIFO, clears queue
|
void ExecuteAll(World world); // FIFO, fully drains queue
|
||||||
|
void Clear();
|
||||||
|
void ClearErrors();
|
||||||
int Count { get; }
|
int Count { get; }
|
||||||
|
IReadOnlyList<Exception> Errors { get; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
|
||||||
|
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
|
||||||
|
|
||||||
### 3.3 Integration with World
|
### 3.3 Integration with World
|
||||||
|
|
||||||
`World` owns a `CommandQueue`. After each system runs (or after the full tick),
|
`World` owns a `CommandQueue`. After each system runs (or after the full tick),
|
||||||
the queue is drained. This is configurable:
|
the queue is drained automatically by `SystemGroup`. Manual drain is also
|
||||||
|
available:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
world.ExecuteCommands(); // manual drain
|
world.ExecuteCommands(); // manual drain
|
||||||
|
|
@ -279,36 +312,48 @@ available via `CommandQueue.Errors`.
|
||||||
**Goal:** Relationship components with auto-managed source/target and reverse
|
**Goal:** Relationship components with auto-managed source/target and reverse
|
||||||
lookup.
|
lookup.
|
||||||
|
|
||||||
### 4.1 Relationship\<TSelf, TTarget\>
|
### 4.1 IRelationship
|
||||||
|
|
||||||
|
```
|
||||||
|
interface IRelationship
|
||||||
|
{
|
||||||
|
Entity Source { get; }
|
||||||
|
Entity Target { get; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Relationship\<TSelf, TTarget\>
|
||||||
|
|
||||||
```
|
```
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
struct Relationship<TSelf, TTarget> : IRelationship
|
struct Relationship<TSelf, TTarget> : IRelationship
|
||||||
|
where TSelf : struct where TTarget : struct
|
||||||
{
|
{
|
||||||
[Key(0)] Entity Source { get; set; }
|
[Key(0)] Entity Source { get; set; }
|
||||||
[Key(1)] Entity Target { get; set; }
|
[Key(1)] Entity Target { get; set; }
|
||||||
// ... payload fields
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `IRelationship` marker interface lets `ComponentStore` detect relationships
|
The `IRelationship` marker interface lets `ComponentStore` detect relationships
|
||||||
and maintain the reverse index.
|
and maintain the reverse index. The `TSelf`/`TTarget` phantom types
|
||||||
|
differentiate relationship kinds at the type level.
|
||||||
|
|
||||||
### 4.2 Reverse Index
|
Alternatively, implement `IRelationship` directly on your own struct (see the
|
||||||
|
Blackjack `Holds` and `InDeck` types).
|
||||||
|
|
||||||
`World` maintains a `Dictionary<Entity, HashSet<Entity>>` per relationship
|
### 4.3 Reverse Index
|
||||||
type, mapping target → set of source entities.
|
|
||||||
|
|
||||||
When a relationship component is added/removed, the index is updated
|
`World` maintains a reverse index per relationship type, mapping target →
|
||||||
automatically.
|
set of source entities. Updated automatically when relationship components
|
||||||
|
are added/removed.
|
||||||
|
|
||||||
### 4.3 Reverse Lookup API
|
### 4.4 Reverse Lookup API
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;
|
IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 Cascading Behavior
|
### 4.5 Cascading Behavior
|
||||||
|
|
||||||
When an entity is destroyed:
|
When an entity is destroyed:
|
||||||
- All relationships where it is the **source** are removed (components dropped).
|
- All relationships where it is the **source** are removed (components dropped).
|
||||||
|
|
@ -316,7 +361,7 @@ When an entity is destroyed:
|
||||||
from source entities).
|
from source entities).
|
||||||
- The reverse index is cleaned up.
|
- The reverse index is cleaned up.
|
||||||
|
|
||||||
### 4.5 Tests
|
### 4.6 Tests
|
||||||
|
|
||||||
- Adding a relationship updates the reverse index.
|
- Adding a relationship updates the reverse index.
|
||||||
- Removing a relationship updates the reverse index.
|
- Removing a relationship updates the reverse index.
|
||||||
|
|
@ -346,23 +391,17 @@ struct EntityChange
|
||||||
{
|
{
|
||||||
Entity Entity { get; }
|
Entity Entity { get; }
|
||||||
ChangeKind Kind { get; }
|
ChangeKind Kind { get; }
|
||||||
Type ComponentType { get; } // null for entity-level changes
|
Type? ComponentType { get; } // null for entity-level changes
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 ChangeSet
|
### 5.2 ChangeBuffer
|
||||||
|
|
||||||
```
|
```
|
||||||
class ChangeSet
|
class ChangeBuffer
|
||||||
{
|
{
|
||||||
void MarkEntityAdded(Entity entity);
|
void Mark<...>(...);
|
||||||
void MarkEntityRemoved(Entity entity);
|
void Post(); // pushes to R3 subjects, then clears
|
||||||
void MarkComponentAdded(Entity entity, Type componentType);
|
|
||||||
void MarkComponentRemoved(Entity entity, Type componentType);
|
|
||||||
void MarkComponentModified(Entity entity, Type componentType);
|
|
||||||
|
|
||||||
IReadOnlyList<EntityChange> Changes { get; }
|
|
||||||
void Clear();
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -381,25 +420,15 @@ Rationale: structural changes are always detectable. Value mutations inside a
|
||||||
value. Requiring an explicit `MarkModified` call is the simplest correct
|
value. Requiring an explicit `MarkModified` call is the simplest correct
|
||||||
approach.
|
approach.
|
||||||
|
|
||||||
**Debug aid:** In `DEBUG` builds, `ref T Get<T>(Entity)` returns a wrapper that
|
**`ReadComponent<T>`** (returns a copy) and **`ReadSingleton<T>`** never
|
||||||
tracks whether the value was written. If a system iterates `ref T` and never
|
auto-mark. Use these when you only need to inspect state without signaling
|
||||||
calls `MarkModified`, a warning is logged. This catches the most common
|
changes.
|
||||||
mistake.
|
|
||||||
|
|
||||||
### 5.4 Posting Model
|
### 5.4 Posting Model
|
||||||
|
|
||||||
```
|
- During a system's `Run`, changes accumulate in a pending buffer.
|
||||||
class ChangeBuffer
|
- After each system's `Run`, `Post()` is called automatically by `SystemGroup`.
|
||||||
{
|
- After the full tick, `Post()` is called once more.
|
||||||
ChangeSet Pending { get; } // accumulates during system run
|
|
||||||
void Post(); // pushes to R3 subjects, then clears
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- During a system's `Run`, changes accumulate in `Pending`.
|
|
||||||
- After each system's `Run`, `Post()` is called automatically.
|
|
||||||
- After the full tick, `Post()` is called once more (for any changes made
|
|
||||||
outside systems, e.g., during command execution).
|
|
||||||
- When not in a system run, changes are **not** posted automatically — the
|
- When not in a system run, changes are **not** posted automatically — the
|
||||||
caller must call `world.PostChanges()`.
|
caller must call `world.PostChanges()`.
|
||||||
|
|
||||||
|
|
@ -408,9 +437,9 @@ class ChangeBuffer
|
||||||
`World` exposes observables:
|
`World` exposes observables:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
IObservable<EntityChange> ObserveEntityChanges();
|
Observable<EntityChange> ObserveEntityChanges();
|
||||||
IObservable<EntityChange> ObserveComponentChanges<T>() where T : struct;
|
Observable<EntityChange> ObserveComponentChanges<T>() where T : struct;
|
||||||
IObservable<EntityChange> ObserveQuery(QueryDescriptor query);
|
Observable<EntityChange> ObserveQuery(QueryDescriptor query);
|
||||||
```
|
```
|
||||||
|
|
||||||
These are backed by `Subject<EntityChange>` instances. Subscribers receive
|
These are backed by `Subject<EntityChange>` instances. Subscribers receive
|
||||||
|
|
@ -445,19 +474,21 @@ world.ObserveComponentChanges<Health>()
|
||||||
### 6.1 Singleton Entity
|
### 6.1 Singleton Entity
|
||||||
|
|
||||||
`World` reserves entity ID `1` as the singleton entity. It is never destroyed
|
`World` reserves entity ID `1` as the singleton entity. It is never destroyed
|
||||||
and is excluded from normal queries by default.
|
and is excluded from normal queries (all iterators skip ID 1).
|
||||||
|
|
||||||
### 6.2 Singleton Accessors
|
### 6.2 Singleton Accessors
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
void SetSingleton<T>(T component) where T : struct;
|
void SetSingleton<T>(T component) where T : struct;
|
||||||
ref T GetSingleton<T>() where T : struct;
|
ref T GetSingleton<T>() where T : struct;
|
||||||
|
T ReadSingleton<T>() where T : struct;
|
||||||
bool HasSingleton<T>() where T : struct;
|
bool HasSingleton<T>() where T : struct;
|
||||||
void RemoveSingleton<T>() where T : struct;
|
void RemoveSingleton<T>() where T : struct;
|
||||||
```
|
```
|
||||||
|
|
||||||
These are convenience wrappers around `AddComponent`/`GetComponent` on the
|
These are convenience wrappers around `AddComponent`/`GetComponent` on the
|
||||||
singleton entity.
|
singleton entity. `GetSingleton` returns a `ref` (auto-marks during iteration);
|
||||||
|
`ReadSingleton` returns a copy (never auto-marks).
|
||||||
|
|
||||||
### 6.3 Query Exclusion
|
### 6.3 Query Exclusion
|
||||||
|
|
||||||
|
|
@ -473,28 +504,62 @@ to include it, they can query it by its entity ID directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7 — Polish & Documentation (Week 7)
|
## Phase 7 — Source Generator & Serialization (Week 7)
|
||||||
|
|
||||||
### 7.1 XML Docs
|
**Goal:** Compile-time component registry for serialization without reflection.
|
||||||
|
|
||||||
|
### 7.1 OECS.SourceGen
|
||||||
|
|
||||||
|
A Roslyn incremental source generator that scans for all types used as
|
||||||
|
generic arguments to `World` methods (`AddComponent<T>`, `GetComponent<T>`,
|
||||||
|
`SetSingleton<T>`, etc.) and generates a `ComponentRegistry` class with
|
||||||
|
per-type serialize/deserialize callbacks via MessagePack.
|
||||||
|
|
||||||
|
### 7.2 WorldSerializer
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
static class WorldSerializer
|
||||||
|
{
|
||||||
|
static void Save(World world, Stream stream);
|
||||||
|
static void Load(World world, Stream stream);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `ComponentRegistry` to serialize/deserialize all entities and their
|
||||||
|
components to/from a MessagePack stream. `IRelationship.Source` is fixed
|
||||||
|
up to the owning entity on load.
|
||||||
|
|
||||||
|
### 7.3 Snapshot Types
|
||||||
|
|
||||||
|
`WorldSnapshot`, `EntitySnapshot`, and `ComponentEntry` are the public
|
||||||
|
serialization DTOs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — Polish & Documentation (Week 8)
|
||||||
|
|
||||||
|
### 8.1 XML Docs
|
||||||
|
|
||||||
All public API surface gets `<summary>` XML documentation comments.
|
All public API surface gets `<summary>` XML documentation comments.
|
||||||
|
|
||||||
### 7.2 README
|
### 8.2 README
|
||||||
|
|
||||||
Quick-start guide with a minimal example: create world, register system, run
|
Quick-start guide with a minimal example: create world, register system, run
|
||||||
tick, observe changes.
|
tick, observe changes.
|
||||||
|
|
||||||
### 7.3 NuGet Packaging
|
### 8.3 NuGet Packaging
|
||||||
|
|
||||||
`OECS.csproj` includes package metadata:
|
`OECS.csproj` includes package metadata:
|
||||||
- `PackageId`: `OECS`
|
- `PackageId`: `OECS`
|
||||||
- `Description`: "Observable ECS for C# — an entity component system focused on
|
- `Description`: "Observable ECS for C# — an entity component system focused on
|
||||||
a clean reactive API surface."
|
a clean reactive API surface."
|
||||||
- `PackageTags`: `ecs;reactive;observable;gamedev`
|
- `PackageTags`: `ecs;reactive;observable;gamedev`
|
||||||
|
- Bundles `OECS.SourceGen.dll` as an analyzer for consumers.
|
||||||
|
|
||||||
### 7.4 CI (optional)
|
### 8.4 Testing Games
|
||||||
|
|
||||||
GitHub Actions workflow: build, test, pack.
|
Test games (`Game.Blackjack`, `Game.TicTacToe`) serve as integration tests
|
||||||
|
and design validation. See `docs/testing-games.md` for the testing strategy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -507,7 +572,8 @@ Phase 1 (Core)
|
||||||
└─→ Phase 4 (Relationships)
|
└─→ Phase 4 (Relationships)
|
||||||
└─→ Phase 5 (Reactivity)
|
└─→ Phase 5 (Reactivity)
|
||||||
└─→ Phase 6 (Singletons)
|
└─→ Phase 6 (Singletons)
|
||||||
└─→ Phase 7 (Polish)
|
└─→ Phase 7 (Source Gen & Serialization)
|
||||||
|
└─→ Phase 8 (Polish)
|
||||||
```
|
```
|
||||||
|
|
||||||
Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
|
Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
|
||||||
|
|
@ -520,8 +586,8 @@ Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
|
||||||
default. If needed, systems could declare read/write component access for
|
default. If needed, systems could declare read/write component access for
|
||||||
automatic parallel scheduling — but this adds significant complexity.
|
automatic parallel scheduling — but this adds significant complexity.
|
||||||
|
|
||||||
2. **World serialization?** Since components are MessagePack-serializable,
|
2. **Multiple worlds?** The design supports it naturally — `World` is a class,
|
||||||
snapshotting the entire world is feasible. This is a Phase 7+ stretch goal.
|
|
||||||
|
|
||||||
3. **Multiple worlds?** The design supports it naturally — `World` is a class,
|
|
||||||
you can instantiate multiple. No cross-world references are supported.
|
you can instantiate multiple. No cross-world references are supported.
|
||||||
|
|
||||||
|
3. **Component import from CSV/MasterMemory?** The `design.md` mentions this as
|
||||||
|
a potential feature. Not yet implemented.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Testing games
|
||||||
|
|
||||||
|
1. Games are DLLs. Use a standalone test project to test a game.
|
||||||
|
2. Create baseline game snapshots in textual format. Save to files.
|
||||||
|
3. Create baseline logs via the R3 observable API. Save to files.
|
||||||
|
4. Read the snapshot/logs manually to identify issues.
|
||||||
|
5. Make sure serialization roundtrip works.
|
||||||
|
|
||||||
|
## Play tests
|
||||||
|
|
||||||
|
The goal of playtests is to execute a game with AI players and potentially
|
||||||
|
random seeds. Then the log/snapshots are read by the LLM agent to spot issues.
|
||||||
|
|
||||||
|
There is no failure in play tests; all tests pass but the agent should read the
|
||||||
|
log for reference.
|
||||||
|
|
||||||
|
AI players are not LLMs here; they are typically common game AI implementations.
|
||||||
|
|
||||||
|
Create static functions that analyze the game state and return a command. Each
|
||||||
|
such function is an agent. Each AI player routes its decisions to a random
|
||||||
|
agent in a pool of weighted agents.
|
||||||
|
|
||||||
|
Agents to consider:
|
||||||
|
- **Random:** evenly chooses every move randomly.
|
||||||
|
- **Greedy:** uses a specific way to score actions. Always chooses the one with
|
||||||
|
highest score. There can be multiple Greedy agents with different scoring.
|
||||||
|
|
||||||
|
For each playtest attempt, generate a full game log, analyze and report what
|
||||||
|
you find from the log.
|
||||||
|
|
||||||
|
### Round Runner
|
||||||
|
|
||||||
|
Playtests should run multiple rounds, not just one. The recommended pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
while chips >= minimum_bet AND chips < 2× starting_chips:
|
||||||
|
place bet → deal → agent decides hit/stand → resolve → new round
|
||||||
|
```
|
||||||
|
|
||||||
|
This exercises the full game lifecycle including deck reshuffling, round
|
||||||
|
transitions, and chip management. Safety cap at ~200 rounds to prevent
|
||||||
|
infinite loops.
|
||||||
|
|
||||||
|
### Play Log Format
|
||||||
|
|
||||||
|
Each play log should contain:
|
||||||
|
- **Header:** agent name, seed, result summary, win/loss/push counts.
|
||||||
|
- **Round Summaries:** per-round result, chip delta, hit count.
|
||||||
|
- **Decisions:** per-decision hand total and choice (Hit/Stand).
|
||||||
|
- **Reactivity:** raw R3 observable log of component/entity changes.
|
||||||
|
- **Final State:** snapshot of the world after all rounds.
|
||||||
|
|
||||||
|
Logs are saved as `.playlog` files alongside test output for manual review.
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>Blackjack</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
|
||||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
using OECS;
|
|
||||||
|
|
||||||
namespace Blackjack;
|
|
||||||
|
|
||||||
public static class Program
|
|
||||||
{
|
|
||||||
public static void Main()
|
|
||||||
{
|
|
||||||
var world = new World();
|
|
||||||
|
|
||||||
// ── Initialize game state ─────────────────────────────────────
|
|
||||||
|
|
||||||
// Use a seed based on current time, or a fixed seed for reproducibility.
|
|
||||||
uint seed = (uint)Environment.TickCount;
|
|
||||||
// Uncomment the next line for a deterministic game:
|
|
||||||
// seed = 12345u;
|
|
||||||
|
|
||||||
world.SetSingleton(new GameState
|
|
||||||
{
|
|
||||||
Phase = GamePhase.Betting,
|
|
||||||
Result = RoundResult.None,
|
|
||||||
Chips = 100,
|
|
||||||
CurrentBet = 0,
|
|
||||||
RoundNumber = 1,
|
|
||||||
Seed = seed
|
|
||||||
});
|
|
||||||
world.PostChanges();
|
|
||||||
|
|
||||||
// ── Wire up systems ───────────────────────────────────────────
|
|
||||||
|
|
||||||
var group = new SystemGroup(world);
|
|
||||||
group.Add(new DeckSetupSystem());
|
|
||||||
group.Add(new DealSystem());
|
|
||||||
group.Add(new PlayerBustCheckSystem());
|
|
||||||
group.Add(new DealerSystem());
|
|
||||||
group.Add(new RenderSystem());
|
|
||||||
|
|
||||||
// ── Game loop ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
group.RunLogical();
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var state = world.ReadSingleton<GameState>();
|
|
||||||
|
|
||||||
// Check for game over (out of chips).
|
|
||||||
if (state.Chips <= 0 && state.Phase == GamePhase.Betting)
|
|
||||||
{
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine(" You're out of chips! Game over.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (state.Phase)
|
|
||||||
{
|
|
||||||
case GamePhase.Betting:
|
|
||||||
var input = Console.ReadLine();
|
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
|
||||||
continue;
|
|
||||||
if (input.Trim().ToLower() == "quit")
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (int.TryParse(input.Trim(), out var bet))
|
|
||||||
{
|
|
||||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = bet });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine(" Invalid bet. Enter a number.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case GamePhase.PlayerTurn:
|
|
||||||
var choice = Console.ReadLine();
|
|
||||||
if (string.IsNullOrWhiteSpace(choice))
|
|
||||||
continue;
|
|
||||||
if (choice.Trim().ToLower() == "quit")
|
|
||||||
return;
|
|
||||||
|
|
||||||
switch (choice.Trim().ToLower())
|
|
||||||
{
|
|
||||||
case "h":
|
|
||||||
case "hit":
|
|
||||||
world.Commands.Enqueue(new HitCommand());
|
|
||||||
break;
|
|
||||||
case "s":
|
|
||||||
case "stand":
|
|
||||||
world.Commands.Enqueue(new StandCommand());
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
Console.WriteLine(" Invalid choice. Enter H or S.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case GamePhase.RoundOver:
|
|
||||||
Console.ReadLine();
|
|
||||||
ref var roundState = ref world.GetSingleton<GameState>();
|
|
||||||
roundState.RoundNumber++;
|
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
world.PostChanges();
|
|
||||||
world.Commands.Enqueue(new NewRoundCommand());
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Dealing, DealerTurn — systems handle these automatically.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
group.RunLogical();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
using OECS;
|
|
||||||
|
|
||||||
namespace Blackjack;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Renders the current game state to the console.
|
|
||||||
/// </summary>
|
|
||||||
public class RenderSystem : ISystem
|
|
||||||
{
|
|
||||||
public void Run(World world)
|
|
||||||
{
|
|
||||||
var state = world.ReadSingleton<GameState>();
|
|
||||||
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine(" Blackjack");
|
|
||||||
Console.WriteLine(" ═════════");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine($" Chips: {state.Chips} | Round: {state.RoundNumber}");
|
|
||||||
Console.WriteLine();
|
|
||||||
|
|
||||||
// Show dealer's hand.
|
|
||||||
var dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
|
||||||
Console.Write(" Dealer: ");
|
|
||||||
if (state.Phase == GamePhase.PlayerTurn)
|
|
||||||
{
|
|
||||||
// Hide first card (hole card) during player's turn.
|
|
||||||
Console.Write("?? ");
|
|
||||||
PrintHand(world, DealerHandTag.Instance, skipFirst: true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
PrintHand(world, DealerHandTag.Instance);
|
|
||||||
Console.Write($" ({dealerTotal})");
|
|
||||||
}
|
|
||||||
Console.WriteLine();
|
|
||||||
|
|
||||||
// Show player's hand.
|
|
||||||
var playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
|
|
||||||
Console.Write(" Player: ");
|
|
||||||
PrintHand(world, PlayerHandTag.Instance);
|
|
||||||
Console.Write($" ({playerTotal})");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine();
|
|
||||||
|
|
||||||
// Status line.
|
|
||||||
switch (state.Phase)
|
|
||||||
{
|
|
||||||
case GamePhase.Betting:
|
|
||||||
Console.Write($" Enter bet (1–{state.Chips}): ");
|
|
||||||
break;
|
|
||||||
case GamePhase.PlayerTurn:
|
|
||||||
Console.Write(" [H]it or [S]tand: ");
|
|
||||||
break;
|
|
||||||
case GamePhase.RoundOver:
|
|
||||||
Console.WriteLine($" {ResultText(state.Result)}");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.Write(" Press Enter for next round...");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintHand(World world, DealerHand hand, bool skipFirst = false)
|
|
||||||
{
|
|
||||||
PrintHandImpl(world, hand, skipFirst);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintHand(World world, PlayerHand hand)
|
|
||||||
{
|
|
||||||
PrintHandImpl(world, hand, skipFirst: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintHandImpl<T>(World world, T handTag, bool skipFirst)
|
|
||||||
where T : struct
|
|
||||||
{
|
|
||||||
Entity handEntity = Entity.Null;
|
|
||||||
using (var iter = world.Select<T>())
|
|
||||||
{
|
|
||||||
while (iter.MoveNext())
|
|
||||||
{
|
|
||||||
if (iter.CurrentEntity != World.SingletonEntity)
|
|
||||||
{
|
|
||||||
handEntity = iter.CurrentEntity;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handEntity == Entity.Null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var cards = world.GetSources<Holds>(handEntity);
|
|
||||||
var ordered = cards.OrderByDescending(c =>
|
|
||||||
{
|
|
||||||
var card = world.ReadComponent<Card>(c);
|
|
||||||
return (int)card.Rank;
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
bool first = true;
|
|
||||||
foreach (var cardEntity in ordered)
|
|
||||||
{
|
|
||||||
if (skipFirst && first)
|
|
||||||
{
|
|
||||||
first = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
first = false;
|
|
||||||
var card = world.ReadComponent<Card>(cardEntity);
|
|
||||||
Console.Write(CardToString(card));
|
|
||||||
Console.Write(" ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string CardToString(Card card)
|
|
||||||
{
|
|
||||||
var suit = card.Suit switch
|
|
||||||
{
|
|
||||||
Suit.Hearts => "♥",
|
|
||||||
Suit.Diamonds => "♦",
|
|
||||||
Suit.Clubs => "♣",
|
|
||||||
Suit.Spades => "♠",
|
|
||||||
_ => "?"
|
|
||||||
};
|
|
||||||
|
|
||||||
var rank = card.Rank switch
|
|
||||||
{
|
|
||||||
Rank.Ace => "A",
|
|
||||||
Rank.Jack => "J",
|
|
||||||
Rank.Queen => "Q",
|
|
||||||
Rank.King => "K",
|
|
||||||
_ => ((int)card.Rank).ToString()
|
|
||||||
};
|
|
||||||
|
|
||||||
return $"{rank}{suit}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResultText(RoundResult result) => result switch
|
|
||||||
{
|
|
||||||
RoundResult.PlayerBust => "You bust! Dealer wins.",
|
|
||||||
RoundResult.DealerBust => "Dealer busts! You win!",
|
|
||||||
RoundResult.PlayerWin => "You win!",
|
|
||||||
RoundResult.DealerWin => "Dealer wins.",
|
|
||||||
RoundResult.Push => "Push — it's a tie!",
|
|
||||||
RoundResult.Blackjack => "Blackjack!",
|
|
||||||
_ => ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
using OECS;
|
|
||||||
using R3;
|
|
||||||
|
|
||||||
namespace TicTacToe;
|
|
||||||
|
|
||||||
public static class Program
|
|
||||||
{
|
|
||||||
private static readonly string SavePath = Path.Combine(
|
|
||||||
AppContext.BaseDirectory, "save.bin");
|
|
||||||
|
|
||||||
public static void Main()
|
|
||||||
{
|
|
||||||
var world = new World();
|
|
||||||
var reactivityLog = new List<string>();
|
|
||||||
SetupReactivityLogging(world, reactivityLog);
|
|
||||||
|
|
||||||
// ── Try to load a saved game ──────────────────────────────────
|
|
||||||
|
|
||||||
bool loaded = false;
|
|
||||||
if (File.Exists(SavePath))
|
|
||||||
{
|
|
||||||
Console.Write("Saved game found. Load it? (y/n): ");
|
|
||||||
if (Console.ReadLine()?.Trim().ToLower() == "y")
|
|
||||||
{
|
|
||||||
using var stream = File.OpenRead(SavePath);
|
|
||||||
WorldSerializer.Load(world, stream);
|
|
||||||
loaded = true;
|
|
||||||
Console.WriteLine("Game loaded!");
|
|
||||||
Console.WriteLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Load fresh board from CSV (if not loaded) ─────────────────
|
|
||||||
|
|
||||||
if (!loaded)
|
|
||||||
{
|
|
||||||
var csvPath = Path.Combine(AppContext.BaseDirectory, "Data", "board.csv");
|
|
||||||
CsvLoader.LoadCells(world, csvPath);
|
|
||||||
world.PostChanges();
|
|
||||||
|
|
||||||
world.SetSingleton(new GameState
|
|
||||||
{
|
|
||||||
CurrentPlayer = Player.X,
|
|
||||||
Status = GameStatus.Playing,
|
|
||||||
MoveCount = 0
|
|
||||||
});
|
|
||||||
world.PostChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wire up systems ───────────────────────────────────────────
|
|
||||||
|
|
||||||
var group = new SystemGroup(world);
|
|
||||||
group.Add(new WinCheckSystem());
|
|
||||||
group.Add(new RenderSystem());
|
|
||||||
|
|
||||||
// ── Game loop ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
group.RunLogical();
|
|
||||||
PrintReactivityLog(reactivityLog);
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
ref var state = ref world.GetSingleton<GameState>();
|
|
||||||
if (state.Status != GameStatus.Playing)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var input = Console.ReadLine();
|
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// "save" command.
|
|
||||||
if (input.Trim().ToLower() == "save")
|
|
||||||
{
|
|
||||||
SaveGame(world);
|
|
||||||
Console.WriteLine(" Game saved.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parts = input.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
if (parts.Length != 2 ||
|
|
||||||
!int.TryParse(parts[0], out var row) ||
|
|
||||||
!int.TryParse(parts[1], out var col))
|
|
||||||
{
|
|
||||||
Console.WriteLine(" Invalid input. Try \"1 2\" or \"save\".");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row < 0 || row > 2 || col < 0 || col > 2)
|
|
||||||
{
|
|
||||||
Console.WriteLine(" Row and col must be 0–2.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
|
|
||||||
group.RunLogical();
|
|
||||||
PrintReactivityLog(reactivityLog);
|
|
||||||
|
|
||||||
// Auto-save after every move.
|
|
||||||
SaveGame(world);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Final state ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine("=== Game over ===");
|
|
||||||
PrintReactivityLog(reactivityLog);
|
|
||||||
|
|
||||||
// Clean up save file on game over.
|
|
||||||
if (File.Exists(SavePath))
|
|
||||||
File.Delete(SavePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SaveGame(World world)
|
|
||||||
{
|
|
||||||
using var stream = File.Create(SavePath);
|
|
||||||
WorldSerializer.Save(world, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetupReactivityLogging(World world, List<string> log)
|
|
||||||
{
|
|
||||||
world.ObserveEntityChanges().Subscribe(change =>
|
|
||||||
{
|
|
||||||
if (change.ComponentType != null) return;
|
|
||||||
log.Add($"[react] {change}");
|
|
||||||
});
|
|
||||||
|
|
||||||
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
|
||||||
{
|
|
||||||
log.Add($"[react] {change}");
|
|
||||||
});
|
|
||||||
|
|
||||||
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
|
||||||
{
|
|
||||||
log.Add($"[react] {change}");
|
|
||||||
});
|
|
||||||
|
|
||||||
var markedQuery = world.Query().With<Cell>().With<Mark>().Build();
|
|
||||||
world.ObserveQuery(markedQuery).Subscribe(change =>
|
|
||||||
{
|
|
||||||
log.Add($"[react:query] {change}");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintReactivityLog(List<string> log)
|
|
||||||
{
|
|
||||||
if (log.Count == 0) return;
|
|
||||||
Console.WriteLine("── reactivity log ──");
|
|
||||||
foreach (var entry in log)
|
|
||||||
Console.WriteLine(entry);
|
|
||||||
Console.WriteLine();
|
|
||||||
log.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
using OECS;
|
|
||||||
|
|
||||||
namespace TicTacToe;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Prints the board to the console.
|
|
||||||
/// </summary>
|
|
||||||
public class RenderSystem : ISystem
|
|
||||||
{
|
|
||||||
public void Run(World world)
|
|
||||||
{
|
|
||||||
var state = world.ReadSingleton<GameState>();
|
|
||||||
|
|
||||||
// Build a 3×3 grid.
|
|
||||||
var grid = new char[3, 3];
|
|
||||||
for (int r = 0; r < 3; r++)
|
|
||||||
for (int c = 0; c < 3; c++)
|
|
||||||
grid[r, c] = '.';
|
|
||||||
|
|
||||||
// Fill in placed marks using the iterator API.
|
|
||||||
using var markIter = world.Select<Cell, Mark>();
|
|
||||||
while (markIter.MoveNext())
|
|
||||||
{
|
|
||||||
grid[markIter.Current1.Row, markIter.Current1.Col] =
|
|
||||||
markIter.Current2.Player == Player.X ? 'X' : 'O';
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine(" Tic-Tac-Toe");
|
|
||||||
Console.WriteLine(" ═══════════");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine(" 0 1 2");
|
|
||||||
Console.WriteLine(" ┌───┬───┬───┐");
|
|
||||||
for (int r = 0; r < 3; r++)
|
|
||||||
{
|
|
||||||
Console.Write($"{r} │");
|
|
||||||
for (int c = 0; c < 3; c++)
|
|
||||||
{
|
|
||||||
Console.Write($" {grid[r, c]} ");
|
|
||||||
if (c < 2) Console.Write("│");
|
|
||||||
}
|
|
||||||
Console.WriteLine("│");
|
|
||||||
if (r < 2) Console.WriteLine(" ├───┼───┼───┤");
|
|
||||||
}
|
|
||||||
Console.WriteLine(" └───┴───┴───┘");
|
|
||||||
Console.WriteLine();
|
|
||||||
|
|
||||||
// Status line.
|
|
||||||
var statusText = state.Status switch
|
|
||||||
{
|
|
||||||
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn (move {state.MoveCount + 1})",
|
|
||||||
GameStatus.XWon => "X wins!",
|
|
||||||
GameStatus.OWon => "O wins!",
|
|
||||||
GameStatus.Draw => "It's a draw!",
|
|
||||||
_ => ""
|
|
||||||
};
|
|
||||||
Console.WriteLine($" {statusText}");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.Write(" Enter row col (e.g. \"1 2\"): ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
0 0
|
|
||||||
1 1
|
|
||||||
0 1
|
|
||||||
1 0
|
|
||||||
0 2
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
y
|
|
||||||
1 0
|
|
||||||
0 2
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
0 0
|
|
||||||
1 1
|
|
||||||
0 1
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
|
|
||||||
namespace OECS;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Registry of component types discovered at compile time by the
|
|
||||||
/// OECS.SourceGen incremental generator. The consuming project's
|
|
||||||
/// generated code populates this via a module initializer, so no
|
|
||||||
/// manual registration is needed.
|
|
||||||
/// </summary>
|
|
||||||
public static class ComponentRegistry
|
|
||||||
{
|
|
||||||
private static ComponentDescriptor[]? _descriptors;
|
|
||||||
private static Dictionary<string, ComponentDescriptor>? _byTypeName;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// All discovered component descriptors. Populated automatically
|
|
||||||
/// by the source generator at module initialization.
|
|
||||||
/// </summary>
|
|
||||||
public static ComponentDescriptor[] Descriptors =>
|
|
||||||
_descriptors ?? Array.Empty<ComponentDescriptor>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lookup by assembly-qualified type name. Populated automatically
|
|
||||||
/// by the source generator at module initialization.
|
|
||||||
/// </summary>
|
|
||||||
public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_byTypeName == null)
|
|
||||||
{
|
|
||||||
var dict = new Dictionary<string, ComponentDescriptor>();
|
|
||||||
foreach (var desc in Descriptors)
|
|
||||||
dict[desc.TypeName] = desc;
|
|
||||||
_byTypeName = dict;
|
|
||||||
}
|
|
||||||
return _byTypeName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called by generated code to register discovered component types.
|
|
||||||
/// Must be called before any serialization occurs.
|
|
||||||
/// </summary>
|
|
||||||
public static void Initialize(ComponentDescriptor[] descriptors)
|
|
||||||
{
|
|
||||||
_descriptors = descriptors;
|
|
||||||
_byTypeName = null; // Rebuild on next access.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue